Files
vulncheck/tests/test_xencenter_bulletins.py
T
vulncheck 1f6199663f 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.
2026-10-23 13:31:09 +02:00

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}&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"]}