fix(zoom): Workplace and VDI CVEs missed under the new CPE names

Field report 13.10.2026: CVE-2026-53412 (ZSB-26014) was not found on hosts
listing "Zoom Workplace (64-bit) 7.1.43453" and "Zoom VDI Universal
Plugin(64bit) 6.6.26830".

Cause 1, one CPE name. Every name containing "zoom" was asked as zoom:zoom,
the desktop client's old name. NVD has since filed it as zoom:meetings and
zoom:workplace_desktop, and the VDI client as vdi_windows_meeting_client(s),
virtual_desktop_infrastructure and workplace_virtual_desktop_infrastructure.
CVE-2026-53412 names only the new ones. Each row now asks every name. The
names are anchored, so Zoom Rooms, the Outlook plugin and the SDKs no longer
draw the client's CVEs.

Cause 2, the version hides the patch. Zoom's MSI writes major.minor.BUILD
(the plugin's 6.4.26620 is release 6.4.12); NVD's server-side match and
ours both read the build as a patch, so "6.6.0 .. < 6.6.15" never fired and
a floor like "> 5.14.14" passed every 5.14 build. NVD is now asked for x.y.0,
and a bound inside the install's own minor gets no verdict: no new finding,
but the CVE counts as touched so an open finding is not closed on no
evidence. Bounds in other minors still decide (6.6.22255 < 7.0.0). The EXE
form "6.0.11 (39959)" is unchanged.

Cause 3, client vs plugin. NVD files the VDI client and the VDI plugins
under one CPE. The CVE text decides: a plugin takes only CVEs that name a
VDI plugin, the client all but plugin-only ones.

cvelistV5: the old pairs used vendor "zoom", which no record does. Paired
now are the spellings with real bounds (CVE-2026-53416, "VDI Client and
Plugins" < 7.0.11, is still Awaiting Analysis at NVD); "see references",
"unspecified" floors and "before X" prose stay unpaired. Windows only.
This commit is contained in:
2026-10-13 13:53:20 +02:00
parent e73f277201
commit 3520ca6c45
4 changed files with 544 additions and 12 deletions
+38
View File
@@ -1487,6 +1487,44 @@ so they never fire. That is a miss, not a wrong finding, and every version they
bound is pre-5.08. The name is anchored, so Vantage, System Interface
Foundation and Update Retriever stay out.
**Zoom Workplace and Zoom VDI** (keys `zoom` / `zoom-vdi` on cvelistV5; NVD
rows `zoom:workplace_desktop` and `zoom:workplace_virtual_desktop_infrastructure`).
NVD renamed both products and kept every old name in use, so each row asks all
of them: the desktop client also as `zoom:zoom` (to 2023, CVE-2024-24691) and
`zoom:meetings`, the VDI client also as `virtual_desktop_infrastructure` and
`vdi_windows_meeting_client(s)`. CVE-2026-53412 names only the new ones and the
old single `zoom:zoom` row missed it. `zoom:workplace` without a suffix is the
phone app. Names are anchored: "Zoom", "Zoom (64-bit)", "Zoom Workplace
(64-bit)", "zoom.us" for the desktop; "Zoom [Workplace] VDI…" for the VDI
client and its plugins (Universal, Citrix, VMware, AVD), never the macOS plugin
installer. Zoom Rooms, the Outlook plugin and the SDKs used to match a bare
"zoom" and drew the client's CVEs.
The MSI writes major.minor.BUILD ("7.1.43453"; the VDI Universal Plugin's
6.4.26620 is release 6.4.12). The EXE writes "6.0.11 (39959)", which reads as
the patch. A build cannot be placed against a bound inside its own minor, so
`zoom_undecided` gives such a bound no verdict on both paths: 6.6.26830 against
"6.6.0 .. < 6.6.15" opens no finding, and the CVE goes into `touched`, so an
existing finding stays open instead of closing on no evidence (logged at info).
A bound in another minor, or x.y.0 as an inclusive floor / exclusive ceiling,
still decides (6.6.22255 is below 7.0.0). NVD is asked for x.y.0, not the
build: its server-side match reads the build as a patch too, and returned
nothing for 6.6.26830. Across CPE names a real match beats
an undecided one. Upgrade path: a build→release table from Zoom's release notes.
NVD files the VDI client and the VDI plugins under one CPE and one version
line. `zoom_cve_applies` reads the CVE text: a plugin takes only what names a
VDI plugin (CVE-2026-30905, CVE-2026-53416 "VDI Client and Plugins"; the
Outlook plugin does not count), the client everything except a plugin-only
flaw ("a malicious client" in passing does not name the client). A Citrix- or
VMware-plugin-only flaw would still reach the Universal plugin; none is filed
under these CPEs today (the 2021 ones are `vdi_citrix` / `vdi_vmware`). cvelistV5 is the smaller half: most Zoom
records say "see references", the 2021-2022 ones floor at `unspecified`, and the
2023 ones write prose ("before 5.14.0") that the exact-version fallback would
read as the fixed build. Only the spellings with a real bound are paired, and
only Windows hosts are scanned there; NVD's target_sw places macOS and Linux.
It still matters: CVE-2026-53416 was at "Awaiting Analysis" at NVD on 2026-10-13.
## Mobile Device Security (Intune)
Runs inline during the Intune sync — no extra Graph calls beyond the
+132 -10
View File
@@ -220,7 +220,22 @@ _REGISTRY: List[tuple] = [
# teamviewer:digital_employee_experience (22 records, versioned 9.x/24.x/25.x)
# and teamviewer:meeting are different products on different version lines.
_cpe(r"teamviewer", "teamviewer:teamviewer", also=["teamviewer:remote"]),
_cpe(r"(?<!\w)zoom(?!\w)", "zoom:zoom"),
# Zoom. NVD has renamed both products and kept every old name in use:
# the desktop client is zoom:zoom (to 2023, CVE-2024-24691), zoom:meetings
# and now zoom:workplace_desktop (CVE-2025-49457, CVE-2026-53412, which
# names ONLY the new one); the VDI client went vdi_windows_meeting_client(s)
# → virtual_desktop_infrastructure → workplace_virtual_desktop_infrastructure.
# zoom:workplace (no suffix) is the phone app and stays out.
# ANCHORED: Zoom Rooms, the Outlook plugin and the SDKs are their own
# products on their own version lines, and a bare "zoom" anywhere in the
# name used to send all of them to the client's CVEs.
# The VDI Plugins (Universal / Citrix / VMware / AVD) share the VDI client's
# version line and its CPE — see zoom_cve_applies for telling them apart.
_cpe(r"^zoom(?: workplace)?(?: ?\((?:(?:32|64)-?bit|arm64)\))?$|^zoom\.us$", "zoom:workplace_desktop",
also=["zoom:zoom", "zoom:meetings"]),
_cpe(r"^zoom (?:workplace )?vdi\b(?!.*\bmac(?:os)?\b)", "zoom:workplace_virtual_desktop_infrastructure",
also=["zoom:virtual_desktop_infrastructure", "zoom:vdi_windows_meeting_clients",
"zoom:vdi_windows_meeting_client"]),
# Anchored, and not the Help / Language Pack or SDK — see the cvelistV5 row.
_cpe(r"^libreoffice\b(?!.*(?:help ?pack|language ?pack|sdk))", "libreoffice:libreoffice"),
_cpe(r"filezilla", "filezilla:filezilla"),
@@ -1122,6 +1137,81 @@ def dotnet_sdk_version(v) -> bool:
return bool(t) and len(t) >= 3 and t[0] >= 5 and t[2] >= 100
def zoom_msi_build(v: str) -> bool:
"""major.minor.BUILD as Zoom's MSI writes it — the third part is a build
(26830), not a patch (a Zoom patch has never reached 100)."""
t = _vtuple(v) or ()
return len(t) == 3 and t[2] >= 1000
# ponytail: no build→release table, so a host inside a bound's own minor gets
# no verdict (and keeps whatever finding it had); add one from Zoom's release
# notes if those misses matter.
def zoom_undecided(installed: str, s_incl=None, s_excl=None,
e_incl=None, e_excl=None) -> bool:
"""True when a Zoom MSI build cannot be placed against these bounds.
Zoom's MSI writes major.minor.BUILD — "7.1.43453", and the VDI Universal
Plugin's 6.4.26620 is release 6.4.12 — while the bounds name the patch
(6.6.15). Read as a patch the build sits above every bound in its own
minor, so "6.6.0 .. < 6.6.15" never fired, and a floor like "> 5.14.14"
would be passed by every 5.14 build. A bound in another minor still
decides, and so does x.y.0 as an inclusive floor or exclusive ceiling:
every build of x.y is at least x.y.0. The EXE writes "6.0.11 (39959)",
which _clean_version reads as the patch, so none of this applies to it.
"""
if not zoom_msi_build(installed):
return False
t = _vtuple(installed)
for b, zero_ok in ((s_incl, True), (e_excl, True), (s_excl, False), (e_incl, False)):
bt = _vtuple(b) if b else None
if bt and bt[:2] == t[:2] and not (zero_ok and not any(bt[2:])):
return True
return False
def _zoom_undecided_match(installed: str, m: dict) -> bool:
"""zoom_undecided for one NVD cpeMatch entry."""
return ":zoom:" in m.get("criteria", "") and zoom_undecided(
installed, m.get("versionStartIncluding"), m.get("versionStartExcluding"),
m.get("versionEndIncluding"), m.get("versionEndExcluding"))
# A VDI plugin as the CVE text names one: "VDI Plugin", "VDI Client and
# Plugins", "VDI Citrix Plugins". The Outlook plugin is cut first — it is its
# own product, and CVE-2022-22782 names it next to the VDI clients.
_ZOOM_OUTLOOK_RE = re.compile(r"plug-?ins? for (?:microsoft )?outlook|outlook plug-?ins?", re.I)
_ZOOM_VDI_PLUGIN_RE = re.compile(r"\bvdi\b[^.;]*?\bplug-?ins?\b", re.I)
# The client as the text names it — not "a malicious client" in passing.
_ZOOM_CLIENT_RE = re.compile(
r"\b(?:zoom|vdi|meeting|desktop)\s+clients?\b|\bvdi\s+(?:app|client)\b", re.I)
def zoom_plugin(name: str) -> bool:
"""A Zoom VDI plugin install (Universal, Citrix, VMware, AVD), not the client."""
return bool(re.search(r"\bvdi\b.*\bplug-?in", name or "", re.I))
def zoom_cve_applies(name: str, desc: Optional[str]) -> bool:
"""Whether a Zoom VDI CVE belongs to this install: client or plugin.
NVD files the VDI Client (in the virtual desktop) and the VDI Plugins (on
the endpoint) under one CPE and one version line, so only the CVE text
tells them apart: CVE-2026-30905 is "the Zoom Workplace VDI Plugin Windows
Universal Installer", CVE-2026-53412 the "VDI Client for Windows",
CVE-2026-53416 "VDI Client and Plugins". A plugin takes only what names a
VDI plugin, the client everything but a plugin-only flaw. No text, no
verdict for a plugin.
"""
if "vdi" not in (name or "").lower():
return True
d = _ZOOM_OUTLOOK_RE.sub("", desc or "")
names_plugin = bool(_ZOOM_VDI_PLUGIN_RE.search(d))
if zoom_plugin(name):
return names_plugin
return not names_plugin or bool(_ZOOM_CLIENT_RE.search(d))
def _is_adobe_acrobat(criteria: str) -> bool:
p = (criteria or "").split(":")
return len(p) > 4 and p[3] == "adobe" and p[4].startswith("acrobat")
@@ -1178,6 +1268,8 @@ def _in_range(installed: str, m: dict) -> bool:
sdk = dotnet_sdk_version(installed)
if any(b and dotnet_sdk_version(b) != sdk for b in (eI, eE)):
return False
if _zoom_undecided_match(installed, m):
return False
if not any([sI, sE, eI, eE]):
# exact version in the CPE criteria (criteria[5]); equal only.
crit = m.get("criteria", "")
@@ -1234,7 +1326,13 @@ def _query_nvd_cpe(cpe: str, version: str, sap_pl: Optional[int] = None,
java_upd: Optional[int] = None) -> List[dict]:
api_key = os.getenv("NVD_API_KEY", "").strip()
headers = {"apiKey": api_key} if api_key else None
base = f"{NVD_CVE_API}?virtualMatchString={cpe}:{version}&resultsPerPage=200"
qver = version
if ":zoom:" in cpe and zoom_msi_build(version):
# NVD compares the version itself, and reads the build as a patch:
# 6.6.26830 returns nothing for "< 6.6.11". Ask for the minor's first
# release instead and let _in_range judge the build (zoom_undecided).
qver = ".".join(version.split(".")[:2]) + ".0"
base = f"{NVD_CVE_API}?virtualMatchString={cpe}:{qver}&resultsPerPage=200"
sleep = NVD_SLEEP_WITH_KEY if api_key else NVD_SLEEP_NO_KEY
# Every page, not just the first: oracle:jdk:1.6.0 alone has 459 records,
# and whatever fell past record 200 reached no host, without a word.
@@ -1281,6 +1379,7 @@ def _query_nvd_cpe(cpe: str, version: str, sap_pl: Optional[int] = None,
fixed = None
fixes: dict = {} # target_sw → fix, see scan_asset_packages
tsws: set = set()
undecided: set = set() # target_sw of Zoom entries with no verdict
lm_ranges: list = []
for cfg in cve_obj.get("configurations", []) or []:
for node in cfg.get("nodes", []) or []:
@@ -1306,6 +1405,8 @@ def _query_nvd_cpe(cpe: str, version: str, sap_pl: Optional[int] = None,
ok = (_sap_affected(m, version, sap_pl) if sap_pl is not None
else _java_affected(m, version, java_upd) if java_upd is not None
else _in_range(version, m))
if not ok and _zoom_undecided_match(version, m):
undecided.add((crit.split(":") + ["*"] * 11)[10])
if ok:
matched = True
parts = crit.split(":")
@@ -1356,7 +1457,7 @@ def _query_nvd_cpe(cpe: str, version: str, sap_pl: Optional[int] = None,
from app.services.cvelistv5_scan_service import sophos_fix
fixed = sophos_fix(version, cve_id) if matched else None
matched, tsws = bool(fixed), {"*"}
if not matched:
if not matched and not undecided:
continue
cvss, sev = _nvd_cvss(cve_obj)
# NVD carries the same CNA text; without it a finding shows only the
@@ -1369,6 +1470,12 @@ def _query_nvd_cpe(cpe: str, version: str, sap_pl: Optional[int] = None,
if desc:
desc = desc[:2000]
break
if not matched:
# No verdict either way (see zoom_undecided): the scan keeps an
# existing finding open on it rather than closing it on no evidence.
out.append({"cve": cve_id.upper(), "undecided": True,
"tsw": sorted(undecided), "desc": desc})
continue
out.append({"cve": cve_id.upper(), "cvss": cvss, "severity": sev,
"fixed": fixed, "tsw": sorted(tsws), "desc": desc,
"fixes": fixes})
@@ -1460,13 +1567,18 @@ def lookup_cves(db: Session, entry: dict, version: str,
loadmaster=bool(entry.get("loadmaster")), sophos=sophos,
java_upd=java_upd)
# Merge the alternate CPE names, keeping the first entry per CVE —
# a duplicate across names is the same finding, not two.
# a duplicate across names is the same finding, not two. A real
# match outranks an undecided one (Zoom) from another name.
for alt in entry.get("also") or []:
seen_ids = {c["cve"] for c in cves}
cves += [c for c in _query_nvd_cpe(alt, version, sap_pl=sap_pl,
skip_editions=skip, sophos=sophos,
java_upd=java_upd)
if c["cve"] not in seen_ids]
seen_ids = {c["cve"] for c in cves if not c.get("undecided")}
more = [c for c in _query_nvd_cpe(alt, version, sap_pl=sap_pl,
skip_editions=skip, sophos=sophos,
java_upd=java_upd)
if c["cve"] not in seen_ids]
found = {c["cve"] for c in more if not c.get("undecided")}
cves = [c for c in cves if c["cve"] not in found] + [
c for c in more
if not (c.get("undecided") and c["cve"] in {x["cve"] for x in cves})]
except _TransientNVD as e:
logger.warning("app-cve: %s — not caching, will retry next run", e)
return []
@@ -1873,7 +1985,8 @@ def scan_asset_packages(db: Session, asset, packages: list, new_ids: Optional[li
continue
key = (entry["key"], eff_ver if java_upd is not None
else cver if sap_pl is None else f"{cver}+pl{sap_pl}",
wazuh_component(entry["key"], name))
wazuh_component(entry["key"], name),
entry["key"].startswith("cpe:zoom:") and zoom_plugin(name))
if key in seen:
continue
seen.add(key)
@@ -1886,6 +1999,15 @@ 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)
if entry["key"].startswith("cpe:zoom:") and not zoom_cve_applies(name, c.get("desc")):
continue # VDI client vs plugin, see zoom_cve_applies
if c.get("undecided"):
# No verdict: keep an existing finding, open none.
logger.info("app-cve: %s %s — no verdict on %s (MSI build in "
"the fix's own minor)", name, version, c["cve"])
if touched is not None:
touched.add(c["cve"])
continue
own_fix = (c.get("fixes") or {}).get(plat)
if own_fix:
c = dict(c, fixed=own_fix) # a Mac is told the Mac build
+37 -2
View File
@@ -415,7 +415,26 @@ _REGISTRY: List[dict] = [
# version lines.
{"key": "lenovo-system-update", "re": r"^lenovo system update$",
"pairs": [("lenovo", "system update")]},
{"key": "zoom", "re": r"(?<!\w)zoom(?!\w)", "pairs": [("zoom", "zoom"), ("zoom", "meetings"), ("zoom", "zoom client for meetings")]},
# Zoom. Same names and anchoring as the CPE rows (Rooms, the Outlook
# plugin and the SDKs are other products). NVD is the main source here:
# most Zoom records say "see references" instead of a version, the
# 2021-2022 ones floor at "unspecified", and the 2023 ones write prose
# ("before 5.14.0") that would read as the exact FIXED build. These pairs
# are the spellings with a real bound, which NVD can lag on
# (CVE-2026-53416 sat at "Awaiting Analysis"). The old bare-"zoom" pairs
# matched no record's vendor and are gone.
{"key": "zoom", "re": r"^zoom(?: workplace)?(?: ?\((?:(?:32|64)-?bit|arm64)\))?$|^zoom\.us$",
"pairs": [("zoom communications", "zoom workplace for windows"),
("zoom communications", "zoom workplace"),
("zoom communications inc.", "zoom workplace"),
("zoom communications inc.", "zoom clients")]},
# VDI client and VDI plugins: one version line, told apart per CVE by
# cpe.zoom_cve_applies.
{"key": "zoom-vdi", "re": r"^zoom (?:workplace )?vdi\b(?!.*\bmac(?:os)?\b)",
"pairs": [("zoom communications", "zoom vdi"),
("zoom communications", "zoom workplace vdi plugin"),
("zoom communications inc.", "zoom workplace vdi client"),
("zoom video communications, inc.", "zoom workplace vdi app for windows")]},
# Teams itself only — not the Office add-in / VDI / Citrix plugin. And
# only the Windows/desktop/generic product entries: the "for Mac" / mobile
# products are dropped so a Mac-only Teams CVE can't land on a Windows host
@@ -3833,7 +3852,8 @@ def scan_asset(db: Session, asset, packages: list, index: dict,
cver = cpe._clean_version(eff_ver)
if not cver:
continue
dedup = (key, cver, cpe.wazuh_component(key, name))
dedup = (key, cver, cpe.wazuh_component(key, name),
key == "zoom-vdi" and cpe.zoom_plugin(name))
if dedup in seen:
continue
seen.add(dedup)
@@ -3843,10 +3863,25 @@ def scan_asset(db: Session, asset, packages: list, index: dict,
# at 1.6.00.17554 (its own fix) sat below the desktop's 18681.
# NVD carries the macOS entries.
continue
if key.startswith("zoom") and fam != "windows":
continue # NVD's target_sw places macOS/Linux; some records name no platform
if key == "teams" and cpe.teams_placeholder_version(version):
continue # "MSTeams 1.0.0.0" names no build
strict = key in _STRICT_SCHEME_KEYS
for entry in index[key]:
if key.startswith("zoom"):
if not cpe.zoom_cve_applies(name, entry.get("desc")):
continue # VDI client vs plugin, see cpe.zoom_cve_applies
if cpe.zoom_undecided(cver, entry.get("start"), None,
entry.get("lte"), entry.get("lt")):
# MSI build inside the fix's own minor, see
# cpe.zoom_undecided: keep an existing finding, open none.
if _platform_ok(fam, entry.get("plats")):
logger.info("cvelistv5: %s %s — no verdict on %s", name,
version, entry["cve"])
if touched is not None:
touched.add(entry["cve"])
continue
if not _affected(cver, entry.get("start"), entry.get("lt"),
entry.get("lte"), scheme_strict=strict):
continue
+337
View File
@@ -0,0 +1,337 @@
"""Zoom Workplace + Zoom VDI detection — run: python -m pytest tests/test_zoom.py
Field report (13.10.2026): CVE-2026-53412 (ZSB-26014) on hosts listing
Zoom Workplace (64-bit) 7.1.43453 Zoom
Zoom VDI Universal Plugin(64bit) 6.6.26830 Zoom Communications, Inc.
Three things were wrong, each reproduced below.
1. One CPE name. Every "zoom" in a name was asked as zoom:zoom, which is the
OLD name of the desktop client. NVD has filed it under three names over the
years — zoom:zoom, zoom:meetings, zoom:workplace_desktop — and the VDI
client under four (vdi_windows_meeting_client[s],
virtual_desktop_infrastructure, workplace_virtual_desktop_infrastructure).
CVE-2026-53412 names only the new ones, so nothing saw it.
2. The version hides the patch. Zoom's MSI writes major.minor.BUILD:
7.1.43453, and the VDI plugin 6.4.26620 is release 6.4.12. The EXE writes
"6.0.11 (39959)", which is fine. Read as a patch, 26830 sits above every
bound in its minor ("6.6.0 .. < 6.6.15"), and below none — and a FLOOR in
the same minor ("> 5.14.14") would be passed by every build of 5.14. Such a
bound gets no verdict; one at a minor boundary (< 7.0.0) still decides.
3. The plugin is not the client. NVD files the VDI Client and the VDI Plugin
under one CPE; only CVEs whose text names the plugin may land on a plugin,
and one that names only the plugin stays off the client.
"Zoom Rooms", the Outlook plugin and the SDKs are other products entirely.
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.services import app_cve_scanner_service as cpe
from app.services import cvelistv5_scan_service as c5
WORKPLACE = "Zoom Workplace (64-bit)"
PLUGIN = "Zoom VDI Universal Plugin(64bit)"
# ---- verbatim from NVD (fetched 13.10.2026) --------------------------------
WD = "cpe:2.3:a:zoom:workplace_desktop:*:*:*:*:*:windows:*:*"
VDI = "cpe:2.3:a:zoom:workplace_virtual_desktop_infrastructure:*:*:*:*:*:windows:*:*"
OLD_VDI = "cpe:2.3:a:zoom:vdi_windows_meeting_clients:*:*:*:*:*:windows:*:*"
OLD_ZOOM = "cpe:2.3:a:zoom:zoom:*:*:*:*:*:windows:*:*"
CVE_2026_53412 = [
{"criteria": WD, "versionEndExcluding": "7.0.0"},
{"criteria": VDI, "versionStartIncluding": "6.5.0", "versionEndExcluding": "6.5.18"},
{"criteria": VDI, "versionStartIncluding": "6.6.0", "versionEndExcluding": "6.6.15"},
{"criteria": VDI, "versionStartIncluding": "7.0.0", "versionEndExcluding": "7.0.10"},
]
CVE_2024_24691 = [
{"criteria": OLD_ZOOM, "versionEndExcluding": "5.16.5"},
{"criteria": OLD_VDI, "versionEndExcluding": "5.14.14"},
{"criteria": OLD_VDI, "versionStartExcluding": "5.14.14", "versionEndExcluding": "5.15.12"},
{"criteria": OLD_VDI, "versionStartExcluding": "5.15.12", "versionEndExcluding": "5.16.10"},
]
CVE_2026_30905 = {"criteria": VDI, "versionEndExcluding": "6.6.11"}
# ---- verbatim from cvelistV5 (fetched 13.10.2026) --------------------------
V5_2026_53412 = {"vendor": "Zoom Communications", "product": "Zoom Workplace for Windows",
"platforms": ["Windows"],
"versions": [{"status": "affected", "version": "0", "lessThan": "7.0.0",
"versionType": "custom"}]}
V5_2026_53416 = {"vendor": "Zoom Communications", "product": "Zoom VDI",
"platforms": ["Windows"],
"versions": [{"status": "affected", "version": "0", "lessThan": "7.0.11",
"versionType": "custom"}]}
def _hit(version, rows):
return any(cpe._in_range(version, m) for m in rows)
# ---- 1. names → products ----------------------------------------------------
def test_desktop_names_ask_every_desktop_cpe_name():
for name in (WORKPLACE, "Zoom Workplace", "Zoom (64-bit)", "Zoom", "zoom.us"):
e = cpe.resolve_product(name)
assert e and e["cpe"] == "cpe:2.3:a:zoom:workplace_desktop", name
assert set(e["also"]) == {"cpe:2.3:a:zoom:zoom", "cpe:2.3:a:zoom:meetings"}, name
def test_vdi_names_ask_every_vdi_cpe_name():
for name in (PLUGIN, "Zoom Workplace VDI", "Zoom VDI", "Zoom VDI Citrix Plugin"):
e = cpe.resolve_product(name)
assert e and e["cpe"] == "cpe:2.3:a:zoom:workplace_virtual_desktop_infrastructure", name
assert set(e["also"]) == {
"cpe:2.3:a:zoom:virtual_desktop_infrastructure",
"cpe:2.3:a:zoom:vdi_windows_meeting_clients",
"cpe:2.3:a:zoom:vdi_windows_meeting_client"}, name
def test_other_zoom_products_are_not_the_client():
for name in ("Zoom Rooms", "Zoom Outlook Plugin", "Zoom Plugin for Microsoft Outlook",
"Zoom Meeting SDK", "ZoomIt", "Zoom Workplace VDI Plugin macOS Universal installer"):
assert cpe.resolve_product(name) is None, name
assert c5.resolve(name) is None, name
# ---- 2. MSI build versions --------------------------------------------------
def test_cve_2026_53412_on_the_field_report_hosts():
assert not _hit("7.1.43453", CVE_2026_53412), "7.1 is past the 7.0.0 fix"
assert _hit("6.6.22255", CVE_2026_53412), "6.6 is below 7.0.0, whatever the build"
# 6.6.26830 could be 6.6.10 or 6.6.20 — the bound 6.6.15 is in its own minor.
assert not _hit("6.6.26830", CVE_2026_53412[1:])
assert not _hit("7.0.27050", CVE_2026_53412[1:])
def test_exe_versions_still_compare_by_patch():
assert _hit(cpe._clean_version("6.6.10 (26100)"), CVE_2026_53412[1:])
assert not _hit("6.6.15", CVE_2026_53412[1:])
assert _hit("7.0.9", CVE_2026_53412[1:])
def test_a_bound_in_another_minor_still_decides():
assert _hit("6.4.26620", [CVE_2026_30905]), "6.4.x is below 6.6.11"
assert not _hit("6.6.26830", [CVE_2026_30905]), "same minor: undecided"
assert not _hit("6.7.30000", [CVE_2026_30905])
def test_old_cpe_names_with_floors_inside_a_minor():
assert _hit("5.15.20000", CVE_2024_24691[:1]), "5.15 < 5.16.5"
assert not _hit("5.16.20000", CVE_2024_24691[:1]), "same minor as 5.16.5"
# "> 5.14.14": a 5.14 build may be 5.14.14 itself or below — undecided,
# and the "< 5.14.14" row next to it is undecided for the same reason.
assert not _hit("5.14.30000", CVE_2024_24691[1:])
assert _hit("5.13.10000", CVE_2024_24691[1:])
def test_other_vendors_are_untouched():
"""The guard is Zoom's: a three-part version with a large third part is a
normal patch elsewhere (Nextcloud 3.15.3, Notepad++ 8.6.2 …)."""
m = {"criteria": "cpe:2.3:a:foo:bar:*:*:*:*:*:*:*:*",
"versionStartIncluding": "6.6.0", "versionEndExcluding": "6.6.15"}
assert not cpe._in_range("6.6.26830", m)
m["versionEndExcluding"] = "6.6.30000"
assert cpe._in_range("6.6.26830", m)
# ---- 3. plugin vs client ----------------------------------------------------
def test_plugin_only_takes_cves_that_name_a_plugin():
client = "Improper Input Validation in Zoom Desktop Client for Windows, Zoom VDI Client for Windows"
both = "Path traversal in Zoom VDI Client and Plugins may allow ..."
plug = "External Control of File Name or Path in the Zoom Workplace VDI Plugin Windows Universal Installer"
assert not cpe.zoom_cve_applies(PLUGIN, client)
assert cpe.zoom_cve_applies(PLUGIN, both)
assert cpe.zoom_cve_applies(PLUGIN, plug)
assert not cpe.zoom_cve_applies(PLUGIN, None)
for name in ("Zoom Workplace VDI", WORKPLACE):
assert cpe.zoom_cve_applies(name, client)
assert cpe.zoom_cve_applies(name, both)
# …and a plugin-only flaw is not the client's.
assert not cpe.zoom_cve_applies("Zoom Workplace VDI", plug)
def test_text_that_is_not_a_vdi_plugin():
outlook = ("Zoom Plugins for Microsoft Outlook for Windows prior to version 5.10.3 "
"and Zoom VDI Windows Meeting Clients prior to version 5.9.6")
assert not cpe.zoom_cve_applies(PLUGIN, outlook)
assert cpe.zoom_cve_applies("Zoom Workplace VDI", outlook)
passing = "Zoom Workplace VDI Plugin installer may allow a malicious client to ..."
assert cpe.zoom_cve_applies(PLUGIN, passing)
assert not cpe.zoom_cve_applies("Zoom Workplace VDI", passing)
def test_name_spellings():
for name in ("Zoom Workplace(64bit)", "Zoom Workplace (ARM64)"):
assert cpe.resolve_product(name)["cpe"].endswith("workplace_desktop"), name
assert cpe.resolve_product("Zoom VDI Machine-Wide Installer"), "'machine' is not 'mac'"
class _Asset:
id = 1
operating_system = "Microsoft Windows 11 Pro"
def _nvd_record(cve, desc, rows):
return {"cve": {"id": cve, "descriptions": [{"lang": "en", "value": desc}],
"configurations": [{"nodes": [{"cpeMatch": [
dict(m, vulnerable=True) for m in rows]}]}]}}
D_53412 = ("Improper Input Validation in Zoom Desktop Client for Windows, Zoom VDI "
"Client for Windows, and Zoom Meeting SDK for Windows may allow ...")
D_30905 = ("External Control of File Name or Path in the Zoom Workplace VDI Plugin "
"Windows Universal Installer before version 6.6.11 may allow ...")
NVD = [_nvd_record("CVE-2026-53412", D_53412, CVE_2026_53412),
_nvd_record("CVE-2026-30905", D_30905, [CVE_2026_30905])]
class _Resp:
status_code = 200
def __init__(self, body):
self._body = body
def json(self):
return self._body
asked: list = [] # virtualMatchString of every NVD request in the last scan
def _scan_nvd(monkeypatch, packages, records=NVD):
"""The real NVD query, merge and scan; only HTTP and the cache are faked.
A record answers for every CPE name its configuration carries."""
class Client:
def __init__(self, **kw):
pass
def __enter__(self):
return self
def __exit__(self, *a):
return False
def get(self, url, headers=None):
asked.append(url.split("virtualMatchString=")[1].split("&")[0])
cpe_name = url.split("virtualMatchString=")[1].split("&")[0].rsplit(":", 1)[0]
hits = [r for r in records if any(
(m["criteria"] + ":").startswith(cpe_name + ":")
for c in r["cve"]["configurations"] for n in c["nodes"] for m in n["cpeMatch"])]
return _Resp({"vulnerabilities": hits, "totalResults": len(hits)})
got, touched = [], set()
asked.clear()
monkeypatch.setattr(cpe.httpx, "Client", Client)
monkeypatch.setattr(cpe.time, "sleep", lambda s: None)
monkeypatch.setattr(cpe, "_cache_get", lambda *a: None)
monkeypatch.setattr(cpe, "_cache_put", lambda *a: None)
monkeypatch.setattr(cpe, "_upsert", lambda db, asset, n, v, c, new_ids, **kw:
got.append((n, c["cve"], c["fixed"])))
cpe.scan_asset_packages(None, _Asset(), packages, touched=touched)
return sorted(got), touched
def test_nvd_scan_keeps_client_cves_off_the_plugin(monkeypatch):
# 6.6.10 sits inside BOTH "6.6.0 .. < 6.6.15" (53412, the client's) and
# "< 6.6.11" (30905, the plugin installer's).
got, _ = _scan_nvd(monkeypatch, [{"name": PLUGIN, "version": "6.6.10"},
{"name": "Zoom Workplace VDI", "version": "6.6.10"}])
assert got == [(PLUGIN, "CVE-2026-30905", "6.6.11"),
("Zoom Workplace VDI", "CVE-2026-53412", "6.6.15")], got
def test_nvd_undecided_keeps_an_existing_finding(monkeypatch):
"""No verdict must not read as "patched": the CVE goes into `touched`, so
the reconcile leaves an open finding alone, and no new one is opened."""
got, touched = _scan_nvd(monkeypatch, [{"name": PLUGIN, "version": "6.6.26830"}])
assert got == [] and touched == {"CVE-2026-30905"}, (got, touched)
got, touched = _scan_nvd(monkeypatch, [{"name": "Zoom Workplace VDI", "version": "6.6.26830"}])
assert got == [] and touched == {"CVE-2026-53412"}, (got, touched)
got, touched = _scan_nvd(monkeypatch, [{"name": WORKPLACE, "version": "7.1.43453"}])
assert got == [] and touched == set(), (got, touched)
def test_nvd_is_asked_for_the_minor_not_the_build(monkeypatch):
"""NVD matches server-side and reads 26830 as a patch: asked for
6.6.26830 it returned nothing for "< 6.6.11". 6.6.0 returns every range
the minor starts in, and the build is judged here."""
_scan_nvd(monkeypatch, [{"name": PLUGIN, "version": "6.6.26830"}])
assert asked and all(a.endswith(":6.6.0") for a in asked), asked
_scan_nvd(monkeypatch, [{"name": PLUGIN, "version": "6.6.10 (26830)"}])
assert all(a.endswith(":6.6.10") for a in asked), asked
def test_nvd_field_report_hosts(monkeypatch):
got, _ = _scan_nvd(monkeypatch, [{"name": WORKPLACE, "version": "6.6.22255"}])
assert got == [(WORKPLACE, "CVE-2026-53412", "7.0.0")], got
def test_a_match_under_one_name_beats_no_verdict_under_another(monkeypatch):
rows = [{"criteria": VDI, "versionStartIncluding": "6.6.0", "versionEndExcluding": "6.6.15"},
{"criteria": OLD_VDI, "versionEndExcluding": "6.7.0"}]
rec = _nvd_record("CVE-2099-0001", "Zoom VDI Client for Windows ...", rows)
got, _ = _scan_nvd(monkeypatch, [{"name": "Zoom Workplace VDI", "version": "6.6.26830"}],
[rec])
assert got == [("Zoom Workplace VDI", "CVE-2099-0001", "6.7.0")], got
def _scan_v5(monkeypatch, packages, affected, os_name="Microsoft Windows 11 Pro"):
index = {}
for cve, aff, desc in affected:
key = c5._pair_key(aff["vendor"], aff["product"])
plats = [p.lower() for p in aff.get("platforms") or []]
for start, lt, lte in c5._ranges_from_affected(aff):
index.setdefault(key, []).append({"cve": cve, "start": start, "lt": lt, "lte": lte,
"plats": plats, "prod": aff["product"],
"desc": desc})
got, touched = [], set()
monkeypatch.setattr(cpe, "_upsert", lambda db, asset, n, v, c, new_ids, **kw:
got.append((n, c["cve"])))
monkeypatch.setattr(_Asset, "operating_system", os_name)
c5.scan_asset(None, _Asset(), packages, index, touched=touched)
return sorted(got), touched
def test_cvelistv5_pairs_and_keys():
assert c5._pair_key(V5_2026_53412["vendor"], V5_2026_53412["product"]) == "zoom"
assert c5._pair_key(V5_2026_53416["vendor"], V5_2026_53416["product"]) == "zoom-vdi"
assert c5._pair_key("Zoom Communications", "Zoom Workplace VDI Plugin") == "zoom-vdi"
assert c5.resolve(WORKPLACE) == "zoom"
assert c5.resolve(PLUGIN) == "zoom-vdi"
V5_RECS = [("CVE-2026-53412", V5_2026_53412, D_53412),
("CVE-2026-53416", V5_2026_53416,
"Path traversal in Zoom VDI Client and Plugins may allow ...")]
def test_cvelistv5_scan(monkeypatch):
got, touched = _scan_v5(monkeypatch, [{"name": WORKPLACE, "version": "7.1.43453"},
{"name": PLUGIN, "version": "7.0.27050"}], V5_RECS)
assert got == [] and touched == {"CVE-2026-53416"}, (got, touched)
got, _ = _scan_v5(monkeypatch, [{"name": WORKPLACE, "version": "6.6.22255"},
{"name": PLUGIN, "version": "6.6.26830"}], V5_RECS)
assert got == [(PLUGIN, "CVE-2026-53416"), (WORKPLACE, "CVE-2026-53412")], got
def test_cvelistv5_zoom_is_windows_only(monkeypatch):
"""A record without platforms says nothing about the OS, and the Windows
pairs are the ones that carry bounds — so the cvelistV5 half stays on
Windows. NVD's target_sw places macOS and Linux."""
rec = {"vendor": "Zoom Communications Inc.", "product": "Zoom Clients",
"versions": [{"lessThan": "6.4.5", "status": "affected", "version": "0",
"versionType": "custom"}]}
pkgs = [{"name": "zoom.us", "version": "6.3.6 (47101)"}]
recs = [("CVE-2025-49462", rec, "x")]
assert _scan_v5(monkeypatch, pkgs, recs, "macOS 15.6")[0] == []
assert _scan_v5(monkeypatch, pkgs, recs)[0] == [("zoom.us", "CVE-2025-49462")]
def test_prose_bounds_have_no_pair():
""""before 5.14.0" reads as the exact version 5.14.0 — the fixed build."""
for vendor, product in (("Zoom Video Communications, Inc.", "Zoom Desktop Client for Windows"),
("Zoom Video Communications, Inc.", "Zoom VDI Windows Meeting Client"),
("Zoom", "Zoom")):
assert c5._pair_key(vendor, product) is None, product