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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user