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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(?:'|'|’|’)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
|
||||
@@ -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} </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"]}
|
||||
Reference in New Issue
Block a user