Audit the lockfiles against OSV, and open security update PRs from CI

d3812d5 · Gabriel Morell · 2026-10-23 04:52

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

Files changed

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