|
| 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()) |
0 commit comments