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:
2026-10-14 08:58:57 +02:00
parent 6754b6b700
commit 5c55a4e9f3
5 changed files with 518 additions and 6 deletions
+22
View File
@@ -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
+1 -1
View File
@@ -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
+38 -5
View File
@@ -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")):
+181
View File
@@ -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 {}
+276
View File
@@ -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>&nbsp;</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"]}