""" Microsoft 365 Apps CVE detection (Plan P). Microsoft 365 Apps (formerly Office 365 ProPlus) security fixes are NOT published to NVD and are NOT detected by Wazuh's vulnerability detector — they only live on one human-readable Microsoft Learn page: https://learn.microsoft.com/en-us/officeupdates/microsoft365-apps-security-updates There is no Microsoft API. So we parse that page, learn the latest patched build per update channel, compare it against the build Wazuh's syscollector reports as installed, and create real-CVE vulnerability rows for every monthly update the host is behind on. Build logic (verified against the tester's example): installed 16.0.19929.20172 vs Monthly Enterprise Channel 19929.20162 -> 20172 >= 20162 -> UNAFFECTED (no CVEs) installed < a section's channel build -> AFFECTED -> attach that section's CVEs (union across every section the host is behind on). Channel mapping (tester's rule): the deployed channel isn't in the syscollector name, so we approximate it from the product name — "...enterprise..." -> Monthly Enterprise Channel, else Current Channel. """ import json import logging import re from datetime import datetime, timedelta from typing import Dict, List, Optional, Tuple import httpx import lxml.html from sqlalchemy.orm import Session from app.models.setting import Setting logger = logging.getLogger(__name__) M365_SECURITY_URL = ( "https://learn.microsoft.com/en-us/officeupdates/" "microsoft365-apps-security-updates" ) # ---------- cache (settings table) ---------- M365_CACHE_KEY = "m365_security_cache" M365_CACHE_TS_KEY = "m365_security_cache_updated_at" M365_TTL_HOURS = 24 # ---------- toggle ---------- SETTING_M365_ENABLED = "m365_detection_enabled" HTTP_TIMEOUT = 30.0 # Build line: "Monthly Enterprise Channel: Version 2604 (Build 19929.20162)" _BUILD_RE = re.compile( r"([A-Za-z0-9()/ \-]+?):\s*Version\s+(\d{3,4})\s*\(\s*Build\s+(\d+\.\d+)\s*\)" ) # Month-day-year heading that delimits each monthly section. _DATE_RE = re.compile( r"\b(January|February|March|April|May|June|July|August|September|" r"October|November|December)\s+(\d{1,2}),\s+(\d{4})\b" ) _CVE_RE = re.compile(r"CVE-\d{4}-\d{4,}", re.IGNORECASE) # Product-name -> channel approximation. CHANNEL_MONTHLY_ENTERPRISE = "Monthly Enterprise Channel" CHANNEL_CURRENT = "Current Channel" class M365Error(Exception): """Raised when the M365 security page cannot be fetched/parsed.""" # ============================================================ # build helpers # ============================================================ def parse_build(version: str) -> Optional[Tuple[int, int]]: """'16.0.19929.20172' or '19929.20172' -> (19929, 20172). Microsoft 365 build numbers are the last two dotted segments (BBBBB.RRRRR). The leading '16.0.' is the Office major and is constant, so we ignore it. """ if not version: return None nums = re.findall(r"\d+", version) if len(nums) < 2: return None try: return int(nums[-2]), int(nums[-1]) except ValueError: return None def channel_for_product(product_name: str) -> str: """Tester's rule: name contains 'enterprise' -> MEC, else Current.""" return ( CHANNEL_MONTHLY_ENTERPRISE if "enterprise" in (product_name or "").lower() else CHANNEL_CURRENT ) def is_m365_apps(product_name: str) -> bool: """True for syscollector entries like 'Microsoft 365 Apps for enterprise'.""" n = (product_name or "").lower() return "microsoft 365 apps" in n or "office 365 proplus" in n # ============================================================ # page fetch + parse # ============================================================ def _parse_security_page(html: str) -> List[dict]: """Parse the MS365 security page into a list of monthly releases. Each release: { "date": "May 12, 2026", "channel_builds": {channel_name: [(major, rev), ...]}, # max = newest "cves": ["CVE-2026-40361", ...], # every CVE in the section } Releases are returned in page order (newest first). """ # Flatten to text in document order. The page is a linear sequence of # date headings -> channel/build lines -> product headings -> CVE # bullets, so segmenting the flattened text by date heading is robust # against markup churn. doc = lxml.html.fromstring(html) for bad in doc.xpath("//script | //style | //nav | //header | //footer"): bad.getparent().remove(bad) body = doc.xpath("//main") or [doc] text = body[0].text_content() # Find date-heading anchors and slice between them. matches = list(_DATE_RE.finditer(text)) releases: List[dict] = [] for i, m in enumerate(matches): start = m.end() end = matches[i + 1].start() if i + 1 < len(matches) else len(text) section = text[start:end] date_label = f"{m.group(1)} {m.group(2)}, {m.group(3)}" channel_builds: Dict[str, List[Tuple[int, int]]] = {} for bm in _BUILD_RE.finditer(section): channel = bm.group(1).strip() build = parse_build(bm.group(3)) if build: channel_builds.setdefault(channel, []).append(build) if not channel_builds: # Not a real release section (e.g. intro paragraph mentioning # a date) — skip. continue cves = sorted({c.upper() for c in _CVE_RE.findall(section)}) if not cves: continue releases.append({ "date": date_label, "channel_builds": channel_builds, "cves": cves, }) return releases def _load_cache(db: Session) -> Optional[List[dict]]: ts = db.query(Setting).filter(Setting.key == M365_CACHE_TS_KEY).first() cache = db.query(Setting).filter(Setting.key == M365_CACHE_KEY).first() if not ts or not cache or not cache.value: return None try: if datetime.now() - datetime.fromisoformat(ts.value) > timedelta(hours=M365_TTL_HOURS): return None return json.loads(cache.value) except (ValueError, json.JSONDecodeError): return None def _store_cache(db: Session, releases: List[dict]) -> None: s = db.query(Setting).filter(Setting.key == M365_CACHE_KEY).first() if s: s.value = json.dumps(releases) else: db.add(Setting(key=M365_CACHE_KEY, value=json.dumps(releases), description="MS365 Apps security-updates parse cache (24h)")) ts = db.query(Setting).filter(Setting.key == M365_CACHE_TS_KEY).first() if ts: ts.value = datetime.now().isoformat() else: db.add(Setting(key=M365_CACHE_TS_KEY, value=datetime.now().isoformat(), description="Timestamp of last MS365 page parse")) db.commit() def fetch_security_data(db: Session, force_refresh: bool = False) -> List[dict]: """Return parsed monthly releases, cached 24h in the settings table.""" if not force_refresh: cached = _load_cache(db) if cached is not None: return cached try: with httpx.Client(timeout=HTTP_TIMEOUT, follow_redirects=True, headers={"User-Agent": "TrueVuln/1.0"}) as client: resp = client.get(M365_SECURITY_URL) resp.raise_for_status() html = resp.text except httpx.HTTPError as e: raise M365Error(f"could not fetch MS365 security page: {e}") from e releases = _parse_security_page(html) if not releases: raise M365Error("MS365 page parsed to zero releases — layout changed?") _store_cache(db, releases) logger.info("MS365: parsed %d monthly releases", len(releases)) return releases # ============================================================ # detection # ============================================================ def _channel_max(release: dict, channel: str) -> Optional[Tuple[int, int]]: """Newest (max) build for `channel` in a release, as a tuple.""" builds = release.get("channel_builds", {}).get(channel) if not builds: return None # builds may be lists from JSON -> normalise to tuples return max(tuple(b) for b in builds) def detect_missing_cves( releases: List[dict], *, installed_version: str, channel: str, ) -> dict: """Compare an installed M365 build against the parsed releases. Returns { "affected": bool, "installed_build": "19929.20172" or None, "latest_build": "19929.20162" or None, # newest patched, this channel "missing_cves": [ ... ], # union, deduped "behind_releases": [ "May 12, 2026", ... ], } """ out = { "affected": False, "installed_build": None, "latest_build": None, "missing_cves": [], "behind_releases": [], } installed = parse_build(installed_version) if not installed: return out out["installed_build"] = f"{installed[0]}.{installed[1]}" # Newest patched build for this channel across the whole page. channel_builds = [b for r in releases if (b := _channel_max(r, channel))] if not channel_builds: return out latest = max(channel_builds) out["latest_build"] = f"{latest[0]}.{latest[1]}" if installed >= latest: return out # fully patched -> unaffected # Behind: union CVEs from every section whose channel build the host # has not reached. out["affected"] = True cve_set: set = set() for r in releases: b = _channel_max(r, channel) if b and installed < b: cve_set.update(r.get("cves", [])) out["behind_releases"].append(r.get("date")) out["missing_cves"] = sorted(cve_set) return out def upsert_m365_vulnerability( db: Session, *, asset_id: int, cve_id: str, product_name: str, installed_version: str, fixed_build: Optional[str], ) -> Tuple[Optional[int], bool]: """Create/refresh a real-CVE M365 vuln row. Returns (id, was_created). CVSS/severity are left as a neutral placeholder; the nightly enrichment (EPSS/KEV/NVD dates) and the Correct-CVSS job refine them. These are real CVE ids, so they enrich like any other CVE. """ from app.models.vulnerability import ( Vulnerability, VulnerabilitySeverity, VulnerabilityStatus, ) cve_id = cve_id.upper() existing = ( db.query(Vulnerability) .filter(Vulnerability.cve_id == cve_id, Vulnerability.asset_id == asset_id) .first() ) title = f"{product_name} — {cve_id} (Microsoft 365 Apps security update)" desc = ( f"{cve_id} affects {product_name} and is fixed by a Microsoft 365 " f"Apps security update not yet applied on this host.\n" f"Installed build: {installed_version}. Fixed in build: " f"{fixed_build or 'unknown'} or later — update via the configured " f"Office update channel.\n\n" f"Detection source: this finding comes from the host's installed " f"Microsoft 365 Apps build (Wazuh syscollector inventory) compared " f"against the Microsoft 365 Apps security-updates release notes — " f"M365 Apps fixes are NOT published to NVD and are NOT seen by " f"Wazuh's vulnerability detector.\n" f"Severity / CVSS / dates are enriched from MSRC + CISA Vulnrichment " f"/ cvelistV5 for this real CVE id (see the Remediation section for " f"the Microsoft (MSRC) KB / advisory details)." ) if existing: existing.title = title[:500] existing.description = desc existing.package_name = product_name[:255] existing.package_version = installed_version[:100] existing.fixed_version = (fixed_build or None) if existing.status == VulnerabilityStatus.patched: existing.status = VulnerabilityStatus.open existing.patched_at = None existing.detected_at = datetime.now() try: existing.refresh_scores() except Exception: pass return existing.id, False vuln = Vulnerability( cve_id=cve_id, asset_id=asset_id, cvss_score=None, severity=VulnerabilitySeverity.medium, # placeholder; enrichment refines status=VulnerabilityStatus.open, title=title[:500], description=desc, package_name=product_name[:255], package_version=installed_version[:100], fixed_version=(fixed_build or None), detected_at=datetime.now(), sources='["microsoft365-apps"]', first_detected_by="m365_check", ) db.add(vuln) db.flush() try: vuln.refresh_scores() except Exception: pass # Revisionssicher: initial detected-event for the new M365 finding. try: from app.services.audit_events import audit_new_vulnerabilities audit_new_vulnerabilities(db, [vuln.id], source="m365_check") except Exception: pass return vuln.id, True # ============================================================ # orchestration (shared by the endpoint and the nightly job) # ============================================================ def run_m365_check(db: Session, wazuh, asset_id: Optional[int] = None) -> dict: """Walk Wazuh-linked assets, detect M365-Apps CVE exposure, upsert rows. `wazuh` is an already-configured WazuhClient (the caller owns its lifecycle, matching the eol-check pattern). """ from app.models.asset import Asset releases = fetch_security_data(db) q = db.query(Asset).filter(Asset.wazuh_agent_id.isnot(None)) if asset_id is not None: q = q.filter(Asset.id == asset_id) assets = q.all() stats = { "assets_scanned": 0, "m365_installs": 0, "assets_affected": 0, "cve_findings_total": 0, "cve_findings_new": 0, "releases_parsed": len(releases), "errors": [], } touched_cves: set = set() for asset in assets: try: pkgs = wazuh.get_packages(asset.wazuh_agent_id) or [] except Exception as e: stats["errors"].append(f"asset {asset.id} ({asset.hostname}): {e}") continue stats["assets_scanned"] += 1 # An asset can list the same product per language pack (de-de, # en-us, .proof, ...) — collapse to one detection per build. seen_builds: set = set() asset_affected = False for pkg in pkgs: name = (pkg.get("name") or "").strip() version = (pkg.get("version") or "").strip() if not name or not version or not is_m365_apps(name): continue stats["m365_installs"] += 1 channel = channel_for_product(name) key = (channel, version) if key in seen_builds: continue seen_builds.add(key) result = detect_missing_cves( releases, installed_version=version, channel=channel ) if not result["affected"]: continue asset_affected = True for cve_id in result["missing_cves"]: try: _, created = upsert_m365_vulnerability( db, asset_id=asset.id, cve_id=cve_id, product_name=name, installed_version=version, fixed_build=result["latest_build"], ) stats["cve_findings_total"] += 1 touched_cves.add(cve_id.upper()) if created: stats["cve_findings_new"] += 1 except Exception as e: logger.warning( "M365 upsert failed (%s on asset %s): %s", cve_id, asset.id, e, ) if asset_affected: stats["assets_affected"] += 1 db.commit() # Pull real metrics for the M365 CVEs right at check-in: these are real # CVE ids absent from Wazuh/NVD, so the CVSS/severity placeholder is # corrected from the vulnrichment → cvelistV5 → NVD cascade, and dates # via the enrichment service. The custom source note in `description` # is preserved (the override path never touches description). if touched_cves: cve_list = sorted(touched_cves) try: from app.services.vuln_override_service import correct_vulnerability_scores cstats = correct_vulnerability_scores(db, cve_ids=cve_list) stats["cvss_corrected"] = cstats.get("updated", 0) except Exception as e: logger.warning("M365: CVSS correction failed (non-fatal): %s", e) try: from app.models.vulnerability import Vulnerability from app.services.enrichment_service import enrich_vulnerabilities fresh = db.query(Vulnerability).filter(Vulnerability.cve_id.in_(cve_list)).all() if fresh: enrich_vulnerabilities(db, fresh) # EPSS/KEV/EUVD + NVD dates except Exception as e: logger.warning("M365: enrichment failed (non-fatal): %s", e) try: stats["meta_filled"] = apply_real_cve_metadata(db, cve_list) except Exception as e: logger.warning("M365: real CVE metadata fill failed (non-fatal): %s", e) logger.info( "M365 check: %d assets scanned, %d installs, %d affected, " "%d CVE rows (%d new), %d cvss-corrected", stats["assets_scanned"], stats["m365_installs"], stats["assets_affected"], stats["cve_findings_total"], stats["cve_findings_new"], stats.get("cvss_corrected", 0), ) return stats _M365_SOURCE_NOTE = ( "\n\n— Detected via the Microsoft 365 Apps security-updates page " "(installed build vs. patched channel build; not published to NVD and " "not seen by Wazuh). Title/description from CVE.org cvelistV5; " "severity/CVSS/dates via MSRC + CISA Vulnrichment." ) def _fetch_cve_title_desc(cve_id: str) -> Tuple[Optional[str], Optional[str]]: """Real CVE title + English description from CVE.org cvelistV5 raw.""" m = re.fullmatch(r"CVE-(\d{4})-(\d+)", cve_id.upper()) if not m: return None, None year, num = m.group(1), m.group(2) url = (f"https://raw.githubusercontent.com/CVEProject/cvelistV5/main/" f"cves/{year}/{int(num) // 1000}xxx/{cve_id.upper()}.json") try: with httpx.Client(timeout=15.0, follow_redirects=True, headers={"User-Agent": "TrueVuln/1.0"}) as c: r = c.get(url) if r.status_code != 200: return None, None cna = (r.json().get("containers") or {}).get("cna") or {} except (httpx.HTTPError, ValueError): return None, None title = cna.get("title") desc = None for d in cna.get("descriptions") or []: if (d.get("lang") or "").lower().startswith("en"): desc = d.get("value") break return title, desc def apply_real_cve_metadata(db: Session, cve_ids: list) -> int: """Fill the REAL CVE title + description (cvelistV5) on M365 findings, keeping a trailing note that the finding originated from the M365 Apps source. CVSS-correction deliberately leaves title/description alone, so this runs separately. Returns rows updated. Caller commits.""" from app.models.vulnerability import Vulnerability updated = 0 for cve_id in sorted({c.upper() for c in cve_ids if c}): title, desc = _fetch_cve_title_desc(cve_id) if not title and not desc: continue rows = ( db.query(Vulnerability) .filter( Vulnerability.cve_id == cve_id, Vulnerability.first_detected_by == "m365_check", ) .all() ) for v in rows: if title: v.title = title[:500] if desc: v.description = desc.strip() + _M365_SOURCE_NOTE updated += 1 if updated: db.commit() return updated def run_m365_for_packages(db: Session, asset, packages: list) -> int: """Source-agnostic M365-Apps CVE detection for one asset's installed apps (e.g. Intune detectedApps). Same build-vs-channel logic as run_m365_check; pulls real metrics for new CVEs. Returns findings upserted. Caller commits.""" try: releases = fetch_security_data(db) except M365Error as e: logger.debug("M365-for-packages: security data unavailable: %s", e) return 0 count = 0 touched: set = set() 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 or not is_m365_apps(name): continue channel = channel_for_product(name) key = (channel, version) if key in seen: continue seen.add(key) result = detect_missing_cves(releases, installed_version=version, channel=channel) if not result["affected"]: continue for cve_id in result["missing_cves"]: try: upsert_m365_vulnerability( db, asset_id=asset.id, cve_id=cve_id, product_name=name, installed_version=version, fixed_build=result["latest_build"], ) count += 1 touched.add(cve_id.upper()) except Exception as e: logger.warning("M365-for-packages upsert failed (%s on asset %s): %s", cve_id, asset.id, e) if touched: try: from app.services.vuln_override_service import correct_vulnerability_scores correct_vulnerability_scores(db, cve_ids=sorted(touched)) except Exception as e: logger.debug("M365-for-packages CVSS correction failed: %s", e) try: apply_real_cve_metadata(db, sorted(touched)) except Exception as e: logger.debug("M365-for-packages metadata fill failed: %s", e) return count