Compensating for NVD Enrichment Lag in Network Vulnerability Scanning
Problem
The National Vulnerability Database (NVD), maintained by NIST, is the canonical source for CVE enrichment data: CVSS scores, CPE product matching, and vulnerability descriptions that most commercial and open-source vulnerability scanners depend on. Since early 2024, NIST has published notices acknowledging a significant enrichment backlog — CVEs are being registered and published without the enrichment data that makes them actionable for scanners.
The practical impact: a CVE may be publicly disclosed, actively exploited, and affecting your infrastructure while appearing in your scanner as “awaiting analysis” or with no CVSS score. Scanner rules that filter on CVSS severity thresholds (e.g., “alert only on Critical and High”) will not surface these CVEs. A scanner that depends on CPE mapping to identify affected products may not match the CVE to your installed software at all.
The network scanner specific problem. Network-level vulnerability scanners (Nessus, OpenVAS, Nuclei, Qualys) match CVEs to network-accessible services using CPE (Common Platform Enumeration) identifiers. If NVD has not yet published the CPE matching data for a CVE, the scanner cannot correlate the CVE to the service it affects. The scanner completes successfully and reports a clean result for a host that is actually vulnerable.
Scale of the backlog. By mid-2024, NIST reported over 13,000 CVEs awaiting enrichment. The problem persisted into 2025, and the April 2025 near-shutdown of MITRE’s CVE program compounded the issue by creating uncertainty about upstream CVE registration capacity.
Why this matters for network-facing components. Network components — NGINX, OpenSSL, SSH servers, VPN gateways, database ports — are the highest-value targets for attackers. A CVE in OpenSSL that affects your TLS stack or a CVE in SSHD that allows authentication bypass is exactly the kind of vulnerability that must be caught by network scanning. If NVD enrichment lag causes these to be missed, the hosts are scanned and cleared while remaining vulnerable.
Target systems: any organisation relying on NVD-enriched vulnerability scanning; network security teams responsible for CVE coverage of internet-facing and internal services; teams using Nessus, OpenVAS, Tenable.io, Qualys, Rapid7, or similar scanners.
Threat Model
Adversary 1 — Exploitation during NVD enrichment lag. A CVE in an internet-facing service is published. NVD has not yet enriched it with CVSS or CPE data. Your scanner runs, finds no match, and reports the host clean. An attacker using a PoC circulating in underground channels exploits the CVE before your scanner catches up.
Adversary 2 — Compliance false-positive clean. Your quarterly vulnerability scan report is submitted to auditors. The report shows no Critical CVEs. Three CVEs affecting your TLS infrastructure are not in the report because NVD enrichment lag prevented the scanner from classifying them. The compliance report is accurate per scanner output but inaccurate per actual risk.
Adversary 3 — CVSS-based SLA circumvented by unenriched CVE. Your patch SLA requires Critical CVEs to be patched in 7 days. A CVE with eventual CVSS 9.8 is unenriched for 60 days, so it never triggers the 7-day SLA. By the time NVD enriches it, it is already in the KEV catalog.
Configuration / Implementation
Step 1 — Measure your scanner’s NVD dependency
# Test: compare scanner results against OSV for the same software
# OpenVAS/GVM example — export scan results and cross-reference with OSV
# First, get the current unenriched CVEs for software you're scanning
# Query NIST NVD API for CVEs in "awaiting analysis" state
curl -s "https://services.nvd.nist.gov/rest/json/cves/2.0?cvssV3Severity=CRITICAL&noRejected" \
-H "apiKey: ${NVD_API_KEY}" | \
jq '[.vulnerabilities[] | select(.cve.vulnStatus == "Awaiting Analysis") | .cve.id]' | \
head -20
# This shows Critical CVEs that NVD has not yet scored — your scanner may be missing these
# Cross-reference: query OSV for the same CVEs
# OSV has independent enrichment and often has data before NVD
curl -s -X POST "https://api.osv.dev/v1/vulns/CVE-2024-XXXX" | jq '.severity'
Step 2 — Add OSV as a supplementary feed
#!/usr/bin/env python3
# scripts/osv-supplementary-scan.py
# Queries OSV for vulnerabilities affecting packages/software on your hosts
# Independent of NVD — often has earlier enrichment
import json
import urllib.request
import sys
from dataclasses import dataclass
@dataclass
class SoftwareComponent:
name: str
version: str
ecosystem: str # e.g., "PyPI", "npm", "Go", "Debian", "Alpine"
def query_osv_for_package(component: SoftwareComponent) -> list[dict]:
"""Query OSV for vulnerabilities affecting a specific package version."""
url = "https://api.osv.dev/v1/query"
query = json.dumps({
"version": component.version,
"package": {
"name": component.name,
"ecosystem": component.ecosystem
}
}).encode()
try:
req = urllib.request.Request(
url, data=query,
headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read()).get("vulns", [])
except Exception as e:
print(f"Warning: OSV query failed for {component.name}: {e}", file=sys.stderr)
return []
def get_severity_from_osv(vuln: dict) -> str:
"""Extract severity from OSV entry — uses CVSS if available."""
for severity in vuln.get("severity", []):
if severity.get("type") == "CVSS_V3":
score_str = severity.get("score", "")
# Parse CVSS vector for base score
for part in score_str.split("/"):
if "AV:" in score_str:
# Rough severity mapping from CVSS vector
if "CVSS:3" in score_str:
return "CVSS_V3"
return "unknown"
# Example: scan network-facing components
NETWORK_COMPONENTS = [
SoftwareComponent("openssl", "3.0.7", "ecosystem-unknown"), # Use actual version
SoftwareComponent("nginx", "1.24.0", "ecosystem-unknown"),
SoftwareComponent("openssh", "9.0p1", "ecosystem-unknown"),
]
# For Linux packages, query the OS package ecosystem
DEBIAN_PACKAGES = [
SoftwareComponent("openssl", "3.0.11-1~deb12u2", "Debian"),
SoftwareComponent("nginx", "1.22.1-9", "Debian"),
]
if __name__ == "__main__":
all_findings = []
for pkg in DEBIAN_PACKAGES:
vulns = query_osv_for_package(pkg)
for vuln in vulns:
vuln_id = vuln.get("id", "")
aliases = vuln.get("aliases", [])
cve_ids = [a for a in aliases if a.startswith("CVE-")]
all_findings.append({
"package": pkg.name,
"version": pkg.version,
"osv_id": vuln_id,
"cve_ids": cve_ids,
"summary": vuln.get("summary", ""),
"published": vuln.get("published", ""),
})
print(f"OSV findings: {len(all_findings)}")
for f in all_findings:
cves = ", ".join(f["cve_ids"]) or f["osv_id"]
print(f" {f['package']} {f['version']}: {cves}")
print(f" {f['summary'][:80]}")
Step 3 — Integrate GitHub Advisory Database
#!/bin/bash
# scripts/ghsa-supplement.sh
# Queries GitHub Advisory Database for CVEs affecting specific software
# GHSA often has enrichment before NVD
PACKAGE_NAME="${1:?Usage: $0 <package-name> <ecosystem>}"
ECOSYSTEM="${2:-DEBIAN}" # DEBIAN, NPM, PIP, MAVEN, etc.
# GitHub GraphQL API — requires GITHUB_TOKEN
GH_TOKEN="${GITHUB_TOKEN:?Set GITHUB_TOKEN environment variable}"
curl -s -X POST "https://api.github.com/graphql" \
-H "Authorization: bearer $GH_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"query\": \"query {
securityVulnerabilities(
first: 20,
ecosystem: ${ECOSYSTEM},
package: \\\"${PACKAGE_NAME}\\\"
) {
nodes {
advisory {
ghsaId
summary
severity
publishedAt
identifiers { type value }
cvss { score vectorString }
}
vulnerableVersionRange
firstPatchedVersion { identifier }
}
}
}\"
}" | jq '
.data.securityVulnerabilities.nodes[] |
{
ghsa: .advisory.ghsaId,
cves: [.advisory.identifiers[] | select(.type=="CVE") | .value],
severity: .advisory.severity,
cvss_score: .advisory.cvss.score,
summary: .advisory.summary,
affects: .vulnerableVersionRange,
fixed_in: .firstPatchedVersion.identifier,
published: .advisory.publishedAt
}'
Step 4 — Build a unified CVE feed aggregator
#!/usr/bin/env python3
# scripts/unified-cve-feed.py
# Aggregates CVE data from NVD, OSV, GitHub Advisory, and CISA KEV
# Produces a unified view that compensates for any single source's lag
import json
import urllib.request
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class UnifiedCVE:
cve_id: str
sources: list[str] = field(default_factory=list)
cvss_score: Optional[float] = None
cvss_source: Optional[str] = None # Which source provided the CVSS score
severity: str = "UNKNOWN"
summary: str = ""
affected_packages: list[str] = field(default_factory=list)
fixed_versions: dict[str, str] = field(default_factory=dict)
in_kev: bool = False
epss_score: Optional[float] = None
class UnifiedCVEFeed:
def __init__(self, nvd_api_key: Optional[str] = None):
self.nvd_api_key = nvd_api_key
self.kev_catalog: set[str] = set()
self._load_kev()
def _load_kev(self):
"""Load CISA KEV catalog."""
try:
url = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json"
with urllib.request.urlopen(url, timeout=15) as resp:
data = json.loads(resp.read())
self.kev_catalog = {v["cveID"] for v in data.get("vulnerabilities", [])}
print(f"Loaded {len(self.kev_catalog)} KEV entries")
except Exception as e:
print(f"Warning: KEV load failed: {e}")
def query_nvd(self, cve_id: str) -> Optional[dict]:
"""Query NVD API for a specific CVE."""
url = f"https://services.nvd.nist.gov/rest/json/cves/2.0?cveId={cve_id}"
headers = {}
if self.nvd_api_key:
headers["apiKey"] = self.nvd_api_key
try:
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=15) as resp:
data = json.loads(resp.read())
vulns = data.get("vulnerabilities", [])
if vulns:
return vulns[0].get("cve", {})
except Exception:
pass
return None
def query_osv(self, cve_id: str) -> Optional[dict]:
"""Query OSV for a specific CVE."""
url = f"https://api.osv.dev/v1/vulns/{cve_id}"
try:
with urllib.request.urlopen(url, timeout=15) as resp:
return json.loads(resp.read())
except Exception:
pass
return None
def get_unified(self, cve_id: str) -> UnifiedCVE:
"""Build a unified CVE record from all available sources."""
result = UnifiedCVE(cve_id=cve_id)
result.in_kev = cve_id in self.kev_catalog
# Try NVD first
nvd_data = self.query_nvd(cve_id)
if nvd_data:
result.sources.append("NVD")
status = nvd_data.get("vulnStatus", "")
result.summary = nvd_data.get("descriptions", [{}])[0].get("value", "")
if status not in ("Awaiting Analysis", "Undergoing Analysis"):
# NVD has enriched this CVE
for metric in nvd_data.get("metrics", {}).get("cvssMetricV31", []):
result.cvss_score = metric["cvssData"]["baseScore"]
result.severity = metric["cvssData"]["baseSeverity"]
result.cvss_source = "NVD"
break
else:
result.summary += f" [NVD status: {status}]"
# Supplement with OSV regardless of NVD status
osv_data = self.query_osv(cve_id)
if osv_data:
result.sources.append("OSV")
if not result.summary:
result.summary = osv_data.get("summary", "")
# OSV may have CVSS when NVD doesn't
if result.cvss_score is None:
for sev in osv_data.get("severity", []):
if sev.get("type") == "CVSS_V3":
result.cvss_source = "OSV"
# Parse base score from CVSS vector would go here
# Extract affected packages from OSV
for affected in osv_data.get("affected", []):
pkg = affected.get("package", {})
pkg_name = f"{pkg.get('ecosystem', '')}/{pkg.get('name', '')}"
if pkg_name not in result.affected_packages:
result.affected_packages.append(pkg_name)
# Determine final severity
if result.in_kev:
result.severity = "KEV-EXPLOITED"
elif result.cvss_score is not None:
if result.cvss_score >= 9.0:
result.severity = "CRITICAL"
elif result.cvss_score >= 7.0:
result.severity = "HIGH"
elif result.cvss_score >= 4.0:
result.severity = "MEDIUM"
else:
result.severity = "LOW"
return result
Step 5 — Alert on NVD enrichment lag for tracked CVEs
# Prometheus alert for NVD enrichment backlog monitoring
groups:
- name: nvd_enrichment_monitoring
rules:
# Track how many CVEs in your environment are awaiting NVD enrichment
- alert: NVDEnrichmentLagDetected
expr: |
nvd_awaiting_analysis_cves_total > 0
labels:
severity: warning
annotations:
summary: "{{ $value }} CVEs in your tracking scope awaiting NVD enrichment"
description: "These CVEs have no CVSS score in NVD. Supplement with OSV and GHSA data."
# Alert when a KEV CVE was previously unenriched in NVD
- alert: KEVCVEWasUnenriched
expr: |
kev_cve_days_unenriched_in_nvd > 14
labels:
severity: critical
annotations:
summary: "KEV CVE {{ $labels.cve_id }} was in NVD backlog for {{ $value }} days before enrichment"
description: "This CVE was being actively exploited while your NVD-based scanner could not score it."
Expected Behaviour
| Scenario | NVD-only scanner | Multi-source scanner |
|---|---|---|
| CVE published, NVD enrichment pending 60 days | Not found or shown as “unscored” | OSV/GHSA data populates severity; finding surfaced |
| Critical CVE enters KEV before NVD enrichment | Not triggered by severity threshold | KEV status flags as P0 regardless of CVSS |
CVE affecting openssl not yet CPE-mapped in NVD |
Scanner does not match to host | OSV package-name matching finds the affected package |
| CVSS score differs between NVD and OSV | Single NVD score | Unified record notes discrepancy; uses highest score for triage |
| NVD API is unavailable | Scanner stalls or skips enrichment | OSV and GHSA provide fallback data |
Trade-offs
| Aspect | Benefit | Cost | Mitigation |
|---|---|---|---|
| Multi-source aggregation | Full coverage during NVD lag | More complex tooling; potential duplicate findings | Deduplicate by CVE ID in the aggregation layer; unified record per CVE |
| OSV as supplement | Free, no auth, fast enrichment | OSV ecosystem coverage may not include all Linux packages | Use Debian/Alpine/Fedora-specific OSV ecosystems for OS package CVEs |
| GHSA integration | Often earliest enrichment for OSS packages | Requires GitHub token; rate limits apply | Use the token with read:packages scope only; cache responses for 24h |
| Raising severity for KEV-listed unenriched CVEs | Ensures exploited CVEs get attention | May conflict with CVSS-based SLA policies | Update SLA policy to explicitly include KEV status as a severity override |
Failure Modes
| Failure | Symptom | Detection | Recovery |
|---|---|---|---|
| All supplementary APIs rate-limited simultaneously | Scanner produces no results | Scanner reports zero findings (abnormal) | Alert on unexpectedly low finding count; implement exponential backoff |
| OSV data conflicts with NVD data for the same CVE | Inconsistent severity between runs | Different CVSS scores in reports for same CVE | Accept higher score for conservative triage; log source discrepancy |
| NVD enrichment catches up and score differs from OSV | Re-scoring changes priority of previously triaged CVE | Re-scan after NVD enrichment produces different severity | Re-triage CVEs when NVD enriches them; automated re-scan trigger on NVD status change |
| KEV catalog fetch fails | KEV override not applied | KEV count metric drops to zero | Cache last-known KEV catalog; alert on stale KEV data older than 24h |
Related Articles
- CVE Program Resilience and NVD Alternatives — strategic response to NVD enrichment lag and CVE program instability
- CISA KEV Alerting Integration — integrating the KEV catalog as a real-time enrichment source
- EPSS-Driven Patch Prioritization — using EPSS to prioritize the CVEs that NVD does enrich
- Prometheus Security Metrics — monitoring the health of your vulnerability pipeline
- Vulnerability Management Program — the broader programme within which NVD supplement strategies operate