From db37f1618bf06895ca785ad5bb855cbb718b155d Mon Sep 17 00:00:00 2001 From: autonomic-bot Date: Tue, 11 Aug 2026 19:49:43 +0000 Subject: [PATCH] advisory-scan: attribute vendor-changelog CVEs to the release that fixed them nginx publishes NO GitHub security advisories. Every nginx CVE we can see comes from nginx.org/en/CHANGES, and the scan scraped ids out of it without attributing them to a release - so they had no patched version, could never be classified, and every nginx bump in the fleet reported 0 CVEs. nginx is a sidecar in most recipes, so this was a fleet-wide blind spot. Measured on the two PRs that prompted the question: lasuite-docs#7 nginx 1.31.1 -> 1.31.3 0 -> 6 CVEs lasuite-drive#6 nginx 1.31.2 -> 1.31.3 0 -> 3 CVEs matching a hand count of the changelog exactly (three fixed in 1.31.2, three in 1.31.3; the narrower window correctly counts only the latter). How: when a vendor page is organised by release, each CVE is attributed to the nearest preceding release heading ('Changes with nginx 1.31.3', '## v1.31.3'), and that becomes its fixed-in version. The CVE is tied to a window by the image name appearing in the page URL (window 'nginx' <-> nginx.org/...). A changelog lists the project's whole history, so only releases the window actually crosses count - asserted by a test that the 2013 entries stay out. 76 tests. discourse 140 / gitea 2 / mailu 2 / keycloak 12 unchanged. --- cc-ci-plan/advisory-scan.SPEC.md | 17 +++++++- cc-ci-plan/advisory-scan.py | 69 ++++++++++++++++++++++++++++++-- cc-ci-plan/test-advisory-scan.py | 53 ++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 5 deletions(-) diff --git a/cc-ci-plan/advisory-scan.SPEC.md b/cc-ci-plan/advisory-scan.SPEC.md index d11fb6a..708d752 100644 --- a/cc-ci-plan/advisory-scan.SPEC.md +++ b/cc-ci-plan/advisory-scan.SPEC.md @@ -107,8 +107,21 @@ URLs containing `<`, `>`, `{`, `}`, `VERSION`, or `vX.Y.Z` are **skipped as temp human documentation (`…/changelog/v/`), not fetchable, and counting them as failures is wrong. This is the source that would have caught gitea: the vendor blog names both CVEs, the GitHub release -page names neither. A CVE found **only** here carries no version data, so pass 1 cannot place it — it -goes to pass 2 (§6). +page names neither. + +**When the page is a changelog organised by release, each CVE is attributed to the release heading it +appears under** (`Changes with nginx 1.31.3`, `## v1.31.3`, …) and that becomes its fixed-in version. +Without this, a project that publishes no advisory feed can never contribute a CVE: + +> **nginx publishes NO GitHub security advisories.** Every nginx CVE we can see comes from +> `nginx.org/en/CHANGES`. Scraping ids out of it without attributing them to a release left them with +> no patched version, so they were never classifiable — and every nginx bump in the fleet reported +> **0** forever. nginx is a sidecar in most recipes. Measured: `1.31.1 → 1.31.3` fixes **six** CVEs +> (three in .2, three in .3); lasuite-docs#7 went 0 → 6 and lasuite-drive#6 went 0 → 3 on this alone. + +A changelog CVE is tied to a window by the **image name appearing in the page URL** (window `nginx` ↔ +`nginx.org/...`). A CVE found on a vendor page with no attributable release still has no version data, +so pass 1 cannot place it — it goes to pass 2 (§6). ### 2c. OSV.dev — supplementary diff --git a/cc-ci-plan/advisory-scan.py b/cc-ci-plan/advisory-scan.py index 2fc0301..848cd73 100755 --- a/cc-ci-plan/advisory-scan.py +++ b/cc-ci-plan/advisory-scan.py @@ -272,6 +272,37 @@ def github_advisories(urls: list[str]) -> list[dict]: return results +# Release headings in a vendor changelog. nginx's CHANGES uses "Changes with nginx 1.31.3", most +# markdown changelogs use "## 1.31.3" / "## v1.31.3". +_HEADING_RE = re.compile( + r"^\s*(?:#{1,4}\s*)?(?:Changes with\s+\S+\s+|Version\s+|Release\s+)?v?(\d+\.\d+(?:\.\d+)*)\s*$" + r"|^\s*Changes with\s+\S+\s+(\d+\.\d+(?:\.\d+)*)", re.I) + + +def _changelog_versions(text: str) -> dict: + """{cve: version} for a changelog that is ORGANISED BY RELEASE. + + Why this exists: nginx publishes NO GitHub security advisories. Every nginx CVE we can see comes + from nginx.org/en/CHANGES, and scraping ids out of it without attributing them to a release + leaves them with no patched version — so they can never be classified, and an nginx bump reports + 0 CVEs forever. nginx 1.31.1 -> 1.31.3 in fact fixes SIX (three in .2, three in .3), and nginx is + a sidecar in most of the fleet, so that was a fleet-wide blind spot. + + Attributes each CVE to the nearest PRECEDING release heading — the release that fixed it. + """ + plain = re.sub(r"<[^>]+>", " ", text) + out, cur = {}, None + for line in plain.splitlines(): + m = _HEADING_RE.match(line) + if m: + cur = m.group(1) or m.group(2) + continue + if cur: + for cve in CVE_RE.findall(line): + out.setdefault(cve, cur) + return out + + def vendor_pages(urls: list[str]) -> list[dict]: """Fetch each registry URL and regex out CVE ids, with a little surrounding context.""" out = [] @@ -284,7 +315,7 @@ def vendor_pages(urls: list[str]) -> list[dict]: # 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": {}} + entry = {"source": u, "status": "ok", "cves": [], "context": {}, "fixed_in": {}} try: text = _fetch(u) plain = re.sub(r"<[^>]+>", " ", text) @@ -292,6 +323,7 @@ def vendor_pages(urls: list[str]) -> list[dict]: entry["cves"].append(cve) i = plain.find(cve) entry["context"][cve] = re.sub(r"\s+", " ", plain[max(0, i - 160) : i + 200]).strip() + entry["fixed_in"] = _changelog_versions(text) except Exception as e: # noqa: BLE001 entry["status"] = f"error: {type(e).__name__}: {e}" out.append(entry) @@ -595,7 +627,8 @@ def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str, 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}) + "description": None, "url": None, "cvss": None, + "changelog_fixed_in": None}) if src not in e["sources"]: e["sources"].append(src) for k, v in extra.items(): @@ -616,7 +649,8 @@ def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str, 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)) + record(cve, entry["source"], context=entry["context"].get(cve), + changelog_fixed_in=(entry.get("fixed_in") or {}).get(cve)) for version in filter(None, (v_from, v_to)): o = osv(recipe, version) @@ -656,8 +690,10 @@ def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str, report["primary_source"] = primary windows = {} # source name -> (from, to) + window_key = {} # source name -> the image name it covers if primary: windows[primary] = (v_from, v_to) + window_key[primary] = primary.split("/")[-1] for key, wf, wt in (images or []): for src in gh_sources: if src in windows: @@ -669,6 +705,7 @@ def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str, k, name = key.lower(), src.split("/")[-1].lower() if k in src.lower() or name in k: windows[src] = (wf, wt) + window_key[src] = key report["windows"] = {k: {"from": f, "to": t} for k, (f, t) in windows.items()} def _classify_window(src, wf, wt): @@ -726,6 +763,32 @@ def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str, report["cves"][cve]["classification"] = f"fixed-by-this-upgrade ({method}) via {src}" fixed_set.add(cve) + # A CVE seen only in a vendor CHANGELOG has no advisory feed behind it, but the changelog says + # which release fixed it (see _changelog_versions). Tie it to a window by the image name + # appearing in the page URL — nginx's window is `nginx`, and its changelog is nginx.org/... . + # Without this, projects that publish no GitHub advisories (nginx being the big one) can never + # contribute a CVE, and every nginx bump in the fleet silently reports 0. + from_changelog = {} + for cve, e in report["cves"].items(): + if cve in fixed_set or not e.get("changelog_fixed_in"): + continue + for src, (wf, wt) in windows.items(): + key = (window_key.get(src) or "").lower() + if not key: + continue + if not any(key in s_.lower() for s_ in e["sources"] if s_.startswith("http")): + continue + kf, kt = _vkey(wf), _vkey(wt) + cand = _vkey(e["changelog_fixed_in"]) + if kf and kt and cand and _within(kf, kt, cand): + e["classification"] = (f"fixed-by-this-upgrade (named under {e['changelog_fixed_in']} " + f"in the vendor changelog) via {src}") + fixed_set.add(cve) + from_changelog[cve] = e["changelog_fixed_in"] + break + if from_changelog: + report["resolved_by_changelog"] = from_changelog + unknown = [] for cve, e in report["cves"].items(): if cve in fixed_set: diff --git a/cc-ci-plan/test-advisory-scan.py b/cc-ci-plan/test-advisory-scan.py index 777d376..e964211 100755 --- a/cc-ci-plan/test-advisory-scan.py +++ b/cc-ci-plan/test-advisory-scan.py @@ -537,6 +537,59 @@ class TestReleaseLineSemantics(unittest.TestCase): self.assertEqual(rep["fixed_by_this_upgrade"], ["CVE-2025-49844"]) +class TestChangelogAttribution(unittest.TestCase): + """Projects that publish no advisory feed still say which release fixed what — in their changelog.""" + + CHANGES = """ +Changes with nginx 1.31.3 11 Aug 2026 + *) Security: a flaw ... (CVE-2026-60005) + *) Security: another ... (CVE-2026-56434) + +Changes with nginx 1.31.2 04 Aug 2026 + *) Security: something ... (CVE-2026-48142) + +Changes with nginx 1.31.1 21 Jul 2026 + *) Security: older ... (CVE-2026-9256) + +Changes with nginx 1.20.0 01 Jan 2021 + *) Security: ancient ... (CVE-2013-2028) +""" + + def test_each_cve_is_attributed_to_the_release_that_fixed_it(self): + got = A._changelog_versions(self.CHANGES) + self.assertEqual(got["CVE-2026-60005"], "1.31.3") + self.assertEqual(got["CVE-2026-48142"], "1.31.2") + self.assertEqual(got["CVE-2026-9256"], "1.31.1") + self.assertEqual(got["CVE-2013-2028"], "1.20.0") + + def _scan(self, wfrom, wto): + # nginx publishes NO GitHub advisories — the feed is empty and the changelog is everything. + return run_scan( + [gh("nginx/nginx", [])], + [{"source": "https://nginx.org/en/CHANGES", "status": "ok", + "cves": sorted(A._changelog_versions(self.CHANGES)), + "context": {}, "fixed_in": A._changelog_versions(self.CHANGES)}], + images=[("nginx", wfrom, wto)], urls=["https://github.com/nginx/nginx"]) + + def test_window_counts_only_the_releases_it_crosses(self): + rep = self._scan("1.31.1", "1.31.3") # 1.31.1 is the FROM, so its CVE is already fixed + self.assertEqual(set(rep["fixed_by_this_upgrade"]), + {"CVE-2026-48142", "CVE-2026-56434", "CVE-2026-60005"}) + + def test_a_narrower_window_counts_fewer(self): + rep = self._scan("1.31.2", "1.31.3") + self.assertEqual(set(rep["fixed_by_this_upgrade"]), {"CVE-2026-56434", "CVE-2026-60005"}) + + def test_ancient_entries_are_not_swept_in(self): + # The changelog lists the project's whole history; only the crossed releases may count. + rep = self._scan("1.31.1", "1.31.3") + self.assertNotIn("CVE-2013-2028", rep["fixed_by_this_upgrade"]) + + def test_evidence_is_recorded(self): + rep = self._scan("1.31.1", "1.31.3") + self.assertEqual(rep["resolved_by_changelog"]["CVE-2026-60005"], "1.31.3") + + class TestComposeDerivedWindows(unittest.TestCase): """Windows read off a compose diff, so nobody has to remember which --image args an upgrade needs."""