#!/usr/bin/env python3 """Deterministic per-recipe CVE/advisory scan — an ADDITIVE pre-step for /recipe-upgrade. WHY THIS EXISTS (2026-08-10): gitea 1.27.1 fixed two CVSS-9.8 RCEs (CVE-2026-60004, CVE-2026-59774). Our weekly report showed gitea's CVE count as "1", then "none". The upgrade subagent had scanned the GitHub *release notes*, which mention neither; the two CVEs were announced only in the vendor's blog security section. The report generator then derived security content from those notes plus model knowledge — and the model's training predates the CVEs. Nothing in the pipeline ever queried an advisory source, so a critical CVE that is newer than the model and absent from the changelog was invisible by construction. WHAT IT DOES NOT DO: it does not replace or alter any existing security analysis. It is a strictly ADDITIONAL evidence source whose findings are unioned into the CVE count. SOURCES (measured against the gitea case before being chosen): 1. GitHub Security Advisories API — repos///security-advisories. PRIMARY: carries CVE id, GHSA id, severity AND vulnerable/patched version ranges, so "fixed by THIS upgrade" is computable rather than guessed. Found both gitea CVEs. Derived from the source-repo URLs the per-recipe registry already records — no new per-recipe config needed. 2. Vendor release/security pages — every URL in cc-ci-plan/upstream/.md, fetched and regex-scanned for CVE ids. This is what would have caught gitea: the vendor blog names both, while the GitHub releases page names neither. Add vendor security/announcement URLs to the registry to widen this. 3. OSV.dev — supplementary, best-effort, only when the recipe declares an ecosystem/package mapping below. NOTE: for gitea, OSV returned only Go *dependency* advisories and 404'd on both application CVEs; NVD's API had them neither by CPE, CVE id, nor keyword. Advisory databases lag the vendor — which is exactly why (1) and (2) lead. Every source reports its own status, so "checked, none found" is never confused with "not checked". Usage: advisory-scan.py [--from ] [--to ] [--json] [--registry DIR] --from/--to are the app versions being upgraded between (e.g. 1.26.2 -> 1.27.1). When given, each advisory is classified fixed-by-this-upgrade / still-open / older. Without them everything known is listed unclassified. Exits 0 even when sources fail (informational; failures are reported). """ from __future__ import annotations import argparse import gzip import json import os import re import sys import urllib.error import urllib.request REGISTRY_DIR = os.environ.get("CCCI_UPSTREAM_REGISTRY", "/srv/cc-ci/cc-ci-plan/upstream") UA = "cc-ci-advisory-scan (+https://git.autonomic.zone/recipe-maintainers/cc-ci)" 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]] = { "gitea": ("Go", "code.gitea.io/gitea"), "n8n": ("npm", "n8n"), } def _github_token() -> str | None: """Read-only GitHub token, for the API rate limit ONLY (60/hr anonymous → 5000/hr with a token). Env `GITHUB_TOKEN` wins; otherwise the file at `GITHUB_TOKEN_FILE` (default /srv/cc-ci/.github-token, chmod 600, never in git). Reading PUBLIC security advisories needs NO scopes at all — create a classic PAT with every box unticked, or a fine-grained token limited to "Public repositories: read". Do NOT grant repo/write scopes: this tool only ever GETs advisories. A missing token is not an error — the scan simply runs anonymously and will report sources as failed once the 60/hr limit bites, which is visible rather than silent. """ tok = os.environ.get("GITHUB_TOKEN") if tok: return tok.strip() path = os.environ.get("GITHUB_TOKEN_FILE", "/srv/cc-ci/.github-token") try: with open(path) as f: return f.read().strip() or None except OSError: return None def _gh_paginate(url: str, hdrs: dict, max_pages: int = 20): """Yield every row from a GitHub list endpoint, following Link rel=\"next\" cursors.""" seen_keys = set() for _ in range(max_pages): req = urllib.request.Request(url, headers={"User-Agent": UA, **hdrs}) with urllib.request.urlopen(req, timeout=TIMEOUT) as r: rows = json.load(r) link = r.headers.get("Link", "") or "" fresh = 0 for a in rows: k = a.get("ghsa_id") or json.dumps(a, sort_keys=True)[:120] if k not in seen_keys: seen_keys.add(k); fresh += 1 yield a nxt = None for part in link.split(","): if 'rel="next"' in part: nxt = part.split(";")[0].strip().strip("<>") if not nxt or fresh == 0: return url = nxt def _tag_date(owner: str, repo: str, version: str | None) -> str | None: """Publish date of a release tag, for DATE-BASED classification (see classify_by_date). Version strings cannot be ordered across a scheme change (semver → calver), but tag dates always can. Tries the common tag spellings; returns an ISO timestamp or None.""" if not version: return None hdrs = {"Accept": "application/vnd.github+json"} tok = _github_token() if tok: hdrs["Authorization"] = f"Bearer {tok}" for tag in (f"v{version}", version): try: ref = json.loads(_fetch(f"https://api.github.com/repos/{owner}/{repo}/git/ref/tags/{tag}", hdrs)) obj = ref.get("object", {}) sha, typ = obj.get("sha"), obj.get("type") if typ == "tag": t = json.loads(_fetch(f"https://api.github.com/repos/{owner}/{repo}/git/tags/{sha}", hdrs)) if t.get("tagger", {}).get("date"): return t["tagger"]["date"] sha = t.get("object", {}).get("sha") c = json.loads(_fetch(f"https://api.github.com/repos/{owner}/{repo}/commits/{sha}", hdrs)) return c["commit"]["committer"]["date"] except Exception: # noqa: BLE001 — try the next spelling continue return None def _fetch(url: str, headers: dict | None = None) -> str: h = {"User-Agent": UA, "Accept-Encoding": "gzip"} h.update(headers or {}) with urllib.request.urlopen(urllib.request.Request(url, headers=h), timeout=TIMEOUT) as r: raw = r.read() if r.headers.get("Content-Encoding") == "gzip": raw = gzip.decompress(raw) return raw.decode(errors="replace") def _vkey(v: str | None) -> tuple: """Loose version ordering key: leading integers of each dot-part ('1.27.1-rootless' -> (1,27,1)).""" if not v: return () v = v.strip().lstrip("vV").split("+")[0] out = [] for part in re.split(r"[.\-_]", v): m = re.match(r"^\d+", part) if m: out.append(int(m.group())) elif out: break 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: with open(path) as f: text = f.read() except OSError: return [], None urls = [] for u in re.findall(r"https?://[^\s)|\]]+", text): # The registry is MARKDOWN: urls appear inside `backticks`, 'quotes', **bold**, and at the # end of sentences. Trailing punctuation captured into the url makes the fetch 404 and the # recipe render '?' for no real reason — that is what put immich and n8n in the unknown # column on 2026-08-07 (https://docs.n8n.io/release-notes/` ← note the backtick). u = u.rstrip("`'\"*.,;:>)") if u and u not in urls: urls.append(u) return urls, path def github_advisories(urls: list[str]) -> list[dict]: """Query GitHub Security Advisories for every github.com// in the registry.""" seen, results = set(), [] for u in urls: m = re.match(r"https?://github\.com/([^/]+)/([^/#?]+)", u) if not m: continue owner, repo = m.group(1), m.group(2).removesuffix(".git") if (owner, repo) in seen: continue seen.add((owner, repo)) api = f"https://api.github.com/repos/{owner}/{repo}/security-advisories?per_page=100" hdrs = {"Accept": "application/vnd.github+json"} tok = _github_token() if tok: hdrs["Authorization"] = f"Bearer {tok}" entry = {"source": f"github-advisories:{owner}/{repo}", "status": "ok", "advisories": []} try: # PAGINATE. This endpoint caps at 100 per response and IGNORES ?page= — it returns the # same rows again, which silently truncates busy projects (discourse has 286; a hand # count on 2026-08-10 found 123 CVEs in one upgrade window that a single page missed). # Follow the Link rel="next" cursor to exhaustion instead. for a in _gh_paginate(api, hdrs): # An advisory carries ONE ENTRY PER PATCHED RELEASE LINE. n8n patches three # (1.123.32, 2.17.4, 2.18.1); reading only vulnerabilities[0] silently dropped the # line our deployment is actually on, so CVE-2026-42231/42232 classified as # out-of-window. Keep them ALL and let the classifier match any of them. vulns = a.get("vulnerabilities") or [] entry["advisories"].append( { "cve": a.get("cve_id"), "ghsa": a.get("ghsa_id"), "severity": a.get("severity"), "summary": (a.get("summary") or "")[:200], "vulnerable_range": "; ".join( filter(None, (v.get("vulnerable_version_range") for v in vulns)) ) or None, "patched": "; ".join( filter(None, (v.get("patched_versions") for v in vulns)) ) 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: # 404 = this repo simply publishes no security advisories (e.g. sidecar images like # pgautoupgrade). That is a BENIGN ABSENCE, not a failed check — conflating the two # would push nearly every recipe to "unknown" and make the ? signal meaningless again. entry["status"] = "no-advisories-published" if e.code == 404 else f"error: HTTP {e.code}" except Exception as e: # noqa: BLE001 — a genuinely dead source must be REPORTED, never silent entry["status"] = f"error: {type(e).__name__}: {e}" results.append(entry) return results def vendor_pages(urls: list[str]) -> list[dict]: """Fetch each registry URL and regex out CVE ids, with a little surrounding context.""" out = [] for u in urls: if u.startswith("https://api.github.com"): continue if re.search(r"[<>{}]|\bVERSION\b|\bvX\.Y\.Z\b", u): # Registry entries sometimes carry TEMPLATE urls for humans # (…/changelog/v/). They are documentation, not fetchable — skipping them is # correct; counting them as failures would wrongly mark the recipe's count unreliable. out.append({"source": u, "status": "skipped: template URL (not fetchable)", "cves": [], "context": {}}) continue entry = {"source": u, "status": "ok", "cves": [], "context": {}} try: text = _fetch(u) plain = re.sub(r"<[^>]+>", " ", text) for cve in sorted(set(CVE_RE.findall(plain))): entry["cves"].append(cve) i = plain.find(cve) entry["context"][cve] = re.sub(r"\s+", " ", plain[max(0, i - 160) : i + 200]).strip() except Exception as e: # noqa: BLE001 entry["status"] = f"error: {type(e).__name__}: {e}" out.append(entry) return out def osv(recipe: str, version: str | None) -> dict | None: pkg = OSV_PACKAGES.get(recipe) if not pkg or not version: return None eco, name = pkg entry = {"source": f"osv:{eco}/{name}@{version}", "status": "ok", "cves": []} try: body = json.dumps({"package": {"name": name, "ecosystem": eco}, "version": version}).encode() req = urllib.request.Request( "https://api.osv.dev/v1/query", data=body, headers={"Content-Type": "application/json", "User-Agent": UA}, method="POST", ) with urllib.request.urlopen(req, timeout=TIMEOUT) as r: data = json.load(r) ids = set() for v in data.get("vulns", []): for a in [v.get("id")] + (v.get("aliases") or []): if a and a.startswith("CVE"): ids.add(a) entry["cves"] = sorted(ids) except Exception as e: # noqa: BLE001 entry["status"] = f"error: {type(e).__name__}: {e}" 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 _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. 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. """ ref = _source_repo(source) if not ref: return [] return [tag for tag, body in _releases(*ref) 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/ 404s for them (all 12 redis ones, for instance) # while /repos///security-advisories/ 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) report: dict = { "recipe": recipe, "from": v_from, "to": v_to, "registry": reg_path, "registry_urls": len(urls), "sources": [], "cves": {}, } if reg_path is None: report["sources"].append( {"source": f"registry:{recipe}.md", "status": "error: registry file not found"} ) 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, "description": None, "url": None, "cvss": None}) if src not in e["sources"]: e["sources"].append(src) for k, v in extra.items(): if v and not e.get(k): e[k] = v for entry in github_advisories(urls): report["sources"].append({"source": entry["source"], "status": entry["status"], "found": len(entry.get("advisories", []))}) for a in entry.get("advisories", []): 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"), 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"], "found": len(entry.get("cves", []))}) for cve in entry.get("cves", []): record(cve, entry["source"], context=entry["context"].get(cve)) for version in filter(None, (v_from, v_to)): o = osv(recipe, version) if o: report["sources"].append({"source": o["source"], "status": o["status"], "found": len(o.get("cves", []))}) for cve in o.get("cves", []): record(cve, o["source"]) # Classify against the upgrade window when we know it: an advisory is "fixed by this upgrade" # when its patched version is newer than `from` and no newer than `to`. # # TWO HARD-WON CONSTRAINTS (2026-08-10, discourse reported a false 133): # a) The window belongs to ONE image. Advisories from OTHER repos in the registry (redis, # postgres, nginx sidecars) must NOT be judged by it — redis CVE-2021-21309, patched in # redis 6.0.11, scored as "fixed" because 6.0.11 sits numerically inside discourse's # 3.5.3 → 2026.7.1 window. Only the PRIMARY app repo is classified; every other source is # reported as unclassified so a human/agent still sees it but it never inflates the count. # b) A version-SCHEME change (semver → calver, 3.5.3 → 2026.7.1) makes numeric ordering # meaningless: 2025.12.2 compares "newer" than 3.5.3 while shipping earlier. When the # leading component jumps by more than SCHEME_JUMP we refuse to classify and say so, # rather than emitting a confident wrong number. # ── Classification ──────────────────────────────────────────────────────────────────────── # A recipe upgrades SEVERAL images (app + redis/postgres/nginx sidecars), each with its OWN # version window. Judging every advisory by the app's window is how discourse once reported a # false 133 (34 of them redis CVEs, incl. one patched in redis 6.0.11 in 2021). So each source # is classified against ITS OWN window, and the count is the union across windows. # # --from/--to → the PRIMARY app repo (first github source in the registry) # --image NAME=FROM:TO → any other source whose name contains NAME (repeatable), # e.g. --image redis=7.4:8.10 # # A source with no window is not classified: its advisories are listed as unclassified so they # stay visible without inflating the count. gh_sources = [x["source"] for x in report["sources"] if x["source"].startswith("github-advisories:")] primary = gh_sources[0] if (gh_sources and (v_from or v_to)) else None report["primary_source"] = primary windows = {} # source name -> (from, to) if primary: windows[primary] = (v_from, v_to) for key, wf, wt in (images or []): for src in gh_sources: if key.lower() in src.lower() and src not in windows: windows[src] = (wf, wt) report["windows"] = {k: {"from": f, "to": t} for k, (f, t) in windows.items()} def _classify_window(src, wf, wt): """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, undecidable = set(), set() for cve, e in report["cves"].items(): if src not in e["sources"]: continue 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) 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. owner, _, repo = src.split(":", 1)[1].partition("/") d_from, d_to = _tag_date(owner, repo, wf), _tag_date(owner, repo, wt) if d_from and d_to and d_from < d_to: 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} 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, undecidable = _classify_window(src, wf, wt) indeterminate |= undecidable methods[src] = method if dw: date_windows[src] = {"from": dw[0], "to": dw[1]} if unresolved: unresolved_any = True for cve in got: report["cves"][cve]["classification"] = f"fixed-by-this-upgrade ({method}) via {src}" fixed_set.add(cve) unknown = [] for cve, e in report["cves"].items(): if cve in fixed_set: continue if not any(src in e["sources"] for src in windows): e["classification"] = "unclassified: no versions given for this image" unknown.append(cve) else: e.setdefault("classification", "outside-window") if e["classification"] == "outside-window": pass else: unknown.append(cve) 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. # 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"]: ref = _source_repo(src) if src not in windows and ref not in win_by_repo: continue 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 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) 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 # 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 | 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] 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. report["count_known"] = not unresolved_any report["cve_count_fixed"] = len(fixed_set) if not unresolved_any else None report["cve_count_total_seen"] = len(report["cves"]) # Only GENUINE failures make a count unreliable. "no-advisories-published" (404: the repo has # no advisory feed) and "skipped: template URL" are benign and must not degrade the verdict. report["sources_failed"] = [ s["source"] for s in report["sources"] if not (s["status"] == "ok" or s["status"].startswith(("no-advisories-published", "skipped:"))) ] report["sources_benign"] = [ s["source"] for s in report["sources"] if s["status"].startswith(("no-advisories-published", "skipped:")) ] return report def markdown(rep: dict) -> str: """Human/agent-readable block for pasting into the per-recipe upgrade log.""" L = [f"### Advisory scan (deterministic pre-step) — {rep['recipe']} " f"{rep.get('from') or '?'} → {rep.get('to') or '?'}"] if not rep.get("count_known", True): L.append("\n**CVEs fixed by this upgrade: UNKNOWN — the scan could NOT determine a count.**") L.append("\n⚠ This is NOT zero. A version-scheme change (e.g. semver → calver) makes numeric " "ordering meaningless across this jump, so no advisory could be classified. Render " "this recipe's cve cell as `?`, never `0`. Read the vendor's release notes for the " "jump and count by hand.") if rep["unclassified"]: L.append(f"\nAdvisories seen but unclassifiable ({len(rep['unclassified'])}) — includes " f"other images in this recipe: " + ", ".join(rep["unclassified"][:12])) if rep["sources_failed"]: L.append(f"\n⚠ sources that FAILED: {', '.join(rep['sources_failed'])}") L.append(f"\n_Sources checked: {len(rep['sources'])} ({rep['registry_urls']} registry URLs + " 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"]: 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(): dw = (rep.get("date_window") or {}).get(src) win = (rep.get("windows") or {}).get(src, {}) span = f"{win.get('from')} → {win.get('to')}" extra = (f" (dates {dw['from'][:10]} → {dw['to'][:10]})" if dw else "") L.append(f"_{src.split(':',1)[-1]}: {span} — counted by {method}{extra}._") L.append("") L.append("| CVE | severity | fixed in | advisory | source |") L.append("|---|---|---|---|---|") for cve in rep["fixed_by_this_upgrade"]: e = rep["cves"][cve] L.append(f"| {cve} | {e.get('severity') or '?'} | {e.get('patched') or '?'} | " 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: " + ", ".join(rep["unclassified"][:12])) if rep["sources_failed"]: L.append(f"\n⚠ sources that FAILED (treat counts as incomplete): {', '.join(rep['sources_failed'])}") L.append(f"\n_Sources checked: {len(rep['sources'])} " f"({rep['registry_urls']} registry URLs + advisory APIs). This scan is ADDITIVE — it does " f"not replace the release-note reading in the upgrade step._") return "\n".join(L) def main() -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("recipe") 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. " "--image redis=7.4:8.10 (repeatable). NAME matches a source repo name; " "its advisories are then counted against ITS OWN versions instead of " "being left unclassified.") a = ap.parse_args() images = [] for spec in a.image: name, _, rng = spec.partition('=') vf, _, vt = rng.partition(':') if name and vf and vt: images.append((name, vf, vt)) 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) 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 if __name__ == "__main__": sys.exit(main())