Files
vulncheck/tests/test_vsphere_autoclose.py
T
vulncheck 6e449dba12 fix(vcenter): auto-close patched ESXi/vCenter CVEs, and stop leaking the sync lock
Two bugs, one visible symptom: a hypervisor patched during the day kept every
CVE its old build had, and the manual sync answered "another sync was already
running" forever.

1. The vSphere pass never reconciled. The app scan's auto-resolve sits behind
   `if packages:` — a hypervisor runs no agent, has no package inventory, and
   so never reached it. Nothing else ever revisited these findings: the new
   version and build were written onto the asset (correct in the GUI) while
   the findings from the old build stayed open, run after run. The reconcile
   now lives in scan_asset_vmware, where the verdict is made, so both callers
   (vCenter sync + nightly app scan) get it. Same contract as every other
   reconcile: retract our own source, mark patched only once no scanner still
   claims it, and close nothing at all when this pass had no verdict — no
   build on the asset, no index, or a bound the catalog cannot resolve.
   It commits itself: the nightly scan commits an asset only when the scan
   FOUND something, and a host whose findings were all just closed found
   nothing — its cleanup would have been rolled back and redone every night.

2. The advisory lock leaked across the connection pool. pg_try_advisory_lock
   is SESSION scoped — it belongs to the postgres backend, i.e. to the pooled
   connection — but both syncs took it on their ORM Session and then commit
   once per host, and a committed Session hands its connection back to the
   pool. Any concurrent request could take it, after which the sync ran on a
   different backend: the closing pg_advisory_unlock found nothing, returned
   false, and the lock stayed held for the life of that pooled connection.
   Reproduced against postgres: run 1 released it by luck, run 2 unlocked the
   wrong backend, run 3 was skipped, one lock held. From then on EVERY vCenter
   sync — manual and nightly — was skipped, so nothing rescanned the hosts and
   nothing could ever be closed. The lock now gets a connection of its own
   (database.advisory_lock), and a failed unlock invalidates that connection
   instead of returning it to the pool still locked. Intune/Defender had the
   same pattern and the same fix.
2026-08-21 08:28:05 +02:00

182 lines
6.0 KiB
Python

"""A patched ESXi host must LOSE the CVEs its old build had.
The version and the build were updated on the asset (visible in the GUI), but
every vSphere finding stayed open — through the vCenter sync AND through the
nightly app-CVE scan. Cause: the app scan's reconcile
(`_resolve_stale_app_findings`) sits behind `if packages:`, and a hypervisor
runs no agent, so it has no package inventory and never reached it. Nothing
else ever revisited these findings, so a fixed box kept reporting false
positives for as long as it existed.
The reconcile now lives in the vSphere pass itself, where the verdict is made
— so both callers (vCenter sync + nightly app scan) get it.
Run: python tests/test_vsphere_autoclose.py
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.services import cvelistv5_scan_service as c5 # noqa: E402
# VMSA-style record: fixed in ESXi 8.0 U3k, build 25595708.
CVE_ESX = {
"vendor": "VMware", "product": "ESX", "defaultStatus": "unaffected",
"versions": [{"status": "affected", "version": "8.0",
"lessThan": "ESXi80U3k-25595708", "versionType": "custom"}],
}
# Same shape, but the fix is named as a release the catalog cannot resolve.
CVE_UNRESOLVABLE = {
"vendor": "VMware", "product": "ESX", "defaultStatus": "unaffected",
"versions": [{"status": "affected", "version": "8.0",
"lessThan": "ESXi650-202005401-SG", "versionType": "custom"}],
}
class _Vuln:
def __init__(self, cve_id, package_name, sources=("app-scan",), vid=1):
self.id, self.cve_id, self.package_name = vid, cve_id, package_name
self.sources = list(sources)
self.status, self.patched_at = "open", None
# mirrors app.models.vulnerability
@property
def source_list(self):
return list(self.sources)
def remove_source(self, s):
if s in self.sources:
self.sources.remove(s)
class _Status:
open, patched = "open", "patched"
class _Query:
def __init__(self, rows):
self._rows = rows
def filter(self, *a, **kw):
return self
def first(self):
return None # no settings row → load_catalog uses the seed
def all(self):
return self._rows
class _DB:
"""query(Vulnerability) → the findings; query(anything else) → empty."""
def __init__(self, rows):
self.rows, self.commits = rows, 0
def query(self, model):
return _Query(self.rows if getattr(model, "__name__", "") == "Vulnerability" else [])
def commit(self):
self.commits += 1
class _Esxi:
id, hostname = 1, "esx01.lab.local"
operating_system, os_version, vmware_build = "VMware ESXi", "8.0.3", "25304314"
def _index(record=CVE_ESX, cve="CVE-2026-47876"):
return {"vmware-esxi": c5.vmware_entries(record, cve, 9.3, "critical", "OOB write")}
LAST_DB: list = []
def _scan(asset, rows, index=None):
"""Run the pass with the real upsert stubbed out — this test is about what
gets CLOSED, not what gets written."""
orig = c5.cpe._upsert
db = _DB(rows)
LAST_DB[:] = [db]
try:
c5.cpe._upsert = lambda db, a, n, v, c, new_ids, **kw: new_ids.append(1)
return c5.scan_asset_vmware(db, asset, index or _index(), [])
finally:
c5.cpe._upsert = orig
def patched_host_closes_its_findings():
class _Patched(_Esxi):
vmware_build = "25595708" # exactly the fixed build
v = _Vuln("CVE-2026-47876", "VMware ESXi")
assert _scan(_Patched(), [v]) == 0
assert v.status == "patched", v.status
assert v.patched_at is not None
assert v.source_list == [] # our claim retracted
# The nightly scan commits an asset only when it FOUND something, and this
# host found nothing — so the close has to commit itself or it is lost.
assert LAST_DB[0].commits == 1
print(" patched host → finding auto-closed and committed OK")
def still_vulnerable_host_keeps_them():
v = _Vuln("CVE-2026-47876", "VMware ESXi")
assert _scan(_Esxi(), [v]) == 1 # build 25304314 is still behind U3k
assert v.status == "open"
print(" unpatched host → finding kept OK")
def a_second_scanner_keeps_it_open():
"""We only ever retract OUR OWN source — same rule as every other reconcile."""
class _Patched(_Esxi):
vmware_build = "25595708"
v = _Vuln("CVE-2026-47876", "VMware ESXi", sources=("app-scan", "nessus"))
_scan(_Patched(), [v])
assert v.status == "open"
assert v.source_list == ["nessus"]
print(" cross-confirmed finding stays open OK")
def other_products_are_not_touched():
class _Patched(_Esxi):
vmware_build = "25595708"
tools = _Vuln("CVE-2026-99999", "VMware Tools")
client = _Vuln("CVE-2026-99998", "VMware ESXi Embedded Host Client")
_scan(_Patched(), [tools, client])
assert tools.status == "open" and client.status == "open"
print(" neighbouring VMware products untouched OK")
def no_verdict_closes_nothing():
"""Three ways this pass can fail to decide. None of them may close a thing:
"we could not look" must never read as "it is fixed"."""
class _NoBuild(_Esxi):
vmware_build = None
v = _Vuln("CVE-2026-47876", "VMware ESXi")
_scan(_NoBuild(), [v]); assert v.status == "open", "no build"
_scan(_Esxi(), [v], index={}) # no index
assert v.status == "open", "no index"
class _Patched(_Esxi):
vmware_build = "25595708"
# A bound the catalog cannot turn into a build → unresolved → no reconcile.
_scan(_Patched(), [v], index=_index(CVE_UNRESOLVABLE, "CVE-2026-47876"))
assert v.status == "open", "unresolvable bound"
print(" no verdict → nothing closed OK")
def demo():
patched_host_closes_its_findings()
still_vulnerable_host_keeps_them()
a_second_scanner_keeps_it_open()
other_products_are_not_touched()
no_verdict_closes_nothing()
print("vsphere auto-close OK")
def test_vsphere_autoclose():
demo()
if __name__ == "__main__":
demo()