Files
vulncheck/app/services/m365_service.py
T
vulncheck dbad9a365e chore(rebrand): VulnCheck → TrueVuln
Renames the product name in every user-visible surface and internal
self-reference: page title, nav/shell, login/MFA pages, email templates and
subject prefixes ([VULNCHECK] → [TRUEVULN]), TOTP issuer label, report/PDF
headers, notification previews, outbound User-Agent/HTTP-Referer headers we
set ourselves, docs (README, ARCHITECTURE, PROJECT_OVERVIEW, DATABASE_SCHEMA,
README.DEV, TROUBLESHOOTING is untouched — see below), and .env.example
placeholder config (LDAP/OIDC/SAML example domains and paths).

Also renamed the on-disk cache file paths (/tmp/vulncheck-*.zip|csv|json →
/tmp/truevuln-*), kept consistent across the two files that share the
cvelistV5 ZIP cache path — first run after deploy re-downloads that ~557 MB
cache once (harmless, disposable).

Deliberately LEFT UNCHANGED (not branding — real external references or
infra identifiers; renaming the text without renaming the underlying thing
would just break/mislead):
- The actual Gitea repo URL/path (gitea.isuit.ch/vulncheck/vulncheck) and the
  README lines derived from it (git clone target dir, tree listing) — a real
  repo rename is a manual Gitea-side step (Settings → repository name) the
  user would need to do themselves, and existing clones would need
  `git remote set-url` after.
- The real support mailbox (support-vulncheck.sq9vd@passmail.net, in both
  README and TROUBLESHOOTING) and the Buy Me A Coffee link — both point to
  accounts that still exist under the old name; renaming the text alone
  wouldn't create new ones.
- GitNexus MCP resource URIs in CLAUDE.md/AGENTS.md (gitnexus://repo/
  vulncheck/...) — tied to GitNexus's own index name for this repo, not our
  branding; those files are untracked in this repo anyway.
- docker-compose.yml container/network/Postgres user+db names
  (vulnmanager-*) — explicit user decision: infra naming carries real
  deploy/data risk on an already-running instance and isn't part of the
  product-branding ask.
- The Tailwind color token class `vulncheck-blue` (frontend/app/globals.css)
  — invisible internal CSS variable name, renaming it would touch ~270
  className occurrences for zero user-visible benefit.

Verified: backend py_compile clean on every touched .py file; frontend tsc
clean (two pre-existing, unrelated errors remain: assets/page.tsx SVG title
prop, mfa-setup missing qrcode.react types). All diffs are exact-string
renames — no other changes riding along.
2026-07-07 16:34:40 +02:00

604 lines
22 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": "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