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.
231 lines
10 KiB
Python
231 lines
10 KiB
Python
"""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} </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. 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. 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"]}
|