Files
vulncheck/app/services/cvelistv5_scan_service.py
T
vulncheckandClaude Opus 5 3e6c15cef6 feat(scan): cover Adobe Acrobat via cvelistV5, and stop Defender flapping
Two findings from the same report.

Adobe had no cvelistV5 entry at all — only the CPE path, which meant current
versions depended entirely on NVD having published a CPE yet. cvelistV5 names
the product "Adobe" / "Acrobat Reader": no DC suffix, and no Reader-vs-Acrobat
split, because Adobe stopped shipping them apart — APSB26-63 covers both and
links the same release notes for either. NVD meanwhile keeps the older _dc
spellings alive in parallel, which is why 2dc60be made the CPE path query both
names. Verified against CVE-2026-47965 / -47911 / -47961: affected up to and
including 26.001.21651, and the bound resolves correctly against that build.
Wazuh only detects ancient Reader builds (wazuh/wazuh#29960), so these two
paths are the entire coverage for current versions.

Separately, Defender findings flapped open and closed within one sync. The
auto-resolve ran per MACHINE, but several Defender machines can map to one
asset — a re-imaged or dual-registered device keeps its old machine entry. The
machine that no longer lists a CVE closed the finding; the one that still
lists it reopened it a minute later; next sync the same again (tester:
CVE-2026-66313, patched 13:41, open 13:42, patched 15:00). The CVE sets are
now unioned per asset and resolved once, after every machine has been asked.

Index key bumped to v14.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 15:53:03 +02:00

1097 lines
51 KiB
Python

"""
cvelistV5-based CVE detection for installed software.
The NVD-CPE scanner (app_cve_scanner_service) misses CVEs when NVD hasn't
published a CPE yet (fresh CVEs) or files them under a different CPE product
string than we curated (e.g. TeamViewer CVE lives under teamviewer:remote,
not teamviewer:teamviewer). cvelistV5 — the authoritative MITRE feed we
already cache as a ZIP — carries clean affected[].vendor/product/version
ranges instead, so we match those directly.
Design (CURATED + PRECISE, same as the CPE scanner):
- A curated product registry maps an installed-software name → the
cvelistV5 (vendor, product) pairs that identify it. Unknown software is
ignored (no fuzzy vendor/product guessing → no FP storm).
- One pass over the cached cvelistV5 ZIP builds a reverse index
{product_key: [{cve, start, lt, lte}]} for the curated products only.
The index is cached in a Setting (refreshed on the nightly job) so the
557 MB walk happens once, not per scan.
- Per installed package: resolve → look up indexed CVEs → check the
installed version falls inside an affected range → upsert (source
'app-scan', shared with the CPE scanner so the badge / cross-confirm /
enrichment all apply).
"""
from __future__ import annotations
import json
import logging
import os
import re
import time
import zipfile
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
from sqlalchemy.orm import Session
from app.services import app_cve_scanner_service as cpe
logger = logging.getLogger(__name__)
# Reuse the same ZIP the override service already downloads/caches (12h).
_ZIP_PATH = "/tmp/truevuln-cvelistv5-cache.zip"
_ZIP_URL = "https://github.com/CVEProject/cvelistV5/archive/refs/heads/main.zip"
_ZIP_TTL = 12 * 3600
_INDEX_SETTING = "cvelistv5_product_index_v14" # v14: + adobe-acrobat
_INDEX_TTL = timedelta(hours=26) # rebuilt nightly; a missed night still serves
# Curated registry: name-regex (installed software) → cvelistV5 (vendor,
# product) pairs, lowercased. First regex match wins. Vendor/product are
# matched case-insensitively against affected[].vendor / .product.
# ponytail: curated; add a row when a product is missed — unknown names are
# skipped, never guessed.
_REGISTRY: List[dict] = [
{"key": "teamviewer", "re": r"teamviewer",
"pairs": [("teamviewer", "remote"), ("teamviewer", "tensor"),
("teamviewer", "host"), ("teamviewer", "full client"),
("teamviewer", "teamviewer")]},
{"key": "notepad++", "re": r"notepad\+\+",
"pairs": [("notepad-plus-plus", "notepad-plus-plus"), ("notepad++", "notepad++"),
("don ho", "notepad++")]},
{"key": "devolutions-rdm", "re": r"remote desktop manager|devolutions",
"pairs": [("devolutions", "remote desktop manager")]},
# Oracle Java. NVD leaves these CVEs "Awaiting Enrichment" (no CPEs, no
# affected block), so cvelistV5 is the ONLY structured source — verified by
# the tester across CVE-2026-60526 / -21925 / -47057 / -62574.
# Both matching routes are covered by `pairs`: the vendor/product block
# ("Oracle Corporation" / "Oracle Java SE") and the CPE product
# (oracle:java_se, plus the older oracle:jre / oracle:jdk spellings — the
# same CVE can use either, and Java SE *is* the JRE).
# Version handling lives in _java_version(): the inventory name carries the
# truth ("Java 8 Update 441"), not the ARP field (8.0.4410.7).
{"key": "oracle-java", "re": r"^java \d+( update \d+)?|java\(tm\)|jdk|jre|java se",
"pairs": [("oracle corporation", "oracle java se"), ("oracle", "oracle java se"),
("oracle", "java se"), ("oracle", "java_se"), ("oracle", "jre"),
("oracle", "jdk"), ("oracle corporation", "java se")]},
# Exchange Server SE. cvelistV5 states the bound as "15.02.2562.043" while
# Wazuh reports "15.2.2562.27" — the leading zeros that break a string
# comparison (and, per wazuh/wazuh#36200, Wazuh's own matching) are
# irrelevant here because both sides are parsed as numbers.
# The name regex is anchored: see the app_cve_scanner_service entry for why
# the language packs and the KB hotfix row must not match.
{"key": "exchange-se", "re": r"^microsoft exchange server subscription edition$",
"pairs": [("microsoft", "microsoft exchange server subscription edition rtm"),
("microsoft", "microsoft exchange server subscription edition")]},
# Checkmk agent — Wazuh does not detect it (wazuh/wazuh#35646). cvelistV5
# states a bound per release branch ("2.4.0" .. lessThan "2.4.0p13"), which
# is the precise answer; NVD's CPE list covers it too (registry entry in
# app_cve_scanner_service), so both paths see it.
{"key": "checkmk", "re": r"checkmk agent|check_mk agent|checkmk(?!.*server)",
"pairs": [("checkmk gmbh", "checkmk"), ("checkmk", "checkmk"),
("tribe29", "checkmk"), ("checkmk gmbh", "checkmk agent")]},
# Adobe Acrobat. cvelistV5 names it "Adobe" / "Acrobat Reader" — no DC
# suffix, no separate Reader vs Acrobat product: Adobe stopped shipping
# them apart and one bulletin (APSB26-63) now covers both, linking the same
# release notes for either. NVD still keeps the older _dc spellings alive
# in parallel, which is why the CPE path queries both names.
# Wazuh only detects ancient Reader builds (wazuh/wazuh#29960), so these
# two paths are all the coverage current versions get.
{"key": "adobe-acrobat", "re": r"adobe acrobat|acrobat reader",
"pairs": [("adobe", "acrobat reader"), ("adobe", "acrobat"),
("adobe", "acrobat reader dc"), ("adobe", "acrobat dc"),
("adobe", "adobe acrobat reader"), ("adobe", "adobe acrobat")]},
{"key": "7-zip", "re": r"7-?zip",
"pairs": [("7-zip", "7-zip"), ("igor pavlov", "7-zip")]},
# Require the vendor word: match "Mozilla Firefox" (and "Mozilla Firefox
# ESR"), never a bare "Firefox" — a stray "…Firefox…" in some other
# product's name must not resolve here (tester: "nur 'Mozilla' UND
# 'Firefox', nicht 'Firefox' alleine"). Windows ARP / Wazuh always carry the
# "Mozilla" prefix, so this loses no real install.
{"key": "firefox", "re": r"mozilla firefox",
"pairs": [("mozilla", "firefox")]},
{"key": "chrome", "re": r"google chrome|com\.android\.chrome",
"pairs": [("google", "chrome")]},
# SharePoint — ONE KEY PER RELEASE, deliberately. Every MS SharePoint range
# uses a generic 16.0.0 floor (checked across 82 recent CVEs), and 2016,
# 2019 and Subscription Edition all report 16.0.x, so a single shared key
# would let a 2016 install (16.0.5456) fall inside the 2019 range
# (16.0.0 .. 16.0.10417.20153) — the exact cross-release false positive the
# Windows OS scan just had. The release comes from the NAME; the range then
# only ever decides "patched or not" within that one release.
# 2013 IS here: it is EOL (2023-04-11) and absent from recent MSRC docs, but
# the CVE records from its supported years carry real fix builds
# (CVE-2023-23395: 15.0.0 .. 15.0.5537.1000), and an unpatched 2013 farm is
# behind all of them. Checking only recent MSRC docs is what made this look
# undetectable — the tester's Nessus finds these, and so should we.
{"key": "sharepoint-2013", "re": r"sharepoint.*\b2013\b",
"pairs": [("microsoft", "microsoft sharepoint foundation 2013 service pack 1"),
("microsoft", "microsoft sharepoint enterprise server 2013 service pack 1"),
("microsoft", "microsoft sharepoint server 2013 service pack 1"),
("microsoft", "microsoft sharepoint foundation 2013"),
("microsoft", "microsoft sharepoint enterprise server 2013"),
("microsoft", "microsoft sharepoint server 2013")]},
{"key": "sharepoint-se", "re": r"sharepoint.*subscription",
"pairs": [("microsoft", "microsoft sharepoint server subscription edition")]},
{"key": "sharepoint-2019", "re": r"sharepoint.*\b2019\b",
"pairs": [("microsoft", "microsoft sharepoint server 2019")]},
{"key": "sharepoint-2016", "re": r"sharepoint.*\b2016\b",
"pairs": [("microsoft", "microsoft sharepoint enterprise server 2016"),
("microsoft", "microsoft sharepoint server 2016")]},
# Modern .NET (8/9/10) — one key PER RELEASE (same reasoning as SharePoint:
# the record floors are generic release floors, so a shared key would
# cross-match releases). The real semantic version lives in the NAME
# ("Microsoft .NET Runtime - 8.0.16 (x64)"), which bumps with every monthly
# patch — the version FIELD is an MSI build → name_ver. SDKs are excluded:
# their numbering (8.0.1xx) never falls inside the runtime fix range and
# the runtime is installed alongside anyway.
# .NET FRAMEWORK is deliberately NOT here: its ARP version (4.8.04084) is
# STATIC across monthly patches (only file versions change), so comparing
# it against fix builds like 4.8.4803.0 would flag every install forever.
# Framework patch state is file/KB-based — Defender TVM covers it.
{"key": "dotnet-10", "name_ver": True,
"re": r"(?:microsoft \.net (?:desktop )?(?:runtime|host)|windows desktop runtime).*?\b10\.0\.",
"pairs": [("microsoft", ".net 10.0")]},
{"key": "dotnet-9", "name_ver": True,
"re": r"(?:microsoft \.net (?:desktop )?(?:runtime|host)|windows desktop runtime).*?\b9\.0\.",
"pairs": [("microsoft", ".net 9.0")]},
{"key": "dotnet-8", "name_ver": True,
"re": r"(?:microsoft \.net (?:desktop )?(?:runtime|host)|windows desktop runtime).*?\b8\.0\.",
"pairs": [("microsoft", ".net 8.0")]},
{"key": "edge", "re": r"microsoft edge(?!.*webview)",
"pairs": [("microsoft", "microsoft edge (chromium-based)"),
("microsoft", "edge (chromium-based)"), ("microsoft", "microsoft edge")]},
{"key": "vlc", "re": r"vlc media player|videolan",
"pairs": [("videolan", "vlc media player"), ("videolan", "vlc")]},
{"key": "putty", "re": r"(?<!\w)putty",
"pairs": [("putty", "putty"), ("simon tatham", "putty")]},
{"key": "winscp", "re": r"winscp", "pairs": [("winscp", "winscp"), ("martin prikryl", "winscp")]},
{"key": "wireshark", "re": r"wireshark", "pairs": [("wireshark", "wireshark")]},
{"key": "filezilla", "re": r"filezilla", "pairs": [("filezilla", "filezilla"), ("filezilla", "filezilla client")]},
{"key": "zoom", "re": r"(?<!\w)zoom(?!\w)", "pairs": [("zoom", "zoom"), ("zoom", "meetings"), ("zoom", "zoom client for meetings")]},
# Teams itself only — not the Office add-in / VDI / Citrix plugin. And
# only the Windows/desktop/generic product entries: the "for Mac" / mobile
# products are dropped so a Mac-only Teams CVE can't land on a Windows host
# (the per-scan platform filter is the general backstop, but not every CNA
# fills the platforms field, so scoping the pairs is belt-and-suspenders).
{"key": "teams", "re": r"microsoft teams(?!.*(machine-wide|add-in|plugin|vdi|citrix))",
"pairs": [("microsoft", "microsoft teams for desktop"),
("microsoft", "microsoft teams for windows"),
("microsoft", "microsoft teams"), ("microsoft", "teams")]},
]
_COMPILED = [(re.compile(e["re"], re.I), e) for e in _REGISTRY]
_KEY_ENTRY = {e["key"]: e for e in _REGISTRY}
# Reverse map (vendor_lc, product_lc) → product_key, for the index build.
_PAIR_TO_KEY: Dict[Tuple[str, str], str] = {}
for _e in _REGISTRY:
for _v, _p in _e["pairs"]:
_PAIR_TO_KEY[(_v.lower(), _p.lower())] = _e["key"]
# Pattern products — one curated key for a whole family whose CVE records name
# a product per RELEASE ("Windows 11 Version 23H2", "Windows Server 2025
# (Server Core installation)", ...), so an exact pair list would rot with every
# new release. The entry keeps its product NAME so the scan can tell the
# releases apart; the build line alone cannot (see _WIN_FAMILIES).
_PRODUCT_PATTERNS: List[dict] = [
{"key": "windows", "vendor_lit": "microsoft", "vendor_re": r"^microsoft$",
"product_re": r"^windows\s+(10|11|server)\b"},
# Apple OS records. NVD is the only source the CPE path can use for Apple,
# and it lags: CVE-2026-28911 sat at "UNDERGOING ENRICHMENT" with no
# configuration at all, so no path could see it and only Defender reported
# it. cvelistV5 has the data from day one (Apple / macOS / lessThan 14.8.8).
# Product names are a small closed set, so a pattern beats a pair list.
{"key": "apple-os", "vendor_lit": "apple", "vendor_re": r"^apple$",
"product_re": r"^(macos|ios(\s+and\s+ipados)?|ipados|tvos|visionos|watchos)$"},
# SAP desktop clients. NVD enumerates one CPE per patch level, which misses
# every level published after the record was written; cvelistV5 states the
# honest bound instead ("7.70 PL0" .. "7.70 PL11"). Both spellings of the
# vendor occur — "SAP SE" and "SAP_SE" — in records days apart.
{"key": "sap", "vendor_lit": "sap", "vendor_re": r"^sap[\s_]se$",
"product_re": r"^sap (gui for windows|business client)$"},
]
# Asset OS string → which cvelistV5 product names it may match.
#
# This is the part that was missing and caused the cross-release FPs: Windows 11
# 24H2 and Windows Server 2025 both live on build line 10.0.26100 but keep
# SEPARATE revision sequences (24H2 fix .8655, Server 2025 fix .32860), so
# CVE-2026-41089 — Server 2025 only — matched a fully-patched 24H2 client.
# Deciding the family from the OS string first makes the build range a
# within-release "patched or not" test, which is all it can honestly answer.
# Order matters: "Windows Server" must be tested before the bare client names.
_WIN_FAMILIES: List[tuple] = [
(re.compile(r"windows\s+server", re.I), re.compile(r"^windows\s+server\b", re.I)),
(re.compile(r"windows\s*11", re.I), re.compile(r"^windows\s+11\b", re.I)),
(re.compile(r"windows\s*10", re.I), re.compile(r"^windows\s+10\b", re.I)),
]
def _win_family(os_name: str) -> Optional[re.Pattern]:
"""Asset OS string → regex the entry's product name must satisfy."""
n = (os_name or "").strip().lower()
for os_rx, prod_rx in _WIN_FAMILIES:
if os_rx.search(n):
return prod_rx
return None
_PATTERNS_COMPILED = [
(re.compile(p["vendor_re"], re.I), re.compile(p["product_re"], re.I), p["key"])
for p in _PRODUCT_PATTERNS
]
def _pair_key(vendor: str, product: str) -> Optional[str]:
"""(vendor, product) from a CVE record → curated product key."""
v, p = (vendor or "").strip().lower(), (product or "").strip().lower()
key = _PAIR_TO_KEY.get((v, p))
if key:
return key
for vrx, prx, k in _PATTERNS_COMPILED:
if vrx.search(v) and prx.search(p):
return k
return None
def resolve(name: str) -> Optional[str]:
n = (name or "").strip().lower()
if not n:
return None
for rx, e in _COMPILED:
if rx.search(n):
if e["key"] == "firefox" and re.search(r"\besr\b", n):
# ESR install ("Mozilla Firefox 52.2.1 ESR"): the cvelistV5
# Firefox ranges describe the RELEASE train (fresh records list
# only "unaffected 153+"), so matching an ESR build against
# them flags CVEs whose MFSA advisory doesn't touch ESR at all
# (tester: CVE-2026-16395 on Firefox 52 ESR; mfsa2026-70 lists
# no ESR fix). ESR patch state needs the MFSA fixed_in data —
# until that's wired, no match beats a false positive.
return None
return e["key"]
return None
# ---------- CVSS base score ----------
def _cvss_from_record(data: dict) -> Tuple[Optional[float], Optional[str]]:
"""Pull (baseScore, baseSeverity) from a CVE-5 record. Prefers CVSS 3.1 >
3.0 > 4.0, and the CNA's own metrics over the CISA-ADP block. Returns
(None, None) when the record carries no score (many fresh CVEs) — enrichment
can still backfill later."""
conts = data.get("containers") or {}
metric_blocks = [(conts.get("cna") or {}).get("metrics") or []]
for adp in (conts.get("adp") or []):
metric_blocks.append(adp.get("metrics") or [])
for field in ("cvssV3_1", "cvssV3_0", "cvssV4_0"):
for metrics in metric_blocks:
for m in metrics:
cv = m.get(field) if isinstance(m, dict) else None
if isinstance(cv, dict) and cv.get("baseScore") is not None:
try:
return float(cv["baseScore"]), (cv.get("baseSeverity") or "").lower() or None
except (TypeError, ValueError):
pass
# Chrome/Chromium CVEs ship NO metrics block at all — Google states the
# severity in prose instead: "… (Chromium security severity: Critical)".
# Without this every fresh Chrome CVE landed on the neutral 'medium'
# placeholder, so a Critical sandbox escape and a Low UI spoof sorted
# identically in the queue and in the digest mail. No score is invented —
# only the severity word the vendor stated.
return None, _chromium_severity(data)
_CHROMIUM_SEV_RE = re.compile(
r"chromium\s+security\s+severity:\s*(critical|high|medium|low)", re.I)
def _chromium_severity(data: dict) -> Optional[str]:
for d in ((data.get("containers") or {}).get("cna") or {}).get("descriptions") or []:
m = _CHROMIUM_SEV_RE.search((d or {}).get("value") or "")
if m:
return m.group(1).lower()
return None
# ---------- affected[] range parsing ----------
def _ranges_from_affected(aff: dict) -> List[Tuple[Optional[str], Optional[str], Optional[str]]]:
"""→ [(version_start, lessThan, lessThanOrEqual)] for the affected entry.
Only ranges with a real upper bound are returned (exact-version-only and
unbounded entries are skipped → no over-matching)."""
out = []
unaffected_floors: List[str] = []
unaffected_total = 0
for v in aff.get("versions", []) or []:
if not isinstance(v, dict):
continue
if (v.get("status") or "affected") != "affected":
# Some CNAs state the INVERSE — Mozilla ships no affected range at
# all, only "152.0.6 and up are unaffected" (status=unaffected,
# lessThanOrEqual="*"). Skipping those meant the newest Firefox CVEs
# never entered the index. "X and up are fixed" == "below X is
# affected", so remember X as a fix bound.
if v.get("status") == "unaffected":
unaffected_total += 1
if (not v.get("lessThan")
and (v.get("lessThanOrEqual") in ("*", None))):
ver = v.get("version")
if isinstance(ver, str) and ver.strip() not in ("0", "*", "-", ""):
unaffected_floors.append(ver.strip())
continue
start = None
lt = v.get("lessThan")
lte = v.get("lessThanOrEqual")
ver = v.get("version")
if isinstance(ver, str):
vs = ver.strip()
if vs.startswith("<="):
lte = lte or vs[2:].strip()
elif vs.startswith("<"):
lt = lt or vs[1:].strip()
elif vs not in ("0", "*", "-", ""):
start = vs
if lt in ("*", "-", ""):
lt = None
if lte in ("*", "-", ""):
lte = None
if start is not None and (start == lt or start == lte):
# Some CNAs emit "version" == "lessThan" (e.g. a few Chrome
# records) — a literal reading gives a zero-width, impossible
# range. NVD treats these as an open floor (no lower bound); do
# the same rather than silently dropping the CVE.
start = None
if lt or lte:
out.append((start, lt, lte))
elif start is not None:
# A single exact version with no bound at all. These were dropped
# to avoid over-matching, which also dropped the CVE entirely:
# CVE-2026-14266 states only "7-Zip 20.01 affected", so nothing but
# Defender ever saw it. Expressed as the closed range [v, v] it
# matches that one version and nothing else — the narrowest
# possible reading, and exactly what the record says.
out.append((start, None, start))
# Inverse-only records (see above): derive the fix bound from the single
# "unaffected" floor — but ONLY when the record has EXACTLY ONE unaffected
# entry. Firefox ESR CVEs list two ("115.38 lte 115.*" AND "140.13 lte *"),
# and Mozilla writes the ESR floor as an unbounded "lte *", so a "below X"
# rule wrongly catches regular Firefox (tester: ESR-only CVE-2026-16361
# flagged on Firefox 121/152). Multiple unaffected entries = multi-train
# (ESR + release) record → the inverse heuristic can't tell them apart, so
# skip it rather than risk the false positive. Distinguishing them reliably
# needs Mozilla's MFSA advisories (per-product), not cvelistV5 alone.
if not out and unaffected_total == 1 and len(unaffected_floors) == 1:
out.append((None, unaffected_floors[0], None))
return out
# Recognised OS names in cvelistV5 affected[].platforms. Microsoft also uses
# that field for CPU arch ("x64-based Systems", "ARM64-based Systems") — those
# are NOT OS names, so when a record lists only arch/hardware we can't judge
# the OS and must keep the finding (never drop on arch alone).
_OS_PLATFORMS = {
"windows": {"windows"},
"macos": {"macos", "mac os", "mac os x", "os x", "mac"},
"linux": {"linux"},
"iphone_os": {"ios"},
"ipados": {"ipados", "ios"},
"android": {"android"},
}
_ALL_OS_TOKENS = {tok for toks in _OS_PLATFORMS.values() for tok in toks}
def _platform_ok(asset_family: Optional[str], platforms) -> bool:
"""Keep a finding unless the CVE explicitly lists OS platform(s) and the
asset's OS isn't among them. Records with no platforms, or only
arch/hardware platforms (x64/ARM/…), are kept (can't judge the OS)."""
if not asset_family or not platforms:
return True
listed = {str(p).lower().strip() for p in platforms}
os_listed = listed & _ALL_OS_TOKENS
if not os_listed:
return True # arch/hardware only → not an OS signal
return bool(_OS_PLATFORMS.get(asset_family, set()) & os_listed)
def _affected(installed: str, start: Optional[str], lt: Optional[str], lte: Optional[str]) -> bool:
if start:
c = cpe._vcmp(installed, start)
if c is None or c < 0:
return False
if lt:
c = cpe._vcmp(installed, lt)
return c is not None and c < 0
if lte:
c = cpe._vcmp(installed, lte)
return c is not None and c <= 0
return False
# ---------- index build + cache ----------
def _ensure_zip() -> bool:
fresh = (os.path.exists(_ZIP_PATH)
and (time.time() - os.path.getmtime(_ZIP_PATH)) < _ZIP_TTL
and os.path.getsize(_ZIP_PATH) > 100_000_000)
if fresh:
return True
import httpx
tmp = _ZIP_PATH + ".part"
try:
logger.info("cvelistv5-scan: downloading ZIP snapshot (~557 MB)…")
with httpx.Client(timeout=httpx.Timeout(600.0, connect=15.0), follow_redirects=True) as c:
with c.stream("GET", _ZIP_URL) as r:
r.raise_for_status()
with open(tmp, "wb") as f:
for chunk in r.iter_bytes(chunk_size=1024 * 512):
f.write(chunk)
os.replace(tmp, _ZIP_PATH)
return True
except Exception as e:
logger.warning("cvelistv5-scan: ZIP download failed: %s", e)
if os.path.exists(tmp):
try:
os.remove(tmp)
except Exception:
pass
return False
def build_product_index(db: Session) -> dict:
"""Walk the cvelistV5 ZIP once, building {product_key: [{cve,start,lt,lte}]}
for the curated products. Heavy (~250k files) → call from the nightly job.
Caches the result in a Setting. Returns the index."""
if not _ensure_zip():
return load_index(db) or {}
index: Dict[str, list] = {}
seen: set = set()
scanned = 0
parsed = 0
# Pre-filter on raw bytes: only JSON-parse files that mention a curated
# vendor. ~99% of the 250k CVEs don't, so this skips that many json.loads
# (the expensive part) — build drops from minutes to ~a minute.
# Pattern products carry no pair, so their vendor has to be added by hand —
# miss it and the pattern never sees a single file (Apple has no pair).
vendor_bytes = {v.encode() for (v, _p) in _PAIR_TO_KEY.keys()}
vendor_bytes |= {p["vendor_lit"].encode() for p in _PRODUCT_PATTERNS
if p.get("vendor_lit")}
with zipfile.ZipFile(_ZIP_PATH) as zf:
for name in zf.namelist():
if not name.endswith(".json") or "/cves/" not in name:
continue
scanned += 1
try:
raw = zf.read(name)
except Exception:
continue
low = raw.lower()
if not any(vb in low for vb in vendor_bytes):
continue
try:
data = json.loads(raw)
except Exception:
continue
parsed += 1
cna = (data.get("containers") or {}).get("cna") or {}
affected = cna.get("affected") or []
if not affected:
continue
cve_id = ((data.get("cveMetadata") or {}).get("cveId") or "").upper()
if not cve_id.startswith("CVE-"):
continue
cvss, sev = _cvss_from_record(data)
for aff in affected:
key = _pair_key(aff.get("vendor") or "", aff.get("product") or "")
if not key:
continue
plats = [str(p).lower().strip() for p in (aff.get("platforms") or [])]
prod = (aff.get("product") or "").strip()
if key == "sap":
for _v in (aff.get("versions") or []):
if not isinstance(_v, dict):
continue
if (_v.get("status") or "affected") != "affected":
continue
ent = _sap_entry(_v)
if not ent:
continue
_sig = (key, cve_id, prod.lower(), ent["rel"],
ent.get("pl_to"), ent.get("rel_lt"), ent.get("rel_lte"))
if _sig in seen:
continue
seen.add(_sig)
ent.update({"cve": cve_id, "prod": prod, "cvss": cvss, "sev": sev})
index.setdefault(key, []).append(ent)
continue
if key == "oracle-java":
# Oracle states bare affected versions ("8u491", status
# affected) with no range at all, so _ranges_from_affected
# yields nothing for them. Index the literal versions; the
# scan side applies the cumulative rule (older updates of
# the same feature release are affected too).
for _v in (aff.get("versions") or []):
if not isinstance(_v, dict):
continue
if (_v.get("status") or "affected") != "affected":
continue
_raw = (_v.get("version") or "").strip()
if not _raw or _raw in ("0", "*", "-"):
continue
_sig = (key, cve_id, _raw, prod.lower())
if _sig in seen:
continue
seen.add(_sig)
index.setdefault(key, []).append(
{"cve": cve_id, "start": None, "lt": _v.get("lessThan"),
"lte": _v.get("lessThanOrEqual"), "ver": _raw,
"plats": plats, "cvss": cvss, "sev": sev, "prod": prod})
continue
for start, lt, lte in _ranges_from_affected(aff):
sig = (key, cve_id, start, lt, lte, prod.lower())
if sig in seen:
continue
seen.add(sig)
index.setdefault(key, []).append(
{"cve": cve_id, "start": start, "lt": lt, "lte": lte,
"plats": plats, "cvss": cvss, "sev": sev, "prod": prod})
_store_index(db, index)
logger.info("cvelistv5-scan: index built (%d files scanned, %d parsed) → %d products, %d ranges",
scanned, parsed, len(index), sum(len(v) for v in index.values()))
return index
def _store_index(db: Session, index: dict) -> None:
from app.models.setting import Setting
payload = json.dumps({"built_at": datetime.now().isoformat(), "index": index})
row = db.query(Setting).filter(Setting.key == _INDEX_SETTING).first()
if row:
row.value = payload
else:
db.add(Setting(key=_INDEX_SETTING, value=payload,
description="cvelistV5 product→CVE reverse index (curated)"))
db.commit()
def load_index(db: Session, allow_stale: bool = True) -> Optional[dict]:
from app.models.setting import Setting
row = db.query(Setting).filter(Setting.key == _INDEX_SETTING).first()
if not row or not row.value:
return None
try:
blob = json.loads(row.value)
built = datetime.fromisoformat(blob.get("built_at"))
except Exception:
return None
if not allow_stale and datetime.now() - built > _INDEX_TTL:
return None
return blob.get("index") or {}
# ---------- scan ----------
# ---------- false-positive suppression ----------
_FP_STOP = {"setup", "edition", "en", "english", "x64", "x86", "cu", "gdr",
"for", "based", "systems", "the", "of", "and", "client", "full",
"host", "version", "core", "server"}
def _sig_tokens(s: str) -> set:
"""Significant tokens: drop stopwords, 4-digit years, and bare numbers."""
out = set()
for t in re.findall(r"[a-z0-9]+", (s or "").lower()):
if t in _FP_STOP or re.fullmatch(r"(19|20)\d{2}", t) or re.fullmatch(r"\d+", t):
continue
out.add(t)
return out
def _product_matches(installed_name: str, vendor: str, product: str) -> bool:
"""True when the CVE's affected product is the same product family as the
installed package. Requires ≥2 shared significant tokens — so only
multi-word products (SQL Server, Visual Studio, …) where Wazuh's loose
CPE match over-reports across editions are ever scoped for suppression;
single-token apps never match → their findings are left untouched."""
a = _sig_tokens(f"{vendor} {product}")
b = _sig_tokens(installed_name)
return len(a & b) >= 2
def _zip_path_for(cve_id: str) -> Optional[str]:
m = re.match(r"^CVE-(\d{4})-(\d+)$", cve_id)
if not m:
return None
year, num = m.group(1), m.group(2)
return f"cvelistV5-main/cves/{year}/{int(num) // 1000}xxx/{cve_id}.json"
def suppress_false_positives(db: Session, asset_id: Optional[int] = None) -> dict:
"""Auto-flag Wazuh findings whose installed version is provably OUTSIDE
all cvelistV5 affected ranges for the matched product (e.g. a SQL Server
2019 / 15.x host carrying a CVE that only affects 16.x/17.x because Wazuh
matched 'Microsoft SQL Server' too loosely).
Conservative — only marks when ALL relevant affected entries have clean
numeric ranges and the install is outside every one. Sets
status=false_positive (reversible, audit-logged); never auto-unmarks.
"""
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
stats = {"checked": 0, "suppressed": 0, "errors": []}
if not _ensure_zip():
stats["errors"].append("cvelistV5 ZIP unavailable")
return stats
q = (db.query(Vulnerability)
.filter(Vulnerability.status == VulnerabilityStatus.open,
Vulnerability.cve_id.like("CVE-%"),
Vulnerability.sources.contains('"wazuh"'),
Vulnerability.package_name.isnot(None),
Vulnerability.package_version.isnot(None)))
if asset_id is not None:
q = q.filter(Vulnerability.asset_id == asset_id)
candidates = q.all()
if not candidates:
return stats
suppressed_ids: list = []
with zipfile.ZipFile(_ZIP_PATH) as zf:
names = set(zf.namelist())
for v in candidates:
stats["checked"] += 1
cver = cpe._clean_version(v.package_version or "")
if not cver:
continue
path = _zip_path_for(v.cve_id)
if not path or path not in names:
continue # CVE not in snapshot → can't judge → keep
try:
data = json.loads(zf.read(path))
except Exception:
continue
affected = ((data.get("containers") or {}).get("cna") or {}).get("affected") or []
relevant = [a for a in affected
if _product_matches(v.package_name, a.get("vendor") or "", a.get("product") or "")]
if not relevant:
continue # CVE doesn't clearly name this product → keep
ranges = []
uncertain = False
for a in relevant:
rs = _ranges_from_affected(a)
if not rs:
uncertain = True # an entry we can't bound → don't risk it
break
ranges.extend(rs)
if uncertain or not ranges:
continue
if any(_affected(cver, s, lt, lte) for s, lt, lte in ranges):
continue # installed IS in an affected range → real, keep
# Outside every clean range → false positive.
v.status = VulnerabilityStatus.false_positive
v.notification_suppressed = True
rng = "; ".join(f"[{s or '0'}, {lt or lte})" for s, lt, lte in ranges)
v.defer_reason = (f"[auto] installed {v.package_version} is outside all "
f"cvelistV5 affected ranges for this product ({rng})")[:500]
suppressed_ids.append(v.id)
stats["suppressed"] += 1
if suppressed_ids:
db.commit()
logger.info("cvelistV5 FP-suppression: checked %d, suppressed %d",
stats["checked"], stats["suppressed"])
return stats
def _build_line(v: Optional[str]) -> Optional[tuple]:
"""First three segments of a Windows build — the release line
(10.0.14393.9234 → (10,0,14393) = Server 2016 / Win10 1607)."""
t = cpe._vtuple(v or "")
return t[:3] if t and len(t) >= 3 else None
def _release_bounded(start: Optional[str], lt: Optional[str]) -> bool:
"""True when a range actually pins ONE Windows release, i.e. its floor and
its fix sit on the same build line (10.0.22631.0 .. 10.0.22631.7219).
Modern MS records do this; older ones (≈pre-2022) use a generic floor —
CVE-2021-26432 says `version 10.0.0, lessThan 10.0.17763.2114` for Server
2019, and that range swallows EVERY lower build, so a Server 2016 host
(14393.9234) got flagged with a 17763 fix. Such an entry carries no release
information at all, so the OS scan must skip it rather than guess: the OS
string alone can't name the client release, which is the whole reason we
lean on the bounds.
"""
sl, ll = _build_line(start), _build_line(lt)
return bool(sl and ll and sl == ll)
# ---------- SAP (release + patch level) ----------
#
# SAP states two KINDS of affected version in the same record, and they
# contradict each other. CVE-2023-32113 carries both "<= 7.70" (the whole
# release) and "7.70 PL0".."7.70 PL11" (up to that patch level). A host on
# 7.70 PL26 is affected by the first and patched by the second. The patch
# level is the precise statement, so it WINS for its own release, and the
# release-wide bound only answers for releases that have no PL entry at all.
_SAP_RELPL_RE = re.compile(r"^\s*(\d+(?:\.\d+)*)\s*(?:pl\s*(\d+))?\s*$", re.I)
_SAP_OP_RE = re.compile(r"^\s*(<=|<)\s*(\d+(?:\.\d+)*)\s*$")
def _sap_split(raw: str) -> Optional[tuple]:
"""'7.70 PL11' → ('7.70', 11); '8.00' → ('8.00', None)."""
m = _SAP_RELPL_RE.match(raw or "")
if not m:
return None
return m.group(1), (int(m.group(2)) if m.group(2) is not None else None)
def _sap_entry(v: dict) -> Optional[dict]:
"""One cvelistV5 version object → an index entry, or None if unusable."""
raw = (v.get("version") or "").strip()
lte = (v.get("lessThanOrEqual") or "").strip()
lt = (v.get("lessThan") or "").strip()
# Release-wide bound stated as an operator inside the version string.
op = _SAP_OP_RE.match(raw)
if op and not (lte or lt):
return {"rel": None,
"rel_lt": op.group(2) if op.group(1) == "<" else None,
"rel_lte": op.group(2) if op.group(1) == "<=" else None}
start = _sap_split(raw)
if not start:
return None
rel, pl_from = start
end = _sap_split(lte or lt)
if end and end[1] is not None and end[0] == rel:
return {"rel": rel, "pl_from": pl_from or 0,
"pl_to": end[1] if lte else end[1] - 1}
if pl_from is not None and not (lte or lt):
# A single exact level, no range.
return {"rel": rel, "pl_from": pl_from, "pl_to": pl_from}
return None
def _sap_affected(entries: List[dict], rel: str, pl: Optional[int]) -> bool:
"""True if (release, patch level) falls in this CVE's affected set."""
same_rel = [e for e in entries
if e.get("rel") and cpe._vcmp(rel, e["rel"]) == 0]
if same_rel:
# Precise statement for this exact release — it decides, alone.
if pl is None:
return False
return any(e["pl_from"] <= pl <= e["pl_to"] for e in same_rel)
for e in entries:
if e.get("rel_lt") and cpe._vcmp(rel, e["rel_lt"]) is not None \
and cpe._vcmp(rel, e["rel_lt"]) < 0:
return True
if e.get("rel_lte") and cpe._vcmp(rel, e["rel_lte"]) is not None \
and cpe._vcmp(rel, e["rel_lte"]) <= 0:
return True
return False
_SAP_PRODUCTS: List[tuple] = [
(re.compile(r"sap gui for windows|sap\s+gui(?!.*java)", re.I),
re.compile(r"^sap gui for windows$", re.I)),
(re.compile(r"sap business client", re.I),
re.compile(r"^sap business client$", re.I)),
]
def scan_asset_sap(db: Session, asset, packages: list, index: dict,
new_ids: Optional[list] = None,
touched: Optional[set] = None) -> int:
"""SAP desktop CVEs from cvelistV5, matched by release + patch level."""
if not index:
return 0
entries = index.get("sap") or []
if not entries:
return 0
from app.services.app_cve_scanner_service import _sap_patch_level
if new_ids is None:
new_ids = []
count = 0
for pkg in packages or []:
name = (pkg.get("name") or "").strip()
version = (pkg.get("version") or "").strip()
prod_rx = next((rx for nrx, rx in _SAP_PRODUCTS if nrx.search(name)), None)
if not prod_rx:
continue
rel = cpe._clean_version(version)
if not rel:
continue
pl = _sap_patch_level(name, version)
# Group this product's entries per CVE: the release-wide bound and the
# patch-level bound of one CVE have to be weighed together, not row by
# row, or the contradiction above resolves the wrong way.
per_cve: Dict[str, list] = {}
for e in entries:
if prod_rx.match((e.get("prod") or "").strip()):
per_cve.setdefault(e["cve"], []).append(e)
for cve_id, ents in per_cve.items():
if not _sap_affected(ents, rel, pl):
continue
c = {"cve": cve_id, "cvss": ents[0].get("cvss"),
"severity": ents[0].get("sev"), "fixed": None}
try:
before = len(new_ids)
cpe._upsert(db, asset, name, version, c, new_ids, touched=touched)
count += 1 if len(new_ids) > before else 0
except Exception as e:
logger.debug("cvelistv5 SAP upsert failed (%s on %s): %s",
cve_id, asset.id, e)
return count
_APPLE_OS_PRODUCTS: List[tuple] = [
# asset OS family (cpe._os_family) → cvelistV5 product names that apply.
("macos", re.compile(r"^macos$", re.I)),
("iphone_os", re.compile(r"^ios(\s+and\s+ipados)?$", re.I)),
("ipados", re.compile(r"^(ipados|ios\s+and\s+ipados)$", re.I)),
]
def _same_train(installed: str, lt: Optional[str]) -> bool:
"""True if a fix bound sits on the host's own release train.
Apple ships parallel trains and states each one as its own range with a
ZERO floor: CVE-2026-64721 carries lessThan 14.8.8, 15.7.8 AND 26.6. Taken
at face value a Sonoma 14.7 host matches all three (14.7 < 26.6), and the
finding would claim "fixed in 26.6" — an upgrade the host will never get.
The major version picks the train, exactly like _win_family does for
Windows builds.
"""
a, b = cpe._vtuple(installed), cpe._vtuple(lt or "")
return bool(a and b and a[0] == b[0])
def scan_asset_os_apple(db: Session, asset, index: dict,
new_ids: Optional[list] = None,
touched: Optional[set] = None) -> int:
"""Apple OS CVEs (macOS / iOS / iPadOS) from asset.os_version.
The CPE path covers Apple only through NVD, which routinely has no
configuration yet for a fresh Apple CVE — those were invisible to every
scan path. cvelistV5 carries them immediately.
"""
if not index:
return 0
entries = index.get("apple-os") or []
if not entries:
return 0
fam = cpe._os_family(asset.operating_system or "")
prod_rx = next((rx for f, rx in _APPLE_OS_PRODUCTS if f == fam), None)
if not prod_rx:
return 0
cver = cpe._clean_version(asset.os_version or "")
if not cver:
return 0
if new_ids is None:
new_ids = []
label = (asset.operating_system or "Apple").strip()
count = 0
for entry in entries:
if not prod_rx.match((entry.get("prod") or "").strip()):
continue
lt = entry.get("lt")
if not _same_train(cver, lt):
continue
if not _affected(cver, entry.get("start"), lt, entry.get("lte")):
continue
c = {"cve": entry["cve"], "cvss": entry.get("cvss"),
"severity": entry.get("sev"), "fixed": lt}
try:
before = len(new_ids)
cpe._upsert(db, asset, label, asset.os_version or cver, c, new_ids,
touched=touched)
count += 1 if len(new_ids) > before else 0
except Exception as e:
logger.debug("cvelistv5 apple-OS upsert failed (%s on %s): %s",
entry["cve"], asset.id, e)
return count
def scan_asset_os(db: Session, asset, index: dict,
new_ids: Optional[list] = None, touched: Optional[set] = None) -> int:
"""Windows OS CVEs straight from the asset's build (asset.os_version).
Two guards, both learned the hard way — either one alone is not enough:
1. FAMILY (_win_family): the entry's product name must belong to the same
family as the asset. Windows 11 24H2 and Windows Server 2025 share build
line 10.0.26100 but keep separate revision sequences, so CVE-2026-41089
(Server 2025 only, fix .32860) otherwise matches a fully-patched 24H2
client at .8655.
2. RELEASE-BOUNDED (_release_bounded): the range's floor and fix must sit on
one build line. Older records use a generic 10.0.0 floor, which swallows
every lower build (CVE-2021-26432 put a 17763 fix on a 14393 host).
With both, the range only ever answers "patched or not" inside the host's
own release — which is all it can honestly answer.
"""
if not index:
return 0
entries = index.get("windows") or []
if not entries:
return 0
if cpe._os_family(asset.operating_system or "") != "windows":
return 0
fam_rx = _win_family(asset.operating_system or "")
if not fam_rx:
return 0 # Windows flavour we can't place (e.g. bare "Windows") → skip
cver = cpe._clean_version(asset.os_version or "")
if not cver:
return 0
if new_ids is None:
new_ids = []
label = (asset.operating_system or "Microsoft Windows").strip()
count = 0
for entry in entries:
if not fam_rx.search((entry.get("prod") or "").strip()):
continue # different Windows family → its revisions are unrelated
if not _release_bounded(entry.get("start"), entry.get("lt")):
continue # range can't tell releases apart → would cross-match
if not _affected(cver, entry.get("start"), entry.get("lt"), entry.get("lte")):
continue
c = {"cve": entry["cve"], "cvss": entry.get("cvss"), "severity": entry.get("sev"),
"fixed": entry.get("lt")}
try:
before = len(new_ids)
cpe._upsert(db, asset, label, asset.os_version or cver, c, new_ids, touched=touched)
count += 1 if len(new_ids) > before else 0
except Exception as e:
logger.debug("cvelistv5 OS upsert failed (%s on %s): %s", entry["cve"], asset.id, e)
return count
# ---------- Oracle Java ----------
_JAVA_NAME_RE = re.compile(r"\bjava\b[^\d]*(\d+)\s*update\s*(\d+)", re.I)
_JAVA_UPD_RE = re.compile(r"^(\d+)\s*u\s*(\d+)", re.I) # "8u491"
_JAVA_DOT_RE = re.compile(r"^1\.(\d+)\.\d+[._]?(\d+)?", re.I) # "1.8.0_471"
def _java_version(name: str, version: str):
"""Installed Java → (feature, update), e.g. 'Java 8 Update 441' → (8, 441).
The ARP/syscollector VERSION field is an MSI build (8.0.4410.7) that maps to
nothing; the display NAME carries the real one. 32-bit and 64-bit are two
separate installs with the same name+version, which is fine — they dedupe.
"""
m = _JAVA_NAME_RE.search(name or "")
if m:
return (int(m.group(1)), int(m.group(2)))
m = _JAVA_DOT_RE.match((version or "").strip())
if m:
return (int(m.group(1)), int(m.group(2) or 0))
return None
def _java_affected_version(raw: str):
"""CVE-side version → (feature, update). Handles '8u491' and '1.8.0_491'."""
s = (raw or "").strip().lower()
m = _JAVA_UPD_RE.match(s)
if m:
return (int(m.group(1)), int(m.group(2)))
m = _JAVA_DOT_RE.match(s)
if m:
return (int(m.group(1)), int(m.group(2) or 0))
return None
def _java_is_affected(installed, entry) -> bool:
"""Oracle names only the CURRENT supported update as affected, but every
older update carries the same flaw. Verified against Defender TVM, which
reports CVE-2026-62574 (affected: 8u491) on hosts running 8u102 and 8u191 —
so the rule is `installed <= affected`, not an exact match. Same feature
release only: 8u491 says nothing about Java 11 or 17.
"""
if not installed:
return False
for raw in (entry.get("start"), entry.get("lt"), entry.get("lte"), entry.get("ver")):
av = _java_affected_version(raw) if raw else None
if not av:
continue
if av[0] != installed[0]:
continue # different feature release
if raw == entry.get("lt"):
return installed[1] < av[1] # exclusive upper bound
return installed[1] <= av[1] # affected version + everything below
return False
def scan_asset(db: Session, asset, packages: list, index: dict,
new_ids: Optional[list] = None, touched: Optional[set] = None) -> int:
"""Match an asset's installed software against the cvelistV5 index.
Returns findings upserted. Caller commits."""
if not index:
return 0
if new_ids is None:
new_ids = []
count = 0
seen: set = set()
fam = cpe._os_family(asset.operating_system or "")
for pkg in packages or []:
name = (pkg.get("name") or "").strip()
version = (pkg.get("version") or "").strip()
if not name or not version:
continue
if cpe._is_citrix_shim(pkg):
continue # published-app registry stub, software not on the box
key = resolve(name)
if not key or key not in index:
continue
# name_ver products (modern .NET): the semantic version lives in the
# display NAME; the version field is an MSI build that never matches.
if key == "oracle-java":
jver = _java_version(name, version)
if not jver:
continue
dedup_j = (key, jver)
if dedup_j in seen:
continue
seen.add(dedup_j)
for entry in index[key]:
if not _java_is_affected(jver, entry):
continue
c = {"cve": entry["cve"], "cvss": entry.get("cvss"),
"severity": entry.get("sev"), "fixed": None}
try:
before = len(new_ids)
cpe._upsert(db, asset, name, f"{jver[0]}u{jver[1]}", c, new_ids,
touched=touched, vendor=(pkg.get("vendor") or None))
count += 1 if len(new_ids) > before else 0
except Exception as e:
logger.debug("java upsert failed (%s on %s): %s", entry["cve"], asset.id, e)
continue
eff_ver = cpe._effective_version(name, version, _KEY_ENTRY.get(key) or {})
cver = cpe._clean_version(eff_ver)
if not cver:
continue
dedup = (key, cver)
if dedup in seen:
continue
seen.add(dedup)
for entry in index[key]:
if not _affected(cver, entry.get("start"), entry.get("lt"), entry.get("lte")):
continue
if not _platform_ok(fam, entry.get("plats")):
continue # CVE is for a different OS platform (e.g. Teams-for-Mac)
# Only lessThan is a real fix target; lessThanOrEqual means that
# version is still affected (no published fix) → leave fixed empty.
c = {"cve": entry["cve"], "cvss": entry.get("cvss"), "severity": entry.get("sev"),
"fixed": entry.get("lt")}
try:
before = len(new_ids)
cpe._upsert(db, asset, name, eff_ver, c, new_ids, touched=touched,
vendor=(pkg.get("vendor") or None))
count += 1 if len(new_ids) > before else 0
except Exception as e:
logger.debug("cvelistv5 upsert failed (%s on %s): %s", entry["cve"], asset.id, e)
return count
if __name__ == "__main__":
# ponytail: one self-check for the metric parser (CNA v3.1 preferred, ADP
# fallback, missing → None). Run: python -m app.services.cvelistv5_scan_service
rec = {"containers": {"cna": {"metrics": [{"cvssV3_1": {"baseScore": 4.3, "baseSeverity": "MEDIUM"}}]},
"adp": [{"metrics": [{"cvssV3_1": {"baseScore": 9.8, "baseSeverity": "CRITICAL"}}]}]}}
assert _cvss_from_record(rec) == (4.3, "medium"), _cvss_from_record(rec) # CNA wins
assert _cvss_from_record({"containers": {"adp": [{"metrics": [{"cvssV3_0": {"baseScore": 7.5}}]}]}}) == (7.5, None)
assert _cvss_from_record({"containers": {"cna": {}}}) == (None, None) # no metrics
print("cvelistv5 _cvss_from_record self-check OK")