Files
vulncheck/tests/test_eol_citrix_stub.py
T
vulncheck f14eb1b5b2 fix(eol): a Citrix stub keeps the release in its name
Intune lists published apps as "Microsoft Access 2010 / 1.0 / Delivered by
Citrix". The CVE scanner drops those rows because the version is a
placeholder, and the EOL sweep reused that same filter — so two products
years past end-of-support produced no finding at all.

The version is the untrustworthy half, not the name. A stub is now kept and
restricted to the name-only sources (the Microsoft lifecycle export and the
hardcoded exotics); the endoflife.date path, which derives the release from
the installed version, is skipped for it. A stub named just "Firefox" still
matches nothing, which is what the filter was written for.
2026-08-15 08:45:00 +02:00

115 lines
4.7 KiB
Python

"""A Citrix published-app stub still carries a real product NAME.
The CVE scanner drops the stub outright, and rightly: its version is a
placeholder ("Firefox 1.0"), so version-based matching reports decade-old CVEs
on software that is not installed. The EOL sweep reused that same filter and
inherited a false NEGATIVE — the tester's Intune inventory lists
Microsoft Access 2010 1.0 Delivered by Citrix
Microsoft Visio 2016 1.0 Delivered by Citrix
and both are long past end-of-support. The release is in the NAME there, not in
the version, so the Microsoft-lifecycle listing can date them without ever
looking at the "1.0".
Run: python tests/test_eol_citrix_stub.py
"""
import os
import sys
import types
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_fake = types.ModuleType("app.routers.vulnerabilities")
_fake.log_vulnerability_change = (
lambda db, uid, vid, old, new, reason=None, cve_id=None, source=None,
hostname=None: None)
sys.modules["app.routers.vulnerabilities"] = _fake
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 # noqa: E402
from app.models.vulnerability import Vulnerability # noqa: E402
from app.services import eol_service as E # noqa: E402
from app.services import ms_lifecycle_service as MSL # noqa: E402
# Trimmed from the real export (product-lifecycle-data-new.xlsx). Pinned: the
# assertion is about which packages reach the matcher, not about the dates.
ROWS = [
{"name": "Microsoft Access 2010", "release": "Service Pack 2",
"end_date": "2020-10-13"},
{"name": "Microsoft Visio 2016", "release": "Original Release",
"end_date": "2025-10-14"},
]
INVENTORY = [
# The two findings that went missing.
{"name": "Microsoft Access 2010", "version": "1.0",
"vendor": "Delivered by Citrix"},
{"name": "Microsoft Visio 2016", "version": "1.0",
"vendor": "Delivered by Citrix"},
# The stub the filter was written for: no release in the name, so the only
# thing left to match on is the placeholder version. Must stay unmatched.
{"name": "Firefox", "version": "1.0", "vendor": "Delivered by Citrix"},
# A real local install — the version path must be untouched.
{"name": "Mozilla Firefox", "version": "141.0", "vendor": "Mozilla"},
]
def demo():
MSL.fetch_lifecycle_data = lambda db, force_refresh=False: ROWS
# endoflife.date stands in for "the version says EOL": if a stub ever
# reaches this path, it produces a finding and the assertions below catch
# it. Recording the names is what tells a false negative (no row, nothing
# asked) apart from a correctly skipped one.
asked = []
def fake_check_eol(db, name, version):
asked.append(name)
return E.EOLStatus(
is_eol=True, is_eoas=False, is_maintained=False, days_to_eol=-4000,
release_label=version, release_name=version, eol_date="2014-01-01",
product_slug="firefox")
E.resolve_product_slug = lambda name: "firefox"
E.check_eol = fake_check_eol
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
db = sessionmaker(bind=engine)()
asset = Asset(hostname="ise-citrix-vdi", ip_address="10.0.0.11")
db.add(asset)
db.commit()
E.run_eol_for_packages(db, asset, INVENTORY)
db.commit()
rows = {v.cve_id: v for v in db.query(Vulnerability)
.filter(Vulnerability.asset_id == asset.id).all()}
# A stub is only dated by its name. The version never gets a vote.
assert asked == ["Mozilla Firefox"], asked
for cve_id, pkg in (("EOL-MS-LIFECYCLE-MicrosoftAccess2010", "Microsoft Access 2010"),
("EOL-MS-LIFECYCLE-MicrosoftVisio2016", "Microsoft Visio 2016")):
row = rows.get(cve_id)
assert row is not None, f"{pkg} lost its EOL finding: {list(rows)}"
assert row.source_list == ["ms-lifecycle"], row.source_list
# The stub with no release in its name stays where it was: nowhere. The
# real install next to it keeps its row.
assert "EOL-FIREFOX-1.0" not in rows, list(rows)
assert "EOL-FIREFOX-141.0" in rows, list(rows)
assert len(rows) == 3, list(rows) # 2 stubs + the real Firefox
print("ok a Citrix stub is EOL-checked by name, never by its placeholder version")
if __name__ == "__main__":
demo()