Follow-up to the per-device vulnrichment fix: an audit of the backend found
more feeds pulled again by every caller.
cvelistV5 (~557 MB) had three downloaders: the index build, the CVSS
cascade's stage 3 and the date backfill. Callers arriving together each
pulled the whole ZIP, and after a failed download every later caller
retried at once. _ensure_zip is now the only one: a lock with a freshness
re-check after acquiring it, a zipfile check before the swap, and a 15 min
backoff after a failure (the on-disk ZIP still serves meanwhile).
Per-CVE lookups that keep missing are remembered for 6h: the M365 title
fetch on a cvelistV5 404 (MSRC publishes days before CVE.org), and NVD /
GHSA for CVEs they answered without a score. Rate limits and network
errors are not answers and are retried.
A failed or partial index build stores nothing, which keeps the cache
stale, so every per-asset scan and every per-sync enrichment rebuilt it
during an outage. The MFSA, repo-advisory and TeamViewer indexes and the
KEV / EUVD / VulnCheck refreshes now serve the stale copy for 1h after a
failure; the nightly and forced paths still rebuild. The app scan loads
the repo-advisory and TeamViewer indexes once per run, like the IGEL ISNs.
The repo-advisory index no longer falls back to {} when a build raises:
the reconcile would read every untouched finding as patched.
MSRC CVRF documents were downloaded by the 01:30 index build and again by
the 03:50 / Sunday ingest. Both now go through fetch_cvrf_doc, a disk
cache keyed by the revision date /updates lists (max 4h old), so a
revised document is still fetched again.
3983 lines
198 KiB
Python
3983 lines
198 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 threading
|
|
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__)
|
|
|
|
# Shared ZIP cache (12h) — every reader in the app goes through _ensure_zip.
|
|
_ZIP_PATH = "/tmp/truevuln-cvelistv5-cache.zip"
|
|
_ZIP_URL = "https://github.com/CVEProject/cvelistV5/archive/refs/heads/main.zip"
|
|
_ZIP_TTL = 12 * 3600
|
|
_ZIP_MIN_BYTES = 100_000_000 # sanity floor; the real ZIP is ~557 MB
|
|
_INDEX_SETTING = "cvelistv5_product_index_v48" # v48: Zoom bulletins
|
|
_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).
|
|
# Oracle's own records changed product name twice: "Java" (2018-2020,
|
|
# "Java SE: 6u201, 7u191, 8u181") and "Java SE JDK and JRE" (2021-2024,
|
|
# "Oracle Java SE:17.0.9"). Neither was paired, so those years reached no
|
|
# Java install at all; _java_se_versions reads both list shapes.
|
|
{"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"),
|
|
("oracle corporation", "java"), ("oracle corporation", "java se jdk and jre")]},
|
|
# 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")]},
|
|
# Entra Connect Sync (formerly Azure AD Connect). Same anchored name as the
|
|
# CPE registry entry, for the same reason: the Health and pass-through
|
|
# agents on that server are other products whose versions fall inside the
|
|
# sync ranges. The pre-rename records name one product per line ("… 1.X.Y.Z",
|
|
# "… 2.0.X.Y"); the Provisioning Agent is Cloud Sync and stays out.
|
|
{"key": "entra-connect",
|
|
"re": r"^microsoft (entra|azure ad) connect( sync)?( synchronization services)?$",
|
|
"pairs": [("microsoft", "microsoft entra connect"),
|
|
("microsoft", "microsoft azure active directory connect 1.x.y.z"),
|
|
("microsoft", "microsoft azure active directory connect 2.0.x.y")]},
|
|
# 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 version field does not; the version field is what is compared.
|
|
# Since 2026 Illustrator and Photoshop records name the yearly line
|
|
# instead ("Illustrator Desktop 2026", "Photoshop 2025"); those come in
|
|
# through _PRODUCT_PATTERNS. Each line is stated as "0 .. <= last broken
|
|
# build", so major_floor keeps the 2026 range off a patched 2025 install —
|
|
# see the Media Encoder row.
|
|
{"key": "adobe-illustrator", "re": r"^adobe illustrator\b", "major_floor": True,
|
|
"pairs": [("adobe", "illustrator"), ("adobe", "illustrator desktop"),
|
|
("adobe", "adobe illustrator")]},
|
|
{"key": "adobe-photoshop", "re": r"^adobe photoshop\b", "major_floor": True,
|
|
"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")]},
|
|
# Media Encoder. One product name for both supported lines, each stated as
|
|
# "0 .. <= last broken build" (CVE-2026-48370: <= 26.2.2 and <= 25.6.5),
|
|
# the patched build only as "unaffected". Read literally, the 26 range
|
|
# swallows a 25.6.6 that is already patched. major_floor confines each
|
|
# range to the major it names: Media Encoder 2025 is 25.x.
|
|
{"key": "adobe-media-encoder", "re": r"^adobe media encoder\b", "major_floor": True,
|
|
"pairs": [("adobe", "adobe media encoder"), ("adobe", "media encoder")]},
|
|
# 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 have keys of
|
|
# their own below: their numbering (8.0.1xx) never falls inside a runtime
|
|
# fix range, and an SDK-only CVE's bound (8.0.130) is no runtime bound.
|
|
# .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")]},
|
|
# .NET SDK ("Microsoft .NET SDK 10.0.401 (x64)"). No pairs: the records
|
|
# name ".NET 10.0" for runtime and SDK alike, and only the bound tells
|
|
# them apart — build_product_index routes SDK-shaped bounds of the
|
|
# dotnet-* keys here (see cpe.dotnet_sdk_version, _sdk_band_ranges).
|
|
# Toolset, Templates and the packs are parts of the SDK and stay out.
|
|
{"key": "dotnet-sdk-10", "name_ver": True, "re": r"^microsoft \.net sdk 10\.0\.\d{3}\b", "pairs": []},
|
|
{"key": "dotnet-sdk-9", "name_ver": True, "re": r"^microsoft \.net sdk 9\.0\.\d{3}\b", "pairs": []},
|
|
{"key": "dotnet-sdk-8", "name_ver": True, "re": r"^microsoft \.net sdk 8\.0\.\d{3}\b", "pairs": []},
|
|
# ASP.NET Core — Microsoft files some CVEs ONLY under "ASP.NET Core 8.0",
|
|
# never ".NET 8.0" (CVE-2025-55315, CVSS 9.9), so the runtime keys cannot
|
|
# see them. Shared framework / runtime only: the targeting pack and the IIS
|
|
# hosting bundle options are not the runtime. One key per release, as above.
|
|
{"key": "aspnetcore-10", "name_ver": True,
|
|
"re": r"^microsoft asp\.net core (?:runtime - 10\.0\.|10\.0\.\d+ (?:- )?shared framework)",
|
|
"pairs": [("microsoft", "asp.net core 10.0")]},
|
|
{"key": "aspnetcore-9", "name_ver": True,
|
|
"re": r"^microsoft asp\.net core (?:runtime - 9\.0\.|9\.0\.\d+ (?:- )?shared framework)",
|
|
"pairs": [("microsoft", "asp.net core 9.0")]},
|
|
{"key": "aspnetcore-8", "name_ver": True,
|
|
"re": r"^microsoft asp\.net core (?:runtime - 8\.0\.|8\.0\.\d+ (?:- )?shared framework)",
|
|
"pairs": [("microsoft", "asp.net core 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")]},
|
|
# LibreOffice. NVD analyses TDF's records late (the 26.2.5 batch,
|
|
# CVE-2026-63266 ff., sat at "Received" with no CPE), so this is the path
|
|
# that sees the fresh ones. Each record bounds one series ("26.2" ..
|
|
# lessThan "< 26.2.5" — the operator inside the string, see _clean_bound).
|
|
# The Help and Language Packs are separate MSIs carrying the product's
|
|
# version that lag behind after an update; the SDK is a developer kit.
|
|
# None of them is the suite.
|
|
{"key": "libreoffice", "re": r"^libreoffice\b(?!.*(?:help ?pack|language ?pack|sdk))",
|
|
"pairs": [("the document foundation", "libreoffice"), ("libreoffice", "libreoffice")]},
|
|
# Lenovo System Update. NVD left CVE-2026-6387 at "Awaiting Analysis" with
|
|
# no configuration, so this is the only structured source ("0" .. lessThan
|
|
# "5.08.04.85"). The zero-padded build parts compare as numbers. Only
|
|
# Lenovo's structured spelling: the records naming the product "Lenovo
|
|
# System Update" carry prose ("Versions prior to 5.08.02.25"), which the
|
|
# exact-version fallback would read as the fixed build itself. Anchored:
|
|
# Vantage, System Interface Foundation and Update Retriever are other
|
|
# version lines.
|
|
{"key": "lenovo-system-update", "re": r"^lenovo system update$",
|
|
"pairs": [("lenovo", "system update")]},
|
|
# Blender. NVD left CVE-2026-60103 "Deferred" with no configuration and its
|
|
# blender:blender rows stop at 2022, so this is the only structured source
|
|
# ("3.0.0" .. lessThanOrEqual "5.1.2"). Vendor's own spelling only: the
|
|
# 2022 records under "n/a" write prose into the version field.
|
|
{"key": "blender", "re": r"^blender(?: \d+(?:\.\d+)*(?: lts)?)?$",
|
|
"pairs": [("blender", "blender")]},
|
|
# Zoom. Same names and anchoring as the CPE rows (Rooms, the Outlook
|
|
# plugin and the SDKs are other products). NVD is the main source here:
|
|
# most Zoom records say "see references" instead of a version, the
|
|
# 2021-2022 ones floor at "unspecified", and the 2023 ones write prose
|
|
# ("before 5.14.0") that would read as the exact FIXED build. These pairs
|
|
# are the spellings with a real bound, which NVD can lag on
|
|
# (CVE-2026-53416 sat at "Awaiting Analysis"). The old bare-"zoom" pairs
|
|
# matched no record's vendor and are gone. The "see references" CVEs come
|
|
# from Zoom's bulletins instead, see _merge_zoom_bulletins.
|
|
{"key": "zoom", "re": r"^zoom(?: workplace)?(?: ?\((?:(?:32|64)-?bit|arm64)\))?$|^zoom\.us$",
|
|
"pairs": [("zoom communications", "zoom workplace for windows"),
|
|
("zoom communications", "zoom workplace"),
|
|
("zoom communications inc.", "zoom workplace"),
|
|
("zoom communications inc.", "zoom clients")]},
|
|
# VDI client and VDI plugins: one version line, told apart per CVE by
|
|
# cpe.zoom_cve_applies.
|
|
{"key": "zoom-vdi", "re": r"^zoom (?:workplace )?vdi\b(?!.*\bmac(?:os)?\b)",
|
|
"pairs": [("zoom communications", "zoom vdi"),
|
|
("zoom communications", "zoom workplace vdi plugin"),
|
|
("zoom communications inc.", "zoom workplace vdi client"),
|
|
("zoom video communications, inc.", "zoom workplace vdi app for windows")]},
|
|
# 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))"
|
|
r"|^(?:ms)?teams$",
|
|
"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)$"},
|
|
# Adobe's yearly-line product names (see the Creative-Cloud rows): a pair
|
|
# per year would go stale every autumn.
|
|
{"key": "adobe-illustrator", "vendor_lit": "adobe", "vendor_re": r"^adobe$",
|
|
"product_re": r"^illustrator desktop 20\d\d$"},
|
|
{"key": "adobe-photoshop", "vendor_lit": "adobe", "vendor_re": r"^adobe$",
|
|
"product_re": r"^photoshop( desktop)? 20\d\d$"},
|
|
# 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?$"},
|
|
# NetScaler ADC and NetScaler Gateway, the sixth Netdisco firmware line.
|
|
# One appliance and one firmware: the records list both products with the
|
|
# identical bounds, so both land on the same key. The CNA has filed under
|
|
# three spellings, vendor "Citrix" with product "NetScaler ADC" (2023),
|
|
# vendor "NetScaler" with product "ADC" (2025 on) and vendor "Citrix
|
|
# NetScaler" with product "ADC" (CTX697096, 2026-09). The bounds are not
|
|
# ranges the generic reader can take; they are indexed raw and decided by
|
|
# scan_asset_netscaler.
|
|
#
|
|
# ANCHORED against the rest of the NetScaler range on the same vendor:
|
|
# "NetScaler Console" (ADM), "NetScaler SDX", "NetScaler Agent" and
|
|
# "Secure Private Access" each number their builds differently.
|
|
{"key": "netscaler-adc", "vendor_lit": "netscaler",
|
|
"vendor_re": r"^(citrix|(citrix\s+)?netscaler)$",
|
|
"product_re": r"^((netscaler|citrix)\s+)?(adc|gateway)$"},
|
|
# Kemp LoadMaster, the seventh Netdisco firmware line. The CNA files it as
|
|
# "Progress Software" or "Progress", and the Multi Tenant hypervisor as
|
|
# "Multi Tenant" / "Multi Tenant LoadMaster": same firmware, its fixes are
|
|
# the 7.1.35 line's (CVE-2026-59690). ECS Connection Manager, ObjectScale
|
|
# Connection Manager and MOVEit WAF share the records and are not matched.
|
|
# Indexed raw and decided by scan_asset_loadmaster.
|
|
{"key": "kemp-loadmaster", "vendor_lit": "progress",
|
|
"vendor_re": r"^progress(\s+software)?(\s+corporation)?$",
|
|
"product_re": r"^(progress\s+)?(kemp\s+)?(multi[\s-]tenant(\s+loadmaster)?|loadmaster)$"},
|
|
# Sophos Firewall (SFOS), the eighth Netdisco firmware line. The CNA files
|
|
# every record since 2022 as Sophos / "Sophos Firewall"; SG UTM, Sophos
|
|
# Connect Client and the endpoint products share the vendor and are not
|
|
# matched. Indexed raw and decided by scan_asset_sophos.
|
|
{"key": "sophos-firewall", "vendor_lit": "sophos",
|
|
"vendor_re": r"^sophos(\s+ltd\.?|\s+limited)?$",
|
|
"product_re": r"^(sophos\s+)?(firewall|sfos|xg\s+firewall)$"},
|
|
# QNAP QTS and QuTS hero, the ninth Netdisco firmware line: two NAS
|
|
# operating systems, one numbering, and hero writes it with a leading "h".
|
|
# QNAP files both as separate `affected[]` blocks on the same record, so
|
|
# each gets its own key. ANCHORED: "QuTScloud", "QTS Login function",
|
|
# File Station, Qsync Central and the other NAS apps share the vendor.
|
|
# Indexed by qnap_entries and decided by scan_asset_qnap.
|
|
{"key": "qnap-qts", "vendor_lit": "qnap",
|
|
"vendor_re": r"^qnap(\s+systems(\s*,?\s*inc\.?)?)?$",
|
|
"product_re": r"^(qnap\s+)?qts$"},
|
|
{"key": "qnap-quts-hero", "vendor_lit": "qnap",
|
|
"vendor_re": r"^qnap(\s+systems(\s*,?\s*inc\.?)?)?$",
|
|
"product_re": r"^(qnap\s+)?quts\s*hero$"},
|
|
]
|
|
|
|
# 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)
|
|
_BOUND_LT_RE = re.compile(r"^\s*<\s*(\d+(?:\.\d+)*)\s*$")
|
|
|
|
# "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, less_than=False):
|
|
"""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.
|
|
|
|
less_than: the value is a lessThan, and The Document Foundation repeats
|
|
that operator inside it — "< 26.2.5" (CVE-2026-63266). Same story as
|
|
Dell: the comparison survives, but every LibreOffice finding would offer
|
|
"< 26.2.5" as the version to install, and a fixed_version is never
|
|
overwritten (see _fix_build). Only "<" in a lessThan: "<=" in a
|
|
lessThanOrEqual is HPE's Aruba shape, which scan_asset_aruba deliberately
|
|
skips while it is unparsed (its `version` field is prose, "Version
|
|
10.5.0.0: 10.6.0.2 and below"); cleaning it here would switch those
|
|
entries on.
|
|
"""
|
|
if not isinstance(v, str):
|
|
return v
|
|
m = _BOUND_LT_RE.match(v) if less_than else None
|
|
if m:
|
|
return m.group(1)
|
|
m = _BOUND_PROSE_RE.match(v)
|
|
return m.group(1) if m else v
|
|
|
|
|
|
def _sdk_band_ranges(start: Optional[str], lt: Optional[str]) -> List[Tuple[Optional[str], str]]:
|
|
"""An SDK bound as one range per feature band: (start, lessThan) pairs.
|
|
|
|
Microsoft writes every band's fix into ONE field — CVE-2026-58649 says
|
|
lessThan "10.0.111, 10.0.400" — and the advisory reads it per band:
|
|
10.0.1xx is fixed in 10.0.111, 10.0.3xx in 10.0.400. Read as one version
|
|
that is (10, 0, 111, 10, 0, 400), and 10.0.399 compares above it.
|
|
|
|
Each fix closes its own band and every band below it down to the previous
|
|
fix, because a band without a fix of its own is out of support and still
|
|
vulnerable (8.0.3xx falls in the 8.0.424 range):
|
|
"10.0.111, 10.0.400" → [(10.0.0, 10.0.111), (10.0.200, 10.0.400)].
|
|
"""
|
|
fixes = sorted((b.strip() for b in (lt or "").split(",")
|
|
if cpe.dotnet_sdk_version(b.strip())), key=cpe._vtuple)
|
|
out, floor = [], start
|
|
for f in fixes:
|
|
out.append((floor, f))
|
|
t = cpe._vtuple(f)
|
|
floor = f"{t[0]}.{t[1]}.{(t[2] // 100 + 1) * 100}"
|
|
return out
|
|
|
|
|
|
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"), less_than=True)
|
|
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:
|
|
# Upper bounds only. Microsoft writes the Teams desktop floor as
|
|
# "1.0.0", three parts against classic Teams' 1.5.00.31168, and
|
|
# checking it refused every classic Teams range (CVE-2023-29328).
|
|
for b in (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 ----------
|
|
# One downloader for the whole app: the nightly index build, the GUI app scan,
|
|
# the CVSS cascade and the date backfill all come through here. The lock makes
|
|
# a second caller wait for the first download instead of starting its own.
|
|
_ZIP_LOCK = threading.Lock()
|
|
_ZIP_RETRY_BACKOFF = 15 * 60
|
|
_zip_retry_after = 0.0 # time.monotonic() before which a failed download is not retried
|
|
|
|
|
|
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; the one GUI path that forces a download is the
|
|
Data-jobs app scan, whose dialog says so (refresh_indexes).
|
|
|
|
After a failed download, callers within _ZIP_RETRY_BACKOFF use what is on
|
|
disk (or get False) instead of each pulling 557 MB from GitHub again.
|
|
"""
|
|
global _zip_retry_after
|
|
asked_at = time.time()
|
|
with _ZIP_LOCK:
|
|
on_disk = (os.path.exists(_ZIP_PATH)
|
|
and os.path.getsize(_ZIP_PATH) > _ZIP_MIN_BYTES)
|
|
mtime = os.path.getmtime(_ZIP_PATH) if on_disk else 0.0
|
|
fresh = on_disk and (time.time() - mtime) < _ZIP_TTL
|
|
# A forced caller is also served by a download that finished while it
|
|
# waited for the lock.
|
|
if fresh and (not force or mtime >= asked_at):
|
|
return True
|
|
if time.monotonic() < _zip_retry_after:
|
|
logger.warning("cvelistv5-scan: ZIP download failed recently — %s",
|
|
"using the on-disk ZIP" if on_disk else "no ZIP available")
|
|
return on_disk
|
|
import httpx
|
|
# Per-writer temp name: never let a reader see a half-written file.
|
|
tmp = f"{_ZIP_PATH}.{os.getpid()}.{threading.get_ident()}.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)
|
|
if not zipfile.is_zipfile(tmp):
|
|
raise ValueError("download is not a ZIP")
|
|
os.replace(tmp, _ZIP_PATH)
|
|
_zip_retry_after = 0.0
|
|
return True
|
|
except Exception as e:
|
|
_zip_retry_after = time.monotonic() + _ZIP_RETRY_BACKOFF
|
|
logger.warning("cvelistv5-scan: ZIP download failed: %s", e)
|
|
# 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() - mtime) / 3600)
|
|
return True
|
|
return False
|
|
finally:
|
|
if os.path.exists(tmp):
|
|
try:
|
|
os.remove(tmp)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
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 in ("citrix-workspace-win", "netscaler-adc", "kemp-loadmaster",
|
|
"sophos-firewall"):
|
|
# 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.
|
|
# NetScaler the same way: "14.1" in version, the build in
|
|
# the bound. See scan_asset_netscaler. LoadMaster: one range
|
|
# per release line, see scan_asset_loadmaster. Sophos writes
|
|
# "21.0 MR2 (21.0.2)", see sophos_bound.
|
|
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, _raw in _java_se_versions(aff.get("versions") or []):
|
|
_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 in QNAP_LABELS:
|
|
for ent in qnap_entries(aff, cve_id, cvss, sev, desc):
|
|
_sig = (key, cve_id, ent["start"], ent["lt"], ent["lte"])
|
|
if _sig in seen:
|
|
continue
|
|
seen.add(_sig)
|
|
index.setdefault(key, []).append(ent)
|
|
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):
|
|
if key.startswith("dotnet-") and cpe.dotnet_sdk_version(lt or lte):
|
|
# SDK fix (CVE-2026-58649): never a runtime bound. It
|
|
# goes to the SDK key, one range per feature band.
|
|
sdk_key = "dotnet-sdk-" + key.split("-")[-1]
|
|
for s, l in _sdk_band_ranges(start, lt):
|
|
sig = (sdk_key, cve_id, s, l, None, prod.lower())
|
|
if sig not in seen:
|
|
seen.add(sig)
|
|
index.setdefault(sdk_key, []).append(
|
|
{"cve": cve_id, "start": s, "lt": l, "lte": None,
|
|
"plats": plats, "cvss": cvss, "sev": sev,
|
|
"prod": prod, "desc": desc})
|
|
continue
|
|
# Inclusive bounds only: "0 .. < 26.0" floored at 26 would
|
|
# be an empty range and drop the CVE without a word.
|
|
if start is None and lte and _KEY_ENTRY.get(key, {}).get("major_floor"):
|
|
start = str((cpe._vtuple(lte) or (0,))[0])
|
|
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)
|
|
_merge_chrome_releases(db, index)
|
|
_merge_zoom_bulletins(db, index, 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 _merge_chrome_releases(db: Session, index: Dict[str, list]) -> None:
|
|
"""Chrome CVEs Google has shipped a fix for but no record states yet.
|
|
|
|
Chrome 154 (22.09.2026, 108 fixes) had no cvelistV5 record two days on, so
|
|
no host was flagged. The Chrome Releases blog names the fixed build, which
|
|
is the record's own `lessThan` (see chrome_releases_service). Additive only:
|
|
a CVE with a range from its record keeps exactly that, and the blog entry
|
|
is gone the night the record arrives. Blog unreachable → last parse.
|
|
"""
|
|
try:
|
|
from app.services import chrome_releases_service as crs
|
|
blog = crs.get_index(db)
|
|
except Exception as e:
|
|
logger.warning("cvelistv5-scan: Chrome Releases merge skipped (%s)", e)
|
|
return
|
|
have = {e.get("cve") for e in index.get("chrome") or []}
|
|
add = [{"cve": cve, "start": None, "lt": d["fix"], "lte": None, "plats": [],
|
|
"cvss": None, "sev": d["sev"], "prod": "Chrome", "desc": crs.description(d)}
|
|
for cve, d in sorted(blog.items()) if cve not in have]
|
|
if add:
|
|
index.setdefault("chrome", []).extend(add)
|
|
logger.info("cvelistv5-scan: Chrome Releases blog added %d CVEs without a record "
|
|
"(%d on the blog)", len(add), len(blog))
|
|
|
|
|
|
def _merge_zoom_bulletins(db: Session, index: Dict[str, list],
|
|
force_fresh: bool = False) -> None:
|
|
"""Zoom's bulletins decide every Zoom CVE they bound.
|
|
|
|
Most Zoom records say "see references" and are never indexed, and NVD
|
|
takes weeks (CVE-2026-108184, see zoom_bulletin_service). Where a record
|
|
does state a bound, the bulletin is still the finer one: CVE-2026-53416's
|
|
record says "< 7.0.11", ZSB-26018 adds that 6.6.15 fixed the 6.6 branch.
|
|
Per key, so a bulletin that names only the desktop client leaves a VDI
|
|
range from the record alone. Fresh on the nightly build, like MFSA.
|
|
"""
|
|
try:
|
|
from app.services import zoom_bulletin_service as zsb
|
|
bulletins = (not force_fresh and zsb.load(db)) or zsb.build_index(db)
|
|
except Exception as e:
|
|
logger.warning("cvelistv5-scan: Zoom bulletin merge skipped (%s)", e)
|
|
return
|
|
rows = [e for es in bulletins.values() for e in es]
|
|
for key in ("zoom", "zoom-vdi"):
|
|
mine = [e for e in rows if e["key"] == key]
|
|
ruled = {e["cve"] for e in mine}
|
|
index[key] = [e for e in index.get(key) or [] if e["cve"] not in ruled] + mine
|
|
logger.info("cvelistv5-scan: Zoom bulletins decided %d CVEs",
|
|
len({e["cve"] for e in rows}))
|
|
|
|
|
|
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 cisco_unlisted(index: Optional[dict], os_name: Optional[str],
|
|
raw_version: Optional[str]) -> set:
|
|
"""CVEs whose Cisco record enumerates this family and leaves this release out.
|
|
|
|
The NVD half must not overrule these. NVD sometimes writes a Cisco record
|
|
as one envelope range instead of the list: CVE-2025-20363's IOS XE
|
|
configuration is `3.2.0sg .. 17.17.1` inclusive — Cisco's first and last
|
|
enumerated release, with every train in between — while Cisco's own list
|
|
stops the 17.15 train at 17.15.3. Read as a range, a router on 17.15.6 (the
|
|
fix) is affected. Measured over the 89 IOS XE CVEs NVD writes with ranges:
|
|
wherever Cisco also enumerates, the range adds only releases Cisco did not
|
|
list (CVE-2025-20196: 102 of them). Where Cisco enumerates nothing, NVD's
|
|
range is all there is and stays — so this is per CVE, never "drop ranges".
|
|
"""
|
|
key = cisco_key(os_name)
|
|
cver = cisco_version(raw_version)
|
|
if not key or not cver:
|
|
return set()
|
|
judged, listed = set(), set()
|
|
for e in (index or {}).get(key) or ():
|
|
judged.add(e["cve"].upper())
|
|
if cver in (e.get("vers") or ()):
|
|
listed.add(e["cve"].upper())
|
|
return judged - listed
|
|
|
|
|
|
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
|
|
|
|
|
|
# ---------- NetScaler ADC / Gateway (Netdisco inventory) ----------
|
|
#
|
|
# The sixth firmware line on the Netdisco path. A NetScaler build is a release
|
|
# line plus a build inside it, "14.1-72.61", and a fix only speaks to its own
|
|
# line: 14.1-73.32 says nothing about a 13.1 appliance.
|
|
#
|
|
# The CNA writes exactly that split, and it is why the generic range reader
|
|
# cannot take these records. `version` names the line and the bound names the
|
|
# build within it:
|
|
#
|
|
# CVE-2025-5777 version "14.1" lessThan "43.56" (fix 14.1-43.56)
|
|
# CVE-2023-4966 version "14.1" lessThan "8.50"
|
|
#
|
|
# Read as one range that is 14.1 <= x < 43.56, which every 14.1 build sits
|
|
# inside, so the scanner would flag every appliance for every record.
|
|
#
|
|
# Two further traps, both in live data:
|
|
#
|
|
# * lessThanOrEqual names the FIX, not the last broken build. CVE-2026-19489
|
|
# and -19490 write lessThanOrEqual "73.32", while Citrix's bulletin
|
|
# CTX696939 says "14.1 BEFORE 14.1-73.32" and NVD's analysis of -19490 says
|
|
# versionEndExcluding 14.1-73.32. Every earlier Citrix record uses lessThan
|
|
# for the same thing. Read literally, a patched appliance on 73.32 would be
|
|
# flagged, and the finding could not close until the next release.
|
|
# * The FIPS and NDcPP builds are separate lines with their own fixes
|
|
# ("13.1 FIPS and NDcPP" < 37.241 next to "13.1" < 59.22), and nothing in
|
|
# the Netdisco inventory says whether an appliance is one. On 13.1 and 12.1
|
|
# they sit on build 37.x and 55.x, below every normal fix, so such a build
|
|
# gets no verdict at all (netscaler_build). Some records do not list the
|
|
# FIPS line even when the bulletin does (-19489/-19490), so gating on the
|
|
# record would not be enough.
|
|
NETSCALER_LABEL = "NetScaler ADC"
|
|
|
|
_NETSCALER_OS_RE = re.compile(r"^\s*(citrix\s+)?netscaler\s+(adc|gateway)\b", re.I)
|
|
_NS_BUILD_RE = re.compile(r"^(?:ns)?(\d+\.\d+)-(\d+\.\d+)$", re.I)
|
|
_NS_LINE_RE = re.compile(r"^\d+\.\d+$")
|
|
# ponytail: hand-kept, the lines on which FIPS/NDcPP builds share numbers with
|
|
# normal ones. 14.1 FIPS follows the normal 14.1 build numbers. Add a line when
|
|
# Citrix ships a FIPS build on it.
|
|
_NS_FIPS_BUILDS = {"13.1": 37, "12.1": 55}
|
|
|
|
|
|
def netscaler_build(raw: Optional[str]) -> Optional[Tuple[str, str]]:
|
|
""""14.1-72.61" → ("14.1", "72.61"), or None when there is no verdict.
|
|
|
|
None for anything that is not line-dash-build, and for a build that may be
|
|
a FIPS/NDcPP one (see above)."""
|
|
m = _NS_BUILD_RE.match((raw or "").strip())
|
|
if not m:
|
|
return None
|
|
line, build = m.group(1), m.group(2)
|
|
if _NS_FIPS_BUILDS.get(line) == int(build.split(".")[0]):
|
|
return None
|
|
return line, build
|
|
|
|
|
|
def netscaler_key(os_name: Optional[str]) -> Optional[str]:
|
|
"""Canonical NetScaler label → curated product key, or None."""
|
|
return "netscaler-adc" if _NETSCALER_OS_RE.match((os_name or "").strip()) else None
|
|
|
|
|
|
def _ns_bound(line: str, bound: str) -> Optional[str]:
|
|
"""A record's bound as a build within `line`: "73.32" or "14.1-73.32"."""
|
|
b = (bound or "").strip()
|
|
m = _NS_BUILD_RE.match(b)
|
|
if m:
|
|
return m.group(2) if m.group(1) == line else None
|
|
return b if _NS_LINE_RE.match(b) else None
|
|
|
|
|
|
def scan_asset_netscaler(db: Session, asset, index: dict,
|
|
new_ids: Optional[list] = None,
|
|
touched: Optional[set] = None) -> int:
|
|
"""NetScaler ADC/Gateway CVEs from the firmware build on the asset."""
|
|
if not index or not netscaler_key(asset.operating_system or ""):
|
|
return 0
|
|
entries = index.get("netscaler-adc") or []
|
|
parsed = netscaler_build(asset.os_version)
|
|
if not entries or not parsed:
|
|
return 0
|
|
line, build = parsed
|
|
if new_ids is None:
|
|
new_ids = []
|
|
|
|
count = 0
|
|
still_affected: set = set()
|
|
for entry in entries:
|
|
# Only the entry for this exact line. "13.1-FIPS", "13.1 FIPS and
|
|
# NDcPP" and anything else are not a normal appliance's line. The line
|
|
# is named in `version` ("14.1" < "73.32") or, since CTX697096, in the
|
|
# bound (version "0" < "14.1-73.37"; "14.1-73.37 FIPS" is not a build).
|
|
ver = (entry.get("ver") or "").strip()
|
|
raw = (entry.get("lt") or entry.get("lte") or "").strip()
|
|
if ver != line and not (ver in ("", "0") and _NS_BUILD_RE.match(raw)):
|
|
continue
|
|
# lessThanOrEqual is read as lessThan, see above.
|
|
bound = _ns_bound(line, raw)
|
|
if not bound or cpe._vcmp(build, bound) != -1:
|
|
continue
|
|
cve = entry["cve"].upper()
|
|
if cve in still_affected:
|
|
continue # ADC and Gateway: one finding
|
|
still_affected.add(cve)
|
|
c = {"cve": entry["cve"], "cvss": entry.get("cvss"),
|
|
"severity": entry.get("sev"), "fixed": f"{line}-{bound}",
|
|
"desc": entry.get("desc")}
|
|
try:
|
|
before = len(new_ids)
|
|
cpe._upsert(db, asset, NETSCALER_LABEL, asset.os_version,
|
|
c, new_ids, touched=touched, vendor="NetScaler")
|
|
count += 1 if len(new_ids) > before else 0
|
|
except Exception as e:
|
|
logger.debug("netscaler upsert failed (%s on %s): %s",
|
|
entry["cve"], asset.id, e)
|
|
|
|
# Same reconcile and fold-in as the other firmware passes: an appliance
|
|
# has no software inventory, and the NVD-CPE pass runs first.
|
|
_resolve_stale_appliance(db, asset, NETSCALER_LABEL,
|
|
still_affected | set(touched or ()), asset.os_version,
|
|
tag="netscaler")
|
|
return count
|
|
|
|
|
|
# ---------- Kemp LoadMaster (Netdisco inventory) ----------
|
|
#
|
|
# The seventh firmware line on the Netdisco path. LoadMaster ships a GA line and
|
|
# long-term lines (LTSF) in ONE numbering: GA runs 7.2.55 .. 7.2.63, LTSF stays
|
|
# on 7.2.54.x. Every advisory therefore names one fix per line, and both
|
|
# sources write them as independent ranges:
|
|
#
|
|
# CVE-2026-59687 (CNA) 7.0.8 <= x < 7.2.63.3 and 7.0.8 <= x < 7.2.54.19
|
|
# CVE-2024-6658 (NVD) x < 7.1.35.12, x <= 7.2.48.12, 7.2.49.0 <= x < 7.2.54.12, ...
|
|
#
|
|
# Read range by range, "any range contains it" is a false positive on every
|
|
# patched long-term box: LTSF 7.2.54.19 sits inside the GA range, a Multi Tenant
|
|
# 7.1.35.13 below "<= 7.2.48.12". The decision is per line (loadmaster_fix):
|
|
#
|
|
# * a range whose bound is on the build's own line (first three numbers)
|
|
# decides alone;
|
|
# * otherwise a GA build takes any range that contains it ("and all prior
|
|
# versions", Progress bulletin 000301124);
|
|
# * otherwise a 7.1.35 build gets no verdict. That line is Multi Tenant's,
|
|
# still patched (7.1.35.16 for CVE-2026-59690), and only its own ranges
|
|
# speak to it; NVD files them under multi-tenant_loadmaster/_hypervisor;
|
|
# * otherwise an LTSF build takes only a range ending on a LATER long-term
|
|
# line (an EOL 7.2.48 box below the 7.2.54 fix). A record naming only the
|
|
# GA fix (CVE-2024-6658's CNA entry, < 7.2.60.1) gives it no verdict: the
|
|
# LTSF fix exists, the record just does not say which.
|
|
#
|
|
# Where the sources disagree the union stands and both agree with the bulletin:
|
|
# CVE-2026-8037's CNA range starts at 7.2.60.0, NVD's at 7.2.55.0; the bulletin
|
|
# says "GA v7.2.63.1 and all prior versions". Nothing from the bulletins is
|
|
# ingested. CVE-2026-33691 (June 2026 bulletin) is an OWASP CRS record with no
|
|
# LoadMaster in it and is not detected.
|
|
KEMP_LOADMASTER_LABEL = "Kemp LoadMaster"
|
|
|
|
_LOADMASTER_OS_RE = re.compile(r"^\s*(kemp\s+|progress\s+)?loadmaster\b", re.I)
|
|
_LM_BUILD_RE = re.compile(r"^v?(\d+\.\d+\.\d+\.\d+)(?:\.|$)", re.I)
|
|
_LM_BOUND_RE = re.compile(r"^v?(\d+\.\d+\.\d+(?:\.\d+)?)$", re.I)
|
|
_LM_OPEN_START = {"", "0", "*", "all previous versions"}
|
|
# ponytail: hand-kept, Progress's long-term lines. Add one when a new LTSF
|
|
# branches off; until then its builds are read as GA.
|
|
_LM_LONG_TERM = {(7, 2, 48), (7, 2, 54)}
|
|
_LM_MULTI_TENANT = (7, 1, 35)
|
|
|
|
# The records above cannot speak for every line. Some name only the GA fix
|
|
# (CVE-2024-7591: CNA < 7.2.60.1, NVD <= 7.2.60.0), some write bounds no reader
|
|
# parses ("7.2.54.9 ( LoadMaster LTSF)", "LoadMaster 7.2.49.0 (LTSF)"), and NVD
|
|
# files some under a:progress:loadmaster, which the o: query never returns. A
|
|
# long-term box then gets no verdict for a CVE its line was patched for.
|
|
#
|
|
# _LM_BULLETIN holds, per CVE, (start, fix) per line, read off the Progress
|
|
# release notes (docs.progress.com/bundle/release-notes_loadmaster-<build>/page/
|
|
# Security-Updates.html) on 17.09.2026; starts from the CNA record. A CVE with a
|
|
# row is decided by the row alone, in both passes, through the same per-line
|
|
# rules. A line the notes do not name stays out: 7.2.48 got no fix for 7591,
|
|
# 1758 or the 2026 ones, so an LTS box takes the 7.2.54 fix; 7.1.35 has no row
|
|
# where the sources disagree (7591: NVD < 7.1.35.11, finder "<= 7.1.35.11").
|
|
# ponytail: hand-kept like _SFOS_ADVISORIES. A CVE whose record already names
|
|
# every line needs no row.
|
|
_LM_BULLETIN: Dict[str, tuple] = {
|
|
"CVE-2024-2448": (("7.2.48.10", "7.2.48.11"), ("7.2.49.0", "7.2.54.9"),
|
|
("7.2.55.0", "7.2.59.3"), ("7.1.35.10", "7.1.35.11")),
|
|
"CVE-2024-2449": (("7.2.48.10", "7.2.48.11"), ("7.2.49.0", "7.2.54.9"),
|
|
("7.2.55.0", "7.2.59.3"), ("7.1.35.10", "7.1.35.11")),
|
|
"CVE-2024-3544": (("7.2.48.11", "7.2.48.12"), ("7.2.49.0", "7.2.54.10"),
|
|
("7.2.55.0", "7.2.59.4")),
|
|
"CVE-2024-7591": (("7.2.40.0", "7.2.54.12"), ("7.2.40.0", "7.2.60.1")),
|
|
"CVE-2025-1758": (("7.2.40.0", "7.2.54.14"), ("7.2.40.0", "7.2.61.1")),
|
|
"CVE-2026-3517": (("7.1.32.0", "7.2.54.17"), ("7.1.32.0", "7.2.63.1")),
|
|
"CVE-2026-3518": (("7.2.37.0", "7.2.54.17"), ("7.2.37.0", "7.2.63.1")),
|
|
"CVE-2026-3519": (("7.2.45.0", "7.2.54.17"), ("7.2.45.0", "7.2.63.1")),
|
|
"CVE-2026-4048": (("7.1.20.0", "7.2.54.17"), ("7.1.20.0", "7.2.63.1")),
|
|
}
|
|
|
|
|
|
def loadmaster_version(raw: Optional[str]) -> Optional[str]:
|
|
""""7.2.63.3.23e1735.RELEASE.20260625-1143" → "7.2.63.3", or None."""
|
|
m = _LM_BUILD_RE.match((raw or "").strip())
|
|
return m.group(1) if m else None
|
|
|
|
|
|
def loadmaster_key(os_name: Optional[str]) -> Optional[str]:
|
|
"""Canonical LoadMaster label → curated product key, or None."""
|
|
return "kemp-loadmaster" if _LOADMASTER_OS_RE.match(os_name or "") else None
|
|
|
|
|
|
def _lm_line(v: str) -> tuple:
|
|
return tuple(int(x) for x in v.split(".")[:3])
|
|
|
|
|
|
def loadmaster_range(start, start_incl, end, end_incl) -> Optional[tuple]:
|
|
"""One bound pair as (start|None, start_incl, end, end_incl), None when unreadable."""
|
|
e = _LM_BOUND_RE.match((end or "").strip())
|
|
if not e:
|
|
return None # no ceiling, no verdict
|
|
s = (start or "").strip()
|
|
if s.lower() in _LM_OPEN_START:
|
|
return None, False, e.group(1), bool(end_incl)
|
|
m = _LM_BOUND_RE.match(s)
|
|
return (m.group(1), bool(start_incl), e.group(1), bool(end_incl)) if m else None
|
|
|
|
|
|
def loadmaster_fix(version: str, ranges: list, cve: str = "") -> Optional[str]:
|
|
"""The fix for `version` from one CVE's ranges, or None when not affected.
|
|
|
|
">X" when the deciding bound is inclusive (no fix named). A CVE in
|
|
_LM_BULLETIN is decided by its row instead of `ranges`. See above."""
|
|
v = loadmaster_version(version)
|
|
row = _LM_BULLETIN.get((cve or "").upper())
|
|
if row:
|
|
ranges = [(start, True, fix, False) for start, fix in row]
|
|
rs = [r for r in ranges if r]
|
|
if not v or not rs:
|
|
return None
|
|
line = _lm_line(v)
|
|
own = [r for r in rs if _lm_line(r[2]) == line]
|
|
if own:
|
|
rs = own
|
|
elif line == _LM_MULTI_TENANT:
|
|
return None
|
|
elif line in _LM_LONG_TERM:
|
|
rs = [r for r in rs if _lm_line(r[2]) in _LM_LONG_TERM and _lm_line(r[2]) > line]
|
|
|
|
def inside(r):
|
|
start, s_incl, end, e_incl = r
|
|
if start and cpe._vcmp(v, start) not in ((0, 1) if s_incl else (1,)):
|
|
return False
|
|
c = cpe._vcmp(v, end)
|
|
return c == -1 or (e_incl and c == 0)
|
|
|
|
hits = sorted((r for r in rs if inside(r)), key=lambda r: cpe._vtuple(r[2]))
|
|
if not hits:
|
|
return None
|
|
return f">{hits[0][2]}" if hits[0][3] else hits[0][2]
|
|
|
|
|
|
def scan_asset_loadmaster(db: Session, asset, index: dict,
|
|
new_ids: Optional[list] = None,
|
|
touched: Optional[set] = None) -> int:
|
|
"""Kemp LoadMaster CVEs from the firmware build on the asset."""
|
|
if not index or not loadmaster_key(asset.operating_system):
|
|
return 0
|
|
entries = index.get("kemp-loadmaster") or []
|
|
if not entries or not loadmaster_version(asset.os_version):
|
|
return 0
|
|
if new_ids is None:
|
|
new_ids = []
|
|
|
|
by_cve: Dict[str, list] = {}
|
|
for e in entries:
|
|
by_cve.setdefault(e["cve"].upper(), []).append(e)
|
|
count = 0
|
|
still_affected: set = set()
|
|
for cve, ents in by_cve.items():
|
|
fix = loadmaster_fix(asset.os_version, [
|
|
loadmaster_range(e.get("ver"), True, e.get("lt") or e.get("lte"), not e.get("lt"))
|
|
for e in ents], cve)
|
|
if not fix:
|
|
continue
|
|
still_affected.add(cve)
|
|
c = {"cve": cve, "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, KEMP_LOADMASTER_LABEL, asset.os_version,
|
|
c, new_ids, touched=touched, vendor="Progress")
|
|
count += 1 if len(new_ids) > before else 0
|
|
except Exception as e:
|
|
logger.debug("loadmaster upsert failed (%s on %s): %s", cve, asset.id, e)
|
|
|
|
_resolve_stale_appliance(db, asset, KEMP_LOADMASTER_LABEL,
|
|
still_affected | set(touched or ()), asset.os_version,
|
|
tag="loadmaster")
|
|
return count
|
|
|
|
|
|
# ---------- Sophos Firewall / SFOS (Netdisco inventory) ----------
|
|
#
|
|
# The eighth firmware line on the Netdisco path. Netdisco reports vendor
|
|
# "sophos", os "sfos", version "22.0.1 MR-1-Build490": release 22.0, maintenance
|
|
# release 1, build 490. Both sources bound the maintenance release: NVD
|
|
# "versionEndExcluding 21.0.2", the CNA "lessThan 21.0 MR2 (21.0.2)".
|
|
#
|
|
# Read as a range that is a false positive on most firewalls it touches. Sophos
|
|
# fixes a critical flaw first with a HOTFIX for every supported release
|
|
# (CVE-2025-6704: 19.0 MR2, 20.0 MR2, 20.0 MR3, 21.0 GA, 21.0 MR1, 21.5 GA), a
|
|
# firewall installs it by itself within 30 minutes ("Allow automatic
|
|
# installation of hotfixes" is the default), and the firmware version does not
|
|
# change. Netdisco cannot see it; only `system diagnostics show version-info` on
|
|
# the box can. Neither NVD nor the CNA records name the hotfixed releases.
|
|
#
|
|
# The advisories on sophos.com name them, as HTML only (no CSAF; the RSS feed
|
|
# carries titles). They are not scraped. _SFOS_ADVISORIES holds, per CVE, the
|
|
# releases the fix is included in and the releases a hotfix was published for,
|
|
# read off the advisories on 16.09.2026. A source match becomes a finding only
|
|
# when (sophos_fix):
|
|
#
|
|
# * the CVE has a row — a record without one gets no verdict and a warning,
|
|
# because the next advisory will hotfix most releases again;
|
|
# * the box's release (x.y.z, the MR level) got no hotfix. MR sub-releases
|
|
# (21.0 MR1-1, MR1-2) share x.y.z and are read as hotfixed when any build
|
|
# of it was, which errs towards no verdict;
|
|
# * no fix on the box's own line is at or below it. The CNA's per-line bounds
|
|
# and NVD's one bound disagree (CVE-2022-3236: NVD "<= 19.0.1", fixed in
|
|
# 18.5 MR5), so the advisory decides.
|
|
#
|
|
# The fix shown is the lowest fixed release above the box. EAP builds are not
|
|
# read (sophos_version).
|
|
SOPHOS_FIREWALL_LABEL = "Sophos Firewall"
|
|
|
|
_SOPHOS_OS_RE = re.compile(r"^\s*sophos\s+firewall\b", re.I)
|
|
_SFOS_RE = re.compile(
|
|
r"^\s*(?:sfos\s+)?(\d+)\.(\d+)\.(\d+)\s+(?:(ga)|mr-(\d+)(?:-\d+)?)-build\s*(\d+)\s*$", re.I)
|
|
_SFOS_BOUND_RE = re.compile(r"^v?(\d+)\.(\d+)(?:\.(\d+)|\s+(ga)|\s+mr\s*(\d+))\b", re.I)
|
|
_SFOS_PAREN_RE = re.compile(r"\((\d+)\.(\d+)\.(\d+)\)")
|
|
|
|
|
|
def _sfos(fixed: str, hotfixed: str = "") -> tuple:
|
|
rel = lambda s: tuple(int(x) for x in s.split(".")) # noqa: E731
|
|
return tuple(map(rel, fixed.split())), frozenset(map(rel, hotfixed.split()))
|
|
|
|
|
|
# The releases sophos-sa-20250721-sfos-rce hotfixed for CVE-2025-6704/-7624/-7382.
|
|
_HF_2025_07 = "19.0.2 20.0.2 20.0.3 21.0.0 21.0.1 21.5.0"
|
|
# ponytail: hand-kept from sophos.com/security-advisories. A new SFOS advisory
|
|
# needs a row, until then its CVEs get no verdict (logged by sophos_fix).
|
|
_SFOS_ADVISORIES: Dict[str, tuple] = {
|
|
# sophos-sa-20250721-sfos-rce
|
|
"CVE-2025-6704": _sfos("21.0.2", _HF_2025_07),
|
|
"CVE-2025-7624": _sfos("21.0.2", _HF_2025_07),
|
|
"CVE-2025-7382": _sfos("21.0.2", _HF_2025_07),
|
|
"CVE-2024-13974": _sfos("21.0.1", "19.0.2 20.0.2 20.0.3 21.0.0"),
|
|
"CVE-2024-13973": _sfos("21.0.1"),
|
|
# sophos-sa-20241219-sfos-rce
|
|
"CVE-2024-12727": _sfos("21.0.1", "19.0.2 19.5.3 19.5.4 20.0.0 20.0.1 20.0.2 20.0.3 21.0.0"),
|
|
"CVE-2024-12728": _sfos("20.0.3 21.0.1",
|
|
"19.0.2 19.5.0 19.5.1 19.5.2 19.5.3 19.5.4 20.0.0 20.0.1 20.0.2 21.0.0"),
|
|
"CVE-2024-12729": _sfos("21.0.1", "19.0.2 19.0.3 19.5.0 19.5.1 19.5.2 19.5.3 19.5.4 "
|
|
"20.0.0 20.0.1 20.0.2 20.0.3 21.0.0"),
|
|
# sophos-sa-20231017-spx-password (20.0 EAP1 hotfix: EAP is not read)
|
|
"CVE-2023-5552": _sfos("19.5.4 20.0.0", "19.0.1 19.0.2 19.0.3 19.5.0 19.5.1 19.5.2 19.5.3"),
|
|
# sophos-sa-20221201-sfos-19-5-0
|
|
"CVE-2022-3226": _sfos("18.5.5 19.0.2 19.5.0"),
|
|
"CVE-2022-3696": _sfos("18.5.5 19.0.2 19.5.0"),
|
|
"CVE-2022-3709": _sfos("18.5.5 19.0.2 19.5.0"),
|
|
"CVE-2022-3713": _sfos("18.5.5 19.0.2 19.5.0"),
|
|
"CVE-2022-3710": _sfos("19.0.2 19.5.0"),
|
|
"CVE-2022-3711": _sfos("19.0.2 19.5.0"),
|
|
# sophos-sa-20220923-sfos-rce
|
|
"CVE-2022-3236": _sfos("18.5.5 19.0.2 19.5.0",
|
|
"17.0.10 17.5.12 17.5.13 17.5.14 17.5.15 17.5.16 17.5.17 "
|
|
"18.0.3 18.0.4 18.0.5 18.0.6 18.5.0 18.5.1 18.5.2 18.5.3 18.5.4 "
|
|
"19.0.0 19.0.1"),
|
|
# sophos-sa-20220907-sfos-18-5-4 / -19-0-1
|
|
"CVE-2022-1807": _sfos("18.5.4 19.0.1"),
|
|
# sophos-sa-20220505-sfos-19-0-0 / 20220907-sfos-18-5-4 / 20220328-sfos-18-5-3
|
|
"CVE-2021-25267": _sfos("18.5.4 19.0.0"),
|
|
"CVE-2021-25268": _sfos("18.5.4 19.0.0"),
|
|
"CVE-2022-0331": _sfos("18.5.3 19.0.0"),
|
|
# sophos-sa-20220325-sfos-rce (19.0 EAP hotfix: EAP is not read)
|
|
"CVE-2022-1040": _sfos("18.5.4 19.0.0",
|
|
"17.0.10 17.5.3 17.5.12 17.5.13 17.5.14 17.5.15 17.5.16 17.5.17 "
|
|
"18.0.3 18.0.4 18.0.5 18.0.6 18.5.0 18.5.1 18.5.2 18.5.3"),
|
|
}
|
|
|
|
|
|
# CVEs sophos_fix withheld for want of a row, collected for the run's errors
|
|
# (take_sophos_no_row) so a new advisory shows on the sync-run ledger, not only
|
|
# in the log.
|
|
# ponytail: module-global, two overlapping runs may report each other's CVEs.
|
|
_SOPHOS_NO_ROW: set = set()
|
|
|
|
|
|
def take_sophos_no_row() -> Optional[str]:
|
|
"""The run error line for CVEs without an advisory row since the last call, or None."""
|
|
cves = sorted(_SOPHOS_NO_ROW)
|
|
_SOPHOS_NO_ROW.clear()
|
|
if not cves:
|
|
return None
|
|
return (f"sophos: no _SFOS_ADVISORIES row for {', '.join(cves)} — no verdict "
|
|
f"until the advisory's hotfixed releases are added")
|
|
|
|
|
|
def sophos_version(raw: Optional[str]) -> Optional[str]:
|
|
""""22.0.1 MR-1-Build490" → "22.0.1.490", or None (EAP, or GA/MR not matching x.y.z)."""
|
|
m = _SFOS_RE.match(raw or "")
|
|
if not m:
|
|
return None
|
|
major, minor, mr = (int(x) for x in m.group(1, 2, 3))
|
|
if (mr != 0) if m.group(4) else (int(m.group(5)) != mr):
|
|
return None
|
|
return f"{major}.{minor}.{mr}.{int(m.group(6))}"
|
|
|
|
|
|
def sophos_key(os_name: Optional[str]) -> Optional[str]:
|
|
"""Canonical Sophos Firewall label → curated product key, or None."""
|
|
return "sophos-firewall" if _SOPHOS_OS_RE.match(os_name or "") else None
|
|
|
|
|
|
def sophos_bound(raw: Optional[str]) -> Optional[tuple]:
|
|
"""A CNA bound ("21.0 MR2 (21.0.2)", "19.0 GA", "18.5 MR3") → (21, 0, 2), or None."""
|
|
s = (raw or "").strip()
|
|
m = _SFOS_PAREN_RE.search(s)
|
|
if m:
|
|
return tuple(int(x) for x in m.groups())
|
|
m = _SFOS_BOUND_RE.match(s)
|
|
if not m:
|
|
return None
|
|
major, minor, dotted, ga, mr = m.groups()
|
|
return int(major), int(minor), int(dotted or (0 if ga else mr))
|
|
|
|
|
|
def sophos_fix(version: str, cve: str) -> Optional[str]:
|
|
"""The fix for a normalised build ("21.0.1.277") the sources flag, or None. See above."""
|
|
rel = (cpe._vtuple(version) or ())[:3]
|
|
if len(rel) < 3:
|
|
return None
|
|
row = _SFOS_ADVISORIES.get((cve or "").upper())
|
|
if not row:
|
|
# Every SFOS record since 2021 has a row, so a newer one is a new
|
|
# advisory to add; the NVD-only 17.x/18.0 CVEs before it never get one.
|
|
log = logger.debug
|
|
if cve[4:8] >= "2021":
|
|
log = logger.warning
|
|
_SOPHOS_NO_ROW.add(cve.upper())
|
|
log("Sophos Firewall %s: no advisory row in _SFOS_ADVISORIES, "
|
|
"no verdict for %s", cve, version)
|
|
return None
|
|
fixes, hotfixed = row
|
|
if rel in hotfixed or any(f[:2] == rel[:2] and f <= rel for f in fixes):
|
|
return None
|
|
later = [f for f in fixes if f > rel]
|
|
return ".".join(map(str, min(later))) if later else None
|
|
|
|
|
|
def scan_asset_sophos(db: Session, asset, index: dict,
|
|
new_ids: Optional[list] = None,
|
|
touched: Optional[set] = None) -> int:
|
|
"""Sophos Firewall CVEs from the firmware build on the asset."""
|
|
if not index or not sophos_key(asset.operating_system):
|
|
return 0
|
|
entries = index.get("sophos-firewall") or []
|
|
version = sophos_version(asset.os_version)
|
|
if not entries or not version:
|
|
return 0
|
|
if new_ids is None:
|
|
new_ids = []
|
|
rel = cpe._vtuple(version)[:3]
|
|
|
|
def inside(e):
|
|
start = (e.get("ver") or "").strip().lower()
|
|
if start not in ("", "0", "unspecified", "*"):
|
|
lo = sophos_bound(start)
|
|
if not lo or rel < lo:
|
|
return False
|
|
hi = sophos_bound(e.get("lt") or e.get("lte"))
|
|
return bool(hi) and (rel < hi if e.get("lt") else rel <= hi)
|
|
|
|
by_cve: Dict[str, list] = {}
|
|
for e in entries:
|
|
by_cve.setdefault(e["cve"].upper(), []).append(e)
|
|
count = 0
|
|
still_affected: set = set()
|
|
for cve, ents in by_cve.items():
|
|
if not any(inside(e) for e in ents):
|
|
continue
|
|
fix = sophos_fix(version, cve)
|
|
if not fix:
|
|
continue
|
|
still_affected.add(cve)
|
|
c = {"cve": cve, "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, SOPHOS_FIREWALL_LABEL, asset.os_version,
|
|
c, new_ids, touched=touched, vendor="Sophos")
|
|
count += 1 if len(new_ids) > before else 0
|
|
except Exception as e:
|
|
logger.debug("sophos upsert failed (%s on %s): %s", cve, asset.id, e)
|
|
|
|
_resolve_stale_appliance(db, asset, SOPHOS_FIREWALL_LABEL,
|
|
still_affected | set(touched or ()), asset.os_version,
|
|
tag="sophos")
|
|
return count
|
|
|
|
|
|
# ---------- QNAP QTS / QuTS hero (Netdisco inventory) ----------
|
|
#
|
|
# Netdisco rows: vendor "qnap", os "qts", version "5.2.5.3145"; os "quts hero",
|
|
# version "h5.2.10.3577". Two operating systems with one numbering (release plus
|
|
# a build counter shared across branches), told apart by the leading "h" hero
|
|
# writes and QTS does not. Both sources file them separately: NVD as
|
|
# o:qnap:qts / o:qnap:quts_hero, the CNA as "QTS" / "QuTS hero" blocks on the
|
|
# same record. QNAP's advisory pages attach exactly these CNA records as JSON,
|
|
# so they are not a third source.
|
|
#
|
|
# The CNA states one range per release branch, and the floor in several
|
|
# spellings: "5.2.x", "h5.2.0", "5.0.*", "5.x", "4.5.x, 4.4.x", "?" and
|
|
# "unspecified". The bound carries the build date and sometimes the product
|
|
# name ("QuTS hero h5.2.0.2851 build 20240808"). A missing floor is the trap:
|
|
# CVE-2025-66273 writes "?" < h6.0.0.3397 for the h6.0 branch next to the h5.2
|
|
# and h5.3 ranges, and read literally that puts every patched h5.x box inside.
|
|
# An entry without a floor therefore only applies on the bound's own branch
|
|
# (major.minor), the Aruba rule. The cost is a missed old branch the record
|
|
# left implicit, never a finding on the wrong one.
|
|
QNAP_LABELS = {"qnap-qts": "QNAP QTS", "qnap-quts-hero": "QNAP QuTS hero"}
|
|
|
|
_QNAP_OS_RE = re.compile(r"^\s*qnap\s+(qts|quts\s*hero)\s*$", re.I)
|
|
_QNAP_VER_RE = re.compile(r"^(h?)(\d+\.\d+\.\d+(?:\.\d+)?)(?:\s+build\s+\d+)?$", re.I)
|
|
_QNAP_BOUND_RE = re.compile(
|
|
r"^(?:qnap\s+)?(?:qts\s+|quts\s*hero\s+)?h?(\d+(?:\.\d+){1,3})(?:\s+build\s+\d+)?$", re.I)
|
|
_QNAP_FLOOR_RE = re.compile(r"^h?(\d+(?:\.\d+)*)((?:\.[x*])*)$", re.I)
|
|
|
|
|
|
def qnap_key(os_name: Optional[str]) -> Optional[str]:
|
|
"""Canonical QNAP label → curated product key, or None."""
|
|
m = _QNAP_OS_RE.match(os_name or "")
|
|
if not m:
|
|
return None
|
|
return "qnap-qts" if m.group(1).lower() == "qts" else "qnap-quts-hero"
|
|
|
|
|
|
def qnap_version(raw: Optional[str], hero: bool) -> Optional[str]:
|
|
""""5.2.5.3145" / "h5.2.10.3577" as NVD writes it, or None.
|
|
|
|
The "h" has to agree with the OS: a hero version on a QTS row (or the
|
|
reverse) is a row that contradicts itself, and it gets no verdict.
|
|
"""
|
|
m = _QNAP_VER_RE.match((raw or "").strip())
|
|
if not m or bool(m.group(1)) != hero:
|
|
return None
|
|
return ("h" if hero else "") + m.group(2)
|
|
|
|
|
|
def qnap_bound(raw: Optional[str]) -> Optional[str]:
|
|
"""A CNA bound → its release, "h" and build date dropped, or None for prose."""
|
|
m = _QNAP_BOUND_RE.match((raw or "").strip())
|
|
return m.group(1) if m else None
|
|
|
|
|
|
def qnap_floor(raw: Optional[str]) -> Optional[str]:
|
|
"""A CNA `version` floor → the lowest release it names, or None when unstated."""
|
|
floors = []
|
|
for part in (raw or "").split(","):
|
|
m = _QNAP_FLOOR_RE.match(part.strip())
|
|
if not m:
|
|
return None
|
|
floors.append(m.group(1))
|
|
floors = [f for f in floors if f != "0"]
|
|
return min(floors, key=cpe._vtuple) if floors else None
|
|
|
|
|
|
def qnap_entries(aff: dict, cve_id: str, cvss=None, sev=None, desc=None) -> List[dict]:
|
|
"""The affected ranges of one QTS / QuTS hero block, see above."""
|
|
out = []
|
|
for v in aff.get("versions") or []:
|
|
if not isinstance(v, dict) or (v.get("status") or "affected") != "affected":
|
|
continue
|
|
ver = (v.get("version") or "").strip()
|
|
lt, lte = v.get("lessThan"), v.get("lessThanOrEqual")
|
|
start = None
|
|
if not (lt or lte) and ver.startswith("<"):
|
|
lt = ver[1:] # 2019-2020: "< 4.5.1.1456" in `version`
|
|
else:
|
|
start = qnap_floor(ver)
|
|
lt, lte = qnap_bound(lt) if lt else None, qnap_bound(lte) if lte else None
|
|
if not (lt or lte):
|
|
continue
|
|
out.append({"cve": cve_id, "start": start, "lt": lt, "lte": lte,
|
|
"cvss": cvss, "sev": sev, "desc": desc})
|
|
return out
|
|
|
|
|
|
def scan_asset_qnap(db: Session, asset, index: dict,
|
|
new_ids: Optional[list] = None,
|
|
touched: Optional[set] = None) -> int:
|
|
"""QTS / QuTS hero CVEs from the firmware version on the asset."""
|
|
key = qnap_key(asset.operating_system)
|
|
if not index or not key:
|
|
return 0
|
|
hero = key == "qnap-quts-hero"
|
|
version = qnap_version(asset.os_version, hero)
|
|
entries = index.get(key) or []
|
|
if not entries or not version:
|
|
return 0
|
|
cver = version.lstrip("h")
|
|
branch = aruba_branch(cver)
|
|
label = QNAP_LABELS[key]
|
|
if new_ids is None:
|
|
new_ids = []
|
|
|
|
count = 0
|
|
still_affected: set = set()
|
|
for entry in entries:
|
|
cve = entry["cve"].upper()
|
|
lt, lte = entry.get("lt"), entry.get("lte")
|
|
if cve in still_affected:
|
|
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(cve)
|
|
# Shown the way the box spells its version, so it can be read against
|
|
# the download page: "h5.2.9.3410" on hero.
|
|
pre = "h" if hero else ""
|
|
fixed = f"{pre}{lt}" if lt else (f">{pre}{lte}" if lte else None)
|
|
c = {"cve": 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 version, c, new_ids,
|
|
touched=touched, vendor="QNAP")
|
|
count += 1 if len(new_ids) > before else 0
|
|
except Exception as e:
|
|
logger.debug("qnap upsert failed (%s on %s): %s", cve, asset.id, e)
|
|
|
|
# A NAS reports no software inventory to this path; the NVD-CPE pass runs
|
|
# first and its CVEs are folded in, as for every other firmware line.
|
|
_resolve_stale_appliance(db, asset, label, still_affected | set(touched or ()),
|
|
version, tag="qnap")
|
|
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"
|
|
# 9 and later: "17.0.14" is feature 17, update 14; a bare "24" is the GA build.
|
|
_JAVA_NEW_RE = re.compile(r"^(\d+)(?:\.\d+\.(\d+))?(?:\.\d+)?$")
|
|
# Oracle's installer names start with the word "Java"; Temurin, Corretto,
|
|
# Zulu and Microsoft's OpenJDK builds do not, and they are not Oracle Java SE.
|
|
# Nor are the distros' "java-17-openjdk" packages, hence the space.
|
|
_JAVA_ORACLE_NAME_RE = re.compile(r"^java(?:\(tm\))?\s", re.I)
|
|
_JAVA_NEW_NAME_RE = re.compile(r"\b(\d+\.\d+\.\d+)\b")
|
|
# Labels in a record's version list that mean Java SE itself. Embedded,
|
|
# JRockit and GraalVM share the record and number their own builds.
|
|
_JAVA_SE_LABELS = ("java se", "oracle java se")
|
|
|
|
|
|
def _java_se_versions(versions: list):
|
|
"""The Java SE versions of one affected block → [(entry, version), …].
|
|
|
|
Three shapes: bare ("8u441", "11.0.26"); labelled per entry ("Oracle Java
|
|
SE:17.0.9", "Oracle GraalVM for JDK:17.0.9"); and a labelled list split
|
|
across entries at random ("Java SE: 6u181", "7u171", "10; Java SE
|
|
Embedded: 8u161; JRockit: R28.3.17"). A label holds until the next one.
|
|
"""
|
|
out, label = [], None
|
|
for v in versions:
|
|
if not isinstance(v, dict) or (v.get("status") or "affected") != "affected":
|
|
continue
|
|
for piece in re.split(r"[,;]", v.get("version") or ""):
|
|
if ":" in piece:
|
|
label, piece = (x.strip() for x in piece.split(":", 1))
|
|
label = label.lower()
|
|
piece = piece.strip()
|
|
if label not in (None, *_JAVA_SE_LABELS):
|
|
continue
|
|
if piece and piece not in ("0", "*", "-"):
|
|
out.append((v, piece))
|
|
return out
|
|
|
|
|
|
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))
|
|
# 9+: "Java(TM) SE Development Kit 17.0.9 (64-bit)", version 17.0.9.0.
|
|
if _JAVA_ORACLE_NAME_RE.search(name or ""):
|
|
m = _JAVA_NEW_NAME_RE.search(name)
|
|
return _java_new(m.group(1) if m else (version or "").strip())
|
|
return None
|
|
|
|
|
|
def _java_new(s: str):
|
|
"""'17.0.14' → (17, 14), '24' → (24, 0). 9 and later only."""
|
|
m = _JAVA_NEW_RE.match(s or "")
|
|
if m and int(m.group(1)) >= 9:
|
|
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 _java_new(s)
|
|
|
|
|
|
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 = []
|
|
for cve_id, ents in per_cve.items():
|
|
affected, fix = cws.decide(installed, ents, catalog)
|
|
if affected is None:
|
|
undecided.append(cve_id)
|
|
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: %s [%s %s]", asset.hostname, len(undecided),
|
|
", ".join(sorted(undecided)), name, version)
|
|
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)
|
|
hit: set = set() # one finding per CVE: 8u441 and 8u441-perf
|
|
for entry in index[key]:
|
|
if entry["cve"] in hit or not _java_is_affected(jver, entry):
|
|
continue
|
|
hit.add(entry["cve"])
|
|
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),
|
|
key == "zoom-vdi" and cpe.zoom_plugin(name))
|
|
if dedup in seen:
|
|
continue
|
|
seen.add(dedup)
|
|
if key == "teams" and fam == "macos":
|
|
# The pairs leave "Microsoft Teams for Mac" out, so all this key
|
|
# holds for a Mac is the desktop's ranges: a classic Mac client
|
|
# at 1.6.00.17554 (its own fix) sat below the desktop's 18681.
|
|
# NVD carries the macOS entries.
|
|
continue
|
|
if key.startswith("zoom") and fam not in ("windows", "macos", "linux"):
|
|
continue # Windows for the records, macOS/Linux only for a bulletin line (below)
|
|
if key == "blender" and fam not in ("windows", "macos"):
|
|
continue # distro "4.3.2+dfsg-2" cleans to 4.3.2 and hides backports; Wazuh's
|
|
if key == "teams" and cpe.teams_placeholder_version(version):
|
|
continue # "MSTeams 1.0.0.0" names no build
|
|
strict = key in _STRICT_SCHEME_KEYS
|
|
for entry in index[key]:
|
|
if key.startswith("zoom"):
|
|
if fam != "windows" and not entry.get("zsb"):
|
|
continue # NVD's target_sw places macOS/Linux; some records name no platform
|
|
if entry.get("vdi"):
|
|
if (entry["vdi"] == "plugin") != cpe.zoom_plugin(name):
|
|
continue # the bulletin line names client or plugin
|
|
elif not cpe.zoom_cve_applies(name, entry.get("desc")):
|
|
continue # VDI client vs plugin, see cpe.zoom_cve_applies
|
|
if cpe.zoom_undecided(cver, entry.get("start"), None,
|
|
entry.get("lte"), entry.get("lt")):
|
|
# MSI build inside the fix's own minor, see
|
|
# cpe.zoom_undecided: keep an existing finding, open none.
|
|
if _platform_ok(fam, entry.get("plats")):
|
|
logger.info("cvelistv5: %s %s — no verdict on %s", name,
|
|
version, entry["cve"])
|
|
if touched is not None:
|
|
touched.add(entry["cve"])
|
|
continue
|
|
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")
|