defmodule NPM.Security.Audit do @moduledoc """ Security audit for npm packages. Checks installed packages against known vulnerabilities. This module provides the data structures and analysis logic; the actual advisory data would come from the npm audit API. """ @type severity :: :critical | :high | :moderate | :low | :info @type advisory :: %{ id: non_neg_integer(), title: String.t(), severity: severity(), vulnerable_versions: String.t(), patched_versions: String.t() | nil, url: String.t() | nil } @type finding :: %{ package: String.t(), installed_version: String.t(), advisory: advisory() } @severity_order %{critical: 0, high: 1, moderate: 2, low: 3, info: 4} @doc """ Checks a lockfile against a list of advisories. Returns findings — packages that match vulnerable version ranges. """ @spec check(map(), [advisory()]) :: [finding()] def check(lockfile, advisories) do Enum.flat_map(advisories, fn advisory -> lockfile |> Enum.filter(fn {_name, entry} -> version_in_range?(entry.version, advisory.vulnerable_versions) end) |> Enum.map(fn {name, entry} -> %{package: name, installed_version: entry.version, advisory: advisory} end) end) |> Enum.sort_by(fn f -> {@severity_order[f.advisory.severity], f.package} end) end @doc """ Checks if a finding has a patch available. """ @spec fixable?(finding()) :: boolean() def fixable?(finding) do finding.advisory.patched_versions != nil and finding.advisory.patched_versions != "" end @doc """ Filters findings by minimum severity level. """ @spec filter_by_severity([finding()], severity()) :: [finding()] def filter_by_severity(findings, min_severity) do min_level = Map.get(@severity_order, min_severity, 4) Enum.filter(findings, fn f -> Map.get(@severity_order, f.advisory.severity, 4) <= min_level end) end @doc """ Returns a summary of audit findings. """ @spec summary([finding()]) :: %{ total: non_neg_integer(), critical: non_neg_integer(), high: non_neg_integer(), moderate: non_neg_integer(), low: non_neg_integer(), fixable: non_neg_integer() } def summary(findings) do %{ total: length(findings), critical: Enum.count(findings, &(&1.advisory.severity == :critical)), high: Enum.count(findings, &(&1.advisory.severity == :high)), moderate: Enum.count(findings, &(&1.advisory.severity == :moderate)), low: Enum.count(findings, &(&1.advisory.severity == :low)), fixable: Enum.count(findings, &fixable?/1) } end @doc """ Formats a finding as a human-readable string. """ @spec format_finding(finding()) :: String.t() def format_finding(finding) do severity = finding.advisory.severity |> Atom.to_string() |> String.upcase() "#{severity} #{finding.advisory.title} - #{finding.package}@#{finding.installed_version}" end @doc """ Compares two severity levels. Returns :gt, :lt, or :eq. """ @spec compare_severity(severity(), severity()) :: :gt | :lt | :eq def compare_severity(a, b) do a_level = Map.get(@severity_order, a, 4) b_level = Map.get(@severity_order, b, 4) cond do a_level < b_level -> :gt a_level > b_level -> :lt true -> :eq end end defp version_in_range?(version, range) do NPMSemver.matches?(version, range) rescue _ -> false end end