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
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]