Skip to main content

lib/npm/security/age.ex

defmodule NPM.Security.Age do
  @moduledoc """
  Heuristics for recently created packages and freshly published versions.

  Very new packages and versions are not automatically malicious, but recent
  publication is a useful supply-chain risk signal. The default policy only
  warns: packages created in the last 7 days and versions published in the last
  3 days are flagged when registry metadata includes timestamps.
  """

  @type warning :: %{
          type: :new_package | :new_version,
          age_days: non_neg_integer(),
          threshold_days: non_neg_integer()
        }

  @doc "Return age warnings for package/version metadata."
  @spec warnings(map()) :: [warning()]
  def warnings(info) do
    []
    |> maybe_warn(:new_package, Map.get(info, :created_at), NPM.Config.package_age_warning_days())
    |> maybe_warn(
      :new_version,
      Map.get(info, :published_at),
      NPM.Config.version_age_warning_days()
    )
    |> Enum.reverse()
  end

  @doc "Format an age warning for display."
  @spec format(String.t(), String.t(), warning()) :: String.t()
  def format(name, version, %{type: :new_package, age_days: age, threshold_days: threshold}) do
    "#{name}@#{version} belongs to a package created #{age} day#{plural(age)} ago (< #{threshold} days)"
  end

  def format(name, version, %{type: :new_version, age_days: age, threshold_days: threshold}) do
    "#{name}@#{version} was published #{age} day#{plural(age)} ago (< #{threshold} days)"
  end

  defp maybe_warn(warnings, _type, nil, _threshold), do: warnings
  defp maybe_warn(warnings, _type, _timestamp, 0), do: warnings

  defp maybe_warn(warnings, type, timestamp, threshold) do
    with {:ok, age_days} <- age_days(timestamp),
         true <- age_days < threshold do
      [%{type: type, age_days: age_days, threshold_days: threshold} | warnings]
    else
      _ -> warnings
    end
  end

  defp age_days(timestamp) when is_binary(timestamp) do
    with {:ok, datetime, _offset} <- DateTime.from_iso8601(timestamp) do
      {:ok, max(div(DateTime.diff(DateTime.utc_now(), datetime, :second), 86_400), 0)}
    end
  end

  defp age_days(_), do: :error

  defp plural(1), do: ""
  defp plural(_), do: "s"
end