feat(m365): Office 2021 / 2024 CVEs from their own build lines

The Microsoft 365 Apps security-updates page lists a build line per
perpetual edition ("Office 2024 Retail", "Office LTSC 2024 Volume
Licensed", same for 2021), but only "Microsoft 365 Apps" entries were
checked, so Office 2021 / 2024 hosts got no CVEs. A suite entry with
"LTSC" in its name is measured against the Volume Licensed line, any
other against Retail. The lines never share a major build, so a build
off its line is skipped instead of reading as behind on every section.

EOL: Office 2019 and 2021 (Retail and LTSC) already resolved as end of
support through endoflife.date; LTSC 2024 now lands on its own
"2024-ltsc" release instead of Retail 2024 (same date).
This commit is contained in:
2026-10-20 15:07:22 +02:00
parent ea79831532
commit 4a04c9d5d9
5 changed files with 220 additions and 10 deletions
+15
View File
@@ -1359,6 +1359,21 @@ under Word or "Office suite" is shared code and stays with the suite. A host
with only Visio/Project gets only their CVEs. MSI Visio 2016 and Visio/Project
LTSC (other channels) are not matched.
**Office 2021 / 2024 in the M365 detection.** The same page has a build line
per perpetual edition: "Office 2024 Retail", "Office LTSC 2024 Volume
Licensed", and the same for 2021. `office_channel` maps a suite entry
("Microsoft Office LTSC Professional Plus 2024", "Microsoft Office Home &
Business 2021") to its line: "LTSC" in the name means Volume Licensed, else
Retail. The two lines never share a major build (LTSC stays on 14334 / 17932,
Retail moves monthly), so a build whose major is not on its line is skipped
(`_on_line`) — an LTSC build read against Retail would be behind on every
section. Office 2019 is not checked: out of support since 2025-10-14 (its
lines on the page are history), and Retail and Volume share one name
("Microsoft Office Professional Plus 2019"), so the line cannot be chosen.
Visio / Project subscription entries keep following a Microsoft 365 Apps
suite's channel only. EOL for Office 2019 / 2021 / 2024 comes
from endoflife.date `office` (year from the name; LTSC 2024 → `2024-ltsc`).
**False-positive suppression** (same cvelistV5 data, inverse direction):
Wazuh's own CPE matching sometimes over-reports across product editions
(e.g. flags a SQL Server 2019 host with a CVE that only affects 2022/2025).
+5 -1
View File
@@ -799,7 +799,11 @@ def check_eol(db: Session, product_name: str, installed_version: str) -> Optiona
if slug in _YEAR_KEYED_SLUGS:
year = _extract_year(product_name)
if year:
rel = _pick_release_by_year(releases, year)
# "Office LTSC Professional Plus 2024" → release "2024-ltsc" where
# endoflife.date has one (2021 LTSC has none: same date as 2021).
if "ltsc" in (product_name or "").lower():
rel = _pick_release_by_year(releases, f"{year}-ltsc")
rel = rel or _pick_release_by_year(releases, year)
if rel is None:
rel = _pick_release(releases, installed_version)
if rel is None:
+52 -9
View File
@@ -125,6 +125,39 @@ def c2r_app(product_name: str) -> Optional[str]:
return _APP_HEADINGS[m.group(1).lower()] if m else None
# Perpetual Office 2021 / 2024 suites ("Microsoft Office LTSC Professional Plus
# 2024", "Microsoft Office Home & Business 2021"). The page lists a build line
# per edition — "Office LTSC 2024 Volume Licensed", "Office 2024 Retail" — and
# only Volume Licensed names carry "LTSC". Office 2019 is out of support (and
# its Retail and Volume names are the same); Visio / Project 2021 / 2024 have
# no line of their own.
_OFFICE_PERPETUAL_RE = re.compile(r"^microsoft office\b.*\b(2021|2024)\b", re.I)
def office_channel(product_name: str) -> Optional[str]:
"""The page's build line for an Office 2021 / 2024 suite entry, else None."""
name = (product_name or "").strip()
m = _OFFICE_PERPETUAL_RE.match(name)
if not m:
return None
year = m.group(1)
return (f"Office LTSC {year} Volume Licensed" if "ltsc" in name.lower()
else f"Office {year} Retail")
def _on_line(releases: List[dict], channel: str, version: str) -> bool:
"""True when the build's major is one the page lists for `channel`.
Retail and Volume Licensed lines never share a major (LTSC stays on one,
Retail moves monthly). A build off its line means the name misled us, and
an LTSC build read against Retail would be behind on every section.
"""
b = parse_build(version)
return bool(b) and any(
tuple(x)[0] == b[0]
for r in releases for x in r.get("channel_builds", {}).get(channel) or [])
def _anchor_candidates(date_label: str) -> List[str]:
"""Anchor forms for a date heading, most likely first.
@@ -381,35 +414,45 @@ def missing_for_packages(
Returns (install_seen, [(cve_id, product_name, version, result), ...]).
A Visio / Project CVE needs that app's own entry and is measured against
its build; every other CVE needs a Microsoft 365 Apps entry. Visio and
Project follow the suite's channel (Click-to-Run requires one channel per
host); without a suite entry their name decides, as for the suite.
its build; every other CVE needs a suite entry (Microsoft 365 Apps or
Office 2021 / 2024). Visio and Project follow the suite's channel
(Click-to-Run requires one channel per host); without a suite entry their
name decides, as for the suite.
"""
entries = [] # (owner, name, version): owner None = the suite
entries = [] # (owner, name, version, channel): owner None = the suite
for pkg in packages or []:
name = (pkg.get("name") or "").strip()
version = (pkg.get("version") or "").strip()
if not name or not version:
continue
if is_m365_apps(name):
entries.append((None, name, version))
entries.append((None, name, version, channel_for_product(name)))
elif office := office_channel(name):
entries.append((None, name, version, office))
elif c2r_app(name):
entries.append((c2r_app(name), name, version))
entries.append((c2r_app(name), name, version, None))
if not entries:
return False, []
suite = next((n for o, n, _ in entries if o is None), None)
# Visio / Project subscription apps share a Microsoft 365 Apps suite's
# channel; Office 2021 / 2024 lines carry no C2R Visio / Project.
suite_channel = next((c for o, n, _, c in entries
if o is None and is_m365_apps(n)), None)
app_cves: Dict[str, str] = {}
for r in releases:
app_cves.update(r.get("app_cves") or {})
out = []
seen: set = set() # one detection per product + build (language packs)
for owner, name, version in entries:
channel = channel_for_product(name if owner is None else (suite or name))
for owner, name, version, channel in entries:
channel = channel or suite_channel or channel_for_product(name)
if (owner, channel, version) in seen:
continue
seen.add((owner, channel, version))
if channel.startswith("Office ") and not _on_line(releases, channel, version):
logger.debug("M365: %s %s is not on the %s build line — skipped",
name, version, channel)
continue
result = detect_missing_cves(releases, installed_version=version, channel=channel)
for cve_id in result["missing_cves"]:
if cve_id.upper() in os_cve_ids:
+61
View File
@@ -0,0 +1,61 @@
"""Office 2019 / 2021 end of support, Office 2024 supported — run: python tests/test_eol_office_perpetual.py
Microsoft Lifecycle: Office 2019 ended 2025-10-14, Office 2021 and Office LTSC
2021 ended 2026-10-13, Office 2024 and Office LTSC 2024 run to 2029-10-09.
endoflife.date's "office" product carries the same dates (releases keyed by
year, LTSC 2024 as its own "2024-ltsc"); the year comes from the inventory name.
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.services import eol_service # noqa: E402
# endoflife.date /api/v1/products/office, as of 2026-10-20.
OFFICE = {"result": {"releases": [
{"name": "2024", "label": "2024", "eolFrom": "2029-10-09", "eoasFrom": "2029-10-09"},
{"name": "2024-ltsc", "label": "2024 LTSC (LTS)", "eolFrom": "2029-10-09", "eoasFrom": "2029-10-09"},
{"name": "2021", "label": "2021", "eolFrom": "2026-10-13", "eoasFrom": "2026-10-13"},
{"name": "2019", "label": "2019", "eolFrom": "2025-10-14", "eoasFrom": "2023-10-10"},
]}}
def _check(name, version):
saved = eol_service.fetch_product
eol_service.fetch_product = lambda db, slug: OFFICE if slug == "office" else None
try:
return eol_service.check_eol(None, name, version)
finally:
eol_service.fetch_product = saved
def test_office_2021_retail_and_ltsc_are_end_of_support():
for name, ver in (("Microsoft Office LTSC Professional Plus 2021 - de-de", "16.0.14334.20974"),
("Microsoft Office Professional Plus 2021 - de-de", "16.0.20326.20232"),
("Microsoft Office Home & Business 2021 - en-us", "16.0.20326.20232")):
st = _check(name, ver)
assert st and st.is_eol and st.eol_date == "2026-10-13", (name, st)
def test_office_2019_is_end_of_support():
st = _check("Microsoft Office Professional Plus 2019 - de-de", "16.0.10417.20000")
assert st and st.is_eol and st.eol_date == "2025-10-14", st
def test_office_2024_retail_and_ltsc_are_supported():
retail = _check("Microsoft Office Professional Plus 2024 - de-de", "16.0.20430.20182")
ltsc = _check("Microsoft Office LTSC Professional Plus 2024 - de-de", "16.0.17932.21056")
assert retail and not retail.is_eol and retail.release_name == "2024", retail
assert ltsc and not ltsc.is_eol and ltsc.release_name == "2024-ltsc", ltsc
def demo():
test_office_2021_retail_and_ltsc_are_end_of_support()
test_office_2019_is_end_of_support()
test_office_2024_retail_and_ltsc_are_supported()
print("OK — Office 2019 / 2021 EOL, Office 2024 supported")
if __name__ == "__main__":
demo()
+87
View File
@@ -0,0 +1,87 @@
"""Office 2021 / 2024 (Retail and LTSC) CVEs from the M365 security page — run: python tests/test_m365_office_perpetual.py
The Microsoft 365 Apps security-updates page carries a build line per
perpetual Office edition next to the subscription channels:
Office 2024 Retail: Version 2609 (Build 20430.20182)
Office LTSC 2024 Volume Licensed: Version 2408 (Build 17932.21056)
Inventory names tell the two apart: Volume Licensed is "Microsoft Office LTSC
Professional Plus 2024", Retail has no "LTSC". The build lines never overlap
(LTSC stays on one major build, Retail moves monthly), so a build that is not
on the edition's line is left alone rather than compared — an LTSC build read
against the Retail line would be "behind" on every section.
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault("JWT_SECRET_KEY", "test-key-test-key-test-key-test-key")
from app.services import m365_service # noqa: E402
PAGE = """<html><body><main>
<h2 id="october-13-2026">October 13, 2026</h2>
<p>Current Channel: Version 2609 (Build 20430.20182)</p>
<p>Office 2024 Retail: Version 2609 (Build 20430.20182)</p>
<p>Office 2021 Retail: Version 2608 (Build 20326.20232)</p>
<p>Office LTSC 2024 Volume Licensed: Version 2408 (Build 17932.21056)</p>
<p>Office LTSC 2021 Volume Licensed: Version 2108 (Build 14334.20974)</p>
<h3 id="office-suite">Office suite</h3><ul><li><a>CVE-2026-91002</a></li></ul>
<h3 id="visio">Visio</h3><ul><li><a>CVE-2026-91009</a></li></ul>
<h2 id="september-8-2026">September 8, 2026</h2>
<p>Current Channel: Version 2608 (Build 20326.20144)</p>
<p>Office 2024 Retail: Version 2608 (Build 20326.20144)</p>
<p>Office 2021 Retail: Version 2608 (Build 20326.20144)</p>
<p>Office LTSC 2024 Volume Licensed: Version 2408 (Build 17932.21000)</p>
<p>Office LTSC 2021 Volume Licensed: Version 2108 (Build 14334.20900)</p>
<h3 id="word">Word</h3><ul><li><a>CVE-2026-91001</a></li></ul>
</main></body></html>"""
BOTH = {"CVE-2026-91001", "CVE-2026-91002"}
def _missing(name, version):
pkgs = [{"name": f"{name} - de-de", "version": version},
{"name": f"{name} - de-de.proof", "version": version}]
releases = m365_service._parse_security_page(PAGE)
seen, rows = m365_service.missing_for_packages(releases, pkgs, set())
return seen, {c for c, *_ in rows}
def test_ltsc_is_measured_against_the_volume_licensed_line():
assert _missing("Microsoft Office LTSC Professional Plus 2024", "16.0.17932.21000") == (True, {"CVE-2026-91002"})
assert _missing("Microsoft Office LTSC Professional Plus 2024", "16.0.17932.20990") == (True, BOTH)
assert _missing("Microsoft Office LTSC Professional Plus 2021", "16.0.14334.20974") == (True, set())
assert _missing("Microsoft Office LTSC Professional Plus 2021", "16.0.14334.20900") == (True, {"CVE-2026-91002"})
def test_no_ltsc_in_the_name_is_retail():
assert _missing("Microsoft Office Professional Plus 2024", "16.0.20430.20182") == (True, set())
assert _missing("Microsoft Office Professional Plus 2024", "16.0.20326.20144") == (True, {"CVE-2026-91002"})
# 2021 Retail's own line: 20326.20232 is its October build, not 2024's.
assert _missing("Microsoft Office Home & Business 2021", "16.0.20326.20232") == (True, set())
assert _missing("Microsoft Office Home & Business 2021", "16.0.20326.20100") == (True, BOTH)
def test_build_off_the_editions_line_is_not_compared():
# A Volume Licensed build under a name without "LTSC": not Retail's line.
assert _missing("Microsoft Office Professional Plus 2024", "16.0.17932.20990") == (True, set())
def test_other_office_entries_are_not_office_2021_or_2024():
for name in ("Microsoft Office Professional Plus 2019", "Microsoft Office Professional Plus 2016",
"Microsoft Project Standard 2021", "Microsoft Visio LTSC Professional 2024"):
assert _missing(name, "16.0.10417.20000") == (False, set()), name
def demo():
test_ltsc_is_measured_against_the_volume_licensed_line()
test_no_ltsc_in_the_name_is_retail()
test_build_off_the_editions_line_is_not_compared()
test_other_office_entries_are_not_office_2021_or_2024()
print("OK — Office 2021 / 2024 are checked against their own build line")
if __name__ == "__main__":
demo()