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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
7.2 KiB · text
History
b868907