From 985dc06e47d87424d650d4e55e6341f16b99f0e3 Mon Sep 17 00:00:00 2001 From: autonomic-bot Date: Tue, 11 Aug 2026 22:16:37 +0000 Subject: [PATCH] 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:*:*:*:*:*:*:*:*