defmodule NPM.Security.SupplyChain do alias NPM.Dependency.Phantom @moduledoc """ Evaluates supply chain security posture of a project's dependencies. Combines multiple signals: provenance, integrity, deprecations, phantom deps, and package age. """ @doc """ Assesses supply chain risk. """ @spec assess(map(), map()) :: map() def assess(pkg_data, lockfile) do total = map_size(lockfile) phantoms = Phantom.count(pkg_data, lockfile) integrity_count = Enum.count(lockfile, fn {_, entry} -> integrity_present?(entry) end) integrity_pct = if total > 0, do: Float.round(integrity_count / total * 100, 1), else: 0.0 %{ total_packages: total, phantom_deps: phantoms, integrity_coverage: integrity_pct, risk_level: risk_level(integrity_pct, phantoms, total) } end @doc """ Computes a risk score (0-100, lower is better). """ @spec risk_score(map()) :: non_neg_integer() def risk_score(assessment) do score = 0 score = score + max(0, round(50 - assessment.integrity_coverage / 2)) score = if assessment.phantom_deps > 5, do: score + 20, else: score score = if assessment.phantom_deps > 0, do: score + 10, else: score min(100, score) end @doc """ Formats assessment for display. """ @spec format(map()) :: String.t() def format(assessment) do """ Supply Chain Assessment (#{assessment.risk_level}): Packages: #{assessment.total_packages} Integrity: #{assessment.integrity_coverage}% Phantom deps: #{assessment.phantom_deps}\ """ end defp risk_level(integrity_pct, phantoms, _total) do cond do integrity_pct >= 90 and phantoms == 0 -> :low integrity_pct >= 50 and phantoms < 5 -> :medium true -> :high end end defp integrity_present?(%{integrity: i}) when is_binary(i) and i != "", do: true defp integrity_present?(%{"integrity" => i}) when is_binary(i) and i != "", do: true defp integrity_present?(_), do: false end