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