CI: audit mix.lock against OSV, as a GitLab job

13ec976 · Gabriel Morell · 2026-10-23 14:27

3 files +151 -0
Message
{commit_body(@commit)}

Files changed

modified .gitlab-ci.yml
+74 −0
@@ -18,6 +18,10 @@
18 18 entrypoint: [""]
19 19 only:
20 20 - master
21 + # Not on the nightly audit's schedule: that pipeline runs on master too,
22 + # and would otherwise rebuild and republish both images every night.
23 + except:
24 + - schedules
21 25 script:
22 26 - |
23 27 echo "{\"auths\":{\"$CI_REGISTRY\":{\"username\":\"$CI_REGISTRY_USER\",\"password\":\"$CI_REGISTRY_PASSWORD\"}}}" > /kaniko/.docker/config.json
@@ -56,3 +60,73 @@ build-kubernetes:
56 60 extends: .build
57 61 variables:
58 62 TLS_MODE: kubernetes
63 +
64 +# Known vulnerabilities in what the ircd is built from: its mix.lock
65 +# checked against the OSV database (https://osv.dev). A finding fails the
66 +# job, which is how it gets noticed. Nightly, since advisories land
67 +# without anyone pushing — from a pipeline schedule on master, which has
68 +# to be created by hand (Build > Pipeline schedules; any time works, the
69 +# Forgejo repos use "17 6 * * *") — and from "Run pipeline" on any branch.
70 +#
71 +# On a merge request, audit-mr compares the lockfiles the merge request
72 +# changes with the same files at its merge base: it fails only on a
73 +# vulnerability the merge request brings in, and says what it fixed and
74 +# what's still there (scripts/osv-diff). A security update goes green for
75 +# what it fixes, not red for what it couldn't.
76 +
77 +.audit:
78 + stage: test
79 + needs: []
80 + image: python:3.13-trixie
81 + # Pinned, and checked against the release's published checksum before
82 + # it runs: a scanner is a strange thing to take on trust. To move it
83 + # on, change both together, from
84 + # https://github.com/google/osv-scanner/releases (…_SHA256SUMS).
85 + variables:
86 + OSV_VERSION: v2.6.0
87 + OSV_SHA256: ca69b3d3cd08f889a49dc0a383122f71cc528b83803671df5fd874d97485b108
88 + before_script:
89 + - |
90 + curl -sSfL -o /tmp/osv-scanner \
91 + "https://github.com/google/osv-scanner/releases/download/${OSV_VERSION}/osv-scanner_linux_amd64"
92 + echo "${OSV_SHA256} /tmp/osv-scanner" | sha256sum -c -
93 + chmod +x /tmp/osv-scanner
94 +
95 +# Exits 1 when anything is vulnerable, so the job fails; the table names
96 +# each package, its version, and the version that fixes it.
97 +audit:
98 + extends: .audit
99 + only:
100 + - schedules
101 + - web
102 + script:
103 + - /tmp/osv-scanner scan source --recursive .
104 +
105 +audit-mr:
106 + extends: .audit
107 + only:
108 + - merge_requests
109 + variables:
110 + # The merge base has to be in the clone to read its lockfiles.
111 + GIT_DEPTH: "0"
112 + script:
113 + - |
114 + BASE="$CI_MERGE_REQUEST_DIFF_BASE_SHA"
115 + changed=$(git diff --name-only "$BASE" HEAD | grep -E '(^|/)mix\.lock$' || true)
116 + if [ -z "$changed" ]; then
117 + echo "No lockfile changed."
118 + exit 0
119 + fi
120 + mkdir -p /tmp/base
121 + for f in $changed; do
122 + mkdir -p "/tmp/base/$(dirname "$f")"
123 + git show "$BASE:$f" > "/tmp/base/$f" 2>/dev/null || true
124 + done
125 + # 0 and 1 (found some) are both answers; with nothing to scan, none.
126 + scan() {
127 + if [ $# -eq 0 ]; then echo '{}'; return; fi
128 + /tmp/osv-scanner scan source --format json $(printf -- '--lockfile %s ' "$@") || [ $? -eq 1 ]
129 + }
130 + (cd /tmp/base && scan $(for f in $changed; do [ -s "$f" ] && echo "$f"; done)) > /tmp/base.json
131 + scan $changed > /tmp/head.json
132 + 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 the audit-mr job in .gitlab-ci.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: 139e48c