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:
@@ -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).
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user