fix(cvelistv5): Citrix Workspace app CVEs never reached a Windows host

Two faults, either one enough on its own. The inventory filter dropped every
package whose vendor contains "citrix", which was meant for the "Delivered by
Citrix" published-app stubs but also removed every real install ("Citrix
Workspace 2507", vendor "Citrix Systems, Inc."). And behind it, Citrix states
its fixes as release names, not builds: CVE-2026-78546/-78547 give
"2603.11 Current Release (CR)", "2507.1 LTSR CU3" and "LTSR 2607". Read as
digits, "LTSR 2607" is (2607,), so every 25.x/26.x build would have matched,
the patched CU3, CR 2603.11 and LTSR 2607 hosts included.

The inventory states only the build (25.7.1000.1025), with no CR/LTSR and no
CU. Both sides now meet on the build via a catalog read from Citrix's download
pages (title + "Version:"), kept only when the build agrees with its title. Five
CR pages print a sidebar build (22.12.0.48) first, and without that check
they would have entered the catalog as 2302..2307.1. The catalog is seeded with
44 pages and refreshed weekly by the 01:30 index job.

CR and LTSR never share a year.month, so the build's first two fields name the
branch. The decision reuses vmware_release_service.is_affected: CR is one
line, each LTSR is its own line with the CU as update line (CVE-2025-4879 fixes
2402 in CU2 HF1 and CU3 HF1). A bound that resolves to no single catalog build
leaves the CVE undecided and held open, never guessed. Windows hosts only; the
Mac app shares the name and its 25.07.x numbering.
This commit is contained in:
2026-09-14 11:14:00 +02:00
parent 330a4e21a3
commit 2214af1d64
6 changed files with 722 additions and 9 deletions
+2
View File
@@ -833,8 +833,10 @@ def vuln_index_refresh_nightly():
# MFSA bug, 978d4f3). Notepad++, Wazuh and the IGEL ISNs without a
# CVE reach no other source at all; TeamViewer publishes days before
# NVD — a night late is a night blind.
from app.services import citrix_workspace_service
for _mod, _label in ((github_repo_advisory_service, "repo-advisory"),
(teamviewer_bulletin_service, "teamviewer-bulletin"),
(citrix_workspace_service, "citrix-build-catalog"),
(igel_isn_service, "igel-isn")):
try:
_mod.build_index(db)
+6 -2
View File
@@ -1371,9 +1371,13 @@ def _is_citrix_shim(pkg: dict) -> bool:
"""Citrix published-app delivery leaves a registry stub ('Firefox 1.0',
vendor 'Delivered by Citrix') for software that is NOT installed on the
box — matching it produced ancient-CVE false positives (observed:
CVE-2008-2798 on 'Firefox 1.0'). The vendor field identifies the stub."""
CVE-2008-2798 on 'Firefox 1.0'). The vendor field identifies the stub.
The delivery phrase, not the bare word: "citrix" also dropped every real
Citrix install ("Citrix Workspace 2507", vendor "Citrix Systems, Inc."),
so no Citrix CVE ever reached a host (CVE-2026-78546/-78547)."""
vendor = (pkg.get("vendor") or "").lower()
return "citrix" in vendor
return "delivered by citrix" in vendor
def filter_inventory(packages: list) -> list:
+415
View File
@@ -0,0 +1,415 @@
"""Citrix Workspace app for Windows — build identity and the CVE decision.
Why this module exists
----------------------
The Windows inventory states one thing about the install: the build.
Citrix Workspace 2507 Citrix Systems, Inc. 25.7.1000.1025
It does not say Current Release (CR) or Long Term Service Release (LTSR), and
it does not say which Cumulative Update (CU). Citrix's CVE records, in turn,
state no build at all — only release names, and in a different spelling on
nearly every record:
CVE-2026-78547 0 lessThan "2603.11 Current Release (CR)"
0 lessThan "2507.1 LTSR CU3"
0 lessThan "LTSR 2607"
CVE-2025-4879 "2402 LTSR" lessThan "CU2 Hotfix 1"
CVE-2024-7889 "Current Release (CR)" lessThan "2405"
Read as digits those bounds are (2603, 11), (2507, 1, 3) and (2607,), and every
25.x/26.x build sits below all of them — the fully patched CU3, CR 2603.11 and
LTSR 2607 hosts included. Read literally, the three ranges also contradict each
other: "0 .. LTSR 2607" swallows 2507.1 CU3, which the same record calls fixed.
The build catalog
-----------------
Both sides meet on the BUILD, and Citrix publishes the mapping on its download
pages: one page per release, its title naming the release and a "Version:"
line naming the build ("Citrix Workspace app for Windows LTSR 2507.1 Cumulative
Update 3" / "Version: 25.7.3000.3034"). The catalog is those pairs — nothing is
derived from a numbering scheme, a row is only ever what a page says.
A page row is accepted only if its build AGREES with its title: year.month
from the release (2507.1 → 25.7), and the third field from the rest (CR
2603.11 → 11, LTSR base 2507.1 → 1, CU3 → 3000, CU2 Hotfix 1 → 2001). That
check is not optional: five CR pages (2302 … 2307.1) print a sidebar build,
22.12.0.48, as their first "Version:", and without it they would teach the
catalog that 22.12.0.48 is CR 2307.1.
Release lines
-------------
CR and LTSR never share a year.month (checked on every Windows download page:
24.2, 25.7, 26.7 are LTSR-only, there is no CR 2402/2507/2607). So the catalog
answers "which branch is this build" from its first two fields, and a build on
a year.month no page has named gets no verdict at all.
The decision then reuses vmware_release_service.is_affected, because the shape
is the same one VMware has: one fix PER LINE, several lines per CVE.
* CR is one line; a build is affected iff it is below the CR fix.
* Each LTSR (2402, 2507.1, 2607) is its own line and its CU is the update
line. CVE-2025-4879 fixes 2402 in "CU2 Hotfix 1" AND "CU3 Hotfix 1": a CU2
HF1 host is patched although CU3 base (a higher build) is not.
* A fix on another branch or another LTSR never decides: "before LTSR 2607"
says nothing about a 2402 host, and the CR fix nothing about any LTSR.
A bound that cannot be resolved to exactly one catalog build (lessThan "1",
a lessThanOrEqual, a release no page lists) makes the whole CVE undecided —
"a missing verdict is recoverable, a wrong one is not".
"""
from __future__ import annotations
import html as _html
import json
import logging
import re
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Dict, Iterable, List, Optional, Sequence, Tuple
from app.services import vmware_release_service as vmr
logger = logging.getLogger(__name__)
# The cvelistV5 registry key.
KEY = "citrix-workspace-win"
CR = "CR"
LTSR = "LTSR"
_DOWNLOADS_URL = "https://www.citrix.com/downloads/workspace-app/"
# The four sections that hold Workspace app for WINDOWS, current and legacy.
# Everything else under /workspace-app/ is another product (Mac, Linux, the
# Enterprise Browser, Desktop Lock) with its own numbering.
_WINDOWS_PAGE_RE = re.compile(
r'href="(/downloads/workspace-app/(?:windows|legacy-workspace-app-for-windows'
r'|workspace-app-for-windows-long-term-service-release'
r'|legacy-workspace-app-for-windows-ltsr)/[^"#?]+\.html)"', re.I)
_CATALOG_SETTING = "citrix_workspace_build_catalog"
_CATALOG_TTL = timedelta(days=7)
# ----------------------------------------------------------------------
# Names
# ----------------------------------------------------------------------
# "2507.1", "2402", "2603.11" — never a piece of a dotted number, or the
# "1001" in "19.12.1001" would read as a release.
_RELEASE_RE = re.compile(r"(?<![\d.])(\d{4})(?:\.(\d{1,2}))?(?![\d.])")
_CU_RE = re.compile(r"\b(?:cu|cumulative\s+update)\s*(\d+)\b", re.I)
_HF_RE = re.compile(r"\b(?:hotfix|hf)\s*(\d+)\b", re.I)
_LTSR_RE = re.compile(r"\bltsr\b|long\s+term\s+service", re.I)
_CR_RE = re.compile(r"\bcr\b|current\s+release", re.I)
_BUILD_RE = re.compile(r"^\s*(\d+)\.(\d+)\.(\d+)\.(\d+)\s*$")
@dataclass(frozen=True)
class Name:
"""What a release name says, with anything it does not say left None."""
branch: Optional[str]
release: Optional[str]
cu: Optional[int]
hotfix: Optional[int]
def parse_name(text: Optional[str]) -> Name:
s = text or ""
ltsr, cr = bool(_LTSR_RE.search(s)), bool(_CR_RE.search(s))
branch = LTSR if ltsr and not cr else CR if cr and not ltsr else None
rel = _RELEASE_RE.search(s)
cu, hf = _CU_RE.search(s), _HF_RE.search(s)
return Name(branch=branch,
release=(rel.group(0) if rel else None),
cu=(int(cu.group(1)) if cu else None),
hotfix=(int(hf.group(1)) if hf else None))
def parse_build(raw: Optional[str]) -> Optional[Tuple[int, int, int, int]]:
m = _BUILD_RE.match(raw or "")
return tuple(int(x) for x in m.groups()) if m else None
# ----------------------------------------------------------------------
# Catalog
# ----------------------------------------------------------------------
@dataclass(frozen=True)
class Entry:
branch: str
release: str
cu: int
hotfix: int
build: Tuple[int, int, int, int]
@property
def label(self) -> str:
s = f"{self.branch} {self.release}"
if self.cu:
s += f" CU{self.cu}"
if self.hotfix:
s += f" Hotfix {self.hotfix}"
return s
def entry_from_page(title: str, version: str) -> Optional[Entry]:
"""One download page (its title and "Version:") → a catalog entry, or None
when the build does not agree with the title (see the module docstring)."""
build = parse_build(version)
n = parse_name(title)
if not build or not n.release:
return None
# A download page never says "CR"; it says LTSR or nothing.
branch = LTSR if n.branch == LTSR else CR
cu, hf = n.cu or 0, n.hotfix or 0
if branch == CR and (cu or hf):
return None
yy, mm = int(n.release[:2]), int(n.release[2:4])
minor = int(n.release.split(".")[1]) if "." in n.release else 0
third = cu * 1000 + hf if cu else minor
if build[:3] != (yy, mm, third):
return None
return Entry(branch=branch, release=n.release, cu=cu, hotfix=hf, build=build)
def compile_rows(rows: Iterable[Sequence[str]]) -> List[Entry]:
out: Dict[tuple, Entry] = {}
for title, version in rows:
e = entry_from_page(title, version)
if e:
out.setdefault(e.build, e)
return list(out.values())
def branch_of(build: Tuple[int, ...], catalog: Sequence[Entry]) -> Optional[str]:
"""The branch every catalog build on this year.month belongs to, or None
when no page names the line (or, which has never happened, both do)."""
found = {e.branch for e in catalog if e.build[:2] == build[:2]}
return found.pop() if len(found) == 1 else None
def _release(branch: str, build: Tuple[int, ...], label: str) -> vmr.Release:
if branch == CR:
return vmr.Release(line=(CR,), update=0, build=build, label=label)
return vmr.Release(line=(LTSR, build[0], build[1]), update=build[2] // 1000,
build=build, label=label)
def installed_release(version: Optional[str],
catalog: Sequence[Entry]) -> Optional[vmr.Release]:
build = parse_build(version)
if not build:
return None
branch = branch_of(build, catalog)
if not branch:
return None
exact = next((e for e in catalog if e.build == build), None)
return _release(branch, build, exact.label if exact else version.strip())
def bound_release(less_than: Optional[str], version_field: Optional[str],
catalog: Sequence[Entry]) -> Optional[vmr.Release]:
"""A record's lessThan (+ its version field) → the fixing release, or None.
The version field is read for the branch ("Current Release (CR)" /
"2402 LTSR"), and for the release only when lessThan names a CU or hotfix
without one ("CU2 Hotfix 1"). A lessThan with neither — CVE-2024-6286
writes "1" — is not a bound and resolves to nothing.
"""
lt = (less_than or "").strip()
if not lt:
return None
build = parse_build(lt)
if build:
branch = branch_of(build, catalog)
return _release(branch, build, lt) if branch else None
n, ctx = parse_name(lt), parse_name(version_field)
release = n.release
if not release and (n.cu is not None or n.hotfix is not None):
release = ctx.release
if not release:
return None
branches = {b for b in (n.branch, ctx.branch) if b}
if len(branches) > 1:
return None
branch = branches.pop() if branches else None
hits = [e for e in catalog
if e.release == release and e.cu == (n.cu or 0)
and e.hotfix == (n.hotfix or 0) and (branch is None or e.branch == branch)]
if len({e.build for e in hits}) != 1:
return None
e = hits[0]
return _release(e.branch, e.build, f"{'.'.join(map(str, e.build))} ({e.label})")
def decide(installed: vmr.Release, entries: Sequence[dict],
catalog: Sequence[Entry]) -> Tuple[Optional[bool], Optional[str]]:
"""All index entries of ONE CVE → (affected, fix hint).
affected is None when any bound of the CVE cannot be resolved: a bound we
could not read may be the one on this host's line.
"""
bounds = []
for ent in entries:
if not ent.get("lt"):
return None, None # lessThanOrEqual only: no fix build named
b = bound_release(ent.get("lt"), ent.get("ver"), catalog)
if b is None:
return None, None
bounds.append(b)
if not bounds:
return None, None
if not vmr.is_affected(installed, bounds):
return False, None
return True, vmr.fix_hint(installed, bounds)
# ----------------------------------------------------------------------
# Seed (Citrix download pages, 2026-09-14) and refresh
# ----------------------------------------------------------------------
SEED_PAGES: List[Tuple[str, str]] = [
('Citrix Workspace app 1912 LTSR for Windows', '19.12.0.119'),
('Citrix Workspace app for Windows LTSR 2203.1', '22.3.1.41'),
('Citrix Workspace app 22.03.5000 for Windows, LTSR 2203.1 Cumulative Update 5', '22.03.5000.5107'),
('Citrix Workspace app 22.03.6002 for Windows, LTSR 2203.1 Cumulative Update 6 Hotfix 2', '22.03.6002.6116'),
('Citrix Workspace app 2209 for Windows', '22.9.0.28'),
('Citrix Workspace app 2210 for Windows', '22.10.0.21'),
('Citrix Workspace app 2210.5 for Windows', '22.10.5.14'),
('Citrix Workspace app 2212 for Windows', '22.12.0.48'),
('Citrix Workspace app 2309 for Windows', '23.9.0.99'),
('Citrix Workspace app 2309.1 for Windows', '23.9.1.104'),
('Citrix Workspace app 2311.1 for Windows', '23.11.1.140'),
('Citrix Workspace app LTSR 2402 for Windows', '24.2.0.172'),
('Citrix Workspace app for Windows LTSR 2402 Cumulative Update 1', '24.2.1000.1016'),
('Citrix Workspace app for Windows LTSR 2402 Cumulative Update 1 Hotfix 1 - 24.02.1001', '24.2.1001.2'),
('Citrix Workspace app for Windows LTSR 2402 Cumulative Update 1 Hotfix 3 - 24.02.1003', '24.2.1003.3'),
('Citrix Workspace app for Windows LTSR 2402 Cumulative Update 2 Hotfix 1', '24.2.2001.3'),
('Citrix Workspace app for Windows LTSR 2402 Cumulative Update 3 Hotfix 1', '24.2.3001.9'),
('Citrix Workspace app for Windows LTSR 2402 Cumulative Update 4 Hotfix 1', '24.2.4001.1'),
('Citrix Workspace app 2403 for Windows', '24.3.0.93'),
('Citrix Workspace app 2403.1 for Windows', '24.3.1.97'),
('Citrix Workspace app 2405 for Windows', '24.5.0.131'),
('Citrix Workspace app 2405.10 for Windows', '24.5.10.29'),
('Citrix Workspace app 2405.11 for Windows', '24.5.11.31'),
('Citrix Workspace app 2405.12 for Windows', '24.5.12.42'),
('Citrix Workspace app 2409 for Windows', '24.9.0.201'),
('Citrix Workspace app 2409.1 for Windows', '24.9.1.207'),
('Citrix Workspace app 2409.10 for Windows', '24.9.10.28'),
('Citrix Workspace app 2503.1 for Windows', '25.3.1.194'),
('Citrix Workspace app 2503.2 for Windows', '25.3.2.196'),
('Citrix Workspace app 2503.10 for Windows', '25.3.10.69'),
('Citrix Workspace app LTSR 2507.1 for Windows', '25.7.1.9'),
('Citrix Workspace app for Windows LTSR 2507.1 Cumulative Update 1', '25.7.1000.1025'),
('Citrix Workspace app for Windows LTSR 2507.1 Cumulative Update 2', '25.7.2000.2020'),
('Citrix Workspace app for Windows LTSR 2507.1 Cumulative Update 3', '25.7.3000.3034'),
('Citrix Workspace app 2508 for Windows', '25.8.0.71'),
('Citrix Workspace app 2508.10 for Windows', '25.8.10.36'),
('Citrix Workspace app 2511 for Windows', '25.11.0.200'),
('Citrix Workspace app 2511.1 for Windows', '25.11.1.209'),
('Citrix Workspace app 2511.10 for Windows', '25.11.10.50'),
('Citrix Workspace app 2603 for Windows', '26.3.0.188'),
('Citrix Workspace app 2603.1 for Windows', '26.3.1.194'),
('Citrix Workspace app 2603.10 for Windows', '26.3.10.69'),
('Citrix Workspace app 2603.11 for Windows', '26.3.11.10'),
('Citrix Workspace app for Windows LTSR 2607', '26.7.0.269'),
]
_H1_RE = re.compile(r"<h1[^>]*>(.*?)</h1>", re.S | re.I)
_PAGE_VERSION_RE = re.compile(r"Version\s*:\s*([\d.]+)")
_TAG_RE = re.compile(r"<[^>]+>")
def parse_page(page_html: str) -> Optional[Tuple[str, str]]:
"""A download page → (title, first "Version:" after the title)."""
m = _H1_RE.search(page_html or "")
if not m:
return None
title = _html.unescape(_TAG_RE.sub("", m.group(1))).strip()
v = _PAGE_VERSION_RE.search(page_html, m.end())
return (title, v.group(1).rstrip(".")) if v else None
def _fetch_pages() -> List[Tuple[str, str]]:
import httpx
rows: List[Tuple[str, str]] = []
with httpx.Client(timeout=30.0, follow_redirects=True,
headers={"User-Agent": "Mozilla/5.0 (TrueVuln)"}) as c:
r = c.get(_DOWNLOADS_URL)
r.raise_for_status()
links = sorted(set(_WINDOWS_PAGE_RE.findall(r.text)))
for path in links:
try:
p = c.get("https://www.citrix.com" + path)
p.raise_for_status()
except Exception as e:
logger.debug("citrix: %s fetch failed: %s", path, e)
continue
row = parse_page(p.text)
if row:
rows.append(row)
valid = len(compile_rows(rows))
if valid < 20:
raise ValueError(f"only {valid} valid build rows from {len(links)} pages — layout changed?")
return rows
def refresh_catalog(db) -> List[Entry]:
"""Re-read the download pages; merged over cache and seed, never replacing
them, so an outage or layout change can only fail to ADD builds."""
from app.models.setting import Setting
row = db.query(Setting).filter(Setting.key == _CATALOG_SETTING).first()
rows = list(SEED_PAGES) + _cached_rows(row)
try:
fetched = _fetch_pages()
rows += fetched
logger.info("citrix: Workspace app build catalog refreshed (%d pages)", len(fetched))
except Exception as e:
logger.warning("citrix: build catalog refresh failed (%s) — keeping cached/seed", e)
rows = sorted({(t, v) for t, v in rows})
payload = json.dumps({"fetched_at": datetime.now().isoformat(), "rows": rows})
if row:
row.value = payload
else:
db.add(Setting(key=_CATALOG_SETTING, value=payload,
description="Citrix Workspace app for Windows: release → build (download pages)"))
db.commit()
_MEMO.clear()
return compile_rows(rows)
def _cached_rows(row) -> List[Tuple[str, str]]:
if not row or not row.value:
return []
try:
return [tuple(r) for r in json.loads(row.value).get("rows") or []]
except Exception:
return []
_MEMO: Dict[str, List[Entry]] = {}
def load_catalog(db) -> List[Entry]:
"""Seed plus whatever the last refresh stored, at any age: an old catalog
only lacks the newest releases, and those get no verdict until it learns
them."""
if "catalog" not in _MEMO:
from app.models.setting import Setting
row = db.query(Setting).filter(Setting.key == _CATALOG_SETTING).first()
_MEMO["catalog"] = compile_rows(list(SEED_PAGES) + _cached_rows(row))
return _MEMO["catalog"]
def build_index(db) -> None:
"""Nightly hook (scheduler): refresh the catalog once it is a week old."""
from app.models.setting import Setting
row = db.query(Setting).filter(Setting.key == _CATALOG_SETTING).first()
try:
fresh = row and datetime.now() - datetime.fromisoformat(
json.loads(row.value)["fetched_at"]) <= _CATALOG_TTL
except Exception:
fresh = False
if not fresh:
refresh_catalog(db)
+89
View File
@@ -357,6 +357,16 @@ _REGISTRY: List[dict] = [
"pairs": [("microsoft", "microsoft teams for desktop"),
("microsoft", "microsoft teams for windows"),
("microsoft", "microsoft teams"), ("microsoft", "teams")]},
# Citrix Workspace app for Windows. The bounds are release NAMES ("2507.1
# LTSR CU3") and the install states only a build, so the key is indexed
# raw and decided by citrix_workspace_service. The umbrella entry only
# ("Citrix Workspace 2507"; Defender: "citrix workspace") — not its
# components "(DV)", "(USB)", "Inside", nor the Chrome app. "Citirx" is
# CVE-2026-78546's own spelling.
{"key": "citrix-workspace-win", "re": r"^citrix workspace(?: app)?(?: \d{4}(?:\.\d{1,2})?)?$",
"pairs": [("citrix", "citrix workspace app for windows"),
("citrix", "workspace app for windows"),
("citirx", "workspace app for windows")]},
]
_COMPILED = [(re.compile(e["re"], re.I), e) for e in _REGISTRY]
@@ -1189,6 +1199,28 @@ def build_product_index(db: Session, force_fresh: bool = False) -> dict:
"desc": desc})
index.setdefault(key, []).append(ent)
continue
if key == "citrix-workspace-win":
# Bounds kept verbatim, version field included: Citrix puts
# the release there when lessThan names only the CU ("2402
# LTSR" / "CU2 Hotfix 1"). See citrix_workspace_service.
for _v in (aff.get("versions") or []):
if not isinstance(_v, dict):
continue
if (_v.get("status") or "affected") != "affected":
continue
_lt = (_v.get("lessThan") or "").strip()
_lte = (_v.get("lessThanOrEqual") or "").strip()
if not (_lt or _lte):
continue
_ver = (_v.get("version") or "").strip()
_sig = (key, cve_id, _lt, _lte, _ver)
if _sig in seen:
continue
seen.add(_sig)
index.setdefault(key, []).append(
{"cve": cve_id, "lt": _lt, "lte": _lte, "ver": _ver,
"cvss": cvss, "sev": sev, "prod": prod, "desc": desc})
continue
if key in _VMWARE_KEYS:
for ent in vmware_entries(aff, cve_id, cvss, sev, desc):
_sig = (key, cve_id, ent["lt"], prod.lower())
@@ -2765,6 +2797,57 @@ def _wazuh_component_ok(key: str, installed_name: str, prod: Optional[str]) -> b
return gh.component_matches(installed_name, gh.component_of(prod or ""))
def _scan_citrix_workspace(db: Session, asset, fam, name: str, version: str,
pkg: dict, entries: list, new_ids: list,
touched: Optional[set]) -> int:
"""Citrix Workspace app for Windows — see citrix_workspace_service.
Windows hosts only: the Mac app is also "Citrix Workspace" and its 25.07.x
numbering would land on the Windows LTSR 2507 line. A CVE this pass cannot
decide goes into `touched`, so the reconcile keeps an existing finding open
instead of closing it on no evidence.
"""
if fam != "windows":
return 0
from app.services import citrix_workspace_service as cws
catalog = cws.load_catalog(db)
installed = cws.installed_release(version, catalog)
per_cve: Dict[str, list] = {}
for e in entries:
per_cve.setdefault(e["cve"], []).append(e)
if not installed:
logger.info("citrix scan: %s build %r is on no line the catalog knows — "
"no verdict", asset.hostname, version)
if touched is not None:
touched.update(per_cve)
return 0
count = 0
undecided = 0
for cve_id, ents in per_cve.items():
affected, fix = cws.decide(installed, ents, catalog)
if affected is None:
undecided += 1
if touched is not None:
touched.add(cve_id)
continue
if not affected:
continue
c = {"cve": cve_id, "cvss": ents[0].get("cvss"), "severity": ents[0].get("sev"),
"fixed": fix, "desc": ents[0].get("desc")}
try:
before = len(new_ids)
cpe._upsert(db, asset, name, f"{version} ({installed.label})"
if installed.label != version else version,
c, new_ids, touched=touched, vendor=(pkg.get("vendor") or None))
count += 1 if len(new_ids) > before else 0
except Exception as e:
logger.debug("citrix upsert failed (%s on %s): %s", cve_id, asset.id, e)
if undecided:
logger.info("citrix scan (%s): %d CVE(s) with a bound not resolvable to a build — "
"held, not decided", asset.hostname, undecided)
return count
def scan_asset(db: Session, asset, packages: list, index: dict,
new_ids: Optional[list] = None, touched: Optional[set] = None) -> int:
"""Match an asset's installed software against the cvelistV5 index.
@@ -2786,6 +2869,12 @@ def scan_asset(db: Session, asset, packages: list, index: dict,
key = resolve(name)
if not key or key not in index:
continue
if key == "citrix-workspace-win":
if (key, version) not in seen:
seen.add((key, version))
count += _scan_citrix_workspace(db, asset, fam, name, version, pkg,
index[key], new_ids, touched)
continue
# name_ver products (modern .NET): the semantic version lives in the
# display NAME; the version field is an MSI build that never matches.
if key == "oracle-java":
+204
View File
@@ -0,0 +1,204 @@
"""Citrix Workspace app for Windows — run: python tests/test_citrix_workspace.py
CVE-2026-78546/-78547 (CTX697034) state their fixes as release names only —
"2603.11 Current Release (CR)", "2507.1 LTSR CU3", "LTSR 2607" — while the
Windows inventory states only a build ("Citrix Workspace 2507", 25.7.1000.1025).
Two things were wrong at once:
* the inventory filter dropped every package whose vendor contains "citrix",
so "Citrix Systems, Inc." never reached a scanner — no finding, ever;
* read as digits, "LTSR 2607" is (2607,), and every 25.x/26.x build sits
below it — the fully patched CU3, CR 2603.11 and LTSR 2607 hosts included.
Records below are the real cvelistV5 `affected` blocks, trimmed to what the
index reads.
"""
import io
import json
import os
import sys
import tempfile
import types
import zipfile
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import create_engine # noqa: E402
from sqlalchemy.orm import sessionmaker # noqa: E402
from app.models.base import Base # noqa: E402
import app.models.user, app.models.group # noqa: E402,F401
import app.models.audit_log, app.models.setting # noqa: E402,F401
from app.models.asset import Asset, AssetStatus # noqa: E402
from app.models.vulnerability import Vulnerability, VulnerabilityStatus # noqa: E402
from app.services import app_cve_scanner_service as A # noqa: E402
from app.services import citrix_workspace_service as C # noqa: E402
from app.services import cvelistv5_scan_service as C5 # noqa: E402
_2026 = [
{"status": "affected", "version": "0", "lessThan": "2603.11 Current Release (CR)"},
{"status": "affected", "version": "0", "lessThan": "2507.1 LTSR CU3"},
{"status": "affected", "version": "0", "lessThan": "LTSR 2607"},
]
RECORDS = {
"CVE-2026-78546": [{"vendor": "Citirx", "product": "Workspace app for Windows",
"defaultStatus": "unaffected", "versions": _2026}],
"CVE-2026-78547": [{"vendor": "Citrix", "product": "Citrix Workspace app for Windows",
"defaultStatus": "unaffected", "versions": _2026}],
# One LTSR line, two fixes: CU2 Hotfix 1 AND CU3 Hotfix 1.
"CVE-2025-4879": [{"vendor": "Citrix", "product": "Workspace App for Windows",
"defaultStatus": "unaffected", "versions": [
{"lessThan": "2409", "status": "affected", "version": "CR"},
{"lessThan": "CU2 Hotfix 1", "status": "affected", "version": "2402 LTSR"},
{"lessThan": "CU3 Hotfix 1", "status": "affected", "version": "2402 LTSR"},
]}],
# lessThan "1" is not a bound: undecidable, never guessed.
"CVE-2024-6286": [{"vendor": "Citrix", "product": "Citrix Workspace app for Windows",
"defaultStatus": "unaffected", "versions": [
{"lessThan": "1", "status": "affected", "version": "2403"},
{"lessThan": "0", "status": "affected", "version": "2402 LTSR"},
]}],
}
def _zip() -> str:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as z:
for cve, affected in RECORDS.items():
rec = {"cveMetadata": {"cveId": cve},
"containers": {"cna": {"affected": affected, "references": [
{"url": "https://support.citrix.com/external/article/CTX697034"}]}}}
z.writestr(f"cvelistV5-main/cves/x/{cve}.json", json.dumps(rec))
path = os.path.join(tempfile.mkdtemp(), "cvelist.zip")
open(path, "wb").write(buf.getvalue())
return path
def _decisions():
cat = C.compile_rows(C.SEED_PAGES)
assert len(cat) == len(C.SEED_PAGES), "a seed row disagrees with its own title"
def verdict(build, cve):
entries = [{"cve": cve, "lt": v.get("lessThan"), "ver": v.get("version")}
for v in RECORDS[cve][0]["versions"]]
inst = C.installed_release(build, cat)
return None if inst is None else C.decide(inst, entries, cat)[0]
cve = "CVE-2026-78547"
assert verdict("25.7.1000.1025", cve) is True # LTSR 2507.1 CU1 — the field case
assert verdict("25.7.1.9", cve) is True # LTSR 2507.1 base
assert verdict("25.7.3000.3034", cve) is False # CU3: the fix
assert verdict("25.7.3001.1", cve) is False # a later CU3 hotfix
assert verdict("26.7.0.269", cve) is False # LTSR 2607
assert verdict("26.3.10.69", cve) is True # CR 2603.10
assert verdict("26.3.11.10", cve) is False # CR 2603.11
assert verdict("24.2.4001.1", cve) is False # LTSR 2402: no fix named for its line
assert verdict("27.1.0.5", cve) is None # a line no download page names
cve = "CVE-2025-4879"
assert verdict("24.2.1000.1016", cve) is True # CU1
assert verdict("24.2.2000.9", cve) is True # CU2 base
assert verdict("24.2.2001.3", cve) is False # CU2 Hotfix 1
assert verdict("24.2.3000.9", cve) is True # CU3 base: higher build, still open
assert verdict("24.2.3001.9", cve) is False # CU3 Hotfix 1
assert verdict("24.2.4001.1", cve) is False # CU4
assert verdict("24.5.10.29", cve) is True # CR 2405.10 < 2409
assert verdict("24.9.0.201", cve) is False # CR 2409
assert verdict("24.3.0.93", "CVE-2024-6286") is None
# A sidebar build must not become a catalog row (CR 2307.1 page).
assert C.entry_from_page("Citrix Workspace app 2307.1 for Windows", "22.12.0.48") is None
page = ('<h1 class="t">Citrix Workspace app for Windows LTSR 2507.1 Cumulative Update 3</h1>'
'<p>Version: 25.7.3000.3034 (2507.3000)</p><p>Version: 22.12.0.48(2212)</p>')
assert C.entry_from_page(*C.parse_page(page)).label == "LTSR 2507.1 CU3"
assert C5.resolve("Citrix Workspace 2507") == C.KEY
assert C5.resolve("citrix workspace") == C.KEY
for other in ("Citrix Workspace(DV)", "Citrix Workspace Inside",
"Citrix Workspace app for Google Chrome"):
assert C5.resolve(other) is None, other
def _scan():
# Patched for this run only — pytest collects every test into one process.
fake = types.ModuleType("app.routers.vulnerabilities")
fake.log_vulnerability_change = lambda *a, **k: None
saved = (sys.modules.get("app.routers.vulnerabilities"),
C5._ZIP_PATH, C5._ensure_zip, C5._merge_mozilla_mfsa)
sys.modules["app.routers.vulnerabilities"] = fake
try:
_scan_patched()
finally:
router, C5._ZIP_PATH, C5._ensure_zip, C5._merge_mozilla_mfsa = saved
if router is None:
sys.modules.pop("app.routers.vulnerabilities", None)
else:
sys.modules["app.routers.vulnerabilities"] = router
C._MEMO.clear()
def _scan_patched():
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
db = sessionmaker(bind=engine)()
C._MEMO.clear()
C5._ZIP_PATH = _zip()
C5._ensure_zip = lambda force=False: True
C5._merge_mozilla_mfsa = lambda *a, **k: None
index = C5.build_product_index(db)
assert len(index.get(C.KEY) or []) == 11, index.get(C.KEY)
def host(name, os_name, build):
asset = Asset(hostname=name, ip_address="10.0.0.1", operating_system=os_name,
status=AssetStatus.ACTIVE)
db.add(asset)
db.commit()
inv = A.filter_inventory([{"name": "Citrix Workspace 2507", "version": build,
"vendor": "Citrix Systems, Inc."}])
touched = set()
C5.scan_asset(db, asset, inv, index, [], touched=touched)
db.commit()
return asset, touched
def open_cves(asset):
return sorted(v.cve_id for v in db.query(Vulnerability).filter(
Vulnerability.asset_id == asset.id,
Vulnerability.status == VulnerabilityStatus.open))
cu1, touched = host("ws-cu1", "Windows 11 Enterprise", "25.7.1000.1025")
assert open_cves(cu1) == ["CVE-2026-78546", "CVE-2026-78547"], open_cves(cu1)
assert "CVE-2024-6286" in touched # undecided → held, not closed
row = db.query(Vulnerability).filter(Vulnerability.asset_id == cu1.id).first()
assert row.fixed_version.startswith("25.7.3000.3034"), row.fixed_version
for name, build in (("ws-cu3", "25.7.3000.3034"), ("ws-2607", "26.7.0.269"),
("ws-cr", "26.3.11.10")):
asset, _ = host(name, "Windows 11 Enterprise", build)
assert open_cves(asset) == [], (name, open_cves(asset))
mac, _ = host("mac", "macOS 15.6", "25.7.1000.1025")
assert open_cves(mac) == []
# The CU1 host updates to CU3: the next scan closes both findings.
inv = [{"name": "Citrix Workspace 2507", "version": "25.7.3000.3034",
"vendor": "Citrix Systems, Inc."}]
touched = set()
C5.scan_asset(db, cu1, inv, index, [], touched=touched)
A._resolve_stale_app_findings(db, cu1, touched)
db.commit()
assert open_cves(cu1) == [], open_cves(cu1)
def demo():
_decisions()
_scan()
print("ok Citrix Workspace: CR/LTSR/CU decided by catalog build, no guessing")
def test_citrix_workspace():
demo()
if __name__ == "__main__":
demo()
+6 -7
View File
@@ -21,16 +21,15 @@ def demo():
{"name": "SAP Business Client 7.0", "version": "7.0 PL21", "vendor": "SAP SE"},
{"name": "Mozilla Firefox", "version": "141.0", "vendor": "Mozilla"},
{"name": "Some Tool", "version": "2.1"}, # no vendor at all
# Real Citrix software IS installed and must still be scanned — the
# stub is identified by the DELIVERY vendor, not the word "citrix".
{"name": "Citrix Workspace 2507", "version": "25.7.1000.1025",
"vendor": "Citrix Systems, Inc."},
]
kept = filter_inventory(inv)
assert [p["name"] for p in kept] == [
"SAP Business Client 7.0", "Mozilla Firefox", "Some Tool"]
# Real Citrix software IS installed and must still be scanned — the stub is
# identified by the DELIVERY vendor, so this one is a known limitation:
# a genuine Citrix product from vendor "Citrix Systems" is dropped too.
# ponytail: vendor-word match; if a real Citrix CVE is ever missed, match
# the "delivered by" phrasing instead of the bare vendor word.
"SAP Business Client 7.0", "Mozilla Firefox", "Some Tool",
"Citrix Workspace 2507"]
assert filter_inventory([]) == []
assert filter_inventory(None) == []