feat(app-scan): Windows OS CVEs via cvelistV5 bounded ranges (Server AND Client)

Corrects bf529ed, which claimed Microsoft products cannot use the CPE/cvelistV5
path. That was wrong, and the reasoning behind it was a sampling error: the
'no ranges' check looked at CVE-2013-3900 and the 'lessThan: publication' check
at CVE-2021-24072 — both ancient. Modern MS records carry real numeric ranges.
Re-verified on the CVE the tester cited:

  CVE-2026-47291 cvelistV5: 20/20 affected entries have a numeric lessThan,
                            0 'publication'
                 NVD:       22/24 microsoft cpeMatch have versionEndExcluding
  CVE-2026-45467 (SharePoint, current): lessThan 16.0.5556.1005 etc.

So there is no Server/Client split and no reason to special-case Microsoft.

Implementation reads cvelistV5, not NVD, for one concrete reason: NVD flattens
these to an END bound only, so a 1607 host (14393.x) sits inside the 22H2 range
(endExcluding 19045.7417) and false-positives. cvelistV5 keeps the range bounded
(10.0.22631.0 .. 10.0.22631.7219), and those bounds select the host's release by
themselves — no build->release table, Server and Client fall out for free.

- pattern products in the index (one key for the whole 'Windows 10|11|Server'
  family) so a new release doesn't need a registry row; anchored so SharePoint,
  Teams and '.NET Framework on Windows Server 2016' can't be swallowed
- scan_asset_os() flags straight from asset.os_version; runs for every Windows
  asset, no software inventory required
- index bumped to v4 (entries changed) so it rebuilds once
This commit is contained in:
2026-07-14 13:01:25 +02:00
parent d4ac987c03
commit 5703dfa806
2 changed files with 94 additions and 11 deletions
+21 -8
View File
@@ -119,14 +119,14 @@ _REGISTRY: List[tuple] = [
# 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.
# NOTE: Microsoft OS/products are deliberately NOT in this registry. NVD models
# them as rangeless CPEs (`cpe:2.3:o:microsoft:windows_server_2016:-:*` with no
# versionEndExcluding) and cvelistV5 MS records use `lessThan: "publication"` —
# neither carries the fixed BUILD, so neither can tell a patched host from an
# unpatched one. _in_range() correctly rejects those wildcard/`-` versions, so
# adding them here would match nothing while burning NVD quota. MSRC (KB +
# FixedBuild per product) is the only source with MS patch state — see the
# msrc remediation path.
# 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"}),
@@ -647,6 +647,19 @@ def run_app_cve_scan(db: Session, asset_id: Optional[int] = None) -> dict:
except Exception as e:
stats["errors"].append(f"asset {asset.id} os: {e}")
# Windows OS CVEs from cvelistV5's bounded build ranges — no software
# inventory needed, so this 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}")
# Package-level CVEs (Wazuh syscollector / Intune detectedApps).
packages: list = []
try:
+73 -3
View File
@@ -42,7 +42,7 @@ logger = logging.getLogger(__name__)
_ZIP_PATH = "/tmp/truevuln-cvelistv5-cache.zip"
_ZIP_URL = "https://github.com/CVEProject/cvelistV5/archive/refs/heads/main.zip"
_ZIP_TTL = 12 * 3600
_INDEX_SETTING = "cvelistv5_product_index_v3" # v3: entries now carry cvss/severity
_INDEX_SETTING = "cvelistv5_product_index_v4" # v4: pattern products (Windows OS)
_INDEX_TTL = timedelta(hours=26) # rebuilt nightly; a missed night still serves
# Curated registry: name-regex (installed software) → cvelistV5 (vendor,
@@ -96,6 +96,38 @@ for _e in _REGISTRY:
for _v, _p in _e["pairs"]:
_PAIR_TO_KEY[(_v.lower(), _p.lower())] = _e["key"]
# Pattern products — for families whose CVE records name one product per
# RELEASE, so an exact pair list would need dozens of rows and rot every time
# Microsoft ships a new one ("Windows 10 Version 1607", "Windows 11 Version
# 23H2", "Windows Server 2016 (Server Core installation)", ...).
#
# Windows is the OS case: the record's ranges are BOUNDED
# (version 10.0.22631.0 .. lessThan 10.0.22631.7219), so a host's build falls
# into exactly the range of its own release — the bounds do the release
# selection, no build→release table needed, and Server and Client are the same
# problem. (NVD flattens these to an end bound only, which is why the OS scan
# reads cvelistV5 and not NVD-CPE.)
_PRODUCT_PATTERNS: List[dict] = [
{"key": "windows", "vendor_re": r"^microsoft$",
"product_re": r"^windows\s+(10|11|server)\b"},
]
_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()
@@ -278,8 +310,7 @@ def build_product_index(db: Session) -> dict:
continue
cvss, sev = _cvss_from_record(data)
for aff in affected:
key = _PAIR_TO_KEY.get(((aff.get("vendor") or "").lower(),
(aff.get("product") or "").lower()))
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 [])]
@@ -436,6 +467,45 @@ def suppress_false_positives(db: Session, asset_id: Optional[int] = None) -> dic
return stats
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).
Works for Server AND Client without a build→release table: every affected
entry is a bounded range for one release (10.0.22631.0 .. 10.0.22631.7219),
so a host's build only ever falls inside its own release's range. Releases
that share a build line (Windows 10 1607 / Server 2016 = 14393; Windows 11
24H2 / Server 2025 = 26100) take the same fix anyway, so matching either is
correct.
"""
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
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 _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")}
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
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.