Files
vulncheck/app/services/vcenter_service.py
T
vulncheck 88a6bd47cc feat(audit): log asset renames done by a sync
A manual rename under Assets is audited, a sync-driven one left no trace:
Netdisco, vCenter, IGEL and Intune adopt the name the source reports once
they matched an asset on a pin, and the old name, still in a ticket or a
mail, then pointed at nothing.

All five rename spots now go through asset_lifecycle.rename, the rename
counterpart of apply_status. It logs "<source> sync renamed asset <id>:
old → new" at INFO and writes an ASSET_UPDATED audit entry "Asset renamed
by <source> sync: old → new", old_value the old name, new_value JSON with
hostname, old_hostname and source so the Asset column shows the new name.
Wazuh, Nessus, Defender and the container imports never rename.
2026-09-18 09:34:33 +02:00

358 lines
15 KiB
Python

"""VMware vCenter inventory sync.
Registers the vCenter appliance AND every ESXi host it manages as assets
(source=VCENTER), then runs EOL + CVE detection on each — the same
find-or-create + lifecycle-reconcile pattern as the Intune and Nessus syncs.
Why this exists: a hypervisor runs no agent. Wazuh cannot reach it, Intune does
not know it, and Nessus only sees it if someone scoped a credentialed scan at
it — so the most consequential machines in the estate (lose an ESXi host and
every VM on it goes with it) were the ones with no coverage at all. vCenter
already holds the exact inventory needed: product version and BUILD per host.
The build is the point. VMware states its fixes as build identifiers
("ESXi80U3k-25595708"), so a version alone cannot answer "patched or not" —
see vmware_release_service.
"""
from __future__ import annotations
import json
import logging
from datetime import datetime
from typing import Optional
from sqlalchemy.orm import Session
from app.models.asset import Asset, AssetSource, AssetStatus
from app.services.asset_matching import match_by_hostname, match_by_ip
logger = logging.getLogger(__name__)
SETTING_KEY = "vcenter_config"
ESXI_OS = "VMware ESXi"
VCENTER_OS = "VMware vCenter Server"
def load_vcenter_config(db: Session) -> Optional[dict]:
"""Decrypt + parse vcenter_config, or None when not configured."""
from app.auth.setting_crypto import read_setting_value
raw = read_setting_value(db, SETTING_KEY)
if not raw:
return None
try:
cfg = json.loads(raw)
except json.JSONDecodeError:
logger.warning("vcenter_config is not valid JSON")
return None
if not all([cfg.get("host"), cfg.get("username"), cfg.get("password")]):
return None
return cfg
def _resolve_ip(host: str) -> Optional[str]:
"""The appliance's own address.
vCenter does not report its management IP over the API — `about` carries no
address at all — so it comes from resolving the host we are configured to
talk to. That IS the address in use, and an asset without one cannot be
correlated with a Nessus scan or a firewall log. An unresolvable name is
not fatal: the sync continues without it.
"""
import ipaddress
import socket
h = (host or "").strip()
if not h:
return None
try:
ipaddress.ip_address(h)
return h # already an address
except ValueError:
pass
try:
return socket.gethostbyname(h)
except OSError:
logger.info("vCenter sync: could not resolve %s to an address", h)
return None
def _build_client(cfg: dict):
from app.integrations.vcenter_client import VCenterClient
return VCenterClient(
host=cfg["host"],
username=cfg["username"],
password=cfg["password"],
port=int(cfg.get("port") or 443),
verify_ssl=cfg.get("verify_ssl", True),
)
# Cross-process guard, same reasoning as the Intune sync: the nightly job and a
# manual trigger run in different contexts and would update the same asset rows
# in different orders. A Postgres advisory lock is global to the DB.
_SYNC_ADVISORY_LOCK_KEY = 0x54560102 # "TV" + 02
def _find_or_create_asset(db: Session, *, uuid: Optional[str], hostname: str,
auto_create: bool, ip: Optional[str] = None):
"""Match a vSphere object to an asset by hardware UUID first, hostname
second. UUID first because an ESXi host gets renamed (DNS change, estate
re-standardisation) far more often than its mainboard is swapped, and a
rename must not fork the asset — the finding history hangs off it."""
uuid = (uuid or "").strip() or None
hostname = (hostname or "").strip()
short = hostname.split(".")[0] if hostname else ""
def _pin(a):
if uuid and a.vmware_uuid != uuid:
a.vmware_uuid = uuid
if uuid:
a = db.query(Asset).filter(Asset.vmware_uuid == uuid).first()
if a:
_pin(a)
return a, "uuid"
for candidate in [c for c in (hostname, short) if c]:
a = match_by_hostname(db, candidate, pin_col=Asset.vmware_uuid, pin_value=uuid, ip=ip)
if a:
_pin(a)
return a, "hostname"
if short:
a = match_by_hostname(db, f"{short}.%", pin_col=Asset.vmware_uuid,
pin_value=uuid, ip=ip)
if a:
_pin(a)
return a, "hostname-fqdn-prefix"
# An ESXi host is very often registered in vCenter by IP alone, and a
# Nessus scan of the same box records the IP too — matching on it keeps
# the two sources on one asset instead of creating a duplicate.
if ip:
a = match_by_ip(db, ip, hostname)
if a:
_pin(a)
return a, "ip"
if auto_create and hostname:
a = Asset(hostname=short or hostname, ip_address=ip,
vmware_uuid=uuid, source=AssetSource.VCENTER,
status=AssetStatus.ACTIVE)
db.add(a)
db.flush()
logger.info("vCenter sync: auto-created asset %s", a.hostname)
return a, "created"
return None, "skipped"
# A host vCenter cannot currently talk to reports the version it had when the
# connection dropped. That is still the truth about what is installed — the
# host did not patch itself while disconnected — so its findings are kept and
# only the counter records the state. Matching Wazuh's six-week grace window
# for offline agents (migration 044) is the lifecycle side of the same rule.
_LIVE_STATES = {"connected"}
def run_vcenter_sync(db: Session, *, refresh_catalog: bool = False) -> dict:
"""Sync the vCenter appliance + its ESXi hosts → assets, EOL and CVEs."""
cfg = load_vcenter_config(db)
if not cfg:
raise RuntimeError("vCenter is not configured (settings.vcenter_config missing/incomplete).")
# The lock must NOT ride on `db`: this sync commits per host, and a
# committed Session gives its connection back to the pool — taking the
# session-scoped lock with it. See database.advisory_lock.
from app.database import advisory_lock
with advisory_lock(_SYNC_ADVISORY_LOCK_KEY) as got:
if not got:
logger.warning("vCenter sync skipped — another vCenter sync holds the lock")
return {"skipped": "another sync already running"}
return _run_vcenter_sync_locked(db, cfg, refresh_catalog=refresh_catalog)
def _run_vcenter_sync_locked(db: Session, cfg: dict, *, refresh_catalog: bool) -> dict:
from app.services.asset_lifecycle import reconcile_vcenter_by_seen_ids, rename
auto_create = bool(cfg.get("auto_create_assets", True))
sync_hosts = bool(cfg.get("sync_hosts", True))
stats = {"hosts": 0, "assets_matched": 0, "assets_created": 0,
"disconnected_hosts": 0, "eol_findings": 0, "cve_findings": 0,
"assets_inactivated": 0, "assets_reactivated": 0, "errors": []}
seen_asset_ids: set = set()
# The vCenter build catalog decides every vCenter bound ("8.0 U3k" → a
# build). Refresh it on the nightly run so a fix published after this
# release still resolves; a manual sync uses whatever is cached.
if refresh_catalog:
try:
from app.services import vmware_release_service as vmr
vmr.refresh_catalog(db)
except Exception as e:
logger.warning("vCenter build catalog refresh failed (non-fatal): %s", e)
client = _build_client(cfg)
try:
about = client.get_about()
hosts = client.get_hosts() if sync_hosts else []
except Exception as e:
client.close()
raise RuntimeError(f"vCenter inventory fetch failed: {e}") from e
# --- the vCenter appliance itself ---
try:
own_ip = _resolve_ip(cfg["host"])
asset, how = _find_or_create_asset(
db, uuid=about.get("instance_uuid"), hostname=cfg["host"],
auto_create=auto_create, ip=own_ip)
if asset:
stats["assets_created" if how == "created" else "assets_matched"] += 1
if own_ip:
asset.ip_address = own_ip[:45]
asset.operating_system = VCENTER_OS
asset.os_version = (about.get("version") or "")[:100] or None
asset.vmware_build = (str(about.get("build") or "") or None)
asset.description = about.get("full_name") or asset.description
asset.last_scan = datetime.now()
asset.last_seen = datetime.now()
asset.last_seen_source = "vcenter"
db.flush()
if asset.id:
seen_asset_ids.add(asset.id)
stats["eol_findings"] += _run_eol(db, asset)
except Exception as e:
stats["errors"].append(f"vcenter appliance: {e}")
# --- ESXi hosts ---
for h in hosts:
stats["hosts"] += 1
try:
if h.get("connection_state") and h["connection_state"] not in _LIVE_STATES:
stats["disconnected_hosts"] += 1
asset, how = _find_or_create_asset(
db, uuid=h.get("uuid"), hostname=h.get("name") or "",
auto_create=auto_create, ip=h.get("ip_address"))
if not asset:
continue
if how == "created":
stats["assets_created"] += 1
else:
stats["assets_matched"] += 1
# Renames are common in vSphere estates; matched by UUID means
# the name vCenter reports now is the current one.
name = (h.get("name") or "").strip()
if how == "uuid" and name and asset.hostname != name:
rename(db, asset, name.split(".")[0] or name, "vCenter")
if h.get("ip_address"):
asset.ip_address = h["ip_address"][:45]
asset.operating_system = ESXI_OS
if h.get("version"):
asset.os_version = str(h["version"])[:100]
if h.get("build"):
asset.vmware_build = str(h["build"])[:32]
# Display, not a match key: the host is already found by its UUID,
# and the serial is what an operator reads off the chassis or
# quotes to the vendor. Kept when a later sync reports none.
if h.get("serial"):
asset.vmware_serial = h["serial"]
model = " ".join(x for x in (h.get("vendor"), h.get("model")) if x)
asset.description = (h.get("full_name") or "") + (f" — {model}" if model else "") or None
asset.last_scan = datetime.now()
asset.last_seen = datetime.now()
asset.last_seen_source = "vcenter"
db.flush()
if asset.id:
seen_asset_ids.add(asset.id)
stats["eol_findings"] += _run_eol(db, asset)
except Exception as e:
stats["errors"].append(f"host {h.get('name')}: {e}")
db.commit()
client.close()
# CVE pass over the assets this sync touched. Runs after the commit so a
# scan failure cannot lose the inventory we just collected.
try:
stats["cve_findings"] = _run_cve_scan(db, seen_asset_ids)
except Exception as e:
stats["errors"].append(f"cve scan: {e}")
try:
recon = reconcile_vcenter_by_seen_ids(
db, seen_asset_ids=seen_asset_ids,
reason="not reported by the latest vCenter sync")
stats["assets_inactivated"] = recon["inactivated"]
stats["assets_reactivated"] = recon["reactivated"]
db.commit()
except Exception as e:
logger.warning("vCenter reconcile failed: %s", e)
logger.info(
"vCenter sync done: %d hosts, %d matched, %d created, %d disconnected, "
"%d EOL, %d CVE findings, %d inactivated, %d reactivated",
stats["hosts"], stats["assets_matched"], stats["assets_created"],
stats["disconnected_hosts"], stats["eol_findings"], stats["cve_findings"],
stats["assets_inactivated"], stats["assets_reactivated"])
return stats
def _run_eol(db: Session, asset) -> int:
"""endoflife.date lifecycle check for the hypervisor / appliance line.
ESXi 7.0 went end-of-general-support on 2025-10-02 and 6.7 back in 2022 —
an estate still on those gets no patches for anything found later, which is
the finding that outranks every individual CVE on the box.
"""
from app.services import eol_service
try:
status = eol_service.check_os_eol(db, asset.operating_system or "",
asset.os_version or "")
if status and (status.is_eol or status.is_eol_soon or status.is_eoas):
eol_service.upsert_eol_vulnerability(
db, asset_id=asset.id,
product_name=(asset.operating_system or "VMware vSphere").strip(),
installed_version=(asset.os_version or status.release_name or "unknown"),
status=status)
return 1
except Exception as e:
logger.warning("vCenter EOL check failed for %s: %s", asset.hostname, e)
return 0
def _run_cve_scan(db: Session, asset_ids: set) -> int:
"""cvelistV5 vSphere pass over the assets this sync touched."""
if not asset_ids:
return 0
from app.services import cvelistv5_scan_service as c5
index = c5.load_index(db) or {}
if not index:
logger.info("vCenter sync: no cvelistV5 index yet — CVE pass deferred "
"to the nightly app-CVE scan")
return 0
new_ids: list = []
total = 0
for asset in db.query(Asset).filter(Asset.id.in_(asset_ids)).all():
try:
total += c5.scan_asset_vmware(db, asset, index, new_ids)
except Exception as e:
logger.warning("vSphere CVE scan failed for %s: %s", asset.hostname, e)
db.commit()
if new_ids:
# Same tail as the app-CVE scan: audit, enrich, notify. A vSphere
# finding that never reaches EPSS/KEV enrichment or the new-CVE mail is
# half a finding — and these are the assets where the mail matters most.
try:
from app.services.audit_events import audit_new_vulnerabilities
audit_new_vulnerabilities(db, new_ids, source="app-scan")
db.commit()
except Exception as e:
logger.debug("vSphere detected-audit failed: %s", e)
try:
from app.models.vulnerability import Vulnerability
from app.services.enrichment_service import enrich_vulnerabilities
from app.services.email_service import dispatch_new_vuln_notifications
fresh = db.query(Vulnerability).filter(Vulnerability.id.in_(new_ids)).all()
if fresh:
enrich_vulnerabilities(db, fresh)
dispatch_new_vuln_notifications(db, fresh)
except Exception as e:
logger.debug("vSphere enrichment/notify failed: %s", e)
return total