CI: nightly OSV audit and weekly security-update pull requests

73a7879 · Gabriel Morell · 2026-10-23 04:17

10 files +623 -0
Message
{commit_body(@commit)}

Files changed

added .forgejo/workflows/audit.yml
+87 −0
@@ -0,0 +1,87 @@
1 +name: audit
2 +# Known vulnerabilities in what the site is built from: every lockfile
3 +# in the repository (today code/Cargo.lock, the types scratch crate)
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 +# The site itself isn't covered: Zola and static-web-server come in as
16 +# pinned images (Dockerfile), and reveal.js is vendored under static/
17 +# with no lockfile for osv-scanner to read.
18 +on:
19 + schedule:
20 + - cron: "17 6 * * *"
21 + workflow_dispatch:
22 + pull_request:
23 +
24 +permissions:
25 + contents: read
26 +
27 +jobs:
28 + osv:
29 + runs-on: self-hosted
30 + steps:
31 + - uses: actions/checkout@v4
32 + with:
33 + fetch-depth: 0
34 +
35 + # Pinned, and checked against the release's published checksum
36 + # before it runs: a scanner is a strange thing to take on trust.
37 + # To move it on, change both together, from
38 + # https://github.com/google/osv-scanner/releases (…_SHA256SUMS).
39 + - name: Fetch osv-scanner
40 + env:
41 + OSV_VERSION: v2.6.0
42 + OSV_SHA256: ca69b3d3cd08f889a49dc0a383122f71cc528b83803671df5fd874d97485b108
43 + run: |
44 + curl -sSfL -o /tmp/osv-scanner \
45 + "https://github.com/google/osv-scanner/releases/download/${OSV_VERSION}/osv-scanner_linux_amd64"
46 + echo "${OSV_SHA256} /tmp/osv-scanner" | sha256sum -c -
47 + chmod +x /tmp/osv-scanner
48 +
49 + # Exit 1 when anything is vulnerable, so the run fails; the table
50 + # names each package, its version, and the version that fixes it.
51 + # Only the lockfiles git tracks: code/target/ is committed, and
52 + # nothing in a build directory is what the site is built from.
53 + - name: Scan the lockfiles
54 + if: github.event_name != 'pull_request'
55 + run: |
56 + lockfiles=$(git ls-files | grep -E '(^|/)Cargo\.lock$' || true)
57 + if [ -z "$lockfiles" ]; then
58 + echo "No lockfile to scan."
59 + exit 0
60 + fi
61 + /tmp/osv-scanner scan source $(printf -- '--lockfile %s ' $lockfiles)
62 +
63 + - name: Scan the lockfiles this pull request changes
64 + if: github.event_name == 'pull_request'
65 + env:
66 + BASE: ${{ github.base_ref }}
67 + run: |
68 + git fetch --quiet origin "$BASE"
69 + changed=$(git diff --name-only "origin/$BASE"...HEAD | grep -E '(^|/)Cargo\.lock$' || true)
70 + if [ -z "$changed" ]; then
71 + echo "No lockfile changed."
72 + exit 0
73 + fi
74 + mkdir -p /tmp/base
75 + for f in $changed; do
76 + mkdir -p "/tmp/base/$(dirname "$f")"
77 + git show "origin/$BASE:$f" > "/tmp/base/$f" 2>/dev/null || true
78 + done
79 + # 0 and 1 (found some) are both answers; with nothing to scan, none.
80 + scan() {
81 + if [ $# -eq 0 ]; then echo '{}'; return; fi
82 + /tmp/osv-scanner scan source --format json $(printf -- '--lockfile %s ' "$@") || [ $? -eq 1 ]
83 + }
84 + (cd /tmp/base && scan $(for f in $changed; do [ -s "$f" ] && echo "$f"; done)) > /tmp/base.json
85 + # A lockfile the pull request deletes has nothing on this side.
86 + scan $(for f in $changed; do [ -s "$f" ] && echo "$f"; done) > /tmp/head.json
87 + python3 scripts/osv-diff /tmp/base.json /tmp/head.json
added .forgejo/workflows/dependency-updates.yml
+59 −0
@@ -0,0 +1,59 @@
1 +name: dependency-updates
2 +# Security updates as pull requests: scripts/dependency-update finds the
3 +# dependencies with a known vulnerability (osv-scanner, against OSV) that
4 +# a newer version fixes, bumps them, and keeps one pull request —
5 +# deps/security-cargo, for code/Cargo.lock — refreshed in place, closing
6 +# it once there's nothing left to fix. The pull request is opened by the
7 +# forge's CI account (gg-security) with this job's own token; its CI
8 +# (audit.yml) is what shows the fixes landed. Merging it to master
9 +# deploys, as any push there does (image.yml).
10 +#
11 +# Weekly, and by hand from the Actions page (`gg ci dispatch
12 +# dependency-updates`).
13 +on:
14 + schedule:
15 + - cron: "23 5 * * 1"
16 + workflow_dispatch:
17 +
18 +permissions:
19 + contents: write # push the deps/security-cargo branch
20 + pull-requests: write # open, refresh and close its pull request
21 +
22 +jobs:
23 + update:
24 + runs-on: self-hosted
25 + steps:
26 + - uses: actions/checkout@v4
27 + with:
28 + # The deps/security-* branches too, to tell whether a refresh
29 + # changed anything before pushing it.
30 + fetch-depth: 0
31 +
32 + # Cargo only: the one lockfile is code/Cargo.lock. Pinned, since a
33 + # lockfile is only as good as the resolver that wrote it; the crate
34 + # is edition 2024, so 1.85 at the least. Installed directly with
35 + # rustup, since setup actions choose by ImageOS, which this runner
36 + # sets to "selfhosted" over anything a workflow says.
37 + - name: Rust
38 + run: |
39 + curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.88.0
40 + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
41 +
42 + # Pinned and checked, as in audit.yml; bump both together.
43 + - name: Fetch osv-scanner
44 + env:
45 + OSV_VERSION: v2.6.0
46 + OSV_SHA256: ca69b3d3cd08f889a49dc0a383122f71cc528b83803671df5fd874d97485b108
47 + run: |
48 + curl -sSfL -o /tmp/osv-scanner \
49 + "https://github.com/google/osv-scanner/releases/download/${OSV_VERSION}/osv-scanner_linux_amd64"
50 + echo "${OSV_SHA256} /tmp/osv-scanner" | sha256sum -c -
51 + chmod +x /tmp/osv-scanner
52 +
53 + # The script sets its own git identity (gg-security) before it
54 + # commits: a fresh runner container has none.
55 + - name: Update
56 + env:
57 + OSV_SCANNER: /tmp/osv-scanner
58 + GITHUB_TOKEN: ${{ github.token }}
59 + run: scripts/dependency-update --base "${{ github.ref_name }}"
modified ?
+0 −0

Binary file — not rendered.

modified ?
+0 −0

Binary file — not rendered.

added scripts/dependency-update
+400 −0
@@ -0,0 +1,400 @@
1 +#!/usr/bin/env python3
2 +"""Security updates for a repository's dependencies, as pull requests.
3 +
4 +Run by .forgejo/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 per ecosystem up to
8 +date with the result:
9 +
10 + deps/security-cargo Cargo.lock files (cargo update --precise)
11 +
12 +(The Hex/mix paths below are kept from the copy this came from, so the
13 +two stay easy to compare; ECOSYSTEMS doesn't ask for them here.)
14 +
15 +The branch is force-pushed and its pull request refreshed in place, so
16 +there is never a pile of them; once an ecosystem has nothing left to
17 +fix, its pull request is closed. A branch someone else has pushed to —
18 +a person fixing what a bump broke — is left alone: their commits aren't
19 +the bot's to overwrite. Packages it can't fix — no fixed
20 +version yet, or one the manifest's constraint won't reach — are listed
21 +in the pull request, not touched.
22 +
23 +Needs: osv-scanner (OSV_SCANNER, default `osv-scanner`), mix and/or
24 +cargo for the ecosystems present, git 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). Taken
46 + # from GitGud's scripts/dependency-update, where Hex is here too; this
47 + # repository has Cargo.lock only, so only Cargo is asked after.
48 + "crates.io": ("cargo", "Cargo.lock", "crates.io (cargo)"),
49 +}
50 +
51 +BOT_NAME = os.environ.get("GG_BOT_NAME", "gg-security")
52 +BOT_EMAIL = os.environ.get("GG_BOT_EMAIL", "gg-security@bots.invalid")
53 +
54 +
55 +# ── versions ────────────────────────────────────────────────────────────
56 +
57 +
58 +def vkey(version):
59 + """Sort key for a semver-ish version: 1.2.3 < 1.2.10, 1.0.0-rc.1 < 1.0.0."""
60 + main, _, pre = version.partition("-")
61 + nums = []
62 + for part in main.split("."):
63 + m = re.match(r"\d+", part)
64 + nums.append(int(m.group()) if m else 0)
65 + nums += [0] * (3 - len(nums))
66 + if not pre:
67 + return (nums, 1, ())
68 + pre_parts = tuple((0, int(p), "") if p.isdigit() else (1, 0, p) for p in pre.split("."))
69 + return (nums, 0, pre_parts)
70 +
71 +
72 +def fixed_for(vuln, name, current):
73 + """The version that fixes `vuln` for `name` at `current`, or None."""
74 + for affected in vuln.get("affected", []):
75 + if affected.get("package", {}).get("name") != name:
76 + continue
77 + for rng in affected.get("ranges", []):
78 + if rng.get("type") not in ("SEMVER", "ECOSYSTEM"):
79 + continue
80 + introduced = None
81 + for event in rng.get("events", []):
82 + if "introduced" in event:
83 + introduced = event["introduced"]
84 + elif "fixed" in event and introduced is not None:
85 + low = "0" if introduced == "0" else introduced
86 + if (low == "0" or vkey(low) <= vkey(current)) and vkey(current) < vkey(event["fixed"]):
87 + return event["fixed"]
88 + introduced = None
89 + return None
90 +
91 +
92 +# ── scanning ────────────────────────────────────────────────────────────
93 +
94 +
95 +def tracked_lockfiles():
96 + """The lockfiles git tracks, of the kinds handled here: not vendored
97 + copies, scratch checkouts or anything else lying in the tree."""
98 + names = {lockname for _, lockname, _ in ECOSYSTEMS.values()}
99 + listed = run(["git", "ls-files", "-z"]).stdout.split("\0")
100 + return sorted(p for p in listed if os.path.basename(p) in names)
101 +
102 +
103 +def scan(lockfiles):
104 + """osv-scanner's JSON for `lockfiles`."""
105 + if not lockfiles:
106 + return {}
107 + scanner = os.environ.get("OSV_SCANNER", "osv-scanner")
108 + args = [scanner, "scan", "source", "--format", "json"]
109 + for path in lockfiles:
110 + args += ["--lockfile", path]
111 + out = subprocess.run(args, capture_output=True, text=True)
112 + # 0: nothing found, 1: vulnerabilities found; anything else is a failure.
113 + if out.returncode not in (0, 1):
114 + sys.exit(f"osv-scanner failed ({out.returncode}):\n{out.stderr}")
115 + return json.loads(out.stdout or "{}")
116 +
117 +
118 +def findings(report):
119 + """{(ecosystem, lockfile): [finding]} for the ecosystems handled here."""
120 + found = {}
121 + for result in report.get("results", []):
122 + lockfile = os.path.relpath(result["source"]["path"])
123 + for pkg in result.get("packages", []):
124 + info = pkg["package"]
125 + ecosystem = info.get("ecosystem")
126 + if ecosystem not in ECOSYSTEMS:
127 + continue
128 + name, current = info["name"], info["version"]
129 + vulns = pkg.get("vulnerabilities", [])
130 + fixes = [fixed_for(v, name, current) for v in vulns]
131 + ids = sorted({v["id"] for v in vulns})
132 + entry = {
133 + "name": name,
134 + "current": current,
135 + "advisories": ids,
136 + "target": max((f for f in fixes if f), key=vkey, default=None),
137 + "unfixed": sorted(v["id"] for v, f in zip(vulns, fixes) if not f),
138 + }
139 + found.setdefault((ecosystem, lockfile), []).append(entry)
140 + return found
141 +
142 +
143 +# ── updating ────────────────────────────────────────────────────────────
144 +
145 +
146 +def run(args, cwd=None, check=True):
147 + out = subprocess.run(args, cwd=cwd, capture_output=True, text=True)
148 + if check and out.returncode != 0:
149 + raise RuntimeError(f"{' '.join(args)} failed:\n{out.stdout}{out.stderr}")
150 + return out
151 +
152 +
153 +def lock_versions(ecosystem, lockfile):
154 + """{name: [versions]} as the lockfile has them now."""
155 + text = open(lockfile).read()
156 + versions = {}
157 + if ecosystem == "Hex":
158 + for name, version in re.findall(r'^\s*"([^"]+)": \{:hex, :[^,]+, "([^"]+)"', text, re.M):
159 + versions.setdefault(name, []).append(version)
160 + else:
161 + for name, version in re.findall(r'^\[\[package\]\]\nname = "([^"]+)"\nversion = "([^"]+)"', text, re.M):
162 + versions.setdefault(name, []).append(version)
163 + return versions
164 +
165 +
166 +def cargo_reason(stderr):
167 + """cargo's own account of why it couldn't, from its `error:` line on."""
168 + lines = [l.strip() for l in stderr.strip().splitlines()]
169 + start = next((i for i, l in enumerate(lines) if l.startswith("error:")), None)
170 + if start is None:
171 + return "cargo update failed"
172 + reason = " ".join(l for l in lines[start : start + 3] if l)
173 + return reason.removeprefix("error: ")[:300]
174 +
175 +
176 +def bump(ecosystem, lockfile, entries):
177 + """Bump `entries` in `lockfile`; returns (bumped, left, carried) lists,
178 + `carried` being what moved along without being asked — what the bumps
179 + required."""
180 + root = os.path.dirname(lockfile) or "."
181 + before = lock_versions(ecosystem, lockfile)
182 + fixable = [e for e in entries if e["target"]]
183 + left = [dict(e, reason="no fixed version yet") for e in entries if not e["target"]]
184 + if not fixable:
185 + return [], left, []
186 +
187 + if ecosystem == "Hex":
188 + # deps.update takes them as far as mix.exs allows, which may be
189 + # past the fix; checked against the target below.
190 + out = run(["mix", "deps.update"] + sorted({e["name"] for e in fixable}), cwd=root, check=False)
191 + if out.returncode != 0:
192 + return [], left + [dict(e, reason=f"mix deps.update failed: {out.stderr.strip()[-200:]}") for e in fixable], []
193 + else:
194 + for e in fixable:
195 + precise = ["cargo", "update", "-p", f"{e['name']}@{e['current']}", "--precise", e["target"]]
196 + out = run(precise, cwd=root, check=False)
197 + # --precise moves nothing else, so a fix that needs a newer
198 + # build dependency than the lockfile holds ("previously
199 + # selected package `autocfg v1.5.0`") fails. Move that one
200 + # within its own requirements, and try once more.
201 + held = re.search(r"previously selected package `(\S+) v", out.stderr) if out.returncode else None
202 + if held:
203 + run(["cargo", "update", "-p", held.group(1)], cwd=root, check=False)
204 + out = run(precise, cwd=root, check=False)
205 + if out.returncode != 0:
206 + e["failed"] = cargo_reason(out.stderr)
207 +
208 + now = lock_versions(ecosystem, lockfile)
209 + bumped = []
210 + for e in fixable:
211 + versions = now.get(e["name"], [])
212 + reached = [v for v in versions if vkey(v) >= vkey(e["target"])]
213 + if e.get("failed"):
214 + left.append(dict(e, reason=e["failed"]))
215 + elif e["current"] not in versions and reached:
216 + bumped.append(dict(e, to=max(reached, key=vkey)))
217 + else:
218 + left.append(dict(e, reason=f"the manifest's constraint doesn't reach {e['target']}"))
219 +
220 + asked = {e["name"] for e in fixable}
221 + carried = [
222 + {"name": name, "current": ", ".join(old), "to": ", ".join(now.get(name, ["(removed)"]))}
223 + for name, old in sorted(before.items())
224 + if name not in asked and sorted(old) != sorted(now.get(name, []))
225 + ]
226 + return bumped, left, carried
227 +
228 +
229 +def authors_besides_bot(base, branch):
230 + """Who other than the bot has commits on `branch` past `base`."""
231 + remote = f"refs/remotes/origin/{branch}"
232 + if run(["git", "rev-parse", "--verify", "--quiet", remote], check=False).returncode != 0:
233 + return []
234 + emails = run(["git", "log", "--format=%ae", f"origin/{base}..{remote}"]).stdout.split()
235 + return sorted({e for e in emails if e != BOT_EMAIL})
236 +
237 +
238 +# ── the forge ───────────────────────────────────────────────────────────
239 +
240 +
241 +class Forge:
242 + def __init__(self):
243 + self.base = os.environ["GITHUB_SERVER_URL"].rstrip("/") + "/api/mobile/v1"
244 + self.repo = os.environ["GITHUB_REPOSITORY"]
245 + self.token = os.environ["GITHUB_TOKEN"]
246 +
247 + def call(self, method, path, body=None):
248 + req = urllib.request.Request(
249 + f"{self.base}/repos/{self.repo}{path}",
250 + method=method,
251 + data=json.dumps(body).encode() if body is not None else None,
252 + headers={
253 + "Authorization": f"Bearer {self.token}",
254 + "Accept": "application/json",
255 + "Content-Type": "application/json",
256 + },
257 + )
258 + try:
259 + with urllib.request.urlopen(req, timeout=60) as resp:
260 + return json.loads(resp.read() or b"{}")
261 + except urllib.error.HTTPError as err:
262 + raise RuntimeError(f"{method} {path}: {err.code} {err.read().decode(errors='replace')}")
263 +
264 + def open_pull(self, branch):
265 + pulls = self.call("GET", "/pulls?state=open&limit=100").get("pulls", [])
266 + return next((p for p in pulls if p.get("source_ref") == branch), None)
267 +
268 +
269 +# ── pull request text ───────────────────────────────────────────────────
270 +
271 +
272 +def osv_links(ids):
273 + return ", ".join(f"[{i}](https://osv.dev/vulnerability/{i})" for i in ids)
274 +
275 +
276 +def describe(human, bumped, left, carried):
277 + lines = [
278 + f"Security updates for {human} dependencies with known vulnerabilities "
279 + "([OSV](https://osv.dev)), each to the lowest version that fixes every advisory "
280 + "against it, or as far as the manifest allows past that.",
281 + "",
282 + "| package | from | to | advisories | lockfile |",
283 + "|---|---|---|---|---|",
284 + ]
285 + for b in sorted(bumped, key=lambda b: (b["lockfile"], b["name"])):
286 + lines.append(
287 + f"| `{b['name']}` | {b['current']} | {b['to']} | {osv_links(b['advisories'])} | `{b['lockfile']}` |"
288 + )
289 + if carried:
290 + lines += ["", "Also moved, as the above required:", ""]
291 + for c in sorted(carried, key=lambda c: (c["lockfile"], c["name"])):
292 + lines.append(f"- `{c['name']}` {c['current']} → {c['to']} ({c['lockfile']})")
293 + if left:
294 + lines += ["", "Left alone:", ""]
295 + for l in sorted(left, key=lambda l: (l["lockfile"], l["name"])):
296 + lines.append(f"- `{l['name']}` {l['current']} ({l['lockfile']}): {l['reason']}")
297 + run_number = os.environ.get("GITHUB_RUN_NUMBER")
298 + lines += [
299 + "",
300 + "Refreshed in place by the `dependency-updates` workflow"
301 + + (f" (run #{run_number})" if run_number else "")
302 + + "; it closes this once there's nothing left to fix.",
303 + ]
304 + return "\n".join(lines)
305 +
306 +
307 +# ── main ────────────────────────────────────────────────────────────────
308 +
309 +
310 +def main():
311 + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
312 + parser.add_argument("--dry-run", action="store_true", help="show what would change; change nothing")
313 + parser.add_argument("--base", default=None, help="the branch to update from (default: the current one)")
314 + opts = parser.parse_args()
315 +
316 + base = opts.base or run(["git", "rev-parse", "--abbrev-ref", "HEAD"]).stdout.strip()
317 + by_lockfile = findings(scan(tracked_lockfiles()))
318 + forge = None if opts.dry_run else Forge()
319 +
320 + if not opts.dry_run:
321 + run(["git", "config", "user.name", BOT_NAME])
322 + run(["git", "config", "user.email", BOT_EMAIL])
323 +
324 + for ecosystem, (suffix, lockname, human) in ECOSYSTEMS.items():
325 + branch = f"deps/security-{suffix}"
326 + lockfiles = sorted(lf for (eco, lf) in by_lockfile if eco == ecosystem)
327 + bumped, left, carried = [], [], []
328 +
329 + if lockfiles and not opts.dry_run:
330 + run(["git", "checkout", "-B", branch, base])
331 +
332 + for lockfile in lockfiles:
333 + entries = by_lockfile[(ecosystem, lockfile)]
334 + for e in entries:
335 + e["lockfile"] = lockfile
336 + if opts.dry_run:
337 + for e in entries:
338 + plan = f"→ {e['target']}" if e["target"] else "(no fixed version)"
339 + print(f"{human}: {lockfile}: {e['name']} {e['current']} {plan} {', '.join(e['advisories'])}")
340 + continue
341 + b, l, c = bump(ecosystem, lockfile, entries)
342 + bumped += [dict(x, lockfile=lockfile) for x in b]
343 + left += [dict(x, lockfile=lockfile) for x in l]
344 + carried += [dict(x, lockfile=lockfile) for x in c]
345 +
346 + if opts.dry_run:
347 + continue
348 +
349 + existing = forge.open_pull(branch)
350 +
351 + others = authors_besides_bot(base, branch)
352 + if others:
353 + run(["git", "checkout", "--force", base])
354 + print(f"{human}: {branch} has commits by {', '.join(others)}; leaving it alone")
355 + continue
356 +
357 + if not bumped:
358 + run(["git", "checkout", "--force", base])
359 + if existing:
360 + forge.call("POST", f"/pulls/{existing['number']}/comments",
361 + {"body": "Nothing left here to fix: closing."})
362 + forge.call("POST", f"/pulls/{existing['number']}/state", {"state": "closed"})
363 + print(f"{human}: nothing to fix; closed #{existing['number']}")
364 + else:
365 + print(f"{human}: nothing to fix")
366 + continue
367 +
368 + names = ", ".join(sorted({b["name"] for b in bumped}))
369 + title = f"Security updates: {human} — {names}"
370 + if len(title) > 120:
371 + title = f"Security updates: {human} — {len(bumped)} packages"
372 + body = describe(human, bumped, left, carried)
373 +
374 + run(["git", "add"] + lockfiles)
375 + run(["git", "commit", "--quiet", "-m", title, "-m", body])
376 +
377 + # Push only when the result differs from what the branch has — the
378 + # bumps, or the base it sits on — so an unchanged week doesn't
379 + # re-run the pull request's CI, but a moved base does move it.
380 + remote = run(["git", "rev-parse", "--verify", "--quiet", f"refs/remotes/origin/{branch}"], check=False)
381 + same = remote.returncode == 0 and run(
382 + ["git", "diff", "--quiet", f"origin/{branch}", "HEAD"], check=False
383 + ).returncode == 0
384 + if not same:
385 + run(["git", "push", "--force", "origin", f"HEAD:refs/heads/{branch}"])
386 +
387 + if existing:
388 + forge.call("PATCH", f"/pulls/{existing['number']}", {"title": title, "body": body})
389 + print(f"{human}: refreshed #{existing['number']}" + ("" if not same else " (no new changes)"))
390 + else:
391 + created = forge.call("POST", "/pulls", {"title": title, "body": body, "head": branch, "base": base})
392 + print(f"{human}: opened #{created['pull']['number']}")
393 +
394 + run(["git", "checkout", "--force", base])
395 +
396 + return 0
397 +
398 +
399 +if __name__ == "__main__":
400 + 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 .forgejo/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: 700d177