diff --git a/.claude/skills/cve-check/SKILL.md b/.claude/skills/cve-check/SKILL.md index 999cc16..4dc4d97 100644 --- a/.claude/skills/cve-check/SKILL.md +++ b/.claude/skills/cve-check/SKILL.md @@ -104,6 +104,18 @@ CRITICAL came from, and an image with no window is not counted at all. distinction was the difference between two false zeros and the truth (both recipes turned out fine, but nothing in the survey said so). +### 2c. Know which recipes CANNOT see CVEs at all +``` +python3 cc-ci-plan/audit-sources.py --security-sources +``` +A recipe whose sources yield **no CVE data at all** cannot produce a meaningful `0` — nothing was +measured, the same way a missing registry file cannot. As of 2026-08-11 that is **mattermost-lts** +(its GitHub advisory feed is empty and its security bulletins are client-side rendered) and +**mumble**. Render those as **`?`**, not `0`, and say why in the notes. + +An *unparseable page* is NOT the same thing: it is harmless when the same project also publishes an +advisory feed (redis, gitea, minio, clickhouse all do). Only "no usable source for this image" counts. + ### 3. Run the advisory scan over that window ``` python3 /srv/cc-ci/cc-ci-plan/advisory-scan.py --from --to \ diff --git a/cc-ci-plan/advisory-scan.py b/cc-ci-plan/advisory-scan.py index 848cd73..e333e2a 100755 --- a/cc-ci-plan/advisory-scan.py +++ b/cc-ci-plan/advisory-scan.py @@ -303,6 +303,20 @@ def _changelog_versions(text: str) -> dict: return out +_BLOB_RE = re.compile(r"^https://github\.com/([^/]+)/([^/]+)/blob/(.+)$") + + +def _raw_if_blob(url: str) -> str: + """A GitHub *blob* URL is an HTML viewer, not the file. + + The registry pointed ONLYOFFICE's CHANGELOG.md at its blob page. Fetching that returns 636KB of + markup in which the release headings do not survive HTML-stripping, so 24 CVEs were visible and + NONE attributable to a release — the same shape of blind spot as nginx. The raw URL attributes + all 24. Normalising here fixes every registry entry at once, present and future.""" + m = _BLOB_RE.match(url) + return f"https://raw.githubusercontent.com/{m.group(1)}/{m.group(2)}/{m.group(3)}" if m else url + + def vendor_pages(urls: list[str]) -> list[dict]: """Fetch each registry URL and regex out CVE ids, with a little surrounding context.""" out = [] @@ -317,7 +331,7 @@ def vendor_pages(urls: list[str]) -> list[dict]: continue entry = {"source": u, "status": "ok", "cves": [], "context": {}, "fixed_in": {}} try: - text = _fetch(u) + text = _fetch(_raw_if_blob(u)) plain = re.sub(r"<[^>]+>", " ", text) for cve in sorted(set(CVE_RE.findall(plain))): entry["cves"].append(cve) diff --git a/cc-ci-plan/audit-sources.py b/cc-ci-plan/audit-sources.py index dda4da6..9f0b103 100755 --- a/cc-ci-plan/audit-sources.py +++ b/cc-ci-plan/audit-sources.py @@ -42,6 +42,11 @@ _spec = importlib.util.spec_from_file_location("resolve_images", os.path.join(HE RI = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(RI) +# advisory-scan supplies the source-fetching + changelog-attribution used by --security-sources +_aspec = importlib.util.spec_from_file_location("advisory_scan", os.path.join(HERE, "advisory-scan.py")) +A = importlib.util.module_from_spec(_aspec) +_aspec.loader.exec_module(A) + REGISTRY_DIR = os.environ.get("CCCI_UPSTREAM_REGISTRY", os.path.join(HERE, "upstream")) USED_RECIPES = os.path.join(HERE, "used-recipes.md") DEPRECATION_RE = re.compile( @@ -108,6 +113,36 @@ def newest_tag_date(registry: str, repo: str, tag: str) -> str | None: return (d.get("results") or [{}])[0].get("last_updated") +def security_source_audit(recipe: str) -> list[dict]: + """Per source: are its CVEs USABLE, or merely visible? + + The nginx lesson. nginx publishes no GitHub advisories; all its CVEs live in nginx.org/en/CHANGES. + The scan saw them and could do nothing with them, because nothing said which release fixed which + CVE — so every nginx bump in the fleet reported 0. Attribution (advisory-scan §2b) fixed that for + changelogs organised by release, but a page that lists CVEs with NO release structure is still a + blind spot: visible, uncountable. This finds those. + + Per source: `advisory-feed` (structured, best), `changelog` (CVEs attributable to a release), + `unattributable` (CVEs present but no release structure — BLIND), or `no-cve-data`. + """ + urls, _ = _registry_urls(recipe) + out = [] + for entry in A.github_advisories(urls): + out.append({"source": entry["source"], "kind": "advisory-feed", + "status": entry["status"], "cves": len(entry.get("advisories") or []), + "usable": len(entry.get("advisories") or [])}) + for entry in A.vendor_pages(urls): + if entry["status"].startswith("skipped"): + continue + n = len(entry.get("cves") or []) + attributed = len(entry.get("fixed_in") or {}) + kind = ("no-cve-data" if n == 0 else + "changelog" if attributed else "unattributable") + out.append({"source": entry["source"], "kind": kind, "status": entry["status"], + "cves": n, "usable": attributed}) + return out + + def audit_recipe(recipe: str, ssh: str | None, quiet_days: int) -> dict: out = {"recipe": recipe, "findings": [], "images": [], "sources": []} try: @@ -214,9 +249,41 @@ def main() -> int: ap.add_argument("--ssh", default=None) ap.add_argument("--quiet-days", type=int, default=365) ap.add_argument("--json", action="store_true") + ap.add_argument("--security-sources", action="store_true", + help="audit whether each recipe's CVE sources are USABLE (structured advisory " + "feed / release-attributable changelog) or merely visible") a = ap.parse_args() recipes = a.recipes or all_recipes() + if a.security_sources: + # What matters is whether the RECIPE can see CVEs at all — not whether some individual page + # is unparseable. A page with no release structure is harmless when the same project also + # publishes an advisory feed (redis, gitea, minio, clickhouse all do); it is only a blind + # spot when nothing else covers that project. + blind_recipes, noisy = [], 0 + for r in recipes: + rows = security_source_audit(r) + feeds = [x for x in rows if x["kind"] == "advisory-feed" and x["cves"] > 0] + logs = [x for x in rows if x["kind"] == "changelog"] + unattr = [x for x in rows if x["kind"] == "unattributable"] + noisy += len(unattr) + usable = len(feeds) + len(logs) + if usable == 0: + blind_recipes.append(r) + print(f"!! {r}: NO USABLE CVE SOURCE — {len(unattr)} unparseable page(s), " + f"0 advisory feeds, 0 attributable changelogs") + for x in rows: + print(f" {x['kind']:15} {x['source'][:64]} ({x['cves']} CVEs)") + else: + print(f"OK {r}: {len(feeds)} advisory-feed(s), {len(logs)} changelog(s)" + + (f", {len(unattr)} unparseable page(s) (redundant — covered by a feed)" + if unattr else "")) + for x in logs: + print(f" changelog {x['source'][:62]} ({x['usable']}/{x['cves']})") + print(f"\n{len(recipes)} recipes · {len(blind_recipes)} with NO usable CVE source" + + (f": {', '.join(blind_recipes)}" if blind_recipes else "") + + f" · {noisy} unparseable page(s) elsewhere (harmless where a feed covers them)") + return 0 reports = [audit_recipe(r, a.ssh, a.quiet_days) for r in recipes] if a.json: print(json.dumps(reports, indent=2))