Comments across the codebase credited one individual by role and, in places, described that person's own machines: which SQL Server versions a host ran, which devices were enrolled, what a particular dashboard showed, how many findings sat open on which server. In a public repository that reads as a profile of someone's unpatched estate. The observations are why the code looks the way it does, so they stay. Every CVE id, version, build number, count and date is preserved, as are the verbatim quotes that motivated specific sort and filter rules — only the attribution changes, to "field report", "observed", "a host". A local variable in tests/test_autodesk_year.py was renamed for the same reason; its value and every assertion around it are byte-identical. PROJECT_OVERVIEW.md additionally loses a subtitle naming the kind of organisation this was built for, and a support section pointing at an internal team, both replaced with neutral wording. Comments, docstrings and markdown prose only: 74 files, 200 lines, one-for-one swaps. detect_changes reports 104 touched symbols and zero affected execution flows, and all 55 test scripts pass. Nothing here needs re-testing.
1673 lines
84 KiB
Python
1673 lines
84 KiB
Python
"""
|
|
Built-in app→CVE scanner.
|
|
|
|
Maps installed software (Wazuh syscollector packages + Intune detectedApps)
|
|
to CVEs so assets without a real scanner (Intune-only / mobile, or any app
|
|
no scanner covers) still get findings.
|
|
|
|
Design: CURATED + PRECISE (low false-positives).
|
|
- Only a curated product registry is matched — unknown app names are
|
|
ignored (no CPE auto-guessing → no FP storm).
|
|
- Per product: query OSV (language/OSS ecosystems, server-side version
|
|
match) or NVD-CPE (desktop apps), then verify the installed version
|
|
actually falls inside the CVE's affected version range ourselves.
|
|
- Results cached per (product_key, version) in app_cve_cache (TTL) so the
|
|
same Chrome version across N hosts = one query (and NVD rate limit).
|
|
- Findings are upserted as source 'app-scan' with REAL CVE ids → the
|
|
normal EPSS/KEV/CVSS enrichment + multi-source remediation apply, and
|
|
they cross-confirm with Wazuh/Nessus/Defender on the same (cve, asset).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import time
|
|
from datetime import datetime, timedelta
|
|
from typing import Dict, List, Optional, Tuple
|
|
|
|
import httpx
|
|
from sqlalchemy.orm import Session
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
OSV_QUERY_URL = "https://api.osv.dev/v1/query"
|
|
NVD_CVE_API = "https://services.nvd.nist.gov/rest/json/cves/2.0"
|
|
HTTP_TIMEOUT = 30.0
|
|
# Was 7 days — but a (product,version) queried BEFORE a new CVE for that
|
|
# exact version is published stays cached empty for the whole window,
|
|
# hiding the CVE from every host on that version until it expires (observed:
|
|
# CVE-2026-14152 undetected while a same-day sibling CVE was). 24h still
|
|
# collapses most redundant NVD/OSV traffic (many hosts share a version).
|
|
CACHE_TTL = timedelta(hours=24)
|
|
NVD_SLEEP_NO_KEY = 6.5
|
|
NVD_SLEEP_WITH_KEY = 0.7
|
|
|
|
# Curated registry: (compiled name regex, entry). First match wins.
|
|
# entry: {"key", "kind": "cpe"|"osv", "cpe"? "a:vendor:product", "eco"?, "oname"?}
|
|
def _cpe(name_re: str, vendor_product: str, name_ver: bool = False,
|
|
also: Optional[List[str]] = None, pkg_ver: bool = False) -> tuple:
|
|
e = {"key": f"cpe:{vendor_product}", "kind": "cpe",
|
|
"cpe": f"cpe:2.3:a:{vendor_product}"}
|
|
if pkg_ver:
|
|
# Product shipped as an rpm/deb, so the version carries a packaging
|
|
# release suffix ("4.14.5-1") that _clean_version rejects outright —
|
|
# that rule exists to keep distro packages out, and it also kept out
|
|
# vendor software that simply ships that way. Trim the suffix; what
|
|
# remains is the upstream version the advisories talk about.
|
|
e["pkg_ver"] = True
|
|
if also:
|
|
# A product NVD files under more than one CPE name. Adobe is the case
|
|
# that surfaced it: the same Reader install is described by
|
|
# adobe:acrobat_reader_dc AND adobe:acrobat_reader, and the two carry
|
|
# DIFFERENT CVEs — querying one name silently loses the other's.
|
|
e["also"] = [f"cpe:2.3:a:{vp}" for vp in also]
|
|
if name_ver:
|
|
# Take the version from the software NAME, not the package version
|
|
# field. Some products (e.g. .NET Runtime) report an MSI build number
|
|
# (48.x, 94.x) in the version field while the real semantic version
|
|
# (6.0.16) lives in the display name — the field version never matches
|
|
# NVD's 6.0.x/8.0.x ranges, so the CVE was silently missed.
|
|
e["name_ver"] = True
|
|
return (re.compile(name_re, re.I), e)
|
|
|
|
|
|
def _year(name_re: str, vendor_product: str) -> tuple:
|
|
rx, e = _cpe(name_re, vendor_product)
|
|
e["year_ver"] = True # release year from the NAME, not the build number
|
|
return (rx, e)
|
|
|
|
|
|
def _sap(name_re: str, vendor_product: str) -> tuple:
|
|
rx, e = _cpe(name_re, vendor_product)
|
|
e["sap"] = True # match by patch level, not by version range
|
|
return (rx, e)
|
|
|
|
|
|
_REGISTRY: List[tuple] = [
|
|
# "Google Chrome" (Wazuh/Windows) + "com.android.chrome" (Intune Android
|
|
# package id). Android Chrome shares version numbers AND security fixes with
|
|
# Desktop (Google: "Android releases contain the same security fixes"), so
|
|
# the same google:chrome ranges apply. iOS Chrome is WebKit-backed and
|
|
# reported as bare "Chrome" — deliberately NOT matched (Blink CVEs N/A).
|
|
_cpe(r"google chrome|com\.android\.chrome", "google:chrome"),
|
|
_cpe(r"microsoft edge(?!.*webview)", "microsoft:edge_chromium"),
|
|
# Desktop + Android ("org.mozilla.firefox"), which share Gecko, version
|
|
# numbers and security fixes — NVD keeps them under one CPE with a neutral
|
|
# target_sw, so no separate entry is needed. iOS Firefox is EXCLUDED for
|
|
# the same reason as iOS Chrome: Apple mandates WebKit there, so the Gecko
|
|
# CVEs behind these ranges do not apply to it.
|
|
_cpe(r"(mozilla firefox|(?<!\w)firefox)(?!.*\bios\b)(?<!ios\.firefox)",
|
|
"mozilla:firefox"),
|
|
_cpe(r"thunderbird", "mozilla:thunderbird"),
|
|
# Adobe renamed these products and NVD kept BOTH CPE names in use, each
|
|
# carrying its own CVEs — the "_dc" suffix is the older spelling. Wazuh
|
|
# only detects ancient Reader versions, so this path is the one that has to
|
|
# be complete.
|
|
# ANCHORED: the product name must START the string. Add-ons carry it in
|
|
# the middle — "Asian Language And Spelling Dictionaries Support For Adobe
|
|
# Acrobat Reader" is a dictionary pack, and its own version (23.008.20421)
|
|
# looks exactly like an old Reader build, so an unanchored match reported
|
|
# every Reader CVE against it (observed: CVE-2026-48373 eight times over).
|
|
_cpe(r"^(adobe )?acrobat reader(?!.*(language pack|dictionar|spelling|font pack))",
|
|
"adobe:acrobat_reader_dc",
|
|
also=["adobe:acrobat_reader"]),
|
|
_cpe(r"^(adobe )?acrobat(?!.*(reader|language pack|dictionar|spelling|font pack))",
|
|
"adobe:acrobat_dc",
|
|
also=["adobe:acrobat"]),
|
|
# Creative-Cloud desktop apps. The inventory name carries a YEAR
|
|
# ("Adobe Illustrator 2026") that the CVE records never use — the real
|
|
# version lives in the version field (30.1), so the year is ignored here
|
|
# rather than parsed. Anchored, and helper components are excluded:
|
|
# AdobeNotificationClient, Adobe Refresh Manager and AdobeAcrobatDCCoreApp
|
|
# are not the products and carry their own unrelated versions.
|
|
_cpe(r"^adobe illustrator\b", "adobe:illustrator"),
|
|
_cpe(r"^adobe photoshop\b", "adobe:photoshop"),
|
|
_cpe(r"^adobe indesign\b", "adobe:indesign"),
|
|
_cpe(r"^adobe bridge\b", "adobe:bridge"),
|
|
# Autodesk. Versioned by release YEAR, which is how NVD lists them too
|
|
# (autodesk:autocad:2018) — but the inventory carries the internal build
|
|
# (AutoCAD LT 2026 = 25.1.60.0), so the year has to come from the name.
|
|
# LT and full AutoCAD are separate CPE products.
|
|
# The companions are NOT the application and are excluded by anchoring:
|
|
# Autodesk Access, CER, Genuine Service, Identity Manager, "AutoCAD Open
|
|
# in Desktop" — all with their own unrelated versions.
|
|
# Language packs ship as separate entries with the SAME version as the
|
|
# application ("AutoCAD LT 2022 Language Pack - Deutsch", 24.1.51.0;
|
|
# Navisworks Freedom 2025 has twelve of them). Each would collect the full
|
|
# CVE set of the product it belongs to — the Adobe dictionary trap again.
|
|
_year(r"^(autodesk )?autocad lt(?!.*language pack)", "autodesk:autocad_lt"),
|
|
_year(r"^(autodesk )?autocad(?! lt| open in)(?!.*language pack)\b", "autodesk:autocad"),
|
|
_year(r"^autodesk dwg trueview(?!.*language pack)", "autodesk:dwg_trueview"),
|
|
_year(r"^autodesk navisworks freedom(?!.*(language pack|module linguistique|"
|
|
r"pacote de idioma|paquete de idioma|localizatsi))", "autodesk:navisworks_freedom"),
|
|
# VMware Tools on Windows. NVD has a CPE for only a fraction of these
|
|
# (CVE-2025-41244 yes, most others "NOT SCHEDULED"), so this path is the
|
|
# smaller half — cvelistV5 carries the rest. open_vm_tools is the Linux
|
|
# package and a separate product; not queried from the Windows name.
|
|
_cpe(r"^vmware tools", "vmware:tools"),
|
|
_cpe(r"7-?zip", "7-zip:7-zip"),
|
|
_cpe(r"notepad\+\+", "notepad-plus-plus:notepad-plus-plus"),
|
|
_cpe(r"vlc media player|videolan", "videolan:vlc_media_player"),
|
|
_cpe(r"(?<!\w)putty", "putty:putty"),
|
|
_cpe(r"winscp", "winscp:winscp"),
|
|
_cpe(r"wireshark", "wireshark:wireshark"),
|
|
# Exclude the FIPS provider/module builds (e.g. Veeam ships "OpenSSL v3.0.0
|
|
# FIPS"): OpenSSL advisories explicitly carve the FIPS modules OUT of most
|
|
# CVEs (the vulnerable code is outside the FIPS boundary), and they carry a
|
|
# separate 4-part build version that doesn't map to NVD's ranges anyway →
|
|
# matching them is a false positive (observed: CVE-2025-15467).
|
|
_cpe(r"openssl(?!.*fips)", "openssl:openssl"),
|
|
_cpe(r"openvpn", "openvpn:openvpn"),
|
|
_cpe(r"node\.?js", "nodejs:node.js"),
|
|
# Wazuh's own components. The scanner watches every other product on these
|
|
# hosts and was blind to the one doing the watching — 54 CVEs at NVD, some
|
|
# of them remote code execution on the manager. Shipped as rpm/deb, so the
|
|
# version arrives as "4.14.5-1" (see pkg_ver). NVD files nearly all of them
|
|
# under wazuh:wazuh; the dashboard and the old Kibana app have their own
|
|
# names and carry different CVEs, hence `also`.
|
|
_cpe(r"^wazuh[- ](agent|manager|server)(?![-\w])", "wazuh:wazuh", pkg_ver=True),
|
|
_cpe(r"^wazuh[- ]dashboard(?![-\w])", "wazuh:wazuh-dashboard", pkg_ver=True,
|
|
also=["wazuh:wazuh-kibana-app", "wazuh:wazuh"]),
|
|
_cpe(r"^wazuh[- ]indexer(?![-\w])", "wazuh:wazuh", pkg_ver=True),
|
|
# Windows Python reports an MSI BUILD in the version field ("Python 3.13.7
|
|
# (64-bit)" → version 3.13.7150.0) while the semantic version lives in the
|
|
# NAME. Matching the build number against NVD's 3.13.x ranges finds nothing,
|
|
# so real Python CVEs were silently missed (seen: CVE-2025-12781 et al.).
|
|
# The name must END at the interpreter (optionally with its release, as
|
|
# "python3" / "python3.11" / "Python 3.13.7"): a trailing "-" or letter
|
|
# means a PyPI/distro MODULE, not CPython — "python-dotenv 1.1.1" was read
|
|
# as Python 1.1.1 and flagged with every CPython CVE up to 2.7.15
|
|
# (observed: CVE-2017-1000158). Same for python-dateutil, python3-pip,
|
|
# pythonnet — each its own product with its own version line.
|
|
_cpe(r"(?<!\w)python[\d.]*(?![-\w.])(?!.*launcher)", "python:python",
|
|
name_ver=True),
|
|
# Vim and VS Code. The CURRENT CVEs for both carry no NVD data at all and
|
|
# are matched via cvelistV5 instead (registry entries there). These two
|
|
# rows are the other half: NVD did enrich the older ones — including every
|
|
# pre-2020 Vim record, whose CNA block says vendor "n/a" and so is
|
|
# unmatchable on the cvelistV5 side — and enriches the fresh ones weeks
|
|
# later, which then cross-confirms the finding.
|
|
# Anchored: Neovim is its own product, and "Visual Studio Community 2022"
|
|
# is a different product with a different version line (17.x).
|
|
# Apache Tomcat. NVD enriches the ASF records well, so this is the primary
|
|
# source here and cvelistV5 covers the fresh ones it has not reached yet.
|
|
# Same exclusions as there: mod_jk, Tomcat Native and TomEE are their own
|
|
# products on their own version lines.
|
|
_cpe(r"^(apache )?tomcat\b(?!.*(connector|native|jk|tomee))", "apache:tomcat"),
|
|
_cpe(r"^g?vim\b", "vim:vim"),
|
|
_cpe(r"^(microsoft )?visual studio code\b", "microsoft:visual_studio_code"),
|
|
_cpe(r"teamviewer", "teamviewer:teamviewer"),
|
|
_cpe(r"(?<!\w)zoom(?!\w)", "zoom:zoom"),
|
|
_cpe(r"libreoffice", "libreoffice:libreoffice"),
|
|
_cpe(r"filezilla", "filezilla:filezilla"),
|
|
_cpe(r"(?<!\w)git(?: for windows| version control)", "git:git"),
|
|
_cpe(r"oracle vm virtualbox|virtualbox", "oracle:vm_virtualbox"),
|
|
_cpe(r"(?<!\w)gimp(?!\w)", "gimp:gimp"),
|
|
# .NET (6/7/8/9) and .NET Framework share the SAME NVD CPE
|
|
# (microsoft:.net) — NVD scopes each major version's fix range via its own
|
|
# cpeMatch entry under that one product, so one registry row covers both.
|
|
# The semantic version lives in the NAME ("… - 6.0.16 (x64)"), not the
|
|
# version field (an MSI build number) → name_ver=True. The developer
|
|
# reference packs (Targeting Pack / Multi-Targeting Pack / Framework SDK)
|
|
# are NOT the runtime; excluded via lookahead so their version doesn't
|
|
# false-positive against a runtime CVE range. (Bare ".NET Framework" the
|
|
# runtime is a Windows feature, often not a syscollector package anyway.)
|
|
_cpe(r"microsoft\s+\.net\s+(?:desktop\s+)?(?:runtime|host|sdk)\b"
|
|
r"|microsoft\s+\.net\s+framework(?!.*(?:targeting|sdk|client))\s+[\d.]+",
|
|
"microsoft:.net", name_ver=True),
|
|
# Only Teams itself — NOT the Office add-in, the VDI/Citrix plugin, or the
|
|
# machine-wide installer (separate products with their own versioning that
|
|
# would false-positive against Teams-app CVE ranges).
|
|
_cpe(r"^microsoft teams(?!.*(machine-wide|add-in|plugin|vdi|citrix))", "microsoft:teams"),
|
|
# Exchange Server Subscription Edition — a full blind spot in Wazuh
|
|
# (wazuh/wazuh#36200). ANCHORED and exact on purpose: the same host also
|
|
# carries "Microsoft Exchange Server" and a dozen "… Language Pack - X"
|
|
# entries, all still on the RTM build, plus "Hotfix Update for Exchange
|
|
# Server Subscription Edition (KB5066373)" whose version field is the
|
|
# literal "1" — which would compare below every fix build and flag the host
|
|
# forever. Only the product entry itself carries the real build.
|
|
_cpe(r"^microsoft exchange server subscription edition$",
|
|
"microsoft:exchange_server_subscription_edition"),
|
|
# Checkmk agent. Wazuh does not detect it (wazuh/wazuh#35646), and its
|
|
# inline patch numbering (2.4.0p12) is what kept every path away.
|
|
_cpe(r"checkmk agent|check_mk agent|checkmk(?!.*server)", "checkmk:checkmk"),
|
|
# SAP desktop clients. cvelistV5 carries no CPE for these at all, so NVD is
|
|
# the only usable source (Wazuh misses them too — wazuh/wazuh#30334). SAP
|
|
# states the patch level in the CPE's UPDATE field, one entry per level, so
|
|
# these are matched by patch level instead of by range — see _sap_affected.
|
|
# Without a readable level nothing is reported: the base release alone
|
|
# matches every CVE ever filed against it.
|
|
_sap(r"sap gui for windows|sap\s+gui(?!.*java)", "sap:gui_for_windows"),
|
|
_sap(r"sap business client", "sap:business_client"),
|
|
# Analysis for Microsoft Office is deliberately NOT here. It carries no
|
|
# patch level at all — it ships 2.8.x builds — so the patch-level match
|
|
# can never fire for it, and NVD states the affected version as a bare
|
|
# `…:analysis_for_microsoft_office:2.8:*:*` with no range, which would call
|
|
# every 2.8 build affected forever. cvelistV5 has the honest bound
|
|
# ("< 2.8", CVE-2021-38175), but the cvelistV5 path carries no SAP pairs
|
|
# yet. Until it does, this product stays out rather than guessing.
|
|
# OSV ecosystem examples (rarely in desktop inventory, but supported):
|
|
(re.compile(r"^node-(.+)$", re.I), {"key": "osv:npm", "kind": "osv", "eco": "npm"}),
|
|
]
|
|
|
|
|
|
# OS-level CPEs. Apple ships the precise OS version (e.g. 18.1.2) and NVD
|
|
# carries proper version ranges for it → a clean CPE-range check, same as the
|
|
# desktop apps. Android is deliberately absent: NVD only lists the base
|
|
# version (13/14/15) without ranges, so it needs the Intune security-patch
|
|
# level + Android bulletin parsing — a separate feature.
|
|
# ponytail: iOS/iPadOS only; add Android when the patch-level path is built.
|
|
# Windows OS CVEs are handled by cvelistv5_scan_service.scan_asset_os(), not
|
|
# from here. Modern Microsoft CVE records DO carry real build ranges (e.g.
|
|
# CVE-2026-47291: 20/20 affected entries with a numeric lessThan) — but NVD
|
|
# flattens them to an END bound only (versionEndExcluding, no start), so a
|
|
# 1607 host (14393.x) would fall inside the 22H2 range (endExcluding
|
|
# 19045.7417) and false-positive. cvelistV5 keeps the range BOUNDED
|
|
# (version 10.0.22631.0 .. lessThan 10.0.22631.7219), and those bounds pick the
|
|
# host's release on their own — which is why the OS path lives there.
|
|
_OS_REGISTRY: List[tuple] = [
|
|
(re.compile(r"ipad", re.I), {"key": "cpe:apple:ipados",
|
|
"cpe": "cpe:2.3:o:apple:ipados", "label": "Apple iPadOS"}),
|
|
(re.compile(r"ios|iphone", re.I), {"key": "cpe:apple:iphone_os",
|
|
"cpe": "cpe:2.3:o:apple:iphone_os", "label": "Apple iOS"}),
|
|
(re.compile(r"mac ?os|macos|mac_os|os x", re.I), {"key": "cpe:apple:macos",
|
|
"cpe": "cpe:2.3:o:apple:macos", "label": "Apple macOS"}),
|
|
]
|
|
|
|
|
|
_NAME_VER_RE = re.compile(r"(\d+\.\d+(?:\.\d+)*)")
|
|
# Release YEAR in a product name — "AutoCAD LT 2026 - Deutsch (German)".
|
|
_NAME_YEAR_RE = re.compile(r"\b(19|20)(\d{2})\b")
|
|
|
|
# AutoCAD-family Product Version (Control Panel) → the RELEASE the advisories
|
|
# bound by. Keyed by the build's leading "major.minor", which names the release
|
|
# year on its own (25.1 = 2026, 26.0 = 2027, …); the rows are the third
|
|
# component, ascending.
|
|
#
|
|
# The year alone is not enough. Autodesk states the fix as a release
|
|
# ("2026.0.0 .. <2026.1.2", ADSK-SA-2026-0012 / CVE-2026-7405) and a bare
|
|
# "2026" sits below every 2026.x bound forever — a host patched to 2026.1.2
|
|
# keeps every 2026 finding it ever had, with no version it could reach to clear
|
|
# them. Seven autocad_lt CVEs at NVD today, three of them with a bare-year CPE
|
|
# and no range at all.
|
|
#
|
|
# Source (the German page is the fresher one — the English article still stops
|
|
# at 2026.1.1 / 2025.1.3):
|
|
# https://www.autodesk.com/de/support/technical/article/caas/sfdcarticles/sfdcarticles/DEU/How-to-tie-the-Product-Version-or-Build-number-with-the-AutoCAD-update.html
|
|
# ponytail: hand-maintained; refresh when an update ships. A build NEWER than
|
|
# the last row resolves to that row — over-reporting, never hiding, which is
|
|
# the safe direction for a scanner. Autodesk's own 2023 rows are out of order
|
|
# (2023.1.1 = 24.2.153.0 but 2023.1.2 = 24.2.116.0); kept verbatim, the lookup
|
|
# is per-build so each host still lands on its own row.
|
|
_ACAD_BUILDS = {
|
|
"26.0": ("2027", ((60, "2027.0.0"),)),
|
|
"25.1": ("2026", ((60, "2026.0.0"), (74, "2026.0.1"), (122, "2026.1"),
|
|
(164, "2026.1.1"), (172, "2026.1.2"))),
|
|
"25.0": ("2025", ((58, "2025.0.0"), (72, "2025.0.1"), (116, "2025.1"),
|
|
(154, "2025.1.1"), (162, "2025.1.2"), (171, "2025.1.3"),
|
|
(181, "2025.1.4"))),
|
|
"24.3": ("2024", ((61, "2024.0.0"), (71, "2024.0.1"), (119, "2024.1"),
|
|
(151, "2024.1.1"), (152, "2024.1.2"), (171, "2024.1.3"),
|
|
(181, "2024.1.4"), (191, "2024.1.5"), (203, "2024.1.6"),
|
|
(212, "2024.1.7"), (222, "2024.1.8"), (231, "2024.1.9"))),
|
|
"24.2": ("2023", ((53, "2023.0.0"), (72, "2023.0.1"), (114, "2023.1"),
|
|
(116, "2023.1.2"), (153, "2023.1.1"), (172, "2023.1.3"),
|
|
(181, "2023.1.4"), (192, "2023.1.5"), (201, "2023.1.6"),
|
|
(212, "2023.1.7"), (222, "2023.1.8"))),
|
|
"24.1": ("2022", ((51, "2022.0.0"), (74, "2022.0.1"), (113, "2022.1"),
|
|
(154, "2022.1.1"), (162, "2022.1.2"), (173, "2022.1.3"),
|
|
(182, "2022.1.4"), (191, "2022.1.5"), (203, "2022.1.6"))),
|
|
}
|
|
_ACAD_BUILD_RE = re.compile(r"(\d+\.\d+)\.(\d+)")
|
|
|
|
|
|
def _acad_release(name_year: str, version: str) -> Optional[str]:
|
|
"""AutoCAD-family build number → its release, or None.
|
|
|
|
The build's "major.minor" has to agree with the year in the NAME. That
|
|
single check is what keeps the table off products that are versioned some
|
|
other way — Navisworks Freedom 2025 ships 22.0.1411.23, whose 22.0 prefix
|
|
belongs to no AutoCAD release at all, so it falls back to the bare year.
|
|
"""
|
|
m = _ACAD_BUILD_RE.match((version or "").strip())
|
|
if not m:
|
|
return None
|
|
year, rows = _ACAD_BUILDS.get(m.group(1), (None, ()))
|
|
if year != name_year:
|
|
return None
|
|
build = int(m.group(2))
|
|
hits = [rel for lo, rel in rows if build >= lo]
|
|
return hits[-1] if hits else None
|
|
|
|
|
|
def _effective_version(name: str, version: str, entry: dict) -> str:
|
|
"""Version to scan with. For name_ver products the real semantic version
|
|
is in the display name, not the (MSI-build) version field."""
|
|
if entry.get("year_ver"):
|
|
# Autodesk versions its releases by YEAR and NVD follows suit
|
|
# (autodesk:autocad:2018), but the inventory reports the internal
|
|
# build — AutoCAD LT 2026 installs as 25.1.60.0. Comparing that
|
|
# against a year matches nothing, which is why these were invisible.
|
|
# The year is in the name; without one there is nothing to scan.
|
|
m = _NAME_YEAR_RE.search(name or "")
|
|
if not m:
|
|
return ""
|
|
# …and the year alone can never clear a patched host, so where the
|
|
# build maps to a known release, scan with that instead (see
|
|
# _ACAD_BUILDS).
|
|
return _acad_release(m.group(0), version) or m.group(0)
|
|
if entry.get("name_ver"):
|
|
m = _NAME_VER_RE.search(name or "")
|
|
if m:
|
|
return m.group(1)
|
|
if entry.get("pkg_ver"):
|
|
return re.split(r"[-_]", (version or "").strip(), 1)[0]
|
|
return version
|
|
|
|
|
|
def _resolve_os(os_name: str) -> Optional[dict]:
|
|
n = (os_name or "").lower()
|
|
for rx, e in _OS_REGISTRY:
|
|
if rx.search(n):
|
|
return e
|
|
return None
|
|
|
|
|
|
def _os_family(os_name: str) -> Optional[str]:
|
|
"""Map an asset OS string to a CPE target_sw token."""
|
|
n = (os_name or "").lower()
|
|
if "windows" in n:
|
|
return "windows"
|
|
if "ipad" in n:
|
|
return "ipados"
|
|
if "iphone" in n or "ios" in n:
|
|
return "iphone_os"
|
|
if "android" in n:
|
|
return "android"
|
|
if "mac" in n or "darwin" in n or "os x" in n:
|
|
return "macos"
|
|
if any(x in n for x in ("linux", "ubuntu", "debian", "centos", "red hat",
|
|
"rhel", "fedora", "suse", "alma", "rocky")):
|
|
return "linux"
|
|
return None
|
|
|
|
|
|
def _platform_ok(tsws, plat: Optional[str]) -> bool:
|
|
"""A CVE's matching cpeMatch target_sw must be platform-neutral (*) or
|
|
name the asset's OS. Stops desktop Firefox-on-Windows matching the
|
|
Firefox-for-iOS CPE (target_sw=iphone_os) and similar cross-platform FPs.
|
|
"""
|
|
if not tsws:
|
|
return True # unknown (e.g. pre-fix cache) → keep
|
|
if any(t in ("*", "-", "") for t in tsws):
|
|
return True
|
|
return bool(plat) and plat in tsws
|
|
|
|
|
|
# "<something> for <product>" names a companion, not the product. Backup tools,
|
|
# plugins and connectors are all built this way: "Veeam Explorer for Microsoft
|
|
# Teams" is Veeam's software, and its 13.3.2.3 has nothing to do with Teams'
|
|
# 25122.x — four Teams CVEs were seen landing on it. The same shape produced
|
|
# Chrome findings on "Citrix Workspace app for Google Chrome" and Firefox
|
|
# findings on "Kaspersky Plugin for Mozilla Firefox".
|
|
#
|
|
# The product being described always stands FIRST, so only that part is matched
|
|
# against the registry. Names where the product itself carries the word — a
|
|
# hypothetical "Microsoft Teams for Desktop" — still resolve, because what
|
|
# precedes "for" is the real product.
|
|
_COMPANION_RE = re.compile(
|
|
r"\s+(?:plug-?in|add-?in|add-?on|extension|connector|agent|module|"
|
|
r"integration|explorer|backup|app)?\s*\bfor\b\s+", re.I)
|
|
|
|
|
|
def companion_prefix(name: str) -> str:
|
|
"""The product part of a "<x> for <y>" name, or the name unchanged."""
|
|
parts = _COMPANION_RE.split(name or "", maxsplit=1)
|
|
head = (parts[0] or "").strip()
|
|
return head if len(parts) > 1 and head else (name or "")
|
|
|
|
|
|
# Manager nodes are assets without an agent; the id marks them so the scan
|
|
# knows not to ask syscollector about them.
|
|
NODE_AGENT_PREFIX = "node:"
|
|
|
|
|
|
def node_inventory(asset) -> List[dict]:
|
|
"""The one package a Wazuh manager node has: itself."""
|
|
# The API reports "v4.14.1" on some builds. The sync strips the v, but an
|
|
# asset synced before that — or by any other path — still carries it, and
|
|
# _clean_version rejects the whole string over that one letter, so the node
|
|
# silently went unscanned. Strip it here too rather than depend on the
|
|
# stored value being clean.
|
|
ver = (getattr(asset, "os_version", "") or "").strip().lstrip("vV")
|
|
if not ver:
|
|
return []
|
|
node_type = (getattr(asset, "operating_system", "") or "").lower()
|
|
name = "wazuh-server" if "worker" in node_type or "master" in node_type else "wazuh-manager"
|
|
return [{"name": name, "version": ver, "vendor": "Wazuh"}]
|
|
|
|
|
|
def resolve_product(name: str) -> Optional[dict]:
|
|
n = (name or "").strip().lower()
|
|
if not n:
|
|
return None
|
|
# Matching runs on the FULL name so every exclusion in the registry still
|
|
# sees what it was written against — "Firefox for iOS" is rejected by the
|
|
# Firefox entry itself (WebKit, different CVEs), and trimming the name
|
|
# first would have hidden that from it. The prefix only decides WHERE the
|
|
# match landed: in the product, or in the "…for <product>" tail.
|
|
head = companion_prefix(n)
|
|
for rx, entry in _REGISTRY:
|
|
m = rx.search(n)
|
|
if m:
|
|
if head != n and not rx.search(head):
|
|
continue # companion product — see companion_prefix
|
|
if entry.get("key") == "cpe:mozilla:firefox" and re.search(r"\besr\b", n):
|
|
# ESR install: the plain mozilla:firefox CPE / cvelistV5 ranges
|
|
# describe the release train, not ESR (NVD tracks ESR as its
|
|
# own firefox_esr CPE) — matching produces FPs on CVEs whose
|
|
# MFSA advisory has no ESR fix. See cvelistv5 resolve().
|
|
return None
|
|
e = dict(entry)
|
|
if e["kind"] == "osv" and not e.get("oname"):
|
|
# derive package name from the capture group when present
|
|
e["oname"] = (m.group(1) if m.groups() else n)
|
|
e["key"] = f"osv:{e['eco']}:{e['oname']}"
|
|
return e
|
|
return None
|
|
|
|
|
|
class _TransientNVD(Exception):
|
|
"""NVD was unreachable/throttled — caller must NOT cache the empty result."""
|
|
|
|
|
|
# ---------- version compare ----------
|
|
def _clean_version(v: str) -> Optional[str]:
|
|
"""Desktop version core, or None for distro/rpm/deb-style versions.
|
|
|
|
Linux package versions (epoch `1:3.2`, release tags `4.6.5-3.el8`,
|
|
`2.43.0.windows.1`) are Wazuh's job — pushing them at NVD produces
|
|
noise (503 storms) and false-positives. Only clean dotted-numeric
|
|
versions go to the scanner; a trailing build like `7.0.2 (34567)` is
|
|
trimmed to its core.
|
|
# ponytail: dotted-numeric only; if a real product ever uses a hyphenated
|
|
# version we want, special-case it then, not now.
|
|
"""
|
|
v = (v or "").strip()
|
|
if not v or ":" in v:
|
|
return None
|
|
core = re.split(r"[ (]", v, 1)[0]
|
|
# Checkmk numbers its patches inline — 2.4.0p12 — and both NVD and
|
|
# cvelistV5 state their bounds the same way (lessThan "2.4.0p13"). The
|
|
# dotted-numeric rule threw the whole version away, so the agent was
|
|
# invisible to every path. _vtuple already reads it as (2,4,0,12), so
|
|
# letting it through is all that was missing.
|
|
return core if re.fullmatch(r"\d+(\.\d+)*(p\d+)?", core) else None
|
|
|
|
|
|
# Adobe renamed its version scheme mid-life: Acrobat/Reader DC shipped as
|
|
# 2019.010.20098 (four-digit year) until the 2020 release, then dropped the
|
|
# century — 20.001.30005, and today 26.001.21771. Both spellings mean the same
|
|
# build, and the sources disagree on which to use: NVD states the bound as
|
|
# 19.010.20098, cvelistV5 as 2019.010.20098 (wazuh#29960 reports the same
|
|
# split). Compared as plain numbers 26 < 2019, so every current Acrobat fell
|
|
# inside every pre-2020 Adobe CVE — CVE-2019-7819 was open on 18 hosts.
|
|
#
|
|
# The pattern is narrow enough to be self-identifying: three parts, a 2000-2099
|
|
# leader, a three-digit track and a four/five-digit build. Autodesk's 2026 or
|
|
# 2026.0.0 does not match it. And because the rewrite keeps the ordering inside
|
|
# the old scheme (2019 < 2020 stays 19 < 20) and runs on BOTH sides of every
|
|
# comparison, it can only ever change the outcome where the two schemes meet —
|
|
# which is exactly the case we are fixing.
|
|
_ADOBE_YEAR_RE = re.compile(r"20(\d\d)\.(\d{3})\.(\d{4,5})$")
|
|
|
|
|
|
_HASH_RE = re.compile(r"^[0-9a-f]{7,40}$", re.I)
|
|
|
|
|
|
def is_version(v) -> bool:
|
|
"""False for a git commit hash used where a version belongs.
|
|
|
|
Some CNAs bound a flaw with the commit that fixed it — Wazuh writes
|
|
CVE-2026-67308 as "before 44bf114d2f49". Read as digits that becomes
|
|
(44, 114, 2, 49), which every 4.x install sits below, so it matched
|
|
everything. A hash is hex, seven characters or more, no separator;
|
|
versions that carry letters keep theirs, because "2.4.0p12" and
|
|
"5.0.0-beta1" both have separators and all-digit builds are versions.
|
|
"""
|
|
t = str(v or "").strip()
|
|
if not t:
|
|
return False
|
|
if "." in t or "-" in t or "_" in t or t.isdigit():
|
|
return True
|
|
return not _HASH_RE.match(t)
|
|
|
|
|
|
def _vtuple(v: str) -> Optional[tuple]:
|
|
if not v:
|
|
return None
|
|
m = _ADOBE_YEAR_RE.fullmatch(v.strip())
|
|
if m:
|
|
return tuple(int(x) for x in m.groups())
|
|
nums = re.findall(r"\d+", v)
|
|
if not nums:
|
|
return None
|
|
return tuple(int(x) for x in nums[:6])
|
|
|
|
|
|
def _vcmp(a: str, b: str) -> Optional[int]:
|
|
"""-1/0/1, or None when not comparable."""
|
|
ta, tb = _vtuple(a), _vtuple(b)
|
|
if ta is None or tb is None:
|
|
return None
|
|
n = max(len(ta), len(tb))
|
|
ta += (0,) * (n - len(ta))
|
|
tb += (0,) * (n - len(tb))
|
|
return (ta > tb) - (ta < tb)
|
|
|
|
|
|
# SAP states the patch level in the CPE's UPDATE field, enumerated one entry
|
|
# per level (cpe:2.3:a:sap:gui_for_windows:7.70:patch_level4), never as a
|
|
# range — so "affected" is set membership, not a comparison. Verified against
|
|
# CVE-2023-32113 (15 entries) and CVE-2021-38150 (68).
|
|
_SAP_PL_RE = re.compile(r"(?:\bpl|\bpatch(?:\s*level)?)\s*[-_]?\s*(\d+)", re.I)
|
|
_SAP_CPE_PL_RE = re.compile(r"^patch_level(\d+)$", re.I)
|
|
|
|
|
|
def _sap_patch_level(name: str, version: str) -> Optional[int]:
|
|
"""Patch level from the inventory strings, or None if it isn't stated.
|
|
|
|
None means "we cannot tell", and the caller must then report NOTHING: the
|
|
base version alone (7.70) appears in every CVE ever filed against that
|
|
release, so treating an unknown level as affected turns one unreadable
|
|
string into a full page of false positives.
|
|
"""
|
|
for s in (version or "", name or ""):
|
|
m = _SAP_PL_RE.search(s)
|
|
if m:
|
|
return int(m.group(1))
|
|
return None
|
|
|
|
|
|
def _sap_affected(m: dict, installed: str, pl: Optional[int]) -> bool:
|
|
"""True if this cpeMatch names the host's release AND its patch level.
|
|
|
|
BOTH halves are required. The same patch-level number exists under every
|
|
release — NVD lists gui_for_windows 7.70:patch_level17 next to
|
|
8.0:patch_level1 — so matching on the level alone puts a 7.70 CVE on an
|
|
8.00 host. The releases are also spelled differently on the two sides
|
|
(Wazuh reports "8.00", NVD writes "8.0"), which is why this compares them
|
|
numerically instead of as strings.
|
|
"""
|
|
if pl is None:
|
|
return False
|
|
parts = (m.get("criteria") or "").split(":")
|
|
ver = parts[5] if len(parts) > 5 else "*"
|
|
upd = parts[6] if len(parts) > 6 else "*"
|
|
if ver in ("*", "-", "") or _vcmp(installed, ver) != 0:
|
|
return False
|
|
hit = _SAP_CPE_PL_RE.match(upd or "")
|
|
if hit:
|
|
return int(hit.group(1)) == pl
|
|
# ':-' is the base release with no patch applied — level 0.
|
|
return upd == "-" and pl == 0
|
|
|
|
|
|
# Three completely different products share Adobe's acrobat/acrobat_reader
|
|
# CPEs, and only their version SHAPE tells them apart:
|
|
#
|
|
# desktop Acrobat/Reader 26.001.21771 3 parts
|
|
# Acrobat Reader T5, the 127.0.2651.105 4 parts, Chromium numbering
|
|
# PDF engine inside Edge
|
|
# browser extension 26.7.1.0 4 parts, its own numbering
|
|
#
|
|
# CVE-2024-41879 says "Acrobat Reader versions 127.0.2651.105 and earlier" and
|
|
# its second NVD configuration is cpe:microsoft:edge — it is the Edge engine,
|
|
# not the desktop app. Compared as numbers 26 < 127, so it landed on every
|
|
# desktop Acrobat, and on the extension too. Same for CVE-2024-20721/-20709/
|
|
# -39379 (bound 120.0.2210.91) and the CVE-2026-479xx block.
|
|
#
|
|
# Part count separates the desktop app from the other two; the leading number
|
|
# separates Chromium (three digits) from the extension (two). Both sides of a
|
|
# comparison must agree, otherwise the bound describes a different product and
|
|
# the versions are not comparable at all — which is what None means here, and
|
|
# every caller already drops the match on None.
|
|
def _same_version_scheme(installed: str, bound: str) -> bool:
|
|
a, b = _vtuple(installed), _vtuple(bound)
|
|
if not a or not b:
|
|
return True # nothing to judge — leave it alone
|
|
if len(a) != len(b):
|
|
return False
|
|
return (a[0] >= 100) == (b[0] >= 100)
|
|
|
|
|
|
def _is_adobe_acrobat(criteria: str) -> bool:
|
|
p = (criteria or "").split(":")
|
|
return len(p) > 4 and p[3] == "adobe" and p[4].startswith("acrobat")
|
|
|
|
|
|
def _in_range(installed: str, m: dict) -> bool:
|
|
"""True if installed version satisfies one NVD cpeMatch entry."""
|
|
sI, sE = m.get("versionStartIncluding"), m.get("versionStartExcluding")
|
|
eI, eE = m.get("versionEndIncluding"), m.get("versionEndExcluding")
|
|
# A commit hash bounds a source tree, not a release — see is_version. The
|
|
# cvelistV5 path dropped these already; this one did not, so the same CVE
|
|
# could still arrive through NVD.
|
|
if any(b is not None and not is_version(b) for b in (sI, sE, eI, eE)):
|
|
return False
|
|
if _is_adobe_acrobat(m.get("criteria", "")):
|
|
for b in (sI, sE, eI, eE):
|
|
if b and not _same_version_scheme(installed, b):
|
|
return False
|
|
if not any([sI, sE, eI, eE]):
|
|
# exact version in the CPE criteria (criteria[5]); equal only.
|
|
crit = m.get("criteria", "")
|
|
parts = crit.split(":")
|
|
ver = parts[5] if len(parts) > 5 else "*"
|
|
if ver in ("*", "-", ""):
|
|
return False # wildcard → would match everything → skip (FP)
|
|
return _vcmp(installed, ver) == 0
|
|
if sI is not None and (_vcmp(installed, sI) is None or _vcmp(installed, sI) < 0):
|
|
return False
|
|
if sE is not None and (_vcmp(installed, sE) is None or _vcmp(installed, sE) <= 0):
|
|
return False
|
|
if eI is not None and (_vcmp(installed, eI) is None or _vcmp(installed, eI) > 0):
|
|
return False
|
|
if eE is not None and (_vcmp(installed, eE) is None or _vcmp(installed, eE) >= 0):
|
|
return False
|
|
return True
|
|
|
|
|
|
# ---------- sources ----------
|
|
def _query_osv(eco: str, name: str, version: str) -> List[dict]:
|
|
try:
|
|
with httpx.Client(timeout=HTTP_TIMEOUT, headers={"User-Agent": "TrueVuln/1.0"}) as c:
|
|
r = c.post(OSV_QUERY_URL, json={"package": {"name": name, "ecosystem": eco}, "version": version})
|
|
if r.status_code != 200:
|
|
return []
|
|
data = r.json()
|
|
except (httpx.HTTPError, ValueError) as e:
|
|
logger.debug("OSV query failed %s/%s@%s: %s", eco, name, version, e)
|
|
return []
|
|
out = []
|
|
for v in data.get("vulns", []) or []:
|
|
cid = v.get("id", "")
|
|
aliases = v.get("aliases", []) or []
|
|
cve = cid if cid.upper().startswith("CVE-") else next((a for a in aliases if a.upper().startswith("CVE-")), None)
|
|
if not cve:
|
|
continue
|
|
out.append({"cve": cve.upper(), "cvss": None, "severity": None, "fixed": None})
|
|
return out
|
|
|
|
|
|
def _query_nvd_cpe(cpe: str, version: str, sap_pl: Optional[int] = None) -> List[dict]:
|
|
api_key = os.getenv("NVD_API_KEY", "").strip()
|
|
headers = {"apiKey": api_key} if api_key else None
|
|
url = f"{NVD_CVE_API}?virtualMatchString={cpe}:{version}&resultsPerPage=200"
|
|
sleep = NVD_SLEEP_WITH_KEY if api_key else NVD_SLEEP_NO_KEY
|
|
items = None
|
|
for attempt in range(4):
|
|
try:
|
|
with httpx.Client(timeout=HTTP_TIMEOUT) as c:
|
|
r = c.get(url, headers=headers)
|
|
except httpx.HTTPError as e:
|
|
logger.debug("NVD cpe query error %s@%s: %s", cpe, version, e)
|
|
else:
|
|
if r.status_code == 200:
|
|
try:
|
|
items = r.json().get("vulnerabilities", []) or []
|
|
except ValueError:
|
|
items = []
|
|
break
|
|
if r.status_code not in (429, 502, 503, 504):
|
|
items = [] # permanent (e.g. 404/400) → genuinely empty, cacheable
|
|
break
|
|
logger.debug("NVD %s for %s@%s (try %d)", r.status_code, cpe, version, attempt + 1)
|
|
# NVD 2.0 throws 503 under load even with a key → back off generously.
|
|
time.sleep(max(3.0, sleep) * (attempt + 1))
|
|
time.sleep(sleep)
|
|
if items is None:
|
|
raise _TransientNVD(f"NVD unavailable for {cpe}@{version}")
|
|
|
|
prod_token = ":".join(cpe.split(":")[3:5]) # vendor:product
|
|
out = []
|
|
for it in items:
|
|
cve_obj = it.get("cve", {})
|
|
cve_id = cve_obj.get("id", "")
|
|
if not cve_id.upper().startswith("CVE-"):
|
|
continue
|
|
matched = False
|
|
fixed = None
|
|
tsws: set = set()
|
|
for cfg in cve_obj.get("configurations", []) or []:
|
|
for node in cfg.get("nodes", []) or []:
|
|
for m in node.get("cpeMatch", []) or []:
|
|
crit = m.get("criteria") or ""
|
|
if prod_token not in crit:
|
|
continue
|
|
if not m.get("vulnerable", True):
|
|
continue
|
|
ok = (_sap_affected(m, version, sap_pl) if sap_pl is not None
|
|
else _in_range(version, m))
|
|
if ok:
|
|
matched = True
|
|
parts = crit.split(":")
|
|
# target_sw is field 10, not 9 — 9 is sw_edition.
|
|
# Reading the wrong one meant every match looked
|
|
# platform-neutral ("*"), so _platform_ok never
|
|
# rejected anything and the browser-extension CPEs
|
|
# came through as if they were the desktop app:
|
|
# cpe:2.3:a:adobe:acrobat:*:...:*:edge:*:* is the
|
|
# Acrobat extension FOR Edge, and its versions are
|
|
# BROWSER versions (up to 126.0.2592.81). Desktop
|
|
# Acrobat 26.001.21771 compares below that, so every
|
|
# host with Acrobat installed collected the extension's
|
|
# CVEs (observed: CVE-2026-48294, CVE-2024-39379,
|
|
# CVE-2024-20721, CVE-2024-20709).
|
|
tsws.add(parts[10] if len(parts) > 10 else "*")
|
|
end_excl = m.get("versionEndExcluding")
|
|
if end_excl and is_version(end_excl):
|
|
fixed = fixed or end_excl
|
|
else:
|
|
# "up to and including X" names no patched build,
|
|
# the same as cvelistV5's lessThanOrEqual — but the
|
|
# floor is still actionable ("newer than X"), and
|
|
# only that path reported it. NVD-sourced findings
|
|
# showed a bare "not announced" (observed:
|
|
# CVE-2026-48294 on the Acrobat extension, where
|
|
# NVD says "Up to (including) 26.5.2.2").
|
|
end_incl = m.get("versionEndIncluding")
|
|
if end_incl and is_version(end_incl):
|
|
fixed = fixed or f">{end_incl}"
|
|
if not matched:
|
|
continue
|
|
cvss, sev = _nvd_cvss(cve_obj)
|
|
# NVD carries the same CNA text; without it a finding shows only the
|
|
# title we generated, which says what matched rather than what the
|
|
# flaw is.
|
|
desc = None
|
|
for d in cve_obj.get("descriptions") or []:
|
|
if (d or {}).get("lang", "en").lower().startswith("en"):
|
|
desc = ((d.get("value") or "").strip() or None)
|
|
if desc:
|
|
desc = desc[:2000]
|
|
break
|
|
out.append({"cve": cve_id.upper(), "cvss": cvss, "severity": sev,
|
|
"fixed": fixed, "tsw": sorted(tsws), "desc": desc})
|
|
return out
|
|
|
|
|
|
def _nvd_cvss(cve_obj: dict) -> Tuple[Optional[float], Optional[str]]:
|
|
metrics = cve_obj.get("metrics", {}) or {}
|
|
for key in ("cvssMetricV31", "cvssMetricV30", "cvssMetricV2"):
|
|
arr = metrics.get(key) or []
|
|
if arr:
|
|
d = arr[0].get("cvssData", {})
|
|
score = d.get("baseScore")
|
|
sev = (d.get("baseSeverity") or arr[0].get("baseSeverity") or "").lower() or None
|
|
try:
|
|
return (float(score) if score is not None else None), sev
|
|
except (TypeError, ValueError):
|
|
return None, sev
|
|
return None, None
|
|
|
|
|
|
# ---------- cache ----------
|
|
def _cache_get(db: Session, product_key: str, version: str) -> Optional[List[dict]]:
|
|
from app.models.app_cve_cache import AppCveCache
|
|
row = (db.query(AppCveCache)
|
|
.filter(AppCveCache.product_key == product_key, AppCveCache.version == version)
|
|
.first())
|
|
if not row or not row.fetched_at:
|
|
return None
|
|
if datetime.now() - row.fetched_at > CACHE_TTL:
|
|
return None
|
|
try:
|
|
return json.loads(row.cves or "[]")
|
|
except json.JSONDecodeError:
|
|
return None
|
|
|
|
|
|
def _cache_put(db: Session, product_key: str, version: str, cves: List[dict]) -> None:
|
|
from app.models.app_cve_cache import AppCveCache
|
|
row = (db.query(AppCveCache)
|
|
.filter(AppCveCache.product_key == product_key, AppCveCache.version == version)
|
|
.first())
|
|
if row:
|
|
row.cves = json.dumps(cves)
|
|
row.fetched_at = datetime.now()
|
|
else:
|
|
db.add(AppCveCache(product_key=product_key, version=version,
|
|
cves=json.dumps(cves), fetched_at=datetime.now()))
|
|
db.commit()
|
|
|
|
|
|
CACHE_PREFIX = "v3:" # v3: target_sw was read from the wrong CPE field
|
|
|
|
|
|
def lookup_cves(db: Session, entry: dict, version: str,
|
|
sap_pl: Optional[int] = None) -> List[dict]:
|
|
"""Cached (product, version) → CVE list."""
|
|
key = CACHE_PREFIX + entry["key"]
|
|
# The patch level is part of what was asked, so it must be part of the
|
|
# cache identity — otherwise PL 12 serves PL 3's answer.
|
|
cache_ver = version if sap_pl is None else f"{version}+pl{sap_pl}"
|
|
cached = _cache_get(db, key, cache_ver)
|
|
if cached is not None:
|
|
return cached
|
|
try:
|
|
if entry["kind"] == "osv":
|
|
cves = _query_osv(entry["eco"], entry["oname"], version)
|
|
else:
|
|
cves = _query_nvd_cpe(entry["cpe"], version, sap_pl=sap_pl)
|
|
# Merge the alternate CPE names, keeping the first entry per CVE —
|
|
# a duplicate across names is the same finding, not two.
|
|
for alt in entry.get("also") or []:
|
|
seen_ids = {c["cve"] for c in cves}
|
|
cves += [c for c in _query_nvd_cpe(alt, version, sap_pl=sap_pl)
|
|
if c["cve"] not in seen_ids]
|
|
except _TransientNVD as e:
|
|
logger.warning("app-cve: %s — not caching, will retry next run", e)
|
|
return []
|
|
_cache_put(db, key, cache_ver, cves)
|
|
return cves
|
|
|
|
|
|
# ---------- upsert + scan ----------
|
|
# Wazuh files agent, manager and indexer under one CPE, so every server-side
|
|
# CVE lands on every endpoint running the agent. CVE-2026-25769 is the clearest
|
|
# case: remote code execution "on the master node" reachable "from a worker
|
|
# node" via the cluster protocol — an agent speaks 1514/1515 to a manager and
|
|
# is not part of that protocol at all, yet the finding appeared on a client
|
|
# workstation.
|
|
#
|
|
# Only phrases that name the server side unambiguously count, and only when the
|
|
# text does NOT also mention the agent — a flaw in how the manager handles
|
|
# agent input is a real risk for both. Missing a genuine agent CVE is the far
|
|
# worse error, so anything ambiguous is kept.
|
|
_MANAGER_ONLY_RE = re.compile(
|
|
r"\b(cluster mode|master node|worker node|wazuh-clusterd|clusterd|"
|
|
r"cluster protocol|cluster synchronization|wazuh manager|"
|
|
r"manager'?s? (?:file system|configuration)|wazuh api|wazuh server)\b", re.I)
|
|
_AGENT_MENTION_RE = re.compile(r"\b(agent|endpoint|wazuh-agentd|agentd)\b", re.I)
|
|
|
|
|
|
# The packages that are not the manager. A module constant rather than an
|
|
# inline pattern so the test pins this rule instead of its own copy of it.
|
|
NON_MANAGER_PKG_RE = re.compile(r"^wazuh[- ](agent|dashboard|indexer)\b", re.I)
|
|
|
|
|
|
def wazuh_component(product_key: str, pkg_name: str) -> Optional[str]:
|
|
"""Which Wazuh component a package is — the scan-side dedup discriminator.
|
|
|
|
Agent, manager, server, dashboard and indexer all resolve to ONE product
|
|
key, and every scan path dedups on (key, version) so the same product is
|
|
not looked up twice. On a Wazuh manager that is not a duplicate: the box
|
|
runs wazuh-manager AND wazuh-agent at the identical version, so whichever
|
|
one syscollector listed first won the key and the other was never scanned
|
|
at all. With the agent listed first, every manager advisory was then also
|
|
dropped by the manager-side guard in _upsert — the manager's own CVEs
|
|
vanished from its own host, with nothing logged.
|
|
|
|
Non-Wazuh products get None and dedup exactly as before.
|
|
"""
|
|
if "wazuh" not in (product_key or "").lower():
|
|
return None
|
|
from app.services.github_repo_advisory_service import component_of
|
|
return component_of(pkg_name)
|
|
|
|
|
|
def _is_manager_only(desc: Optional[str]) -> bool:
|
|
"""Does this CVE describe the server side, and only the server side?"""
|
|
text = (desc or "").strip()
|
|
if not text or not _MANAGER_ONLY_RE.search(text):
|
|
return False
|
|
return not _AGENT_MENTION_RE.search(text)
|
|
|
|
|
|
def _upsert(db: Session, asset, pkg_name: str, version: str, c: dict, new_ids: list,
|
|
touched: Optional[set] = None, vendor: Optional[str] = None) -> None:
|
|
# A manager-side flaw is not a finding on a package that is not the
|
|
# manager. This first covered the agent only, which left the same CVE on
|
|
# the dashboard: CVE-2026-25769 is cluster-protocol code execution between
|
|
# master and worker, and a dashboard takes part in that no more than an
|
|
# agent does. Indexer likewise. Checked here because all three sources
|
|
# converge on this function.
|
|
if NON_MANAGER_PKG_RE.match((pkg_name or "").strip()) \
|
|
and _is_manager_only(c.get("desc")):
|
|
logger.debug("skipping %s on %s: manager-side CVE, package is not the manager",
|
|
c.get("cve"), pkg_name)
|
|
return
|
|
from app.models.vulnerability import Vulnerability, VulnerabilitySeverity, VulnerabilityStatus
|
|
sev_map = {"critical": VulnerabilitySeverity.critical, "high": VulnerabilitySeverity.high,
|
|
"medium": VulnerabilitySeverity.medium, "low": VulnerabilitySeverity.low,
|
|
"none": VulnerabilitySeverity.none}
|
|
cve_id = c["cve"].upper()
|
|
if touched is not None:
|
|
touched.add(cve_id)
|
|
existing = (db.query(Vulnerability)
|
|
.filter(Vulnerability.cve_id == cve_id, Vulnerability.asset_id == asset.id)
|
|
.first())
|
|
if existing:
|
|
existing.add_source("app-scan")
|
|
# Backfill the flaw description onto rows created before we carried
|
|
# it — fill-only, so a source with a better text keeps the last word.
|
|
if c.get("desc") and not existing.description:
|
|
existing.description = c["desc"]
|
|
# Keep EVERY affected product visible: one finding is unique per
|
|
# (cve, asset), so a CVE hitting two products on the same host (Chrome
|
|
# AND Edge — different build schemes entirely) would otherwise show
|
|
# only whichever scanner wrote first.
|
|
from app.services.audit_events import record_affected_package
|
|
record_affected_package(db, existing, name=pkg_name, version=version,
|
|
fixed_version=c.get("fixed"), source="app-scan")
|
|
if not existing.package_name:
|
|
existing.package_name = pkg_name[:255]
|
|
if vendor and not existing.package_vendor:
|
|
existing.package_vendor = vendor[:255]
|
|
# Re-detected with the CURRENT inventory version → refresh it. Fill-only
|
|
# left the first-ever version on the row (observed: Firefox showed
|
|
# 'Installed: 150.0.3' while 152.0.5 was on the box).
|
|
if version:
|
|
existing.package_version = version[:100]
|
|
# Backfill metric/fix data once better info arrives (e.g. cvelistV5 now
|
|
# carries the CVSS the first import lacked) — don't overwrite existing.
|
|
if existing.cvss_score is None and c.get("cvss") is not None:
|
|
existing.cvss_score = c["cvss"]
|
|
if c.get("fixed") and not existing.fixed_version:
|
|
existing.fixed_version = c["fixed"]
|
|
if c.get("severity") and existing.severity == VulnerabilitySeverity.medium:
|
|
existing.severity = sev_map.get(c["severity"].lower(), existing.severity)
|
|
from app.services.audit_events import reopen_if_patched
|
|
reopen_if_patched(db, existing, reason="App CVE scan detects this CVE on the host again", source="app_scan")
|
|
try:
|
|
existing.refresh_scores()
|
|
except Exception:
|
|
pass
|
|
return
|
|
sev = sev_map.get((c.get("severity") or "").lower(), VulnerabilitySeverity.medium)
|
|
row = Vulnerability(
|
|
cve_id=cve_id, asset_id=asset.id,
|
|
cvss_score=c.get("cvss"), severity=sev,
|
|
status=VulnerabilityStatus.open,
|
|
title=f"{pkg_name} {version} — {cve_id}"[:500],
|
|
description=(c.get("desc") or None),
|
|
package_name=pkg_name[:255], package_version=version[:100],
|
|
package_vendor=(vendor[:255] if vendor else None),
|
|
fixed_version=(c.get("fixed") or None),
|
|
detected_at=datetime.now(),
|
|
sources=json.dumps(["app-scan"]), first_detected_by="app-scan",
|
|
)
|
|
db.add(row)
|
|
db.flush()
|
|
from app.services.audit_events import record_affected_package
|
|
record_affected_package(db, row, name=pkg_name, version=version,
|
|
fixed_version=c.get("fixed"), source="app-scan")
|
|
# CVE metrics (CVSS/EPSS/KEV) are properties of the CVE, not of one host.
|
|
# Only 49 of 436 Edge CVEs carry a CVSSScoreSet in the CVRF, so an MSRC-only
|
|
# finding often has no score of its own while the SAME CVE on another asset
|
|
# already does (observed: CVE-2026-16423 showed 8.8 on the Chrome row and
|
|
# '-' / priority 0 on the Edge row). Inherit from a sibling before scoring.
|
|
try:
|
|
if row.cvss_score is None:
|
|
from app.services.vuln_override_service import apply_canonical_from_siblings
|
|
apply_canonical_from_siblings(db, row)
|
|
except Exception as e:
|
|
logger.debug("sibling metric inherit failed (%s): %s", row.cve_id, e)
|
|
try:
|
|
row.refresh_scores()
|
|
except Exception:
|
|
pass
|
|
new_ids.append(row.id)
|
|
|
|
|
|
def _is_citrix_shim(pkg: dict) -> bool:
|
|
"""Citrix published-app delivery leaves a registry stub ('Firefox 1.0',
|
|
vendor 'Delivered by Citrix') for software that is NOT installed on the
|
|
box — matching it produced ancient-CVE false positives (observed:
|
|
CVE-2008-2798 on 'Firefox 1.0'). The vendor field identifies the stub."""
|
|
vendor = (pkg.get("vendor") or "").lower()
|
|
return "citrix" in vendor
|
|
|
|
|
|
def filter_inventory(packages: list) -> list:
|
|
"""Drop entries that are not real local installs, ONCE, at the source.
|
|
|
|
These stubs carry a made-up product name and a placeholder version ("SAP
|
|
Business Client 1.0", "Firefox 1.0"), so every consumer that matches them
|
|
finds ancient CVEs. Each scan path used to filter for itself, which meant
|
|
every new path started out unfiltered — the SAP path did, and it reported
|
|
a published-app stub as a finding. Filtering the list once, where the
|
|
inventory is fetched, is the only version of this that stays true as paths
|
|
are added.
|
|
"""
|
|
keep, dropped = [], 0
|
|
for p in packages or []:
|
|
if _is_citrix_shim(p):
|
|
dropped += 1
|
|
continue
|
|
keep.append(p)
|
|
if dropped:
|
|
logger.debug("inventory: dropped %d delivered/published stub(s)", dropped)
|
|
return keep
|
|
|
|
|
|
# Browser extensions. A SEPARATE registry from the desktop products: NVD
|
|
# describes an extension with the same vendor:product as the application and
|
|
# distinguishes them only by target_sw, so the two must never share a lookup —
|
|
# their version schemes are unrelated (Acrobat desktop 26.001.21691 vs the
|
|
# Chrome extension 25.5.4.1).
|
|
#
|
|
# Keyed by the STORE ID, not the display name. The name is localised and
|
|
# changes with marketing ("Adobe Acrobat: PDF edit, convert, sign tools"),
|
|
# while the id is what the browser installs it under and is stable for the
|
|
# life of the extension.
|
|
_EXTENSION_REGISTRY: Dict[str, dict] = {
|
|
# Adobe Acrobat, one id per store. Same product in NVD, told apart by
|
|
# target_sw — see CVE-2026-48294 (chrome, up to 26.5.2.2).
|
|
# "(User)" the way Windows ARP marks a per-user install (VS Code shows as
|
|
# "Microsoft Visual Studio Code (User)"): an extension lives in the browser
|
|
# profile, so it is removed or updated per user, not machine-wide. Whoever
|
|
# picks up the finding needs to know that before looking for it in
|
|
# Programs and Features.
|
|
"efaidnbmnnnibpcajpcglclefindmkaj": {
|
|
"key": "ext:adobe:acrobat:chrome", "cpe": "cpe:2.3:a:adobe:acrobat",
|
|
"label": "Adobe Acrobat (User) extension (Chrome)"},
|
|
"elhekieabhbkpmcefcoobjddigjcaadp": {
|
|
"key": "ext:adobe:acrobat:edge", "cpe": "cpe:2.3:a:adobe:acrobat",
|
|
"label": "Adobe Acrobat (User) extension (Edge)"},
|
|
}
|
|
|
|
|
|
def resolve_extension(ext: dict) -> Optional[dict]:
|
|
"""Curated lookup by store id. Unknown extensions are ignored, same rule
|
|
as the package registry — no guessing from names."""
|
|
return _EXTENSION_REGISTRY.get((ext.get("id") or "").strip())
|
|
|
|
|
|
def scan_asset_extensions(db: Session, asset, extensions: list,
|
|
new_ids: Optional[list] = None,
|
|
touched: Optional[set] = None) -> int:
|
|
"""Match installed browser extensions to CVEs.
|
|
|
|
The extension is a product in its own right, and nothing else inventories
|
|
it: syscollector lists applications, not what runs inside a browser. Its
|
|
CVEs went nowhere — or worse, onto the desktop application, whose CPE NVD
|
|
shares with it (fixed in c88eb45).
|
|
|
|
A DISABLED extension is recorded but not reported: the code is on disk,
|
|
yet it does not execute, so calling it vulnerable would misstate the risk.
|
|
"""
|
|
if new_ids is None:
|
|
new_ids = []
|
|
count = 0
|
|
seen: set = set()
|
|
for ext in extensions or []:
|
|
entry = resolve_extension(ext)
|
|
if not entry:
|
|
continue
|
|
# A disabled extension used to be skipped, on the reasoning that code
|
|
# which does not execute is not a live risk. That was the wrong call:
|
|
# the vulnerable code sits in the user's profile and a single click —
|
|
# or a policy push, or a profile sync — puts it back in the browser.
|
|
# It is latent, not absent.
|
|
#
|
|
# It also made us inconsistent with the rest of the tool. Wazuh reports
|
|
# every installed Linux kernel, including the ones the machine has not
|
|
# booted; nobody argues those should be hidden because they are not
|
|
# running right now. Same situation, same answer — report it, and say
|
|
# in the name that it is currently off so the risk can be judged.
|
|
disabled = ext.get("enabled") is False
|
|
version = _clean_version(ext.get("version") or "")
|
|
if not version:
|
|
continue
|
|
browser = (ext.get("browser") or "").lower()
|
|
label = entry["label"] + (" [disabled]" if disabled else "")
|
|
key = (entry["key"], version, disabled)
|
|
if key in seen:
|
|
continue # same extension in several browser profiles
|
|
seen.add(key)
|
|
try:
|
|
cves = lookup_cves(db, {"key": entry["key"], "kind": "cpe",
|
|
"cpe": entry["cpe"]}, version)
|
|
except Exception as e:
|
|
logger.debug("extension lookup failed (%s): %s", entry["label"], e)
|
|
continue
|
|
for c in cves:
|
|
# Here target_sw is the BROWSER, not the OS — that is exactly the
|
|
# field that separates the extension's CVEs from the desktop
|
|
# application's.
|
|
if not _platform_ok(c.get("tsw"), browser):
|
|
continue
|
|
before = len(new_ids)
|
|
try:
|
|
_upsert(db, asset, label, ext.get("version") or version,
|
|
c, new_ids, touched=touched,
|
|
vendor=(ext.get("vendor") or "Adobe"))
|
|
count += 1 if len(new_ids) > before else 0
|
|
except Exception as e:
|
|
logger.debug("extension upsert failed (%s on %s): %s",
|
|
c.get("cve"), asset.id, e)
|
|
return count
|
|
|
|
|
|
def scan_asset_packages(db: Session, asset, packages: list, new_ids: Optional[list] = None,
|
|
touched: Optional[set] = None) -> int:
|
|
"""Match an asset's installed software to CVEs (curated + precise).
|
|
Returns findings upserted. Caller commits."""
|
|
if new_ids is None:
|
|
new_ids = []
|
|
count = 0
|
|
seen: set = set()
|
|
for pkg in packages or []:
|
|
name = (pkg.get("name") or "").strip()
|
|
version = (pkg.get("version") or "").strip()
|
|
if not name:
|
|
continue
|
|
if _is_citrix_shim(pkg):
|
|
continue
|
|
entry = resolve_product(name)
|
|
if not entry:
|
|
continue
|
|
eff_ver = _effective_version(name, version, entry)
|
|
if not eff_ver:
|
|
continue
|
|
cver = _clean_version(eff_ver)
|
|
if not cver:
|
|
continue # distro/rpm version → Wazuh's domain, skip (no NVD noise)
|
|
# SAP is versioned as release + patch level, and the level decides
|
|
# everything — an unreadable one means we report nothing rather than
|
|
# everything ever filed against the release.
|
|
sap_pl = _sap_patch_level(name, version) if entry.get("sap") else None
|
|
if entry.get("sap") and sap_pl is None:
|
|
logger.debug("app-cve: no SAP patch level in %r / %r — skipping",
|
|
name, version)
|
|
continue
|
|
key = (entry["key"], cver if sap_pl is None else f"{cver}+pl{sap_pl}",
|
|
wazuh_component(entry["key"], name))
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
try:
|
|
cves = lookup_cves(db, entry, cver, sap_pl=sap_pl)
|
|
except Exception as e:
|
|
logger.debug("app-cve lookup failed for %s %s: %s", name, version, e)
|
|
continue
|
|
plat = _os_family(asset.operating_system or "")
|
|
for c in cves:
|
|
if not _platform_ok(c.get("tsw"), plat):
|
|
continue # CVE is for a different OS platform (e.g. Firefox-iOS)
|
|
try:
|
|
before = len(new_ids)
|
|
_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("app-cve upsert failed (%s on %s): %s", c.get("cve"), asset.id, e)
|
|
return count
|
|
|
|
|
|
def scan_asset_os(db: Session, asset, new_ids: list, touched: Optional[set] = None) -> int:
|
|
"""OS-level CVEs from asset.operating_system + asset.os_version
|
|
(iOS/iPadOS). Returns findings. Caller commits."""
|
|
e = _resolve_os(asset.operating_system or "")
|
|
if not e:
|
|
return 0
|
|
cver = _clean_version(asset.os_version or "")
|
|
if not cver:
|
|
return 0
|
|
try:
|
|
cves = lookup_cves(db, {"key": e["key"], "kind": "cpe", "cpe": e["cpe"]}, cver)
|
|
except Exception as ex:
|
|
logger.debug("app-cve OS lookup failed for %s: %s", asset.hostname, ex)
|
|
return 0
|
|
plat = _os_family(asset.operating_system or "")
|
|
count = 0
|
|
for c in cves:
|
|
if not _platform_ok(c.get("tsw"), plat):
|
|
continue
|
|
before = len(new_ids)
|
|
try:
|
|
_upsert(db, asset, e["label"], asset.os_version or cver, c, new_ids, touched=touched)
|
|
count += 1 if len(new_ids) > before else 0
|
|
except Exception as ex:
|
|
logger.debug("app-cve OS upsert failed (%s on %s): %s", c.get("cve"), asset.id, ex)
|
|
return count
|
|
|
|
|
|
def prune_stale_packages(db: Session, asset, since: datetime) -> int:
|
|
"""Drop per-package rows this run no longer confirmed.
|
|
|
|
A finding is one row per (cve, asset), but it lists every affected product
|
|
— and a CVE can hit two products that patch on completely separate
|
|
schedules. CVE-2026-17733 hits Chrome and Edge; a host patched Chrome
|
|
to 151.0.7922.72 while Edge stayed on 150.0.4078.105, and the finding kept
|
|
listing Chrome at its OLD version as still affected. The row is only ever
|
|
touched while the product is detected, so once it is patched nothing
|
|
updates it and it sits there looking unfixed.
|
|
|
|
last_seen_at was written for this from the start; the pass that reads it
|
|
was never built. Only rows on findings this run actually re-examined are
|
|
pruned — a finding the scan never reached keeps everything it had.
|
|
"""
|
|
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
|
|
from app.models.vulnerability_package import VulnerabilityPackage
|
|
rows = (db.query(VulnerabilityPackage)
|
|
.join(Vulnerability,
|
|
Vulnerability.id == VulnerabilityPackage.vulnerability_id)
|
|
.filter(Vulnerability.asset_id == asset.id,
|
|
Vulnerability.status == VulnerabilityStatus.open,
|
|
VulnerabilityPackage.last_seen_at < since)
|
|
.all())
|
|
dropped = 0
|
|
for r in rows:
|
|
# Never leave a finding with no packages at all — that reads as "we
|
|
# know nothing" instead of "this product is fixed". If it was the last
|
|
# one, the finding itself is stale and the reconcile below closes it.
|
|
siblings = (db.query(VulnerabilityPackage)
|
|
.filter(VulnerabilityPackage.vulnerability_id == r.vulnerability_id,
|
|
VulnerabilityPackage.id != r.id)
|
|
.all())
|
|
if not siblings:
|
|
continue
|
|
db.delete(r)
|
|
dropped += 1
|
|
# The finding's own package_name/version columns are written once, when
|
|
# it is created, and never revisited — so once the product they name is
|
|
# pruned the list view keeps showing it while the detail page lists only
|
|
# the surviving ones (observed: list said "Asian Language And Spelling
|
|
# Dictionaries Support For Adobe Acrobat Reader", detail said "Adobe
|
|
# Acrobat (64-bit) 26.001.21662" for the same finding). Move the columns
|
|
# to a product that is still there.
|
|
v = (db.query(Vulnerability)
|
|
.filter(Vulnerability.id == r.vulnerability_id).first())
|
|
if v is not None and v.package_name == r.package_name:
|
|
keep = max(siblings, key=lambda s: s.last_seen_at or datetime.min)
|
|
v.package_name = keep.package_name
|
|
v.package_version = keep.package_version or v.package_version
|
|
if dropped:
|
|
logger.info("pruned %d stale package row(s) on %s", dropped, asset.hostname)
|
|
return dropped
|
|
|
|
|
|
def _resolve_stale_app_findings(db: Session, asset, touched_cves: set) -> int:
|
|
"""Reconcile OPEN app-scan findings not re-detected this run: DROP the
|
|
app-scan source (same contract as the Nessus backfill), and mark patched
|
|
only once no source is left. The old skip-if-cross-confirmed rule dead-
|
|
locked: a Chrome CVE seen by app-scan AND Defender was never closed by
|
|
either reconcile (each deferred to the other), so a patched host kept an
|
|
open cross-confirmed finding forever (observed: Chrome 150.0.7871.125
|
|
installed, fix .115, finding still open). Caller must have had a real
|
|
inventory this run."""
|
|
from sqlalchemy import or_
|
|
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
|
|
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.cve_id in touched_cves:
|
|
continue
|
|
v.remove_source("app-scan")
|
|
reason = (f"App CVE scan no longer detects this CVE on {asset.hostname} "
|
|
f"(software updated/removed past the vulnerable version)")
|
|
if v.source_list:
|
|
# Another scanner still reports it → it stays open under their
|
|
# claim. We only ever retract OUR OWN source. (An inventory-based
|
|
# override that closed over a lone Wazuh claim used to live here;
|
|
# it produced false negatives in four different ways — multi-stream
|
|
# fixes, 2.x minor lines, MSI build numbers in the version field,
|
|
# and stale inventory from disconnected agents — so it was removed.
|
|
# A single stored fixed_version cannot validate an arbitrary
|
|
# inventory version string.)
|
|
continue
|
|
old_status = v.status
|
|
v.status = VulnerabilityStatus.patched
|
|
v.patched_at = datetime.now()
|
|
resolved += 1
|
|
# Revisionssicher: one status-change row (feeds both the global audit
|
|
# log AND the per-CVE Change History). user_id=None = automated.
|
|
try:
|
|
from app.routers.vulnerabilities import log_vulnerability_change
|
|
log_vulnerability_change(
|
|
db, None, v.id, old_status, v.status,
|
|
reason=reason,
|
|
cve_id=v.cve_id,
|
|
source="app_scan",
|
|
)
|
|
except Exception as e:
|
|
logger.warning("audit log for app-scan auto-resolve failed (vuln_id=%s): %s", v.id, e)
|
|
return resolved
|
|
|
|
|
|
def run_app_cve_scan(db: Session, asset_id: Optional[int] = None) -> dict:
|
|
"""Scan all assets with software inventory (Wazuh packages + Intune
|
|
detectedApps) → app-scan CVEs, then enrich the new ones."""
|
|
from app.models.asset import Asset, AssetSource
|
|
stats = {"assets": 0, "findings": 0, "new": 0, "errors": []}
|
|
new_ids: list = []
|
|
logger.info("App CVE scan starting: NVD key %s",
|
|
"present" if os.getenv("NVD_API_KEY", "").strip() else "MISSING (keyless = frequent 503)")
|
|
|
|
wazuh = None
|
|
graph = None
|
|
try:
|
|
from app.integrations.wazuh_client import WazuhClient
|
|
from app.auth.setting_crypto import read_setting_value
|
|
raw = read_setting_value(db, "wazuh_config")
|
|
if raw:
|
|
cfg = json.loads(raw)
|
|
if all([cfg.get("api_url"), cfg.get("username"), cfg.get("password")]):
|
|
wazuh = WazuhClient(base_url=cfg.get("api_url"), username=cfg.get("username"),
|
|
password=cfg.get("password"), indexer_url=cfg.get("indexer_url"),
|
|
indexer_username=cfg.get("indexer_username"),
|
|
indexer_password=cfg.get("indexer_password"),
|
|
verify_ssl=cfg.get("verify_ssl", False))
|
|
except Exception as e:
|
|
logger.debug("app-cve: wazuh client unavailable: %s", e)
|
|
try:
|
|
from app.services.intune_service import load_intune_config, _build_client
|
|
icfg = load_intune_config(db)
|
|
if icfg:
|
|
graph = _build_client(icfg)
|
|
except Exception as e:
|
|
logger.debug("app-cve: graph client unavailable: %s", e)
|
|
|
|
# cvelistV5 reverse index (curated products) — catches CVEs NVD hasn't
|
|
# CPE'd yet / filed under a different CPE product string. Cached; built by
|
|
# the nightly job. Absent → that pass is skipped (logged).
|
|
cve5_index = {}
|
|
try:
|
|
from app.services import cvelistv5_scan_service
|
|
cve5_index = cvelistv5_scan_service.load_index(db) or {}
|
|
if not cve5_index:
|
|
# No cached index → build it now so a manual scan works the same
|
|
# as the nightly job (downloads/walks the cvelistV5 ZIP — slow on
|
|
# first run, then cached + refreshed nightly).
|
|
logger.info("app-cve: cvelistV5 index missing — building now (one-time, may take a few minutes)")
|
|
cve5_index = cvelistv5_scan_service.build_product_index(db) or {}
|
|
except Exception as e:
|
|
logger.warning("app-cve: cvelistV5 index build/load failed: %s", e)
|
|
|
|
q = db.query(Asset)
|
|
if asset_id is not None:
|
|
q = q.filter(Asset.id == asset_id)
|
|
for asset in q.all():
|
|
touched = False
|
|
touched_cves: set = set() # every CVE re-detected this run → reconcile base
|
|
# Marks the start of THIS asset's pass. Package rows not refreshed past
|
|
# it were not re-confirmed and get pruned below.
|
|
asset_scan_started = datetime.now()
|
|
# OS-level CVEs (iOS/iPadOS) — version already in the DB, no client needed.
|
|
try:
|
|
n = scan_asset_os(db, asset, new_ids, touched=touched_cves)
|
|
if n:
|
|
stats["findings"] += n
|
|
touched = True
|
|
except Exception as e:
|
|
stats["errors"].append(f"asset {asset.id} os: {e}")
|
|
|
|
# Windows OS CVEs from cvelistV5 — family-matched and release-bounded
|
|
# (see scan_asset_os). Needs no software inventory, so it runs for every
|
|
# Windows asset.
|
|
if cve5_index:
|
|
try:
|
|
from app.services import cvelistv5_scan_service
|
|
n = cvelistv5_scan_service.scan_asset_os(
|
|
db, asset, cve5_index, new_ids, touched=touched_cves)
|
|
if n:
|
|
stats["findings"] += n
|
|
touched = True
|
|
except Exception as e:
|
|
stats["errors"].append(f"asset {asset.id} win-os: {e}")
|
|
|
|
# Apple OS CVEs from cvelistV5. scan_asset_os above covers Apple's
|
|
# CVEs from NVD only, and NVD's Apple enrichment lags by days —
|
|
# those findings existed nowhere but Defender until now.
|
|
try:
|
|
from app.services import cvelistv5_scan_service
|
|
n = cvelistv5_scan_service.scan_asset_os_apple(
|
|
db, asset, cve5_index, new_ids, touched=touched_cves)
|
|
if n:
|
|
stats["findings"] += n
|
|
touched = True
|
|
except Exception as e:
|
|
stats["errors"].append(f"asset {asset.id} apple-os: {e}")
|
|
|
|
# VMware vSphere (ESXi hypervisor / vCenter appliance). Needs no
|
|
# software inventory either — the version + build already sit on the
|
|
# asset, put there by the vCenter connector (or by Nessus, for a
|
|
# credentialed hypervisor scan). This is the only path that sees
|
|
# these CVEs at all: NVD publishes no configuration for them and
|
|
# the bounds are build identifiers, not versions.
|
|
try:
|
|
from app.services import cvelistv5_scan_service
|
|
n = cvelistv5_scan_service.scan_asset_vmware(
|
|
db, asset, cve5_index, new_ids, touched=touched_cves)
|
|
if n:
|
|
stats["findings"] += n
|
|
touched = True
|
|
except Exception as e:
|
|
stats["errors"].append(f"asset {asset.id} vsphere: {e}")
|
|
|
|
# Package-level CVEs (Wazuh syscollector / Intune detectedApps).
|
|
packages: list = []
|
|
try:
|
|
if asset.wazuh_agent_id and wazuh:
|
|
if str(asset.wazuh_agent_id).startswith(NODE_AGENT_PREFIX):
|
|
# A manager node runs no agent, so there is no package list
|
|
# to fetch. Its version came from the cluster API at sync
|
|
# time and is the one thing worth scanning on it.
|
|
packages = node_inventory(asset)
|
|
else:
|
|
packages = wazuh.get_packages(asset.wazuh_agent_id) or []
|
|
elif asset.intune_device_id and graph:
|
|
packages = graph.get_detected_apps(asset.intune_device_id) or []
|
|
except Exception as e:
|
|
stats["errors"].append(f"asset {asset.id}: {e}")
|
|
packages = []
|
|
# Filter once, here — every path below shares this list.
|
|
packages = filter_inventory(packages)
|
|
|
|
# Browser extensions (Wazuh IT Hygiene, indexer-side). Independent of
|
|
# `packages`: an extension is not in the software inventory, so it must
|
|
# be scanned even when that list is empty.
|
|
if asset.wazuh_agent_id and wazuh:
|
|
try:
|
|
exts = wazuh.get_browser_extensions(asset.wazuh_agent_id) or []
|
|
if exts:
|
|
n = scan_asset_extensions(db, asset, exts, new_ids,
|
|
touched=touched_cves)
|
|
stats["extension_findings"] = \
|
|
stats.get("extension_findings", 0) + n
|
|
if n:
|
|
touched = True
|
|
except Exception as e:
|
|
stats["errors"].append(f"asset {asset.id} extensions: {e}")
|
|
if packages:
|
|
stats["findings"] += scan_asset_packages(db, asset, packages, new_ids, touched=touched_cves)
|
|
if cve5_index:
|
|
try:
|
|
from app.services import cvelistv5_scan_service
|
|
stats["findings"] += cvelistv5_scan_service.scan_asset(db, asset, packages, cve5_index, new_ids, touched=touched_cves)
|
|
except Exception as e:
|
|
stats["errors"].append(f"asset {asset.id} cvelistv5: {e}")
|
|
# SAP desktop clients — cvelistV5 states a patch-level RANGE, so
|
|
# it also covers levels published after the record was written,
|
|
# which NVD's per-level enumeration cannot.
|
|
try:
|
|
from app.services import cvelistv5_scan_service
|
|
stats["findings"] += cvelistv5_scan_service.scan_asset_sap(
|
|
db, asset, packages, cve5_index, new_ids, touched=touched_cves)
|
|
except Exception as e:
|
|
stats["errors"].append(f"asset {asset.id} sap: {e}")
|
|
# GitHub REPO advisories (Notepad++ …): upstream-published CVEs that
|
|
# reach neither NVD nor cvelistV5, so no other path can see them.
|
|
# Writes through _upsert with the app-scan source → the reconcile
|
|
# below closes them once the host updates.
|
|
try:
|
|
from app.services import github_repo_advisory_service
|
|
stats["repo_advisory_findings"] = stats.get("repo_advisory_findings", 0) + \
|
|
github_repo_advisory_service.scan_asset(
|
|
db, asset, packages, new_ids, touched=touched_cves)
|
|
except Exception as e:
|
|
stats["errors"].append(f"asset {asset.id} repo-advisories: {e}")
|
|
touched = True
|
|
# MSRC fixed-build scan for installed MS software (SharePoint):
|
|
# Microsoft ships no version ranges, so this is the only source that
|
|
# knows the patch state. Separate source ('msrc') + its own reconcile.
|
|
try:
|
|
from app.services import msrc_scan_service
|
|
m_index = msrc_scan_service.load_index(db)
|
|
if not m_index:
|
|
# Absent (or retired by a version bump) → build it now so a
|
|
# manual scan behaves like the nightly job, instead of
|
|
# silently skipping every MSRC product.
|
|
logger.info("app-cve: MSRC index missing — building now")
|
|
m_index = msrc_scan_service.build_product_index(db) or {}
|
|
if m_index:
|
|
m_touched: set = set()
|
|
stats["msrc_findings"] = stats.get("msrc_findings", 0) + \
|
|
msrc_scan_service.scan_asset_packages(
|
|
db, asset, packages, m_index, new_ids, touched=m_touched)
|
|
stats["msrc_resolved"] = stats.get("msrc_resolved", 0) + \
|
|
msrc_scan_service.resolve_stale_packages(db, asset, m_touched)
|
|
# OS-level MSRC findings too. Their scan+reconcile lived
|
|
# only in run_msrc_scan (nightly / its own button), so a
|
|
# patched Windows host kept its MSRC OS findings open until
|
|
# that separate job happened to run — the app scan looked
|
|
# like it had done nothing (field report).
|
|
os_touched: set = set()
|
|
stats["msrc_os_findings"] = stats.get("msrc_os_findings", 0) + \
|
|
msrc_scan_service.scan_asset(db, asset, m_index, new_ids,
|
|
touched=os_touched)
|
|
stats["msrc_os_resolved"] = stats.get("msrc_os_resolved", 0) + \
|
|
msrc_scan_service.resolve_stale_os(db, asset, os_touched)
|
|
except Exception as e:
|
|
stats["errors"].append(f"asset {asset.id} msrc: {e}")
|
|
# Auto-resolve: an app-scan-only finding no longer re-detected means
|
|
# the software was updated/removed past it. Only safe when we had a
|
|
# real inventory this run (packages non-empty) — else we'd close
|
|
# everything on a transient fetch failure.
|
|
#
|
|
# AND only on a LIVE asset. A disconnected Wazuh agent still serves
|
|
# its last stored syscollector data, so the scan "succeeds" on data
|
|
# that may be months old — closing findings from it is unprovable
|
|
# (seen: a disconnected host ended up with zero CVEs while Wazuh
|
|
# still listed 31 for it). Wazuh's own vuln sync skips inactive
|
|
# agents too, so nothing would ever reopen them.
|
|
#
|
|
# The "packages non-empty" half of that rule was described here
|
|
# but never actually checked — only the status was. A Wazuh agent
|
|
# that re-registers under a NEW id keeps its old id's syscollector
|
|
# data until it rescans, so the fetch returns 200 with an empty
|
|
# list: a live, healthy asset, reporting no software at all. Every
|
|
# app-scan-only finding on it was then "no longer detected" and got
|
|
# closed, to be reopened by the next scan that saw the inventory
|
|
# again. Same for any transient fetch that comes back empty.
|
|
#
|
|
# An asset with an inventory source and nothing in it is a fetch we
|
|
# cannot trust, never a host that runs no software — so leave its
|
|
# findings alone. A finding held open one cycle too long is a far
|
|
# smaller error than several hundred closed on no evidence.
|
|
from app.models.asset import AssetStatus
|
|
if not packages and (asset.wazuh_agent_id or asset.intune_device_id):
|
|
stats["resolve_skipped_no_inventory"] = \
|
|
stats.get("resolve_skipped_no_inventory", 0) + 1
|
|
logger.info("app-cve: %s reported an EMPTY inventory — skipping "
|
|
"auto-resolve (agent re-registered, or a failed fetch)",
|
|
asset.hostname)
|
|
elif asset.status == AssetStatus.ACTIVE:
|
|
# Prune first: a finding may stay open on one product while
|
|
# another of its products is already patched.
|
|
pruned = prune_stale_packages(db, asset, asset_scan_started)
|
|
closed = _resolve_stale_app_findings(db, asset, touched_cves)
|
|
stats["packages_pruned"] = stats.get("packages_pruned", 0) + pruned
|
|
stats["resolved"] = stats.get("resolved", 0) + closed
|
|
# Cleaning up IS a change worth keeping. The commit below used
|
|
# to fire only when the scan had FOUND something, so an asset
|
|
# where the only outcome was closing stale findings rolled its
|
|
# work back — the reconcile ran, set them patched, and nothing
|
|
# was written. That is why false positives survived scan after
|
|
# scan on hosts that had nothing new to report, while hosts
|
|
# with a fresh finding got their cleanup committed alongside
|
|
# it. Exactly the wrong way round: a quiet host is the one
|
|
# whose cleanup matters most.
|
|
if pruned or closed:
|
|
touched = True
|
|
else:
|
|
stats["resolve_skipped_inactive"] = stats.get("resolve_skipped_inactive", 0) + 1
|
|
|
|
if touched:
|
|
stats["assets"] += 1
|
|
db.commit()
|
|
|
|
if graph:
|
|
graph.close()
|
|
stats["new"] = len(new_ids)
|
|
|
|
# Enrich + audit the new app-scan CVEs.
|
|
if new_ids:
|
|
try:
|
|
from app.services.audit_events import audit_new_vulnerabilities
|
|
audit_new_vulnerabilities(db, new_ids, source="app-scan")
|
|
db.commit()
|
|
except Exception as e:
|
|
logger.debug("app-cve detected-audit failed: %s", e)
|
|
try:
|
|
from app.models.vulnerability import Vulnerability
|
|
from app.services.enrichment_service import enrich_vulnerabilities
|
|
fresh = db.query(Vulnerability).filter(Vulnerability.id.in_(new_ids)).all()
|
|
if fresh:
|
|
enrich_vulnerabilities(db, fresh)
|
|
# New-CVE email notifications (was Wazuh/Nessus-only — app-scan
|
|
# findings silently skipped notifications). Same digest path.
|
|
from app.services.email_service import dispatch_new_vuln_notifications
|
|
stats["notifications"] = dispatch_new_vuln_notifications(db, fresh)
|
|
except Exception as e:
|
|
logger.debug("app-cve enrichment/notify failed: %s", e)
|
|
|
|
# Suppress Wazuh findings whose installed version is provably outside every
|
|
# cvelistV5 range. This used to hang off the nightly job only, so the same
|
|
# host came out differently depending on how the scan was started: the
|
|
# nightly run cleared those false positives, a manual run from the GUI left
|
|
# them standing. Scoped to the same asset_id as the scan itself, so a
|
|
# single-asset run stays a single-asset run.
|
|
# A fixed_version is written once and never overwritten, so a bad one
|
|
# sticks for the life of the finding — a Node 24.13.1 install was told
|
|
# to upgrade to "4.*", a release line taken from another entry of the same
|
|
# record. New scans no longer store those (see _fix_target), but the ones
|
|
# already written have to be cleared, or they stay wrong forever.
|
|
try:
|
|
from sqlalchemy import or_
|
|
from app.models.vulnerability import Vulnerability
|
|
from app.models.vulnerability_package import VulnerabilityPackage
|
|
wild = ["%*%", "%.x%"]
|
|
for model in (Vulnerability, VulnerabilityPackage):
|
|
n = (db.query(model)
|
|
.filter(or_(*[model.fixed_version.like(w) for w in wild]))
|
|
.update({model.fixed_version: None}, synchronize_session=False))
|
|
if n:
|
|
stats["fix_versions_cleared"] = stats.get("fix_versions_cleared", 0) + n
|
|
if stats.get("fix_versions_cleared"):
|
|
db.commit()
|
|
except Exception as e:
|
|
logger.warning("app-cve: clearing wildcard fix versions failed: %s", e)
|
|
|
|
if cve5_index:
|
|
try:
|
|
from app.services import cvelistv5_scan_service
|
|
fp = cvelistv5_scan_service.suppress_false_positives(db, asset_id=asset_id)
|
|
stats["fp_suppressed"] = fp.get("suppressed", 0)
|
|
except Exception as e:
|
|
logger.warning("app-cve: FP-suppression failed (non-fatal): %s", e)
|
|
|
|
logger.info("App CVE scan: %d assets, %d findings (%d new, %d auto-resolved, %d FP-suppressed)",
|
|
stats["assets"], stats["findings"], stats["new"],
|
|
stats.get("resolved", 0), stats.get("fp_suppressed", 0))
|
|
return stats
|