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.
687 lines
26 KiB
Python
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
|