advisory-scan: two more cases decided in pass 1, found by the first real /cve-check

1. Release-note resolution now covers vendor pages on the same repo. It required
   a github-advisories: source, so mailu's Roundcube CVEs — announced only on
   github.com/Mailu/Mailu/releases — went to pass 2 even though the answer was
   sitting in the release notes. mailu now reports 2 deterministically, matching
   what previously took an agent reading the notes.

2. 'All known fix versions predate the version we were on' is now 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; reporting them as 'could not judge' overstated the
   uncertainty. Recorded as outside-window with the naming tags as evidence.
   A fix landing ABOVE the window still stays indeterminate on purpose: that is
   an open vulnerability and must stay visible.

60 offline tests (was 58). discourse 140 / gitea 2 unchanged.
This commit is contained in:
autonomic-bot
2026-08-11 04:24:15 +00:00
parent b0bdce2c15
commit 18caf047bf
2 changed files with 75 additions and 10 deletions
+49 -9
View File
@@ -333,6 +333,19 @@ def _releases(owner: str, repo: str, max_pages: int = 4) -> list[tuple[str, str]
return out 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]: 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. """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 (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. 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 [] return []
owner, _, repo = source.split(":", 1)[1].partition("/") return [tag for tag, body in _releases(*ref) if cve in body]
return [tag for tag, body in _releases(owner, repo) if cve in body]
def advisory_text(ghsa: str, source: str | None = None) -> dict: 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 # 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 # 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. # failed to publish. Deterministic and citable — not a judgement call.
resolved_by_release = {} # A window is keyed by advisory-feed source; map it to its repo so a vendor page on the SAME
for cve in sorted(indeterminate - fixed_set): # 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] e = report["cves"][cve]
for src in e["sources"]: 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 continue
wf, wt = windows[src] wf, wt = windows[src] if src in windows else win_by_repo[ref]
kf, kt = _vkey(wf), _vkey(wt) kf, kt = _vkey(wf), _vkey(wt)
if not (kf and kt): if not (kf and kt):
continue 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: if hits:
fixed_set.add(cve) fixed_set.add(cve)
resolved_by_release[cve] = sorted(hits) 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}") f"{', '.join(sorted(hits))}) via {src}")
e["fix_versions_from_release_notes"] = sorted(hits) e["fix_versions_from_release_notes"] = sorted(hits)
break 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: if resolved_by_release:
report["resolved_by_release_notes"] = 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: for cve in indeterminate:
report["cves"][cve]["classification"] = "indeterminate: no fix version published" report["cves"][cve]["classification"] = "indeterminate: no fix version published"
unknown = [c for c in unknown if c not in fixed_set] unknown = [c for c in unknown if c not in fixed_set]
+26 -1
View File
@@ -455,13 +455,38 @@ class TestReleaseNoteResolution(unittest.TestCase):
self.assertEqual(rep["indeterminate"], []) self.assertEqual(rep["indeterminate"], [])
self.assertEqual(rep["resolved_by_release_notes"]["CVE-TBD"], ["7.4.5", "8.0.3"]) 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")])], 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"], v_from="7.4", v_to="8.10", urls=["https://github.com/redis/redis"],
releases={"CVE-TBD": ["6.2.19"]}) releases={"CVE-TBD": ["6.2.19"]})
self.assertEqual(rep["fixed_by_this_upgrade"], []) 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"]) 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): def test_release_evidence_is_recorded_for_audit(self):
rep = run_scan([gh("redis/redis", [adv("CVE-TBD", patched="TBD")])], 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"], v_from="7.4", v_to="8.10", urls=["https://github.com/redis/redis"],