feat(override): nvd cvss fallback when vulnrichment misses a cve

Tester reported CVE-2026-40416 (Microsoft Edge spoofing) kept its
Nessus plugin-bundle CVSS 8.3 after 'Correct CVSS' even though NVD
publishes it as 4.3 MEDIUM. Root cause: CISA Vulnrichment lags by
weeks/months for new CVEs — the CVE wasn't in the snapshot, so the
override service had no verified value to compare against and left
the Nessus score alone.

Pipeline now:
  1. Vulnrichment ZIP snapshot (>25 CVEs) or per-CVE raw (≤25)
  2. For CVE-IDs missing from the Vulnrichment response, fall back
     to https://services.nvd.nist.gov/rest/json/cves/2.0
  3. Parse cvssMetricV31 → V30 → first found base_score + severity
  4. Returned in the same VerifiedCVEData shape so apply_overrides
     can't tell which source filled it

Throttle: 1 req / sec via time.sleep(1) every 5 calls — well under
the unauth NVD limit (~5 req / 30 s). Single-correction runs (one
CVE missing) finish instantly; a batch missing 50 takes ~10 s.

For higher throughput we could later add nvd_config setting with
an API key (50 req / 30 s). Out of scope for now.

After deploy, run 'Correct CVSS' again — CVE-2026-40416 should drop
from 8.3 to 4.3 across all affected Nessus rows.
This commit is contained in:
2026-05-20 09:05:26 +02:00
parent f6ee3ff2fa
commit a99e131326
+85 -3
View File
@@ -254,14 +254,37 @@ class VulnOverrideService:
cve_ids_upper = [c.upper() for c in cve_ids if c]
if len(cve_ids_upper) > self._ZIP_FALLBACK_THRESHOLD:
try:
return self._load_via_zip_snapshot(cve_ids_upper)
verified = self._load_via_zip_snapshot(cve_ids_upper)
except Exception as e:
logger.warning(
"vulnrichment ZIP snapshot failed (%s) — falling back "
"to per-CVE raw fetch", e,
)
# fall through to per-CVE
return self._load_via_per_cve_raw(cve_ids_upper)
verified = self._load_via_per_cve_raw(cve_ids_upper)
else:
verified = self._load_via_per_cve_raw(cve_ids_upper)
# NVD fallback for CVEs Vulnrichment hasn't analysed yet.
# CISA Vulnrichment lags by weeks/months for new CVEs; NVD often
# has authoritative CVSSv3 the day the CVE is published. Tester
# reported CVE-2026-40416 (Microsoft Edge) showed Nessus's
# plugin-bundle 8.3 even after "Correct CVSS" because Vulnrichment
# didn't know that CVE. NVD reported 4.3 the same day.
# Pull only the missing CVEs — bounded and avoids hammering NVD.
missing = [c for c in cve_ids_upper if c not in verified]
if missing:
try:
nvd_data = self._load_via_nvd(missing)
for cve_id, data in nvd_data.items():
if cve_id not in verified:
verified[cve_id] = data
logger.info(
"vulnrichment fallback: %d/%d missing CVEs filled from NVD",
len(nvd_data), len(missing),
)
except Exception as e:
logger.warning("NVD fallback failed: %s", e)
return verified
def _load_via_per_cve_raw(self, cve_ids: List[str]) -> Dict[str, VerifiedCVEData]:
"""Per-CVE GitHub raw fetch. Pfadschema:
@@ -304,6 +327,65 @@ class VulnOverrideService:
logger.error("vulnrichment client error: %s", e)
return verified
def _load_via_nvd(self, cve_ids: List[str]) -> Dict[str, VerifiedCVEData]:
"""
Pull CVSSv3 from the public NVD REST API as a Vulnrichment
fallback. Endpoint: https://services.nvd.nist.gov/rest/json/cves/2.0
Unauthenticated cap is ~5 requests / 30 seconds — fine for the
handful of CVEs that miss Vulnrichment per correction run. For
higher throughput an NVD API key could be added later via the
nvd_config setting.
Returns the same VerifiedCVEData shape as the Vulnrichment
loaders so apply_overrides treats both sources identically.
"""
import httpx
import time
verified: Dict[str, VerifiedCVEData] = {}
# Throttle to be polite — 1 req/sec stays well below the unauth limit.
with httpx.Client(timeout=15.0, follow_redirects=True) as client:
for idx, cve_id in enumerate(cve_ids):
if idx and idx % 5 == 0:
time.sleep(1.0) # crude throttle
try:
r = client.get(
"https://services.nvd.nist.gov/rest/json/cves/2.0",
params={"cveId": cve_id},
)
if r.status_code != 200:
continue
data = r.json()
items = data.get("vulnerabilities") or []
if not items:
continue
cve = items[0].get("cve", {})
metrics = cve.get("metrics", {})
# Try CVSSv3.1 first, then v3.0
cvss_score: Optional[float] = None
severity_label: Optional[str] = None
for key in ("cvssMetricV31", "cvssMetricV30"):
entries = metrics.get(key) or []
if entries:
data_part = entries[0].get("cvssData", {})
cvss_score = data_part.get("baseScore")
severity_label = (
data_part.get("baseSeverity") or ""
).lower() or None
break
if cvss_score is None:
continue
verified[cve_id] = VerifiedCVEData(
cve_id=cve_id,
cvss_score=cvss_score,
severity=severity_label,
source="nvd",
)
except Exception as e:
logger.debug("NVD fetch failed for %s: %s", cve_id, e)
return verified
def _load_via_zip_snapshot(self, cve_ids: List[str]) -> Dict[str, VerifiedCVEData]:
"""Single 249 MB ZIP download → walk locally for the requested CVE
files. Two orders of magnitude faster than per-CVE 404 lookups