fix(scan): a companion product is not the product it names

"Veeam Explorer for Microsoft Teams" 13.3.2.3 collected four Teams CVEs whose
bounds are 25122.x — Veeam's own software, matched purely because its name
contains "Microsoft Teams". Checking the rest of the registry against the same
shape found four more: Chrome findings on "Citrix Workspace app for Google
Chrome", Firefox findings on "Kaspersky Plugin for Mozilla Firefox", and so on.
Backup tools, plugins and connectors are all named this way.

The product being described stands FIRST, so a match that lands only in the
"…for <product>" tail is dropped. Matching itself still runs on the full name,
which matters: the Firefox entry rejects "Firefox for iOS" itself (WebKit, not
Gecko, different CVEs), and trimming the name before matching hid that from it
— caught by test_mobile_browsers.

Teams is anchored to the start of the name as well, so a product that merely
mentions it later cannot match even without a "for". Both scanner paths share
the rule, so they cannot disagree about the same install.
This commit is contained in:
2026-08-10 14:18:56 +02:00
parent 591e27bbe9
commit 93120f5dd6
3 changed files with 96 additions and 2 deletions
+32 -1
View File
@@ -181,7 +181,7 @@ _REGISTRY: List[tuple] = [
# Only Teams itself — NOT the Office add-in, the VDI/Citrix plugin, or the # Only Teams itself — NOT the Office add-in, the VDI/Citrix plugin, or the
# machine-wide installer (separate products with their own versioning that # machine-wide installer (separate products with their own versioning that
# would false-positive against Teams-app CVE ranges). # would false-positive against Teams-app CVE ranges).
_cpe(r"microsoft teams(?!.*(machine-wide|add-in|plugin|vdi|citrix))", "microsoft:teams"), _cpe(r"^microsoft teams(?!.*(machine-wide|add-in|plugin|vdi|citrix))", "microsoft:teams"),
# Exchange Server Subscription Edition — a full blind spot in Wazuh # Exchange Server Subscription Edition — a full blind spot in Wazuh
# (wazuh/wazuh#36200). ANCHORED and exact on purpose: the same host also # (wazuh/wazuh#36200). ANCHORED and exact on purpose: the same host also
# carries "Microsoft Exchange Server" and a dozen "… Language Pack - X" # carries "Microsoft Exchange Server" and a dozen "… Language Pack - X"
@@ -300,13 +300,44 @@ def _platform_ok(tsws, plat: Optional[str]) -> bool:
return bool(plat) and plat in tsws return bool(plat) and plat in tsws
# "<something> for <product>" names a companion, not the product. Backup tools,
# plugins and connectors are all built this way: "Veeam Explorer for Microsoft
# Teams" is Veeam's software, and its 13.3.2.3 has nothing to do with Teams'
# 25122.x — the tester saw four Teams CVEs land on it. The same shape produced
# Chrome findings on "Citrix Workspace app for Google Chrome" and Firefox
# findings on "Kaspersky Plugin for Mozilla Firefox".
#
# The product being described always stands FIRST, so only that part is matched
# against the registry. Names where the product itself carries the word — a
# hypothetical "Microsoft Teams for Desktop" — still resolve, because what
# precedes "for" is the real product.
_COMPANION_RE = re.compile(
r"\s+(?:plug-?in|add-?in|add-?on|extension|connector|agent|module|"
r"integration|explorer|backup|app)?\s*\bfor\b\s+", re.I)
def companion_prefix(name: str) -> str:
"""The product part of a "<x> for <y>" name, or the name unchanged."""
parts = _COMPANION_RE.split(name or "", maxsplit=1)
head = (parts[0] or "").strip()
return head if len(parts) > 1 and head else (name or "")
def resolve_product(name: str) -> Optional[dict]: def resolve_product(name: str) -> Optional[dict]:
n = (name or "").strip().lower() n = (name or "").strip().lower()
if not n: if not n:
return None return None
# Matching runs on the FULL name so every exclusion in the registry still
# sees what it was written against — "Firefox for iOS" is rejected by the
# Firefox entry itself (WebKit, different CVEs), and trimming the name
# first would have hidden that from it. The prefix only decides WHERE the
# match landed: in the product, or in the "…for <product>" tail.
head = companion_prefix(n)
for rx, entry in _REGISTRY: for rx, entry in _REGISTRY:
m = rx.search(n) m = rx.search(n)
if m: if m:
if head != n and not rx.search(head):
continue # companion product — see companion_prefix
if entry.get("key") == "cpe:mozilla:firefox" and re.search(r"\besr\b", n): if entry.get("key") == "cpe:mozilla:firefox" and re.search(r"\besr\b", n):
# ESR install: the plain mozilla:firefox CPE / cvelistV5 ranges # ESR install: the plain mozilla:firefox CPE / cvelistV5 ranges
# describe the release train, not ESR (NVD tracks ESR as its # describe the release train, not ESR (NVD tracks ESR as its
+6 -1
View File
@@ -245,7 +245,7 @@ _REGISTRY: List[dict] = [
# products are dropped so a Mac-only Teams CVE can't land on a Windows host # products are dropped so a Mac-only Teams CVE can't land on a Windows host
# (the per-scan platform filter is the general backstop, but not every CNA # (the per-scan platform filter is the general backstop, but not every CNA
# fills the platforms field, so scoping the pairs is belt-and-suspenders). # fills the platforms field, so scoping the pairs is belt-and-suspenders).
{"key": "teams", "re": r"microsoft teams(?!.*(machine-wide|add-in|plugin|vdi|citrix))", {"key": "teams", "re": r"^microsoft teams(?!.*(machine-wide|add-in|plugin|vdi|citrix))",
"pairs": [("microsoft", "microsoft teams for desktop"), "pairs": [("microsoft", "microsoft teams for desktop"),
("microsoft", "microsoft teams for windows"), ("microsoft", "microsoft teams for windows"),
("microsoft", "microsoft teams"), ("microsoft", "teams")]}, ("microsoft", "microsoft teams"), ("microsoft", "teams")]},
@@ -328,8 +328,13 @@ def resolve(name: str) -> Optional[str]:
n = (name or "").strip().lower() n = (name or "").strip().lower()
if not n: if not n:
return None return None
# Full name for matching (exclusions depend on it), prefix only to tell a
# companion product apart — see cpe.companion_prefix / resolve_product.
head = cpe.companion_prefix(n)
for rx, e in _COMPILED: for rx, e in _COMPILED:
if rx.search(n): if rx.search(n):
if head != n and not rx.search(head):
continue
if e["key"] == "firefox" and re.search(r"\besr\b", n): if e["key"] == "firefox" and re.search(r"\besr\b", n):
# ESR install ("Mozilla Firefox 52.2.1 ESR"): the cvelistV5 # ESR install ("Mozilla Firefox 52.2.1 ESR"): the cvelistV5
# Firefox ranges describe the RELEASE train (fresh records list # Firefox ranges describe the RELEASE train (fresh records list
+58
View File
@@ -0,0 +1,58 @@
""""<x> for <y>" is a companion product, not the product itself.
"Veeam Explorer for Microsoft Teams" 13.3.2.3 collected four Teams CVEs whose
bounds are 25122.x — Veeam's software, matched because its name contains
"Microsoft Teams". Backup tools, plugins and connectors are all named this way,
and the same shape put Chrome findings on "Citrix Workspace app for Google
Chrome" and Firefox findings on "Kaspersky Plugin for Mozilla Firefox".
The product being described always comes FIRST, so only that part is matched.
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.services.app_cve_scanner_service import ( # noqa: E402
companion_prefix, resolve_product)
from app.services import cvelistv5_scan_service as c5 # noqa: E402
def demo():
# The split itself.
assert companion_prefix("Veeam Explorer for Microsoft Teams") == "Veeam"
assert companion_prefix("Citrix Workspace app for Google Chrome") == "Citrix Workspace"
assert companion_prefix("Microsoft Teams") == "Microsoft Teams"
# No "for" → untouched, including names with punctuation and editions.
for name in ("Microsoft Visual C++ 2013 Redistributable (x86) - 12.0.21005",
"7-Zip 19.00 (x64 edition)", "Adobe Acrobat (64-bit)"):
assert companion_prefix(name) == name, name
# Companions must not resolve to the product they name.
for name in ("Veeam Explorer for Microsoft Teams",
"Veeam Backup for Microsoft 365",
"Citrix Workspace app for Google Chrome",
"Kaspersky Plugin for Mozilla Firefox",
"Nagios Plugin for Node.js"):
assert resolve_product(name) is None, name
assert c5.resolve(name) is None, name
# The real products still resolve on both paths.
for name, cpe_key, c5_key in (
("Microsoft Teams", "cpe:microsoft:teams", "teams"),
("Google Chrome", "cpe:google:chrome", "chrome"),
("Mozilla Firefox", "cpe:mozilla:firefox", "firefox"),
("Node.js", "cpe:nodejs:node.js", "nodejs"),
("7-Zip 19.00 (x64 edition)", "cpe:7-zip:7-zip", "7-zip")):
assert (resolve_product(name) or {}).get("key") == cpe_key, name
assert c5.resolve(name) == c5_key, name
# Teams is also anchored now: the name has to START with it, so a product
# that merely mentions Teams later cannot match even without a "for".
assert resolve_product("Some Vendor Microsoft Teams Toolkit") is None
print("ok companion products no longer inherit the product's CVEs")
if __name__ == "__main__":
demo()