advisory-scan: stop cross-image and cross-scheme miscounting (discourse's false 133)
Operator disbelieved discourse's '133 CVEs fixed' — correctly. Two defects made it confidently wrong: 1. ONE WINDOW APPLIED TO EVERY IMAGE. The scan queries all source repos in the recipe's registry (app + redis/postgres/nginx sidecars) but judged them all with the APP's version window. 34 of the 133 were redis advisories, including CVE-2021-21309 — patched in redis 6.0.11 back in 2021 — scored as 'fixed by this upgrade' purely because 6.0.11 sits numerically inside discourse's 3.5.3 -> 2026.7.1 range. Only the PRIMARY app repo is now classified; other sources are reported as unclassified so they stay visible without inflating the count. 2. VERSION-SCHEME CHANGES BREAK ORDERING. discourse moved semver -> calver (3.5.3 -> 2026.7.1), so 2025.12.2 compares 'newer' than 3.5.3 while shipping earlier. Numeric comparison cannot order that. The scan now detects a leading- component jump >= 100, refuses to classify, and says so in the block: the count is '0 by refusal, not by evidence — read the vendor's release notes'. Refusing to answer beats answering wrongly: a fabricated 133 in a public security report is worse than an explicit 'cannot determine'. Verified after the fix: discourse 133 -> 0 (with the refusal caveat), gitea still exactly 2 (both criticals, patched 1.27.1), keycloak 7 all genuinely from keycloak/keycloak patched in 26.7.1, plausible 1. No other count changed.
This commit is contained in:
@@ -275,7 +275,27 @@ def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str) -
|
||||
|
||||
# Classify against the upgrade window when we know it: an advisory is "fixed by this upgrade"
|
||||
# when its patched version is newer than `from` and no newer than `to`.
|
||||
#
|
||||
# TWO HARD-WON CONSTRAINTS (2026-08-10, discourse reported a false 133):
|
||||
# a) The window belongs to ONE image. Advisories from OTHER repos in the registry (redis,
|
||||
# postgres, nginx sidecars) must NOT be judged by it — redis CVE-2021-21309, patched in
|
||||
# redis 6.0.11, scored as "fixed" because 6.0.11 sits numerically inside discourse's
|
||||
# 3.5.3 → 2026.7.1 window. Only the PRIMARY app repo is classified; every other source is
|
||||
# reported as unclassified so a human/agent still sees it but it never inflates the count.
|
||||
# b) A version-SCHEME change (semver → calver, 3.5.3 → 2026.7.1) makes numeric ordering
|
||||
# meaningless: 2025.12.2 compares "newer" than 3.5.3 while shipping earlier. When the
|
||||
# leading component jumps by more than SCHEME_JUMP we refuse to classify and say so,
|
||||
# rather than emitting a confident wrong number.
|
||||
kf, kt = _vkey(v_from), _vkey(v_to)
|
||||
SCHEME_JUMP = 100
|
||||
scheme_change = bool(kf and kt and abs(kt[0] - kf[0]) >= SCHEME_JUMP)
|
||||
primary = None
|
||||
if v_from or v_to:
|
||||
# the app repo = first github source in the registry (registry lists the app service first)
|
||||
primary = next((s["source"] for s in report["sources"]
|
||||
if s["source"].startswith("github-advisories:")), None)
|
||||
report["primary_source"] = primary
|
||||
report["scheme_change"] = scheme_change
|
||||
fixed, unknown = [], []
|
||||
for cve, e in report["cves"].items():
|
||||
# `patched_versions` is a RANGE EXPRESSION (">= 2.18.1"), not a bare version, and there may
|
||||
@@ -283,7 +303,14 @@ def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str) -
|
||||
# token and treat the advisory as fixed-by-this-upgrade if ANY of them lands in (from, to].
|
||||
cands = [_vkey(t) for t in re.findall(r"\d+(?:\.\d+)*", e.get("patched") or "")]
|
||||
kp = next((c for c in cands if kf and kt and kf < c <= kt), None) or (cands[0] if cands else ())
|
||||
if kf and kt and kp and kf < kp <= kt:
|
||||
from_primary = primary is not None and primary in e["sources"]
|
||||
if scheme_change:
|
||||
e["classification"] = "unclassified: version-scheme change, cannot order reliably"
|
||||
unknown.append(cve)
|
||||
elif not from_primary:
|
||||
e["classification"] = "unclassified: different image than the given version window"
|
||||
unknown.append(cve)
|
||||
elif kf and kt and kp and kf < kp <= kt:
|
||||
e["classification"] = "fixed-by-this-upgrade"
|
||||
fixed.append(cve)
|
||||
else:
|
||||
@@ -323,8 +350,13 @@ def markdown(rep: dict) -> str:
|
||||
f"{e.get('ghsa') or '-'} | {e['sources'][0]} |")
|
||||
else:
|
||||
L.append("\n**CVEs fixed by this upgrade: 0 identified by the deterministic scan.**")
|
||||
if rep.get("scheme_change"):
|
||||
L.append("\n⚠ **Version-scheme change detected** (e.g. semver → calver): numeric ordering is "
|
||||
"not meaningful across it, so NOTHING was auto-classified. The CVE count above is "
|
||||
"**0 by refusal, not by evidence** — read the vendor's release notes for this jump.")
|
||||
if rep["unclassified"]:
|
||||
L.append(f"\nSeen but not version-classified ({len(rep['unclassified'])}): "
|
||||
L.append(f"\nSeen but not version-classified ({len(rep['unclassified'])}) — includes advisories "
|
||||
f"from OTHER images in this recipe (sidecars), which this window cannot judge: "
|
||||
+ ", ".join(rep["unclassified"][:12]))
|
||||
if rep["sources_failed"]:
|
||||
L.append(f"\n⚠ sources that FAILED (treat counts as incomplete): {', '.join(rep['sources_failed'])}")
|
||||
|
||||
Reference in New Issue
Block a user