Skip to content

Commit c40e32a

Browse files
committed
feat: block releases on HIGH/CRITICAL OSV advisories with override support
The release workflow's OSV scan previously used continue-on-error: true, so HIGH/CRITICAL advisories were noted but never blocked the release. - scripts/osv-release-gate.py: parses osv-result.json, classifies by CVSS score or database severity, blocks on HIGH/CRITICAL unless the advisory ID appears in .github/osv-overrides.json with a valid expiry. - .github/osv-overrides.json: empty allowlist for explicit overrides; each entry requires id, severity, rationale, owner, and expiry date. - .github/workflows/release.yml: added "Gate on HIGH/CRITICAL" step between OSV scan and summary generation. - docs/SECURITY.md: updated release-scan section to document the gate and override mechanism.
1 parent ddd16cc commit c40e32a

4 files changed

Lines changed: 161 additions & 6 deletions

File tree

.github/osv-overrides.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"_comment": "Explicit overrides for OSV advisories that should not block the release. Each entry must document why the advisory does not apply or is accepted.",
4+
"overrides": []
5+
}

.github/workflows/release.yml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,8 @@ jobs:
117117
run: ./gradlew :app:lintDebug
118118

119119
# ROADMAP matrix #9 + #44 — capture OSV scan status into the release body so each
120-
# tag carries a dated "OSV scan: ..." appendix. `continue-on-error: true` ensures
121-
# the release still ships if OSV reports something — the result is recorded either
122-
# way so users can reason about how stale / clean the dep tree was on release day.
120+
# tag carries a dated "OSV scan: ..." appendix. HIGH/CRITICAL advisories block
121+
# the release unless explicitly overridden in .github/osv-overrides.json.
123122
# See `docs/SECURITY.md` for the policy.
124123
- name: Generate Gradle dependency tree for OSV scan
125124
run: ./gradlew :app:dependencies --configuration releaseRuntimeClasspath > gradle-deps.txt
@@ -150,6 +149,9 @@ jobs:
150149
echo "osv-exit=$osv_exit" >> "$GITHUB_OUTPUT"
151150
set -e
152151
152+
- name: Gate on HIGH/CRITICAL advisories
153+
run: python3 scripts/osv-release-gate.py
154+
153155
- name: Summarise OSV result into release appendix
154156
run: |
155157
scan_date=$(date -u +"%Y-%m-%d")

docs/SECURITY.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,13 @@ and appended to the GitHub Release body. The result is reproducible — anyone c
114114
locally against the source tree at the matching tag and expect the same advisory set, modulo CVEs disclosed after the
115115
release date.
116116

117-
If the release-time scan finds a HIGH or CRITICAL vulnerability the workflow does **not** silently swallow it; the
118-
step is `continue-on-error: true` so the release proceeds, but the failure is recorded in the release body and
119-
visible at a glance.
117+
If the release-time scan finds a HIGH or CRITICAL advisory, `scripts/osv-release-gate.py` blocks the release. The
118+
gate parses `osv-result.json`, classifies each finding by CVSS score or database severity, and exits non-zero for any
119+
unoverridden HIGH/CRITICAL match. LOW and MEDIUM findings are summarized but non-blocking.
120+
121+
To override a blocking advisory (e.g. not reachable, awaiting upstream fix), add an entry to `.github/osv-overrides.json`
122+
with the advisory `id`, `severity`, `rationale`, `owner`, and `expiry` (YYYY-MM-DD). Expired overrides are ignored and
123+
produce a CI warning. The override file is committed and auditable.
120124

121125
### Provenance attestation (`.github/workflows/release.yml`)
122126

scripts/osv-release-gate.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
#!/usr/bin/env python3
2+
"""
3+
scripts/osv-release-gate.py
4+
5+
Release-time OSV severity gate. Parses osv-result.json and fails if any
6+
HIGH or CRITICAL advisory is present that is not explicitly overridden in
7+
.github/osv-overrides.json.
8+
9+
Exit codes:
10+
0 — no blocking findings (clean, or all high/critical overridden)
11+
1 — at least one high/critical advisory is not overridden; release blocked
12+
2 — osv-result.json missing or unparseable (scan did not complete)
13+
14+
Override file format (.github/osv-overrides.json):
15+
{
16+
"overrides": [
17+
{
18+
"id": "GHSA-xxxx-yyyy-zzzz",
19+
"severity": "HIGH",
20+
"rationale": "Not reachable: SwiftFloris never calls the affected API.",
21+
"owner": "matt_parker@outlook.com",
22+
"expiry": "2026-09-01"
23+
}
24+
]
25+
}
26+
"""
27+
28+
import json
29+
import sys
30+
from datetime import date
31+
from pathlib import Path
32+
33+
OSV_RESULT = Path("osv-result.json")
34+
OVERRIDES_FILE = Path(".github/osv-overrides.json")
35+
36+
BLOCKING_SEVERITIES = {"HIGH", "CRITICAL"}
37+
38+
39+
def load_overrides():
40+
if not OVERRIDES_FILE.exists():
41+
return {}
42+
data = json.loads(OVERRIDES_FILE.read_text())
43+
today = date.today().isoformat()
44+
active = {}
45+
for entry in data.get("overrides", []):
46+
advisory_id = entry.get("id", "")
47+
expiry = entry.get("expiry", "")
48+
if expiry and expiry < today:
49+
print(f"::warning::OSV override expired: {advisory_id} (expiry {expiry})")
50+
continue
51+
if not all(entry.get(k) for k in ("id", "severity", "rationale", "owner")):
52+
print(f"::warning::OSV override missing required fields: {entry}")
53+
continue
54+
active[advisory_id] = entry
55+
return active
56+
57+
58+
def extract_severity(vuln):
59+
for sv in vuln.get("severity", []):
60+
score_str = sv.get("score", "")
61+
if "CVSS" in sv.get("type", ""):
62+
try:
63+
score = float(score_str.split("/")[0].split(":")[-1])
64+
except (ValueError, IndexError):
65+
continue
66+
if score >= 9.0:
67+
return "CRITICAL"
68+
if score >= 7.0:
69+
return "HIGH"
70+
if score >= 4.0:
71+
return "MEDIUM"
72+
return "LOW"
73+
db_severity = vuln.get("database_specific", {}).get("severity", "").upper()
74+
if db_severity in ("CRITICAL", "HIGH", "MEDIUM", "LOW"):
75+
return db_severity
76+
return "UNKNOWN"
77+
78+
79+
def main():
80+
if not OSV_RESULT.exists() or OSV_RESULT.stat().st_size == 0:
81+
print("::warning::osv-result.json missing or empty — scan did not complete.")
82+
sys.exit(2)
83+
84+
try:
85+
data = json.loads(OSV_RESULT.read_text())
86+
except json.JSONDecodeError as e:
87+
print(f"::error::osv-result.json is not valid JSON: {e}")
88+
sys.exit(2)
89+
90+
overrides = load_overrides()
91+
92+
blocking = []
93+
non_blocking = []
94+
95+
for result in data.get("results", []):
96+
for pkg in result.get("packages", []):
97+
pkg_name = pkg.get("package", {}).get("name", "unknown")
98+
for vuln in pkg.get("vulnerabilities", []):
99+
vuln_id = vuln.get("id", "unknown")
100+
severity = extract_severity(vuln)
101+
summary = vuln.get("summary", "")[:120]
102+
aliases = vuln.get("aliases", [])
103+
104+
all_ids = {vuln_id} | set(aliases)
105+
overridden = any(aid in overrides for aid in all_ids)
106+
107+
entry = {
108+
"id": vuln_id,
109+
"severity": severity,
110+
"package": pkg_name,
111+
"summary": summary,
112+
"overridden": overridden,
113+
}
114+
115+
if severity in BLOCKING_SEVERITIES and not overridden:
116+
blocking.append(entry)
117+
else:
118+
non_blocking.append(entry)
119+
120+
if non_blocking:
121+
print(f"Non-blocking findings ({len(non_blocking)}):")
122+
for e in non_blocking:
123+
status = " [overridden]" if e["overridden"] else ""
124+
print(f" {e['severity']:8s} {e['id']} ({e['package']}){status}")
125+
126+
if blocking:
127+
print(f"\n::error::Release blocked by {len(blocking)} HIGH/CRITICAL advisory(ies):")
128+
for e in blocking:
129+
print(f" {e['severity']:8s} {e['id']} ({e['package']}): {e['summary']}")
130+
print(
131+
"\nTo override, add entries to .github/osv-overrides.json with:"
132+
"\n id, severity, rationale, owner, expiry (YYYY-MM-DD)"
133+
)
134+
sys.exit(1)
135+
136+
total = len(blocking) + len(non_blocking)
137+
if total == 0:
138+
print("OSV release gate: PASS (0 advisories)")
139+
else:
140+
print(f"OSV release gate: PASS ({total} advisory(ies), none blocking)")
141+
142+
143+
if __name__ == "__main__":
144+
main()

0 commit comments

Comments
 (0)