ENISA's bare /exploitedvulnerabilities endpoint returns a curated 4-entry "recent" widget, not the full catalog. The actual full catalog lives at /api/search?exploited=true (1500+ CVEs, paginated). Also fix date parsing — ENISA returns 'Apr 29, 2026, 3:10:37 PM' style, not ISO. Try multiple formats, fall back to raw string when unknown. Prefer exploitedSince over datePublished as date_added when available. Parser now correctly handles ENISA item shape (aliases as newline-separated string, not array).
620 lines
22 KiB
Python
620 lines
22 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 re
|
|
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
|
|
|
|
# ---------- Toggles ----------
|
|
SETTING_EPSS_ENABLED = "enrichment_epss_enabled"
|
|
SETTING_KEV_ENABLED = "enrichment_kev_enabled"
|
|
SETTING_EUVD_ENABLED = "enrichment_euvd_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"),
|
|
}
|
|
|
|
_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
|
|
# ============================================================
|
|
|
|
def enrich_vulnerabilities(
|
|
db: Session,
|
|
vulns: List[Vulnerability],
|
|
use_epss: Optional[bool] = None,
|
|
use_kev: Optional[bool] = None,
|
|
use_euvd: 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,
|
|
"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)
|
|
|
|
stats = {
|
|
"epss_updated": 0,
|
|
"kev_marked": 0, "kev_cleared": 0,
|
|
"euvd_marked": 0, "euvd_cleared": 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}")
|
|
|
|
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 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
|
|
|
|
db.commit()
|
|
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']}"
|
|
)
|
|
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)
|