feat(app-scan): XenCenter CVEs from Citrix bulletins

CVE-2026-42491 affects XenCenter, and neither NVD nor cvelistV5 knew the CVE
at all. Only Citrix's bulletin CTX696811 states it: "XenCenter versions
before 2026.4.0".

The bulletin list on support.citrix.com is a JavaScript app, but XenCenter's
static "What's new" page on docs.xenserver.com links every security release
to its bulletin, and the bulletin is served as HTML under
/external/article/<CTX>. A bulletin counts only if its bound is the release
the notes link it from and it names exactly one CVE: XenServer bulletins also
bundle host CVEs, and which of several is XenCenter's is prose.

The install reads "XenCenter 2026.4.1" with MSI version 26.4.1, read as
2026.4.1. A name and version naming different releases, and the Citrix
Hypervisor console (8.2.x), get no verdict: open findings are held, none is
opened. Windows hosts only. Merged into the cvelistV5 index under
"xencenter" on the nightly build, like Zoom's bulletins; findings are
app-scan findings and close when the host updates. A bulletin that is not
freshly parsed keeps its last parse.
This commit is contained in:
2026-10-23 13:31:09 +02:00
parent 5e09201b5a
commit 1f6199663f
4 changed files with 452 additions and 0 deletions
+18
View File
@@ -1612,6 +1612,24 @@ build the table does not know. 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.
**XenCenter** (key `xencenter`, no pairs, no NVD row). Its CVEs exist only in
Citrix's bulletins: CVE-2026-42491 was unknown to NVD and cvelistV5 on
2026-10-23, and CTX696811 says "XenCenter versions before 2026.4.0". The
bulletin list on support.citrix.com is a JavaScript app, so the list is
XenCenter's static "What's new" page on docs.xenserver.com, which links each
security release to its bulletin (`xencenter_bulletin_service`, merged by
`_merge_xencenter_bulletins` on the nightly build). The bulletin is read from
`/external/article/<CTX>`; support.citrix.com answers 403 to a User-Agent
without "Mozilla". A bulletin counts only if its bound is the release the
notes link it from and it names exactly one CVE, since XenServer bulletins
also bundle host CVEs. The install reads "XenCenter 2026.4.1" / 26.4.1. The
MSI version drops the century. A name and version that name different
releases, or the Citrix Hypervisor console (8.2.x, not bounded by these
bulletins), give no verdict: open findings are held, none is opened. Windows
hosts only. The SDK bound in the same bulletins is not read, since the SDK is
not an installed program. A bulletin that is not freshly parsed keeps its last
parse.
**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
+33
View File
@@ -518,6 +518,10 @@ _REGISTRY: List[dict] = [
("citrix", "workspace app for windows"),
("citirx", "citrix workspace app for windows"),
("citirx", "workspace app for windows")]},
# XenCenter, the XenServer console. No record or NVD entry states its
# CVEs (CVE-2026-42491 had neither); they come from Citrix's bulletins
# only, see _merge_xencenter_bulletins. Windows hosts only.
{"key": "xencenter", "re": r"^xencenter(?: 20\d{2}\.\d+\.\d+)?$", "pairs": []},
]
_COMPILED = [(re.compile(e["re"], re.I), e) for e in _REGISTRY]
@@ -1696,6 +1700,7 @@ def build_product_index(db: Session, force_fresh: bool = False) -> dict:
_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)
_merge_xencenter_bulletins(db, index, force_fresh=force_fresh)
_ARUBA_UNMATCHED.clear()
_ARUBA_UNMATCHED.update({p: sorted(c) for p, c in aruba_unmatched.items()})
note = aruba_unmatched_note()
@@ -1832,6 +1837,21 @@ def _merge_zoom_bulletins(db: Session, index: Dict[str, list],
len({e["cve"] for e in rows}))
def _merge_xencenter_bulletins(db: Session, index: Dict[str, list],
force_fresh: bool = False) -> None:
"""XenCenter's CVEs exist only in Citrix's bulletins (see
xencenter_bulletin_service). Fresh on the nightly build, like Zoom's."""
try:
from app.services import xencenter_bulletin_service as xcb
bulletins = (not force_fresh and xcb.load(db)) or xcb.build_index(db)
except Exception as e:
logger.warning("cvelistv5-scan: XenCenter bulletin merge skipped (%s)", e)
return
index["xencenter"] = [e for es in bulletins.values() for e in es]
logger.info("cvelistv5-scan: XenCenter bulletins decided %d CVEs",
len({e["cve"] for e in index["xencenter"]}))
def _store_index(db: Session, index: dict) -> None:
from app.models.setting import Setting
payload = json.dumps({"built_at": datetime.now().isoformat(), "index": index})
@@ -4185,6 +4205,16 @@ def scan_asset(db: Session, asset, packages: list, index: dict,
continue
if key.startswith("zoom") and fam == "windows":
version = cpe.zoom_release(db, version, key == "zoom-vdi")
if key == "xencenter":
if fam != "windows":
continue
from app.services.xencenter_bulletin_service import xencenter_version
version = xencenter_version(name, version) # 26.4.1 → 2026.4.1
if not version:
# no verdict: keep what is open rather than close it as patched
if touched is not None:
touched.update(e["cve"] for e in index[key])
continue
eff_ver = cpe._effective_version(name, version, _KEY_ENTRY.get(key) or {})
cver = cpe._clean_version(eff_ver)
if not cver:
@@ -4251,6 +4281,9 @@ def scan_asset(db: Session, asset, packages: list, index: dict,
# like TeamViewer's and IGEL's.
from app.services.zoom_bulletin_service import BASE as _ZSB
c["refs"] = json.dumps([f"{_ZSB}{entry['zsb']}/"])
elif entry.get("ctx"):
from app.services.xencenter_bulletin_service import BULLETIN as _CTX_URL
c["refs"] = json.dumps([_CTX_URL + entry["ctx"]])
try:
before = len(new_ids)
cpe._upsert(db, asset, name, eff_ver, c, new_ids, touched=touched,
+171
View File
@@ -0,0 +1,171 @@
"""
XenCenter CVEs from XenServer's own release notes and Citrix bulletins.
XenCenter (the Windows management console for XenServer) gets CVEs that no
CVE feed carries: CVE-2026-42491 was unknown to NVD and cvelistV5 alike on
23.10.2026, and only Citrix's bulletin CTX696811 states it:
This issue affects XenCenter versions before 2026.4.0 and, for PowerShell
and C# clients only, SDK versions before 26.15.0.
The bulletin list on support.citrix.com is a JavaScript app. XenCenter's
"What's new" page is static and links every security release to its bulletin
("What's new in 2026.4.0 … This update includes security fixes … see the
Security Bulletin"), so it is the list; the bulletin page itself is served as
HTML under /external/article/<CTX>. A bulletin counts only when its "XenCenter
versions before X" is the release the notes link it from, and when it names
one CVE: XenServer bulletins also bundle host CVEs, and which of several is
XenCenter's is prose.
The install reads "XenCenter 2026.4.1" with version 26.4.1 (the MSI field
cannot hold 2026). Only that year-numbered line is read; the Citrix Hypervisor
console (8.2.x) is another line the bulletins do not bound. The SDK bound in
the same bulletins ("SDK versions before 26.15.0") is not read: the
PowerShell module and C# library are not installed programs an inventory lists.
cvelistv5_scan_service merges this into the index under "xencenter"
(_merge_xencenter_bulletins); findings are app-scan findings and close when
the host updates.
"""
import html
import json
import logging
import re
from typing import Dict, List, Optional, Tuple
from sqlalchemy.orm import Session
from app.models.setting import Setting
logger = logging.getLogger(__name__)
INDEX_SETTING = "xencenter_bulletin_index"
RELEASE_NOTES = "https://docs.xenserver.com/en-us/xencenter/current-release/whats-new.html"
BULLETIN = "https://support.citrix.com/external/article/"
# support.citrix.com answers 403 to a User-Agent without "Mozilla".
_HEADERS = {"User-Agent": "Mozilla/5.0 (TrueVuln)"}
# The newest release is an <h2>, every older one an <h3>.
_SECTION = re.compile(r"What(?:'|&#39;|&rsquo;|’)s new in (\d{4}\.\d+\.\d+)</h[23]>")
_CTX = re.compile(r"articleNumber=(CTX\d+)|/article/(CTX\d+)", re.I)
_BOUND = re.compile(r"\bXenCenter versions? (?:before|prior to|earlier than) (\d{4}\.\d+\.\d+)",
re.I)
_CVES = re.compile(r"following identifiers?:(.*?)Affected Versions", re.I | re.S)
_SEV = re.compile(r"Severity:\s*(critical|high|medium|low)\b", re.I)
_DESC = re.compile(r"Description of Problem\s*(.*?)\s*This issue has the following", re.I | re.S)
def _text(fragment: str) -> str:
return re.sub(r"\s+", " ", html.unescape(re.sub(r"<[^>]+>", " ", fragment or ""))).strip()
def parse_release_notes(page: str) -> List[Tuple[str, str]]:
""""What's new" → [(release, CTX id)] for each release that links a
security bulletin."""
heads = list(_SECTION.finditer(page or ""))
out = []
for i, m in enumerate(heads):
body = page[m.end():heads[i + 1].start() if i + 1 < len(heads) else len(page)]
if not re.search(r"security bulletin", _text(body), re.I):
continue
for ctx in dict.fromkeys(a or b for a, b in _CTX.findall(body)):
out.append((m.group(1), ctx.upper()))
return out
def parse_bulletin(ctx: str, page: str, fixed: str) -> List[dict]:
"""One bulletin page → cvelistV5 index entries, or [] when the page does
not bound XenCenter at the release the notes name as its fix."""
t = _text(page)
bounds = set(_BOUND.findall(t))
m = _CVES.search(t)
cves = sorted(set(re.findall(r"CVE-\d{4}-\d+", m.group(1) if m else "", re.I)))
if bounds != {fixed} or len(cves) != 1:
# ponytail: one CVE per bulletin; a multi-CVE XenCenter bulletin is a
# miss, logged. Read its per-CVE sections if Citrix publishes one.
logger.warning("xencenter-bulletins: %s names bounds %s and CVEs %s, the notes "
"fix %s — not read", ctx, sorted(bounds), cves, fixed)
return []
sev = _SEV.search(t)
d = _DESC.search(t)
desc = (d.group(1) + " " if d else "") + f"Source: Citrix Security Bulletin {ctx}."
return [{"cve": cves[0].upper(), "start": None, "lt": fixed, "lte": None, "plats": [],
"cvss": None, "sev": sev.group(1).lower() if sev else None,
"prod": "XenCenter", "desc": desc[:2000], "ctx": ctx}]
def build_index(db: Session) -> Dict[str, List[dict]]:
"""Fetch the release notes and every bulletin they link → {CTX: entries},
stored. Whatever is not freshly parsed keeps its last parse: a bulletin
dropping out would close its findings as "patched"."""
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=_HEADERS) as client:
r = client.get(RELEASE_NOTES)
r.raise_for_status()
for fixed, ctx in parse_release_notes(r.text):
p = client.get(BULLETIN + ctx)
if p.status_code != 200:
logger.warning("xencenter-bulletins: %s answered %s — keeping its last "
"parse", ctx, p.status_code)
continue
entries = parse_bulletin(ctx, p.text, fixed)
if entries:
idx[ctx] = entries
fresh += 1
except Exception as e:
logger.warning("xencenter-bulletins: unreachable (%s) — using the last parse", e)
return last
if not fresh:
logger.warning("xencenter-bulletins: no bulletin parsed — layout changed? "
"Using the last parse")
return last
try:
_store(db, idx)
except Exception as e:
logger.warning("xencenter-bulletins: storing the parse failed (%s)", e)
logger.info("xencenter-bulletins: %d bulletins (%d fetched now)", len(idx), fresh)
return idx
def _store(db: Session, idx: 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:
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 {}
_NAME_REL = re.compile(r"\b(20\d{2}\.\d+\.\d+)\b")
_VER_REL = re.compile(r"^(\d{2}|20\d{2})\.(\d+)\.(\d+)(?:\.\d+)?$")
def xencenter_version(name: str, version: str) -> Optional[str]:
"""The release as the bulletins write it ("2026.4.1"), or None. The MSI
version 26.4.1 is that release with the century dropped; a name and a
version that name different releases give no verdict."""
m = _VER_REL.match((version or "").strip())
if not m:
return None
year = int(m.group(1))
if year < 2000:
if year < 23:
return None # 8.2.x: the Citrix Hypervisor console
year += 2000
rel = f"{year}.{int(m.group(2))}.{int(m.group(3))}"
n = _NAME_REL.search(name or "")
return rel if not n or n.group(1) == rel else None
+230
View File
@@ -0,0 +1,230 @@
"""XenCenter CVEs from XenServer's release notes and Citrix bulletins — run: python -m pytest tests/test_xencenter_bulletins.py
Field report (23.10.2026): CVE-2026-42491 is in XenCenter, and neither NVD
nor cvelistV5 knows the CVE at all. Only Citrix's bulletin CTX696811 states it:
This issue affects XenCenter versions before 2026.4.0 and, for
PowerShell and C# clients only, SDK versions before 26.15.0.
The bulletin list on support.citrix.com is a JavaScript app, but XenCenter's
own "What's new" page links every security release to its bulletin:
What's new in 2026.4.0 — This update includes security fixes. For more
information, see the Security Bulletin.
The Windows inventory reads "XenCenter 2026.4.1" / version 26.4.1.
Markup below is verbatim from docs.xenserver.com and support.citrix.com
(fetched 23.10.2026), trimmed to the parts the parser reads.
"""
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 xencenter_bulletin_service as xcb
def _section(version, body, h="h3"):
"""The newest release is an <h2> on the live page, every older one <h3>."""
slug = version.replace(".", "")
return (f'<div class="text section"><div class="cmp-text">'
f'<div id="whats-new-in-{slug}" class="topic">\n'
f'<{h} class="title" id="what\'s-new-in-{version}">What\'s new in {version}</{h}>'
f'{body}</div></div></div>\n')
NOTES = (
_section("2026.4.1", '<div class="body"><div class="p">This update contains the following '
'enhancement:</div></div>')
+ _section("2026.4.0", '<span class="body tcx-empty-element"></span>'
'<div id="fixed-issues-2" class="topic"><h4 class="title">Fixed Issues</h4>'
'<div class="body"><ul class="ul"><li class="li"><div class="p">\n'
'This update includes security fixes. For more information, see the '
'<a class="xref" data-has-usertext="true" href="https://support.citrix.com/'
'support-home/kbsearch/article?articleNumber=CTX696811" target="_blank">'
'Security Bulletin</a>.\n</div></li></ul></div></div>')
+ _section("2025.3.0", '<div class="body"><ul class="ul"><li class="li"><div class="p">'
'Incorrect help link on the New Security Certificate dialog. (CA-404836)'
'</div></li></ul></div>'))
def _p(text):
return (f'<p><span style="font-size: 11pt; font-family: arial, helvetica, sans-serif; '
f'color: #000000; font-weight: 400;">{text}</span></p>\n')
def _bulletin(cves, affected):
lis = "".join(f'<li aria-level="1"><span style="font-size: 11pt;">{c}&nbsp;</span></li>'
for c in cves)
return (
'<h4 class="wolken-h4">Issue/Introduction</h4>\n'
'<div class="article-detail-card-content wolken-h5">\n'
'<p><span style="font-family: arial, helvetica, sans-serif;"><strong><span style='
'"font-size: 11pt; color: #000000;">Severity:</span></strong><span style="font-size: '
'11pt; color: #000000; font-weight: 400;"> Medium</span></span></p>\n'
'<p><span><strong><span>Description of Problem</span></strong></span></p>\n'
+ _p("An issue has been identified in the XenServer management API SDK that may allow "
"an attacker on the management network who is able to intercept specific HTTPS "
"requests within a higher-level operation to be able to act with the privileges "
"of the intercepted administrator.&nbsp; This issue affects the XenCenter "
"management agent together with third-party PowerShell and C# SDK clients.")
+ _p("This issue has the following identifier:")
+ f"<ul>\n{lis}\n</ul>\n"
'<p><span><strong><span style="font-size: 11pt;">Affected Versions</span></strong>'
'</span></p>\n'
+ _p(affected) + "</div>")
CTX696811 = _bulletin(
["CVE-2026-42491"],
"This issue affects XenCenter versions before 2026.4.0 and, for PowerShell and C# "
"clients only, SDK versions before 26.15.0.&nbsp; As the issue is in the client-side "
"(XenCenter and SDK) code, it is independent of the version of XenServer that is in use.")
# ---- 1. parse -----------------------------------------------------------------
def test_release_notes_name_the_security_releases():
assert xcb.parse_release_notes(NOTES) == [("2026.4.0", "CTX696811")]
def test_newest_release_is_read_too():
top = _section("2026.5.0", '<div class="p">This update includes security fixes. For more '
'information, see the <a href="https://support.citrix.com/support-home/'
'kbsearch/article?articleNumber=CTX699999">Security Bulletin</a>.</div>',
h="h2")
assert xcb.parse_release_notes(top + NOTES) == [("2026.5.0", "CTX699999"),
("2026.4.0", "CTX696811")]
def test_bulletin_page():
[e] = xcb.parse_bulletin("CTX696811", CTX696811, "2026.4.0")
assert (e["cve"], e["start"], e["lt"], e["sev"], e["ctx"]) == \
("CVE-2026-42491", None, "2026.4.0", "medium", "CTX696811")
assert e["desc"].startswith("An issue has been identified in the XenServer management API")
assert e["desc"].endswith("Source: Citrix Security Bulletin CTX696811.")
def test_bulletin_that_disagrees_with_the_release_notes_decides_nothing():
"""The notes say which release fixed it; a bulletin bound that is another
release is a different bulletin or a misread one."""
assert xcb.parse_bulletin("CTX696811", CTX696811, "2026.3.0") == []
def test_bulletin_with_several_cves_decides_nothing():
"""XenServer bulletins mix host and XenCenter CVEs ("CVE-2026-79610, ...,
CVE-2026-102700 and CVE-2026-102701"); which of them is XenCenter's is not
machine-readable, so none is guessed."""
page = _bulletin(["CVE-2099-0001", "CVE-2099-0002"],
"This issue affects XenCenter versions before 2026.4.0.")
assert xcb.parse_bulletin("CTX699999", page, "2026.4.0") == []
def test_installed_version():
v = xcb.xencenter_version
assert v("XenCenter 2026.4.1", "26.4.1") == "2026.4.1"
assert v("XenCenter", "26.4.1") == "2026.4.1" # name without the release
assert v("XenCenter 2026.4.1", "26.4.1.12") == "2026.4.1"
assert v("XenCenter", "2026.3.0") == "2026.3.0"
assert v("XenCenter 2026.4.1", "26.3.0") is None # name and build disagree
assert v("Citrix XenCenter", "8.2.7") is None # Citrix Hypervisor line
assert v("XenCenter", "") is None
# ---- 2. scan ------------------------------------------------------------------
class _Asset:
id = 1
hostname = "ws-test"
operating_system = "Microsoft Windows 11 Pro"
def _scan(monkeypatch, packages, os_name="Microsoft Windows 11 Pro"):
monkeypatch.setattr(xcb, "load", lambda db: {
"CTX696811": xcb.parse_bulletin("CTX696811", CTX696811, "2026.4.0")})
index = {}
c5._merge_xencenter_bulletins(None, index)
got = []
monkeypatch.setattr(cpe, "_upsert", lambda db, asset, n, v, c, new_ids, **kw:
got.append((n, v, c["cve"], c["fixed"], c.get("refs"))))
monkeypatch.setattr(_Asset, "operating_system", os_name)
touched = set()
c5.scan_asset(None, _Asset(), packages, index, touched=touched)
_scan.touched = touched
return got
def test_field_report(monkeypatch):
got = _scan(monkeypatch, [{"name": "XenCenter 2026.3.0", "version": "26.3.0",
"vendor": "Cloud Software Group, Inc."}])
assert got == [("XenCenter 2026.3.0", "2026.3.0", "CVE-2026-42491", "2026.4.0",
'["https://support.citrix.com/external/article/CTX696811"]')], got
# the screenshot's install, and the fix itself
for name, ver in (("XenCenter 2026.4.1", "26.4.1"), ("XenCenter 2026.4.0", "26.4.0")):
assert _scan(monkeypatch, [{"name": name, "version": ver}]) == [], ver
def test_no_verdict_without_a_readable_release(monkeypatch):
"""No finding opens, and an open one is held rather than closed as patched."""
for name, ver in (("XenCenter", "8.2.7"), ("XenCenter 2026.4.1", "26.3.0")):
assert _scan(monkeypatch, [{"name": name, "version": ver}]) == []
assert _scan.touched == {"CVE-2026-42491"}, ver
_scan(monkeypatch, [{"name": "XenCenter 2026.4.1", "version": "26.4.1"}])
assert _scan.touched == set() # a verdict of "patched" lets reconcile close it
def test_windows_only(monkeypatch):
assert _scan(monkeypatch, [{"name": "XenCenter", "version": "26.3.0"}],
os_name="Ubuntu 24.04") == []
# ---- 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 == xcb.RELEASE_NOTES:
return pages["notes"]
return pages.get(url.rsplit("/", 1)[1]) or _Resp("", 403)
return Client
def test_build_index(monkeypatch):
import httpx
stored = {}
monkeypatch.setattr(xcb, "_store", lambda db, idx: stored.update(idx))
monkeypatch.setattr(xcb, "load", lambda db: {"CTX600000": ["kept"]})
monkeypatch.setattr(httpx, "Client", _client(
{"notes": _Resp(NOTES), "CTX696811": _Resp(CTX696811)}))
idx = xcb.build_index(None)
assert sorted(idx) == ["CTX600000", "CTX696811"], idx
assert idx["CTX696811"][0]["cve"] == "CVE-2026-42491"
assert stored == idx
def test_unreachable_serves_last_parse(monkeypatch):
"""A bulletin that drops out would close its findings as "patched"."""
import httpx
monkeypatch.setattr(xcb, "load", lambda db: {"CTX696811": ["last"]})
monkeypatch.setattr(httpx, "Client", _client({"notes": _Resp("", 503)}))
assert xcb.build_index(None) == {"CTX696811": ["last"]}
monkeypatch.setattr(httpx, "Client", _client({"notes": _Resp(NOTES)})) # bulletin 403
assert xcb.build_index(None) == {"CTX696811": ["last"]}