Skip to main content

lib/npm/security/provenance.ex

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