diff --git a/cc-ci-plan/advisory-scan.py b/cc-ci-plan/advisory-scan.py index 1c9697a..622f741 100755 --- a/cc-ci-plan/advisory-scan.py +++ b/cc-ci-plan/advisory-scan.py @@ -333,6 +333,19 @@ def _releases(owner: str, repo: str, max_pages: int = 4) -> list[tuple[str, str] return out +def _source_repo(source: str) -> tuple[str, str] | None: + """(owner, repo) for a source, whether it is an advisory feed or a vendor page on GitHub. + + A CVE that appears ONLY on a vendor page still deserves the release-note method when that page + lives on GitHub — mailu announces its Roundcube CVEs on github.com/Mailu/Mailu/releases and + nowhere structured, so requiring an advisory feed sent a deterministic case to pass 2.""" + if source.startswith("github-advisories:"): + owner, _, repo = source.split(":", 1)[1].partition("/") + return (owner, repo) if owner and repo else None + m = re.match(r"https?://github\.com/([^/]+)/([^/#?]+)", source) + return (m.group(1), m.group(2).removesuffix(".git")) if m else None + + def release_fix_versions(source: str, cve: str) -> list[str]: """Release tags whose notes NAME this CVE — a deterministic fix version when the advisory has none. @@ -342,10 +355,10 @@ def release_fix_versions(source: str, cve: str) -> list[str]: (CVE-2025-32023 → 6.2.19, 7.2.10, 7.4.5, 8.0.3, 8.2.0). Ignoring that evidence undercounted discourse by 12 CVEs, so this is checked BEFORE giving up on an advisory. """ - if not source.startswith("github-advisories:"): + ref = _source_repo(source) + if not ref: return [] - owner, _, repo = source.split(":", 1)[1].partition("/") - return [tag for tag, body in _releases(owner, repo) if cve in body] + return [tag for tag, body in _releases(*ref) if cve in body] def advisory_text(ghsa: str, source: str | None = None) -> dict: @@ -707,17 +720,31 @@ def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str, # THIRD METHOD: before declaring an advisory undecidable, look for the CVE id in the project's # own release notes. A tag that names it, inside the window, IS the fix version the advisory # failed to publish. Deterministic and citable — not a judgement call. - resolved_by_release = {} - for cve in sorted(indeterminate - fixed_set): + # A window is keyed by advisory-feed source; map it to its repo so a vendor page on the SAME + # repo can be judged by the same window. + win_by_repo = {} + for wsrc, wv in windows.items(): + ref = _source_repo(wsrc) + if ref: + win_by_repo[ref] = wv + resolved_by_release, already_fixed = {}, set() + candidates = set(indeterminate) | { + c for c in unknown + if not any(s in windows for s in report["cves"][c]["sources"]) + and any(_source_repo(s) in win_by_repo for s in report["cves"][c]["sources"]) + } + for cve in sorted(candidates - fixed_set): e = report["cves"][cve] for src in e["sources"]: - if src not in windows: + ref = _source_repo(src) + if src not in windows and ref not in win_by_repo: continue - wf, wt = windows[src] + wf, wt = windows[src] if src in windows else win_by_repo[ref] kf, kt = _vkey(wf), _vkey(wt) if not (kf and kt): continue - hits = [t for t in release_fix_versions(src, cve) if _within(kf, kt, _vkey(t))] + naming = release_fix_versions(src, cve) + hits = [t for t in naming if _within(kf, kt, _vkey(t))] if hits: fixed_set.add(cve) resolved_by_release[cve] = sorted(hits) @@ -725,10 +752,23 @@ def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str, f"{', '.join(sorted(hits))}) via {src}") e["fix_versions_from_release_notes"] = sorted(hits) break + # Naming releases exist but ALL predate the version we were already on: the fix shipped + # before this upgrade, so the upgrade did not deliver it. That is a DECISION, not an + # unknown — mailu's redis 8.8.0 → 8.10.0 crosses 12 advisories all fixed by 8.6.3 or + # earlier, and reporting them as "could not judge" overstates the uncertainty. + if naming and all(_vkey(t) and not _within(kf, kt, _vkey(t)) for t in naming) \ + and max(_vkey(t) for t in naming if _vkey(t)) <= kf: + e["classification"] = ("outside-window: fixed in " + f"{', '.join(sorted(naming))}, all at or before {wf}") + e["fix_versions_from_release_notes"] = sorted(naming) + already_fixed.add(cve) + break if resolved_by_release: report["resolved_by_release_notes"] = resolved_by_release - indeterminate -= fixed_set + indeterminate -= fixed_set | already_fixed + if already_fixed: + report["already_fixed_before_upgrade"] = sorted(already_fixed) for cve in indeterminate: report["cves"][cve]["classification"] = "indeterminate: no fix version published" unknown = [c for c in unknown if c not in fixed_set] diff --git a/cc-ci-plan/test-advisory-scan.py b/cc-ci-plan/test-advisory-scan.py index 65b1fc6..69e46c3 100755 --- a/cc-ci-plan/test-advisory-scan.py +++ b/cc-ci-plan/test-advisory-scan.py @@ -455,13 +455,38 @@ class TestReleaseNoteResolution(unittest.TestCase): self.assertEqual(rep["indeterminate"], []) self.assertEqual(rep["resolved_by_release_notes"]["CVE-TBD"], ["7.4.5", "8.0.3"]) - def test_release_naming_it_only_outside_the_window_stays_indeterminate(self): + def test_naming_releases_all_below_the_window_means_ALREADY_fixed(self): + # Every known fix predates the version we were already on, so this upgrade did not deliver + # it. That is a DECISION, not an unknown — mailu's redis 8.8.0 → 8.10.0 crosses 12 such + # advisories, and calling them "could not judge" overstates the uncertainty. rep = run_scan([gh("redis/redis", [adv("CVE-TBD", patched="TBD")])], v_from="7.4", v_to="8.10", urls=["https://github.com/redis/redis"], releases={"CVE-TBD": ["6.2.19"]}) self.assertEqual(rep["fixed_by_this_upgrade"], []) + self.assertEqual(rep["indeterminate"], []) + self.assertIn("CVE-TBD", rep["already_fixed_before_upgrade"]) + self.assertIn("outside-window", rep["cves"]["CVE-TBD"]["classification"]) + + def test_naming_releases_only_ABOVE_the_window_stays_indeterminate(self): + # The fix landed after our target, so we are still exposed. Deliberately NOT decided as a + # tidy "not fixed": it is an open vulnerability and must stay visible to the operator. + rep = run_scan([gh("redis/redis", [adv("CVE-TBD", patched="TBD")])], + v_from="7.4", v_to="8.10", urls=["https://github.com/redis/redis"], + releases={"CVE-TBD": ["9.0.0"]}) + self.assertEqual(rep["fixed_by_this_upgrade"], []) self.assertIn("CVE-TBD", rep["indeterminate"]) + def test_vendor_page_cve_on_the_same_repo_uses_release_notes(self): + # mailu announces its Roundcube CVEs only on github.com/Mailu/Mailu/releases. Requiring an + # advisory feed sent a deterministic case to pass 2; it is now decided in pass 1. + rep = run_scan([gh("Mailu/Mailu", [])], + [vendor("https://github.com/Mailu/Mailu/releases", ["CVE-2026-54432"])], + v_from="2024.06.55", v_to="2024.06.57", + urls=["https://github.com/Mailu/Mailu"], + releases={"CVE-2026-54432": ["2024.06.56"]}) + self.assertIn("CVE-2026-54432", rep["fixed_by_this_upgrade"]) + self.assertEqual(rep["cve_count_fixed"], 1) + def test_release_evidence_is_recorded_for_audit(self): rep = run_scan([gh("redis/redis", [adv("CVE-TBD", patched="TBD")])], v_from="7.4", v_to="8.10", urls=["https://github.com/redis/redis"],