defmodule NPM.Security.Provenance do @moduledoc """ Supply chain provenance checking for npm packages. Validates SLSA provenance attestations, checks build source transparency, and identifies packages published from CI. """ @trusted_registries ["https://registry.npmjs.org"] @doc """ Checks if a package entry has provenance information. """ @spec has_provenance?(map()) :: boolean() def has_provenance?(entry) do Map.has_key?(entry, :attestations) or Map.has_key?(entry, "attestations") or Map.has_key?(entry, :provenance) or Map.has_key?(entry, "provenance") end @doc """ Scans a lockfile for packages with/without provenance. """ @spec scan(map()) :: %{with_provenance: [String.t()], without: [String.t()]} def scan(lockfile) do {with_prov, without} = lockfile |> Enum.split_with(fn {_name, entry} -> has_provenance?(entry) end) %{ with_provenance: with_prov |> Enum.map(&elem(&1, 0)) |> Enum.sort(), without: without |> Enum.map(&elem(&1, 0)) |> Enum.sort() } end @doc """ Checks if a package's registry is trusted. """ @spec trusted_registry?(String.t()) :: boolean() def trusted_registry?(registry_url) do Enum.any?(@trusted_registries, &String.starts_with?(registry_url, &1)) end @doc """ Validates that a package has integrity hash. """ @spec has_integrity?(map()) :: boolean() def has_integrity?(entry) do case integrity_value(entry) do nil -> false "" -> false _ -> true end end @doc """ Returns a supply chain risk summary for the lockfile. """ @spec risk_summary(map()) :: map() def risk_summary(lockfile) do total = map_size(lockfile) with_integrity = Enum.count(lockfile, fn {_, e} -> has_integrity?(e) end) with_prov = Enum.count(lockfile, fn {_, e} -> has_provenance?(e) end) %{ total: total, with_integrity: with_integrity, without_integrity: total - with_integrity, with_provenance: with_prov, integrity_pct: if(total > 0, do: Float.round(with_integrity / total * 100, 1), else: 0.0) } end @doc """ Formats the risk summary for display. """ @spec format_summary(map()) :: String.t() def format_summary(summary) do """ Supply Chain Summary: Total packages: #{summary.total} With integrity hash: #{summary.with_integrity} (#{summary.integrity_pct}%) Without integrity: #{summary.without_integrity} With provenance: #{summary.with_provenance}\ """ end defp integrity_value(%{integrity: v}), do: v defp integrity_value(%{"integrity" => v}), do: v defp integrity_value(_), do: nil end