neiam
/feedpug
feedpub
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
ci: audit dependencies against OSV, and open security update PRs
97afdce · Gabriel Morell · 2026-10-23 04:29
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