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:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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":
|
||||
|
||||
@@ -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()
|
||||
@@ -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) == []
|
||||
|
||||
Reference in New Issue
Block a user