From 46ace30b4de7982fd614e93109c6d17f225203e4 Mon Sep 17 00:00:00 2001 From: autonomic-bot Date: Tue, 11 Aug 2026 20:02:06 +0000 Subject: [PATCH] audit-sources --security-sources: find the recipes that cannot see CVEs at all Follow-up to the nginx blind spot. Sweeping all 22 recipes for sources whose CVEs are USABLE (structured advisory feed, or a changelog attributable to releases) rather than merely visible. Before the changelog-attribution fix: 20 unusable sources. After: 5, and all five are redundant - the same project also publishes an advisory feed (redis, gitea, minio, clickhouse), so nothing is actually lost. One real find, same shape as nginx: ONLYOFFICE/DocumentServer publishes NO GitHub advisories, and the registry pointed its CHANGELOG.md at the GitHub *blob* page - 636KB of markup in which the release headings do not survive HTML-stripping, so 24 CVEs were visible and NONE attributable. The raw URL attributes all 24. Rather than fix one registry line, advisory-scan now normalises github.com/../blob/.. to raw.githubusercontent.com, which fixes every entry present and future. lasuite-drive bumps documentserver, so this was live. Genuinely blind after all that: mattermost-lts and mumble - no advisory feed, no attributable changelog, no CVE data anywhere the registry points. mattermost is the notable one: its bulletins are client-side rendered, so a regex sweep sees nothing. Their scans can report 0 while nothing was measured, so /cve-check now renders those recipes as ? and says why. The audit output distinguishes a blind RECIPE from an unparseable PAGE, because conflating them made 5 harmless redundancies look like 5 gaps. --- .claude/skills/cve-check/SKILL.md | 12 ++++++ cc-ci-plan/advisory-scan.py | 16 +++++++- cc-ci-plan/audit-sources.py | 67 +++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 1 deletion(-) 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)) -- 2.54.0