Files
vulncheck/app/services/enrichment_service.py
T
vulncheck 0ba07131cb feat(enrich): Mozilla MFSA severity source for fresh Firefox CVEs
Mozilla publishes a per-CVE impact rating (critical/high/moderate/low)
in foundation-security-advisories before NVD/cvelistV5 have a score — the
gap on brand-new Firefox CVEs. New mozilla_advisory_service builds a
{cve: severity/title/fixed_in/esr_only} index from the MFSA repo (line
parser, no PyYAML dep; 24h cache; github_pat lifts the rate limit) and
apply_mozilla_severity fills the placeholder severity in enrich_vulnerabilities
when the vuln has no CVSS-derived value. Mozilla carries no numeric CVSS,
so it only sets severity/description, never clobbers a real score.

fixed_in / esr_only (e.g. only 'Firefox ESR 115.13' → regular Firefox
unaffected) is the authoritative ESR discriminator, stored for the
upcoming Firefox-scan ESR exclusion.
2026-07-23 13:57:57 +02:00

876 lines
33 KiB
Python

"""
Vulnerability Enrichment Service
Reichert CVE-Daten mit Threat-Intelligence aus offenen Quellen an:
- EPSS (Exploit Prediction Scoring System) via FIRST.org
- CISA KEV (Known Exploited Vulnerabilities) catalog
- ENISA EUVD (EU exploited + critical vulnerabilities)
Alle Quellen sind kostenlos und benötigen keinen API-Key.
"""
import json
import logging
import os
import re
import time
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Iterable
import httpx
from sqlalchemy.orm import Session
from app.models.setting import Setting
from app.models.vulnerability import Vulnerability
logger = logging.getLogger(__name__)
# ---------- Endpoints ----------
EPSS_API_URL = "https://api.first.org/data/v1/epss"
KEV_FEED_URL = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json"
# Full EUVD-exploited catalog (paginated, ~1500+ CVEs).
# The bare /exploitedvulnerabilities endpoint only returns a curated "recent 4" widget.
EUVD_SEARCH_EXPLOITED_URL = "https://euvdservices.enisa.europa.eu/api/search?exploited=true"
# Small curated ENISA-flagged critical list (handful of entries).
EUVD_CRITICAL_URL = "https://euvdservices.enisa.europa.eu/api/criticalvulnerabilities"
# Pagination size for search API
EUVD_PAGE_SIZE = 100
# Hard cap on pages to avoid runaway loop
EUVD_MAX_PAGES = 50
# ---------- Cache keys (settings table) ----------
KEV_CACHE_KEY = "enrichment_kev_cache"
KEV_CACHE_TS_KEY = "enrichment_kev_cache_updated_at"
KEV_TTL_HOURS = 24
EUVD_CACHE_KEY = "enrichment_euvd_cache"
EUVD_CACHE_TS_KEY = "enrichment_euvd_cache_updated_at"
EUVD_TTL_HOURS = 24
# ---------- NVD CVE dates (published / lastModified) ----------
# NVD is the only authoritative source for a CVE's official publish date.
# Nessus/Wazuh imports never carried it, so published_date was all-NULL
# and the "Newly Published" sort was meaningless. We backfill it here.
NVD_CVE_API_URL = "https://services.nvd.nist.gov/rest/json/cves/2.0"
# Official CVE.org cvelistV5 raw JSON — primary, non-rate-limited date
# source (cveMetadata.datePublished / .dateUpdated for every CVE).
CVELISTV5_RAW_BASE = "https://raw.githubusercontent.com/CVEProject/cvelistV5/main/cves"
# Per-run cap on date lookups. cvelistV5 has no aggressive rate limit, so
# this can be generous; a fresh DB still drains over a couple nightly runs.
CVE_DATE_MAX_LOOKUPS_PER_RUN = 4000
# Above this many missing CVEs, one cvelistV5 ZIP snapshot (reusing the
# CVSS cascade's shared 12h disk cache) beats thousands of per-CVE HTTP
# round-trips. Below it, per-CVE raw fetches avoid a 557 MB download for a
# handful of new CVEs.
CVE_DATE_ZIP_THRESHOLD = 200
# Persistent cache — a CVE's published date is immutable, lastModified
# changes rarely. No TTL: once cached we never refetch (keeps us well
# under NVD's rate limit).
NVD_DATE_CACHE_KEY = "enrichment_nvd_date_cache"
# Per-run lookup cap so a fresh DB with thousands of CVEs doesn't hammer
# NVD in one go — the nightly job catches up incrementally over days.
# Key-aware: without a key each request must sleep 6.5s, so a big cap
# would stall the whole enrichment job for the better part of an hour.
# With a key the floor is 0.7s, so we can drain a much larger batch.
NVD_MAX_LOOKUPS_NO_KEY = 150 # ~16 min/run
NVD_MAX_LOOKUPS_WITH_KEY = 1500 # ~18 min/run
# NVD rate limit: 5 req / 30s without key, 50 req / 30s with key.
# Sleep just over the floor to stay safe.
NVD_SLEEP_NO_KEY = 6.5
NVD_SLEEP_WITH_KEY = 0.7
# ---------- Toggles ----------
SETTING_EPSS_ENABLED = "enrichment_epss_enabled"
SETTING_KEV_ENABLED = "enrichment_kev_enabled"
SETTING_EUVD_ENABLED = "enrichment_euvd_enabled"
SETTING_NVD_DATES_ENABLED = "enrichment_nvd_dates_enabled"
# ---------- HTTP ----------
HTTP_TIMEOUT = 30.0
EPSS_BATCH_SIZE = 100 # FIRST EPSS API supports comma-separated CVE list
# ---------- Helpers ----------
CVE_REGEX = re.compile(r"CVE-\d{4}-\d{4,}")
class EnrichmentError(Exception):
"""Raised when enrichment cannot complete."""
def _setting_bool(db: Session, key: str, default: bool = True) -> bool:
s = db.query(Setting).filter(Setting.key == key).first()
if not s or s.value is None:
return default
return str(s.value).strip().lower() in {"1", "true", "yes", "on"}
def _set_setting(db: Session, key: str, value: str, description: str = "") -> None:
s = db.query(Setting).filter(Setting.key == key).first()
if s:
s.value = value
else:
s = Setting(key=key, value=value, description=description)
db.add(s)
db.commit()
# ============================================================
# EPSS
# ============================================================
def fetch_epss_scores(cve_ids: Iterable[str]) -> Dict[str, Dict[str, float]]:
"""
Fetch EPSS scores for a list of CVE IDs.
Returns: { "CVE-XXXX-NNNN": {"epss": 0.123, "percentile": 0.94}, ... }
Missing CVEs are simply absent from the response.
"""
cve_list = [c for c in cve_ids if c and c.startswith("CVE-")]
if not cve_list:
return {}
results: Dict[str, Dict[str, float]] = {}
with httpx.Client(timeout=HTTP_TIMEOUT) as client:
for i in range(0, len(cve_list), EPSS_BATCH_SIZE):
batch = cve_list[i:i + EPSS_BATCH_SIZE]
params = {"cve": ",".join(batch)}
try:
resp = client.get(EPSS_API_URL, params=params)
resp.raise_for_status()
payload = resp.json()
except httpx.HTTPError as e:
logger.warning(f"EPSS batch fetch failed ({len(batch)} CVEs): {e}")
continue
for entry in payload.get("data", []):
cve = entry.get("cve")
if not cve:
continue
try:
score = float(entry.get("epss")) if entry.get("epss") is not None else None
pct = float(entry.get("percentile")) if entry.get("percentile") is not None else None
except (TypeError, ValueError):
continue
if score is None:
continue
results[cve] = {"epss": score, "percentile": pct or 0.0}
logger.info(f"EPSS: fetched {len(results)} scores for {len(cve_list)} CVEs")
return results
# ============================================================
# CISA KEV
# ============================================================
def _load_kev_cache(db: Session) -> Optional[Dict[str, dict]]:
"""Returns {cve_id: kev_entry} from settings cache, or None if missing/stale."""
ts_setting = db.query(Setting).filter(Setting.key == KEV_CACHE_TS_KEY).first()
cache_setting = db.query(Setting).filter(Setting.key == KEV_CACHE_KEY).first()
if not ts_setting or not cache_setting or not cache_setting.value:
return None
try:
ts = datetime.fromisoformat(ts_setting.value)
except ValueError:
return None
if datetime.now() - ts > timedelta(hours=KEV_TTL_HOURS):
return None
try:
return json.loads(cache_setting.value)
except json.JSONDecodeError:
return None
def _store_kev_cache(db: Session, kev_map: Dict[str, dict]) -> None:
_set_setting(db, KEV_CACHE_KEY, json.dumps(kev_map), "CISA KEV cache (24h TTL)")
_set_setting(db, KEV_CACHE_TS_KEY, datetime.now().isoformat(), "Timestamp of last KEV refresh")
def fetch_kev_catalog(db: Session, force_refresh: bool = False) -> Dict[str, dict]:
"""
Fetch CISA KEV catalog. Cached for 24h in `settings` table.
Returns: { "CVE-XXXX-NNNN": {date_added, ransomware_use, short_description}, ... }
"""
if not force_refresh:
cached = _load_kev_cache(db)
if cached is not None:
logger.debug(f"KEV: using cached catalog ({len(cached)} entries)")
return cached
logger.info("KEV: fetching fresh catalog from CISA")
try:
with httpx.Client(timeout=HTTP_TIMEOUT) as client:
resp = client.get(KEV_FEED_URL)
resp.raise_for_status()
payload = resp.json()
except httpx.HTTPError as e:
logger.error(f"KEV fetch failed: {e}")
# Fall back to stale cache if present
cache_setting = db.query(Setting).filter(Setting.key == KEV_CACHE_KEY).first()
if cache_setting and cache_setting.value:
try:
logger.warning("KEV: returning stale cache after fetch failure")
return json.loads(cache_setting.value)
except json.JSONDecodeError:
pass
raise EnrichmentError(f"KEV fetch failed and no cache available: {e}")
kev_map: Dict[str, dict] = {}
for entry in payload.get("vulnerabilities", []):
cve = entry.get("cveID")
if not cve:
continue
kev_map[cve] = {
"date_added": entry.get("dateAdded"),
"ransomware_use": (entry.get("knownRansomwareCampaignUse") or "").lower() == "known",
"short_description": entry.get("shortDescription"),
# Extra fields for the advisory/awareness feed (enrichment ignores them).
"vendor": entry.get("vendorProject"),
"product": entry.get("product"),
"name": entry.get("vulnerabilityName"),
}
_store_kev_cache(db, kev_map)
logger.info(f"KEV: cached {len(kev_map)} entries from CISA")
return kev_map
# ============================================================
# ENISA EUVD
# ============================================================
def _load_euvd_cache(db: Session) -> Optional[Dict[str, dict]]:
"""Returns {cve_id: euvd_entry} from settings cache, or None if missing/stale."""
ts_setting = db.query(Setting).filter(Setting.key == EUVD_CACHE_TS_KEY).first()
cache_setting = db.query(Setting).filter(Setting.key == EUVD_CACHE_KEY).first()
if not ts_setting or not cache_setting or not cache_setting.value:
return None
try:
ts = datetime.fromisoformat(ts_setting.value)
except ValueError:
return None
if datetime.now() - ts > timedelta(hours=EUVD_TTL_HOURS):
return None
try:
return json.loads(cache_setting.value)
except json.JSONDecodeError:
return None
def _store_euvd_cache(db: Session, euvd_map: Dict[str, dict]) -> None:
_set_setting(db, EUVD_CACHE_KEY, json.dumps(euvd_map), "ENISA EUVD cache (24h TTL)")
_set_setting(db, EUVD_CACHE_TS_KEY, datetime.now().isoformat(), "Timestamp of last EUVD refresh")
def _parse_euvd_date(raw) -> Optional[str]:
"""
Convert ENISA date strings to ISO-8601 if possible.
Returns the original string when we can't parse (caller stores raw).
ENISA observed formats: 'Apr 29, 2026, 3:10:37 PM', ISO-8601, plain dates.
"""
if not raw or not isinstance(raw, str):
return None
raw = raw.strip()
# Try ISO first (with possible trailing Z)
try:
return datetime.fromisoformat(raw.replace("Z", "+00:00")).isoformat()
except ValueError:
pass
# ENISA's "%b %d, %Y, %I:%M:%S %p" e.g. "Apr 29, 2026, 3:10:37 PM"
for fmt in ("%b %d, %Y, %I:%M:%S %p", "%b %d, %Y", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
try:
return datetime.strptime(raw, fmt).isoformat()
except ValueError:
continue
return raw # store as-is, downstream handles failure gracefully
def _parse_euvd_entry(entry: dict, is_critical: bool) -> Iterable[tuple]:
"""
EUVD entries carry one EUVD-ID and CVE aliases (sometimes as
newline-separated string, sometimes as list).
Yields (cve_id, payload) tuples for every CVE we can extract.
"""
euvd_id = entry.get("id") or entry.get("euvd_id") or entry.get("euvdId")
# Prefer exploitedSince > datePublished > others
raw_date = (
entry.get("exploitedSince")
or entry.get("exploited_since")
or entry.get("datePublished")
or entry.get("date_published")
or entry.get("dateAdded")
or entry.get("date_added")
or entry.get("published")
)
date_added = _parse_euvd_date(raw_date) if raw_date else None
# Collect CVE-bearing strings from common fields.
cve_candidates: List[str] = []
for key in ("aliases", "references", "description"):
v = entry.get(key)
if isinstance(v, str):
cve_candidates.append(v) # ENISA uses newline-separated strings
elif isinstance(v, list):
for a in v:
if isinstance(a, str):
cve_candidates.append(a)
elif isinstance(a, dict):
cve_candidates.extend(x for x in a.values() if isinstance(x, str))
for key in ("cveId", "cve_id", "cve"):
v = entry.get(key)
if isinstance(v, str):
cve_candidates.append(v)
elif isinstance(v, list):
cve_candidates.extend(x for x in v if isinstance(x, str))
# Last-resort: regex over JSON dump (catches CVE strings buried anywhere)
if not cve_candidates:
cve_candidates.append(json.dumps(entry, default=str))
cves = set()
for s in cve_candidates:
for match in CVE_REGEX.findall(s):
cves.add(match.upper())
for cve in cves:
yield cve, {
"date_added": date_added,
"critical": is_critical,
"euvd_id": euvd_id,
}
def _extract_entries(payload) -> List[dict]:
"""Find the list-of-entries inside an ENISA response (varying shapes)."""
if isinstance(payload, list):
return [e for e in payload if isinstance(e, dict)]
if isinstance(payload, dict):
for key in ("items", "data", "vulnerabilities", "results"):
v = payload.get(key)
if isinstance(v, list):
return [e for e in v if isinstance(e, dict)]
if "id" in payload:
return [payload]
return []
def _merge_euvd_entries(
entries: List[dict],
is_critical: bool,
merged: Dict[str, dict],
) -> int:
"""Apply parser to entries, merge into `merged` map. Returns count of entries processed."""
count = 0
for entry in entries:
for cve, info in _parse_euvd_entry(entry, is_critical):
existing = merged.get(cve)
if existing:
existing["critical"] = existing.get("critical") or info["critical"]
if not existing.get("date_added") and info.get("date_added"):
existing["date_added"] = info["date_added"]
if not existing.get("euvd_id") and info.get("euvd_id"):
existing["euvd_id"] = info["euvd_id"]
else:
merged[cve] = info
count += 1
return count
def _fetch_euvd_search_paginated(
client: httpx.Client,
base_url: str,
is_critical: bool,
merged: Dict[str, dict],
) -> int:
"""
Fetch all pages from a /search?... endpoint.
Returns total entries processed.
"""
total_processed = 0
sep = "&" if "?" in base_url else "?"
for page in range(EUVD_MAX_PAGES):
page_url = f"{base_url}{sep}size={EUVD_PAGE_SIZE}&page={page}"
try:
resp = client.get(page_url, headers={"Accept": "application/json"})
resp.raise_for_status()
payload = resp.json()
except httpx.HTTPError as e:
logger.warning(f"EUVD search page {page} failed: {e}")
break
entries = _extract_entries(payload)
if not entries:
break
processed = _merge_euvd_entries(entries, is_critical, merged)
total_processed += processed
# Stop when we've covered the reported total
if isinstance(payload, dict):
reported_total = payload.get("total")
if isinstance(reported_total, int) and (page + 1) * EUVD_PAGE_SIZE >= reported_total:
break
if len(entries) < EUVD_PAGE_SIZE:
break # last page
return total_processed
def fetch_euvd_catalogs(db: Session, force_refresh: bool = False) -> Dict[str, dict]:
"""
Fetch ENISA EUVD catalogs and merge into one CVE map.
Cached for 24h in `settings` table.
Sources:
- /api/search?exploited=true — paginated, ~1500+ CVEs (CISA-KEV-style)
- /api/criticalvulnerabilities — small curated ENISA-priority list (Critical flag)
Returns: { "CVE-XXXX-NNNN": {date_added, critical, euvd_id}, ... }
"""
if not force_refresh:
cached = _load_euvd_cache(db)
if cached is not None:
logger.debug(f"EUVD: using cached catalog ({len(cached)} entries)")
return cached
logger.info("EUVD: fetching fresh catalogs from ENISA")
merged: Dict[str, dict] = {}
exploited_count = 0
critical_count = 0
try:
with httpx.Client(timeout=HTTP_TIMEOUT) as client:
# 1) Exploited (paginated)
exploited_count = _fetch_euvd_search_paginated(
client, EUVD_SEARCH_EXPLOITED_URL, is_critical=False, merged=merged
)
# 2) Curated Critical (small list, single call)
try:
resp = client.get(EUVD_CRITICAL_URL, headers={"Accept": "application/json"})
resp.raise_for_status()
critical_count = _merge_euvd_entries(
_extract_entries(resp.json()), is_critical=True, merged=merged
)
except httpx.HTTPError as e:
logger.warning(f"EUVD critical fetch failed: {e}")
except Exception as e:
logger.error(f"EUVD client error: {e}")
if not merged:
# All fetches failed: try stale cache
cache_setting = db.query(Setting).filter(Setting.key == EUVD_CACHE_KEY).first()
if cache_setting and cache_setting.value:
try:
logger.warning("EUVD: returning stale cache after fetch failure")
return json.loads(cache_setting.value)
except json.JSONDecodeError:
pass
raise EnrichmentError("EUVD fetch failed and no cache available")
_store_euvd_cache(db, merged)
logger.info(
f"EUVD: cached {len(merged)} unique CVEs from ENISA "
f"(exploited pages processed={exploited_count}, critical entries={critical_count})"
)
return merged
# ============================================================
# Apply to DB
# ============================================================
# ============================================================
# NVD CVE dates (published / lastModified)
# ============================================================
def _parse_nvd_dt(raw) -> Optional[str]:
"""NVD timestamps look like '2024-01-31T17:15:34.123'. Keep ISO str."""
if not raw or not isinstance(raw, str):
return None
return raw.strip() or None
def _load_nvd_date_cache(db: Session) -> Dict[str, dict]:
s = db.query(Setting).filter(Setting.key == NVD_DATE_CACHE_KEY).first()
if not s or not s.value:
return {}
try:
return json.loads(s.value)
except json.JSONDecodeError:
return {}
def _cvelistv5_raw_url(cve_id: str) -> Optional[str]:
"""Per-CVE raw URL in the official CVE.org cvelistV5 GitHub repo.
.../cves/2026/9xxx/CVE-2026-9988.json
"""
m = re.fullmatch(r"CVE-(\d{4})-(\d+)", cve_id.upper())
if not m:
return None
year, num = m.group(1), m.group(2)
bucket = f"{int(num) // 1000}xxx"
return f"{CVELISTV5_RAW_BASE}/{year}/{bucket}/{cve_id.upper()}.json"
def _dates_from_cvelistv5(payload: dict) -> dict:
"""Extract published/lastModified from a cvelistV5 record.
cveMetadata.datePublished / .dateUpdated are the authoritative MITRE
timestamps and exist for every published CVE.
"""
meta = payload.get("cveMetadata") or {}
return {
"published": _parse_nvd_dt(meta.get("datePublished")),
"last_modified": _parse_nvd_dt(meta.get("dateUpdated")),
}
def _dates_from_nvd(client: "httpx.Client", cve: str) -> Optional[dict]:
"""NVD fallback for a single CVE (rate-limited; only when cvelistV5 misses)."""
api_key = os.getenv("NVD_API_KEY", "").strip()
headers = {"apiKey": api_key} if api_key else None
resp = client.get(NVD_CVE_API_URL, params={"cveId": cve}, headers=headers)
if resp.status_code == 404:
return {}
resp.raise_for_status()
items = resp.json().get("vulnerabilities") or []
if not items:
return {}
obj = items[0].get("cve") or {}
return {
"published": _parse_nvd_dt(obj.get("published")),
"last_modified": _parse_nvd_dt(obj.get("lastModified")),
}
def fetch_nvd_cve_dates(
db: Session,
cve_ids: Iterable[str],
max_lookups: Optional[int] = None,
) -> Dict[str, dict]:
"""Resolve {cve_id: {"published": iso, "last_modified": iso}}.
Primary source is the official CVE.org **cvelistV5** raw JSON on GitHub
(cveMetadata.datePublished / .dateUpdated) — it has dates for every
published CVE and, unlike the NVD API, is not aggressively rate-limited,
so we can fill thousands of CVEs quickly without the per-request sleep
that used to make this crawl take hours. NVD is kept only as a per-CVE
fallback when cvelistV5 has no record (and honours NVD_API_KEY).
Persistent settings cache (dates are effectively immutable) so each CVE
is fetched once ever; only cache-missing CVEs are looked up, capped at
`max_lookups` per run so a fresh DB drains over a few nightly runs.
NOTE: must run OFF the asyncio loop (scheduler jobs are sync → executed
in a worker thread). Never call this inline on a request handler.
"""
cache = _load_nvd_date_cache(db)
cve_list = [c for c in cve_ids if c and CVE_REGEX.fullmatch(c)]
missing = [c for c in cve_list if c not in cache]
if not missing:
return cache
if max_lookups is None:
max_lookups = CVE_DATE_MAX_LOOKUPS_PER_RUN
# Bulk path: many missing at once → one cvelistV5 ZIP snapshot over the
# WHOLE missing set (a local zip walk is cheap, so it is NOT subject to
# the per-run cap — this dates a fresh DB completely in a single run so
# the "Newly Published" widget isn't stuck showing a partial subset).
# Reuses the CVSS cascade's shared 12h disk cache (download-free when
# CVSS-correction already pulled it).
if len(missing) > CVE_DATE_ZIP_THRESHOLD:
try:
from app.services.vuln_override_service import VulnOverrideService
zip_dates = VulnOverrideService(db).load_cve_dates_via_zip(missing)
for cve, d in zip_dates.items():
cache[cve] = {
"published": _parse_nvd_dt(d.get("published")),
"last_modified": _parse_nvd_dt(d.get("last_modified")),
}
missing = [c for c in missing if c not in cache]
logger.info(
"CVE dates: ZIP filled %d, %d remain for per-CVE fallback",
len(zip_dates), len(missing),
)
except Exception as e:
logger.warning("cvelistV5 ZIP date pass failed (%s) — per-CVE fallback", e)
# Per-CVE path (raw cvelistV5 → NVD) only for whatever the ZIP missed,
# capped so a huge unresolved remainder doesn't run forever.
to_fetch = missing[:max_lookups]
from_cvelist = 0
from_nvd = 0
not_found = 0
with httpx.Client(timeout=HTTP_TIMEOUT, follow_redirects=True,
headers={"User-Agent": "TrueVuln/1.0"}) as client:
for cve in to_fetch:
url = _cvelistv5_raw_url(cve)
got = None
if url:
try:
r = client.get(url)
if r.status_code == 200:
got = _dates_from_cvelistv5(r.json())
from_cvelist += 1
elif r.status_code != 404:
r.raise_for_status()
except (httpx.HTTPError, ValueError) as e:
logger.debug("cvelistV5 date fetch failed for %s: %s", cve, e)
# Fallback to NVD only when cvelistV5 had no record.
if got is None:
try:
got = _dates_from_nvd(client, cve)
if got:
from_nvd += 1
# NVD courtesy delay (no key = 5 req/30s).
time.sleep(NVD_SLEEP_WITH_KEY if os.getenv("NVD_API_KEY", "").strip() else NVD_SLEEP_NO_KEY)
except httpx.HTTPError as e:
logger.debug("NVD date fallback failed for %s: %s", cve, e)
continue # don't cache failure — retry next run
if got is None:
continue
if not got.get("published") and not got.get("last_modified"):
not_found += 1
cache[cve] = got
_set_setting(db, NVD_DATE_CACHE_KEY, json.dumps(cache),
"CVE published/lastModified cache (cvelistV5 + NVD, persistent)")
logger.info(
"CVE dates: %d from cvelistV5, %d from NVD, %d empty, %d cached total, %d still missing",
from_cvelist, from_nvd, not_found, len(cache), max(0, len(missing) - len(to_fetch)),
)
return cache
def enrich_vulnerabilities(
db: Session,
vulns: List[Vulnerability],
use_epss: Optional[bool] = None,
use_kev: Optional[bool] = None,
use_euvd: Optional[bool] = None,
use_nvd_dates: Optional[bool] = None,
) -> dict:
"""
Reichert eine Liste von Vulnerabilities in-place an und commited.
Returns stats dict: {epss_updated, kev_marked, kev_cleared,
euvd_marked, euvd_cleared, total}.
"""
if not vulns:
return {
"epss_updated": 0,
"kev_marked": 0, "kev_cleared": 0,
"euvd_marked": 0, "euvd_cleared": 0,
"nvd_dates_set": 0,
"total": 0,
}
if use_epss is None:
use_epss = _setting_bool(db, SETTING_EPSS_ENABLED, default=True)
if use_kev is None:
use_kev = _setting_bool(db, SETTING_KEV_ENABLED, default=True)
if use_euvd is None:
use_euvd = _setting_bool(db, SETTING_EUVD_ENABLED, default=True)
if use_nvd_dates is None:
use_nvd_dates = _setting_bool(db, SETTING_NVD_DATES_ENABLED, default=True)
stats = {
"epss_updated": 0,
"kev_marked": 0, "kev_cleared": 0,
"euvd_marked": 0, "euvd_cleared": 0,
"nvd_dates_set": 0,
"total": len(vulns),
}
# Deduplicate CVE IDs (multiple assets can share a CVE)
cve_ids = sorted({v.cve_id for v in vulns if v.cve_id})
epss_map: Dict[str, Dict[str, float]] = {}
if use_epss and cve_ids:
try:
epss_map = fetch_epss_scores(cve_ids)
except Exception as e:
logger.error(f"EPSS enrichment failed: {e}")
kev_map: Dict[str, dict] = {}
if use_kev:
try:
kev_map = fetch_kev_catalog(db)
except EnrichmentError as e:
logger.error(f"KEV enrichment skipped: {e}")
except Exception as e:
logger.error(f"KEV enrichment failed unexpectedly: {e}")
euvd_map: Dict[str, dict] = {}
if use_euvd:
try:
euvd_map = fetch_euvd_catalogs(db)
except EnrichmentError as e:
logger.error(f"EUVD enrichment skipped: {e}")
except Exception as e:
logger.error(f"EUVD enrichment failed unexpectedly: {e}")
# NVD published/lastModified backfill. Only look up CVEs that still
# lack a published_date — that's the entire point (sort was broken
# because the column was NULL). Saves NVD calls on already-dated rows.
nvd_dates: Dict[str, dict] = {}
if use_nvd_dates:
need_dates = sorted({
v.cve_id for v in vulns
if v.cve_id and v.published_date is None and CVE_REGEX.fullmatch(v.cve_id)
})
if need_dates:
try:
nvd_dates = fetch_nvd_cve_dates(db, need_dates)
except Exception as e:
logger.error(f"NVD date backfill failed unexpectedly: {e}")
now = datetime.now()
for vuln in vulns:
sources_used = []
if use_epss and vuln.cve_id in epss_map:
data = epss_map[vuln.cve_id]
vuln.epss_score = data["epss"]
vuln.epss_percentile = data.get("percentile")
vuln.epss_updated_at = now
sources_used.append("epss")
stats["epss_updated"] += 1
if use_kev:
if vuln.cve_id in kev_map:
kev_entry = kev_map[vuln.cve_id]
if not vuln.kev_listed:
stats["kev_marked"] += 1
vuln.kev_listed = True
vuln.kev_ransomware_use = bool(kev_entry.get("ransomware_use"))
vuln.kev_short_description = kev_entry.get("short_description")
date_added_str = kev_entry.get("date_added")
if date_added_str:
try:
vuln.kev_date_added = datetime.fromisoformat(date_added_str)
except ValueError:
pass
sources_used.append("kev")
else:
if vuln.kev_listed:
# No longer in KEV (rare but possible)
vuln.kev_listed = False
vuln.kev_ransomware_use = False
vuln.kev_date_added = None
vuln.kev_short_description = None
stats["kev_cleared"] += 1
if use_euvd:
if vuln.cve_id in euvd_map:
euvd_entry = euvd_map[vuln.cve_id]
if not vuln.euvd_listed:
stats["euvd_marked"] += 1
vuln.euvd_listed = True
vuln.euvd_critical = bool(euvd_entry.get("critical"))
vuln.euvd_id = euvd_entry.get("euvd_id")
date_added_str = euvd_entry.get("date_added")
if date_added_str:
# ENISA dates can be plain dates or ISO timestamps
try:
vuln.euvd_date_added = datetime.fromisoformat(
date_added_str.replace("Z", "+00:00")
)
except (ValueError, AttributeError):
pass
sources_used.append("euvd")
else:
if vuln.euvd_listed:
vuln.euvd_listed = False
vuln.euvd_critical = False
vuln.euvd_date_added = None
vuln.euvd_id = None
stats["euvd_cleared"] += 1
if use_nvd_dates and vuln.cve_id in nvd_dates:
entry = nvd_dates[vuln.cve_id]
pub = entry.get("published")
mod = entry.get("last_modified")
if pub and vuln.published_date is None:
try:
vuln.published_date = datetime.fromisoformat(pub.replace("Z", "+00:00"))
stats["nvd_dates_set"] += 1
sources_used.append("nvd")
except (ValueError, AttributeError):
pass
if mod and vuln.last_modified_date is None:
try:
vuln.last_modified_date = datetime.fromisoformat(mod.replace("Z", "+00:00"))
except (ValueError, AttributeError):
pass
if sources_used:
# Merge with existing sources
existing = []
if vuln.enrichment_sources:
try:
existing = json.loads(vuln.enrichment_sources)
except json.JSONDecodeError:
existing = []
vuln.enrichment_sources = json.dumps(sorted(set(existing) | set(sources_used)))
vuln.enrichment_updated_at = now
# Refresh materialised sort columns — EPSS / KEV / EUVD changes
# ripple into priority_score and cpr_score.
vuln.refresh_scores()
db.commit()
# Mozilla MFSA severity for fresh Firefox CVEs — Mozilla's authoritative
# `impact` fills the placeholder severity when NVD/cvelistV5 have no score
# yet (the gap the tester hit on brand-new Firefox CVEs). Best-effort.
try:
from app.services import mozilla_advisory_service
stats["mozilla_severity"] = mozilla_advisory_service.apply_mozilla_severity(db, cve_ids)
except Exception as e:
logger.debug("Mozilla MFSA enrichment skipped: %s", e)
logger.info(
f"Enrichment done: {stats['total']} vulns, "
f"epss_updated={stats['epss_updated']}, "
f"kev_marked={stats['kev_marked']}, kev_cleared={stats['kev_cleared']}, "
f"euvd_marked={stats['euvd_marked']}, euvd_cleared={stats['euvd_cleared']}, "
f"nvd_dates_set={stats['nvd_dates_set']}, "
f"mozilla_severity={stats.get('mozilla_severity', 0)}"
)
return stats
def enrich_vulnerability_by_id(db: Session, vuln_id: int) -> dict:
"""Single-vuln convenience wrapper."""
vuln = db.query(Vulnerability).filter(Vulnerability.id == vuln_id).first()
if not vuln:
raise EnrichmentError(f"Vulnerability {vuln_id} not found")
return enrich_vulnerabilities(db, [vuln])
def enrich_all_open_vulnerabilities(db: Session) -> dict:
"""Refresh enrichment for every open vuln. Use for scheduled daily refresh."""
from app.models.vulnerability import VulnerabilityStatus
vulns = db.query(Vulnerability).filter(
Vulnerability.status == VulnerabilityStatus.open
).all()
return enrich_vulnerabilities(db, vulns)