advisory-scan: NVD by CPE, so mattermost and mumble stop scanning as '?' #11
@@ -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.
|
||||
|
||||
@@ -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: <image-key> = <cpe:2.3:...>`.
|
||||
|
||||
> **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
|
||||
|
||||
+138
-2
@@ -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: <key> = <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.
|
||||
|
||||
@@ -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 []),
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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:*:*:*:*:*:*:*:*
|
||||
|
||||
@@ -19,3 +19,11 @@
|
||||
- The server image tag is `v<version>-<build>` (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:*:*:*:*:*:*:*:*
|
||||
|
||||
Reference in New Issue
Block a user