CI: audit the lockfiles against OSV, and open security update PRs

a1e6e1c · Gabriel Morell · 2026-10-23 14:12

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

Files changed

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