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:
@@ -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]
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user