From 1daf0fa616ce86a26294368c05063463a06c134b Mon Sep 17 00:00:00 2001 From: autonomic-bot Date: Mon, 10 Aug 2026 20:44:27 +0000 Subject: [PATCH] advisory-scan: stop cross-image and cross-scheme miscounting (discourse's false 133) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cc-ci-plan/advisory-scan.py | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/cc-ci-plan/advisory-scan.py b/cc-ci-plan/advisory-scan.py index 01cccd3..ebd4611 100755 --- a/cc-ci-plan/advisory-scan.py +++ b/cc-ci-plan/advisory-scan.py @@ -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'])}")