defmodule Mix.Tasks.Npm.Audit do @shortdoc "Check for security vulnerabilities" @moduledoc """ Check installed packages for known security vulnerabilities. mix npm.audit mix npm.audit --compromised mix npm.audit --osv mix npm.audit --osv --write-cache mix npm.audit --osv --write priv/security/compromised_packages.json mix npm.audit --compromised mix npm.audit --compromised --db priv/security/compromised_packages.json mix npm.audit --compromised --format json With no flags, queries the npm registry audit endpoint for vulnerability advisories affecting packages in `npm.lock`. `--compromised` checks `npm.lock` offline against a local OSV-format malicious package database. `--osv` queries OSV.dev for malicious package advisories and can optionally write matching advisories to a local database for future offline checks. """ use Mix.Task alias NPM.Config alias NPM.Security.Compromised alias NPM.Security.TaskReporter @switches [ compromised: :boolean, osv: :boolean, db: :string, lockfile: :string, format: :string, policy: :string, write: :string, write_cache: :boolean ] @impl true def run(args) do Application.ensure_all_started(:req) {opts, argv, invalid} = OptionParser.parse(args, strict: @switches) case {argv, invalid, TaskReporter.parse_format(opts[:format]), TaskReporter.parse_policy(opts[:policy])} do {[], [], {:ok, format}, {:ok, policy}} -> run_audit(opts, format, policy) _ -> Mix.shell().error(usage()) end end defp run_audit(opts, format, policy) do lockfile_path = opts[:lockfile] || NPM.Lockfile.default_path() case NPM.Lockfile.read(lockfile_path) do {:ok, lockfile} when lockfile == %{} -> Mix.shell().info("No #{lockfile_path} found, run `mix npm.install` first.") {:ok, lockfile} -> audit_with_options(lockfile, opts, format, policy) {:error, reason} -> Mix.raise("Failed to read #{lockfile_path}: #{inspect(reason)}") end end defp audit_with_options(lockfile, opts, format, policy) do cond do opts[:osv] -> audit_osv(lockfile, opts, format, policy) opts[:compromised] -> audit_compromised(lockfile, opts, format, policy) true -> audit_registry(lockfile) end end defp audit_osv(lockfile, opts, format, policy) do case Compromised.check_osv(lockfile) do {:ok, findings} -> advisories = findings |> Enum.map(& &1.advisory) |> Enum.uniq_by(& &1["id"]) TaskReporter.report( findings, format, "No malicious OSV advisories found for npm.lock packages.", "Found #{length(findings)} malicious OSV advisory matches:" ) maybe_write(write_path(opts), advisories) TaskReporter.enforce(findings, policy) {:error, reason} -> Mix.raise("OSV query failed: #{inspect(reason)}") end end defp audit_compromised(lockfile, opts, format, policy) do findings = Compromised.check(lockfile, sources: [:local], db_path: opts[:db] || Config.compromised_db_path() ) TaskReporter.report( findings, format, "No compromised packages found in npm.lock.", "Found #{length(findings)} compromised package matches:" ) TaskReporter.enforce(findings, policy) end defp write_path(opts) do cond do opts[:write_cache] -> Compromised.cache_path() opts[:write] -> opts[:write] true -> nil end end defp maybe_write(nil, _advisories), do: :ok defp maybe_write(path, advisories) do case Compromised.merge_database(path, advisories) do {:ok, merged} -> Mix.shell().info( "Merged #{length(advisories)} OSV advisories into #{path} (#{length(merged)} total)" ) {:error, reason} -> Mix.raise("Failed to write OSV advisories to #{path}: #{inspect(reason)}") end end defp audit_registry(lockfile) do payload = build_audit_payload(lockfile) url = "#{NPM.Registry.registry_url()}/-/npm/v1/security/audits" case Req.post(url, json: payload) do {:ok, %{status: 200, body: body}} -> print_audit_results(body) {:ok, %{status: status}} -> Mix.shell().info("Audit API returned status #{status}. Checking packages manually...") check_deprecated(lockfile) {:error, reason} -> Mix.shell().error("Audit failed: #{inspect(reason)}") check_deprecated(lockfile) end end defp build_audit_payload(lockfile) do requires = for {name, entry} <- lockfile, into: %{} do {name, entry.version} end dependencies = for {name, entry} <- lockfile, into: %{} do {name, %{"version" => entry.version}} end %{ "name" => "npm-audit", "version" => "0.0.0", "requires" => requires, "dependencies" => dependencies } end defp print_audit_results(%{"advisories" => advisories}) when map_size(advisories) == 0 do Mix.shell().info("No known vulnerabilities found.") end defp print_audit_results(%{"advisories" => advisories}) do count = map_size(advisories) Mix.shell().info("Found #{count} advisor#{if count == 1, do: "y", else: "ies"}:") Enum.each(advisories, fn {_id, advisory} -> severity = Map.get(advisory, "severity", "unknown") title = Map.get(advisory, "title", "Unknown") module_name = Map.get(advisory, "module_name", "?") Mix.shell().info(" [#{severity}] #{module_name}: #{title}") end) end defp print_audit_results(_body) do Mix.shell().info("No vulnerability data available.") end defp check_deprecated(lockfile) do lockfile |> Task.async_stream( fn {name, _entry} -> {name, NPM.Registry.get_packument(name)} end, max_concurrency: 8, timeout: 30_000 ) |> Enum.each(fn {:ok, {name, {:ok, packument}}} -> version = lockfile[name].version info = Map.get(packument.versions, version, %{}) case Map.get(info, :deprecated) do msg when is_binary(msg) and msg != "" -> Mix.shell().info(" [deprecated] #{name}@#{version}: #{msg}") _ -> :ok end _ -> :ok end) end defp usage do "Usage: mix npm.audit [--compromised | --osv] [--lockfile path] [--db path] [--format text|json] [--policy error|warn|off] [--write path] [--write-cache]" end end