Files
vulncheck/app/services/cvelistv5_scan_service.py
T
vulncheck 2214af1d64 fix(cvelistv5): Citrix Workspace app CVEs never reached a Windows host
Two faults, either one enough on its own. The inventory filter dropped every
package whose vendor contains "citrix", which was meant for the "Delivered by
Citrix" published-app stubs but also removed every real install ("Citrix
Workspace 2507", vendor "Citrix Systems, Inc."). And behind it, Citrix states
its fixes as release names, not builds: CVE-2026-78546/-78547 give
"2603.11 Current Release (CR)", "2507.1 LTSR CU3" and "LTSR 2607". Read as
digits, "LTSR 2607" is (2607,), so every 25.x/26.x build would have matched,
the patched CU3, CR 2603.11 and LTSR 2607 hosts included.

The inventory states only the build (25.7.1000.1025), with no CR/LTSR and no
CU. Both sides now meet on the build via a catalog read from Citrix's download
pages (title + "Version:"), kept only when the build agrees with its title. Five
CR pages print a sidebar build (22.12.0.48) first, and without that check
they would have entered the catalog as 2302..2307.1. The catalog is seeded with
44 pages and refreshed weekly by the 01:30 index job.

CR and LTSR never share a year.month, so the build's first two fields name the
branch. The decision reuses vmware_release_service.is_affected: CR is one
line, each LTSR is its own line with the CU as update line (CVE-2025-4879 fixes
2402 in CU2 HF1 and CU3 HF1). A bound that resolves to no single catalog build
leaves the CVE undecided and held open, never guessed. Windows hosts only; the
Mac app shares the name and its 25.07.x numbering.
2026-09-14 11:14:00 +02:00

2944 lines
145 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_v35" # v35: Ubiquiti UniFi APs
_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
# field testing 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")]},
# Exchange 2016 / 2019, one key PER CUMULATIVE UPDATE. Same reasoning as
# SharePoint: every CU carries its own build line and its own bound in the
# same record (CVE-2026-62911: 2016 CU23 < 15.01.2507.072, 2019 CU14 <
# 15.02.1544.044, 2019 CU15 < 15.02.1748.049), all floored at a generic
# 15.0x.0.0 — so a shared key would compare a CU14 host (15.2.1544.x)
# against the CU15 bound (1748.049) and call a fully patched server
# affected. The CU is named in the inventory, so the pair can be exact and
# the range then only decides "patched or not" within that one CU.
# The set is closed: CU23 was the last 2016 CU, CU15 the last for 2019.
# These live on the cvelistV5 path ONLY. NVD's CPE for the same product is
# microsoft:exchange_server with version "2016" and update
# "cumulative_update_23" — no build anywhere in the CPE, so a build-range
# check cannot be expressed there (SE is different: it has its own CPE
# product carrying real builds, hence its entry in app_cve_scanner_service).
# Anchored for the reason the SE row is: the same host also carries a plain
# "Microsoft Exchange Server" row frozen at the CU's RTM build (.6 while the
# CU row reads .61), plus language packs and KB hotfix rows whose version
# field is a stub — each would compare below every fix build forever.
{"key": "exchange-2016-cu23", "re": r"^microsoft exchange server 2016 cumulative update 23$",
"pairs": [("microsoft", "microsoft exchange server 2016 cumulative update 23")]},
{"key": "exchange-2019-cu14", "re": r"^microsoft exchange server 2019 cumulative update 14$",
"pairs": [("microsoft", "microsoft exchange server 2019 cumulative update 14")]},
{"key": "exchange-2019-cu15", "re": r"^microsoft exchange server 2019 cumulative update 15$",
"pairs": [("microsoft", "microsoft exchange server 2019 cumulative update 15")]},
# 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 as suspected. 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 (observed:
# 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 (requested: "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 — 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")]},
# Citrix Workspace app for Windows. The bounds are release NAMES ("2507.1
# LTSR CU3") and the install states only a build, so the key is indexed
# raw and decided by citrix_workspace_service. The umbrella entry only
# ("Citrix Workspace 2507"; Defender: "citrix workspace") — not its
# components "(DV)", "(USB)", "Inside", nor the Chrome app. "Citirx" is
# CVE-2026-78546's own spelling.
{"key": "citrix-workspace-win", "re": r"^citrix workspace(?: app)?(?: \d{4}(?:\.\d{1,2})?)?$",
"pairs": [("citrix", "citrix workspace app for windows"),
("citrix", "workspace app for windows"),
("citirx", "workspace app for windows")]},
]
_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)$"},
# TeamViewer desktop clients. The pair list above cannot hold this: TeamViewer
# writes ONE product string per bulletin listing every component it covers —
# "Full Client, Host, QuickSupport & Portable", plus a parenthesised note per
# legacy line ("(v14 for Windows)", "(for Windows 7 & 8)"). Across 54 records
# the same client appears under 25 spellings, and the compound form is the one
# TeamViewer has used since 2026. That is what hid CVE-2026-16444 (TV-2026-1008,
# CVSS 7.5): NVD has no configuration for it either — the record sits at
# "Received" with zero cpeMatch entries — so cvelistV5 was the only structured
# source, its product name matched no pair, and a host on 15.80.6.0 reported
# clean against a bound of 15.81.5.
#
# The regex is ANCHORED and enumerates the client component words, which is the
# whole safety story: TeamViewer files DEX (32 records, versioned 9.x/24.x/25.x),
# ONE, Frontline, IoT, Engage and "Patch & Asset Management" under the same
# vendor, and every one of them carries a version line that would compare
# nonsensically against a 15.x client. None of them can pass the anchor.
# "Meeting" is likewise out — its own installer, its own advisories.
# The trailing "(…)" accepts any note TeamViewer writes there, so the MOBILE
# apps have to be shut out by name: "Host (Android)" and "QuickSupport (iOS)"
# would otherwise pass the anchor and be compared, on a Windows workstation,
# against a phone app's version line. _platform_ok is the general backstop,
# but it only fires when the CNA fills `platforms` — which TeamViewer does
# not always do. Same exclusion the bulletin parser applies to its rows.
{"key": "teamviewer", "vendor_lit": "teamviewer", "vendor_re": r"^teamviewer\b",
"product_re": r"^(?!.*\b(android|ios|ipados|web)\b)"
r"(teamviewer(\s+for\s+(windows|linux|macos))?|remote|tensor|"
r"(remote\s+)?(full\s+client|host|quicksupport|portable|client)"
r"|remote\s*\((full\s+client|host)\)"
r")"
r"(\s*,\s*|\s*&\s*|\s+and\s+)?"
r"((remote\s+)?(full\s+client|host|quicksupport|portable|client)"
r"(\s*,\s*|\s*&\s*|\s+and\s+)?)*"
r"\s*(\((for\s+)?[^)]*\))?$"},
# 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)\))?$"},
# IGEL OS (thin-client endpoints). NVD has a CPE for exactly ONE IGEL OS
# CVE in its whole history (CVE-2025-47827) — everything else IGEL's CNAs
# publish arrives here and nowhere else, including the two fresh Secure
# Boot / Boot Registry flaws from the DEF CON 34 thin-client research
# (CVE-2026-82017, CVE-2026-82018), which sit at NVD with no configuration
# at all. A thin client runs no agent and appears in no other inventory,
# so without this path an IGEL estate has no CVE coverage whatsoever.
#
# A pattern rather than a pair list because IGEL states the RELEASE in the
# product name and adds a new one every few years: "IGEL OS 12", "IGEL OS
# 11", and the older records simply "OS" under the long vendor spelling.
# Two vendor spellings occur, four years apart ("IGEL" on the 2026 records,
# "IGEL Technology GmbH" on CVE-2025-34082).
#
# The product regex is ANCHORED, which is the safety story: IGEL files the
# management stack under the same vendor — Universal Management Suite (6.x,
# CVE-2022-25804..25807), Cloud Gateway, IGEL Management Interface — and
# every one of them carries a version line that would compare nonsensically
# against an endpoint's 11.x/12.x. None of them can pass the anchor.
# UMS is matched from the CPE side instead (see _OS_REGISTRY), where NVD's
# exact-version entries say precisely what they mean.
#
# The release in the product name is NOT what separates 11 from 12 at scan
# time — the installed version's major is (see scan_asset_igel). The name
# is only ever a cross-check.
{"key": "igel-os", "vendor_lit": "igel",
"vendor_re": r"^igel(\s+technology(\s+gmbh)?)?$",
"product_re": r"^(igel\s+)?(os|linux)(\s+\d+)?$"},
# HPE Aruba networking gear, as inventoried by the Netdisco connector.
# THREE products, three version lines, and keeping them apart is the whole
# safety story — HPE files all of them under one vendor and the numbers
# overlap:
#
# AOS-CX 10.13.1005 campus/data-centre switches (6300, 6200, ...)
# ArubaOS-Switch 16.11.0016 the ProVision line (2530, 2930F, 5400R)
# ArubaOS (AOS) 8.13.1.1 / 10.7.2.2 Mobility controllers and gateways
#
# A CX switch on 10.13.1005 and a Mobility controller on 10.7.2.2 are both
# "10.x" and share nothing else: matched across, the switch would be told
# to install a controller image. So each family gets its OWN key, its own
# anchored product regex, and the scan additionally checks the release
# BRANCH (see scan_asset_aruba).
#
# Product spellings are the ones HPE's CNA actually writes, verified
# against the records: "AOS-CX" (CVE-2026-73749, -44880), "ArubaOS-Switch"
# (CVE-2023-39266) and "ArubaOS-S Switch" (CVE-2024-26303), and "HPE Aruba
# Networking Wireless Operating System (AOS)" (CVE-2026-44857). The vendor
# is "Hewlett Packard Enterprise (HPE)" today and "Aruba Networks" on the
# older records.
#
# The anchors matter as much as they do for IGEL: HPE files ClearPass,
# AirWave, Central, EdgeConnect, InstantOS and the Fabric Composer under
# the same vendor, each with a version line that would compare
# nonsensically against a switch's firmware. None of them can pass.
{"key": "aruba-cx", "vendor_lit": "hewlett packard enterprise",
"vendor_re": r"^(hewlett[- ]packard enterprise( \(hpe\))?|hpe|hp|"
r"aruba ?networks?( ?, ?inc\.?)?|n/a)$",
"product_re": r"^(hpe\s+)?(aruba\s+networking\s+)?(arubaos-cx|aos-cx)"
r"(\s+switch(es)?)?$"},
{"key": "aruba-switch", "vendor_lit": "aruba networks",
"vendor_re": r"^(hewlett[- ]packard enterprise( \(hpe\))?|hpe|hp|"
r"aruba ?networks?( ?, ?inc\.?)?|n/a)$",
"product_re": r"^(hpe\s+)?(aruba\s+networking\s+)?(arubaos-s(witch)?|aos-s)"
r"(\s+switch(es)?)?$"},
{"key": "aruba-os", "vendor_lit": "arubaos",
"vendor_re": r"^(hewlett[- ]packard enterprise( \(hpe\))?|hpe|hp|"
r"aruba ?networks?( ?, ?inc\.?)?|alcatel-lucent|n/a)$",
"product_re": r"^(arubaos|aruba\s+os|aos-w|"
r"(hpe\s+)?aruba\s+networking\s+wireless\s+operating\s+"
r"system\s+\(aos\))$"},
# Cisco IOS XE / IOS XR, as inventoried by the Netdisco connector.
#
# Cisco states no ranges at all. Every record ENUMERATES the affected
# releases, one bare `version` entry each — CVE-2026-20267 carries 268 of
# them, CVE-2026-20274 carries 112 — with `defaultStatus: unknown`. So
# these keys are indexed as a SET of versions per CVE (see the cisco branch
# in build_product_index) and matched by exact membership, never compared.
# That is not caution added on top of the data, it is the data: Cisco ships
# 17.15.4, 17.15.4a, 17.15.4b, 17.15.4c and 17.15.4d as five releases, and
# a given record names some and not others.
#
# The product regexes are ANCHORED, and here that carries more weight than
# anywhere else in this list, because Cisco files EVERY product it makes
# under one vendor and states them as separate affected[] blocks on the
# same record. CVE-2025-20363 alone carries five: "IOS", "Cisco IOS XR
# Software", "Cisco IOS XE Software", "Cisco Adaptive Security Appliance
# (ASA) Software" and "Cisco Firepower Threat Defense Software" — 2005,
# 13, 456, 220 and 94 versions respectively, all in one file. Matched
# loosely, a router would collect an ASA's release list.
#
# Two exclusions the anchor buys, both real:
# * "IOS" — classic IOS, whose releases are spelled "12.2(33)CY2" and
# "15.2(7)E3". Not covered here or on the NVD side; see _OS_REGISTRY.
# * "Cisco IOS XE Software 3.2.11aSG" — CVE-2019-12660 puts the affected
# RELEASE in the product name and writes the version as "unspecified".
# A product string that names one build is not the product.
{"key": "cisco-ios-xe", "vendor_lit": "cisco",
"vendor_re": r"^cisco(\s+systems(\s*,?\s*inc\.?)?)?$",
"product_re": r"^cisco\s+ios\s*[- ]?xe(\s+software)?$"},
{"key": "cisco-ios-xr", "vendor_lit": "cisco",
"vendor_re": r"^cisco(\s+systems(\s*,?\s*inc\.?)?)?$",
"product_re": r"^cisco\s+ios\s*[- ]?xr(\s+software)?$"},
# Extreme Networks EXOS — the switch firmware, marketed as "Switch Engine"
# since 2022 and still spelled ExtremeXOS/EXOS everywhere the version is
# read from. Its records need no branch of their own in the index build:
# Extreme states proper floored ranges (see scan_asset_extreme), so the
# generic `_ranges_from_affected` path reads them as they stand.
#
# ANCHORED, and against a vendor who files ten products under one name.
# Extreme's CNA has published, on the same vendor string: "Fabric Engine
# (VOSS)", "ExtremeControl", "ExtremeCloud IQ - Site Engine", "Extreme
# Platform ONE", "ExtremeGuest Essentials" and "ExtremeCloud Universal
# ZTNA". Every one of those numbers its releases in a scheme that looks
# like a switch's ("25.5.12", "9.2", "26.02.11"), so a loose product match
# would compare a 33.x switch against a management server's ranges.
#
# Fabric Engine (VOSS) is deliberately NOT a second key here, and the
# reason is inventory, not data: SNMP::Info classes a VSP as
# Layer3::Passport, which answers os "passport" and vendor "avaya" — the
# same pair an Avaya/Nortel ERS switch answers, whose 5.9.x firmware is a
# different product entirely. Nothing on the Netdisco row separates them,
# so a VOSS key could only be filled by guessing. See netdisco_service.
{"key": "extreme-exos", "vendor_lit": "extreme networks",
"vendor_re": r"^extreme\s*networks$",
"product_re": r"^(switch\s+engine(\s*\(exos\))?|"
r"extremexos(\s*\(exos\))?|exos)$"},
# Ubiquiti UniFi access points, the fourth Netdisco firmware line — and the
# one whose records are filed PER MODEL as often as per product line:
#
# CVE-2024-22054 "UniFi Access Points" < 6.6.55
# CVE-2023-38034 "UniFi Access Points" <= 6.5.53
# CVE-2024-37380 "UniFi U6+ Access Point" < 6.6.74
#
# The product regex therefore admits an optional model word in the middle
# and nothing else, and `prod` is carried into the index so the scan side
# can hold a model-specific record to the model it names — see
# scan_asset_ubiquiti. Without that gate CVE-2024-37380, a U6+ record,
# would land on every U6-LR in the estate.
#
# ANCHORED against the rest of the UniFi range, all filed under this same
# vendor: "UniFi Switches" (6.6.61 on the same CVE as the APs' 6.6.55 —
# the identical numbering is exactly why loose matching is unsafe here),
# "UniFi LTE Backup", "UniFi Express", "UniFi iOS App", "UniFi Protect".
# Both vendor spellings occur, sometimes on records published the same
# month: "Ubiquiti" and "Ubiquiti Inc".
{"key": "ubiquiti-uap", "vendor_lit": "ubiquiti",
"vendor_re": r"^ubiquiti(\s+inc\.?)?$",
"product_re": r"^unifi\s+(\S+\s+)?access\s*points?$"},
]
# 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
# (seen: 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".
This was requested twice, and rightly so: 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 a 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 (observed: 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 (observed: 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
def _esr_patched(installed: str, esr_fixes: List[str]) -> bool:
"""True when the install sits on an ESR train at or above THAT train's fix.
An ESR build is below the mainline fix by construction — 140.14 < 154 — so
the mainline bound alone would flag a fully patched ESR host with every CVE
of every release since 140. The install is only spared when its own major
train is one Mozilla named: 153.0.4 (mainline) against ESR 153.1 is still
affected, 153.1.0 (ESR) is not.
resolve() already refuses installs whose NAME says ESR; this catches the
ones where it does not (Intune / Defender report the app as plain
"Firefox").
"""
it = cpe._vtuple(installed)
if not it:
return False
for f in esr_fixes:
ft = cpe._vtuple(f)
if ft and it[0] == ft[0]:
c = cpe._vcmp(installed, f)
if c is not None and c >= 0:
return True
return False
# ---------- index build + cache ----------
def _ensure_zip(force: bool = False) -> bool:
"""Make sure the shared cvelistV5 ZIP is on disk.
`force` bypasses the 12h TTL and always re-downloads. The nightly index
build needs it: the same /tmp file is refreshed by the threat-intel job,
whose IntervalTrigger(24h) is anchored to app startup — so on a container
started in the late afternoon the ZIP is only ~8h old at 01:30 and the
TTL kept the build on a snapshot taken hours before the day's CVEs were
published (Chrome 151.0.7922.169 surfaced >24h late that way). On-demand
callers keep the cache so the GUI never triggers a 557 MB download.
"""
on_disk = (os.path.exists(_ZIP_PATH)
and os.path.getsize(_ZIP_PATH) > 100_000_000)
fresh = (on_disk
and (time.time() - os.path.getmtime(_ZIP_PATH)) < _ZIP_TTL)
if fresh and not force:
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
# A forced refresh that fails still beats no walk at all: walking a
# stale-but-valid ZIP keeps the index moving on a transient blip.
if on_disk:
logger.warning("cvelistv5-scan: falling back to the on-disk ZIP (%.1fh old)",
(time.time() - os.path.getmtime(_ZIP_PATH)) / 3600)
return True
return False
def build_product_index(db: Session, force_fresh: bool = False) -> 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.
`force_fresh` re-downloads the ZIP first AND rebuilds the Mozilla MFSA
index before the Firefox merge — the nightly job passes it so the index is
never built from a cached snapshot older than this run."""
if not _ensure_zip(force=force_fresh):
return load_index(db) or {}
index: Dict[str, list] = {}
ff_meta: Dict[str, dict] = {}
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 == "firefox" and cve_id not in ff_meta:
# Score + text for the MFSA merge below, taken while the
# record is already open (Mozilla's yml carries neither).
ff_meta[cve_id] = {"cvss": cvss, "sev": sev, "desc": desc}
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 == "citrix-workspace-win":
# Bounds kept verbatim, version field included: Citrix puts
# the release there when lessThan names only the CU ("2402
# LTSR" / "CU2 Hotfix 1"). See citrix_workspace_service.
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 "").strip()
_lte = (_v.get("lessThanOrEqual") or "").strip()
if not (_lt or _lte):
continue
_ver = (_v.get("version") or "").strip()
_sig = (key, cve_id, _lt, _lte, _ver)
if _sig in seen:
continue
seen.add(_sig)
index.setdefault(key, []).append(
{"cve": cve_id, "lt": _lt, "lte": _lte, "ver": _ver,
"cvss": cvss, "sev": sev, "prod": prod, "desc": desc})
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
if key in _CISCO_KEYS:
vers = cisco_versions(aff)
if not vers:
continue
_sig = (key, cve_id, prod.lower())
if _sig in seen:
continue
seen.add(_sig)
# ONE entry per CVE carrying the whole version SET, not one
# entry per version: a record enumerates hundreds of
# releases (268 on CVE-2026-20267) and there are ~740 IOS
# XE/XR CVEs, so the per-version shape the Java branch uses
# would put six figures of near-identical dicts, each with
# its own copy of the description, into a settings row.
index.setdefault(key, []).append(
{"cve": cve_id, "vers": vers, "plats": plats,
"cvss": cvss, "sev": sev, "prod": prod, "desc": desc})
continue
if key == "aruba-switch":
for ent in aruba_switch_entries(aff, cve_id, cvss, sev, desc):
_sig = (key, cve_id, ent.get("start"), ent.get("lt"),
ent.get("lte"), prod.lower())
if _sig in seen:
continue
seen.add(_sig)
index.setdefault(key, []).append(ent)
continue
fix = igel_fix_version(aff) if key == "igel-os" else None
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)
ent = {"cve": cve_id, "start": start, "lt": lt, "lte": lte,
"plats": plats, "cvss": cvss, "sev": sev, "prod": prod,
"desc": desc}
if key == "igel-os" and not lt and fix:
# An INCLUSIVE bound names the last broken release, not
# the fix — CVE-2026-82018 says "affected .. 12.8.2",
# and the version to upgrade to (12.8.3) is only in the
# `unaffected` entries. That number is the whole point
# of the finding: ISN-2026-20's remediation IS "upgrade
# to 12.8.3". Without it the finding would say a device
# is vulnerable and stay silent on what fixes it.
ent["fix"] = fix
index.setdefault(key, []).append(ent)
_merge_mozilla_mfsa(db, index, ff_meta, force_fresh=force_fresh)
_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 _merge_mozilla_mfsa(db: Session, index: Dict[str, list],
ff_meta: Dict[str, dict],
force_fresh: bool = False) -> None:
"""Let Mozilla's own MFSA advisories decide the Firefox ranges.
cvelistV5 states Firefox INVERSELY — no affected range at all, only a list
of "unaffected" floors, one per maintained train. mfsa2026-74 lands as
unaffected 115.39 lte 115.* ESR 115
unaffected 140.14 lte 140.* ESR 140
unaffected 153.1 lte 153.* ESR 153
unaffected 154 lte * release
and _ranges_from_affected can only invert that when there is exactly ONE
floor, because nothing in the record says which of the four is the release
train and which are ESR (read naively, "below 140.14" flags a regular
Firefox 121 for an ESR-only advisory). So every multi-train record — 21 of
the 23 CVEs in mfsa2026-74 — produced no range and was never indexed. The
two that were are exactly the two seen arriving (CVE-2026-74975,
-74989); the rest depended on NVD publishing a CPE, which is why coverage
looked random from one CVE to the next.
The MFSA yml answers it outright: one advisory, one `fixed_in` train.
"Firefox 154" → every mainline Firefox below 154 is affected by every CVE
the advisory lists. "Firefox ESR 140.14" / "Firefox for iOS 152.4" /
"Thunderbird 154" → regular desktop Firefox is not affected at all, so the
CVE is REMOVED from the Firefox index (this also retires the false-positive
class the single-floor inversion could produce for ESR-only advisories).
Mozilla wins for every CVE it has ruled on; anything it has not (older CVEs,
other CNAs) keeps whatever cvelistV5 derived. No MFSA index (offline, rate
limited) → the index is left exactly as it was.
"""
try:
from app.services import mozilla_advisory_service as mfsa
# The MFSA index has its own 24h TTL and used to be refreshed by
# whoever asked first — in the nightly job that is the enrichment step
# ~10 min AFTER this merge, so the cache timestamp drifted to just
# after the build and the merge then read a ~23h50m old index forever.
# Observed 02.09.2026: merged 551 Firefox CVEs at 03:21 from the 01.09.
# cache while the 03:31 rebuild had 580 — the 29 CVEs of mfsa2026-82..85
# (CVE-2026-84117..84145) were unscannable for a full extra day.
# force_fresh (the nightly path) therefore pulls MFSA first, so the
# Firefox ranges are decided by the advisories published today.
moz = (force_fresh and mfsa.build_index(db)) or mfsa.get_index(db)
except Exception as e:
logger.warning("cvelistv5-scan: MFSA merge skipped (%s) — Firefox falls "
"back to cvelistV5 ranges", e)
return
if not moz:
logger.warning("cvelistv5-scan: MFSA index empty — Firefox falls back to "
"cvelistV5 ranges")
return
before = index.get("firefox") or []
kept = [e for e in before if e.get("cve") not in moz]
added = 0
for cve_id, data in sorted(moz.items()):
fix = data.get("ff_fix")
if not fix:
continue # ESR / iOS / Thunderbird only → not a desktop Firefox CVE
meta = ff_meta.get(cve_id) or {}
kept.append({"cve": cve_id, "start": None, "lt": fix, "lte": None,
"plats": [], "cvss": meta.get("cvss"),
"sev": meta.get("sev") or data.get("sev"),
"prod": "Firefox", "esr": data.get("esr_fix") or [],
"desc": meta.get("desc") or data.get("title")})
added += 1
index["firefox"] = kept
logger.info("cvelistv5-scan: MFSA decided %d Firefox CVEs (was %d cvelistV5 "
"ranges, %d kept as-is)", added, len(before), len(kept) - added)
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
still_affected: set = set()
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
still_affected.add(cve_id.upper())
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. It also blocks the
# reconcile below: a CVE we could not decide must not be closed as
# "no longer affected".
logger.info("vsphere scan (%s): %d bound(s) not resolvable to a build — skipped, "
"no auto-close this run", asset.hostname, unresolved)
else:
_resolve_stale_appliance(db, asset, label, still_affected,
f"{asset.os_version} build {installed.build}",
tag="vsphere")
return count
def _resolve_stale_appliance(db: Session, asset, label: str, still_affected: set,
evidence: str, tag: str) -> int:
"""Close the findings this pass no longer confirms, for a device whose
verdict comes from its OS version alone (a hypervisor, a thin client).
Such a device has no software inventory, and the app scan's own reconcile
sits behind `if packages:` — so it never ran for an ESXi host, a vCenter
appliance or an IGEL endpoint. Nothing else ever revisited these findings
either: patching a host wrote the new version onto the asset (visible in
the GUI) and left every CVE the OLD version had wide open, run after run
(observed: hosts patched during the day, still flagged after the nightly
job).
Same contract as every other reconcile here — retract OUR source, and mark
patched only once no other scanner still claims the finding. Only reached
when this pass had a real verdict: a build on the asset, entries in the
index, and every bound resolvable (see the caller).
"""
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
# The label is what this path writes; the asset's own OS string is what a
# Nessus/manual row would carry for the same box.
names = {label, (asset.operating_system or "").strip()} - {""}
rows = (db.query(Vulnerability)
.filter(Vulnerability.asset_id == asset.id,
Vulnerability.status == VulnerabilityStatus.open,
Vulnerability.sources.contains('"app-scan"'))
.all())
resolved = 0
for v in rows:
if (v.package_name or "").strip() not in names:
continue # a different product — not ours to close
if v.cve_id in still_affected:
continue # this build is still behind its fix
v.remove_source("app-scan")
if v.source_list:
continue # another scanner still claims it
old_status = v.status
v.status = VulnerabilityStatus.patched
v.patched_at = datetime.now()
resolved += 1
try:
from app.routers.vulnerabilities import log_vulnerability_change
log_vulnerability_change(
db, None, v.id, old_status, v.status,
reason=f"{label} {evidence} on {asset.hostname} is at/past the "
f"fix for this CVE",
cve_id=v.cve_id, source="app_scan",
hostname=asset.hostname)
except Exception as e:
logger.warning("audit log for %s auto-resolve failed (%s): %s", tag, v.id, e)
if resolved:
logger.info("%s scan (%s): auto-closed %d finding(s) fixed by %s",
tag, asset.hostname, resolved, evidence)
# Committed here, not left to the caller. The nightly app scan commits
# an asset only when the scan FOUND something (`if touched`), and a
# host whose findings were all just closed found exactly nothing — its
# cleanup would be rolled back at the end of the run and redone,
# fruitlessly, every night. A quiet host is the one whose cleanup
# matters most.
try:
db.commit()
except Exception as e:
logger.warning("%s auto-close commit failed on %s: %s", tag, asset.hostname, e)
return resolved
# ---------- IGEL OS (thin-client endpoints) ----------
#
# The asset's OS string decides whether this is an IGEL endpoint. It is set by
# the IGEL UMS connector, which writes the bare product line "IGEL OS" for
# every release — the release is read from the VERSION, never from the name,
# because UMS has called the same OS three different things over its life
# ("IGEL Universal Desktop LX", "IGEL OS 11", "IGEL OS").
IGEL_OS_RE = re.compile(r"^\s*igel\s+(os|linux)\b", re.I)
IGEL_LABEL = "IGEL OS"
# "IGEL OS 12" → 12. The older records name the product just "OS" and state no
# release at all, which is why this may return None and must not be required.
_IGEL_PROD_RELEASE_RE = re.compile(r"\b(\d+)\s*$")
def igel_release(version: Optional[str]) -> Optional[int]:
"""Release line of an IGEL version string — the major, and nothing else.
11 and 12 are separate products that are patched separately: OS 11's fix
is 11.11.150 and OS 12's is 12.7.6, for the SAME flaw. Comparing across
them produces a finding whose remediation the device can never reach.
"""
t = cpe._vtuple(version or "")
return t[0] if t else None
def igel_fix_version(aff: dict) -> Optional[str]:
"""Lowest version an IGEL affected[] block calls `unaffected`, or None.
This is IGEL's own "Update Instructions" expressed structurally: the ISN
for CVE-2026-82018 says "upgrade the Base System app to 12.8.3", and 12.8.3
is exactly the lowest unaffected entry. Only used where the affected range
is INCLUSIVE and therefore names no fix of its own — an exclusive bound
(`lessThan 12.7.6`) already IS the fix.
"""
floors = []
for v in aff.get("versions", []) or []:
if not isinstance(v, dict) or v.get("status") != "unaffected":
continue
raw = (v.get("version") or "").strip()
if raw and raw not in ("0", "*", "-") and _is_version(raw):
floors.append(raw)
if not floors:
return None
return min(floors, key=lambda x: cpe._vtuple(x) or ())
def scan_asset_igel(db: Session, asset, index: dict,
new_ids: Optional[list] = None,
touched: Optional[set] = None) -> int:
"""IGEL OS CVEs from the endpoint's firmware version (asset.os_version).
This is the only path that sees them. A thin client runs no agent, so
Wazuh has nothing on it; Intune does not enrol it; Nessus sees an open port
and no version. And NVD carries a configuration for exactly one IGEL OS CVE
in the whole catalogue — the two current Secure Boot / Boot Registry flaws
(CVE-2026-82017, CVE-2026-82018) sit there with none at all, so the CPE
scanner is blind to them even for a device it did fingerprint.
Two guards, and both are needed:
1. RELEASE (igel_release): the bound's major must equal the installed
major. IGEL states one range per release line on the same record —
CVE-2026-82017 carries "12.0.0 < 12.7.6" AND "11.0.0 < 11.11.150" — and
a finding that tells an OS 11 device to reach 12.7.6 is worse than no
finding, because 12 is a different product with a different licence.
2. PRODUCT NAME: when the record names the release ("IGEL OS 12"), it has
to agree. Redundant with (1) on today's records, and deliberately so:
the floors are what make (1) work, and a future record written without
one would silently lose that guard.
"""
if not index:
return 0
if not IGEL_OS_RE.match(asset.operating_system or ""):
return 0
entries = index.get("igel-os") or []
if not entries:
return 0
cver = cpe._clean_version(asset.os_version or "")
if not cver:
return 0
release = igel_release(cver)
if release is None:
return 0
if new_ids is None:
new_ids = []
count = 0
still_affected: set = set()
for entry in entries:
lt, lte = entry.get("lt"), entry.get("lte")
if igel_release(lt or lte) != release:
continue
m = _IGEL_PROD_RELEASE_RE.search((entry.get("prod") or "").strip())
if m and int(m.group(1)) != release:
continue
if not _affected(cver, entry.get("start"), lt, lte):
continue
still_affected.add(entry["cve"].upper())
c = {"cve": entry["cve"], "cvss": entry.get("cvss"),
"severity": entry.get("sev"), "fixed": lt or entry.get("fix"),
"desc": entry.get("desc")}
try:
before = len(new_ids)
cpe._upsert(db, asset, IGEL_LABEL, asset.os_version or cver, c,
new_ids, touched=touched, vendor="IGEL")
count += 1 if len(new_ids) > before else 0
except Exception as e:
logger.debug("igel upsert failed (%s on %s): %s",
entry["cve"], asset.id, e)
# An endpoint has no software inventory either, so the app scan's own
# reconcile never reaches it — same hole the vSphere path had. Without
# this, a fleet upgraded to 12.8.3 keeps every 12.8.2 finding forever.
#
# `touched` is folded in, and that is not optional: unlike vSphere, IGEL OS
# has a SECOND path — NVD's CPE for CVE-2025-47827, which this index cannot
# see because MITRE filed the record with vendor "n/a". The CPE scan runs
# first and records its hits in `touched`, so without them here this pass
# would close, every night, the one finding it is structurally unable to
# confirm. Both paths write source "app-scan"; only both together are the
# full verdict for this device.
_resolve_stale_appliance(db, asset, IGEL_LABEL,
still_affected | set(touched or ()), cver,
tag="igel")
return count
# ---------- HPE Aruba switches / controllers (Netdisco inventory) ----------
#
# A switch is the IGEL problem again, one layer down: it runs no agent, no MDM
# enrols it, and a network scan sees an SSH port and no version. Netdisco does
# see the firmware — it asks the device over SNMP — so the Netdisco connector
# writes the family onto the asset as its OS and the numeric firmware as its
# version, and this is what turns that into findings.
#
# The asset's OS string decides the family. It is written by
# netdisco_service.aruba_family() as one of three canonical labels, never as
# whatever the device answered: SNMP::Info reports "hp" for a ProVision switch
# and "arubaos-cx" for a CX one, and a Nessus- or hand-created asset for the
# same box would spell it a third way.
ARUBA_CX_LABEL = "ArubaOS-CX"
ARUBA_SWITCH_LABEL = "ArubaOS-Switch"
ARUBA_OS_LABEL = "ArubaOS"
ARUBA_LABELS = {"aruba-cx": ARUBA_CX_LABEL,
"aruba-switch": ARUBA_SWITCH_LABEL,
"aruba-os": ARUBA_OS_LABEL}
# Ordered: the CX and Switch patterns must be tried before the bare ArubaOS
# one, which would otherwise swallow both ("arubaos-cx" starts with "arubaos").
_ARUBA_OS_RES = [
(re.compile(r"^\s*(hpe\s+)?(aruba\s*)?(arubaos|aos)[-\s]?cx\b", re.I), "aruba-cx"),
(re.compile(r"^\s*(hpe\s+)?(aruba\s*)?(arubaos|aos)[-\s]?s(witch)?\b", re.I),
"aruba-switch"),
(re.compile(r"^\s*(hpe\s+)?aruba\s*os\b|^\s*arubaos\b|^\s*aos-w\b", re.I),
"aruba-os"),
]
# The numeric firmware version inside an Aruba version string. HPE prefixes the
# build with a two-letter CODE LINE — "WC.16.11.0016" on a 2930F, "YA.16.11.0027"
# on a 2530, "PL.10.13.1005" on a CX 6300 — which names the hardware family the
# image is for, not the version. HPE's own advisories bound the numbers only
# ("KB/WC/YA/YB/YC.16.11.0015 and below"), so the letters are dropped here and
# every comparison is numeric. Dropping them is also what makes the version
# usable at all: cpe._clean_version rejects anything that is not dotted-numeric,
# so "WC.16.11.0016" would otherwise mean "no version", which means no scan.
_ARUBA_VER_RE = re.compile(r"\d+(?:\.\d+){1,3}")
def aruba_version(raw: Optional[str]) -> Optional[str]:
""""WC.16.11.0016" → "16.11.0016". None when there is no version in there.
Also survives the longer strings other sources report for the same box
("ArubaOS (MODEL: Aruba7005), Version 8.6.0.7"): the first dotted-numeric
run IS the version, because a model number carries no dot.
"""
m = _ARUBA_VER_RE.search((raw or "").strip())
return m.group(0) if m else None
def aruba_key(os_name: Optional[str]) -> Optional[str]:
"""Canonical Aruba OS label → curated product key, or None."""
n = (os_name or "").strip()
if not n:
return None
for rx, key in _ARUBA_OS_RES:
if rx.match(n):
return key
return None
def aruba_branch(version: Optional[str]) -> Optional[tuple]:
"""Release branch of an Aruba version — major.minor, and nothing else.
HPE patches per branch and states one range per branch on the same record:
CVE-2026-73749 carries 10.18, 10.17, 10.16, 10.13 and 10.10 side by side,
each with its own last-affected build. A 10.13 switch that matched the
10.17 range would be told to install an image its hardware may not even
take — the same class of error as sending an IGEL OS 11 device to 12.7.6.
"""
t = cpe._vtuple(version or "")
return t[:2] if t and len(t) >= 2 else None
# "ArubaOS-Switch 16.11.xxxx: KB/WC/YA/YB/YC.16.11.0015 and below" — the whole
# bound, written as a sentence. Anchored on the phrase, so a number that is not
# a bound (the "16.11.xxxx" branch heading, an advisory id) cannot be read as
# one.
_AOSS_PROSE_RE = re.compile(
r"(\d{1,2}\.\d{1,2}(?:\.\d{1,4}){1,2})\s*(?:and|or)\s+(?:below|earlier|lower|prior)",
re.I)
def aruba_switch_entries(aff: dict, cve_id: str, cvss=None, sev=None,
desc=None) -> List[dict]:
"""One ArubaOS-Switch affected[] block → index entries.
AOS-S is the one Aruba family whose records are not machine-readable. HPE
writes AOS-CX and the controllers as proper semver ranges (version +
lessThanOrEqual), but the switch records state every branch as PROSE inside
the `version` field:
"ArubaOS-Switch 16.11.xxxx: KB/WC/YA/YB/YC.16.11.0015 and below"
"ArubaOS-Switch 16.09.xxxx: All versions."
`_ranges_from_affected` finds no bound in either and drops them, which left
the ProVision switches — the 2530/2930F estate this connector exists for —
with only the three CVEs NVD happens to carry a CPE range for.
So: structured versions win when the record has them (HPE may start writing
them any day), and the prose is parsed only as a fallback.
"All versions" is deliberately NOT indexed. It has no bound, so it can
neither be compared nor ever be cleared by an upgrade inside that branch —
the device has to leave the branch entirely, which is a lifecycle finding
and not a version verdict. Flagging it here would produce a finding with no
fix to reach, i.e. exactly the noise this scanner is built to avoid; the
cost is that a switch on such a branch is under-reported rather than
misreported.
The two-letter code lines in the prose are ignored, and that is a
deliberate, bounded imprecision: HPE ships one build number across all of
them ("KB/WC/YA/YB/YC.16.11.0015"), and the one record that splits them
(WB on its own cadence) states the OTHER lines as "All versions", which is
skipped anyway. A device's own code line is not carried on the asset, so
honouring the split would mean storing it for the sake of a case that
resolves the same way.
"""
prod = (aff.get("product") or "").strip()
meta = {"cve": cve_id, "plats": [], "cvss": cvss, "sev": sev,
"prod": prod, "desc": desc}
# The shared extractor is used ONLY when the block really carries bounds.
# Handed the prose, it reads the whole sentence as a version — "ArubaOS-
# Switch 16.11.xxxx: …0012 and below." parses to (16,11,16,11,12) — and
# emits it as a closed range, which is worse than no entry at all.
if any(isinstance(v, dict) and (v.get("lessThan") or v.get("lessThanOrEqual"))
for v in aff.get("versions") or []):
return [dict(meta, start=start, lt=lt, lte=lte)
for start, lt, lte in _ranges_from_affected(aff)]
out: List[dict] = []
for v in aff.get("versions") or []:
if not isinstance(v, dict) or (v.get("status") or "affected") != "affected":
continue
m = _AOSS_PROSE_RE.search(str(v.get("version") or ""))
if not m:
continue
bound = m.group(1)
t = cpe._vtuple(bound)
if not t or len(t) < 3:
continue
# The branch is the floor. Without it "16.10.0024 and below" would also
# cover every 16.04 and 15.16 switch, which the record states
# separately and differently — one entry per branch is exactly how HPE
# writes these.
out.append(dict(meta, start=f"{t[0]}.{t[1]}", lt=None, lte=bound))
return out
def scan_asset_aruba(db: Session, asset, index: dict,
new_ids: Optional[list] = None,
touched: Optional[set] = None) -> int:
"""Aruba switch / controller CVEs from the firmware version on the asset.
Two guards, both needed:
1. FAMILY (aruba_key): decided from the asset's OS label, and each family
reads only its own curated key. AOS-CX 10.13.1005 and Mobility AOS
10.7.2.2 are both "10.x" of two unrelated products.
2. BRANCH (aruba_branch): for an entry that states no floor of its own, the
bound's major.minor must equal the installed one. HPE always writes one
range per branch, so an entry without a floor is a record that left it
implicit — and read literally, "below 10.17.1021" swallows every 10.13
and 10.10 switch, all of which the same record bounds separately and
lower. Entries that DO carry a floor are already confined by it and are
left alone, so a genuinely cross-branch range still matches.
"""
if not index:
return 0
key = aruba_key(asset.operating_system or "")
if not key:
return 0
entries = index.get(key) or []
if not entries:
return 0
# The connector already stores the numeric form; aruba_version() is applied
# again for assets that came from somewhere else with the raw string on
# them (a Nessus scan, a hand-created row).
cver = cpe._clean_version(asset.os_version or "") or aruba_version(asset.os_version)
if not cver:
return 0
branch = aruba_branch(cver)
if branch is None:
return 0
label = ARUBA_LABELS[key]
if new_ids is None:
new_ids = []
count = 0
still_affected: set = set()
for entry in entries:
lt, lte = entry.get("lt"), entry.get("lte")
# Every bound has to be a plain dotted-numeric version. HPE states the
# switch records as prose, and a sentence read as digits is a bound
# nobody wrote — this is the backstop for one slipping through the
# index build (see aruba_switch_entries).
if cpe._clean_version(lt or lte or "") is None:
continue
if not entry.get("start") and aruba_branch(lt or lte) != branch:
continue
if not _affected(cver, entry.get("start"), lt, lte):
continue
still_affected.add(entry["cve"].upper())
# An inclusive bound names the last BROKEN build, not the fix — HPE
# writes "10.13.0000 .. 10.13.1180 affected" and the fixed image is the
# next one it published, which the record does not state. ">10.13.1180"
# is the honest answer and the same one the NVD path gives for
# versionEndIncluding; inventing a build number would not be.
fixed = lt or (f">{lte}" if lte else None)
c = {"cve": entry["cve"], "cvss": entry.get("cvss"),
"severity": entry.get("sev"), "fixed": fixed,
"desc": entry.get("desc")}
try:
before = len(new_ids)
cpe._upsert(db, asset, label, asset.os_version or cver, c, new_ids,
touched=touched, vendor="HPE Aruba Networking")
count += 1 if len(new_ids) > before else 0
except Exception as e:
logger.debug("aruba upsert failed (%s on %s): %s",
entry["cve"], asset.id, e)
# A switch has no software inventory, so the app scan's own reconcile never
# reaches it — the same hole the vSphere and IGEL paths had. `touched` is
# folded in because the NVD-CPE pass runs first and sees CVEs this index
# cannot (AOS-S has three that exist only there); closing what the other
# half of the verdict just confirmed would flap them every night.
_resolve_stale_appliance(db, asset, label,
still_affected | set(touched or ()), cver,
tag="aruba")
return count
# ---------- Cisco IOS XE / IOS XR (Netdisco inventory) ----------
#
# The third device class with no agent, no MDM and no credentialed scan — and
# the one the Aruba pass explicitly left out ("Cisco, Cumulus and the rest need
# their own curated matching"). This is that matching.
#
# It is EXACT, and nothing here compares two versions. Cisco publishes no
# ranges: both sources enumerate the affected releases one by one (NVD as 267
# separate cpeMatch entries for CVE-2026-20267, Cisco's own record as 268 bare
# `version` entries), so "affected" is set membership. Comparing instead would
# be strictly worse, because the numbers are not the release: 17.15.1, 17.15.1w,
# 17.15.1x and 17.15.1y all read as (17,15,1).
#
# What that costs, stated plainly: `defaultStatus` on these records is
# "unknown", not "unaffected", so a release Cisco did not list is a release
# Cisco did not judge — and this reads it as not affected. That under-reports a
# brand-new rebuild until the record is updated. The alternative (treating an
# unlisted version as affected) would flag every device forever, which is not a
# verdict. NVD's own CPE data makes the same choice.
#
# The asset's OS string decides the family, and it is written by
# netdisco_service.cisco_family() as one of two canonical labels — never the
# "ios-xe" slug SNMP::Info answers with, and never bare "ios", which belongs to
# Apple in _OS_REGISTRY.
CISCO_XE_LABEL = "Cisco IOS XE"
CISCO_XR_LABEL = "Cisco IOS XR"
CISCO_LABELS = {"cisco-ios-xe": CISCO_XE_LABEL,
"cisco-ios-xr": CISCO_XR_LABEL}
_CISCO_KEYS = frozenset(CISCO_LABELS)
_CISCO_OS_RES = [
(re.compile(r"^\s*cisco\s+ios[- ]?xe\b", re.I), "cisco-ios-xe"),
(re.compile(r"^\s*cisco\s+ios[- ]?xr\b", re.I), "cisco-ios-xr"),
]
# A Cisco release: dotted numbers plus an optional train/rebuild suffix.
# Anchored end-to-end, which is what rejects classic IOS ("15.2(7)E3") and the
# placeholder Cisco writes when a record states its release in the product name
# instead ("unspecified", CVE-2019-12660).
_CISCO_VER_RE = re.compile(r"\d+(?:\.\d+){1,3}[a-z]{0,3}\d*", re.I)
def cisco_version(raw: Optional[str]) -> Optional[str]:
"""Installed firmware string → the release, in the sources' own spelling.
Netdisco hands over what SNMP::Info read out of sysDescr, which is usually
the bare release ("17.15.4c") but may carry the surrounding sentence
("Cisco IOS Software [Dublin], … Version 17.15.4c, RELEASE SOFTWARE (fc1)").
The first dotted-numeric run with its train letter IS the release; the
normalisation that makes it match — lowercase, no zero-padding — is
cpe._lettered_version's, shared with the NVD half so the two halves cannot
drift into spelling the same release differently.
"""
m = _CISCO_VER_RE.search((raw or "").strip())
return cpe._lettered_version(m.group(0)) if m else None
def cisco_key(os_name: Optional[str]) -> Optional[str]:
"""Canonical Cisco OS label → curated product key, or None."""
n = (os_name or "").strip()
if not n:
return None
for rx, key in _CISCO_OS_RES:
if rx.match(n):
return key
return None
def cisco_versions(aff: dict) -> List[str]:
"""One Cisco affected[] block → the set of affected releases, normalised.
Only `status: affected` entries with a readable release are kept. Anything
with a lessThan/lessThanOrEqual is dropped rather than turned into a range:
Cisco does not publish ranges for these products, so a bound here would be
an outlier record whose meaning we have not verified — and a range read
from an enumerating vendor is how "below 17.15.6" would swallow the whole
17.9 and 16.12 trains that the same record bounds separately.
"""
out: List[str] = []
seen: set = set()
for v in aff.get("versions") or []:
if not isinstance(v, dict):
continue
if (v.get("status") or "affected") != "affected":
continue
if v.get("lessThan") or v.get("lessThanOrEqual"):
continue
raw = str(v.get("version") or "").strip()
if not raw or raw in ("0", "*", "-"):
continue
ver = cisco_version(raw)
# The release has to BE the whole string. "unspecified" carries no
# version at all, and a sentence would otherwise contribute whichever
# number it happens to contain.
if not ver or _CISCO_VER_RE.fullmatch(raw) is None:
continue
if ver not in seen:
seen.add(ver)
out.append(ver)
return out
def scan_asset_cisco(db: Session, asset, index: dict,
new_ids: Optional[list] = None,
touched: Optional[set] = None) -> int:
"""Cisco IOS XE / IOS XR CVEs from the firmware release on the asset.
Exact membership, per family. The family gate is the same guard the Aruba
pass needs and for a sharper reason: Cisco states IOS, IOS XE, IOS XR, ASA
and Firepower on ONE record (CVE-2025-20363 carries all five), each with
its own release list, so a router read against the wrong list would be
handed an appliance's releases.
No fix version is reported, because neither source states one — a Cisco
record lists what is broken and says nothing about what is not. The finding
still has its path to close: a device upgraded to a release the record does
not enumerate stops matching, and the reconcile below closes it.
"""
if not index:
return 0
key = cisco_key(asset.operating_system or "")
if not key:
return 0
entries = index.get(key) or []
if not entries:
return 0
cver = cisco_version(asset.os_version or "")
if not cver:
return 0
label = CISCO_LABELS[key]
if new_ids is None:
new_ids = []
count = 0
still_affected: set = set()
for entry in entries:
if cver not in (entry.get("vers") or ()):
continue
still_affected.add(entry["cve"].upper())
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, label, asset.os_version or cver, c, new_ids,
touched=touched, vendor="Cisco")
count += 1 if len(new_ids) > before else 0
except Exception as e:
logger.debug("cisco upsert failed (%s on %s): %s",
entry["cve"], asset.id, e)
# Same reconcile, and the same ordering rule, as the Aruba pass: a router
# has no software inventory, so the app scan's own reconcile never reaches
# it, and the NVD-CPE pass runs FIRST and sees CVEs this index cannot —
# CVE-2019-12660 is enumerated by NVD but states its release in the product
# name on the CNA side, so it exists only on the CPE half. Closing what the
# other half just confirmed would flap it every night.
_resolve_stale_appliance(db, asset, label,
still_affected | set(touched or ()), cver,
tag="cisco")
return count
# ---------- Extreme Networks EXOS / Switch Engine (Netdisco inventory) ----------
#
# The third firmware line on the Netdisco path, and the one whose data is
# shaped unlike either of the first two. HPE writes the switch bounds as prose
# and Cisco enumerates releases one at a time; Extreme writes clean, floored
# ranges. CVE-2026-8169 states four of them on one record:
#
# version "0" lessThan "31.7.4"
# version "32.0.0" lessThan "32.7.4.15"
# version "33.0.0" lessThan "33.1.100"
# version "33.2.0" lessThan "33.7.1"
#
# — `defaultStatus: unaffected`, so a release outside all four is a release
# Extreme judged and cleared, not one it forgot. `_ranges_from_affected` reads
# that as it stands and the generic branch of build_product_index indexes it,
# which is why there is no extreme branch up there.
#
# What this pass does NOT do is Aruba's branch guard, and that is the single
# deliberate difference between the two. HPE leaves the floor implicit and
# writes one range per branch, so an unfloored "below 10.17.1021" there is a
# record talking about 10.17 and nothing else. Extreme writes the floor itself
# — including the literal `version: "0"` above, which is the vendor saying
# "every release below 31.7.4, whatever train it is on". Guarding that entry by
# branch would drop exactly the switches it is about: every EXOS box older than
# the current train.
#
# The asset's OS string decides the family, and it is written by
# netdisco_service.extreme_family() as one canonical label, never the "xos"
# slug SNMP::Info answers with.
EXTREME_EXOS_LABEL = "ExtremeXOS"
_EXTREME_OS_RE = re.compile(
r"^\s*(extreme\s+networks\s+)?"
r"(extremexos|extreme\s+x?os|exos|switch\s+engine)\b", re.I)
def extreme_version(raw: Optional[str]) -> Optional[str]:
"""Installed EXOS firmware string → the release, as the sources write it.
Delegates to aruba_version because the rule is genuinely the same one — the
first dotted-numeric run IS the release — and one implementation cannot
drift from itself. What differs is the noise it has to survive:
* SNMP::Info reads the version out of sysDescr, so a row can carry the
whole sentence ("ExtremeXOS (X440G2-24t-10GE4) version 31.7.2.4 …");
* a patch build spells itself "22.7.1.1-patch1-11", and the tag is
dropped. Neither source ever states one: NVD's CPE versions and
Extreme's own bounds are plain releases ("22.7", "32.7.4.15"), so a
patch build is judged as the release it patches. That is also the
honest reading — Extreme fixes these by moving to the bound's release,
not by patching below it.
"""
return aruba_version(raw)
def extreme_key(os_name: Optional[str]) -> Optional[str]:
"""Canonical EXOS label → curated product key, or None."""
return "extreme-exos" if _EXTREME_OS_RE.match((os_name or "").strip()) else None
def scan_asset_extreme(db: Session, asset, index: dict,
new_ids: Optional[list] = None,
touched: Optional[set] = None) -> int:
"""EXOS CVEs from the firmware version on the asset.
Ranges, compared — the ordinary shape, and the guards are the ordinary two:
the family gate above, and a bound that has to be a plain dotted-numeric
version before it is compared at all. That second one is not decoration
here either: Extreme writes some bounds with the operator inside the string
("lessThanOrEqual": "<=25.12.11" on the XIQ-SE record, "<9.2" on the Fabric
Engine one), and a bound nobody can parse must be skipped rather than
guessed at.
"""
if not index:
return 0
key = extreme_key(asset.operating_system or "")
if not key:
return 0
entries = index.get(key) or []
if not entries:
return 0
# The connector already stores the plain release; extreme_version() is
# applied again for assets that arrived from somewhere else with the raw
# sysDescr string on them.
cver = cpe._clean_version(asset.os_version or "") or extreme_version(asset.os_version)
if not cver:
return 0
if new_ids is None:
new_ids = []
count = 0
still_affected: set = set()
for entry in entries:
lt, lte = entry.get("lt"), entry.get("lte")
if cpe._clean_version(lt or lte or "") is None:
continue
if not _affected(cver, entry.get("start"), lt, lte):
continue
still_affected.add(entry["cve"].upper())
# `lt` IS the fixed release for these records — Extreme states the
# first fixed build as the exclusive bound and repeats it as a
# `changes: [{at: X, status: unaffected}]` entry. An inclusive bound
# names the last broken one instead, and ">X" is then the honest
# answer, exactly as on the Aruba path.
fixed = lt or (f">{lte}" if lte else None)
c = {"cve": entry["cve"], "cvss": entry.get("cvss"),
"severity": entry.get("sev"), "fixed": fixed,
"desc": entry.get("desc")}
try:
before = len(new_ids)
cpe._upsert(db, asset, EXTREME_EXOS_LABEL, asset.os_version or cver,
c, new_ids, touched=touched, vendor="Extreme Networks")
count += 1 if len(new_ids) > before else 0
except Exception as e:
logger.debug("extreme upsert failed (%s on %s): %s",
entry["cve"], asset.id, e)
# Same reconcile, same reason as the other two firmware passes: a switch
# carries no software inventory, so the app scan's own reconcile never
# reaches it. `touched` is folded in because the NVD-CPE pass runs first
# and sees CVEs this index cannot — every EXOS CVE before 2025 was filed by
# MITRE with vendor "n/a", so cvelistV5 holds no structured data for any of
# them and NVD is the only half that carries them.
_resolve_stale_appliance(db, asset, EXTREME_EXOS_LABEL,
still_affected | set(touched or ()), cver,
tag="extreme")
return count
# ---------- Ubiquiti UniFi access points (Netdisco inventory) ----------
#
# The fourth firmware line on the Netdisco path, and the one that needs a
# discriminator none of the other three do: THE MODEL. Ubiquiti files roughly
# half its AP records against the product line ("UniFi Access Points") and half
# against one model ("UniFi U6+ Access Point"), on the same vendor, in the same
# version scheme. CVE-2024-37380 is the second kind — U6+ only, fixed in 6.6.74
# — and every other AP in the estate is on a 6.7.x that is BELOW that bound
# without ever having been affected. Matched by version alone it would flag the
# lot, which is the failure mode this connector exists to avoid.
#
# So the model rides along in the asset's OS string ("UniFi AP U6-LR"), written
# there by netdisco_service.ubiquiti_family, and a record that names a model is
# only applied to that model. A record that names none applies to every AP,
# which is what Ubiquiti means by it.
#
# The ranges themselves need no branch in build_product_index: Ubiquiti repeats
# the fixed release in `version` as well as `lessThan` ("6.6.74" / < "6.6.74"),
# and _ranges_from_affected already reads that zero-width floor as no floor at
# all. Its lessThanOrEqual sibling keeps the major as a floor, so an AP still
# on a 5.x release misses the two 2023 CVEs — the safe direction, and no such
# firmware is in support anywhere.
UBIQUITI_UAP_LABEL = "UniFi AP"
_UBQ_OS_RE = re.compile(r"^\s*(ubiquiti\s+)?unifi\s+ap\b\s*(?P<model>.*)$", re.I)
# "UniFi U6+ Access Point" → "U6+"; "UniFi Access Points" → no model.
_UBQ_PROD_MODEL_RE = re.compile(r"^unifi\s+(\S+)\s+access\s*points?$", re.I)
def ubiquiti_key(os_name: Optional[str]) -> Optional[str]:
"""Canonical UniFi AP label → curated product key, or None."""
return "ubiquiti-uap" if _UBQ_OS_RE.match((os_name or "").strip()) else None
def ubiquiti_model(os_name: Optional[str]) -> Optional[str]:
"""The model out of "UniFi AP U6-LR", normalised for comparison.
Case, separators and the "+" spelling are all noise: Netdisco reports
"U6-LR" and "U6+", Ubiquiti's records write "U6+" and could as easily write
"U6-Plus". Everything non-alphanumeric goes, with "+" spelled out first so
a U6+ never collapses onto a bare U6.
"""
m = _UBQ_OS_RE.match((os_name or "").strip())
return _model_norm(m.group("model")) if m else None
def _model_norm(raw: str) -> Optional[str]:
t = (raw or "").strip().lower().replace("+", "plus")
t = re.sub(r"[^a-z0-9]", "", t)
return t or None
def scan_asset_ubiquiti(db: Session, asset, index: dict,
new_ids: Optional[list] = None,
touched: Optional[set] = None) -> int:
"""UniFi AP CVEs from the firmware version on the asset, model-gated."""
if not index:
return 0
if not ubiquiti_key(asset.operating_system or ""):
return 0
entries = index.get("ubiquiti-uap") or []
if not entries:
return 0
cver = cpe._clean_version(asset.os_version or "")
if not cver:
return 0
model = ubiquiti_model(asset.operating_system)
if new_ids is None:
new_ids = []
count = 0
still_affected: set = set()
for entry in entries:
pm = _UBQ_PROD_MODEL_RE.match((entry.get("prod") or "").strip())
if pm:
# A record that names a model is about that model. Without a model
# on the asset there is nothing to agree with, so it is skipped —
# "cannot tell" is not "affected".
if not model or _model_norm(pm.group(1)) != model:
continue
lt, lte = entry.get("lt"), entry.get("lte")
if cpe._clean_version(lt or lte or "") is None:
continue
if not _affected(cver, entry.get("start"), lt, lte):
continue
still_affected.add(entry["cve"].upper())
# `lt` is the first fixed release; an inclusive bound names the last
# broken one instead, and ">X" is the honest answer then — same rule as
# the Aruba and Extreme passes.
fixed = lt or (f">{lte}" if lte else None)
c = {"cve": entry["cve"], "cvss": entry.get("cvss"),
"severity": entry.get("sev"), "fixed": fixed,
"desc": entry.get("desc")}
try:
before = len(new_ids)
cpe._upsert(db, asset, UBIQUITI_UAP_LABEL, asset.os_version or cver,
c, new_ids, touched=touched, vendor="Ubiquiti")
count += 1 if len(new_ids) > before else 0
except Exception as e:
logger.debug("ubiquiti upsert failed (%s on %s): %s",
entry["cve"], asset.id, e)
# Same reconcile and the same fold-in as the other firmware passes: an AP
# carries no software inventory, and the NVD-CPE pass runs first and sees
# the two 2023 CVEs this index also holds.
_resolve_stale_appliance(db, asset, UBIQUITI_UAP_LABEL,
still_affected | set(touched or ()), cver,
tag="ubiquiti")
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
_WAZUH_KEYS = ("wazuh", "wazuh-dashboard")
def _wazuh_component_ok(key: str, installed_name: str, prod: Optional[str]) -> bool:
"""Does a Wazuh record naming one component apply to this package?
Wazuh's CNA records DO say which component is meant — CVE-2026-74039 is
filed under product "wazuh-manager" — but every wazuh-* package resolves to
the single key "wazuh", so the manager's CVEs landed on the agents. The
only guard was a prose heuristic in _upsert, and that record's description
("Wazuh 4.0.0 before 4.14.7 … POST /security/user/authenticate/run_as")
names neither side, so it let the finding straight through.
Stated fact beats inference: when the record names a component, honour it.
Most records say plainly "wazuh", which states nothing and changes nothing.
"""
if key not in _WAZUH_KEYS:
return True
from app.services import github_repo_advisory_service as gh
return gh.component_matches(installed_name, gh.component_of(prod or ""))
def _scan_citrix_workspace(db: Session, asset, fam, name: str, version: str,
pkg: dict, entries: list, new_ids: list,
touched: Optional[set]) -> int:
"""Citrix Workspace app for Windows — see citrix_workspace_service.
Windows hosts only: the Mac app is also "Citrix Workspace" and its 25.07.x
numbering would land on the Windows LTSR 2507 line. A CVE this pass cannot
decide goes into `touched`, so the reconcile keeps an existing finding open
instead of closing it on no evidence.
"""
if fam != "windows":
return 0
from app.services import citrix_workspace_service as cws
catalog = cws.load_catalog(db)
installed = cws.installed_release(version, catalog)
per_cve: Dict[str, list] = {}
for e in entries:
per_cve.setdefault(e["cve"], []).append(e)
if not installed:
logger.info("citrix scan: %s build %r is on no line the catalog knows — "
"no verdict", asset.hostname, version)
if touched is not None:
touched.update(per_cve)
return 0
count = 0
undecided = 0
for cve_id, ents in per_cve.items():
affected, fix = cws.decide(installed, ents, catalog)
if affected is None:
undecided += 1
if touched is not None:
touched.add(cve_id)
continue
if not affected:
continue
c = {"cve": cve_id, "cvss": ents[0].get("cvss"), "severity": ents[0].get("sev"),
"fixed": fix, "desc": ents[0].get("desc")}
try:
before = len(new_ids)
cpe._upsert(db, asset, name, f"{version} ({installed.label})"
if installed.label != version else version,
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("citrix upsert failed (%s on %s): %s", cve_id, asset.id, e)
if undecided:
logger.info("citrix scan (%s): %d CVE(s) with a bound not resolvable to a build — "
"held, not decided", asset.hostname, undecided)
return count
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
if key == "citrix-workspace-win":
if (key, version) not in seen:
seen.add((key, version))
count += _scan_citrix_workspace(db, asset, fam, name, version, pkg,
index[key], new_ids, touched)
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, cpe.wazuh_component(key, name))
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 entry.get("esr") and _esr_patched(cver, entry["esr"]):
continue # patched on its own ESR train (Firefox only)
if not _platform_ok(fam, entry.get("plats")):
continue # CVE is for a different OS platform (e.g. Teams-for-Mac)
if not _wazuh_component_ok(key, name, entry.get("prod")):
continue # record names the other Wazuh component
# 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")