Audit the web lockfile against OSV

7a95154 · Gabriel Morell · 2026-10-23 04:16

5 files +159 -0
Message
{commit_body(@commit)}

Files changed

added .forgejo/workflows/audit.yml
+82 −0
@@ -0,0 +1,82 @@
1 +name: audit
2 +# Known vulnerabilities in what the web assets are built from: the
3 +# repository's one lockfile (web/package-lock.json, the Tailwind +
4 +# DaisyUI pipeline behind `feature = "rebuild-assets"`) checked against
5 +# the OSV database (https://osv.dev). A finding fails the run, which is
6 +# how it gets noticed — the CI page, the panel widget, `gg ci list`.
7 +# Nightly, since advisories land without anyone pushing, and by hand
8 +# from the Actions 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 crate's own dependencies aren't covered: this is a library, so
17 +# Cargo.lock is ignored rather than committed and there is nothing
18 +# fixed to scan — each consuming firmware resolves (and should audit)
19 +# its own.
20 +on:
21 + schedule:
22 + - cron: "17 6 * * *"
23 + workflow_dispatch:
24 + pull_request:
25 +
26 +permissions:
27 + contents: read
28 +
29 +jobs:
30 + osv:
31 + runs-on: self-hosted
32 + steps:
33 + - uses: actions/checkout@v4
34 + with:
35 + fetch-depth: 0
36 +
37 + # Pinned, and checked against the release's published checksum
38 + # before it runs: a scanner is a strange thing to take on trust.
39 + # To move it on, change both together, from
40 + # https://github.com/google/osv-scanner/releases (…_SHA256SUMS).
41 + - name: Fetch osv-scanner
42 + env:
43 + OSV_VERSION: v2.6.0
44 + OSV_SHA256: ca69b3d3cd08f889a49dc0a383122f71cc528b83803671df5fd874d97485b108
45 + run: |
46 + curl -sSfL -o /tmp/osv-scanner \
47 + "https://github.com/google/osv-scanner/releases/download/${OSV_VERSION}/osv-scanner_linux_amd64"
48 + echo "${OSV_SHA256} /tmp/osv-scanner" | sha256sum -c -
49 + chmod +x /tmp/osv-scanner
50 +
51 + # Exit 1 when anything is vulnerable, so the run fails; the table
52 + # names each package, its version, and the version that fixes it.
53 + # Named, not --recursive: a recursive walk would also read
54 + # web/node_modules or target/ if a cached checkout ever had them.
55 + - name: Scan the lockfiles
56 + if: github.event_name != 'pull_request'
57 + run: /tmp/osv-scanner scan source --lockfile web/package-lock.json
58 +
59 + - name: Scan the lockfiles this pull request changes
60 + if: github.event_name == 'pull_request'
61 + env:
62 + BASE: ${{ github.base_ref }}
63 + run: |
64 + git fetch --quiet origin "$BASE"
65 + changed=$(git diff --name-only "origin/$BASE"...HEAD | grep -E '(^|/)package-lock\.json$' || true)
66 + if [ -z "$changed" ]; then
67 + echo "No lockfile changed."
68 + exit 0
69 + fi
70 + mkdir -p /tmp/base
71 + for f in $changed; do
72 + mkdir -p "/tmp/base/$(dirname "$f")"
73 + git show "origin/$BASE:$f" > "/tmp/base/$f" 2>/dev/null || true
74 + done
75 + # 0 and 1 (found some) are both answers; with nothing to scan, none.
76 + scan() {
77 + if [ $# -eq 0 ]; then echo '{}'; return; fi
78 + /tmp/osv-scanner scan source --format json $(printf -- '--lockfile %s ' "$@") || [ $? -eq 1 ]
79 + }
80 + (cd /tmp/base && scan $(for f in $changed; do [ -s "$f" ] && echo "$f"; done)) > /tmp/base.json
81 + scan $changed > /tmp/head.json
82 + python3 scripts/osv-diff /tmp/base.json /tmp/head.json
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: e1ac5f6