7.2 KiB · text History 8389210
defmodule GitGud.Advisories.Scanner do
  @moduledoc """
  Runs osv-scanner over a repository's lockfiles at one commit.

  Only the lockfiles are exported (`git archive` of the matching paths)
  into a temporary directory, and osv-scanner reads them there — nothing
  in the repository runs. With the `:mirror` source it matches against
  OSV's dumps kept in `GitGud.Advisories.db_dir/0` (downloaded by
  osv-scanner for the ecosystems the files need); with `:api` it asks
  api.osv.dev instead.

  osv-scanner is a separate install (`GitGud.Advisories.scanner/0`), so
  everything here answers `{:error, :no_scanner}` without it.
  """

  alias GitGud.Advisories

  # The lockfiles osv-scanner reads, by file name.
  @lockfiles ~w(mix.lock Cargo.lock package-lock.json npm-shrinkwrap.json yarn.lock
                pnpm-lock.yaml bun.lock deno.lock go.mod poetry.lock uv.lock Pipfile.lock
                pdm.lock requirements.txt Gemfile.lock gems.locked composer.lock
                gradle.lockfile verification-metadata.xml pubspec.lock packages.lock.json
                conan.lock renv.lock pom.xml)

  @timeout_seconds 300

  @doc "The lockfile names scanned."
  def lockfile_names, do: @lockfiles

  @doc """
  The lockfiles in `disk_path`'s tree at `sha`, `[{path, blob_id}]`,
  sorted. What a scan reads and what its digest is made of.
  """
  @spec lockfiles(String.t(), String.t()) :: [{String.t(), String.t()}]
  def lockfiles(disk_path, sha) do
    case System.cmd("git", ["-C", disk_path, "ls-tree", "-r", "-z", sha], stderr_to_stdout: true) do
      {out, 0} ->
        out
        |> String.split("\0", trim: true)
        |> Enum.flat_map(fn line ->
          with [meta, path] <- String.split(line, "\t", parts: 2),
               [_mode, "blob", blob] <- String.split(meta, " "),
               true <- Path.basename(path) in @lockfiles,
               false <- vendored?(path) do
            [{path, blob}]
          else
            _ -> []
          end
        end)
        |> Enum.sort()

      _ ->
        []
    end
  end

  # Somebody else's lockfiles, checked in alongside: not this repository's
  # dependencies.
  defp vendored?(path),
    do: Enum.any?(Path.split(path), &(&1 in ~w(node_modules vendor deps _build target .git)))

  @doc """
  Scan `paths` at `sha`. `{:ok, findings}` — one map per advisory per
  package per lockfile — or `{:error, reason}`.
  """
  @spec scan(String.t(), String.t(), [String.t()]) :: {:ok, [map()]} | {:error, term()}
  def scan(_disk_path, _sha, []), do: {:ok, []}

  def scan(disk_path, sha, paths) do
    with {:ok, scanner} <- fetch_scanner() do
      dir = Path.join(System.tmp_dir!(), "gitgud-osv-#{System.unique_integer([:positive])}")
      File.mkdir_p!(dir)

      try do
        with :ok <- export(disk_path, sha, paths, dir),
             {:ok, report} <- run(scanner, dir, paths) do
          {:ok, findings(report, dir)}
        end
      after
        File.rm_rf(dir)
      end
    end
  end

  defp fetch_scanner do
    case Advisories.scanner() do
      nil -> {:error, :no_scanner}
      path -> {:ok, path}
    end
  end

  defp export(disk_path, sha, paths, dir) do
    tar = Path.join(dir, ".export.tar")

    case System.cmd(
           "git",
           ["-C", disk_path, "archive", "--format=tar", "-o", tar, sha, "--" | paths],
           stderr_to_stdout: true
         ) do
      {_, 0} ->
        case System.cmd("tar", ["-xf", tar, "-C", dir], stderr_to_stdout: true) do
          {_, 0} ->
            File.rm(tar)
            :ok

          {out, _} ->
            {:error, {:export, out}}
        end

      {out, _} ->
        {:error, {:export, out}}
    end
  end

  defp run(scanner, dir, paths) do
    source = Advisories.source()

    args =
      ["scan", "source", "--format", "json"] ++
        if(source == :mirror,
          do: ["--offline-vulnerabilities", "--download-offline-databases"],
          else: []
        ) ++ Enum.flat_map(paths, &["--lockfile", &1])

    env = [{"OSV_SCANNER_LOCAL_DB_CACHE_DIRECTORY", Advisories.db_dir()}]

    case System.cmd("timeout", [Integer.to_string(@timeout_seconds), scanner | args],
           cd: dir,
           env: env,
           stderr_to_stdout: false
         ) do
      # 0: nothing found; 1: something found. Both are answers.
      {out, status} when status in [0, 1] ->
        case Jason.decode(out) do
          {:ok, report} -> {:ok, report}
          {:error, _} -> {:error, {:unreadable_report, String.slice(out, 0, 200)}}
        end

      {_out, 124} ->
        {:error, :timeout}

      {_out, status} ->
        {:error, {:scanner_exit, status}}
    end
  end

  @doc false
  # The report's results as flat findings, paths relative to the export:
  # one per vulnerability, which osv-scanner groups across the ids it's
  # known by (an EEF id and its GHSA twin are one issue, not two).
  def findings(report, dir) do
    for result <- report["results"] || [],
        path = relative(result["source"]["path"], dir),
        pkg <- result["packages"] || [],
        info = pkg["package"],
        {ids, vulns, score} <- groups(pkg) do
      [primary | others] = ids

      %{
        advisory_id: primary,
        aliases:
          (others ++ Enum.flat_map(vulns, &(&1["aliases"] || [])))
          |> Enum.uniq()
          |> Enum.reject(&(&1 == primary))
          |> Enum.sort(),
        summary: Enum.find_value(vulns, & &1["summary"]),
        ecosystem: info["ecosystem"],
        package: info["name"],
        version: info["version"],
        fixed_versions: vulns |> Enum.flat_map(&fixed_versions(&1, info["name"])) |> Enum.uniq(),
        severity: score,
        manifest_path: path
      }
    end
  end

  # [{ids, vulnerabilities, worst score}] per osv-scanner's groups; a
  # vulnerability no group names is a group of its own.
  defp groups(pkg) do
    vulns = Map.new(pkg["vulnerabilities"] || [], &{&1["id"], &1})

    grouped =
      for group <- pkg["groups"] || [],
          ids = Enum.filter(group["ids"] || [], &Map.has_key?(vulns, &1)),
          ids != [] do
        {primary_first(ids), Enum.map(ids, &vulns[&1]), parse_score(group["max_severity"])}
      end

    named = grouped |> Enum.flat_map(&elem(&1, 0)) |> MapSet.new()

    loose =
      for {id, vuln} <- vulns, not MapSet.member?(named, id), do: {[id], [vuln], nil}

    grouped ++ loose
  end

  # The name people search for first: a GHSA or the ecosystem's own
  # (EEF, RUSTSEC, PYSEC…) ahead of a bare CVE.
  defp primary_first(ids),
    do: Enum.sort_by(ids, &{String.starts_with?(&1, "CVE-"), &1})

  defp relative(path, dir) do
    expanded = Path.expand(path, dir)
    root = Path.expand(dir)

    if String.starts_with?(expanded, root <> "/"),
      do: Path.relative_to(expanded, root),
      else: path
  end

  defp parse_score(nil), do: nil
  defp parse_score(""), do: nil

  defp parse_score(s) when is_binary(s) do
    case Float.parse(s) do
      {f, _} -> f
      :error -> nil
    end
  end

  defp parse_score(n) when is_number(n), do: n / 1

  defp fixed_versions(vuln, name) do
    for affected <- vuln["affected"] || [],
        get_in(affected, ["package", "name"]) == name,
        range <- affected["ranges"] || [],
        range["type"] in ["SEMVER", "ECOSYSTEM"],
        event <- range["events"] || [],
        fixed = event["fixed"],
        uniq: true,
        do: fixed
  end
end