From 985dc06e47d87424d650d4e55e6341f16b99f0e3 Mon Sep 17 00:00:00 2001 From: autonomic-bot Date: Tue, 11 Aug 2026 22:16:37 +0000 Subject: [PATCH 1/2] advisory-scan: NVD by CPE, so mattermost and mumble stop scanning as '?' Two recipes could not see CVEs at all. mattermost-lts has an empty GitHub advisory feed and renders its security bulletins client-side, so a text sweep finds nothing; mumble publishes nothing anywhere the registry points. Both returned '?' - nothing measured - which is honest but useless. NVD is CPE-indexed and carries structured version ranges, so it answers where the vendor does not. Declared per recipe as 'nvd-cpe: = '. mattermost-lts 10.5.0 -> 10.12.4 165 CVEs mattermost-lts 10.11.22 -> 10.12.4 0 CVEs (measured, not unknown) mumble 1.3.0 -> 1.6.870 2 CVEs Both NVD range forms are used: versionEndExcluding is a patched version; versionEndIncluding means the fix version is unpublished but the upgrade delivers it whenever it crosses X. That 0 for the actual mattermost upgrade is the interesting one, and it needed a new rule to be correct: a fix on the line you upgrade FROM was already yours. mattermost patches every maintained line at once, so 10.11.22 -> 10.12.4 crosses 10.12.1 while 10.11.22 already had the 10.11.4 backport. Without the rule the scan claimed 12 CVEs the upgrade did not deliver. The rule is skipped for placeholders: '7.4.X' parses to a bare 7.4 and would read as 'already fixed at 7.4', which silently dropped redis CVE-2024-46981 and took discourse 140 -> 139 before I caught it. 79 tests. discourse 140 / gitea 2 / mailu 2 / keycloak 12 / plausible 6 unchanged. Fleet sweep: 0 recipes with no usable CVE source, down from 2. --- cc-ci-plan/advisory-scan.SPEC.md | 31 +++++- cc-ci-plan/advisory-scan.py | 140 +++++++++++++++++++++++++- cc-ci-plan/audit-sources.py | 9 ++ cc-ci-plan/test-advisory-scan.py | 28 ++++++ cc-ci-plan/upstream/mattermost-lts.md | 9 ++ cc-ci-plan/upstream/mumble.md | 8 ++ 6 files changed, 222 insertions(+), 3 deletions(-) diff --git a/cc-ci-plan/advisory-scan.SPEC.md b/cc-ci-plan/advisory-scan.SPEC.md index 708d752..980e90b 100644 --- a/cc-ci-plan/advisory-scan.SPEC.md +++ b/cc-ci-plan/advisory-scan.SPEC.md @@ -123,7 +123,27 @@ A changelog CVE is tied to a window by the **image name appearing in the page UR `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 +### 2c. NVD by CPE — the fallback for projects that publish nothing + +Declared per recipe in the registry as `nvd-cpe: = `. + +> **Why it exists.** Two recipes could not see CVEs *at all*: `mattermost-lts` (empty GitHub advisory +> feed, security bulletins rendered client-side so a text sweep finds nothing) and `mumble` (nothing +> published anywhere the registry points). Their scans returned `?` — nothing measured. NVD is +> CPE-indexed and carries structured ranges, so it answers where the vendor does not: mattermost +> 10.5.0 → 10.12.4 now scores **165**, and mumble finds `CVE-2025-71264` (fixed 1.6.870). + +Two range forms, both used: + +| NVD field | meaning | how it is judged | +|---|---|---| +| `versionEndExcluding X` | fixed in X exactly | a normal patched version (§4a) | +| `versionEndIncluding X` | affected **up to and including** X; fix version unpublished | fixed when the upgrade crosses X, i.e. `from ≤ X < to` | + +**NVD lags the vendor** — it had neither gitea CVSS-9.8 RCE at publication — so this is a fallback, +never a replacement for 2a/2b. Unauthenticated calls are rate-limited (~5/30s), hence the retry. + +### 2d. OSV.dev — supplementary Only when the recipe has an entry in `OSV_PACKAGES` (ecosystem + package) and a version is given. @@ -186,6 +206,15 @@ literal compose diff, e.g. "what would the compatibility-safe target fix?"). ### 4a. By patched version (preferred — exact) +**A fix on the line you are upgrading FROM was already yours.** Projects that maintain several lines +patch them all at once: mattermost fixed `CVE-2025-11794` in 10.11.4, 10.12.1 *and* 10.5.12. An +upgrade 10.11.22 → 10.12.4 crosses 10.12.1, so a naive window test counts it — but 10.11.22 is +already past 10.11.4, so the deployment had the fix before the upgrade. Counting it credits the +upgrade with work it did not do. This check is **skipped for placeholder versions** (`7.4.X` parses +to a bare `7.4`, which would read as "already fixed at 7.4" and silently drop a real fix — exactly +how redis `CVE-2024-46981` was lost when the rule was first added). + + `patched_versions` is a **range expression** (`">= 2.18.1"`), possibly several joined by `;`. Extract every version-looking token; the advisory is **fixed-by-this-upgrade** if **any** patched version `p` satisfies `from < p <= to` — exclusive lower (a fix already in the version you were on is not this diff --git a/cc-ci-plan/advisory-scan.py b/cc-ci-plan/advisory-scan.py index e333e2a..42cbee3 100755 --- a/cc-ci-plan/advisory-scan.py +++ b/cc-ci-plan/advisory-scan.py @@ -44,7 +44,9 @@ import json import os import re import sys +import time import urllib.error +import urllib.parse import urllib.request REGISTRY_DIR = os.environ.get("CCCI_UPSTREAM_REGISTRY", "/srv/cc-ci/cc-ci-plan/upstream") @@ -164,6 +166,27 @@ def _vkey(v: str | None) -> tuple: return tuple(out) +def _already_fixed_on_from_line(kf: tuple, cands: list[tuple]) -> bool: + """Was it ALREADY fixed on the line we are upgrading FROM? + + The mirror image of _superseded_on_target_line, and just as necessary. mattermost fixes each CVE + across several maintained lines at once — CVE-2025-11794 is patched in 10.11.4, 10.12.1 and + 10.5.12. Upgrading 10.11.22 -> 10.12.4 crosses 10.12.1, so a naive window test counts it; but + 10.11.22 is already past 10.11.4, so the deployment HAD the fix before the upgrade. Counting it + credits the upgrade with work it did not do.""" + if len(kf) < 2: + return False + line = kf[:2] + for c in cands: + if len(c) < 2 or c[:2] != line: + continue + n = max(len(kf), len(c)) + pad = lambda z: z + (0,) * (n - len(z)) + if pad(c) <= pad(kf): + return True + return False + + def _superseded_on_target_line(kt: tuple, cands: list[tuple]) -> bool: """Does a patched version on the TARGET's own release line sit ABOVE the target? @@ -344,6 +367,78 @@ def vendor_pages(urls: list[str]) -> list[dict]: return out +NVD_API = "https://services.nvd.nist.gov/rest/json/cves/2.0" +NVD_CPE_RE = re.compile(r"^\s*[-*]?\s*nvd-cpe:\s*(\S+)\s*=\s*(cpe:2\.3:[^\s`]+)", re.M | re.I) + + +def registry_cpes(recipe: str, registry_dir: str) -> list[tuple[str, str]]: + """[(image-key, cpe)] declared in the recipe's registry as `nvd-cpe: = `.""" + path = os.path.join(registry_dir, f"{recipe}.md") + try: + return [(m.group(1), m.group(2)) for m in NVD_CPE_RE.finditer(open(path).read())] + except OSError: + return [] + + +def nvd_advisories(cpe: str, key: str) -> dict: + """CVEs for a CPE from NVD, with the version data the classifier needs. + + THE FALLBACK FOR PROJECTS THAT PUBLISH NOTHING MACHINE-READABLE. mattermost's GitHub advisory + feed is empty and its security bulletins are client-side rendered; mumble publishes neither. Both + scanned as `?` — nothing measured — until here. NVD is CPE-indexed and carries structured ranges: + + versionEndExcluding X -> fixed in X exactly (a patched version) + versionEndIncluding X -> affected up to and INCLUDING X, fixed in some later release. The + exact fix version is unknown, but the upgrade fixes it whenever it + crosses X — recorded as `affected_max` and judged in the classifier. + + NVD LAGS the vendor (it had neither gitea CVSS-9.8 RCE at publication), so this is a fallback, + never a replacement for 2a/2b. Unauthenticated calls are rate-limited to ~5/30s, hence the retry. + """ + entry = {"source": f"nvd:{key}", "status": "ok", "advisories": []} + url = f"{NVD_API}?resultsPerPage=2000&virtualMatchString={urllib.parse.quote(cpe)}" + data = None + for attempt in range(3): + try: + data = json.loads(_fetch(url)) + break + except Exception as e: # noqa: BLE001 + if attempt == 2: + entry["status"] = f"error: {type(e).__name__}" + return entry + time.sleep(8) + for v in (data or {}).get("vulnerabilities", []): + c = v.get("cve") or {} + cid = c.get("id") + if not cid: + continue + fixed, affected_max = set(), set() + for cfg in c.get("configurations", []): + for node in cfg.get("nodes", []): + for m in node.get("cpeMatch", []): + if m.get("versionEndExcluding"): + fixed.add(m["versionEndExcluding"]) + elif m.get("versionEndIncluding"): + affected_max.add(m["versionEndIncluding"]) + sev = None + for mk in ("cvssMetricV31", "cvssMetricV30", "cvssMetricV2"): + got = (c.get("metrics") or {}).get(mk) or [] + if got: + sev = (got[0].get("cvssData") or {}).get("baseSeverity") + break + entry["advisories"].append({ + "cve": cid, "ghsa": None, "severity": (sev or "").lower() or None, + "summary": next((d.get("value") for d in c.get("descriptions", []) + if d.get("lang") == "en"), "")[:200], + "vulnerable_range": None, + "patched": "; ".join(sorted(fixed)) or None, + "affected_max": "; ".join(sorted(affected_max)) or None, + "url": f"https://nvd.nist.gov/vuln/detail/{cid}", + "published_at": c.get("published"), "description": None, "cvss": None, + }) + return entry + + def osv(recipe: str, version: str | None) -> dict | None: pkg = OSV_PACKAGES.get(recipe) if not pkg or not version: @@ -642,7 +737,7 @@ def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str, "vulnerable_range": None, "patched": None, "context": None, "published_at": None, "description": None, "url": None, "cvss": None, - "changelog_fixed_in": None}) + "changelog_fixed_in": None, "affected_max": None}) if src not in e["sources"]: e["sources"].append(src) for k, v in extra.items(): @@ -659,6 +754,16 @@ def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str, context=a.get("summary"), published_at=a.get("published_at"), description=a.get("description"), url=a.get("url"), cvss=a.get("cvss")) + for key, cpe in registry_cpes(recipe, registry_dir): + entry = nvd_advisories(cpe, key) + report["sources"].append({"source": entry["source"], "status": entry["status"], + "found": len(entry.get("advisories") or [])}) + for a in entry.get("advisories", []): + record(a["cve"], entry["source"], severity=a.get("severity"), + patched=a.get("patched"), affected_max=a.get("affected_max"), + context=a.get("summary"), published_at=a.get("published_at"), + url=a.get("url")) + for entry in vendor_pages(urls): report["sources"].append({"source": entry["source"], "status": entry["status"], "found": len(entry.get("cves", []))}) @@ -699,7 +804,8 @@ def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str, # # 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:")] + gh_sources = [x["source"] for x in report["sources"] + if x["source"].startswith(("github-advisories:", "nvd:"))] primary = gh_sources[0] if (gh_sources and (v_from or v_to)) else None report["primary_source"] = primary @@ -708,6 +814,17 @@ def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str, if primary: windows[primary] = (v_from, v_to) window_key[primary] = primary.split("/")[-1] + # The app's window must also cover its NVD entry. NVD sources are keyed by IMAGE name + # (`mattermost-team-edition`) while the advisory feed is keyed by REPO (`mattermost/ + # mattermost`), so without this the fallback source that exists precisely because the feed + # is empty would itself go unwindowed — and mumble/mattermost would still report nothing. + pname = primary.split("/")[-1].lower() + for src in gh_sources: + if src.startswith("nvd:") and src not in windows: + k = src.split(":", 1)[1].lower() + if pname in k or k in pname: + windows[src] = (v_from, v_to) + window_key[src] = k for key, wf, wt in (images or []): for src in gh_sources: if src in windows: @@ -738,12 +855,31 @@ def scan(recipe: str, v_from: str | None, v_to: str | None, registry_dir: str, continue patched = e.get("patched") or "" cands = [_vkey(t) for t in re.findall(r"\d+(?:\.\d+)*", patched)] + # NEVER on a placeholder: "7.4.X" parses to the bare 7.4, which then reads as + # "already fixed at 7.4" and silently drops a real fix (redis CVE-2024-46981). + # A placeholder means the fix version is unknown — that is the indeterminate path. + if (kf and kt and not PLACEHOLDER_RE.search(patched) + and _already_fixed_on_from_line(kf, cands)): + # already had it before the upgrade + e.setdefault("classification", "outside-window") + continue if kf and kt and _superseded_on_target_line(kt, cands): # The target's own line got the fix LATER than the target: not fixed here. e.setdefault("classification", "outside-window") continue if kf and kt and any(_within(kf, kt, c) for c in cands): got.add(cve) + elif kf and kt and e.get("affected_max"): + # NVD's `versionEndIncluding X`: affected up to and INCLUDING X, fixed in some + # later release. The exact fix version is unpublished, but the upgrade delivers + # it whenever it crosses X — i.e. from <= X < to. + for t in re.findall(r"\d+(?:\.\d+)*", e["affected_max"]): + x = _vkey(t) + n = max(len(kf), len(kt), len(x)) + pad = lambda z: z + (0,) * (n - len(z)) + if x and pad(kf) <= pad(x) < pad(kt): + got.add(cve) + break 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. diff --git a/cc-ci-plan/audit-sources.py b/cc-ci-plan/audit-sources.py index 9f0b103..b0db1b3 100755 --- a/cc-ci-plan/audit-sources.py +++ b/cc-ci-plan/audit-sources.py @@ -127,6 +127,15 @@ def security_source_audit(recipe: str) -> list[dict]: """ urls, _ = _registry_urls(recipe) out = [] + # NVD CPE entries are a first-class source: for projects publishing nothing machine-readable + # (mattermost, mumble) they are the ONLY structured source, and omitting them here made two + # recipes look permanently blind after they had been fixed. + for key, cpe in A.registry_cpes(recipe, REGISTRY_DIR): + e = A.nvd_advisories(cpe, key) + n = len(e.get("advisories") or []) + out.append({"source": e["source"] + f" ({cpe.split(':')[4]}/{cpe.split(':')[3]})", + "kind": "advisory-feed" if n else "no-cve-data", + "status": e["status"], "cves": n, "usable": n}) for entry in A.github_advisories(urls): out.append({"source": entry["source"], "kind": "advisory-feed", "status": entry["status"], "cves": len(entry.get("advisories") or []), diff --git a/cc-ci-plan/test-advisory-scan.py b/cc-ci-plan/test-advisory-scan.py index e964211..3147e83 100755 --- a/cc-ci-plan/test-advisory-scan.py +++ b/cc-ci-plan/test-advisory-scan.py @@ -537,6 +537,34 @@ class TestReleaseLineSemantics(unittest.TestCase): self.assertEqual(rep["fixed_by_this_upgrade"], ["CVE-2025-49844"]) +class TestAlreadyFixedOnFromLine(unittest.TestCase): + """A fix that landed on the line we upgrade FROM was already ours before the upgrade.""" + + def test_backport_to_our_own_line_is_not_credited(self): + # mattermost patches every maintained line at once. 10.11.22 -> 10.12.4 crosses 10.12.1, but + # 10.11.22 is already past 10.11.4, so the deployment HAD the fix. Counting it credits the + # upgrade with work it did not do. + rep = run_scan([gh("mattermost/mattermost", + [adv("CVE-1", patched="10.11.4; 10.12.1; 10.5.12")])], + v_from="10.11.22", v_to="10.12.4", + urls=["https://github.com/mattermost/mattermost"]) + self.assertEqual(rep["fixed_by_this_upgrade"], []) + + def test_a_fix_ABOVE_our_position_on_the_same_line_still_counts(self): + rep = run_scan([gh("mattermost/mattermost", [adv("CVE-2", patched="10.11.30; 10.12.1")])], + v_from="10.11.22", v_to="10.12.4", + urls=["https://github.com/mattermost/mattermost"]) + self.assertEqual(rep["fixed_by_this_upgrade"], ["CVE-2"]) + + def test_placeholders_never_feed_this_rule(self): + # "7.4.X" parses to a bare 7.4, which would read as "already fixed at 7.4" and silently drop + # a real fix — this is exactly how redis CVE-2024-46981 was lost when the rule was added. + rep = run_scan([gh("redis/redis", [adv("CVE-3", patched="6.2.X, 7.2.X, 7.4.X")])], + v_from="7.4", v_to="8.10", urls=["https://github.com/redis/redis"]) + self.assertIn("CVE-3", rep["indeterminate"]) + self.assertEqual(rep["fixed_by_this_upgrade"], []) + + class TestChangelogAttribution(unittest.TestCase): """Projects that publish no advisory feed still say which release fixed what — in their changelog.""" diff --git a/cc-ci-plan/upstream/mattermost-lts.md b/cc-ci-plan/upstream/mattermost-lts.md index ec90ccd..f4e4145 100644 --- a/cc-ci-plan/upstream/mattermost-lts.md +++ b/cc-ci-plan/upstream/mattermost-lts.md @@ -75,3 +75,12 @@ recreate DB, reimport dump. `DROP DATABASE WITH (FORCE)` requires PostgreSQL 13+ — safe on postgres:15-alpine. The previous inline-label approach (no restore hook) was a defect: raw PGDATA restore without a reload was a silent no-op. Fixed in PR #2 (restore fix cherry-picked from PR #1 ci/pg-restore). + +## NVD CPE fallback +This project publishes nothing machine-readable we can reach — no GitHub advisory feed, +no release-attributable changelog — so its CVE count was `?` (nothing measured). NVD is +CPE-indexed and carries structured version ranges, so it can answer where the vendor +cannot. It LAGS the vendor, so it is a fallback, never the primary source. + +- nvd-cpe: mattermost-team-edition = cpe:2.3:a:mattermost:mattermost_server:*:*:*:*:*:*:*:* +- nvd-cpe: postgres = cpe:2.3:a:postgresql:postgresql:*:*:*:*:*:*:*:* diff --git a/cc-ci-plan/upstream/mumble.md b/cc-ci-plan/upstream/mumble.md index 859ea64..9bdea4c 100644 --- a/cc-ci-plan/upstream/mumble.md +++ b/cc-ci-plan/upstream/mumble.md @@ -19,3 +19,11 @@ - The server image tag is `v-` (e.g. `v1.6.870-4`); the trailing number is the image build, not an app version, and moves independently of upstream releases — `abra recipe upgrade` reports "no new versions" for it, so use `resolve-images.py` to see those bumps. + +## NVD CPE fallback +This project publishes nothing machine-readable we can reach — no GitHub advisory feed, +no release-attributable changelog — so its CVE count was `?` (nothing measured). NVD is +CPE-indexed and carries structured version ranges, so it can answer where the vendor +cannot. It LAGS the vendor, so it is a fallback, never the primary source. + +- nvd-cpe: mumble-server = cpe:2.3:a:mumble:mumble:*:*:*:*:*:*:*:* -- 2.54.0 From 74117c22607abca43a5dc61b2d67532cf31764b0 Mon Sep 17 00:00:00 2001 From: autonomic-bot Date: Tue, 11 Aug 2026 22:16:52 +0000 Subject: [PATCH 2/2] cve-check: record the remedy for a blind recipe, not just the symptom The skill said to render a sourceless recipe as '?'. It now says how to stop it being sourceless: declare an NVD CPE in the registry. That is what took the fleet from two blind recipes to zero, and it is the first thing to try when the sweep flags another. --- .claude/skills/cve-check/SKILL.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/.claude/skills/cve-check/SKILL.md b/.claude/skills/cve-check/SKILL.md index 4dc4d97..8527039 100644 --- a/.claude/skills/cve-check/SKILL.md +++ b/.claude/skills/cve-check/SKILL.md @@ -109,9 +109,18 @@ CRITICAL came from, and an image with no window is not counted 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. +measured, the same way a missing registry file cannot. Render those as **`?`**, not `0`. + +**The fleet is currently at zero such recipes.** The last two — `mattermost-lts` (empty advisory +feed, client-side-rendered bulletins) and `mumble` (nothing published anywhere) — were fixed by +declaring an NVD CPE in their registry: +``` +- nvd-cpe: mattermost-team-edition = cpe:2.3:a:mattermost:mattermost_server:*:*:*:*:*:*:*:* +``` +**If this sweep ever reports a blind recipe again, that is the fix**: find the product's CPE at +nvd.nist.gov and add the line. Prefer a real advisory feed or an attributable changelog when one +exists — NVD lags the vendor — but a lagging source beats no source, and it turns a `?` into a +number. 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. -- 2.54.0