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.
2944 lines
145 KiB
Python
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")
|