Skip to content

Commit f205dd4

Browse files
committed
feat: NVD CPE check for pinned native libs (RED on HIGH/CRITICAL)
pbs_cve_check.py queries the NVD API for the dissection-verified versions of the seven embedded libraries (openssl/sqlite/zlib/expat/bzip2/ncurses/xz) and aborts the update on HIGH/CRITICAL CVEs; dependency-reference false positives (mutt/OpenLDAP-style CPE matches with vulnerable=false) are filtered out. trivy has no advisory feed for pkg:generic, so this is the only real CVE signal for those libraries. NVD_API_KEY removes the rate-limit sleeps, PBS_SKIP_CVE_CHECK=1 bypasses. Hermetic test with fixture NVD responses covers the clean path, the RED path and the false-positive filter. Live result for the 20260814 pin: sqlite 3.53.1 carries CVE-2026-11822/CVE-2026-11824 (fixed in 3.53.2); the rest are clean.
1 parent cfe6310 commit f205dd4

8 files changed

Lines changed: 326 additions & 0 deletions

File tree

knife.d/update_python_archives.sh

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,18 @@ PYEOF
300300
rm -rf "$so_dir"
301301
[ -n "${PBS_TARBALL_FILE:-}" ] || rm -f "$tarball_tmp"
302302

303+
# NVD CVE check: the pinned native libraries are invisible to trivy
304+
# (pkg:generic has no advisory feed), so query NVD CPE data for the exact
305+
# verified versions and RED on HIGH/CRITICAL. Hermetic tests inject fixtures
306+
# directly into pbs_cve_check.py and skip this block (no network).
307+
if [ -z "${PBS_TARBALL_FILE:-}" ] && [ -z "${PBS_SKIP_CVE_CHECK:-}" ]; then
308+
if ! python3 python/pbs_cve_check.py "$sbom_tmp"; then
309+
echo "PBS pinned native libraries have HIGH/CRITICAL CVEs; update blocked" >&2
310+
rm -f "$sbom_tmp"
311+
exit 1
312+
fi
313+
fi
314+
303315
printf '%s\n' "${changes[@]}" >&2
304316

305317
local start end section tmp

python/BUILD

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,17 @@ sh_test(
3535
],
3636
)
3737

38+
sh_test(
39+
name = "pbs_cve_check_test",
40+
srcs = ["pbs_cve_check_test.sh"],
41+
data = [
42+
":pbs_cve_check.py",
43+
"testdata/cve_fixture_clean.json",
44+
"testdata/cve_fixture_high.json",
45+
"testdata/cve_sbom_mini.json",
46+
],
47+
)
48+
3849
[
3950
python_image(
4051
arch = arch,

python/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,10 @@ ncurses/bzip2/sqlite/xz versions against the manifest — a release that bumps t
8080
native libraries while the CPython version stays the same fails the update
8181
(`trivy image` cannot see statically embedded libraries; the dissection is the
8282
only check that can).
83+
84+
The dissection-verified versions are then checked against NVD CPE data
85+
(`python/pbs_cve_check.py`, one of trivy's own CVE sources — trivy itself has
86+
no advisory feed for source-pinned C libraries): the update is blocked on
87+
HIGH/CRITICAL CVEs. The 20260814 pin e.g. embeds sqlite 3.53.1 with
88+
CVE-2026-11822 / CVE-2026-11824 (fixed in sqlite 3.53.2); set
89+
`PBS_SKIP_CVE_CHECK=1` to bypass in an emergency.

python/pbs_cve_check.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
#!/usr/bin/env python3
2+
"""Check the pinned PBS native libraries against NVD CVE data.
3+
4+
The native libraries embedded in python-build-standalone releases (openssl,
5+
sqlite, zlib, expat, bzip2, ncurses, xz) are source pins (`pkg:generic`) that
6+
trivy and other SBOM scanners cannot match against advisory feeds. NVD tracks
7+
them as CPE products with per-version CVE data, so this queries the NVD API 2.0
8+
with the exact version pinned in the SBOM (which the updater has already
9+
verified against the binary) and fails on HIGH/CRITICAL findings.
10+
11+
False-positive filtering: the NVD `cpeName` query also returns CVEs of other
12+
products (mutt, OpenLDAP, httpd, ...) whose configurations merely reference the
13+
library. A CVE is reported only when the library's own CPE appears as
14+
vulnerable=true in its configuration.
15+
16+
Usage:
17+
pbs_cve_check.py <sbom.spdx.json>
18+
pbs_cve_check.py <sbom.spdx.json> --nvd-fixture <response.json> # hermetic tests
19+
20+
Env: NVD_API_KEY (optional; removes the unauthenticated rate-limit sleeps).
21+
Exit 0: no CVEs or none HIGH/CRITICAL on pinned versions. Exit 1: findings.
22+
"""
23+
import json
24+
import os
25+
import sys
26+
import time
27+
import urllib.error
28+
import urllib.parse
29+
import urllib.request
30+
31+
# SBOM component name -> NVD CPE vendor/product (all verified extractable from
32+
# the binary by python/pbs_embedded_versions.py).
33+
CPES = {
34+
"openssl-3.5": ("openssl", "openssl"),
35+
"sqlite": ("sqlite", "sqlite"),
36+
"zlib": ("zlib", "zlib"),
37+
"expat": ("libexpat", "expat"),
38+
"bzip2": ("bzip2", "bzip2"),
39+
"ncurses": ("gnu", "ncurses"),
40+
"xz": ("tukaani", "xz"),
41+
}
42+
GATE = {"HIGH", "CRITICAL"}
43+
44+
45+
def cpe_version(name, version):
46+
# sqlite's actual_version is "3.53.1.0"; NVD CPEs use "3.53.1".
47+
if name == "sqlite" and version.endswith(".0"):
48+
return version[:-2]
49+
return version
50+
51+
52+
def fetch_nvd(cpe, api_key, fixture):
53+
if fixture is not None:
54+
return fixture.get(cpe, {"vulnerabilities": []})
55+
url = "https://services.nvd.nist.gov/rest/json/cves/2.0?cpeName=" + urllib.parse.quote(cpe, safe=":")
56+
req = urllib.request.Request(url, headers={"User-Agent": "distroless-pbs-cve-check"})
57+
if api_key:
58+
req.add_header("apiKey", api_key)
59+
for attempt in range(4):
60+
try:
61+
with urllib.request.urlopen(req, timeout=60) as resp:
62+
return json.load(resp)
63+
except urllib.error.HTTPError as err:
64+
if err.code == 429:
65+
time.sleep(10 * (attempt + 1)) # unauth limit: ~5 req/30s
66+
continue
67+
raise
68+
sys.exit("NVD API rate limited for " + cpe)
69+
70+
71+
def affects(cve, vendor, product):
72+
# walk every configuration, recursing into children nodes (NVD nests
73+
# dependency/AND-OR groups); keep only vulnerable matches on the product.
74+
def walk(nodes):
75+
for node in nodes:
76+
for match in node.get("cpeMatch", []):
77+
parts = match["criteria"].split(":")
78+
if len(parts) > 5 and parts[3] == vendor and parts[4] == product and match.get("vulnerable"):
79+
return True
80+
if walk(node.get("children", [])):
81+
return True
82+
return False
83+
for config in cve.get("configurations") or []:
84+
if walk(config.get("nodes", [])):
85+
return True
86+
return False
87+
88+
89+
def severity(cve):
90+
metrics = cve.get("metrics", {})
91+
for key in ("cvssMetricV31", "cvssMetricV30", "cvssMetricV2"):
92+
if metrics.get(key):
93+
return metrics[key][0]["cvssData"].get("baseSeverity", "UNKNOWN")
94+
return "UNKNOWN"
95+
96+
97+
def main():
98+
sbom_path, rest = sys.argv[1], sys.argv[2:]
99+
fixture = None
100+
if rest and rest[0] == "--nvd-fixture":
101+
fixture = json.load(open(rest[1]))
102+
doc = json.load(open(sbom_path))
103+
versions = {p["name"]: p["versionInfo"] for p in doc["packages"]}
104+
105+
findings = []
106+
for name, (vendor, product) in sorted(CPES.items()):
107+
if name not in versions:
108+
continue
109+
cpe = "cpe:2.3:a:{}:{}:{}".format(vendor, product, cpe_version(name, versions[name]))
110+
data = fetch_nvd(cpe, os.environ.get("NVD_API_KEY", ""), fixture)
111+
for vuln in data.get("vulnerabilities", []):
112+
cve = vuln["cve"]
113+
if affects(cve, vendor, product):
114+
desc = cve["descriptions"][0]["value"][:90] if cve.get("descriptions") else ""
115+
findings.append((severity(cve), name, cve["id"], desc))
116+
117+
if not findings:
118+
print("no CVEs found for pinned PBS native libraries")
119+
return 0
120+
for sev, name, cid, desc in sorted(findings):
121+
print("{} {} {} {}".format(sev.ljust(8), name.ljust(10), cid, desc))
122+
if any(sev in GATE for sev, _, _, _ in findings):
123+
print("HIGH/CRITICAL CVEs on pinned versions - update blocked")
124+
return 1
125+
return 0
126+
127+
128+
if __name__ == "__main__":
129+
sys.exit(main())

python/pbs_cve_check_test.sh

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
#!/usr/bin/env bash
2+
# Hermetic test for python/pbs_cve_check.py (NVD CPE check for the pinned
3+
# native libraries): clean fixture exits 0, HIGH/CRITICAL fixture exits 1 and
4+
# reports the library's own CVE while ignoring other-product false positives
5+
# (mutt-style CPE references with vulnerable=false).
6+
set -euo pipefail
7+
8+
cd "$TEST_SRCDIR/${TEST_WORKSPACE:-_main}"
9+
10+
out=$(python3 python/pbs_cve_check.py python/testdata/cve_sbom_mini.json --nvd-fixture python/testdata/cve_fixture_clean.json)
11+
echo "$out" | grep -q 'no CVEs found' || { echo "clean: expected no findings"; exit 1; }
12+
13+
set +e
14+
out=$(python3 python/pbs_cve_check.py python/testdata/cve_sbom_mini.json --nvd-fixture python/testdata/cve_fixture_high.json 2>&1)
15+
rc=$?
16+
set -e
17+
[ "$rc" = 1 ] || { echo "high: expected exit 1, got $rc"; exit 1; }
18+
echo "$out" | grep -q 'CVE-2026-99999' || { echo "high: openssl CVE missing"; exit 1; }
19+
echo "$out" | grep -q 'CVE-2026-00001' || { echo "high: MEDIUM zlib CVE should still be reported"; exit 1; }
20+
! echo "$out" | grep -q 'CVE-2009-1390' || { echo "high: mutt false positive leaked"; exit 1; }
21+
echo "$out" | grep -q 'update blocked' || { echo "high: gate message missing"; exit 1; }
22+
23+
echo "pbs_cve_check OK"
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"cpe:2.3:a:openssl:openssl:3.5.7": {
3+
"vulnerabilities": []
4+
},
5+
"cpe:2.3:a:sqlite:sqlite:3.53.1": {
6+
"vulnerabilities": []
7+
},
8+
"cpe:2.3:a:zlib:zlib:1.3.2": {
9+
"vulnerabilities": []
10+
}
11+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
{
2+
"cpe:2.3:a:openssl:openssl:3.5.7": {
3+
"vulnerabilities": [
4+
{
5+
"cve": {
6+
"id": "CVE-2026-99999",
7+
"descriptions": [
8+
{
9+
"lang": "en",
10+
"value": "Test: openssl 3.5.7 out-of-bounds read in the X.509 parser"
11+
}
12+
],
13+
"metrics": {
14+
"cvssMetricV31": [
15+
{
16+
"cvssData": {
17+
"baseSeverity": "HIGH",
18+
"baseScore": 8.1
19+
}
20+
}
21+
]
22+
},
23+
"configurations": [
24+
{
25+
"nodes": [
26+
{
27+
"cpeMatch": [
28+
{
29+
"criteria": "cpe:2.3:a:openssl:openssl:3.5.7:*:*:*:*:*:*:*:*",
30+
"vulnerable": true
31+
}
32+
]
33+
}
34+
]
35+
}
36+
]
37+
}
38+
},
39+
{
40+
"cve": {
41+
"id": "CVE-2009-1390",
42+
"descriptions": [
43+
{
44+
"lang": "en",
45+
"value": "mutt 1.5.19 when linked against OpenSSL: this CVE is about mutt, not openssl"
46+
}
47+
],
48+
"metrics": {
49+
"cvssMetricV31": [
50+
{
51+
"cvssData": {
52+
"baseSeverity": "HIGH"
53+
}
54+
}
55+
]
56+
},
57+
"configurations": [
58+
{
59+
"nodes": [
60+
{
61+
"cpeMatch": [
62+
{
63+
"criteria": "cpe:2.3:a:mutt:mutt:1.5.19:*:*:*:*:*:*:*:*",
64+
"vulnerable": true
65+
},
66+
{
67+
"criteria": "cpe:2.3:a:openssl:openssl:*:*:*:*:*:*:*:*",
68+
"vulnerable": false
69+
}
70+
]
71+
}
72+
]
73+
}
74+
]
75+
}
76+
}
77+
]
78+
},
79+
"cpe:2.3:a:sqlite:sqlite:3.53.1": {
80+
"vulnerabilities": []
81+
},
82+
"cpe:2.3:a:zlib:zlib:1.3.2": {
83+
"vulnerabilities": [
84+
{
85+
"cve": {
86+
"id": "CVE-2026-00001",
87+
"descriptions": [
88+
{
89+
"lang": "en",
90+
"value": "Test: zlib 1.3.2 minor inflate issue"
91+
}
92+
],
93+
"metrics": {
94+
"cvssMetricV31": [
95+
{
96+
"cvssData": {
97+
"baseSeverity": "MEDIUM"
98+
}
99+
}
100+
]
101+
},
102+
"configurations": [
103+
{
104+
"nodes": [
105+
{
106+
"cpeMatch": [
107+
{
108+
"criteria": "cpe:2.3:a:zlib:zlib:1.3.2:*:*:*:*:*:*:*:*",
109+
"vulnerable": true
110+
}
111+
]
112+
}
113+
]
114+
}
115+
]
116+
}
117+
}
118+
]
119+
}
120+
}

python/testdata/cve_sbom_mini.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"spdxVersion": "SPDX-2.3",
3+
"dataLicense": "CC0-1.0",
4+
"SPDXID": "SPDXRef-DOCUMENT",
5+
"name": "pbs-mini",
6+
"packages": [
7+
{"name": "python-build-standalone", "SPDXID": "SPDXRef-PBS", "versionInfo": "20260814"},
8+
{"name": "openssl-3.5", "SPDXID": "SPDXRef-openssl_3_5", "versionInfo": "3.5.7"},
9+
{"name": "sqlite", "SPDXID": "SPDXRef-sqlite", "versionInfo": "3.53.1.0"},
10+
{"name": "zlib", "SPDXID": "SPDXRef-zlib", "versionInfo": "1.3.2"}
11+
],
12+
"relationships": []
13+
}

0 commit comments

Comments
 (0)