A hypervisor runs no agent. Wazuh cannot reach it, Intune does not know it,
and Nessus only sees it if someone scoped a credentialed scan at it — so the
machines whose compromise takes every VM on them down were the ones with no
vulnerability coverage at all.
The connector reads the vCenter appliance and every ESXi host in its
inventory over the vSphere SOAP API (pyVmomi): hostname, management IP,
hardware model, product version and BUILD. One PropertyCollector pass, so a
500-host estate is one round trip. Read-only rights are enough.
The build is the point. NVD carries nothing usable — CVE-2026-47876 and
CVE-2026-59310 both sit there with no configuration — and the bounds
cvelistV5 does state are build identifiers, not versions:
ESX 8.0 lessThan "ESXi80U3k-25595708"
vCenter 8.0 lessThan "8.0 U3k"
_is_version rejects both, so _ranges_from_affected dropped the entries and
every vSphere CVE was invisible. They are now indexed verbatim and resolved
to build numbers: ESXi bounds carry one inline, vCenter bounds name a release
whose build comes from Broadcom KB 326316 (seeded in full, re-read weekly,
merged never replaced). A bound that resolves to no build produces no
verdict, and neither does a host with no build recorded.
Comparing builds alone is wrong in both directions. CVE-2025-22224 names two
fixes for the 8.0 line at once — U3d (24585383) for 8.0.3 and U2d (24585300)
for 8.0.2 — so the U2d host is patched despite the higher number existing.
And vCenter 8.0 U2f shipped four days AFTER the U3k fix on the older update
line, with a higher build, because Broadcom ships async patches there. So the
decision is scoped to the update line first, then still checked against the
build.
What must never resolve is asserted in the tests: the same advisories file
"VMware Cloud Foundation (vCenter Server)" and "vSphere Foundation" with the
SUITE's version numbers, and matching those would compare a vCenter 8.0.3
appliance against a VCF 5.x range.
EOL comes from endoflife.date (esxi / vcenter), keyed by major line — which
is also the honest granularity, one end-of-support date per line. 7.0 ended
2025-10-02, 6.7/6.5 in 2022. Both slugs are single-release, so an upgrade
retires the old finding. The OS patterns are shared with the CVE pass so a
machine cannot be a hypervisor for its CVEs and something else for its dates.
Checked against all 189 vSphere CVE records currently in cvelistV5: 64 bounds
indexed, none unresolvable, and every current release comes out clean while
each one behind gets its own line's fix named.
Migration 045 adds AssetSource.VCENTER, assets.vmware_uuid (the pin) and
assets.vmware_build.
1622 lines
79 KiB
Python
1622 lines
79 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_v27" # v27: ESXi + vCenter
|
|
_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( reader)?\b(?!.*(language pack|dictionar|spelling|font pack))",
|
|
"pairs": [("adobe", "acrobat reader"), ("adobe", "acrobat"),
|
|
("adobe", "acrobat reader dc"), ("adobe", "acrobat dc"),
|
|
("adobe", "adobe acrobat reader"), ("adobe", "adobe acrobat")]},
|
|
# Creative-Cloud desktop apps. cvelistV5 uses "Photoshop Desktop" and
|
|
# "InDesign Desktop" but plain "Illustrator" and "Bridge" — the suffix is
|
|
# not a pattern, each name has to be listed. The inventory name carries a
|
|
# year the records never mention; the version field is what is compared.
|
|
{"key": "adobe-illustrator", "re": r"^adobe illustrator\b",
|
|
"pairs": [("adobe", "illustrator"), ("adobe", "illustrator desktop"),
|
|
("adobe", "adobe illustrator")]},
|
|
{"key": "adobe-photoshop", "re": r"^adobe photoshop\b",
|
|
"pairs": [("adobe", "photoshop desktop"), ("adobe", "photoshop"),
|
|
("adobe", "adobe photoshop")]},
|
|
{"key": "adobe-indesign", "re": r"^adobe indesign\b",
|
|
"pairs": [("adobe", "indesign desktop"), ("adobe", "indesign"),
|
|
("adobe", "adobe indesign")]},
|
|
{"key": "adobe-bridge", "re": r"^adobe bridge\b",
|
|
"pairs": [("adobe", "bridge"), ("adobe", "adobe bridge")]},
|
|
# Autodesk. cvelistV5 is not optional here: the CVEs from ADSK-SA-2026-0009
|
|
# (CVE-2026-16463, -16465, -17550) and ADSK-SA-2026-0012 (CVE-2026-7405,
|
|
# -7406) carry no NVD configuration at all — they sit at "Received" with
|
|
# zero cpeMatch entries — so the CPE path cannot see them.
|
|
# Versions are RELEASES, and newer records write them as
|
|
# "2027.0.0 .. <2027.1.0" where older ones say "2026 .. <2026.1" — both
|
|
# forms compare correctly as numbers.
|
|
# year_ver is what makes that comparison possible at all: the inventory
|
|
# reports the internal build (AutoCAD LT 2026 = 25.1.60.0), and a build
|
|
# number matches no release range. Without the flag this whole family was
|
|
# indexed and then never matched — the exact blind spot that hid
|
|
# CVE-2026-7405 on a stock AutoCAD LT 2026.
|
|
# AutoCAD and AutoCAD LT are listed separately but carry IDENTICAL ranges;
|
|
# kept apart in case they ever diverge.
|
|
{"key": "autodesk-autocad-lt", "re": r"^(autodesk )?autocad lt(?!.*language pack)",
|
|
"year_ver": True, "pairs": [("autodesk", "autocad lt")]},
|
|
{"key": "autodesk-autocad", "re": r"^(autodesk )?autocad(?! lt| open in)(?!.*language pack)\b",
|
|
"year_ver": True, "pairs": [("autodesk", "autocad")]},
|
|
{"key": "autodesk-dwg-trueview", "re": r"^autodesk dwg trueview(?!.*language pack)",
|
|
"year_ver": True, "pairs": [("autodesk", "dwg trueview")]},
|
|
{"key": "autodesk-navisworks-freedom",
|
|
"re": r"^autodesk navisworks freedom(?!.*(language pack|module linguistique|pacote de idioma|paquete de idioma))",
|
|
"year_ver": True, "pairs": [("autodesk", "navisworks freedom")]},
|
|
# VMware Tools. The critical case for having BOTH sources: NVD carries a
|
|
# CPE for CVE-2025-41244 and for almost none of the others — CVE-2025-41246,
|
|
# -41239, -22247 and -22230 are marked "NOT SCHEDULED" for enrichment, so
|
|
# the CPE path can never see them. cvelistV5 has all of them, but under
|
|
# three different spellings, including vendor "n/a".
|
|
# Versions are wildcards there ("13.x.x.x", "12.x.x, 11.x.x") — see
|
|
# _wildcard_floor for why they cannot be read as numbers.
|
|
{"key": "vmware-tools", "re": r"^vmware tools",
|
|
"pairs": [("vmware", "vmware tools"), ("vmware", "tools"),
|
|
("n/a", "vmware tools"), ("broadcom", "vmware tools")]},
|
|
# Node.js. The CPE path already knows it (nodejs:node.js) but NVD carries
|
|
# no configuration at all for the July-2026 releases — CVE-2026-56846 and
|
|
# -56848 sit there with no CPE, so nothing matched a 24.13.1 host. Every
|
|
# detail is in cvelistV5 instead, under vendor "nodejs" / product "node",
|
|
# stated one entry per release line (24.18.0, 22.23.1, 26.5.0) with
|
|
# version == lessThanOrEqual — see _ranges_from_affected for why that shape
|
|
# keeps its major as the floor.
|
|
{"key": "nodejs", "re": r"^node\.?js\b",
|
|
"pairs": [("nodejs", "node"), ("nodejs", "node.js"),
|
|
("node.js", "node.js")]},
|
|
# Dell RVTools. DSA-2026-325 / CVE-2026-64993 has no NVD configuration —
|
|
# the record is still "Awaiting Enrichment" — so the CPE path is blind to
|
|
# it. Dell writes the bound as "4.8.1 or later", which is prose, not a
|
|
# version; see _clean_bound.
|
|
{"key": "rvtools", "re": r"^(dell )?rvtools\b",
|
|
"pairs": [("dell", "rvtools"), ("robware", "rvtools"),
|
|
("dell technologies", "rvtools")]},
|
|
# Veeam ONE. KB4892 covers six CVEs (CVE-2026-64633, -64634, -64631,
|
|
# -64630, -58074, -58075), none of them enriched by NVD. Only the SERVER is
|
|
# affected: the advisory scopes everything to "Veeam ONE 13.0.2.6723 and
|
|
# all earlier version 13 builds", the server product, and the monitoring
|
|
# client ships its own build number. Matching the client too would flag
|
|
# every workstation that merely has the console installed, so the name has
|
|
# to exclude it — same shape as the AutoCAD / AutoCAD LT split.
|
|
{"key": "veeam-one", "re": r"^veeam one(?!.*(client|console|monitor client|web client))",
|
|
"pairs": [("veeam", "veeam one"), ("veeam software", "veeam one"),
|
|
("veeam", "one")]},
|
|
# Wazuh's own components. Both sources are needed here as usual: NVD files
|
|
# 41 of the 54 under wazuh:wazuh, while the CNA records name the product
|
|
# per component ("wazuh", "wazuh-kibana-app") and state ranges as
|
|
# ">= 4.2.0, < 4.7.2". Version arrives with an rpm/deb release suffix,
|
|
# handled by pkg_ver in the CPE registry.
|
|
{"key": "wazuh", "re": r"^wazuh[- ](agent|manager|server|indexer)(?![-\w])",
|
|
"pkg_ver": True,
|
|
"pairs": [("wazuh", "wazuh"), ("wazuh", "wazuh-manager"),
|
|
("wazuh", "wazuh-agent"), ("wazuh inc", "wazuh")]},
|
|
{"key": "wazuh-dashboard", "re": r"^wazuh[- ]dashboard(?![-\w])",
|
|
"pkg_ver": True,
|
|
"pairs": [("wazuh", "wazuh-dashboard"), ("wazuh", "wazuh-kibana-app")]},
|
|
{"key": "7-zip", "re": r"7-?zip",
|
|
"pairs": [("7-zip", "7-zip"), ("igor pavlov", "7-zip")]},
|
|
# Vim. NVD carries nothing for the current batch — CVE-2026-73070, -73072,
|
|
# -73074, -73076, -73077 and -73078 sit there with no CPE and no affected
|
|
# block — so this is the only source that can see them. Vim states the
|
|
# bound INSIDE the version field ("< 9.2.0842"), the operator form
|
|
# _ranges_from_affected already reads.
|
|
# Two product spellings occur: today's "vim"/"vim" and the huntr-era
|
|
# "vim"/"vim/vim". Pre-2020 records use vendor "n/a", which is not
|
|
# matchable here by design — those are the ones NVD did enrich, so the CPE
|
|
# registry entry covers them.
|
|
# Anchored: Neovim is a different product with its own CVEs.
|
|
{"key": "vim", "re": r"^g?vim\b",
|
|
"pairs": [("vim", "vim"), ("vim", "vim/vim")]},
|
|
# Visual Studio Code. Same story — CVE-2026-47285, -58650, -59113, -69278,
|
|
# -69306, -69320, -70335 and -70336 are all unenriched at NVD, and all
|
|
# eight state the same clean range (1.0.0 .. <1.132.1), which a 1.132.0
|
|
# host is inside.
|
|
# The pairs stay EXACT on purpose: Microsoft files the extensions as their
|
|
# own products ("Python extension for Visual Studio Code", "Visual Studio
|
|
# Code Remote - SSH Extension"), and they carry extension version numbers
|
|
# that would compare nonsensically against the editor's 1.x.
|
|
# The name is anchored so "Microsoft Visual Studio Community 2022" — a
|
|
# different product, versioned 17.x — can never resolve here.
|
|
{"key": "vscode", "re": r"^(microsoft )?visual studio code\b",
|
|
"pairs": [("microsoft", "visual studio code"),
|
|
("microsoft", "microsoft visual studio code")]},
|
|
# CPython. NVD has no usable data for CVE-2025-15366 either. PSF states
|
|
# honest ranges per release line ("0 .. <3.13.15", "3.14.0 .. <3.14.7").
|
|
# name_ver is what makes them comparable: the Windows installer reports an
|
|
# MSI BUILD in the version field (Python 3.13.7 → 3.13.7150.0) while the
|
|
# semantic version sits in the NAME — exactly the tester's hunch. Read as
|
|
# a build, 3.13.7150.0 is ABOVE every 3.13.x bound, so every Python CVE
|
|
# silently passed. Same regex as the CPE registry entry, launcher excluded
|
|
# (its name carries no version at all).
|
|
# The ten component rows (Core Interpreter, Test Suite, …) all carry the
|
|
# same name version and collapse to one finding via the scan-side dedup.
|
|
# The name has to END at the interpreter (optionally with its release):
|
|
# a trailing "-" or letter means a MODULE — "python-dotenv" resolved here
|
|
# and its 1.1.1 was compared against CPython's ranges (tester:
|
|
# CVE-2017-1000158, fixed in 2.7.15).
|
|
{"key": "python", "re": r"(?<!\w)python[\d.]*(?![-\w.])(?!.*launcher)",
|
|
"name_ver": True,
|
|
"pairs": [("python software foundation", "cpython"), ("python", "cpython"),
|
|
("python software foundation", "python"), ("python", "python")]},
|
|
# Apache Tomcat. The ASF states every record per RELEASE LINE, one entry
|
|
# each for 11.0.x / 10.1.x / 9.0.x, and the floor is the line's first
|
|
# milestone ("9.0.0.M1 .. <=9.0.115"). That floor is what keeps the lines
|
|
# apart: without it a 9.0 host would fall inside the 11.0.18 bound.
|
|
# _vtuple reads both spellings of it — "9.0.0.M1" and "11.0.0-M1" — as
|
|
# 9.0.0.1 / 11.0.0.1, below any real release on that line.
|
|
# The newest records name exact versions instead of ranges: CVE-2026-34486
|
|
# affects "11.0.20, 10.1.53, 9.0.116" and nothing else (a regression in the
|
|
# fix for CVE-2026-29146), which _ranges_from_affected reads as three
|
|
# closed [v, v] ranges — so 9.0.107 is correctly NOT flagged for it, while
|
|
# the older ranges it IS inside still match.
|
|
# Vendor is written both ways across the record history ("Apache Software
|
|
# Foundation" today, plain "Apache" on CVE-2020-1938).
|
|
# Anchored, and the satellite products are excluded: Tomcat Connectors
|
|
# (mod_jk), Tomcat Native and the JK/IIS connector are separate products
|
|
# with their own version lines, and TomEE embeds Tomcat but is versioned
|
|
# and patched on its own.
|
|
{"key": "tomcat",
|
|
"re": r"^(apache )?tomcat\b(?!.*(connector|native|jk|tomee))",
|
|
"pairs": [("apache software foundation", "apache tomcat"),
|
|
("apache", "apache tomcat"), ("apache", "tomcat"),
|
|
("apache software foundation", "tomcat"), ("n/a", "apache tomcat")]},
|
|
# 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.
|
|
# Android reports the app by its store id, org.mozilla.firefox, which has
|
|
# both words but no space — so this path missed Firefox on every Android
|
|
# device while the CPE path saw it. Anchored, so it stays a package id and
|
|
# not a substring hit. (Chrome already had com.android.chrome below.)
|
|
{"key": "firefox", "re": r"mozilla firefox|^org\.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)$"},
|
|
# vSphere. Both products are stated under four vendor spellings across the
|
|
# record history ("VMware", "n/a", "Broadcom", "VMware, Inc.") and the
|
|
# product name changed twice — "VMware ESXi" → "ESXi" → "ESX" (the 2026
|
|
# records use the short form), "VMware vCenter Server" → "vCenter".
|
|
#
|
|
# The product regex is ANCHORED and exact, and that is the whole safety
|
|
# story here: the same advisories also file
|
|
# "VMware Cloud Foundation (vCenter Server)" versions 5.x / 4.x
|
|
# "vSphere Foundation" versions 9.1.x.x
|
|
# "VMware ESXi, Workstation, and Fusion" one prose blob
|
|
# Those carry the SUITE's version numbers, not the component's, so letting
|
|
# any of them resolve here would compare a vCenter 8.0.3 host against a
|
|
# VCF 5.x range — a fabricated finding on the most critical asset in the
|
|
# estate. The bundle spellings all fail the anchor.
|
|
#
|
|
# Versions never enter the shared range path: see scan_asset_vmware and
|
|
# vmware_release_service for why a build number is the only comparable
|
|
# quantity VMware states.
|
|
{"key": "vmware-esxi", "vendor_lit": "vmware",
|
|
"vendor_re": r"^(vmware(,? inc\.?)?|broadcom|n/a)$",
|
|
"product_re": r"^(vmware\s+)?esxi?$"},
|
|
{"key": "vmware-vcenter", "vendor_lit": "vmware",
|
|
"vendor_re": r"^(vmware(,? inc\.?)?|broadcom|n/a)$",
|
|
"product_re": r"^(vmware\s+)?vcenter(\s+server)?(\s+appliance)?"
|
|
r"(\s*\((vcsa|vcenter server)\))?$"},
|
|
]
|
|
|
|
# The vSphere keys. Their bounds are build numbers and release names, not
|
|
# versions, so they are indexed raw and decided by vmware_release_service.
|
|
_VMWARE_KEYS = {"vmware-esxi", "vmware-vcenter"}
|
|
|
|
|
|
def vmware_entries(aff: dict, cve_id: str, cvss=None, sev=None, desc=None) -> List[dict]:
|
|
"""One vSphere affected[] block → index entries, bounds kept VERBATIM.
|
|
|
|
"ESXi80U3k-25595708" and "8.0 U3k" are not version numbers: `_is_version`
|
|
rejects both, so `_ranges_from_affected` dropped the entries and every
|
|
vSphere CVE fell out of the index. Here the bound is stored as written and
|
|
resolved to a build at scan time (vmware_release_service).
|
|
|
|
Entries with no upper bound at all are skipped. VMware writes those for a
|
|
line it lists as affected without naming a fix (CVE-2024-37085 does it for
|
|
7.0), and "affected, fix unknown" cannot be turned into a per-host verdict
|
|
— flagging every host on the line would be a guess, not a finding.
|
|
"""
|
|
out: List[dict] = []
|
|
for v in (aff.get("versions") or []):
|
|
if not isinstance(v, dict):
|
|
continue
|
|
if (v.get("status") or "affected") != "affected":
|
|
continue
|
|
lt = (v.get("lessThan") or v.get("lessThanOrEqual") or "").strip()
|
|
if not lt:
|
|
continue
|
|
out.append({"cve": cve_id, "lt": lt,
|
|
"ver": (v.get("version") or "").strip(),
|
|
"cvss": cvss, "sev": sev,
|
|
"prod": (aff.get("product") or "").strip(), "desc": desc})
|
|
return out
|
|
|
|
# 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
|
|
# Full name for matching (exclusions depend on it), prefix only to tell a
|
|
# companion product apart — see cpe.companion_prefix / resolve_product.
|
|
head = cpe.companion_prefix(n)
|
|
for rx, e in _COMPILED:
|
|
if rx.search(n):
|
|
if head != n and not rx.search(head):
|
|
continue
|
|
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 _description_from_record(data: dict) -> Optional[str]:
|
|
"""The CNA's own English description of the flaw.
|
|
|
|
Findings showed only the generated title ("Adobe Acrobat (64-bit)
|
|
26.001.21691 — CVE-2026-48294"), which says what we matched, not what the
|
|
vulnerability IS. The text is right there in the record and answers the
|
|
first question anyone opening a finding has.
|
|
"""
|
|
for d in ((data.get("containers") or {}).get("cna") or {}).get("descriptions") or []:
|
|
if (d or {}).get("lang", "en").lower().startswith("en"):
|
|
v = (d.get("value") or "").strip()
|
|
if v:
|
|
return v[:2000]
|
|
return None
|
|
|
|
|
|
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 ----------
|
|
_WILDCARD_RE = re.compile(r"^\s*(\d+(?:\.\d+)*)\s*\.?x", re.I)
|
|
|
|
|
|
def _wildcard_floor(raw: str) -> Optional[str]:
|
|
"""Lowest concrete release named by a wildcard version string.
|
|
|
|
VMware states affected versions as placeholders — "13.x.x.x", and even
|
|
"12.x.x, 11.x.x" for two release lines in ONE field. Read as digits those
|
|
become (13) and (12, 11), and the second is not a version at all: a 12.4
|
|
host compares BELOW that floor and drops out of its own range, as does an
|
|
11.3 host. Both were reported unaffected while the record says otherwise.
|
|
|
|
So take the concrete part in front of each `x`, and use the lowest — the
|
|
floor of the whole statement. Returns None when there is no wildcard,
|
|
leaving ordinary versions untouched.
|
|
"""
|
|
if "x" not in (raw or "").lower():
|
|
return None
|
|
floors = []
|
|
for part in raw.split(","):
|
|
m = _WILDCARD_RE.match(part)
|
|
if m:
|
|
floors.append(m.group(1))
|
|
if not floors:
|
|
return None
|
|
return min(floors, key=lambda v: cpe._vtuple(v) or ())
|
|
|
|
|
|
# Products whose vendor has used more than one version scheme, so a bound can
|
|
# describe a build the installed version is not comparable to at all. Checked
|
|
# with _same_version_scheme before any range is applied.
|
|
#
|
|
# adobe-acrobat desktop app, Edge's PDF engine and the browser extension
|
|
# all share one product name (see _affected)
|
|
# teams CVE-2025-49731 names three: 1.0.0.2025112902 (Android),
|
|
# 7.10.1 (iOS) and the MSRC build 25060212643. A host on the
|
|
# current 26183.1903.4892.4448 compares below every one of
|
|
# them, so the CVE sat open on a fully patched machine. The
|
|
# MSRC path already refused to compare across build shapes;
|
|
# this path did not, which is why the finding kept coming back
|
|
# after the NVD side had stopped producing it.
|
|
#
|
|
# The old Teams numbering (1.6.00.4472) still compares against the Android and
|
|
# iOS bounds — same shape — so nothing genuine is lost.
|
|
_STRICT_SCHEME_KEYS = {"adobe-acrobat", "teams"}
|
|
|
|
def _fix_target(lt, lte=None):
|
|
"""The fix to show. A lessThan bound names a real build; a lessThanOrEqual
|
|
only bounds the damage, so it is reported as a floor: ">26.5.2.2".
|
|
|
|
The tester asked for this twice and he is right: if "26.5.2.2 and earlier"
|
|
are affected, then anything above it is not, and that is a valid, useful
|
|
statement — an admin can act on "newer than 26.5.2.2" even when the vendor
|
|
named no build. Adobe writes CVE-2026-48294 exactly that way.
|
|
|
|
It is kept out of the plain fixed_version because that field also drives
|
|
the "patch available" badge, and lessThanOrEqual means the CNA named no
|
|
patched build — often because none had shipped yet. The ">" prefix carries
|
|
both facts: the actionable floor is visible, and has_fix (see
|
|
VulnerabilityPackage) reads the prefix and does not claim a patch exists.
|
|
A later record with a real lessThan replaces it.
|
|
"""
|
|
real = _fix_build(lt)
|
|
if real:
|
|
return real
|
|
floor = _fix_build(lte)
|
|
return f">{floor}" if floor else None
|
|
|
|
|
|
def _fix_build(lt):
|
|
"""A lessThan bound only becomes a fixed_version if it names a real build.
|
|
|
|
CVE-2026-21710 lists Node 20.20.1, 22.22.1, 24.14.0 and 25.8.1 as concrete
|
|
entries and then adds "4.0 lessThan 4.*" and "5.0 lessThan 5.*" for the
|
|
ancient lines. A wildcard is a release LINE, not a version you can install,
|
|
and the tester's host ended up advised to upgrade Node 24.13.1 to "4.*".
|
|
|
|
It matters more than it looks: a fixed_version is only ever filled in once
|
|
and never overwritten (see vuln_override_service), so a wrong one sticks
|
|
for the life of the finding. Empty is recoverable, wrong is not.
|
|
"""
|
|
if not isinstance(lt, str) or not lt.strip():
|
|
return None
|
|
v = lt.strip()
|
|
if "*" in v or "x" in v.lower():
|
|
return None
|
|
return v if _is_version(v) else None
|
|
|
|
|
|
_BOUND_PROSE_RE = re.compile(r"^\s*(\d+(?:\.\d+)*)\s*(?:or later|and later|\+)\s*$", re.I)
|
|
|
|
# "5.0.0-beta3" → base "5.0.0". A bound in this shape names a prerelease of a
|
|
# release that is not out yet — see the floor it produces in
|
|
# _ranges_from_affected.
|
|
_PRERELEASE_RE = re.compile(r"^(\d+(?:\.\d+)*)-(?:alpha|beta|rc|pre)", re.I)
|
|
|
|
|
|
def _is_version(v) -> bool:
|
|
"""Shared with the CPE scanner — see cpe.is_version. Kept as a name here
|
|
because both paths reject the same thing: a commit hash where a version
|
|
belongs (Wazuh bounds CVE-2026-67308 with "before 44bf114d2f49")."""
|
|
return cpe.is_version(v)
|
|
|
|
|
|
def _clean_bound(v):
|
|
"""Trim prose off a version bound: "4.8.1 or later" → "4.8.1".
|
|
|
|
Dell writes CVE-2026-64993 that way. The comparison happened to survive it
|
|
(only digits are read), but the string was carried through to the finding
|
|
and shown as the fixed version, so the GUI offered "upgrade to 4.8.1 or
|
|
later" as if that were a build number. Anything that is not this exact
|
|
shape is left untouched — a bound we cannot parse must stay verbatim
|
|
rather than be guessed at.
|
|
"""
|
|
if not isinstance(v, str):
|
|
return v
|
|
m = _BOUND_PROSE_RE.match(v)
|
|
return m.group(1) if m else v
|
|
|
|
|
|
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")
|
|
# Same commit-hash rule as the affected branch below, and
|
|
# for the same reason. CVE-2026-67308 states the CI fix
|
|
# twice — "affected < 44bf114d2f49…" AND "44bf114d2f49…
|
|
# unaffected" — so rejecting only the first bound let the
|
|
# second one back in through here, and every 4.x Wazuh
|
|
# install compared below "44" all over again.
|
|
if (isinstance(ver, str) and ver.strip() not in ("0", "*", "-", "")
|
|
and _is_version(ver.strip())):
|
|
unaffected_floors.append(ver.strip())
|
|
continue
|
|
start = None
|
|
lt = _clean_bound(v.get("lessThan"))
|
|
lte = _clean_bound(v.get("lessThanOrEqual"))
|
|
ver = v.get("version")
|
|
# A bound that is a commit hash describes a source tree, not a release
|
|
# — see _is_version. Dropping it here rather than at compare time means
|
|
# an entry left with no usable bound is skipped entirely instead of
|
|
# matching everything below a number that was never a version.
|
|
if lt is not None and not _is_version(lt):
|
|
lt = None
|
|
if lte is not None and not _is_version(lte):
|
|
lte = None
|
|
if isinstance(ver, str) and ver.strip() and not _is_version(ver.strip()):
|
|
ver = "0"
|
|
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 = _wildcard_floor(vs) or vs
|
|
if lt in ("*", "-", ""):
|
|
lt = None
|
|
if lte in ("*", "-", ""):
|
|
lte = None
|
|
if start is not None and start == lt:
|
|
# "version" == "lessThan" is a zero-width, impossible range — some
|
|
# Chrome records emit it. NVD reads it as an open floor, and so do
|
|
# we: a Chrome CVE fixed in 151.0.7922.72 does affect 150.x, so
|
|
# dropping the floor entirely is right here.
|
|
start = None
|
|
elif start is not None and start == lte:
|
|
# "version" == "lessThanOrEqual" means something DIFFERENT — the
|
|
# bound is inclusive, so the record is naming one release line, not
|
|
# everything below it. Node states CVE-2026-56846 as two entries,
|
|
# 24.18.0 and 22.23.1, each with version == lessThanOrEqual, and
|
|
# its description says "affects Node.js 24.x and 22.x".
|
|
#
|
|
# Dropping the floor here made "up to 24.18.0" swallow every older
|
|
# major: a host on 22.23.2 — already patched, and covered by the
|
|
# OTHER entry — matched through the 24.x range, as did 20.x, which
|
|
# is not affected at all. Keeping the major version as the floor
|
|
# confines each entry to its own release line, which is what the
|
|
# record describes.
|
|
start = (cpe._vtuple(start) or (0,))[0]
|
|
start = str(start)
|
|
if start is None and not lte and lt:
|
|
# A PRERELEASE upper bound describes the prerelease train it names,
|
|
# not the whole history of the product. Wazuh states CVE-2026-67307
|
|
# as "0 → < 5.0.0-beta3" and GitHub's advisory for the same flaw
|
|
# says 5.0.0-beta1 — the bug was written in a 5.0 beta and fixed
|
|
# three betas later. With the floor left open, every shipped 4.x
|
|
# agent sat below the bound and collected it (tester: a Windows
|
|
# agent on 4.14.5). The floor is the base version's first
|
|
# prerelease, which excludes 4.x below it and the finished 5.0.0
|
|
# above it while keeping the betas in between — "-1" and not "-0"
|
|
# because _vcmp pads the shorter tuple with zeros, so "5.0.0-0"
|
|
# compares EQUAL to the released 5.0.0 and would let it back in.
|
|
m = _PRERELEASE_RE.match(lt)
|
|
if m:
|
|
start = f"{m.group(1)}-1"
|
|
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], scheme_strict: bool = False) -> bool:
|
|
"""scheme_strict: only compare bounds written in the SAME version shape as
|
|
the install. Adobe files the desktop app (26.001.21771), the Acrobat engine
|
|
inside Edge (127.0.2651.105, Chromium numbering) and the browser extension
|
|
(26.7.1.0) under one product name — see _same_version_scheme in the CPE
|
|
scanner. CVE-2024-41879 is the Edge engine and landed on all three."""
|
|
if scheme_strict:
|
|
for b in (start, lt, lte):
|
|
if b and not cpe._same_version_scheme(installed, b):
|
|
return False
|
|
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)
|
|
desc = _description_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,
|
|
"desc": desc})
|
|
index.setdefault(key, []).append(ent)
|
|
continue
|
|
if key in _VMWARE_KEYS:
|
|
for ent in vmware_entries(aff, cve_id, cvss, sev, desc):
|
|
_sig = (key, cve_id, ent["lt"], prod.lower())
|
|
if _sig in seen:
|
|
continue
|
|
seen.add(_sig)
|
|
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,
|
|
"desc": desc})
|
|
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,
|
|
"desc": desc})
|
|
_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, "desc": entry.get("desc")}
|
|
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"), "desc": entry.get("desc")}
|
|
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
|
|
|
|
|
|
|
|
# ---------- VMware vSphere (ESXi / vCenter Server) ----------
|
|
#
|
|
# The asset's OS string decides which product it is. Both are set by the
|
|
# vCenter connector, and Nessus reports the same shapes for a hypervisor it
|
|
# fingerprinted ("VMware ESXi 8.0.3", "VMware vCenter Server").
|
|
#
|
|
# The patterns live in vmware_release_service, shared with the EOL resolver:
|
|
# the two must never disagree about what an ESXi host is, or a machine would be
|
|
# a hypervisor for its CVEs and something else for its lifecycle dates.
|
|
_VSPHERE_LABEL = {"vmware-esxi": "VMware ESXi",
|
|
"vmware-vcenter": "VMware vCenter Server"}
|
|
|
|
|
|
def vsphere_product(os_name: Optional[str]) -> Optional[tuple]:
|
|
"""Asset OS string → (curated key, display label), or None."""
|
|
from app.services import vmware_release_service as vmr
|
|
key = vmr.product_for_os(os_name)
|
|
return (key, _VSPHERE_LABEL[key]) if key else None
|
|
|
|
|
|
def scan_asset_vmware(db: Session, asset, index: dict,
|
|
new_ids: Optional[list] = None,
|
|
touched: Optional[set] = None) -> int:
|
|
"""ESXi / vCenter CVEs from the host's version + build.
|
|
|
|
This is the only path that can see them. NVD carries no configuration for
|
|
the current VMSA batch at all (CVE-2026-47876 and CVE-2026-59310 are both
|
|
empty there), and the bounds cvelistV5 does state are build identifiers,
|
|
not versions — so neither the CPE scanner nor the generic cvelistV5 range
|
|
matcher could ever have matched a hypervisor.
|
|
|
|
All bounds of ONE CVE are weighed together: a VMSA lists one fix per update
|
|
line ("8.0 U3d" AND "8.0 U2d"), and reading them row by row is what would
|
|
let a U2 host fall through the U3 range. See vmware_release_service.
|
|
"""
|
|
if not index:
|
|
return 0
|
|
resolved = vsphere_product(asset.operating_system or "")
|
|
if not resolved:
|
|
return 0
|
|
key, label = resolved
|
|
entries = index.get(key) or []
|
|
if not entries:
|
|
return 0
|
|
from app.services import vmware_release_service as vmr
|
|
installed = vmr.installed_release(asset.os_version or "",
|
|
getattr(asset, "vmware_build", None))
|
|
if not installed:
|
|
return 0
|
|
if installed.build is None:
|
|
# Without a build nothing here can be decided (see
|
|
# vmware_release_service). Said out loud rather than silently returning
|
|
# zero: "no findings" and "could not look" must not read the same on a
|
|
# hypervisor. The vCenter connector always records one; a Nessus- or
|
|
# hand-registered host may not.
|
|
logger.info("vsphere scan: %s (%s %s) has no build recorded — "
|
|
"no CVE verdict possible; register it through the vCenter "
|
|
"connector to get one",
|
|
asset.hostname, label, asset.os_version)
|
|
return 0
|
|
catalog = vmr.load_catalog(db).get(key) or {}
|
|
if new_ids is None:
|
|
new_ids = []
|
|
|
|
per_cve: Dict[str, list] = {}
|
|
for e in entries:
|
|
per_cve.setdefault(e["cve"], []).append(e)
|
|
|
|
count = 0
|
|
unresolved = 0
|
|
for cve_id, ents in per_cve.items():
|
|
bounds = []
|
|
for e in ents:
|
|
b = vmr.bound_release(key, e.get("lt"), catalog)
|
|
if b is None:
|
|
unresolved += 1
|
|
continue
|
|
bounds.append(b)
|
|
if not bounds or not vmr.is_affected(installed, bounds):
|
|
continue
|
|
c = {"cve": cve_id, "cvss": ents[0].get("cvss"), "severity": ents[0].get("sev"),
|
|
"fixed": vmr.fix_hint(installed, bounds), "desc": ents[0].get("desc")}
|
|
try:
|
|
before = len(new_ids)
|
|
version_shown = f"{asset.os_version} build {installed.build}"
|
|
cpe._upsert(db, asset, label, version_shown, c, new_ids, touched=touched,
|
|
vendor="VMware")
|
|
count += 1 if len(new_ids) > before else 0
|
|
except Exception as e:
|
|
logger.debug("vsphere upsert failed (%s on %s): %s", cve_id, asset.id, e)
|
|
if unresolved:
|
|
# Visible on purpose: an unresolved bound is a silent blind spot, and
|
|
# the fix is usually one catalog refresh away.
|
|
logger.info("vsphere scan (%s): %d bound(s) not resolvable to a build — skipped",
|
|
asset.hostname, unresolved)
|
|
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, "desc": entry.get("desc")}
|
|
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)
|
|
strict = key in _STRICT_SCHEME_KEYS
|
|
for entry in index[key]:
|
|
if not _affected(cver, entry.get("start"), entry.get("lt"),
|
|
entry.get("lte"), scheme_strict=strict):
|
|
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": _fix_target(entry.get("lt"), entry.get("lte")),
|
|
"desc": entry.get("desc")}
|
|
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")
|