advisory-scan: tests, audit, and two real undercounts they found
Adds test-advisory-scan.py (58 offline tests on fixtures + 6 live regressions against the week-2026-08-07 report) and audit-advisory-scan.py, which re-derives every count with a SEPARATE semver implementation and its own release fetch and diffs against the scanner. Both found real defects: 1. Window membership was compared on ragged tuples, so (18,) < (18,0) — a CVE patched in 18.0 fell OUTSIDE a window ending at 18. Bare major tags are the norm for sidecars (postgres:18, redis:8-alpine). Now zero-padded, which also keeps the upper bound conservative (18.5 stays out of a window ending at 18). 2. Advisories with no knowable fix version were silently counted as 'not fixed'. Twelve redis advisories say patched_versions 'TBD' or '7.4.X' with an open-ended range — six of them high severity. They are now INDETERMINATE: not counted, not dismissed, and surfaced in the output. All twelve turned out to be genuinely fixed: redis names each in the release notes of every branch that got the fix (CVE-2025-32023 -> 6.2.19, 7.2.10, 7.4.5, 8.0.3, 8.2.0). So a third deterministic method resolves them from release notes, with the naming tags recorded as the citation. discourse's redis contribution goes 5 -> 17, and its total 128 -> 140. Pass 2 (--adjudicate) is the model-judged stage for what arithmetic cannot settle: it hands over each open case's full evidence, plus every verdict pass 1 reached, and takes FIXED/NOT-FIXED/STILL-UNKNOWN with a reason citing that evidence. It may only raise a count. Vendor-page-only CVEs — the shape of both gitea CVSS-9.8 RCEs — now reach it instead of being dropped. Tests cover pass 1 only, by design; pass 2's judgement is a model's. What is tested there is deterministic: which cases it selects, and that truncation is announced rather than silent. SPEC.md rewritten around the two passes.
This commit is contained in:
+342
-12
@@ -53,6 +53,12 @@ TIMEOUT = int(os.environ.get("ADVISORY_SCAN_TIMEOUT", "45"))
|
||||
CVE_RE = re.compile(r"CVE-\d{4}-\d{4,7}")
|
||||
# Leading-version-component jump that means the scheme changed (semver → calver).
|
||||
SCHEME_JUMP = 100
|
||||
# A patched_versions field that names no usable version. GitHub carries these verbatim from the
|
||||
# vendor: redis publishes "TBD" for 11 advisories and "6.2.X, 7.2.X, 7.4.X" for another, and their
|
||||
# vulnerable_version_range is open-ended ("All", ">= 7.0.0"), so the fix version is NOT recoverable.
|
||||
# Such an advisory must be reported as INDETERMINATE, never silently counted as "not fixed" — that
|
||||
# would assert an upgrade did not fix something we simply cannot judge.
|
||||
PLACEHOLDER_RE = re.compile(r"\bTBD\b|\bunknown\b|\bnone\b|\d+\.[Xx]\b|\?", re.I)
|
||||
|
||||
# Optional OSV mappings: recipe -> (ecosystem, package). Supplementary only (see module docstring).
|
||||
OSV_PACKAGES: dict[str, tuple[str, str]] = {
|
||||
@@ -158,6 +164,21 @@ def _vkey(v: str | None) -> tuple:
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def _within(kf: tuple, kt: tuple, c: tuple) -> bool:
|
||||
"""Is patched-version `c` inside the window (kf, kt] — exclusive lower, inclusive upper?
|
||||
|
||||
Compares ZERO-PADDED to equal length, so "18" == "18.0" == "18.0.0" the way semver means it.
|
||||
Without the padding, plain tuple order says (18,) < (18,0), i.e. a CVE patched in 18.0 falls
|
||||
OUTSIDE a window ending at 18 — and bare major tags are the norm for sidecars (postgres:18,
|
||||
redis:8-alpine), so that silently dropped real fixes. Padding also keeps the upper bound
|
||||
conservative: a fix in 18.5 is still outside a window ending at "18", because nothing proves
|
||||
which 18.x a floating tag resolved to.
|
||||
"""
|
||||
n = max(len(kf), len(kt), len(c))
|
||||
pad = lambda t: t + (0,) * (n - len(t))
|
||||
return pad(kf) < pad(c) <= pad(kt)
|
||||
|
||||
|
||||
def registry_urls(recipe: str, registry_dir: str) -> tuple[list[str], str | None]:
|
||||
path = os.path.join(registry_dir, f"{recipe}.md")
|
||||
try:
|
||||
@@ -219,6 +240,10 @@ def github_advisories(urls: list[str]) -> list[dict]:
|
||||
) or None,
|
||||
"url": a.get("html_url"),
|
||||
"published_at": a.get("published_at"),
|
||||
# The list response ALREADY carries the prose. Keep it: the adjudication
|
||||
# pass needs it, and re-fetching per advisory costs a request each.
|
||||
"description": (a.get("description") or "")[:4000],
|
||||
"cvss": ((a.get("cvss") or {}).get("vector_string")),
|
||||
}
|
||||
)
|
||||
except urllib.error.HTTPError as e:
|
||||
@@ -283,6 +308,244 @@ def osv(recipe: str, version: str | None) -> dict | None:
|
||||
return entry
|
||||
|
||||
|
||||
_RELEASE_CACHE: dict[str, list[tuple[str, str]]] = {}
|
||||
|
||||
|
||||
def _releases(owner: str, repo: str, max_pages: int = 4) -> list[tuple[str, str]]:
|
||||
"""[(tag, body)] for a repo's GitHub releases, cached per repo for the process."""
|
||||
key = f"{owner}/{repo}"
|
||||
if key in _RELEASE_CACHE:
|
||||
return _RELEASE_CACHE[key]
|
||||
hdrs = {"Accept": "application/vnd.github+json"}
|
||||
tok = _github_token()
|
||||
if tok:
|
||||
hdrs["Authorization"] = f"Bearer {tok}"
|
||||
out: list[tuple[str, str]] = []
|
||||
try:
|
||||
for rel in _gh_paginate(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/releases?per_page=100", hdrs, max_pages
|
||||
):
|
||||
out.append((rel.get("tag_name") or "",
|
||||
f"{rel.get('name') or ''}\n{rel.get('body') or ''}"))
|
||||
except Exception: # noqa: BLE001 — best effort; absence just leaves advisories undetermined
|
||||
pass
|
||||
_RELEASE_CACHE[key] = out
|
||||
return out
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Vendors routinely publish an advisory with `patched_versions: "TBD"` and then name the CVE in the
|
||||
release notes of every branch that got the fix. redis does exactly this: all 12 of its advisories
|
||||
that discourse's redis bump crosses carry TBD, yet each is named in concrete releases
|
||||
(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:"):
|
||||
return []
|
||||
owner, _, repo = source.split(":", 1)[1].partition("/")
|
||||
return [tag for tag, body in _releases(owner, repo) if cve in body]
|
||||
|
||||
|
||||
def advisory_text(ghsa: str, source: str | None = None) -> dict:
|
||||
"""Full text of one advisory, for the ADJUDICATION pass (see adjudication_block).
|
||||
|
||||
The structured `patched_versions` field is often "TBD" while the prose description and the
|
||||
linked references DO state where the fix landed. That prose is not machine-parseable in general
|
||||
— which is the point: it is collected here for a MODEL to judge, not for a regex."""
|
||||
hdrs = {"Accept": "application/vnd.github+json"}
|
||||
tok = _github_token()
|
||||
if tok:
|
||||
hdrs["Authorization"] = f"Bearer {tok}"
|
||||
out = {"ghsa": ghsa, "status": "ok"}
|
||||
# Repo-scoped FIRST. Many repository advisories are never mirrored into the global GitHub
|
||||
# Advisory Database, so /advisories/<ghsa> 404s for them (all 12 redis ones, for instance)
|
||||
# while /repos/<owner>/<repo>/security-advisories/<ghsa> returns the full record.
|
||||
cands = []
|
||||
if source and source.startswith("github-advisories:"):
|
||||
cands.append(f"https://api.github.com/repos/{source.split(':',1)[1]}/security-advisories/{ghsa}")
|
||||
cands.append(f"https://api.github.com/advisories/{ghsa}")
|
||||
try:
|
||||
a, last = None, None
|
||||
for u in cands:
|
||||
try:
|
||||
a = json.loads(_fetch(u, hdrs)); break
|
||||
except Exception as ex: # noqa: BLE001 — try the next endpoint
|
||||
last = ex
|
||||
if a is None:
|
||||
raise last or RuntimeError("no advisory endpoint responded")
|
||||
out.update({
|
||||
"summary": a.get("summary"),
|
||||
"description": (a.get("description") or "")[:4000],
|
||||
"severity": a.get("severity"),
|
||||
"published_at": a.get("published_at"),
|
||||
"references": [r for r in (a.get("references") or [])][:12] or (
|
||||
[a.get("html_url")] if a.get("html_url") else []),
|
||||
"cvss": (a.get("cvss") or {}).get("vector_string"),
|
||||
"vulnerabilities": [
|
||||
{"package": (v.get("package") or {}).get("name"),
|
||||
"vulnerable_version_range": v.get("vulnerable_version_range"),
|
||||
"first_patched_version": v.get("first_patched_version")}
|
||||
for v in (a.get("vulnerabilities") or [])
|
||||
],
|
||||
})
|
||||
except Exception as e: # noqa: BLE001
|
||||
out["status"] = f"error: {type(e).__name__}: {e}"
|
||||
return out
|
||||
|
||||
|
||||
MAX_ADJUDICATE = int(os.environ.get("ADVISORY_SCAN_MAX_ADJUDICATE", "25"))
|
||||
# Compact review rows for advisories pass 1 DID decide. Pass 2 sees these too, so a wrong
|
||||
# deterministic verdict can be caught rather than inherited.
|
||||
MAX_REVIEW_ROWS = int(os.environ.get("ADVISORY_SCAN_MAX_REVIEW", "400"))
|
||||
|
||||
|
||||
def needs_judgement(rep: dict) -> list[str]:
|
||||
"""CVEs the deterministic pass could not decide — the input set for the adjudication pass.
|
||||
|
||||
Two kinds, both real gaps rather than noise:
|
||||
1. INDETERMINATE — from an image WITH a window, but no fix version is knowable (advisory says
|
||||
`TBD`/`7.4.X`, range is open-ended, and no release note names it).
|
||||
2. VENDOR-PAGE-ONLY — a CVE seen only on a vendor security page, with no structured advisory
|
||||
behind it at all. gitea's two CVSS-9.8 RCEs are this shape. They carry no version data, so
|
||||
no arithmetic can place them, but the page's prose usually states the fixed release.
|
||||
"""
|
||||
windows = rep.get("windows") or {}
|
||||
out = list(rep.get("indeterminate") or [])
|
||||
for cve in rep.get("unclassified") or []:
|
||||
srcs = rep["cves"][cve]["sources"]
|
||||
if not any(s.startswith("github-advisories:") for s in srcs) and cve not in out:
|
||||
out.append(cve)
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def evidence_bundle(rep: dict, cve: str) -> dict:
|
||||
"""EVERY deterministic signal held about one CVE, gathered for a model to weigh.
|
||||
|
||||
Pass 1 collects; pass 2 judges. Nothing here interprets — it assembles what was measured, so the
|
||||
judgement is made against evidence rather than recollection (the exact failure that let two
|
||||
CVSS-9.8 gitea RCEs be published as "none": the report leaned on model knowledge that predated
|
||||
them, and no source had been queried at all).
|
||||
"""
|
||||
e = rep["cves"][cve]
|
||||
src = e["sources"][0]
|
||||
windows = rep.get("windows") or {}
|
||||
win = next((windows[s] for s in e["sources"] if s in windows), None)
|
||||
ev = {
|
||||
"cve": cve,
|
||||
"severity": e.get("severity"),
|
||||
"cvss": e.get("cvss"),
|
||||
"sources": e["sources"],
|
||||
"window": win,
|
||||
"why_undecided": ("no fix version published and no release note names it"
|
||||
if cve in (rep.get("indeterminate") or [])
|
||||
else "seen only on a vendor page — no structured advisory, no version data"),
|
||||
"patched_as_published": e.get("patched"),
|
||||
"vulnerable_range_as_published": e.get("vulnerable_range"),
|
||||
"summary": e.get("context"),
|
||||
"description": e.get("description"),
|
||||
"advisory_url": e.get("url"),
|
||||
# Release tags NAMING this CVE, whether or not they fall in the window — the model may
|
||||
# reason about branch lines the arithmetic deliberately would not.
|
||||
"releases_naming_it": release_fix_versions(src, cve) if src.startswith("github-advisories:") else [],
|
||||
"references": [],
|
||||
}
|
||||
if e.get("ghsa"):
|
||||
t = advisory_text(e["ghsa"], src)
|
||||
if t.get("status") == "ok":
|
||||
ev["references"] = t.get("references") or []
|
||||
ev["description"] = t.get("description") or ev["description"]
|
||||
ev["summary"] = t.get("summary") or ev["summary"]
|
||||
ev["affected"] = t.get("vulnerabilities") or []
|
||||
else:
|
||||
ev["detail_fetch"] = t.get("status")
|
||||
return ev
|
||||
|
||||
|
||||
def adjudication_block(rep: dict) -> str:
|
||||
"""SECOND PASS: present the collected evidence and ask for a judgement on each open case.
|
||||
|
||||
This block decides nothing. The deterministic count stands as a FLOOR; a verdict here may only
|
||||
ADD to it, matching the rule that this scan raises a count on evidence but never lowers one.
|
||||
"""
|
||||
todo = needs_judgement(rep)
|
||||
if not todo:
|
||||
return ""
|
||||
shown, dropped = todo[:MAX_ADJUDICATE], max(0, len(todo) - MAX_ADJUDICATE)
|
||||
L = ["", "---", "",
|
||||
f"## Adjudication pass — {len(todo)} advisory/advisories need judgement", "",
|
||||
"Pass 1 collected the evidence below deterministically and could NOT decide these cases. "
|
||||
"Weigh the evidence and decide each one.", "",
|
||||
"**For each: did the version move in its window fix it?** Answer **FIXED** / **NOT-FIXED** / "
|
||||
"**STILL-UNKNOWN**, each with a one-line reason **citing the evidence shown** — a fixed "
|
||||
"release named in the text, a branch line, an affected range. Add every FIXED to the "
|
||||
"recipe's CVE count; the deterministic number is a floor, not a total. If the evidence does "
|
||||
"not settle it, say STILL-UNKNOWN: do NOT infer from memory of the project, and never "
|
||||
"record an undecided CVE as unaffected.", ""]
|
||||
for src, win in (rep.get("windows") or {}).items():
|
||||
L.append(f"- window: `{src.split(':',1)[-1]}` {win['from']} → {win['to']}")
|
||||
if dropped:
|
||||
L += ["", f"⚠ Showing the first {MAX_ADJUDICATE} of {len(todo)}; **{dropped} not shown** "
|
||||
f"(raise ADVISORY_SCAN_MAX_ADJUDICATE). The unshown remain undetermined — do not "
|
||||
f"treat them as absent."]
|
||||
L.append("")
|
||||
for cve in shown:
|
||||
ev = evidence_bundle(rep, cve)
|
||||
L.append(f"### {cve} — {ev['severity'] or '?'}")
|
||||
L.append(f"- undecided because: {ev['why_undecided']}")
|
||||
L.append(f"- source: `{ev['sources'][0]}`"
|
||||
+ (f" · window {ev['window']['from']} → {ev['window']['to']}" if ev["window"] else
|
||||
" · **no version window** for this image"))
|
||||
L.append(f"- patched_versions as published: `{ev['patched_as_published']}`")
|
||||
L.append(f"- vulnerable_range as published: `{ev['vulnerable_range_as_published']}`")
|
||||
if ev["releases_naming_it"]:
|
||||
L.append(f"- **releases naming this CVE**: {', '.join(ev['releases_naming_it'][:14])}")
|
||||
for v in ev.get("affected") or []:
|
||||
L.append(f"- affects `{v.get('package')}` {v.get('vulnerable_version_range')} — "
|
||||
f"first_patched_version: {v.get('first_patched_version')}")
|
||||
if ev["references"]:
|
||||
L.append(f"- references: {', '.join(r.strip() for r in ev['references'][:6])}")
|
||||
if ev.get("detail_fetch"):
|
||||
L.append(f"- ⚠ detail fetch failed: {ev['detail_fetch']} (evidence below is from pass 1)")
|
||||
if ev["summary"]:
|
||||
L += ["", f"> {ev['summary']}"]
|
||||
if ev["description"]:
|
||||
L += ["", "```", (ev["description"] or "").strip()[:2000], "```"]
|
||||
L.append("")
|
||||
|
||||
# ── everything pass 1 DID decide, with the evidence behind each verdict ──────────────────────
|
||||
# Pass 2 must see the whole picture, not only the leftovers: a deterministic verdict can still
|
||||
# be wrong (a mis-parsed range, a release note that names a CVE without fixing it), and only a
|
||||
# reader with the evidence in front of it can catch that.
|
||||
decided = []
|
||||
for cve in rep.get("fixed_by_this_upgrade") or []:
|
||||
e = rep["cves"][cve]
|
||||
decided.append((cve, "COUNTED", e))
|
||||
for cve, e in sorted(rep.get("cves", {}).items()):
|
||||
if e.get("classification") == "outside-window":
|
||||
decided.append((cve, "excluded (outside window)", e))
|
||||
if decided:
|
||||
shown_rows, dropped_rows = decided[:MAX_REVIEW_ROWS], max(0, len(decided) - MAX_REVIEW_ROWS)
|
||||
L += ["---", "",
|
||||
f"## Pass 1 decisions — {len(decided)} already judged deterministically", "",
|
||||
"Review these too. If any verdict looks wrong given its evidence, say so and explain; "
|
||||
"a correction here changes the count. Silence means you agree.", ""]
|
||||
if dropped_rows:
|
||||
L.append(f"⚠ Showing {MAX_REVIEW_ROWS} of {len(decided)}; **{dropped_rows} not shown** "
|
||||
f"(raise ADVISORY_SCAN_MAX_REVIEW).")
|
||||
L.append("")
|
||||
L += ["| CVE | verdict | severity | patched as published | releases naming it | source |",
|
||||
"|---|---|---|---|---|---|"]
|
||||
for cve, verdict, e in shown_rows:
|
||||
rel = e.get("fix_versions_from_release_notes") or []
|
||||
L.append(f"| {cve} | {verdict} | {e.get('severity') or '?'} | "
|
||||
f"{(e.get('patched') or '—')[:60]} | {', '.join(rel[:5]) or '—'} | "
|
||||
f"{e['sources'][0].split(':',1)[-1]} |")
|
||||
L.append("")
|
||||
return "\n".join(L)
|
||||
|
||||
|
||||
def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str,
|
||||
images: list[tuple[str, str, str]] | None = None) -> dict:
|
||||
urls, reg_path = registry_urls(recipe, registry_dir)
|
||||
@@ -303,7 +566,8 @@ def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str,
|
||||
def record(cve: str, src: str, **extra):
|
||||
e = report["cves"].setdefault(cve, {"sources": [], "severity": None, "ghsa": None,
|
||||
"vulnerable_range": None, "patched": None,
|
||||
"context": None, "published_at": None})
|
||||
"context": None, "published_at": None,
|
||||
"description": None, "url": None, "cvss": None})
|
||||
if src not in e["sources"]:
|
||||
e["sources"].append(src)
|
||||
for k, v in extra.items():
|
||||
@@ -317,7 +581,8 @@ def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str,
|
||||
if a.get("cve"):
|
||||
record(a["cve"], entry["source"], severity=a.get("severity"), ghsa=a.get("ghsa"),
|
||||
vulnerable_range=a.get("vulnerable_range"), patched=a.get("patched"),
|
||||
context=a.get("summary"), published_at=a.get("published_at"))
|
||||
context=a.get("summary"), published_at=a.get("published_at"),
|
||||
description=a.get("description"), url=a.get("url"), cvss=a.get("cvss"))
|
||||
|
||||
for entry in vendor_pages(urls):
|
||||
report["sources"].append({"source": entry["source"], "status": entry["status"],
|
||||
@@ -372,20 +637,28 @@ def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str,
|
||||
report["windows"] = {k: {"from": f, "to": t} for k, (f, t) in windows.items()}
|
||||
|
||||
def _classify_window(src, wf, wt):
|
||||
"""Return (set_of_fixed_cves, method, date_window|None, unresolved:boolean) for one source."""
|
||||
"""Return (fixed, method, date_window|None, unresolved, indeterminate) for one source.
|
||||
|
||||
`indeterminate` = advisories from this source that the method COULD NOT JUDGE (no usable
|
||||
patched version, or no publish date). They are neither counted nor dismissed."""
|
||||
kf, kt = _vkey(wf), _vkey(wt)
|
||||
# A version-SCHEME change (semver 3.5.3 → calver 2026.7.1) makes numeric ordering
|
||||
# meaningless: 2025.12.2 compares "newer" than 3.5.3 while shipping earlier.
|
||||
scheme = bool(kf and kt and abs(kt[0] - kf[0]) >= SCHEME_JUMP)
|
||||
if not scheme:
|
||||
got = set()
|
||||
got, undecidable = set(), set()
|
||||
for cve, e in report["cves"].items():
|
||||
if src not in e["sources"]:
|
||||
continue
|
||||
cands = [_vkey(t) for t in re.findall(r"\d+(?:\.\d+)*", e.get("patched") or "")]
|
||||
if kf and kt and any(kf < c <= kt for c in cands):
|
||||
patched = e.get("patched") or ""
|
||||
cands = [_vkey(t) for t in re.findall(r"\d+(?:\.\d+)*", patched)]
|
||||
if kf and kt and any(_within(kf, kt, c) for c in cands):
|
||||
got.add(cve)
|
||||
return got, "patched version ranges", None, False
|
||||
elif not patched or PLACEHOLDER_RE.search(patched):
|
||||
# No fix version published ("TBD") or only a placeholder ("7.4.X" — which could
|
||||
# be 7.4.1, inside the window). We cannot say either way, so say so.
|
||||
undecidable.add(cve)
|
||||
return got, "patched version ranges", None, False, undecidable
|
||||
# DATE FALLBACK: release DATES always order, even across a scheme change. Resolve both
|
||||
# versions to git tag dates and count advisories PUBLISHED in that window — the method a
|
||||
# hand count used to establish discourse 3.5.3 (2025-12-30) → 2026.7.1 (2026-07-31) = 123.
|
||||
@@ -395,12 +668,16 @@ def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str,
|
||||
got = {cve for cve, e in report["cves"].items()
|
||||
if src in e["sources"] and e.get("published_at")
|
||||
and d_from < e["published_at"] <= d_to}
|
||||
return got, "advisory publish date (version scheme changed)", (d_from, d_to), False
|
||||
return set(), "unresolved", None, True
|
||||
undecidable = {cve for cve, e in report["cves"].items()
|
||||
if src in e["sources"] and not e.get("published_at")}
|
||||
return got, "advisory publish date (version scheme changed)", (d_from, d_to), False, undecidable
|
||||
return set(), "unresolved", None, True, set()
|
||||
|
||||
fixed_set, methods, date_windows, unresolved_any = set(), {}, {}, False
|
||||
indeterminate: set = set()
|
||||
for src, (wf, wt) in windows.items():
|
||||
got, method, dw, unresolved = _classify_window(src, wf, wt)
|
||||
got, method, dw, unresolved, undecidable = _classify_window(src, wf, wt)
|
||||
indeterminate |= undecidable
|
||||
methods[src] = method
|
||||
if dw:
|
||||
date_windows[src] = {"from": dw[0], "to": dw[1]}
|
||||
@@ -427,7 +704,37 @@ def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str,
|
||||
report["classified_by"] = methods
|
||||
if date_windows:
|
||||
report["date_window"] = date_windows
|
||||
# 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):
|
||||
e = report["cves"][cve]
|
||||
for src in e["sources"]:
|
||||
if src not in windows:
|
||||
continue
|
||||
wf, wt = windows[src]
|
||||
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))]
|
||||
if hits:
|
||||
fixed_set.add(cve)
|
||||
resolved_by_release[cve] = sorted(hits)
|
||||
e["classification"] = (f"fixed-by-this-upgrade (named in release notes "
|
||||
f"{', '.join(sorted(hits))}) via {src}")
|
||||
e["fix_versions_from_release_notes"] = sorted(hits)
|
||||
break
|
||||
if resolved_by_release:
|
||||
report["resolved_by_release_notes"] = resolved_by_release
|
||||
|
||||
indeterminate -= fixed_set
|
||||
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]
|
||||
report["fixed_by_this_upgrade"] = sorted(fixed_set)
|
||||
report["indeterminate"] = sorted(indeterminate)
|
||||
report["cve_count_indeterminate"] = len(indeterminate)
|
||||
report["unclassified"] = sorted(unknown)
|
||||
# NEVER report 0 for something we could not determine — a 0 asserts safety. If ANY requested
|
||||
# window could not be ordered at all, the total is UNKNOWN rather than a partial number.
|
||||
@@ -468,8 +775,10 @@ def markdown(rep: dict) -> str:
|
||||
f"advisory APIs). This scan is ADDITIVE — it does not replace the release-note "
|
||||
f"reading in the upgrade step._")
|
||||
return "\n".join(L)
|
||||
ind = rep.get("cve_count_indeterminate") or 0
|
||||
if rep["fixed_by_this_upgrade"]:
|
||||
L.append(f"\n**CVEs fixed by this upgrade: {rep['cve_count_fixed']}**\n")
|
||||
floor = " (at least — see undetermined below)" if ind else ""
|
||||
L.append(f"\n**CVEs fixed by this upgrade: {rep['cve_count_fixed']}**{floor}\n")
|
||||
cb = rep.get("classified_by") or {}
|
||||
if isinstance(cb, dict) and cb:
|
||||
for src, method in cb.items():
|
||||
@@ -487,6 +796,15 @@ 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 ind:
|
||||
L.append(f"\n⚠ **{ind} advisory/advisories could NOT be judged** — the vendor published no fix "
|
||||
f"version (GitHub carries `TBD` or a placeholder like `7.4.X`) and the vulnerable "
|
||||
f"range is open-ended, so neither method can tell whether this upgrade fixed them. "
|
||||
f"They are NOT included in the count above and must NOT be read as unaffected: "
|
||||
+ ", ".join(f"{c} ({rep['cves'][c].get('severity') or '?'})"
|
||||
for c in rep["indeterminate"][:15])
|
||||
+ (" …" if len(rep["indeterminate"]) > 15 else "")
|
||||
+ "\n\nRe-run with `--adjudicate` for the collected evidence on each, to judge.")
|
||||
if 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: "
|
||||
@@ -505,6 +823,10 @@ def main() -> int:
|
||||
ap.add_argument("--from", dest="v_from", default=None)
|
||||
ap.add_argument("--to", dest="v_to", default=None)
|
||||
ap.add_argument("--json", action="store_true", help="emit raw JSON instead of markdown")
|
||||
ap.add_argument("--adjudicate", action="store_true",
|
||||
help="SECOND PASS: for advisories the deterministic pass could not judge (no "
|
||||
"fix version published), fetch their full text + references and append a "
|
||||
"block for the agent to judge. Additive: it never changes the count above.")
|
||||
ap.add_argument("--registry", default=REGISTRY_DIR)
|
||||
ap.add_argument("--image", action="append", default=[], metavar="NAME=FROM:TO",
|
||||
help="a sidecar image and the versions it moved between, e.g. "
|
||||
@@ -521,7 +843,15 @@ def main() -> int:
|
||||
else:
|
||||
print(f'ignoring malformed --image {spec!r} (expected NAME=FROM:TO)', file=sys.stderr)
|
||||
rep = scan(a.recipe, a.v_from, a.v_to, a.registry, images)
|
||||
print(json.dumps(rep, indent=2) if a.json else markdown(rep))
|
||||
if a.adjudicate:
|
||||
rep["adjudication"] = [evidence_bundle(rep, c)
|
||||
for c in needs_judgement(rep)[:MAX_ADJUDICATE]]
|
||||
if a.json:
|
||||
print(json.dumps(rep, indent=2))
|
||||
else:
|
||||
print(markdown(rep))
|
||||
if a.adjudicate:
|
||||
print(adjudication_block(rep))
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user