Files
vulncheck/app/services/m365_service.py
T
vulncheck 45524f7f8a feat(m365): detect Microsoft 365 Apps CVEs not in NVD/Wazuh (Plan P)
Tester: M365 Apps security fixes never reach NVD and are invisible to
Wazuh's vulnerability detector — they only live on the Microsoft Learn
"Microsoft 365 Apps security updates" page. No Microsoft API exists.

New app/services/m365_service.py:
  - fetch_security_data(): parse that page into monthly releases
    (channel->build map + CVE list), cached 24h in settings.
  - parse_build("16.0.19929.20172") -> (19929, 20172); compares the last
    two dotted build segments numerically.
  - channel_for_product(): tester's rule — name contains "enterprise" ->
    Monthly Enterprise Channel, else Current Channel.
  - detect_missing_cves(): installed >= newest channel build -> UNAFFECTED;
    otherwise union the CVEs of every monthly section the host is behind.
  - upsert_m365_vulnerability(): real-CVE rows (enrichable like any CVE),
    placeholder severity refined by nightly enrichment / Correct-CVSS.
  - run_m365_check(): walk Wazuh-linked assets, collapse per-language
    duplicates, upsert.

Verified against the live page + tester's example: installed
16.0.19929.20172 vs MEC 19929.20162 -> UNAFFECTED (0 CVEs); an older
build -> the month's 15 CVEs. 92 releases parsed cleanly.

Wired up:
  - POST /api/v1/vulnerabilities/m365-check (synchronous, RequireEditor).
  - Nightly job m365_check_nightly at 03:20 UTC.
  - "M365 CVEs" button on the vulnerabilities page.

No migration — uses existing vulnerabilities + settings tables.
2026-06-03 13:34:42 +02:00

445 lines
15 KiB
Python

"""
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": "VulnCheck/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} is fixed by a Microsoft 365 Apps security update not yet "
f"applied on this host. Installed build {installed_version}; patched "
f"in build {fixed_build or 'unknown'} or later. Source: Microsoft 365 "
f"Apps security updates page (not published to NVD / not seen by Wazuh)."
)
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
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": [],
}
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
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()
logger.info(
"M365 check: %d assets scanned, %d installs, %d affected, "
"%d CVE rows (%d new)",
stats["assets_scanned"], stats["m365_installs"],
stats["assets_affected"], stats["cve_findings_total"],
stats["cve_findings_new"],
)
return stats