CI: nightly vulnerability audit and security-update PRs (osv-scanner)

102921b · Gabriel Morell · 2026-10-24 14:29

7 files +625 -0
Message
{commit_body(@commit)}

Files changed

added .github/workflows/audit.yml
+91 −0
@@ -0,0 +1,91 @@
1 +name: audit
2 +# Known vulnerabilities in what mutant-operator is built from: its
3 +# lockfile (Cargo.lock, which the Dockerfile's image builds from)
4 +# checked against the OSV database (https://osv.dev). A finding fails
5 +# the run, which is how it gets noticed — the CI page, the panel widget,
6 +# `gg ci list`. Nightly, since advisories land without anyone pushing,
7 +# and by hand from the Actions page.
8 +#
9 +# On a pull request it compares the lockfiles the pull request changes
10 +# with the same files on the base branch: it fails only on a
11 +# vulnerability the pull request brings in, and says what it fixed and
12 +# what's still there (scripts/osv-diff). A security update PR goes green
13 +# for what it fixes, not red for what it couldn't.
14 +#
15 +# Here in .github/workflows beside docker-build-push.yml: the forge
16 +# reads .forgejo/workflows *instead of* .github/workflows when that
17 +# exists, so starting one would switch the image build off. On
18 +# arc-runner-set, though, not that workflow's ubuntu-latest.
19 +# Taken from gitgud's .forgejo/workflows/audit.yml; keep the two in step.
20 +on:
21 + schedule:
22 + - cron: "53 6 * * *"
23 + workflow_dispatch:
24 + pull_request:
25 +
26 +permissions:
27 + contents: read
28 +
29 +jobs:
30 + osv:
31 + runs-on: arc-runner-set
32 + steps:
33 + # A bare arc-runner-set container may have no git, and
34 + # actions/checkout without git downloads a tarball with no history
35 + # to diff against. Installed first, then.
36 + - name: Tools
37 + run: |
38 + if ! command -v git >/dev/null || ! command -v python3 >/dev/null || ! command -v curl >/dev/null; then
39 + sudo apt-get update
40 + sudo apt-get install -y git curl ca-certificates python3
41 + fi
42 +
43 + - uses: actions/checkout@v4
44 + with:
45 + fetch-depth: 0
46 +
47 + # Pinned, and checked against the release's published checksum
48 + # before it runs: a scanner is a strange thing to take on trust.
49 + # To move it on, change both together, from
50 + # https://github.com/google/osv-scanner/releases (…_SHA256SUMS).
51 + - name: Fetch osv-scanner
52 + env:
53 + OSV_VERSION: v2.6.0
54 + OSV_SHA256: ca69b3d3cd08f889a49dc0a383122f71cc528b83803671df5fd874d97485b108
55 + run: |
56 + curl -sSfL -o /tmp/osv-scanner \
57 + "https://github.com/google/osv-scanner/releases/download/${OSV_VERSION}/osv-scanner_linux_amd64"
58 + echo "${OSV_SHA256} /tmp/osv-scanner" | sha256sum -c -
59 + chmod +x /tmp/osv-scanner
60 +
61 + # Exit 1 when anything is vulnerable, so the run fails; the table
62 + # names each package, its version, and the version that fixes it.
63 + - name: Scan the lockfiles
64 + if: github.event_name != 'pull_request'
65 + run: /tmp/osv-scanner scan source --recursive .
66 +
67 + - name: Scan the lockfiles this pull request changes
68 + if: github.event_name == 'pull_request'
69 + env:
70 + BASE: ${{ github.base_ref }}
71 + run: |
72 + git fetch --quiet origin "$BASE"
73 + changed=$(git diff --name-only "origin/$BASE"...HEAD | grep -E '(^|/)Cargo\.lock$' || true)
74 + if [ -z "$changed" ]; then
75 + echo "No lockfile changed."
76 + exit 0
77 + fi
78 + mkdir -p /tmp/base
79 + for f in $changed; do
80 + mkdir -p "/tmp/base/$(dirname "$f")"
81 + git show "origin/$BASE:$f" > "/tmp/base/$f" 2>/dev/null || true
82 + done
83 + # 0 and 1 (found some) are both answers; with nothing to scan,
84 + # none.
85 + scan() {
86 + if [ $# -eq 0 ]; then echo '{}'; return; fi
87 + /tmp/osv-scanner scan source --format json $(printf -- '--lockfile %s ' "$@") || [ $? -eq 1 ]
88 + }
89 + (cd /tmp/base && scan $(for f in $changed; do [ -s "$f" ] && echo "$f"; done)) > /tmp/base.json
90 + scan $changed > /tmp/head.json
91 + python3 scripts/osv-diff /tmp/base.json /tmp/head.json
added .github/workflows/dependency-updates.yml
+70 −0
@@ -0,0 +1,70 @@
1 +name: dependency-updates
2 +# Security updates as a pull request: scripts/dependency-update finds the
3 +# crates in Cargo.lock with a known vulnerability (osv-scanner, against
4 +# OSV) that a newer version fixes, bumps them, and keeps one pull
5 +# request — deps/security-cargo — refreshed in place, closing it once
6 +# there's nothing left to fix. The pull request is opened by the forge's
7 +# CI account (gg-security) with this job's own token; its CI (audit.yml
8 +# among it) is what shows the fixes landed.
9 +#
10 +# Daily, before the nightly audit, and by hand from the Actions page
11 +# (`gg ci dispatch dependency-updates`). Taken from gitgud's workflow of
12 +# the same name. Beside docker-build-push.yml in .github/workflows, for
13 +# the reason audit.yml gives.
14 +on:
15 + schedule:
16 + - cron: "57 5 * * *"
17 + workflow_dispatch:
18 +
19 +permissions:
20 + contents: write # push the deps/security-cargo branch
21 + pull-requests: write # open, refresh and close its pull request
22 +
23 +jobs:
24 + update:
25 + runs-on: arc-runner-set
26 + steps:
27 + # A bare arc-runner-set container may have no git, and
28 + # actions/checkout without git downloads a tarball with no .git
29 + # for the script to commit with. Installed first, then.
30 + - name: Tools
31 + run: |
32 + if ! command -v git >/dev/null || ! command -v python3 >/dev/null || ! command -v curl >/dev/null; then
33 + sudo apt-get update
34 + sudo apt-get install -y git curl ca-certificates python3
35 + fi
36 +
37 + - uses: actions/checkout@v4
38 + with:
39 + # The deps/security-cargo branch too, to tell whether a refresh
40 + # changed anything before pushing it.
41 + fetch-depth: 0
42 +
43 + # The toolchain the Dockerfile builds the image with (rust:1.83): a
44 + # lockfile is only as good as the resolver that wrote it, and a bump
45 + # that needs a newer compiler than the image's should fail here,
46 + # not there. Installed directly, since this runner sets ImageOS to
47 + # "selfhosted" over anything a workflow says.
48 + - name: Rust
49 + run: |
50 + curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.83
51 + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
52 +
53 + # Pinned and checked, as in audit.yml; bump both together.
54 + - name: Fetch osv-scanner
55 + env:
56 + OSV_VERSION: v2.6.0
57 + OSV_SHA256: ca69b3d3cd08f889a49dc0a383122f71cc528b83803671df5fd874d97485b108
58 + run: |
59 + curl -sSfL -o /tmp/osv-scanner \
60 + "https://github.com/google/osv-scanner/releases/download/${OSV_VERSION}/osv-scanner_linux_amd64"
61 + echo "${OSV_SHA256} /tmp/osv-scanner" | sha256sum -c -
62 + chmod +x /tmp/osv-scanner
63 +
64 + # The script sets its own git identity (gg-security) before it
65 + # commits: a fresh container has none.
66 + - name: Update
67 + env:
68 + OSV_SCANNER: /tmp/osv-scanner
69 + GITHUB_TOKEN: ${{ github.token }}
70 + run: scripts/dependency-update --base "${{ github.ref_name }}"
added scripts/dependency-update
+387 −0
@@ -0,0 +1,387 @@
1 +#!/usr/bin/env python3
2 +"""Security updates for a repository's dependencies, as pull requests.
3 +
4 +Run by .github/workflows/dependency-updates.yml. For each lockfile
5 +osv-scanner reads, finds the packages with a known vulnerability that a
6 +newer version fixes, bumps them to the lowest version that fixes every
7 +advisory against them, and keeps one pull request up to date with the
8 +result:
9 +
10 + deps/security-cargo Cargo.lock files (cargo update --precise)
11 +
12 +The branch is force-pushed and its pull request refreshed in place, so
13 +there is never a pile of them; once there's nothing left to fix, its
14 +pull request is closed. A branch someone else has pushed to —
15 +a person fixing what a bump broke — is left alone: their commits aren't
16 +the bot's to overwrite. Packages it can't fix — no fixed
17 +version yet, or one the manifest's constraint won't reach — are listed
18 +in the pull request, not touched.
19 +
20 +Taken from gitgud's scripts/dependency-update, with its Hex (mix)
21 +ecosystem dropped: this repository has no mix.lock.
22 +
23 +Needs: osv-scanner (OSV_SCANNER, default `osv-scanner`), cargo, git
24 +with push access, and in CI the
25 +job's token (GITHUB_TOKEN) with `contents: write` and `pull-requests:
26 +write`. Everything else comes from the job's environment
27 +(GITHUB_SERVER_URL, GITHUB_REPOSITORY, GITHUB_RUN_NUMBER).
28 +
29 + scripts/dependency-update do it
30 + scripts/dependency-update --dry-run show what it would bump, change nothing
31 +
32 +Standard library only, so it runs wherever python3 does.
33 +"""
34 +
35 +import argparse
36 +import json
37 +import os
38 +import re
39 +import subprocess
40 +import sys
41 +import urllib.error
42 +import urllib.request
43 +
44 +ECOSYSTEMS = {
45 + # OSV ecosystem → (branch suffix, lockfile name, human name)
46 + "crates.io": ("cargo", "Cargo.lock", "crates.io (cargo)"),
47 +}
48 +
49 +BOT_NAME = os.environ.get("GG_BOT_NAME", "gg-security")
50 +BOT_EMAIL = os.environ.get("GG_BOT_EMAIL", "gg-security@bots.invalid")
51 +
52 +
53 +# ── versions ────────────────────────────────────────────────────────────
54 +
55 +
56 +def vkey(version):
57 + """Sort key for a semver-ish version: 1.2.3 < 1.2.10, 1.0.0-rc.1 < 1.0.0."""
58 + main, _, pre = version.partition("-")
59 + nums = []
60 + for part in main.split("."):
61 + m = re.match(r"\d+", part)
62 + nums.append(int(m.group()) if m else 0)
63 + nums += [0] * (3 - len(nums))
64 + if not pre:
65 + return (nums, 1, ())
66 + pre_parts = tuple((0, int(p), "") if p.isdigit() else (1, 0, p) for p in pre.split("."))
67 + return (nums, 0, pre_parts)
68 +
69 +
70 +def fixed_for(vuln, name, current):
71 + """The version that fixes `vuln` for `name` at `current`, or None."""
72 + for affected in vuln.get("affected", []):
73 + if affected.get("package", {}).get("name") != name:
74 + continue
75 + for rng in affected.get("ranges", []):
76 + if rng.get("type") not in ("SEMVER", "ECOSYSTEM"):
77 + continue
78 + introduced = None
79 + for event in rng.get("events", []):
80 + if "introduced" in event:
81 + introduced = event["introduced"]
82 + elif "fixed" in event and introduced is not None:
83 + low = "0" if introduced == "0" else introduced
84 + if (low == "0" or vkey(low) <= vkey(current)) and vkey(current) < vkey(event["fixed"]):
85 + return event["fixed"]
86 + introduced = None
87 + return None
88 +
89 +
90 +# ── scanning ────────────────────────────────────────────────────────────
91 +
92 +
93 +def tracked_lockfiles():
94 + """The lockfiles git tracks, of the kinds handled here: not vendored
95 + copies, scratch checkouts or anything else lying in the tree."""
96 + names = {lockname for _, lockname, _ in ECOSYSTEMS.values()}
97 + listed = run(["git", "ls-files", "-z"]).stdout.split("\0")
98 + return sorted(p for p in listed if os.path.basename(p) in names)
99 +
100 +
101 +def scan(lockfiles):
102 + """osv-scanner's JSON for `lockfiles`."""
103 + if not lockfiles:
104 + return {}
105 + scanner = os.environ.get("OSV_SCANNER", "osv-scanner")
106 + args = [scanner, "scan", "source", "--format", "json"]
107 + for path in lockfiles:
108 + args += ["--lockfile", path]
109 + out = subprocess.run(args, capture_output=True, text=True)
110 + # 0: nothing found, 1: vulnerabilities found; anything else is a failure.
111 + if out.returncode not in (0, 1):
112 + sys.exit(f"osv-scanner failed ({out.returncode}):\n{out.stderr}")
113 + return json.loads(out.stdout or "{}")
114 +
115 +
116 +def findings(report):
117 + """{(ecosystem, lockfile): [finding]} for the ecosystems handled here."""
118 + found = {}
119 + for result in report.get("results", []):
120 + lockfile = os.path.relpath(result["source"]["path"])
121 + for pkg in result.get("packages", []):
122 + info = pkg["package"]
123 + ecosystem = info.get("ecosystem")
124 + if ecosystem not in ECOSYSTEMS:
125 + continue
126 + name, current = info["name"], info["version"]
127 + vulns = pkg.get("vulnerabilities", [])
128 + fixes = [fixed_for(v, name, current) for v in vulns]
129 + ids = sorted({v["id"] for v in vulns})
130 + entry = {
131 + "name": name,
132 + "current": current,
133 + "advisories": ids,
134 + "target": max((f for f in fixes if f), key=vkey, default=None),
135 + "unfixed": sorted(v["id"] for v, f in zip(vulns, fixes) if not f),
136 + }
137 + found.setdefault((ecosystem, lockfile), []).append(entry)
138 + return found
139 +
140 +
141 +# ── updating ────────────────────────────────────────────────────────────
142 +
143 +
144 +def run(args, cwd=None, check=True):
145 + out = subprocess.run(args, cwd=cwd, capture_output=True, text=True)
146 + if check and out.returncode != 0:
147 + raise RuntimeError(f"{' '.join(args)} failed:\n{out.stdout}{out.stderr}")
148 + return out
149 +
150 +
151 +def lock_versions(ecosystem, lockfile):
152 + """{name: [versions]} as the lockfile has them now."""
153 + text = open(lockfile).read()
154 + versions = {}
155 + for name, version in re.findall(r'^\[\[package\]\]\nname = "([^"]+)"\nversion = "([^"]+)"', text, re.M):
156 + versions.setdefault(name, []).append(version)
157 + return versions
158 +
159 +
160 +def cargo_reason(stderr):
161 + """cargo's own account of why it couldn't, from its `error:` line on."""
162 + lines = [l.strip() for l in stderr.strip().splitlines()]
163 + start = next((i for i, l in enumerate(lines) if l.startswith("error:")), None)
164 + if start is None:
165 + return "cargo update failed"
166 + reason = " ".join(l for l in lines[start : start + 3] if l)
167 + return reason.removeprefix("error: ")[:300]
168 +
169 +
170 +def bump(ecosystem, lockfile, entries):
171 + """Bump `entries` in `lockfile`; returns (bumped, left, carried) lists,
172 + `carried` being what moved along without being asked — what the bumps
173 + required."""
174 + root = os.path.dirname(lockfile) or "."
175 + before = lock_versions(ecosystem, lockfile)
176 + fixable = [e for e in entries if e["target"]]
177 + left = [dict(e, reason="no fixed version yet") for e in entries if not e["target"]]
178 + if not fixable:
179 + return [], left, []
180 +
181 + for e in fixable:
182 + precise = ["cargo", "update", "-p", f"{e['name']}@{e['current']}", "--precise", e["target"]]
183 + out = run(precise, cwd=root, check=False)
184 + # --precise moves nothing else, so a fix that needs a newer
185 + # build dependency than the lockfile holds ("previously
186 + # selected package `autocfg v1.5.0`") fails. Move that one
187 + # within its own requirements, and try once more.
188 + held = re.search(r"previously selected package `(\S+) v", out.stderr) if out.returncode else None
189 + if held:
190 + run(["cargo", "update", "-p", held.group(1)], cwd=root, check=False)
191 + out = run(precise, cwd=root, check=False)
192 + if out.returncode != 0:
193 + e["failed"] = cargo_reason(out.stderr)
194 +
195 + now = lock_versions(ecosystem, lockfile)
196 + bumped = []
197 + for e in fixable:
198 + versions = now.get(e["name"], [])
199 + reached = [v for v in versions if vkey(v) >= vkey(e["target"])]
200 + if e.get("failed"):
201 + left.append(dict(e, reason=e["failed"]))
202 + elif e["current"] not in versions and reached:
203 + bumped.append(dict(e, to=max(reached, key=vkey)))
204 + else:
205 + left.append(dict(e, reason=f"the manifest's constraint doesn't reach {e['target']}"))
206 +
207 + asked = {e["name"] for e in fixable}
208 + carried = [
209 + {"name": name, "current": ", ".join(old), "to": ", ".join(now.get(name, ["(removed)"]))}
210 + for name, old in sorted(before.items())
211 + if name not in asked and sorted(old) != sorted(now.get(name, []))
212 + ]
213 + return bumped, left, carried
214 +
215 +
216 +def authors_besides_bot(base, branch):
217 + """Who other than the bot has commits on `branch` past `base`."""
218 + remote = f"refs/remotes/origin/{branch}"
219 + if run(["git", "rev-parse", "--verify", "--quiet", remote], check=False).returncode != 0:
220 + return []
221 + emails = run(["git", "log", "--format=%ae", f"origin/{base}..{remote}"]).stdout.split()
222 + return sorted({e for e in emails if e != BOT_EMAIL})
223 +
224 +
225 +# ── the forge ───────────────────────────────────────────────────────────
226 +
227 +
228 +class Forge:
229 + def __init__(self):
230 + self.base = os.environ["GITHUB_SERVER_URL"].rstrip("/") + "/api/mobile/v1"
231 + self.repo = os.environ["GITHUB_REPOSITORY"]
232 + self.token = os.environ["GITHUB_TOKEN"]
233 +
234 + def call(self, method, path, body=None):
235 + req = urllib.request.Request(
236 + f"{self.base}/repos/{self.repo}{path}",
237 + method=method,
238 + data=json.dumps(body).encode() if body is not None else None,
239 + headers={
240 + "Authorization": f"Bearer {self.token}",
241 + "Accept": "application/json",
242 + "Content-Type": "application/json",
243 + },
244 + )
245 + try:
246 + with urllib.request.urlopen(req, timeout=60) as resp:
247 + return json.loads(resp.read() or b"{}")
248 + except urllib.error.HTTPError as err:
249 + raise RuntimeError(f"{method} {path}: {err.code} {err.read().decode(errors='replace')}")
250 +
251 + def open_pull(self, branch):
252 + pulls = self.call("GET", "/pulls?state=open&limit=100").get("pulls", [])
253 + return next((p for p in pulls if p.get("source_ref") == branch), None)
254 +
255 +
256 +# ── pull request text ───────────────────────────────────────────────────
257 +
258 +
259 +def osv_links(ids):
260 + return ", ".join(f"[{i}](https://osv.dev/vulnerability/{i})" for i in ids)
261 +
262 +
263 +def describe(human, bumped, left, carried):
264 + lines = [
265 + f"Security updates for {human} dependencies with known vulnerabilities "
266 + "([OSV](https://osv.dev)), each to the lowest version that fixes every advisory "
267 + "against it, or as far as the manifest allows past that.",
268 + "",
269 + "| package | from | to | advisories | lockfile |",
270 + "|---|---|---|---|---|",
271 + ]
272 + for b in sorted(bumped, key=lambda b: (b["lockfile"], b["name"])):
273 + lines.append(
274 + f"| `{b['name']}` | {b['current']} | {b['to']} | {osv_links(b['advisories'])} | `{b['lockfile']}` |"
275 + )
276 + if carried:
277 + lines += ["", "Also moved, as the above required:", ""]
278 + for c in sorted(carried, key=lambda c: (c["lockfile"], c["name"])):
279 + lines.append(f"- `{c['name']}` {c['current']} → {c['to']} ({c['lockfile']})")
280 + if left:
281 + lines += ["", "Left alone:", ""]
282 + for l in sorted(left, key=lambda l: (l["lockfile"], l["name"])):
283 + lines.append(f"- `{l['name']}` {l['current']} ({l['lockfile']}): {l['reason']}")
284 + run_number = os.environ.get("GITHUB_RUN_NUMBER")
285 + lines += [
286 + "",
287 + "Refreshed in place by the `dependency-updates` workflow"
288 + + (f" (run #{run_number})" if run_number else "")
289 + + "; it closes this once there's nothing left to fix.",
290 + ]
291 + return "\n".join(lines)
292 +
293 +
294 +# ── main ────────────────────────────────────────────────────────────────
295 +
296 +
297 +def main():
298 + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
299 + parser.add_argument("--dry-run", action="store_true", help="show what would change; change nothing")
300 + parser.add_argument("--base", default=None, help="the branch to update from (default: the current one)")
301 + opts = parser.parse_args()
302 +
303 + base = opts.base or run(["git", "rev-parse", "--abbrev-ref", "HEAD"]).stdout.strip()
304 + by_lockfile = findings(scan(tracked_lockfiles()))
305 + forge = None if opts.dry_run else Forge()
306 +
307 + if not opts.dry_run:
308 + run(["git", "config", "user.name", BOT_NAME])
309 + run(["git", "config", "user.email", BOT_EMAIL])
310 +
311 + for ecosystem, (suffix, lockname, human) in ECOSYSTEMS.items():
312 + branch = f"deps/security-{suffix}"
313 + lockfiles = sorted(lf for (eco, lf) in by_lockfile if eco == ecosystem)
314 + bumped, left, carried = [], [], []
315 +
316 + if lockfiles and not opts.dry_run:
317 + run(["git", "checkout", "-B", branch, base])
318 +
319 + for lockfile in lockfiles:
320 + entries = by_lockfile[(ecosystem, lockfile)]
321 + for e in entries:
322 + e["lockfile"] = lockfile
323 + if opts.dry_run:
324 + for e in entries:
325 + plan = f"→ {e['target']}" if e["target"] else "(no fixed version)"
326 + print(f"{human}: {lockfile}: {e['name']} {e['current']} {plan} {', '.join(e['advisories'])}")
327 + continue
328 + b, l, c = bump(ecosystem, lockfile, entries)
329 + bumped += [dict(x, lockfile=lockfile) for x in b]
330 + left += [dict(x, lockfile=lockfile) for x in l]
331 + carried += [dict(x, lockfile=lockfile) for x in c]
332 +
333 + if opts.dry_run:
334 + continue
335 +
336 + existing = forge.open_pull(branch)
337 +
338 + others = authors_besides_bot(base, branch)
339 + if others:
340 + run(["git", "checkout", "--force", base])
341 + print(f"{human}: {branch} has commits by {', '.join(others)}; leaving it alone")
342 + continue
343 +
344 + if not bumped:
345 + run(["git", "checkout", "--force", base])
346 + if existing:
347 + forge.call("POST", f"/pulls/{existing['number']}/comments",
348 + {"body": "Nothing left here to fix: closing."})
349 + forge.call("POST", f"/pulls/{existing['number']}/state", {"state": "closed"})
350 + print(f"{human}: nothing to fix; closed #{existing['number']}")
351 + else:
352 + print(f"{human}: nothing to fix")
353 + continue
354 +
355 + names = ", ".join(sorted({b["name"] for b in bumped}))
356 + title = f"Security updates: {human} — {names}"
357 + if len(title) > 120:
358 + title = f"Security updates: {human} — {len(bumped)} packages"
359 + body = describe(human, bumped, left, carried)
360 +
361 + run(["git", "add"] + lockfiles)
362 + run(["git", "commit", "--quiet", "-m", title, "-m", body])
363 +
364 + # Push only when the result differs from what the branch has — the
365 + # bumps, or the base it sits on — so an unchanged week doesn't
366 + # re-run the pull request's CI, but a moved base does move it.
367 + remote = run(["git", "rev-parse", "--verify", "--quiet", f"refs/remotes/origin/{branch}"], check=False)
368 + same = remote.returncode == 0 and run(
369 + ["git", "diff", "--quiet", f"origin/{branch}", "HEAD"], check=False
370 + ).returncode == 0
371 + if not same:
372 + run(["git", "push", "--force", "origin", f"HEAD:refs/heads/{branch}"])
373 +
374 + if existing:
375 + forge.call("PATCH", f"/pulls/{existing['number']}", {"title": title, "body": body})
376 + print(f"{human}: refreshed #{existing['number']}" + ("" if not same else " (no new changes)"))
377 + else:
378 + created = forge.call("POST", "/pulls", {"title": title, "body": body, "head": branch, "base": base})
379 + print(f"{human}: opened #{created['pull']['number']}")
380 +
381 + run(["git", "checkout", "--force", base])
382 +
383 + return 0
384 +
385 +
386 +if __name__ == "__main__":
387 + sys.exit(main())
added scripts/osv-diff
+77 −0
@@ -0,0 +1,77 @@
1 +#!/usr/bin/env python3
2 +"""Compare two osv-scanner JSON reports: a pull request's lockfiles
3 +against the same files on its base branch.
4 +
5 + scripts/osv-diff BASE.json HEAD.json [BASE_ROOT]
6 +
7 +Prints what the pull request fixed, what it brings in, and what's still
8 +there; exits 1 only if it brings something in. BASE_ROOT (default
9 +/tmp/base) is where the base branch's copies were written, stripped so
10 +the two reports' paths line up. Used by .github/workflows/audit.yml.
11 +"""
12 +
13 +import json
14 +import os
15 +import sys
16 +
17 +
18 +def advisories(report, root):
19 + """{(lockfile, package, version): {advisory ids}} from a report."""
20 + found = {}
21 + for result in report.get("results", []):
22 + path = result["source"]["path"]
23 + path = os.path.relpath(path, root) if path.startswith(root + os.sep) else os.path.relpath(path)
24 + for pkg in result.get("packages", []):
25 + info = pkg["package"]
26 + ids = {v["id"] for v in pkg.get("vulnerabilities", [])}
27 + if ids:
28 + found[(path, info["name"], info["version"])] = ids
29 + return found
30 +
31 +
32 +def by_package(found):
33 + """{(lockfile, package): {advisory ids}}, versions folded away."""
34 + out = {}
35 + for (path, name, _version), ids in found.items():
36 + out.setdefault((path, name), set()).update(ids)
37 + return out
38 +
39 +
40 +def load(path):
41 + with open(path) as f:
42 + text = f.read().strip()
43 + return json.loads(text) if text else {}
44 +
45 +
46 +def main(argv):
47 + if len(argv) < 3:
48 + sys.exit(__doc__)
49 + root = argv[3] if len(argv) > 3 else "/tmp/base"
50 + base = by_package(advisories(load(argv[1]), root))
51 + head = by_package(advisories(load(argv[2]), root))
52 +
53 + fixed, added, still = [], [], []
54 + for key in sorted(set(base) | set(head)):
55 + before, after = base.get(key, set()), head.get(key, set())
56 + path, name = key
57 + if before - after:
58 + fixed.append(f"{name} ({path}): {', '.join(sorted(before - after))}")
59 + if after - before:
60 + added.append(f"{name} ({path}): {', '.join(sorted(after - before))}")
61 + if after & before:
62 + still.append(f"{name} ({path}): {', '.join(sorted(after & before))}")
63 +
64 + for title, lines in (("Fixed", fixed), ("Brought in", added), ("Still there, as before", still)):
65 + print(f"{title}: {len(lines)}")
66 + for line in lines:
67 + print(f" {line}")
68 +
69 + if added:
70 + print("\nThis pull request brings in known vulnerabilities.")
71 + return 1
72 + print(f"\nNothing new. {len(fixed)} fixed, {len(still)} still open from before.")
73 + return 0
74 +
75 +
76 +if __name__ == "__main__":
77 + sys.exit(main(sys.argv))

Parents: 92117ff