fix(zoom): read "see references" CVEs from Zoom's security bulletins
Field report 14.10.2026: CVE-2026-108184 (ZSB-26021) and CVE-2026-108185 (ZSB-26022) were not found. Zoom's records on cvelistV5 state "version": "see references" and link the bulletin; NVD had both at "Awaiting Analysis" with no configuration (so were CVE-2026-53413, -53415 and -53420). Neither source had a bound, so no host could be flagged. zoom_bulletin_service reads the overview (newest 20) and each bulletin's "Affected Products" lines for Zoom Workplace and its VDI Client / VDI Plugins. "starting on version 7.0.0 and before version 7.2.0 and 7.1.9 in their respective branches" becomes [7.0.0, 7.1.9): each lower fix closes its own x.y branch. Several branches without a start floor at the lowest one's x.y.0; a single "before X" is everything below. Rooms, the SDKs, Contact Center and the mobile apps are other version lines and skipped; a line with no bound leaves its key to the record and NVD. The nightly cvelistV5 build merges it under the zoom / zoom-vdi keys. The bulletin decides each CVE it bounds, also over a record's own range (CVE-2026-53416: record "< 7.0.11", ZSB-26018 adds the 6.6.15 branch fix). Its lines name the platform and client or plugin, so these entries also reach macOS and Linux and skip the CVE-text heuristic. The MSI-build limit is unchanged: 7.1.43453 gets no verdict on a 7.1.9 fix. A bulletin that is not freshly parsed (unreachable, parsed empty, rolled off the overview) keeps its last parse, so its findings do not close as patched while NVD still has no bound. Index v48.
This commit is contained in:
@@ -1525,6 +1525,28 @@ 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.
|
||||
|
||||
The "see references" records point to Zoom's own bulletin, and that is read
|
||||
instead (`zoom_bulletin_service`, merged by `_merge_zoom_bulletins` on the
|
||||
nightly cvelistV5 build). CVE-2026-108184/-108185 (ZSB-26021/22) had neither a
|
||||
bound on cvelistV5 nor a configuration at NVD on 2026-10-14, and no host was
|
||||
flagged. The overview lists the newest 20 bulletins; each line of "Affected
|
||||
Products" for Zoom Workplace and its VDI Client / VDI Plugins becomes ranges:
|
||||
"starting on version 7.0.0 and before version 7.2.0 and 7.1.9 in their
|
||||
respective branches" is [7.0.0, 7.1.9), each lower fix closing its own x.y
|
||||
branch. Several branches without "starting on" floor at the lowest one's
|
||||
x.y.0 ("before 6.5.17 and 6.6.14" does not reach 6.4; NVD floored
|
||||
CVE-2026-30902 and -53412 the same way, not -53410); a single "before 6.6.0"
|
||||
is everything below. Rooms, the SDKs, Contact Center and the mobile apps are
|
||||
skipped. A line without "before"/"prior to" ("VDI Plugin version 6.6.10")
|
||||
leaves its whole key to the record and NVD. The bulletin decides each CVE it
|
||||
bounds per key, over a record's range (CVE-2026-53416's record says
|
||||
"< 7.0.11"; ZSB-26018 adds the 6.6.15 branch fix). Its lines name the
|
||||
platform and client or plugin, so these entries also reach macOS and Linux
|
||||
and skip the text heuristic. The MSI-build limit above still applies: a
|
||||
7.1.43453 host gets no verdict on a 7.1.9 fix. A bulletin that is not
|
||||
freshly parsed (unreachable, parsed empty, rolled off the overview) keeps its
|
||||
last parse, so its findings do not close as patched while NVD has no bound.
|
||||
|
||||
**Blender** (key `blender` on cvelistV5, no NVD row). NVD had CVE-2026-60103
|
||||
"Deferred" with no configuration as of 2026-10-13, and its `blender:blender`
|
||||
rows stop at 2022. The record states `3.0.0 .. lessThanOrEqual "5.1.2"` (fix
|
||||
|
||||
@@ -2186,7 +2186,7 @@ def _resolve_stale_app_findings(db: Session, asset, touched_cves: set) -> int:
|
||||
|
||||
def refresh_indexes(db: Session) -> list:
|
||||
"""Rebuild every CVE index the scans decide from: cvelistV5 from a fresh
|
||||
ZIP (+MFSA), GitHub advisories, TeamViewer bulletins, Citrix builds, IGEL
|
||||
ZIP (+MFSA, Zoom bulletins), GitHub advisories, TeamViewer bulletins, Citrix builds, IGEL
|
||||
ISNs, MSRC fixed builds.
|
||||
|
||||
Run by the 01:30 nightly job (scheduler.vuln_index_refresh_nightly) and by
|
||||
|
||||
@@ -43,7 +43,7 @@ logger = logging.getLogger(__name__)
|
||||
_ZIP_PATH = "/tmp/truevuln-cvelistv5-cache.zip"
|
||||
_ZIP_URL = "https://github.com/CVEProject/cvelistV5/archive/refs/heads/main.zip"
|
||||
_ZIP_TTL = 12 * 3600
|
||||
_INDEX_SETTING = "cvelistv5_product_index_v47" # v47: Blender
|
||||
_INDEX_SETTING = "cvelistv5_product_index_v48" # v48: Zoom bulletins
|
||||
_INDEX_TTL = timedelta(hours=26) # rebuilt nightly; a missed night still serves
|
||||
|
||||
# Curated registry: name-regex (installed software) → cvelistV5 (vendor,
|
||||
@@ -428,7 +428,8 @@ _REGISTRY: List[dict] = [
|
||||
# ("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.
|
||||
# matched no record's vendor and are gone. The "see references" CVEs come
|
||||
# from Zoom's bulletins instead, see _merge_zoom_bulletins.
|
||||
{"key": "zoom", "re": r"^zoom(?: workplace)?(?: ?\((?:(?:32|64)-?bit|arm64)\))?$|^zoom\.us$",
|
||||
"pairs": [("zoom communications", "zoom workplace for windows"),
|
||||
("zoom communications", "zoom workplace"),
|
||||
@@ -1511,6 +1512,7 @@ def build_product_index(db: Session, force_fresh: bool = False) -> dict:
|
||||
index.setdefault(key, []).append(ent)
|
||||
_merge_mozilla_mfsa(db, index, ff_meta, force_fresh=force_fresh)
|
||||
_merge_chrome_releases(db, index)
|
||||
_merge_zoom_bulletins(db, index, force_fresh=force_fresh)
|
||||
_store_index(db, index)
|
||||
logger.info("cvelistv5-scan: index built (%d files scanned, %d parsed) → %d products, %d ranges",
|
||||
scanned, parsed, len(index), sum(len(v) for v in index.values()))
|
||||
@@ -1614,6 +1616,32 @@ def _merge_chrome_releases(db: Session, index: Dict[str, list]) -> None:
|
||||
"(%d on the blog)", len(add), len(blog))
|
||||
|
||||
|
||||
def _merge_zoom_bulletins(db: Session, index: Dict[str, list],
|
||||
force_fresh: bool = False) -> None:
|
||||
"""Zoom's bulletins decide every Zoom CVE they bound.
|
||||
|
||||
Most Zoom records say "see references" and are never indexed, and NVD
|
||||
takes weeks (CVE-2026-108184, see zoom_bulletin_service). Where a record
|
||||
does state a bound, the bulletin is still the finer one: CVE-2026-53416's
|
||||
record says "< 7.0.11", ZSB-26018 adds that 6.6.15 fixed the 6.6 branch.
|
||||
Per key, so a bulletin that names only the desktop client leaves a VDI
|
||||
range from the record alone. Fresh on the nightly build, like MFSA.
|
||||
"""
|
||||
try:
|
||||
from app.services import zoom_bulletin_service as zsb
|
||||
bulletins = (not force_fresh and zsb.load(db)) or zsb.build_index(db)
|
||||
except Exception as e:
|
||||
logger.warning("cvelistv5-scan: Zoom bulletin merge skipped (%s)", e)
|
||||
return
|
||||
rows = [e for es in bulletins.values() for e in es]
|
||||
for key in ("zoom", "zoom-vdi"):
|
||||
mine = [e for e in rows if e["key"] == key]
|
||||
ruled = {e["cve"] for e in mine}
|
||||
index[key] = [e for e in index.get(key) or [] if e["cve"] not in ruled] + mine
|
||||
logger.info("cvelistv5-scan: Zoom bulletins decided %d CVEs",
|
||||
len({e["cve"] for e in rows}))
|
||||
|
||||
|
||||
def _store_index(db: Session, index: dict) -> None:
|
||||
from app.models.setting import Setting
|
||||
payload = json.dumps({"built_at": datetime.now().isoformat(), "index": index})
|
||||
@@ -3869,8 +3897,8 @@ 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.startswith("zoom") and fam not in ("windows", "macos", "linux"):
|
||||
continue # Windows for the records, macOS/Linux only for a bulletin line (below)
|
||||
if key == "blender" and fam not in ("windows", "macos"):
|
||||
continue # distro "4.3.2+dfsg-2" cleans to 4.3.2 and hides backports; Wazuh's
|
||||
if key == "teams" and cpe.teams_placeholder_version(version):
|
||||
@@ -3878,7 +3906,12 @@ def scan_asset(db: Session, asset, packages: list, index: dict,
|
||||
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")):
|
||||
if fam != "windows" and not entry.get("zsb"):
|
||||
continue # NVD's target_sw places macOS/Linux; some records name no platform
|
||||
if entry.get("vdi"):
|
||||
if (entry["vdi"] == "plugin") != cpe.zoom_plugin(name):
|
||||
continue # the bulletin line names client or plugin
|
||||
elif 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")):
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
Zoom Security Bulletins (zoom.com/en/trust/security-bulletin) — the bounds
|
||||
Zoom's own CVE records point to instead of stating them.
|
||||
|
||||
Most Zoom records on cvelistV5 say `"version": "see references"` and link the
|
||||
bulletin (CVE-2026-108184 → ZSB-26021), so until NVD analyses the CVE neither
|
||||
source has a bound. On 14.10.2026 CVE-2026-108184/-108185 (ZSB-26021/22) and
|
||||
-53413, -53415, -53420 were all at "Awaiting Analysis" with no configuration,
|
||||
and no host was flagged. The bulletin has one line per product and platform:
|
||||
|
||||
Zoom Workplace for Windows starting on version 7.0.0 and before version
|
||||
7.2.0 and 7.1.9 in their respective branches
|
||||
|
||||
Only Zoom Workplace (desktop) and its VDI Client / VDI Plugins are read, the
|
||||
products the `zoom` / `zoom-vdi` keys install-match. Rooms, the SDKs, Contact
|
||||
Center and the mobile apps are other version lines. A line without "before" /
|
||||
"prior to" ("Zoom Workplace VDI Plugin version 6.6.10") states no bound and
|
||||
is skipped. cvelistv5_scan_service merges this over the records for every
|
||||
CVE it bounds (_merge_zoom_bulletins).
|
||||
"""
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.setting import Setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
INDEX_SETTING = "zoom_bulletin_index"
|
||||
# ponytail: the overview lists the newest 20 bulletins (ZSB-26003..26022 on
|
||||
# 14.10.2026), months beyond NVD's lag; one that rolls off keeps its last
|
||||
# parse. Bulletins older than the first build are not read; the page's "Show
|
||||
# More" (/api/security-bulletin/63399) reaches them if NVD ever lags that far.
|
||||
BASE = "https://www.zoom.com/en/trust/security-bulletin/"
|
||||
_LINK = re.compile(r"/security-bulletin/(ZSB-\d+)/", re.I)
|
||||
_FIELD = re.compile(r'(?s)listItem--label">\s*(.*?)\s*:?\s*</label>\s*<span[^>]*>(.*?)</span>')
|
||||
_DESC = re.compile(r'(?s)description--label">.*?</label>.*?<p>(.*?)</p>')
|
||||
_LIST = re.compile(r'(?s)affectedProducts--list">(.*?)</ul>')
|
||||
_LI = re.compile(r"(?s)<li[^>]*>(.*?)</li>")
|
||||
_VER = r"\d+(?:\.\d+)+"
|
||||
_DESKTOP = {"windows", "macos", "linux"}
|
||||
|
||||
|
||||
def _text(fragment: str) -> str:
|
||||
return re.sub(r"\s+", " ", html.unescape(re.sub(r"<[^>]+>", " ", fragment or ""))).strip()
|
||||
|
||||
|
||||
def _ranges(start: Optional[str], fixes: List[str]) -> List[tuple]:
|
||||
""""before 7.2.0 and 7.1.9 in their respective branches" from 7.0.0 →
|
||||
[7.0.0, 7.1.9): each lower fix closes its own x.y branch, the highest one
|
||||
everything above. With several branches and no "starting on", the lowest
|
||||
branch is the floor: the bulletin names those branches, not the ones
|
||||
below (NVD floored CVE-2026-30902 and -53412 the same way). A single
|
||||
"before 6.6.0" is every version below it, as the vendor states."""
|
||||
vt = lambda v: tuple(int(x) for x in v.split("."))
|
||||
fixes = sorted(set(fixes), key=vt)
|
||||
out, lo = [], start
|
||||
if not lo and len(fixes) > 1:
|
||||
lo = "%d.%d.0" % vt(fixes[0])[:2]
|
||||
for fix in fixes:
|
||||
if not lo or vt(lo) < vt(fix):
|
||||
out.append((lo, fix))
|
||||
nxt = "%d.%d.0" % (vt(fix)[0], vt(fix)[1] + 1)
|
||||
if not lo or vt(nxt) > vt(lo):
|
||||
lo = nxt
|
||||
return out
|
||||
|
||||
|
||||
def parse_line(line: str) -> Optional[tuple]:
|
||||
"""One Affected Products line → (key, vdi, plats, ranges), or None when it
|
||||
is not the desktop/VDI client. ranges is [] when the line names no bound
|
||||
("Zoom Workplace VDI Plugin version 6.6.10")."""
|
||||
t = _text(line)
|
||||
if not re.match(r"zoom workplace\b", t, re.I):
|
||||
return None
|
||||
m = re.search(r"\b(?:before|prior to)\b", t, re.I)
|
||||
head = t[:m.start() if m else len(t)].lower()
|
||||
plats = re.findall(r"\bfor (windows|macos|linux|android|ios)\b", head)
|
||||
if plats and not set(plats) & _DESKTOP:
|
||||
return None # the mobile apps
|
||||
start = re.search(r"\bstarting (?:on |from )?version (%s)" % _VER, head)
|
||||
ranges = _ranges(start and start.group(1), re.findall(_VER, t[m.end():])) if m else []
|
||||
vdi = None
|
||||
if re.search(r"\bvdi\b", head):
|
||||
vdi = "plugin" if re.search(r"\bplug-?ins?\b", head) else "client"
|
||||
return ("zoom-vdi" if vdi else "zoom", vdi, [p for p in plats if p in _DESKTOP], ranges)
|
||||
|
||||
|
||||
def parse_bulletin(zid: str, page: str) -> List[dict]:
|
||||
"""One bulletin page → cvelistV5 index entries (plus `key`), one per CVE,
|
||||
line and range. A key with a line it cannot bound gets no entries, so the
|
||||
record and NVD keep deciding it instead of losing that line's product."""
|
||||
fields = {_text(k).lower(): _text(v) for k, v in _FIELD.findall(page)}
|
||||
cves = [c.upper() for c in re.findall(r"CVE-\d{4}-\d+", fields.get("cveid", ""), re.I)]
|
||||
try:
|
||||
cvss = float(fields.get("cvss score", ""))
|
||||
except ValueError:
|
||||
cvss = None
|
||||
sev = (fields.get("cvss severity") or "").lower() or None
|
||||
d = _DESC.search(page)
|
||||
desc = (_text(d.group(1)) + " " if d else "") + f"Source: Zoom Security Bulletin {zid}."
|
||||
lst = _LIST.search(page)
|
||||
out, unbound = [], set()
|
||||
for li in _LI.findall(lst.group(1) if lst else ""):
|
||||
parsed = parse_line(li)
|
||||
if not parsed:
|
||||
continue
|
||||
key, vdi, plats, ranges = parsed
|
||||
if not ranges:
|
||||
unbound.add(key)
|
||||
for cve in cves:
|
||||
for start, lt in ranges:
|
||||
out.append({"key": key, "cve": cve, "start": start, "lt": lt, "lte": None,
|
||||
"plats": plats, "cvss": cvss, "sev": sev, "prod": _text(li),
|
||||
"desc": desc[:2000], "vdi": vdi, "zsb": zid})
|
||||
return [e for e in out if e["key"] not in unbound]
|
||||
|
||||
|
||||
def build_index(db: Session) -> Dict[str, List[dict]]:
|
||||
"""Fetch the overview and every bulletin it links → {ZSB id: entries},
|
||||
stored. Whatever is not freshly parsed keeps its last parse (unreachable,
|
||||
parsed empty, rolled off the overview): a bulletin dropping out would
|
||||
close its findings as "patched" while NVD may still have no bound."""
|
||||
import httpx
|
||||
|
||||
last = load(db)
|
||||
idx: Dict[str, List[dict]] = dict(last)
|
||||
fresh = 0
|
||||
try:
|
||||
with httpx.Client(timeout=httpx.Timeout(30.0, connect=10.0), follow_redirects=True,
|
||||
headers={"User-Agent": "TrueVuln/1.0 (+security scanner)"}) as client:
|
||||
r = client.get(BASE)
|
||||
r.raise_for_status()
|
||||
for zid in sorted({z.upper() for z in _LINK.findall(r.text)}):
|
||||
p = client.get(f"{BASE}{zid}/")
|
||||
if p.status_code != 200:
|
||||
logger.warning("zoom-bulletins: %s answered %s — keeping its last parse",
|
||||
zid, p.status_code)
|
||||
continue
|
||||
entries = parse_bulletin(zid, p.text)
|
||||
if entries:
|
||||
idx[zid] = entries
|
||||
fresh += 1
|
||||
elif zid in last:
|
||||
logger.warning("zoom-bulletins: %s parsed to nothing — keeping its "
|
||||
"last parse", zid)
|
||||
except Exception as e:
|
||||
logger.warning("zoom-bulletins: unreachable (%s) — using the last parse", e)
|
||||
return last
|
||||
if not fresh:
|
||||
logger.warning("zoom-bulletins: no client bounds parsed — layout changed? "
|
||||
"Using the last parse")
|
||||
return last
|
||||
try:
|
||||
_store(db, idx)
|
||||
except Exception as e:
|
||||
logger.warning("zoom-bulletins: storing the parse failed (%s)", e)
|
||||
logger.info("zoom-bulletins: %d bulletins with client bounds (%d fetched now)",
|
||||
len(idx), fresh)
|
||||
return idx
|
||||
|
||||
|
||||
def _store(db: Session, idx: Dict[str, List[dict]]) -> None:
|
||||
row = db.query(Setting).filter(Setting.key == INDEX_SETTING).first()
|
||||
if row:
|
||||
row.value = json.dumps(idx)
|
||||
else:
|
||||
db.add(Setting(key=INDEX_SETTING, value=json.dumps(idx)))
|
||||
db.commit()
|
||||
|
||||
|
||||
def load(db: Session) -> Dict[str, List[dict]]:
|
||||
row = db.query(Setting).filter(Setting.key == INDEX_SETTING).first()
|
||||
try:
|
||||
return json.loads(row.value) if row and row.value else {}
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
@@ -0,0 +1,276 @@
|
||||
"""Zoom CVEs from Zoom's own security bulletins — run: python -m pytest tests/test_zoom_bulletins.py
|
||||
|
||||
Field report (14.10.2026): CVE-2026-108184 (ZSB-26021) and CVE-2026-108185
|
||||
(ZSB-26022) were not found. Zoom's records on cvelistV5 state no version:
|
||||
|
||||
"versions": [{"status": "affected", "version": "see references", ...}]
|
||||
|
||||
and point to the bulletin, and NVD had both at "Awaiting Analysis" with no
|
||||
configuration (so were CVE-2026-53413, -53415, -53420). Neither CVE source
|
||||
had a bound, so no host could be flagged. The bulletin has it, one line per
|
||||
product and platform:
|
||||
|
||||
Zoom Workplace for Windows starting on version 7.0.0 and before version
|
||||
7.2.0 and 7.1.9 in their respective branches
|
||||
|
||||
Every line below is verbatim from zoom.com (fetched 14.10.2026).
|
||||
"""
|
||||
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
|
||||
from app.services import zoom_bulletin_service as zsb
|
||||
|
||||
WORKPLACE = "Zoom Workplace (64-bit)"
|
||||
VDI_CLIENT = "Zoom Workplace VDI"
|
||||
PLUGIN = "Zoom VDI Universal Plugin(64bit)"
|
||||
|
||||
|
||||
def _page(zid, cves, sev, score, desc, lines):
|
||||
"""The bulletin page's own markup, header list and Affected Products list."""
|
||||
items = "".join(
|
||||
f'<li class="module-security-bulletin--list--listItem">\n'
|
||||
f'<label class="module-security-bulletin--list--listItem--label">\n{k}:\n</label>\n'
|
||||
f'<span class="module-security-bulletin--list--listItem--value">\n{v}\n</span></li>'
|
||||
for k, v in (("Bulletin", zid), ("CVEID", cves), ("CVSS Severity", sev),
|
||||
("CVSS Score", score)))
|
||||
lis = "".join(f"<li>{x}</li>\n" for x in lines)
|
||||
return (f'<ul class="module-security-bulletin--list">{items}</ul>'
|
||||
f'<div class="module-security-bulletin--description">'
|
||||
f'<label class="module-security-bulletin--description--label">Description:</label>'
|
||||
f'<p>\n<p>{desc}</p>\n<p> </p><p>Users can help keep themselves secure ...</p></p></div>'
|
||||
f'<div class="module-security-bulletin--affectedProducts">'
|
||||
f'<label class="module-security-bulletin--affectedProducts--label">Affected Products:</label>'
|
||||
f'<ul class="module-security-bulletin--affectedProducts--list">{lis}</ul></div>')
|
||||
|
||||
|
||||
ZSB_26021 = _page(
|
||||
"ZSB-26021", "CVE-2026-108184", "High", "7.4",
|
||||
"Improper certificate validation in certain Zoom Clients for Windows may allow an "
|
||||
"unauthenticated user to conduct an information disclosure via network access.",
|
||||
["Zoom Workplace for Windows starting on version 7.0.0 and before version 7.2.0 and "
|
||||
"7.1.9 in their respective branches",
|
||||
"Zoom Workplace VDI Client for Windows starting on version 7.0.11 and before version 7.0.13",
|
||||
"Zoom Rooms for Windows starting on version 7.0.0 and before version 7.1.7"])
|
||||
ZSB_26022 = _page(
|
||||
"ZSB-26022", "CVE-2026-108185", "High", "7.4",
|
||||
"Improper certificate validation in the My Notes feature of certain Zoom Clients may "
|
||||
"allow an unauthenticated user to conduct an information disclosure via network access.",
|
||||
["Zoom Workplace for Windows starting on version 7.1.5 and before version 7.2.0 and "
|
||||
"7.1.9 in their respective branches",
|
||||
"Zoom Workplace for MacOS starting on version 7.0.5 and before version 7.2.0 and 7.1.9 "
|
||||
"in their respective branches",
|
||||
"Zoom Workplace for Linux starting on version 7.0.5 and before version 7.2.0 and 7.1.9 "
|
||||
"in their respective branches",
|
||||
"Zoom Workplace for Android starting on version 7.0.5 and before version 7.2.0",
|
||||
"Zoom Workplace for iOS starting on version 7.0.5 and before version 7.2.0"])
|
||||
# CVE-2026-53416 HAS a bound on cvelistV5 ("Zoom VDI" < 7.0.11), but not the
|
||||
# 6.6 branch's own fix: read alone it flags a patched 6.6.15 client.
|
||||
ZSB_26018 = _page(
|
||||
"ZSB-26018", "CVE-2026-53416", "High", "7.3",
|
||||
"Path traversal in certain Zoom Workplace VDI Clients and Plugins may allow ...",
|
||||
["Zoom Workplace VDI Client for Windows before versions 7.0.11 and 6.6.15 in their "
|
||||
"respective branches",
|
||||
"Zoom Workplace VDI Plugins all supported platforms before 7.0.11 and 6.6.15 in their "
|
||||
"respective branches"])
|
||||
OVERVIEW = ('<a target="_blank" href="/en/trust/security-bulletin/ZSB-26022/">ZSB-26022</a>'
|
||||
'<a target="_blank" href="/en/trust/security-bulletin/ZSB-26021/">ZSB-26021</a>'
|
||||
'<a target="_blank" href="/en/trust/security-bulletin/ZSB-26018/">ZSB-26018</a>')
|
||||
PAGES = {"ZSB-26021": ZSB_26021, "ZSB-26022": ZSB_26022, "ZSB-26018": ZSB_26018}
|
||||
|
||||
V5_2026_53416 = {"cve": "CVE-2026-53416", "start": "0", "lt": "7.0.11", "lte": None,
|
||||
"plats": ["windows"], "prod": "Zoom VDI",
|
||||
"desc": "Path traversal in Zoom VDI Client and Plugins may allow ..."}
|
||||
|
||||
|
||||
# ---- 1. the wording → ranges ------------------------------------------------
|
||||
def test_line_wording():
|
||||
cases = {
|
||||
# each lower fix closes its own branch, the highest one the rest
|
||||
"Zoom Workplace for Windows starting on version 7.0.0 and before version 7.2.0 and "
|
||||
"7.1.9 in their respective branches": ("zoom", None, ["windows"], [("7.0.0", "7.1.9")]),
|
||||
"Zoom Workplace VDI Client for Windows before versions 6.4.15 and 6.5.13 and 6.6.10 "
|
||||
"in their respective branches": ("zoom-vdi", "client", ["windows"],
|
||||
[("6.4.0", "6.4.15"), ("6.5.0", "6.5.13"),
|
||||
("6.6.0", "6.6.10")]),
|
||||
"Zoom Workplace VDI Client for Windows before version 7.0.10 and 6.6.15 and 6.5.18 "
|
||||
"in their respective branches": ("zoom-vdi", "client", ["windows"],
|
||||
[("6.5.0", "6.5.18"), ("6.6.0", "6.6.15"),
|
||||
("6.7.0", "7.0.10")]),
|
||||
"Zoom Workplace VDI plugin for Windows before 6.5.17 and 6.6.14 in their respective "
|
||||
"branch": ("zoom-vdi", "plugin", ["windows"], [("6.5.0", "6.5.17"), ("6.6.0", "6.6.14")]),
|
||||
"Zoom Workplace VDI Plugin for Windows versions prior to 6.6.14":
|
||||
("zoom-vdi", "plugin", ["windows"], [(None, "6.6.14")]),
|
||||
"Zoom Workplace all supported platforms before version 7.1.0 and 7.0.6 in their "
|
||||
"respective branches": ("zoom", None, [], [("7.0.0", "7.0.6")]),
|
||||
"Zoom Workplace VDI Plugins all supported platforms before 7.0.11 and 6.6.15 in their "
|
||||
"respective branches": ("zoom-vdi", "plugin", [], [("6.6.0", "6.6.15"), ("6.7.0", "7.0.11")]),
|
||||
"Zoom Workplace for Windows before version 6.6.0": ("zoom", None, ["windows"],
|
||||
[(None, "6.6.0")]),
|
||||
}
|
||||
for line, want in cases.items():
|
||||
assert zsb.parse_line(line) == want, line
|
||||
|
||||
|
||||
def test_lines_without_a_bound_or_not_the_client():
|
||||
assert zsb.parse_line("Zoom Workplace VDI Plugin version 6.6.10") == \
|
||||
("zoom-vdi", "plugin", [], []) # one version, no bound
|
||||
for line in ("Zoom Rooms for Windows before version 7.1.0",
|
||||
"Zoom Meeting SDK all supported platforms before version 7.1.0",
|
||||
"Zoom Video SDK all supported platforms before version 2.6.0",
|
||||
"Remote Control for Zoom Contact Center for Windows before version 7.0.0",
|
||||
"Zoom Workplace for Android before version 7.2.1",
|
||||
"Zoom Workplace for iOS starting on version 7.0.5 and before version 7.2.0"):
|
||||
assert zsb.parse_line(line) is None, line
|
||||
|
||||
|
||||
def test_bulletin_page():
|
||||
es = zsb.parse_bulletin("ZSB-26021", ZSB_26021)
|
||||
assert [(e["key"], e["vdi"], e["start"], e["lt"]) for e in es] == [
|
||||
("zoom", None, "7.0.0", "7.1.9"), ("zoom-vdi", "client", "7.0.11", "7.0.13")]
|
||||
e = es[0]
|
||||
assert (e["cve"], e["cvss"], e["sev"], e["zsb"]) == ("CVE-2026-108184", 7.4, "high", "ZSB-26021")
|
||||
assert e["desc"].startswith("Improper certificate validation in certain Zoom Clients")
|
||||
assert "ZSB-26021" in e["desc"]
|
||||
|
||||
|
||||
# ---- 2. through the real merge and scan -------------------------------------
|
||||
class _Asset:
|
||||
id = 1
|
||||
operating_system = "Microsoft Windows 11 Pro"
|
||||
|
||||
|
||||
def _index(monkeypatch, record_index=None):
|
||||
monkeypatch.setattr(zsb, "load", lambda db: {z: zsb.parse_bulletin(z, h)
|
||||
for z, h in PAGES.items()})
|
||||
index = {k: list(v) for k, v in (record_index or {}).items()}
|
||||
c5._merge_zoom_bulletins(None, index)
|
||||
return index
|
||||
|
||||
|
||||
def _scan(monkeypatch, packages, os_name="Microsoft Windows 11 Pro", record_index=None):
|
||||
got, touched = [], set()
|
||||
monkeypatch.setattr(cpe, "_upsert", lambda db, asset, n, v, c, new_ids, **kw:
|
||||
got.append((n, c["cve"], c["fixed"])))
|
||||
monkeypatch.setattr(_Asset, "operating_system", os_name)
|
||||
c5.scan_asset(None, _Asset(), packages, _index(monkeypatch, record_index), touched=touched)
|
||||
return sorted(got), touched
|
||||
|
||||
|
||||
def test_field_report_cves_on_windows(monkeypatch):
|
||||
got, _ = _scan(monkeypatch, [{"name": WORKPLACE, "version": "7.1.8 (61450)"}])
|
||||
assert got == [(WORKPLACE, "CVE-2026-108184", "7.1.9"),
|
||||
(WORKPLACE, "CVE-2026-108185", "7.1.9")], got
|
||||
# 7.0 branch: no fix of its own, below 7.1.5 for the My Notes flaw
|
||||
got, _ = _scan(monkeypatch, [{"name": WORKPLACE, "version": "7.0.12 (60000)"}])
|
||||
assert got == [(WORKPLACE, "CVE-2026-108184", "7.1.9")], got
|
||||
for fixed in ("7.1.9 (61500)", "7.2.0 (62000)", "6.6.10 (55000)"):
|
||||
assert _scan(monkeypatch, [{"name": WORKPLACE, "version": fixed}])[0] == [], fixed
|
||||
|
||||
|
||||
def test_msi_builds(monkeypatch):
|
||||
"""7.0.40000: the bounds sit in other minors, or at 7.0.0 → decided.
|
||||
7.1.43453: the 7.1.9 fix is in its own minor → no verdict, kept open."""
|
||||
got, _ = _scan(monkeypatch, [{"name": WORKPLACE, "version": "7.0.40000"}])
|
||||
assert got == [(WORKPLACE, "CVE-2026-108184", "7.1.9")], got
|
||||
got, touched = _scan(monkeypatch, [{"name": WORKPLACE, "version": "7.1.43453"}])
|
||||
assert got == [] and touched == {"CVE-2026-108184", "CVE-2026-108185"}, (got, touched)
|
||||
|
||||
|
||||
def test_bulletin_names_the_platform(monkeypatch):
|
||||
"""A record without platforms keeps the cvelistV5 half on Windows; the
|
||||
bulletin names each one, so a Mac gets the macOS line and not the
|
||||
Windows-only ZSB-26021."""
|
||||
mac = [{"name": "zoom.us", "version": "7.1.8 (61450)"}]
|
||||
got, _ = _scan(monkeypatch, mac, "macOS 15.6")
|
||||
assert got == [("zoom.us", "CVE-2026-108185", "7.1.9")], got
|
||||
got, _ = _scan(monkeypatch, mac, "macOS 15.6", {"zoom": [dict(
|
||||
V5_2026_53416, cve="CVE-2099-0001", lt="7.2.0", plats=[])]})
|
||||
assert got == [("zoom.us", "CVE-2026-108185", "7.1.9")], got
|
||||
|
||||
|
||||
def test_bulletin_names_client_or_plugin(monkeypatch):
|
||||
pkgs = [{"name": VDI_CLIENT, "version": "7.0.12"}, {"name": PLUGIN, "version": "7.0.12"}]
|
||||
got, _ = _scan(monkeypatch, pkgs)
|
||||
assert got == [(VDI_CLIENT, "CVE-2026-108184", "7.0.13")], got
|
||||
|
||||
|
||||
def test_bulletin_decides_over_the_record(monkeypatch):
|
||||
"""The record says < 7.0.11 for every version; the bulletin says 6.6.15
|
||||
fixed the 6.6 branch."""
|
||||
rec = {"zoom-vdi": [V5_2026_53416]}
|
||||
got, _ = _scan(monkeypatch, [{"name": VDI_CLIENT, "version": "6.6.15"}], record_index=rec)
|
||||
assert got == [], got
|
||||
got, _ = _scan(monkeypatch, [{"name": VDI_CLIENT, "version": "6.6.14"},
|
||||
{"name": PLUGIN, "version": "7.0.10"}], record_index=rec)
|
||||
assert got == [(PLUGIN, "CVE-2026-53416", "7.0.11"),
|
||||
(VDI_CLIENT, "CVE-2026-53416", "6.6.15")], got
|
||||
|
||||
|
||||
# ---- 3. fetch ----------------------------------------------------------------
|
||||
class _Resp:
|
||||
def __init__(self, text, status=200):
|
||||
self.text, self.status_code = text, status
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.status_code != 200:
|
||||
raise OSError(self.status_code)
|
||||
|
||||
|
||||
def _client(pages):
|
||||
class Client:
|
||||
def __init__(self, **kw):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def get(self, url):
|
||||
if url == zsb.BASE:
|
||||
return pages["overview"]
|
||||
return pages.get(url.rstrip("/").rsplit("/", 1)[1]) or _Resp("", 503)
|
||||
return Client
|
||||
|
||||
|
||||
def test_build_index(monkeypatch):
|
||||
"""Whatever is not freshly parsed keeps its last parse, so its findings
|
||||
do not close as "patched": an unreachable page (26018), one that answers
|
||||
with something else (26022), one that rolled off the overview (25001)."""
|
||||
import httpx
|
||||
stored = {}
|
||||
monkeypatch.setattr(zsb, "_store", lambda db, idx: stored.update(idx))
|
||||
monkeypatch.setattr(zsb, "load", lambda db: {"ZSB-26018": ["kept"], "ZSB-26022": ["kept"],
|
||||
"ZSB-25001": ["kept"]})
|
||||
monkeypatch.setattr(httpx, "Client", _client(
|
||||
{"overview": _Resp(OVERVIEW), "ZSB-26021": _Resp(ZSB_26021),
|
||||
"ZSB-26022": _Resp("<html>challenge</html>")}))
|
||||
idx = zsb.build_index(None)
|
||||
assert sorted(idx) == ["ZSB-25001", "ZSB-26018", "ZSB-26021", "ZSB-26022"], idx
|
||||
assert idx["ZSB-26021"][0]["cve"] == "CVE-2026-108184"
|
||||
assert all(idx[z] == ["kept"] for z in ("ZSB-25001", "ZSB-26018", "ZSB-26022"))
|
||||
assert stored == idx
|
||||
|
||||
|
||||
def test_a_line_without_a_bound_leaves_its_key_to_the_record():
|
||||
"""ZSB-26007 writes "VDI Plugin version 6.6.10". Bounding only the client
|
||||
line would still drop the record's plugin range for the CVE."""
|
||||
page = _page("ZSB-29999", "CVE-2099-0002", "High", "7.0", "x",
|
||||
["Zoom Workplace for Windows before version 6.6.0",
|
||||
"Zoom Workplace VDI Client for Windows before version 6.6.10",
|
||||
"Zoom Workplace VDI Plugin version 6.6.10"])
|
||||
assert [e["key"] for e in zsb.parse_bulletin("ZSB-29999", page)] == ["zoom"]
|
||||
|
||||
|
||||
def test_unreachable_serves_last_parse(monkeypatch):
|
||||
import httpx
|
||||
monkeypatch.setattr(zsb, "load", lambda db: {"ZSB-26021": ["last"]})
|
||||
monkeypatch.setattr(httpx, "Client", _client({"overview": _Resp("", 503)}))
|
||||
assert zsb.build_index(None) == {"ZSB-26021": ["last"]}
|
||||
monkeypatch.setattr(httpx, "Client", _client({"overview": _Resp("<html>new layout</html>")}))
|
||||
assert zsb.build_index(None) == {"ZSB-26021": ["last"]}
|
||||
Reference in New Issue
Block a user