Files
vulncheck/app/services/m365_service.py
T
vulncheck 0b71453c48 chore(release): drop personal attribution from comments and docstrings
Comments across the codebase credited one individual by role and, in places,
described that person's own machines: which SQL Server versions a host ran,
which devices were enrolled, what a particular dashboard showed, how many
findings sat open on which server. In a public repository that reads as a
profile of someone's unpatched estate.

The observations are why the code looks the way it does, so they stay. Every
CVE id, version, build number, count and date is preserved, as are the
verbatim quotes that motivated specific sort and filter rules — only the
attribution changes, to "field report", "observed", "a host". A local
variable in tests/test_autodesk_year.py was renamed for the same reason; its
value and every assertion around it are byte-identical.

PROJECT_OVERVIEW.md additionally loses a subtitle naming the kind of
organisation this was built for, and a support section pointing at an
internal team, both replaced with neutral wording.

Comments, docstrings and markdown prose only: 74 files, 200 lines, one-for-one
swaps. detect_changes reports 104 touched symbols and zero affected execution
flows, and all 55 test scripts pass. Nothing here needs re-testing.
2026-08-26 15:34:05 +02:00

687 lines
26 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 a real-world 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 (field 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:
"""Field 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 _os_owned_cve_ids(db: Session) -> set:
"""CVE ids the cvelistV5 Windows-OS registry owns. The MS 365 Apps page
dumps OS-level components (GDI, MSXML, …) under its "Office suite" heading —
CVEs that are really Windows-OS bugs Office just bundles (e.g.
CVE-2026-50387, a Windows GDI vuln). Those belong to scan_asset_os, which
knows the correct build + MSRC KB; attributing them to M365 is wrong AND
upsert clobbers the correct OS finding on the same (cve, asset) row."""
try:
from app.services import cvelistv5_scan_service
idx = cvelistv5_scan_service.load_index(db) or {}
return {(e.get("cve") or "").upper()
for e in (idx.get("windows") or []) if e.get("cve")}
except Exception as e:
logger.debug("M365: OS-CVE dedup index unavailable: %s", e)
return set()
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)
from app.services.audit_events import reopen_if_patched
reopen_if_patched(db, existing, reason="Microsoft 365 Apps check reports this CVE again", source="m365_check")
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)
# ============================================================
_M365_SOURCE = "microsoft365-apps"
def _resolve_stale_m365(db: Session, asset, still_affected: set) -> int:
"""Mark M365-only OPEN findings patched when the host's current build has
caught up (CVE no longer in the missing set). Without this the check only
ever ADDED rows: a host that updated Office kept the old finding open with a
stale installed build forever (upsert refreshes package_version only while
the CVE is still missing). Mirrors the Defender/app-scan reconcile: drop OUR
source, close only when nobody else still reports it. Caller guarantees an
M365 install was actually seen on this asset (else 'patched' is unprovable).
"""
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
rows = (db.query(Vulnerability)
.filter(Vulnerability.asset_id == asset.id,
Vulnerability.status == VulnerabilityStatus.open,
Vulnerability.sources.contains(f'"{_M365_SOURCE}"'))
.all())
resolved = 0
for v in rows:
if v.cve_id and v.cve_id.upper() in still_affected:
continue
v.remove_source(_M365_SOURCE)
if v.source_list:
continue
old_status = 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_status, v.status,
reason=f"Installed Microsoft 365 Apps build on {asset.hostname} "
f"now includes the fix for this CVE",
cve_id=v.cve_id, source="m365_check",
)
except Exception as e:
logger.warning("audit log for M365 auto-resolve failed (vuln_id=%s): %s", v.id, e)
return resolved
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)
os_cve_ids = _os_owned_cve_ids(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
m365_install_seen = False
keep_open: set = set() # CVEs still missing on the current build
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
m365_install_seen = True
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"]:
if cve_id.upper() in os_cve_ids:
continue # Windows-OS CVE — owned by scan_asset_os, not M365
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())
keep_open.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
# Resolve findings the host has since patched — only when we actually
# saw an M365 install (else 'patched' is unprovable, same guard as the
# Defender/app-scan reconcile against an empty read).
if m365_install_seen:
stats["resolved"] = stats.get("resolved", 0) + _resolve_stale_m365(
db, asset, keep_open)
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
os_cve_ids = _os_owned_cve_ids(db)
count = 0
touched: set = set()
seen: set = set()
m365_install_seen = False
keep_open: 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
m365_install_seen = True
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"]:
if cve_id.upper() in os_cve_ids:
continue # Windows-OS CVE — owned by scan_asset_os, not M365
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())
keep_open.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)
# Resolve findings the host has since patched (see run_m365_check).
if m365_install_seen:
_resolve_stale_m365(db, asset, keep_open)
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