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.
358 lines
15 KiB
Python
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
|