fix(app-scan): match classic Teams 1.x on cvelistV5 and NVD

Field case: an Intune host with "Microsoft Teams" 1.5.00.31168 and "Teams
Machine-Wide Installer" 1.5.0.31168 did not get CVE-2023-29328 / -29330
(Teams for Desktop, fixed 1.6.00.18681) from cvelistV5.

cvelistV5: teams is a strict-scheme key, so a bound in another build shape
is not compared. The check also ran over the floor, and Microsoft writes
the desktop floor as "1.0.0": three parts against 1.5.00.31168's four,
so every classic range was refused. It checks the upper bounds only now,
which also applies to adobe-acrobat (its HEAD tests still pass). The key
is skipped on a Mac: "Teams for Mac" is not paired, so it held only the
desktop's ranges.

NVD found both, but showed the macOS fix (1.6.00.17554, listed first).
_query_nvd_cpe now keeps the fix per target_sw, and scan_asset_packages
takes the host's own, else a platform-neutral entry's. That applies to
every NVD product. The Teams entries get the same upper-bound scheme check:
CVE-2023-24881 is "< 2.10.1" on NVD, Microsoft's record says 2.0.0 ..
< 2.10.1 and MSRC's only fix is 2.10.1, so it no longer lands on classic
1.x, where it could never close.

Names: Intune's "MSTeams" and a bare "Teams" match on both paths. The
Machine-Wide Installer stays out: it stages the client and keeps its own
version while the client updates itself; it keeps its EOL flag.

NVD cache prefix v4 (diagnose_scan follows) so the cached decisions are
redone on the next run.
This commit is contained in:
2026-10-07 12:49:45 +02:00
parent 3f2a577680
commit fa6e6af415
5 changed files with 286 additions and 9 deletions
+17
View File
@@ -1423,6 +1423,23 @@ drops the Preview line on 11 records. The four "< 6.5.1749.0" records
(CVE-2026-55780..55783) land on 6.0 hosts too: the 6.0 line ended at 6.0.1742
without a fix.
**Microsoft Teams, classic 1.x** (key `teams` on cvelistV5, `microsoft:teams`
on NVD). Names: "Microsoft Teams" (and "... classic"), the AppX name
"MSTeams" Intune reports (new Teams, and classic at 1.x) and a bare "Teams". The Machine-Wide Installer stays out: it stages
the client into each profile and keeps its own version while the client
updates itself (its EOL flag is separate, `_teams_classic`). Classic, new
(25122.1415.3698.6812), Android (1.0.0.2025102802) and iOS (7.10.1) Teams share
one product, so only bounds in the install's own build shape are compared, on
both paths. Upper bounds only: Microsoft writes the desktop floor as "1.0.0",
and checking it refused every classic range (CVE-2023-29328/-29330, fixed
1.6.00.18681). On NVD the check drops CVE-2023-24881 ("< 2.10.1", Microsoft's
record says 2.0.0 .. < 2.10.1) from classic 1.x. The NVD fix is kept per
target_sw and picked per host: the host's own platform, else a platform-neutral
entry, else the first listed. Windows hosts were told the macOS build
(1.6.00.17554, listed first) for CVE-2023-29328. On a Mac the cvelistV5 key
is skipped: "Teams for Mac" is not paired, so it held only the desktop's
ranges; NVD's macOS entries cover the Mac.
**Oracle Java SE, JRE and JDK** (key `oracle-java` on cvelistV5; NVD rows
`oracle:jdk` and `oracle:jre`, each with its `sun:` twin for the Sun-era
records, 6-8 only; a JDK also asks for the JRE's CVEs). Version from the
+36 -6
View File
@@ -253,7 +253,10 @@ _REGISTRY: List[tuple] = [
# Only Teams itself — NOT the Office add-in, the VDI/Citrix plugin, or the
# machine-wide installer (separate products with their own versioning that
# would false-positive against Teams-app CVE ranges).
_cpe(r"^microsoft teams(?!.*(machine-wide|add-in|plugin|vdi|citrix))", "microsoft:teams"),
# "MSTeams" is the client's AppX name in Intune (new Teams, and classic
# at 1.x); "Teams" the bare one.
_cpe(r"^microsoft teams(?!.*(machine-wide|add-in|plugin|vdi|citrix))|^(?:ms)?teams$",
"microsoft:teams"),
# Exchange Server Subscription Edition — a full blind spot in Wazuh
# (wazuh/wazuh#36200). ANCHORED and exact on purpose: the same host also
# carries "Microsoft Exchange Server" and a dozen "… Language Pack - X"
@@ -1143,6 +1146,16 @@ def _in_range(installed: str, m: dict) -> bool:
for b in (sI, sE, eI, eE):
if b and not _same_version_scheme(installed, b):
return False
if ":microsoft:teams:" in m.get("criteria", ""):
# Classic (1.6.00.18681), new (25122.1415.3698.6812), Android
# (1.0.0.2025102802) and iOS (7.10.1) Teams share the CPE. Upper bounds
# in another shape describe another client: CVE-2023-24881 is
# "< 2.10.1" here, Microsoft's record says 2.0.0 .. < 2.10.1, and it
# landed on every classic 1.x. Upper bounds only, as cvelistV5's strict
# keys do: the floors are "1.0.0" whatever the build shape.
for b in (eI, eE):
if b and not _same_version_scheme(installed, b):
return False
if ":microsoft:.net:" in m.get("criteria", "") and (_vtuple(installed) or (0,))[0] >= 5:
# Runtime and SDK share this CPE, and an SDK fix says nothing about a
# runtime or the other way round — see dotnet_sdk_version. End bounds
@@ -1251,6 +1264,7 @@ def _query_nvd_cpe(cpe: str, version: str, sap_pl: Optional[int] = None,
continue
matched = False
fixed = None
fixes: dict = {} # target_sw → fix, see scan_asset_packages
tsws: set = set()
lm_ranges: list = []
for cfg in cve_obj.get("configurations", []) or []:
@@ -1286,12 +1300,14 @@ def _query_nvd_cpe(cpe: str, version: str, sap_pl: Optional[int] = None,
# host with Acrobat installed collected the extension's
# CVEs (observed: CVE-2026-48294, CVE-2024-39379,
# CVE-2024-20721, CVE-2024-20709).
tsws.add(parts[10] if len(parts) > 10 else "*")
tsw = parts[10] if len(parts) > 10 else "*"
tsws.add(tsw)
end_excl = m.get("versionEndExcluding")
f = None
if java_upd is not None:
pass # "1.6.0" is the release, not a fix
elif end_excl and is_version(end_excl):
fixed = fixed or end_excl
f = end_excl
else:
# "up to and including X" names no patched build,
# the same as cvelistV5's lessThanOrEqual — but the
@@ -1302,7 +1318,15 @@ def _query_nvd_cpe(cpe: str, version: str, sap_pl: Optional[int] = None,
# NVD says "Up to (including) 26.5.2.2").
end_incl = m.get("versionEndIncluding")
if end_incl and is_version(end_incl):
fixed = fixed or f">{end_incl}"
f = f">{end_incl}"
fixed = fixed or f
if f:
fixes.setdefault(tsw, f)
# A platform-neutral entry's fix before the first one listed: CVE-2023-
# 29328 lists macOS (1.6.00.17554) before the desktop (1.6.00.18681),
# and Windows hosts were told the Mac build. The host's own platform
# wins over both, in scan_asset_packages.
fixed = next((fixes[t] for t in ("*", "-", "") if t in fixes), fixed)
if loadmaster:
from app.services.cvelistv5_scan_service import loadmaster_fix
fixed = loadmaster_fix(version, lm_ranges, cve_id)
@@ -1325,7 +1349,8 @@ def _query_nvd_cpe(cpe: str, version: str, sap_pl: Optional[int] = None,
desc = desc[:2000]
break
out.append({"cve": cve_id.upper(), "cvss": cvss, "severity": sev,
"fixed": fixed, "tsw": sorted(tsws), "desc": desc})
"fixed": fixed, "tsw": sorted(tsws), "desc": desc,
"fixes": fixes})
return out
@@ -1387,7 +1412,9 @@ def _cache_put(db: Session, product_key: str, version: str, cves: List[dict]) ->
db.commit()
CACHE_PREFIX = "v3:" # v3: target_sw was read from the wrong CPE field
# v4: Teams bounds scheme-checked, fixes kept per platform. Also drops answers
# cached before every NVD page was read.
CACHE_PREFIX = "v4:"
def lookup_cves(db: Session, entry: dict, version: str,
@@ -1835,6 +1862,9 @@ def scan_asset_packages(db: Session, asset, packages: list, new_ids: Optional[li
for c in cves:
if not _platform_ok(c.get("tsw"), plat):
continue # CVE is for a different OS platform (e.g. Firefox-iOS)
own_fix = (c.get("fixes") or {}).get(plat)
if own_fix:
c = dict(c, fixed=own_fix) # a Mac is told the Mac build
try:
before = len(new_ids)
_upsert(db, asset, name, eff_ver, c, new_ids, touched=touched,
+12 -2
View File
@@ -421,7 +421,8 @@ _REGISTRY: List[dict] = [
# products are dropped so a Mac-only Teams CVE can't land on a Windows host
# (the per-scan platform filter is the general backstop, but not every CNA
# fills the platforms field, so scoping the pairs is belt-and-suspenders).
{"key": "teams", "re": r"^microsoft teams(?!.*(machine-wide|add-in|plugin|vdi|citrix))",
{"key": "teams", "re": r"^microsoft teams(?!.*(machine-wide|add-in|plugin|vdi|citrix))"
r"|^(?:ms)?teams$",
"pairs": [("microsoft", "microsoft teams for desktop"),
("microsoft", "microsoft teams for windows"),
("microsoft", "microsoft teams"), ("microsoft", "teams")]},
@@ -1189,7 +1190,10 @@ def _affected(installed: str, start: Optional[str], lt: Optional[str],
(26.7.1.0) under one product name — see _same_version_scheme in the CPE
scanner. CVE-2024-41879 is the Edge engine and landed on all three."""
if scheme_strict:
for b in (start, lt, lte):
# Upper bounds only. Microsoft writes the Teams desktop floor as
# "1.0.0", three parts against classic Teams' 1.5.00.31168, and
# checking it refused every classic Teams range (CVE-2023-29328).
for b in (lt, lte):
if b and not cpe._same_version_scheme(installed, b):
return False
if start:
@@ -3807,6 +3811,12 @@ def scan_asset(db: Session, asset, packages: list, index: dict,
if dedup in seen:
continue
seen.add(dedup)
if key == "teams" and fam == "macos":
# The pairs leave "Microsoft Teams for Mac" out, so all this key
# holds for a Mac is the desktop's ranges: a classic Mac client
# at 1.6.00.17554 (its own fix) sat below the desktop's 18681.
# NVD carries the macOS entries.
continue
strict = key in _STRICT_SCHEME_KEYS
for entry in index[key]:
if not _affected(cver, entry.get("start"), entry.get("lt"),
+220
View File
@@ -0,0 +1,220 @@
"""Classic Teams (1.x) — run: python tests/test_teams_classic.py
Field case: an Intune host lists "Microsoft Teams" 1.5.00.31168 and "Teams
Machine-Wide Installer" 1.5.0.31168. Neither CVE-2023-29328 nor -29330
(Teams for Desktop, fixed 1.6.00.18681) reached it from cvelistV5.
* cvelistV5: Teams is a strict-scheme key, so bounds in another build shape
(iOS 7.10.1 against new Teams' 26183.x) are not compared. The check ran
over the floor too, and Microsoft writes the desktop floor as "1.0.0":
three parts against 1.5.00.31168's four, so every classic Teams range was
refused. Only the upper bounds decide the scheme now.
* NVD: the same CVEs did match, but showed the macOS fix (1.6.00.17554,
the first entry) instead of the desktop's. A platform-neutral entry's fix
now wins. And CVE-2023-24881: NVD lost Microsoft's floor and says
"< 2.10.1" for any platform; the record says 2.0.0 .. < 2.10.1, MSRC's
only fix is 2.10.1. 1.x is not that product, so the NVD Teams entries
get the same scheme check (bound 2.10.1 has three parts).
The Machine-Wide Installer stays out: it stages the client into each profile
and keeps its own version while the client updates itself. The AppX
name "MSTeams" Intune reports and a bare "Teams" are the client and now match.
Fixtures are the real record fields.
"""
import json
import os
import sys
import tempfile
import zipfile
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.services import app_cve_scanner_service as cpe # noqa: E402
from app.services import cvelistv5_scan_service as c5 # noqa: E402
def _rng(product, lo, lt):
return {"vendor": "Microsoft", "product": product, "versions": [
{"version": lo, "lessThan": lt, "versionType": "custom", "status": "affected"}]}
_29328 = [_rng("Microsoft Teams for Android", "1.0.0", "1.0.0.2023070204"),
_rng("Microsoft Teams for Desktop", "1.0.0", "1.6.00.18681"),
_rng("Microsoft Teams for iOS", "2.0.0", "5.12.1"),
_rng("Microsoft Teams for Mac", "1.0.0.0", "1.6.00.17554")]
_24881 = [dict(_rng("Microsoft Teams", "2.0.0", "2.10.1"), platforms=["Unknown"])]
# CVE-2025-49731: new Teams, MSRC's 11-digit build and the mobile bounds.
_49731 = [_rng("Microsoft Teams for Android", "1.0.0", "1.0.0.2025112902"),
_rng("Microsoft Teams for iOS", "7.0.0", "7.10.1"),
_rng("Microsoft Teams", "0", "25060212643")]
CVE5 = {"CVE-2023-29328": _29328, "CVE-2023-29330": _29328,
"CVE-2023-24881": _24881, "CVE-2025-49731": _49731}
def _m(tsw, end, edition="*"):
return {"vulnerable": True, "versionEndExcluding": end,
"criteria": f"cpe:2.3:a:microsoft:teams:*:*:*:*:{edition}:{tsw}:*:*"}
_N29328 = [_m("android", "1.0.0.2023070204"), _m("macos", "1.6.00.17554"),
_m("-", "1.6.00.18681"), _m("iphone_os", "5.12.1")]
NVD = {"vulnerabilities": [
{"cve": {"id": cve, "configurations": [{"nodes": [{"cpeMatch": ms}]}]}}
for cve, ms in (
("CVE-2023-29328", _N29328), ("CVE-2023-29330", _N29328),
("CVE-2023-24881", [_m("*", "2.10.1")]),
# libwebp, on KEV: desktop edition, classic build bound.
("CVE-2023-4863", [_m("macos", "1.6.00.26463"),
_m("*", "1.6.00.26474", edition="desktop")]),
("CVE-2025-53783", [_m("android", "1.0.0.2025102802"), _m("iphone_os", "7.10.1"),
_m("macos", "25122.1207.3700.1444"),
_m("*", "25122.1415.3698.6812", edition="desktop")]),
)]}
class FakeAsset:
id = 1
operating_system = "Windows 11 Enterprise"
def _build_index():
"""Run the REAL index build over a ZIP shaped like cvelistV5's."""
fd, path = tempfile.mkstemp(suffix=".zip")
os.close(fd)
with zipfile.ZipFile(path, "w") as zf:
for cve_id, aff in CVE5.items():
year, num = cve_id.split("-")[1:]
zf.writestr(f"cvelistV5-main/cves/{year}/{num[:-3]}xxx/{cve_id}.json",
json.dumps({"cveMetadata": {"cveId": cve_id},
"containers": {"cna": {"affected": aff}}}))
orig = (c5._ZIP_PATH, c5._ensure_zip, c5._store_index, c5._merge_mozilla_mfsa,
c5._merge_chrome_releases)
c5._ZIP_PATH = path
c5._ensure_zip = lambda force=False: True
c5._store_index = lambda db, index: None
c5._merge_mozilla_mfsa = lambda *a, **kw: None
c5._merge_chrome_releases = lambda *a, **kw: None
try:
return c5.build_product_index(None)
finally:
(c5._ZIP_PATH, c5._ensure_zip, c5._store_index, c5._merge_mozilla_mfsa,
c5._merge_chrome_releases) = orig
os.unlink(path)
def _scan5(index, name, version, os_name="Windows 11 Enterprise"):
class Host(FakeAsset):
operating_system = os_name
found = []
orig = cpe._upsert
cpe._upsert = lambda db, asset, n, v, c, new_ids, **kw: (
found.append((c["cve"], c["fixed"])), new_ids.append(len(new_ids)))
try:
c5.scan_asset(None, Host(), [{"name": name, "version": version}],
index, touched=set())
finally:
cpe._upsert = orig
return sorted(found)
def _nvd(version):
"""The REAL NVD decision, as a Windows host would keep it."""
return sorted((c["cve"], c["fixed"]) for c in _query(version)
if cpe._platform_ok(c.get("tsw"), "windows"))
def _query(version):
"""The REAL NVD query over the fixture response."""
class _R:
status_code = 200
def json(self):
return NVD
class _C:
def __init__(self, *a, **k):
pass
def __enter__(self):
return self
def __exit__(self, *a):
return False
def get(self, *a, **k):
return _R()
orig = cpe.httpx.Client, cpe.time.sleep
cpe.httpx.Client, cpe.time.sleep = _C, (lambda s: None)
try:
return cpe._query_nvd_cpe("cpe:2.3:a:microsoft:teams", version)
finally:
cpe.httpx.Client, cpe.time.sleep = orig
def _scan_nvd(os_name, version):
"""scan_asset_packages over the fixture, as a host on `os_name`."""
class Host(FakeAsset):
operating_system = os_name
found = []
orig = cpe._upsert, cpe.lookup_cves
cpe._upsert = lambda db, asset, n, v, c, new_ids, **kw: (
found.append((c["cve"], c["fixed"])), new_ids.append(len(new_ids)))
cpe.lookup_cves = lambda db, entry, ver, **kw: _query(ver)
try:
cpe.scan_asset_packages(None, Host(), [{"name": "Microsoft Teams", "version": version}],
touched=set())
finally:
cpe._upsert, cpe.lookup_cves = orig
return sorted(found)
def demo():
# --- names ------------------------------------------------------------------
for name in ("Microsoft Teams", "Microsoft Teams classic", "MSTeams", "Teams"):
assert c5.resolve(name) == "teams", name
assert (cpe.resolve_product(name) or {}).get("key") == "cpe:microsoft:teams", name
for other in ("Teams Machine-Wide Installer", "Microsoft Teams Meeting Add-in for "
"Microsoft Office", "Veeam Explorer for Microsoft Teams", "Teams Rooms"):
assert c5.resolve(other) is None, other
assert cpe.resolve_product(other) is None, other
# --- cvelistV5 ----------------------------------------------------------------
index = _build_index()
want = [("CVE-2023-29328", "1.6.00.18681"), ("CVE-2023-29330", "1.6.00.18681")]
for name in ("Microsoft Teams", "MSTeams", "Teams"):
assert _scan5(index, name, "1.5.00.31168") == want, name
assert _scan5(index, "Microsoft Teams", "1.6.00.18681") == []
# A Mac on its own fix: the record's Mac range is not indexed, the
# desktop's is not the Mac's. NVD has the macOS entries.
assert _scan5(index, "Microsoft Teams", "1.6.00.17554", "macOS 13.6") == []
# New Teams: the 11-digit MSRC build and the mobile bounds stay uncompared.
assert _scan5(index, "MSTeams", "26183.1903.4892.4448") == []
# --- NVD --------------------------------------------------------------------
assert _nvd("1.5.00.31168") == [("CVE-2023-29328", "1.6.00.18681"),
("CVE-2023-29330", "1.6.00.18681"),
("CVE-2023-4863", "1.6.00.26474")]
assert _nvd("1.6.00.18681") == [("CVE-2023-4863", "1.6.00.26474")]
assert _nvd("1.6.00.26474") == []
# New Teams keeps its desktop CVE.
assert _nvd("25122.1405.3600.1111") == [("CVE-2025-53783", "25122.1415.3698.6812")]
assert _nvd("26183.1903.4892.4448") == []
# The fix shown is the host's own platform's: Windows the desktop build,
# a Mac its own (CVE-2023-4863: 1.6.00.26463 on macOS).
assert _scan_nvd("Windows 11 Enterprise", "1.5.00.31168") == _nvd("1.5.00.31168")
mac = dict(_scan_nvd("macOS 13.6", "1.5.00.31168"))
assert mac["CVE-2023-4863"] == "1.6.00.26463", mac
assert mac["CVE-2023-29328"] == "1.6.00.17554", mac
print("ok classic Teams 1.5.00.31168 gets CVE-2023-29328/-29330 from both paths "
"with the desktop fix, CVE-2023-24881 (2.x only) stays off it")
def test_teams_classic():
demo()
if __name__ == "__main__":
demo()
+1 -1
View File
@@ -117,7 +117,7 @@ def _index():
return _INDEX_SETTING
@check("NVD result cache prefix", "v3:")
@check("NVD result cache prefix", "v4:")
def _cache():
from app.services.app_cve_scanner_service import CACHE_PREFIX
return CACHE_PREFIX