diff --git a/app/services/app_cve_scanner_service.py b/app/services/app_cve_scanner_service.py index c8c7e2c..55b5120 100644 --- a/app/services/app_cve_scanner_service.py +++ b/app/services/app_cve_scanner_service.py @@ -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: diff --git a/app/services/cvelistv5_scan_service.py b/app/services/cvelistv5_scan_service.py index f6f7b2c..0584760 100644 --- a/app/services/cvelistv5_scan_service.py +++ b/app/services/cvelistv5_scan_service.py @@ -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.