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:
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user