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