Files
vulncheck/app/services/msrc_scan_service.py
T
vulncheck 948589d07f fix(scan): cross-source auto-resolve, stale installed version, Citrix stubs
Three tester-reported issues:

1. Cross-confirmed findings never auto-resolved. Each reconcile (app-scan /
   defender / msrc) skipped findings any OTHER scanner also reported, so a
   Chrome CVE seen by app-scan AND Defender was closed by neither — a patched
   host (150.0.7871.125 installed, fix .115) kept an open finding forever.
   All three reconciles now use the Nessus-backfill contract instead: drop
   YOUR OWN source when no longer detected, mark patched (audit-logged) only
   once no source is left. A finding another scanner still reports stays open
   under that scanner. Verified both directions in a self-check.

2. Installed version went stale. The app-scan upsert only filled
   package_version when empty, so the row kept its first-ever version
   (tester: Firefox showed 'Installed: 150.0.3' while 152.0.5 was on the
   box). Re-detection now refreshes the version.

3. Citrix published-app registry stubs ('Firefox 1.0', vendor 'Delivered by
   Citrix') describe software NOT installed on the box and produced
   ancient-CVE false positives (CVE-2008-2798 against Firefox 1.0). Packages
   whose vendor names Citrix are skipped in both scanners; the stub vendor
   field is the discriminator, real installs keep their real vendor.
2026-07-21 14:22:50 +02:00

477 lines
20 KiB
Python

"""
MSRC-driven OS CVE detection — patch-level accurate, ahead of the Wazuh CTI feed.
Why this exists (and why NVD/cvelistV5 can't do it): Microsoft does not express
fixes as version ranges. NVD lists MS OS entries as rangeless CPEs
(`cpe:2.3:o:microsoft:windows_server_2016:-:*`, versionEndExcluding=null) and
cvelistV5 MS records use `lessThan: "publication"` — neither says which BUILD
carries the fix, so neither can tell a patched host from an unpatched one.
MSRC's CVRF does: each Type-2 remediation carries a `FixedBuild` plus the
`ProductID`s it applies to, e.g.
CVE-2026-33834 FixedBuild 10.0.14393.9140 ProductID ['10816','10855'] KB5087537
ProductTree: 10816 -> "Windows Server 2016"
10855 -> "Windows Server 2016 (Server Core installation)"
So: index (product → [cve, fixed_build]) from the monthly CVRF docs, then compare
an asset's installed OS build against the fixed build of its own servicing
branch. installed < fixed → affected. Same curated-and-precise contract as the
other scanners: only products we can map to an asset are indexed, nothing is
guessed, and a host that is patched is never flagged.
Scope: Windows Server only for now — its OS string names the product outright.
Client Windows (10/11) needs a build→release table before it can join.
"""
from __future__ import annotations
import json
import logging
import re
from datetime import datetime, timedelta
from typing import Dict, List, Optional
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
SOURCE_NAME = "msrc"
_INDEX_SETTING = "msrc_product_index_v1"
_INDEX_TTL = timedelta(hours=26) # rebuilt nightly; a missed night still serves
# Curated map: asset side (`match_re`) → MSRC ProductTree name (`msrc_re`).
#
# `kind` os = matched against asset.operating_system + asset.os_version
# pkg = matched against an installed-software name + its version
# `branch` True → a fix only speaks to hosts sharing its build prefix, because
# the 3rd segment IS the release (Windows Server 2016 = 14393;
# SharePoint 2019 = 10417 — verified stable across releases).
# False → the 3rd segment moves with every CU, so prefix-matching would
# silently never hit; identity comes from the NAME instead and
# the compare is a plain installed < fixed. Verified: SharePoint
# 2016 shows 5535/5539/5543/5552/5556 over six months.
#
# `msrc_re` is anchored so "Microsoft .NET Framework 4.8 on Windows Server 2016"
# (a *different* product that merely mentions the OS) can't match the OS family.
# Order matters — R2 must precede its base year.
#
# SharePoint 2013 is deliberately absent: it is EOL (2023-04-11) and Microsoft
# publishes no fixes for it, so there is no FixedBuild to compare — no amount of
# fix data can flag it. The EOL finding is the signal there (see eol_service).
_PRODUCTS: List[dict] = [
{"key": "ws2012r2", "kind": "os", "branch": True, "match_re": r"windows server\s*2012\s*r2",
"msrc_re": r"^windows server 2012 r2\b", "label": "Microsoft Windows Server 2012 R2"},
{"key": "ws2012", "kind": "os", "branch": True, "match_re": r"windows server\s*2012(?!\s*r2)",
"msrc_re": r"^windows server 2012\b(?!\s*r2)", "label": "Microsoft Windows Server 2012"},
{"key": "ws2016", "kind": "os", "branch": True, "match_re": r"windows server\s*2016",
"msrc_re": r"^windows server 2016\b", "label": "Microsoft Windows Server 2016"},
{"key": "ws2019", "kind": "os", "branch": True, "match_re": r"windows server\s*2019",
"msrc_re": r"^windows server 2019\b", "label": "Microsoft Windows Server 2019"},
{"key": "ws2022", "kind": "os", "branch": True, "match_re": r"windows server\s*2022",
"msrc_re": r"^windows server 2022\b", "label": "Microsoft Windows Server 2022"},
{"key": "ws2025", "kind": "os", "branch": True, "match_re": r"windows server\s*2025",
"msrc_re": r"^windows server 2025\b", "label": "Microsoft Windows Server 2025"},
# SharePoint — 2016, 2019 and Subscription Edition ALL report 16.0.x, so the
# year in the name is the only thing that tells the releases apart.
{"key": "sp2016", "kind": "pkg", "branch": False, "match_re": r"sharepoint.*\b2016\b",
"msrc_re": r"^microsoft sharepoint (enterprise )?server 2016\b",
"label": "Microsoft SharePoint Server 2016"},
{"key": "sp2019", "kind": "pkg", "branch": False, "match_re": r"sharepoint.*\b2019\b",
"msrc_re": r"^microsoft sharepoint server 2019\b",
"label": "Microsoft SharePoint Server 2019"},
{"key": "spse", "kind": "pkg", "branch": False, "match_re": r"sharepoint.*subscription",
"msrc_re": r"^microsoft sharepoint server subscription edition\b",
"label": "Microsoft SharePoint Server Subscription Edition"},
]
_OS_COMPILED = [(re.compile(p["match_re"], re.I), p) for p in _PRODUCTS if p["kind"] == "os"]
_PKG_COMPILED = [(re.compile(p["match_re"], re.I), p) for p in _PRODUCTS if p["kind"] == "pkg"]
_MSRC_COMPILED = [(re.compile(p["msrc_re"], re.I), p) for p in _PRODUCTS]
_BUILD_RE = re.compile(r"^\d+(\.\d+)+$")
def resolve_os(os_name: str) -> Optional[dict]:
"""Asset OS string → curated product entry (None = not ours to scan)."""
n = (os_name or "").strip().lower()
if not n:
return None
for rx, p in _OS_COMPILED:
if rx.search(n):
return p
return None
def _resolve_msrc_product(name: str) -> Optional[dict]:
n = (name or "").strip().lower()
for rx, p in _MSRC_COMPILED:
if rx.search(n):
return p
return None
def _btuple(b: str) -> Optional[tuple]:
if not b or not _BUILD_RE.match(b):
return None
try:
return tuple(int(x) for x in b.split("."))
except ValueError:
return None
def _branch(b: str) -> Optional[tuple]:
"""Servicing branch = build minus its revision, e.g.
10.0.14393.9140 → (10, 0, 14393). A fix only speaks to hosts on its own
branch: Server 2016 (14393) says nothing about Server 2019 (17763)."""
t = _btuple(b)
return t[:3] if t and len(t) >= 3 else None
# ---------- index ----------
def build_product_index(db: Session, months_back: Optional[int] = None) -> dict:
"""Walk the recent monthly CVRF docs → {product_key: [{cve, build, kb}]}."""
import httpx
from app.services.msrc_service import (
MSRC_BASE, HTTP_TIMEOUT, DEFAULT_MONTHS_BACK, SETTING_MONTHS_BACK, _setting_int,
)
months = months_back or _setting_int(db, SETTING_MONTHS_BACK, DEFAULT_MONTHS_BACK)
index: Dict[str, List[dict]] = {}
seen: set = set()
docs_done = 0
with httpx.Client(timeout=HTTP_TIMEOUT, headers={"Accept": "application/json"}) as client:
r = client.get(f"{MSRC_BASE}/updates")
r.raise_for_status()
doc_ids = [v["ID"] for v in (r.json().get("value") or [])][-months:]
for doc_id in doc_ids:
try:
resp = client.get(f"{MSRC_BASE}/cvrf/{doc_id}")
resp.raise_for_status()
doc = resp.json()
except Exception as e:
logger.warning("msrc-scan: doc %s failed: %s", doc_id, e)
continue
# ProductID → curated product key (only the ones we can map).
pid_key: Dict[str, str] = {}
for fp in (doc.get("ProductTree", {}) or {}).get("FullProductName", []) or []:
p = _resolve_msrc_product(fp.get("Value") or "")
if p and fp.get("ProductID"):
pid_key[str(fp["ProductID"])] = p["key"]
if not pid_key:
continue
for v in doc.get("Vulnerability", []) or []:
cve = (v.get("CVE") or "").strip().upper()
if not cve.startswith("CVE-"):
continue
for rem in v.get("Remediations", []) or []:
if rem.get("Type") != 2:
continue
build = (rem.get("FixedBuild") or "").strip()
if not _btuple(build):
continue # no usable build → tells us nothing about patch state
desc = str((rem.get("Description") or {}).get("Value", "") or "").strip()
kb = desc if desc.isdigit() else None
for pid in rem.get("ProductID") or []:
key = pid_key.get(str(pid))
if not key:
continue
sig = (key, cve, build)
if sig in seen:
continue
seen.add(sig)
index.setdefault(key, []).append({"cve": cve, "build": build, "kb": kb})
docs_done += 1
_store_index(db, index)
logger.info("msrc-scan: index built (%d docs) → %d products, %d fix entries",
docs_done, len(index), sum(len(v) for v in index.values()))
return index
def _store_index(db: Session, index: dict) -> None:
from app.models.setting import Setting
payload = json.dumps({"built_at": datetime.now().isoformat(), "index": index})
row = db.query(Setting).filter(Setting.key == _INDEX_SETTING).first()
if row:
row.value = payload
else:
db.add(Setting(key=_INDEX_SETTING, value=payload,
description="MSRC product→(CVE, FixedBuild) index (curated)"))
db.commit()
def load_index(db: Session, allow_stale: bool = True) -> Optional[dict]:
from app.models.setting import Setting
row = db.query(Setting).filter(Setting.key == _INDEX_SETTING).first()
if not row or not row.value:
return None
try:
blob = json.loads(row.value)
built = datetime.fromisoformat(blob.get("built_at"))
except Exception:
return None
if not allow_stale and datetime.now() - built > _INDEX_TTL:
return None
return blob.get("index") or {}
# ---------- scan ----------
def affected_cves(entries: List[dict], installed: str, branch_match: bool = True) -> List[dict]:
"""CVEs whose fix is newer than the installed build. Per CVE the NEWEST
fixed build wins — that's the one that actually has to be on the box.
branch_match: see _PRODUCTS. True → only fixes on the host's own build
prefix count (the prefix is the release). False → the prefix moves with
every CU, so identity already came from the product name and every fix for
that product applies (MS servicing is cumulative, so installed < fixed is
exactly the right test)."""
inst_t = _btuple(installed)
if not inst_t:
return []
inst_b = _branch(installed)
if branch_match and not inst_b:
return []
newest: Dict[str, dict] = {}
for e in entries:
bt = _btuple(e.get("build") or "")
if not bt:
continue
if branch_match and _branch(e["build"]) != inst_b:
continue # different servicing branch → says nothing about this host
cur = newest.get(e["cve"])
if cur is None or bt > _btuple(cur["build"]):
newest[e["cve"]] = e
return [e for e in newest.values() if inst_t < _btuple(e["build"])]
def resolve_package(name: str) -> Optional[dict]:
"""Installed-software name → curated MSRC product (None = not ours)."""
n = (name or "").strip().lower()
if not n:
return None
for rx, p in _PKG_COMPILED:
if rx.search(n):
return p
return None
def scan_asset(db: Session, asset, index: dict, new_ids: list,
touched: Optional[set] = None) -> int:
"""Flag MS OS CVEs whose FixedBuild is ahead of this host's build."""
if not index:
return 0
prod = resolve_os(asset.operating_system or "")
if not prod:
return 0
entries = index.get(prod["key"]) or []
if not entries:
return 0
installed = (asset.os_version or "").strip()
return _flag(db, asset, prod, installed, entries, new_ids, touched)
def scan_asset_packages(db: Session, asset, packages: list, index: dict,
new_ids: list, touched: Optional[set] = None) -> int:
"""Same fixed-build compare for installed MS software (SharePoint today).
Called from the app-CVE scan, which already has the inventory in hand."""
if not index:
return 0
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 or not version:
continue
prod = resolve_package(name)
if not prod:
continue
# One product reports several components (Core / Lang Pack / SQL
# Express) all carrying the same build — scan the product once.
dedup = (prod["key"], version)
if dedup in seen:
continue
seen.add(dedup)
entries = index.get(prod["key"]) or []
if entries:
count += _flag(db, asset, prod, version, entries, new_ids, touched)
return count
def _flag(db: Session, asset, prod: dict, installed: str, entries: List[dict],
new_ids: list, touched: Optional[set]) -> int:
hits = affected_cves(entries, installed, branch_match=prod.get("branch", True))
count = 0
for h in hits:
if touched is not None:
touched.add(h["cve"])
try:
if _upsert(db, asset, prod["label"], installed, h, new_ids):
count += 1
except Exception as e:
logger.debug("msrc-scan upsert failed (%s on %s): %s", h["cve"], asset.id, e)
return count
def _upsert(db: Session, asset, product: str, installed: str, hit: dict, new_ids: list) -> bool:
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
cve_id = hit["cve"]
fixed = hit["build"]
kb = hit.get("kb")
existing = (db.query(Vulnerability)
.filter(Vulnerability.cve_id == cve_id, Vulnerability.asset_id == asset.id)
.first())
if existing:
existing.add_source(SOURCE_NAME)
if not existing.package_name:
existing.package_name = product[:255]
if not existing.package_version:
existing.package_version = installed[:100]
if not existing.fixed_version:
existing.fixed_version = fixed
if existing.status == VulnerabilityStatus.patched:
existing.status = VulnerabilityStatus.open
existing.patched_at = None
try:
existing.refresh_scores()
except Exception:
pass
return False
title = f"{product}{cve_id}" + (f" (KB{kb})" if kb else "")
row = Vulnerability(
cve_id=cve_id, asset_id=asset.id,
status=VulnerabilityStatus.open,
title=title[:500],
description=(f"MSRC reports {product} is fixed in build {fixed}"
+ (f" via KB{kb}" if kb else "")
+ f"; this host reports {installed}."),
package_name=product[:255], package_version=installed[:100],
fixed_version=fixed,
detected_at=datetime.now(),
sources=json.dumps([SOURCE_NAME]), first_detected_by=SOURCE_NAME,
)
db.add(row)
db.flush()
try:
row.refresh_scores()
except Exception:
pass
new_ids.append(row.id)
return True
def _resolve_stale(db: Session, asset, touched: set, considered: set) -> int:
"""A msrc-only finding no longer reported means the host caught up past the
FixedBuild → patched. Same contract as the app-scan reconcile: never touch
findings another scanner also reports.
`considered` = the product labels THIS pass actually evaluated. Without it
the OS pass would close every SharePoint finding it never looked at (and
vice versa), since neither pass touches the other's CVEs."""
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
if not considered:
return 0
rows = (db.query(Vulnerability)
.filter(Vulnerability.asset_id == asset.id,
Vulnerability.status == VulnerabilityStatus.open,
Vulnerability.sources.contains('"msrc"'),
Vulnerability.package_name.in_(list(considered)))
.all())
resolved = 0
for v in rows:
if v.cve_id in touched:
continue
# Drop OUR source; close only when nobody else still reports it (the
# skip-if-cross-confirmed rule deadlocked across reconciles).
v.remove_source(SOURCE_NAME)
if v.source_list:
continue
old = v.status
v.status = VulnerabilityStatus.patched
v.patched_at = datetime.now()
resolved += 1
try:
from app.routers.vulnerabilities import log_vulnerability_change
log_vulnerability_change(
db, None, v.id, old, v.status,
reason=f"MSRC scan: {asset.hostname} is now at or past the fixed build",
cve_id=v.cve_id, source="msrc_scan",
)
except Exception as e:
logger.warning("audit log for msrc auto-resolve failed (vuln_id=%s): %s", v.id, e)
return resolved
def resolve_stale_packages(db: Session, asset, touched: set) -> int:
"""Reconcile the package (pkg-kind) MSRC findings after a package scan.
Considers ALL pkg product labels — we just saw the full inventory, so a
product that vanished (uninstalled) should resolve too."""
labels = {p["label"] for p in _PRODUCTS if p["kind"] == "pkg"}
return _resolve_stale(db, asset, touched, labels)
def run_msrc_scan(db: Session, asset_id: Optional[int] = None) -> dict:
"""Scan Windows-Server assets against the MSRC fixed-build index."""
from app.models.asset import Asset
stats = {"assets": 0, "findings": 0, "new": 0, "resolved": 0, "errors": []}
index = load_index(db)
if not index:
logger.info("msrc-scan: index missing — building now (one-time, then nightly)")
try:
index = build_product_index(db) or {}
except Exception as e:
stats["errors"].append(f"index build failed: {e}")
return stats
if not index:
return stats
new_ids: list = []
q = db.query(Asset)
if asset_id is not None:
q = q.filter(Asset.id == asset_id)
for asset in q.all():
prod = resolve_os(asset.operating_system or "")
if not prod:
continue
if not (asset.os_version or "").strip():
continue # no build → nothing to compare
touched: set = set()
try:
stats["findings"] += scan_asset(db, asset, index, new_ids, touched=touched)
# Only this asset's OS product — the package pass owns its own labels.
stats["resolved"] += _resolve_stale(db, asset, touched, {prod["label"]})
stats["assets"] += 1
db.commit()
except Exception as e:
db.rollback()
stats["errors"].append(f"asset {asset.id}: {e}")
stats["new"] = len(new_ids)
if new_ids:
try:
from app.services.audit_events import audit_new_vulnerabilities
audit_new_vulnerabilities(db, new_ids, source=SOURCE_NAME)
db.commit()
except Exception as e:
logger.debug("msrc-scan detected-audit failed: %s", e)
try:
from app.models.vulnerability import Vulnerability
from app.services.enrichment_service import enrich_vulnerabilities
from app.services.email_service import dispatch_new_vuln_notifications
fresh = db.query(Vulnerability).filter(Vulnerability.id.in_(new_ids)).all()
if fresh:
enrich_vulnerabilities(db, fresh)
stats["notifications"] = dispatch_new_vuln_notifications(db, fresh)
except Exception as e:
logger.debug("msrc-scan enrichment/notify failed: %s", e)
logger.info("MSRC scan: %d assets, %d findings (%d new, %d auto-resolved)",
stats["assets"], stats["findings"], stats["new"], stats["resolved"])
return stats