Skip to main content

lib/npm/security/audit.ex

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