scripts/osv-audit: sort osv-scanner findings by whether they can be fixed

37f03c4 · Gabriel Morell · 2026-10-27 18:57

5 files +391 -0
Message
{commit_body(@commit)}

Files changed

added scripts/osv-audit
+214 −0
@@ -0,0 +1,214 @@
1 +#!/usr/bin/env python3
2 +"""Scan lockfiles with osv-scanner and sort what it finds by whether
3 +there is anything to do about it yet.
4 +
5 + scripts/osv-audit [osv-scanner scan source args…]
6 + scripts/osv-audit -r .
7 + scripts/osv-audit --lockfile cli/Cargo.lock
8 +
9 +Runs `osv-scanner scan source --format json` with the arguments given
10 +(plus `--config osv-scanner.toml` when that file is here and no
11 +`--config` was given; osv-scanner applies its IgnoredVulns), then
12 +prints two tables:
13 +
14 + Fix now OSV lists a fixed version: bump to it
15 + No fix available / blocked no advisory against the package has one
16 +
17 +A package with both kinds of advisory is in both, each with its own.
18 +
19 +Exits 1 if anything can be fixed now. Otherwise exits 0, and when
20 +something can't be fixed yet, says `conclusion=warning` in
21 +$GITHUB_OUTPUT (when set) — wired to the job's `gitgud-conclusion`
22 +output, the run shows the job as a warning rather than a pass or a
23 +failure:
24 +
25 + outputs:
26 + gitgud-conclusion: ${{ steps.scan.outputs.conclusion }}
27 +
28 +`conclusion=success` when nothing was found. osv-scanner's own errors
29 +are passed on: 127 (it failed) as 127, and 128 (no lockfiles found) as
30 +128 unless `--allow-no-lockfiles` was given, when it's nothing found.
31 +
32 +The scanner is OSV_SCANNER (default `osv-scanner`). Only public
33 +advisory data passes through here. Standard library only, so it runs
34 +wherever python3 does. Used by .forgejo/workflows/audit.yml.
35 +"""
36 +
37 +import json
38 +import os
39 +import re
40 +import subprocess
41 +import sys
42 +
43 +
44 +def vkey(version):
45 + """Sort key for a semver-ish version: 1.2.3 < 1.2.10, 1.0.0-rc.1 < 1.0.0."""
46 + main, _, pre = version.partition("-")
47 + nums = []
48 + for part in main.split("."):
49 + m = re.match(r"\d+", part)
50 + nums.append(int(m.group()) if m else 0)
51 + nums += [0] * (3 - len(nums))
52 + if not pre:
53 + return (nums, 1, ())
54 + pre_parts = tuple((0, int(p), "") if p.isdigit() else (1, 0, p) for p in pre.split("."))
55 + return (nums, 0, pre_parts)
56 +
57 +
58 +def same_ecosystem(a, b):
59 + """`Debian:12` and `Debian` are one ecosystem; case doesn't matter."""
60 + return (a or "").split(":")[0].lower() == (b or "").split(":")[0].lower()
61 +
62 +
63 +def same_package(ecosystem, a, b):
64 + """Whether two names are one package in `ecosystem`. crates.io treats
65 + `-` and `_` alike, and advisories don't always spell a crate as its
66 + lockfile does (GHSA-qwgh-2vcv-g2f7 says `block_buffer`); PyPI folds
67 + case and runs of `-_.` (PEP 503). Missing one would turn a fixable
68 + finding into an unfixable one."""
69 + kind = (ecosystem or "").split(":")[0].lower()
70 + if kind == "crates.io":
71 + return a.replace("_", "-") == b.replace("_", "-")
72 + if kind == "pypi":
73 + fold = lambda n: re.sub(r"[-_.]+", "-", n).lower()
74 + return fold(a) == fold(b)
75 + return a == b
76 +
77 +
78 +def fixed_version(vuln, ecosystem, name, current):
79 + """The version that fixes `vuln` for this package, or None when OSV
80 + lists no fixed version for it at all.
81 +
82 + The fix for the range `current` is in when there is one; otherwise
83 + the newest fix listed (a version range this can't place, say)."""
84 + fixes = []
85 + for affected in vuln.get("affected", []):
86 + pkg = affected.get("package", {})
87 + if not same_ecosystem(pkg.get("ecosystem"), ecosystem):
88 + continue
89 + if not same_package(ecosystem, pkg.get("name", ""), name):
90 + continue
91 + for rng in affected.get("ranges", []):
92 + introduced = None
93 + for event in rng.get("events", []):
94 + if "introduced" in event:
95 + introduced = event["introduced"]
96 + elif "fixed" in event:
97 + fixed = event["fixed"]
98 + fixes.append(fixed)
99 + if rng.get("type") in ("SEMVER", "ECOSYSTEM") and introduced is not None:
100 + low = introduced
101 + if (low == "0" or vkey(low) <= vkey(current)) and vkey(current) < vkey(fixed):
102 + return fixed
103 + introduced = None
104 + return max(fixes, key=vkey, default=None)
105 +
106 +
107 +def classify(report):
108 + """([fixable], [unfixable]) rows from an osv-scanner JSON report."""
109 + fixable, unfixable = [], []
110 + for result in report.get("results", []) or []:
111 + lockfile = os.path.relpath(result.get("source", {}).get("path", "?"))
112 + for pkg in result.get("packages", []) or []:
113 + info = pkg.get("package", {})
114 + name, version = info.get("name", "?"), info.get("version", "?")
115 + ecosystem = info.get("ecosystem")
116 + fixed, blocked = {}, []
117 + for vuln in pkg.get("vulnerabilities", []) or []:
118 + fix = fixed_version(vuln, ecosystem, name, version)
119 + if fix:
120 + fixed[vuln["id"]] = fix
121 + else:
122 + blocked.append(vuln["id"])
123 + row = {"lockfile": lockfile, "package": name, "version": version}
124 + if fixed:
125 + fixable.append(
126 + dict(row, ids=sorted(fixed), fixed=max(fixed.values(), key=vkey))
127 + )
128 + if blocked:
129 + unfixable.append(dict(row, ids=sorted(blocked), why="no fixed version"))
130 + return fixable, unfixable
131 +
132 +
133 +def table(headers, rows):
134 + widths = [max(len(str(c)) for c in col) for col in zip(headers, *rows)]
135 + for line in [headers, *rows]:
136 + print(" " + " ".join(str(c).ljust(w) for c, w in zip(line, widths)).rstrip())
137 +
138 +
139 +def report(fixable, unfixable):
140 + print(f"Fix now: {len(fixable)}")
141 + if fixable:
142 + table(
143 + ["LOCKFILE", "PACKAGE", "VERSION", "ADVISORIES", "FIXED IN"],
144 + [[r["lockfile"], r["package"], r["version"], ", ".join(r["ids"]), r["fixed"]] for r in fixable],
145 + )
146 + print(f"\nNo fix available / blocked: {len(unfixable)}")
147 + if unfixable:
148 + table(
149 + ["LOCKFILE", "PACKAGE", "VERSION", "ADVISORIES", "WHY"],
150 + [[r["lockfile"], r["package"], r["version"], ", ".join(r["ids"]), r["why"]] for r in unfixable],
151 + )
152 +
153 +
154 +def set_output(conclusion):
155 + path = os.environ.get("GITHUB_OUTPUT")
156 + if path:
157 + with open(path, "a") as f:
158 + f.write(f"conclusion={conclusion}\n")
159 +
160 +
161 +def main(argv):
162 + args = argv[1:]
163 + if any(a in ("-h", "--help") for a in args):
164 + print(__doc__)
165 + return 0
166 + if any(a == "--format" or a.startswith("--format=") or a == "--output" or a.startswith("--output=") for a in args):
167 + print("osv-audit reads osv-scanner's JSON itself: don't pass --format or --output.", file=sys.stderr)
168 + return 2
169 + if not any(a == "--config" or a.startswith("--config=") for a in args) and os.path.isfile("osv-scanner.toml"):
170 + args = ["--config", "osv-scanner.toml", *args]
171 +
172 + scanner = os.environ.get("OSV_SCANNER", "osv-scanner")
173 + try:
174 + out = subprocess.run(
175 + [scanner, "scan", "source", "--format", "json", *args], stdout=subprocess.PIPE, text=True
176 + )
177 + except OSError as e:
178 + print(f"osv-audit: can't run {scanner}: {e}", file=sys.stderr)
179 + return 127
180 +
181 + # 0: nothing found (or all ignored), 1: found some. 128: no packages
182 + # or lockfiles, which is only an answer if the caller said it may be.
183 + if out.returncode == 128 and any(a.startswith("--allow-no-lockfiles") for a in args):
184 + print("No lockfiles found.")
185 + set_output("success")
186 + return 0
187 + if out.returncode not in (0, 1):
188 + print(f"osv-audit: osv-scanner failed ({out.returncode})", file=sys.stderr)
189 + return out.returncode
190 +
191 + try:
192 + data = json.loads(out.stdout.strip() or "{}")
193 + except json.JSONDecodeError as e:
194 + print(f"osv-audit: osv-scanner's output isn't JSON: {e}", file=sys.stderr)
195 + return 127
196 +
197 + fixable, unfixable = classify(data)
198 + report(fixable, unfixable)
199 +
200 + if fixable:
201 + print(f"\n{len(fixable)} package(s) have a fixed version: update them.")
202 + set_output("failure")
203 + return 1
204 + if unfixable:
205 + print(f"\nNothing to update yet: {len(unfixable)} package(s) have no fixed version.")
206 + set_output("warning")
207 + return 0
208 + print("\nNo known vulnerabilities.")
209 + set_output("success")
210 + return 0
211 +
212 +
213 +if __name__ == "__main__":
214 + sys.exit(main(sys.argv))
added test/scripts/osv_audit_test.exs
+177 −0
@@ -0,0 +1,177 @@
1 +defmodule GitGud.Scripts.OsvAuditTest do
2 + @moduledoc """
3 + `scripts/osv-audit`, run for real against a stand-in osv-scanner: a
4 + shell script that prints the report this test hands it, exits as told,
5 + and writes down the arguments it got. No network.
6 +
7 + What it covers is the sorting — a fixed version listed for the
8 + package means "fix now", none means "blocked" — and what each outcome
9 + exits with and says in `$GITHUB_OUTPUT`, which a job's
10 + `gitgud-conclusion` output reads.
11 + """
12 + use ExUnit.Case, async: true
13 +
14 + @script Path.expand("../../scripts/osv-audit", __DIR__)
15 +
16 + setup do
17 + if System.find_executable("python3") && System.find_executable("sh") do
18 + dir = Path.join(System.tmp_dir!(), "osv-audit-#{System.unique_integer([:positive])}")
19 + File.mkdir_p!(dir)
20 + on_exit(fn -> File.rm_rf!(dir) end)
21 + {:ok, dir: dir}
22 + else
23 + {:skip, "python3 or sh not on PATH"}
24 + end
25 + end
26 +
27 + defp vuln(id, name, events, ecosystem \\ "crates.io") do
28 + %{
29 + "id" => id,
30 + "affected" => [
31 + %{
32 + "package" => %{"name" => name, "ecosystem" => ecosystem},
33 + "ranges" => [%{"type" => "SEMVER", "events" => events}]
34 + }
35 + ]
36 + }
37 + end
38 +
39 + defp package(name, version, vulns, ecosystem \\ "crates.io") do
40 + %{
41 + "package" => %{"name" => name, "version" => version, "ecosystem" => ecosystem},
42 + "vulnerabilities" => vulns
43 + }
44 + end
45 +
46 + defp report(packages) do
47 + %{"results" => [%{"source" => %{"path" => "Cargo.lock"}, "packages" => packages}]}
48 + end
49 +
50 + # Run the script in `dir` with a stand-in scanner that prints `report`
51 + # and exits `code`. Returns {output, exit status, GITHUB_OUTPUT, args}.
52 + defp audit(dir, report, code, args \\ ["-r", "."]) do
53 + File.write!(Path.join(dir, "report.json"), Jason.encode!(report))
54 + scanner = Path.join(dir, "osv-scanner")
55 +
56 + File.write!(scanner, """
57 + #!/bin/sh
58 + printf '%s\\n' "$@" > "#{dir}/args"
59 + cat "#{dir}/report.json"
60 + exit #{code}
61 + """)
62 +
63 + File.chmod!(scanner, 0o755)
64 + github_output = Path.join(dir, "github_output")
65 + File.rm(github_output)
66 +
67 + {out, status} =
68 + System.cmd("python3", [@script | args],
69 + cd: dir,
70 + env: [{"OSV_SCANNER", scanner}, {"GITHUB_OUTPUT", github_output}],
71 + stderr_to_stdout: true
72 + )
73 +
74 + args_seen =
75 + case File.read(Path.join(dir, "args")) do
76 + {:ok, text} -> String.split(text, "\n", trim: true)
77 + _ -> nil
78 + end
79 +
80 + {out, status, File.read(github_output), args_seen}
81 + end
82 +
83 + test "a fixable finding fails, and both kinds are listed", %{dir: dir} do
84 + report =
85 + report([
86 + package("hashbrown", "0.15.0", [
87 + vuln("RUSTSEC-1", "hashbrown", [%{"introduced" => "0"}, %{"fixed" => "0.15.1"}])
88 + ]),
89 + package("block-buffer", "0.10.4", [
90 + vuln("GHSA-2", "block-buffer", [%{"introduced" => "0"}])
91 + ])
92 + ])
93 +
94 + assert {out, 1, {:ok, "conclusion=failure\n"}, args} = audit(dir, report, 1)
95 + assert args == ~w(scan source --format json -r .)
96 +
97 + [fix_now, blocked] = String.split(out, "No fix available / blocked")
98 + assert fix_now =~ "Fix now: 1"
99 + assert fix_now =~ ~r/hashbrown\s+0\.15\.0\s+RUSTSEC-1\s+0\.15\.1/
100 + assert blocked =~ ~r/block-buffer\s+0\.10\.4\s+GHSA-2\s+no fixed version/
101 + end
102 +
103 + test "only unfixable findings: a warning, exit 0", %{dir: dir} do
104 + report =
105 + report([
106 + package("block-buffer", "0.10.4", [
107 + vuln("GHSA-2", "block-buffer", [%{"introduced" => "0"}])
108 + ])
109 + ])
110 +
111 + assert {out, 0, {:ok, "conclusion=warning\n"}, _} = audit(dir, report, 1)
112 + assert out =~ "Fix now: 0"
113 + assert out =~ "No fix available / blocked: 1"
114 + end
115 +
116 + test "nothing found: success", %{dir: dir} do
117 + assert {out, 0, {:ok, "conclusion=success\n"}, _} = audit(dir, %{"results" => []}, 0)
118 + assert out =~ "No known vulnerabilities"
119 + end
120 +
121 + test "a fix is the one for the range the version is in", %{dir: dir} do
122 + events = [
123 + %{"introduced" => "0"},
124 + %{"fixed" => "1.2.5"},
125 + %{"introduced" => "2.0.0"},
126 + %{"fixed" => "2.0.3"}
127 + ]
128 +
129 + report = report([package("ring", "2.0.1", [vuln("RUSTSEC-3", "ring", events)])])
130 +
131 + assert {out, 1, _, _} = audit(dir, report, 1)
132 + assert out =~ ~r/ring\s+2\.0\.1\s+RUSTSEC-3\s+2\.0\.3/
133 + end
134 +
135 + test "an advisory naming the crate with `_` still counts; another ecosystem's doesn't",
136 + %{dir: dir} do
137 + report =
138 + report([
139 + package("block-buffer", "0.10.4", [
140 + vuln("GHSA-4", "block_buffer", [%{"introduced" => "0"}, %{"fixed" => "0.12.1"}])
141 + ]),
142 + package("serde", "1.0.0", [
143 + vuln("GHSA-5", "serde", [%{"introduced" => "0"}, %{"fixed" => "1.0.1"}], "npm")
144 + ])
145 + ])
146 +
147 + assert {out, 1, _, _} = audit(dir, report, 1)
148 + assert out =~ ~r/block-buffer\s+0\.10\.4\s+GHSA-4\s+0\.12\.1/
149 + assert out =~ ~r/serde\s+1\.0\.0\s+GHSA-5\s+no fixed version/
150 + end
151 +
152 + test "osv-scanner's config is passed when it's here and not given", %{dir: dir} do
153 + File.write!(Path.join(dir, "osv-scanner.toml"), "")
154 +
155 + assert {_, 0, _, args} = audit(dir, %{"results" => []}, 0, ["--lockfile", "Cargo.lock"])
156 + assert args == ~w(scan source --format json --config osv-scanner.toml --lockfile Cargo.lock)
157 +
158 + assert {_, 0, _, args} = audit(dir, %{"results" => []}, 0, ["--config", "x.toml", "-r", "."])
159 + assert args == ~w(scan source --format json --config x.toml -r .)
160 + end
161 +
162 + test "a scanner error is passed on, and writes no conclusion", %{dir: dir} do
163 + assert {_, 127, {:error, :enoent}, _} = audit(dir, %{}, 127)
164 + end
165 +
166 + test "no lockfiles is an error unless allowed", %{dir: dir} do
167 + assert {_, 128, {:error, :enoent}, _} = audit(dir, %{}, 128)
168 +
169 + assert {_, 0, {:ok, "conclusion=success\n"}, _} =
170 + audit(dir, %{}, 128, ["-r", ".", "--allow-no-lockfiles"])
171 + end
172 +
173 + test "--format is refused: the script reads the JSON itself", %{dir: dir} do
174 + assert {out, 2, _, nil} = audit(dir, %{}, 0, ["--format", "table"])
175 + assert out =~ "--format"
176 + end
177 +end

Parents: 52481e7