neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
scripts/osv-audit: sort osv-scanner findings by whether they can be fixed
37f03c4 · Gabriel Morell · 2026-10-27 18:57
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