ci: audit dependencies against OSV, and open security update PRs

97afdce · Gabriel Morell · 2026-10-23 04:29

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

Files changed

added .forgejo/workflows/audit.yml
+81 −0
@@ -0,0 +1,81 @@
1 +name: audit
2 +# Known vulnerabilities in what FeedPug is built from: mix.lock, its
3 +# only lockfile, checked against the OSV database (https://osv.dev). A
4 +# finding fails the run, which is how it gets noticed — the CI page,
5 +# the panel widget, `gg ci list`. Nightly, since advisories land
6 +# without anyone pushing, and by 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 +on:
18 + schedule:
19 + - cron: "41 6 * * *"
20 + workflow_dispatch:
21 + pull_request:
22 +
23 +permissions:
24 + contents: read
25 +
26 +jobs:
27 + osv:
28 + runs-on: self-hosted
29 + steps:
30 + - uses: actions/checkout@v4
31 + with:
32 + fetch-depth: 0
33 +
34 + # Pinned, and checked against the release's published checksum
35 + # before it runs: a scanner is a strange thing to take on trust.
36 + # To move it on, change both together, from
37 + # https://github.com/google/osv-scanner/releases (…_SHA256SUMS).
38 + - name: Fetch osv-scanner
39 + env:
40 + OSV_VERSION: v2.6.0
41 + OSV_SHA256: ca69b3d3cd08f889a49dc0a383122f71cc528b83803671df5fd874d97485b108
42 + run: |
43 + curl -sSfL -o /tmp/osv-scanner \
44 + "https://github.com/google/osv-scanner/releases/download/${OSV_VERSION}/osv-scanner_linux_amd64"
45 + echo "${OSV_SHA256} /tmp/osv-scanner" | sha256sum -c -
46 + chmod +x /tmp/osv-scanner
47 +
48 + # Exit 1 when anything is vulnerable, so the run fails; the table
49 + # names each package, its version, and the version that fixes it.
50 + # Only the lockfiles git tracks: not a deps/ or _build/ a
51 + # persistent runner may have lying around.
52 + - name: Scan the lockfiles
53 + if: github.event_name != 'pull_request'
54 + run: |
55 + /tmp/osv-scanner scan source \
56 + $(git ls-files | grep -E '(^|/)(mix|Cargo)\.lock$' | sed 's/^/--lockfile /')
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 .forgejo/workflows/dependency-updates.yml
+72 −0
@@ -0,0 +1,72 @@
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-hex, mix.lock being FeedPug's only lockfile — refreshed
6 +# in place, closing it once there's nothing left to fix. The pull
7 +# request is opened by the forge's CI account (gg-security) with this
8 +# job's own token; its CI (audit.yml, and ci.yml's tests) is what shows
9 +# the fixes landed and nothing broke.
10 +#
11 +# Weekly, and by hand from the Actions page (`gg ci dispatch
12 +# dependency-updates`).
13 +on:
14 + schedule:
15 + - cron: "47 5 * * 1"
16 + workflow_dispatch:
17 +
18 +permissions:
19 + contents: write # push the deps/security-* 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-* branch too, to tell whether a refresh
29 + # changed anything before pushing it.
30 + fetch-depth: 0
31 +
32 + # The same toolchain the Dockerfile builds with: a lockfile is only
33 + # as good as the resolver that wrote it.
34 + # OTP and Elixir from builds.hex.pm, the builds setup-beam would
35 + # fetch — taken directly, since setup-beam chooses one by ImageOS,
36 + # which this runner sets to "selfhosted" over anything a workflow
37 + # says.
38 + - name: OTP and Elixir
39 + env:
40 + OTP_VERSION: "28.4.1"
41 + ELIXIR_VERSION: "1.20.1"
42 + run: |
43 + ubuntu=$(. /etc/os-release && echo "$VERSION_ID")
44 + curl -sSfL "https://builds.hex.pm/builds/otp/ubuntu-${ubuntu}/OTP-${OTP_VERSION}.tar.gz" | tar xz -C /tmp
45 + # Installed where it's unpacked: Install's root is its own directory.
46 + (cd "/tmp/OTP-${OTP_VERSION}" && ./Install -minimal "/tmp/OTP-${OTP_VERSION}" > /dev/null)
47 + curl -sSfL -o /tmp/elixir.zip "https://builds.hex.pm/builds/elixir/v${ELIXIR_VERSION}-otp-${OTP_VERSION%%.*}.zip"
48 + unzip -q -d /tmp/elixir /tmp/elixir.zip
49 + echo "/tmp/OTP-${OTP_VERSION}/bin" >> "$GITHUB_PATH"
50 + echo /tmp/elixir/bin >> "$GITHUB_PATH"
51 +
52 + # deps.update only resolves and fetches; nothing is compiled, so
53 + # no build tools beyond mix and Hex.
54 + - name: Hex
55 + run: mix local.hex --force
56 +
57 + # Pinned and checked, as in audit.yml; bump both together.
58 + - name: Fetch osv-scanner
59 + env:
60 + OSV_VERSION: v2.6.0
61 + OSV_SHA256: ca69b3d3cd08f889a49dc0a383122f71cc528b83803671df5fd874d97485b108
62 + run: |
63 + curl -sSfL -o /tmp/osv-scanner \
64 + "https://github.com/google/osv-scanner/releases/download/${OSV_VERSION}/osv-scanner_linux_amd64"
65 + echo "${OSV_SHA256} /tmp/osv-scanner" | sha256sum -c -
66 + chmod +x /tmp/osv-scanner
67 +
68 + - name: Update
69 + env:
70 + OSV_SCANNER: /tmp/osv-scanner
71 + GITHUB_TOKEN: ${{ github.token }}
72 + 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 +FeedPug has only mix.lock today; the crates.io side does nothing until
14 +a Cargo.lock is tracked, and needs cargo on the runner then.
15 +
16 +The branch is force-pushed and its pull request refreshed in place, so
17 +there is never a pile of them; once an ecosystem has nothing left to
18 +fix, its pull request is closed. A branch someone else has pushed to —
19 +a person fixing what a bump broke — is left alone: their commits aren't
20 +the bot's to overwrite. Packages it can't fix — no fixed
21 +version yet, or one the manifest's constraint won't reach — are listed
22 +in the pull request, not touched.
23 +
24 +Needs: osv-scanner (OSV_SCANNER, default `osv-scanner`), mix and/or
25 +cargo for the ecosystems present, git with push access, and in CI the
26 +job's token (GITHUB_TOKEN) with `contents: write` and `pull-requests:
27 +write`. Everything else comes from the job's environment
28 +(GITHUB_SERVER_URL, GITHUB_REPOSITORY, GITHUB_RUN_NUMBER).
29 +
30 + scripts/dependency-update do it
31 + scripts/dependency-update --dry-run show what it would bump, change nothing
32 +
33 +Standard library only, so it runs wherever python3 does.
34 +"""
35 +
36 +import argparse
37 +import json
38 +import os
39 +import re
40 +import subprocess
41 +import sys
42 +import urllib.error
43 +import urllib.request
44 +
45 +ECOSYSTEMS = {
46 + # OSV ecosystem → (branch suffix, lockfile name, human name)
47 + "Hex": ("hex", "mix.lock", "Hex (mix)"),
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 + # On a fresh checkout mix knows only the deps it can see in
189 + # mix.exs and in deps/: naming a purely transitive one (decimal,
190 + # under ecto and postgrex) fails with "Unknown dependency" until
191 + # the tree has been fetched once.
192 + out = run(["mix", "deps.get"], cwd=root, check=False)
193 + if out.returncode != 0:
194 + return [], left + [dict(e, reason=f"mix deps.get failed: {out.stderr.strip()[-200:]}") for e in fixable], []
195 + # deps.update takes them as far as mix.exs allows, which may be
196 + # past the fix; checked against the target below.
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: 863808d