Files
vulncheck/app/routers/assets.py
T
vulncheck 236c00ada7 feat(audit): every write route leaves an audit row
Groups, policies, scan schedules, finding assignment and suppression, and
every sync, scan, refresh and connection-test button wrote no audit row,
so "who did this, and what changed" had no answer for them.

Records that change now write one row naming each field old -> new
(audit_trail.record_change; a create passes before={}, a delete after={},
nothing changed writes nothing), in the same transaction as the change.
Defer, undefer and false-positive also record the date, reason and
suppression they set or clear; an admin's email change is logged.

Buttons take the route dependency audited("What"): one row once the
endpoint returned, before the response goes out, so a refused or raising
call writes none and a failing audit write is a 500 rather than a gap. It
keeps the query parameters and only the named body fields, because request
bodies carry passwords: the test-mail recipient and SMTP host, the syslog
target, the AI provider and base URL, the Nessus asset and scan.

A guard test fails for any new POST/PUT/PATCH/DELETE route that does not
audit itself. No new event types, so no migration: groups, policies and
schedules log as CONFIG_CHANGE, buttons as SCAN_TRIGGERED, told apart by
resource_type and the description.
2026-10-26 11:03:31 +01:00

1519 lines
64 KiB
Python

"""
Asset/Inventory Management Router
Endpoints for asset management (Wazuh Agents + manual systems).
"""
from typing import Optional, List, Any
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, Query, Response
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session, selectinload
from sqlalchemy import asc, desc, func, nulls_last
from pydantic import BaseModel, field_validator
from app.database import get_db
from app.models.user import User
from app.models.asset import Asset, AssetSource, AssetStatus, ContainerPlacement
from app.models.policy import Policy
from app.models.vulnerability import Vulnerability, VulnerabilityStatus
from app.models.audit_log import AuditLog, AuditEventType
from app.auth.dependencies import get_current_user, RequireEditor, RequireAdmin, audited
from app.services.audit_trail import record_change
import csv
import io
import logging
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/assets", tags=["Assets"])
# ============================================
# Pydantic Schemas
# ============================================
class AssetResponse(BaseModel):
id: int
hostname: str
ip_address: Optional[str]
wazuh_agent_id: Optional[str]
nessus_host_uuid: Optional[str] = None
# Intune/Defender ids — surfaced so the UI can offer the software/app-scan
# actions on MDM-managed (iOS/macOS/Android) assets, not just Wazuh hosts.
intune_device_id: Optional[str] = None
defender_machine_id: Optional[str] = None
# Entra/AAD device id — the one identifier that survives a device being
# deleted in Intune while Defender TVM keeps reporting it under another
# name. Read-only in the UI; it is what an operator needs to exclude the
# right machine in Defender (field report 2026-08-30).
aad_device_id: Optional[str] = None
# The remaining per-source identity pins, read-only in the edit dialog so
# an operator can see what a sync reconnects the asset by (and the serial
# to find the hardware with).
vmware_uuid: Optional[str] = None
vmware_serial: Optional[str] = None
igel_unit_id: Optional[str] = None
igel_serial: Optional[str] = None
netdisco_device_ip: Optional[str] = None
netdisco_serial: Optional[str] = None
xen_host_uuid: Optional[str] = None
intune_serial: Optional[str] = None
operating_system: Optional[str]
os_version: Optional[str]
# VMware VMkernel/vpxd build number — the precise fix identifier for
# ESXi/vCenter (e.g. "24784735"), surfaced in the OS column.
vmware_build: Optional[str] = None
source: AssetSource
status: AssetStatus
last_scan: Optional[datetime]
last_seen: Optional[datetime]
# Which sync wrote those stamps — the same attribution the reactivation
# audit entry quotes.
last_seen_source: Optional[str] = None
vulnerability_count: Optional[int] = 0
policy_id: Optional[int] = None
description: Optional[str] = None
location: Optional[str] = None
owner: Optional[str] = None
assigned_user_id: Optional[int] = None
assigned_user_name: Optional[str] = None
# assigned_group_id obsolete, replaced by groups list
groups: List[str] = [] # List of Team Names for display
policy_name: Optional[str] = None
criticality: Optional[str] = None
# Network-exposure risk dimension
network_exposure_score: Optional[float] = None
exposed_services: Optional[List[dict]] = None
exposure_updated_at: Optional[datetime] = None
# Risk Dimensions (crown-jewel roles)
high_value_score: Optional[float] = None
risk_dimensions: Optional[List[dict]] = None
# Docker host ↔ image links from the Dockhand sync (container_placements):
# a host's number of images, an image's hosts as {id, hostname}.
container_image_count: int = 0
runs_on: List[dict] = []
@field_validator('exposed_services', 'risk_dimensions', mode='before')
@classmethod
def parse_json_list(cls, v: Any) -> Optional[List[dict]]:
if v is None or isinstance(v, list):
return v
if isinstance(v, str):
try:
import json as _j
parsed = _j.loads(v)
return parsed if isinstance(parsed, list) else None
except Exception:
return None
return None
@field_validator('groups', mode='before')
@classmethod
def map_groups_to_names(cls, v: Any) -> List[str]:
if not v:
return []
# Wenn es bereits Strings sind (z.B. manuell gesetzt)
if isinstance(v, list) and all(isinstance(x, str) for x in v):
return v
# Wenn es SQLAlchemy-Objekte sind
return [g.name for g in v if hasattr(g, 'name')]
class Config:
from_attributes = True
class AssetAssignRequest(BaseModel):
user_id: Optional[int] = None
group_id: Optional[int] = None
class AssetCreateRequest(BaseModel):
hostname: str
ip_address: Optional[str] = None
operating_system: Optional[str] = None
os_version: Optional[str] = None
description: Optional[str] = None
location: Optional[str] = None
owner: Optional[str] = None
policy_id: Optional[int] = None
class AssetUpdateRequest(BaseModel):
hostname: Optional[str] = None
ip_address: Optional[str] = None
operating_system: Optional[str] = None
os_version: Optional[str] = None
status: Optional[AssetStatus] = None
description: Optional[str] = None
location: Optional[str] = None
owner: Optional[str] = None
policy_id: Optional[int] = None
# Plan E — URS inputs (criticality multiplier + framework list)
criticality: Optional[str] = None # low | normal | high | critical
compliance_frameworks: Optional[List[str]] = None
class BulkAssetUpdateRequest(BaseModel):
asset_ids: List[int]
assigned_user_id: Optional[int] = None
assigned_group_id: Optional[int] = None
policy_id: Optional[int] = None
def _asset_state(asset: Asset, fields) -> dict:
"""The audited values of `fields`; group membership by name."""
return {f: sorted(g.name for g in asset.groups) if f == "groups" else getattr(asset, f)
for f in fields}
def _audit_asset_update(db: Session, user: User, asset: Asset, before: dict) -> None:
"""One ASSET_UPDATED row naming every changed field, old → new. Nothing
changed, no row."""
record_change(db, user.id, AuditEventType.ASSET_UPDATED, "asset", asset.id,
f"Asset updated: {asset.hostname}", before, _asset_state(asset, before))
def _assignment(asset: Asset) -> dict:
return {"assigned_user": asset.assigned_user.username if asset.assigned_user else None,
"groups": sorted(g.name for g in asset.groups)}
# ============================================
# Endpoints
# ============================================
@router.post("/bulk-update")
def bulk_update_assets(
update_data: BulkAssetUpdateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor)
):
"""Updates multiple assets at once (assignment, policy)"""
assets = db.query(Asset).filter(Asset.id.in_(update_data.asset_ids)).all()
if not assets:
raise HTTPException(status_code=404, detail="No assets found")
update_fields = {}
if update_data.assigned_user_id is not None:
update_fields["assigned_user_id"] = update_data.assigned_user_id if update_data.assigned_user_id != -1 else None
if update_data.assigned_group_id is not None:
update_fields["assigned_group_id"] = update_data.assigned_group_id if update_data.assigned_group_id != -1 else None
if update_data.policy_id is not None:
update_fields["policy_id"] = update_data.policy_id if update_data.policy_id != -1 else None
if not update_fields:
raise HTTPException(status_code=400, detail="No update fields specified")
count = 0
from app.models.group import Group
audited = ["groups" if f == "assigned_group_id" else f for f in update_fields]
for asset in assets:
before = _asset_state(asset, audited)
for field, value in update_fields.items():
if field == "assigned_group_id":
# Special handling for legacy group assignment
if value is None:
asset.groups = []
else:
group = db.query(Group).filter(Group.id == value).first()
if group:
asset.groups = [group]
else:
setattr(asset, field, value)
_audit_asset_update(db, current_user, asset, before)
count += 1
db.commit()
# Summary row for the overview; the per-asset rows above carry the diff.
audit_log = AuditLog(
user_id=current_user.id,
event_type=AuditEventType.ASSET_UPDATED,
event_description=f"Bulk update performed for {count} assets",
resource_type="asset",
resource_id="multiple",
timestamp=datetime.now()
)
db.add(audit_log)
db.commit()
return {"message": f"{count} assets successfully updated", "count": count}
@router.post("/reconcile-lifecycle", dependencies=[audited("Asset lifecycle reconcile")])
def reconcile_lifecycle(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
):
"""Manually run the soft-inactive reconcile (also runs 04:15 nightly).
Flips assets no source reported within asset_inactive_after_days to
INACTIVE; revives recently-seen INACTIVE ones. History + audit kept.
"""
from app.services.asset_lifecycle import reconcile_asset_lifecycle
return reconcile_asset_lifecycle(db)
@router.get("/{asset_id}/coverage-gap")
def asset_coverage_gap(
asset_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Coverage-gap report: installed packages with NO vuln finding.
Field report: Wazuh's vuln-detector misses some products (MS365,
mRemoteNG, …). This surfaces the GAP — packages present in
syscollector but not matched to any open finding — so the operator
can investigate manually. Deliberately makes NO automatic CVE
assignment (zero false positives). Annotates each gap package with
its endoflife.date status when known.
"""
import json as _json
from app.auth.setting_crypto import read_setting_value
from app.integrations.wazuh_client import WazuhClient
from app.services import eol_service
asset = db.query(Asset).filter(Asset.id == asset_id).first()
if not asset:
raise HTTPException(404, "Asset not found")
if not asset.wazuh_agent_id:
raise HTTPException(422, "Asset has no Wazuh agent — no syscollector inventory")
raw = read_setting_value(db, "wazuh_config")
if not raw:
raise HTTPException(400, "Wazuh is not configured")
cfg = _json.loads(raw)
wazuh = WazuhClient(
base_url=cfg.get("api_url"), username=cfg.get("username"),
password=cfg.get("password"), indexer_url=cfg.get("indexer_url"),
indexer_username=cfg.get("indexer_username"),
indexer_password=cfg.get("indexer_password"),
verify_ssl=bool(cfg.get("verify_ssl", True)),
)
try:
packages = wazuh.get_packages(asset.wazuh_agent_id) or []
except Exception as e:
raise HTTPException(502, f"Could not fetch packages from Wazuh: {e}")
# Build the "covered" set: package names that already have an open
# finding on this asset (parent package_name + child rows).
covered_blob = " ".join(
(v.package_name or "").lower()
for v in db.query(Vulnerability)
.filter(Vulnerability.asset_id == asset_id,
Vulnerability.status == VulnerabilityStatus.open)
.all()
)
seen: set = set()
gap: list[dict] = []
covered_count = 0
for pkg in packages:
name = (pkg.get("name") or "").strip()
version = (pkg.get("version") or "").strip()
if not name:
continue
key = (name.lower(), version)
if key in seen:
continue
seen.add(key)
# Covered if the package name (token) appears in any finding's
# package_name blob. Token match avoids substring noise on very
# short names by requiring length >= 3.
nl = name.lower()
is_covered = len(nl) >= 3 and nl in covered_blob
if is_covered:
covered_count += 1
continue
# Annotate EOL status if endoflife knows the product (cheap —
# cached). Only adds a hint, never a CVE claim.
eol_hint = None
try:
st = eol_service.check_eol(db, name, version) if eol_service.resolve_product_slug(name) else None
if st:
if st.is_eol:
eol_hint = "EOL"
elif st.is_eol_soon:
eol_hint = f"EOL SOON ({st.days_to_eol}d)"
elif st.is_eoas:
eol_hint = "out-of-active-support"
except Exception:
pass
gap.append({"name": name[:255], "version": version[:100], "eol_hint": eol_hint})
gap.sort(key=lambda g: (g["eol_hint"] is None, g["name"].lower()))
return {
"asset_id": asset_id,
"hostname": asset.hostname,
"total_packages": len(seen),
"covered": covered_count,
"gap_count": len(gap),
"gap": gap,
}
@router.get("/{asset_id}/software")
def asset_software(
asset_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""On-demand installed-software inventory for one asset.
Pulled LIVE (not persisted) from whichever source backs the asset:
Wazuh syscollector packages, else Intune detectedApps. This is the same
inventory the app-CVE scanner consumes — surfaced read-only so the operator
can see what's installed without a DB table.
"""
import json as _json
from app.auth.setting_crypto import read_setting_value
asset = db.query(Asset).filter(Asset.id == asset_id).first()
if not asset:
raise HTTPException(404, "Asset not found")
software: list[dict] = []
source = None
from app.services.audit_events import asset_inventory
if asset_inventory(asset) == "wazuh":
from app.integrations.wazuh_client import WazuhClient
raw = read_setting_value(db, "wazuh_config")
if not raw:
raise HTTPException(400, "Wazuh is not configured")
cfg = _json.loads(raw)
wazuh = WazuhClient(
base_url=cfg.get("api_url"), username=cfg.get("username"),
password=cfg.get("password"), indexer_url=cfg.get("indexer_url"),
indexer_username=cfg.get("indexer_username"),
indexer_password=cfg.get("indexer_password"),
verify_ssl=bool(cfg.get("verify_ssl", True)),
)
try:
pkgs = wazuh.get_packages(asset.wazuh_agent_id) or []
except Exception as e:
raise HTTPException(502, f"Could not fetch packages from Wazuh: {e}")
source = "wazuh"
software = [{"name": (p.get("name") or "").strip(),
"version": (p.get("version") or "").strip(),
"vendor": (p.get("vendor") or p.get("format") or "").strip()} for p in pkgs]
elif asset.intune_device_id:
from app.services.intune_service import load_intune_config, _build_client
icfg = load_intune_config(db)
if not icfg:
raise HTTPException(400, "Intune is not configured")
client = _build_client(icfg)
try:
apps = client.get_detected_apps(asset.intune_device_id) or []
except Exception as e:
raise HTTPException(502, f"Could not fetch detectedApps from Intune: {e}")
finally:
client.close()
source = "intune"
software = [{"name": (a.get("name") or "").strip(),
"version": (a.get("version") or "").strip(),
"vendor": (a.get("vendor") or "").strip()} for a in apps]
else:
raise HTTPException(422, "Asset has no Wazuh agent or Intune device — no live software inventory")
# Dedup (name, version) + drop nameless, sort by name.
seen: set = set()
out: list[dict] = []
for s in software:
if not s["name"]:
continue
key = (s["name"].lower(), s["version"])
if key in seen:
continue
seen.add(key)
out.append(s)
out.sort(key=lambda s: s["name"].lower())
return {"asset_id": asset_id, "hostname": asset.hostname, "source": source,
"count": len(out), "software": out}
@router.get("/{asset_id}/containers")
def asset_containers(
asset_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""The container images Dockhand reports on this Docker host, with their
containers and open findings. Only images whose Docker host's hostname
names this asset (container_placements); clean images are not in
Dockhand's export and so not here either."""
host = db.query(Asset).filter(Asset.id == asset_id).first()
if not host:
raise HTTPException(status_code=404, detail="Asset not found")
open_count = (db.query(func.count(Vulnerability.id))
.filter(Vulnerability.asset_id == Asset.id,
Vulnerability.status == VulnerabilityStatus.open)
.correlate(Asset).scalar_subquery())
rows = (db.query(Asset, ContainerPlacement.containers, open_count)
.join(ContainerPlacement, ContainerPlacement.image_asset_id == Asset.id)
.filter(ContainerPlacement.host_asset_id == asset_id)
.order_by(func.lower(Asset.hostname)).all())
return {"hostname": host.hostname, "images": [
{"id": a.id, "image": a.hostname, "status": a.status, "containers": containers,
"vulnerability_count": n or 0, "last_scan": a.last_scan}
for a, containers, n in rows]}
@router.post("/refresh-exposure", dependencies=[audited("Asset exposure refresh")])
def refresh_exposure(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor),
):
"""Refresh network-exposure scores from Wazuh syscollector ports.
Classifies risky listeners (VNC/RDP/Telnet/SMB/…) per asset into a
0-100 exposure score + service list. Informational risk dimension —
does not auto-change criticality.
"""
import json as _json
from app.auth.setting_crypto import read_setting_value
from app.integrations.wazuh_client import WazuhClient
from app.services.exposure_service import refresh_all_exposure
raw = read_setting_value(db, "wazuh_config")
if not raw:
raise HTTPException(400, "Wazuh is not configured")
try:
cfg = _json.loads(raw)
except _json.JSONDecodeError:
raise HTTPException(400, "Invalid Wazuh configuration format")
if not all([cfg.get("api_url"), cfg.get("username"), cfg.get("password")]):
raise HTTPException(400, "Incomplete Wazuh configuration")
wazuh = WazuhClient(
base_url=cfg.get("api_url"),
username=cfg.get("username"),
password=cfg.get("password"),
indexer_url=cfg.get("indexer_url"),
indexer_username=cfg.get("indexer_username"),
indexer_password=cfg.get("indexer_password"),
verify_ssl=bool(cfg.get("verify_ssl", True)),
)
return refresh_all_exposure(db, wazuh)
def _apply_asset_filters(query, db, status=None, source=None, search=None, include_inactive=False,
include_containers=True):
"""Shared filter logic for the list endpoint and the CSV export.
Kept in one place so the two views can never drift apart. Default shows
ACTIVE + INACTIVE and hides only operator-retired DECOMMISSIONED assets;
include_inactive=True (or an explicit status) overrides that.
"""
if status:
query = query.filter(Asset.status == status)
elif include_inactive:
# everything (active + inactive + decommissioned)
pass
else:
# active + inactive visible; hide only operator-retired
query = query.filter(Asset.status != AssetStatus.DECOMMISSIONED)
if source:
# Filter by the actual sync linkage, not the creation-time source
# enum: an asset first created by Wazuh and later matched by Nessus
# keeps source=WAZUH but carries a nessus_host_uuid. The per-scanner
# id columns are the multi-source truth, so a merged asset correctly
# appears under every scanner that sees it.
def _has_vuln_source(tag: str):
# Asset has >=1 finding reported by tag (the vuln-level sources
# list is the maintained multi-source truth). Catches Nessus
# imports whose host had no nessus_host_uuid (matched by hostname).
return Asset.id.in_(
db.query(Vulnerability.asset_id)
.filter(Vulnerability.sources.contains(f'"{tag}"'))
)
if source == AssetSource.WAZUH:
query = query.filter(Asset.wazuh_agent_id.isnot(None) | _has_vuln_source("wazuh"))
elif source == AssetSource.NESSUS:
query = query.filter(Asset.nessus_host_uuid.isnot(None) | _has_vuln_source("nessus"))
elif source == AssetSource.INTUNE:
# Intune devices often carry no findings so id columns are the signal.
query = query.filter(
Asset.intune_device_id.isnot(None) | Asset.defender_machine_id.isnot(None)
| _has_vuln_source("intune") | _has_vuln_source("defender"))
elif source == AssetSource.VCENTER:
# An ESXi host reached only over vCenter carries no findings until
# the first scan, so the pin column is the signal - same as Intune.
query = query.filter(
Asset.vmware_uuid.isnot(None) | (Asset.source == AssetSource.VCENTER))
elif source == AssetSource.IGEL:
# A thin client carries no findings until the first scan either,
# so the pin column is the signal - same as vCenter and Intune.
query = query.filter(
Asset.igel_unit_id.isnot(None) | (Asset.source == AssetSource.IGEL))
elif source == AssetSource.NETDISCO:
# A switch carries no findings until the first firmware scan, and
# one another source registered first keeps that source - so the
# pin column is the signal, same as vCenter and IGEL.
query = query.filter(
Asset.netdisco_device_ip.isnot(None) | (Asset.source == AssetSource.NETDISCO))
elif source == AssetSource.XENSERVER:
# A fully patched host carries no findings, so the pin column is
# the signal - same as vCenter.
query = query.filter(
Asset.xen_host_uuid.isnot(None) | (Asset.source == AssetSource.XENSERVER))
elif source == AssetSource.MANUAL:
# genuinely manual = no scanner linkage AND no scanner-sourced vuln
query = query.filter(
Asset.wazuh_agent_id.is_(None), Asset.nessus_host_uuid.is_(None),
Asset.intune_device_id.is_(None), Asset.defender_machine_id.is_(None),
Asset.vmware_uuid.is_(None), Asset.source != AssetSource.VCENTER,
Asset.igel_unit_id.is_(None), Asset.source != AssetSource.IGEL,
Asset.netdisco_device_ip.is_(None), Asset.source != AssetSource.NETDISCO,
Asset.xen_host_uuid.is_(None), Asset.source != AssetSource.XENSERVER,
Asset.source != AssetSource.CONTAINER,
~_has_vuln_source("wazuh"), ~_has_vuln_source("nessus"),
~_has_vuln_source("intune"), ~_has_vuln_source("defender"))
else:
query = query.filter(Asset.source == source)
elif not include_containers:
query = query.filter(Asset.source != AssetSource.CONTAINER)
if search:
search_pattern = f"%{search}%"
query = query.filter(
(Asset.hostname.ilike(search_pattern)) |
(Asset.ip_address.ilike(search_pattern))
)
return query
@router.get("", response_model=List[AssetResponse])
def list_assets(
status: Optional[AssetStatus] = Query(None),
source: Optional[AssetSource] = Query(None),
search: Optional[str] = Query(None, description="Suche in Hostname, IP"),
include_inactive: bool = Query(False, description="Also include DECOMMISSIONED assets (INACTIVE are shown by default)"),
include_containers: bool = Query(True, description="false hides container images (unless source=CONTAINER)"),
sort_by: str = Query("hostname", description="hostname, ip_address, operating_system, status, last_scan, network_exposure_score, policy_name, assigned_user_name"),
sort_order: str = Query("asc", description="asc, desc"),
limit: int = Query(100, le=1000),
offset: int = Query(0),
response: Response = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Liste aller Assets mit Filterung.
Default zeigt ACTIVE **und** INACTIVE — der Sinn der Status-Spalte ist
ja, soft-inactive Assets (kein source-sync innerhalb
asset_inactive_after_days) mit Badge sichtbar zu halten, nicht sie zu
verstecken. Nur DECOMMISSIONED (operator-final) ist per default
ausgeblendet; include_inactive=true zeigt auch die.
Vuln-historie + audit bleiben erhalten.
"""
query = db.query(Asset)
query = _apply_asset_filters(query, db, status=status, source=source,
search=search, include_inactive=include_inactive,
include_containers=include_containers)
# Whitelist-driven ORDER BY. Always map to a real SQLAlchemy column;
# never pass `sort_by` straight to .order_by() (SQL-injection surface).
# Joins are added lazily so non-join sorts don't pay the join cost.
_SORT_MAP_DIRECT = {
"hostname": Asset.hostname,
"ip_address": Asset.ip_address,
"operating_system": Asset.operating_system,
"status": Asset.status,
"last_scan": Asset.last_scan,
"network_exposure_score": Asset.network_exposure_score,
"high_value_score": Asset.high_value_score,
}
sort_dir = desc if sort_order == "desc" else asc
if sort_by in _SORT_MAP_DIRECT:
col = _SORT_MAP_DIRECT[sort_by]
if sort_by == "last_scan":
# NULL-safe on BOTH directions — never-scanned assets land at
# the bottom regardless of asc/desc (PG: NULLS LAST is independent
# of the primary direction).
query = query.order_by(nulls_last(sort_dir(col)))
elif sort_by == "hostname":
# Case-insensitive alpha sort — a host called "alpine" must
# not outrank "Webserver" because the literal `A` > `W`.
query = query.order_by(nulls_last(sort_dir(func.lower(col))))
else:
# NULLs always last (both directions) — sorting Exposure/Risk
# desc must surface the real high scores first, not the empty
# ("—") rows. (Observed: desc showed blanks before real values.)
query = query.order_by(nulls_last(sort_dir(col)))
elif sort_by == "policy_name":
query = query.outerjoin(Policy, Asset.policy_id == Policy.id).order_by(sort_dir(Policy.name))
elif sort_by == "assigned_user_name":
query = query.outerjoin(User, Asset.assigned_user_id == User.id).order_by(sort_dir(User.username))
else:
# Unknown / unsupported sort_by — fall back to hostname asc.
query = query.order_by(asc(Asset.hostname))
# Total (pre-pagination) so the UI can page; returned as a header to
# keep the response body a plain array (other consumers expect a list).
total_count = query.order_by(None).count()
response.headers["X-Total-Count"] = str(total_count)
response.headers["Access-Control-Expose-Headers"] = "X-Total-Count"
assets = query.offset(offset).limit(limit).all()
# Vulnerability-Counts + assigned user info.
#
# ONE grouped query, not one COUNT per asset. This loop used to issue a
# separate `SELECT count(*) FROM vulnerabilities WHERE asset_id = ?` per
# row, so a page of 1000 assets meant 1000 round trips and the request
# took the better part of a minute (observed in the backend log:
# GET /api/v1/assets?limit=1000 dominating the page load).
counts = {}
if assets:
counts = dict(
db.query(Vulnerability.asset_id, func.count(Vulnerability.id))
.filter(Vulnerability.asset_id.in_([a.id for a in assets]),
Vulnerability.status == VulnerabilityStatus.open)
.group_by(Vulnerability.asset_id).all())
# Docker host ↔ image links, both directions in one query.
image_counts, runs_on = {}, {}
if assets:
ids = [a.id for a in assets]
for image_id, host_id, host_name in (
db.query(ContainerPlacement.image_asset_id, Asset.id, Asset.hostname)
.join(Asset, Asset.id == ContainerPlacement.host_asset_id)
.filter(ContainerPlacement.host_asset_id.in_(ids)
| ContainerPlacement.image_asset_id.in_(ids))
.order_by(func.lower(Asset.hostname))):
image_counts[host_id] = image_counts.get(host_id, 0) + 1
runs_on.setdefault(image_id, []).append({"id": host_id, "hostname": host_name})
for asset in assets:
asset.vulnerability_count = counts.get(asset.id, 0)
asset.container_image_count = image_counts.get(asset.id, 0)
asset.runs_on = runs_on.get(asset.id, [])
asset.assigned_user_name = asset.assigned_user.username if asset.assigned_user else None
asset.policy_name = asset.policy.name if asset.policy else None
return assets
def _csv_json_list(raw):
"""Parse a TEXT JSON-list column into a list (best-effort, non-fatal)."""
if not raw:
return []
try:
import json as _json
parsed = _json.loads(raw)
return parsed if isinstance(parsed, list) else []
except (ValueError, TypeError):
return []
def _fmt_exposed_services(services):
"""exposed_services JSON -> 'service:port/proto (risk N)' joined by ' | '."""
parts = []
for s in services:
if not isinstance(s, dict):
continue
seg = s.get("service") or ""
port = s.get("port")
proto = s.get("proto") or ""
risk = s.get("risk")
if port is not None:
seg = f"{seg}:{port}" if seg else str(port)
if proto:
seg += f"/{proto}"
if risk is not None:
seg += f" (risk {risk})"
if seg:
parts.append(seg)
return " | ".join(parts)
def _fmt_risk_dimensions(dims):
"""risk_dimensions JSON -> 'label (weight)' joined by ' | '."""
parts = []
for d in dims:
if not isinstance(d, dict):
continue
label = d.get("label") or d.get("role") or ""
weight = d.get("weight")
if weight is not None:
label = f"{label} ({weight})"
if label:
parts.append(label)
return " | ".join(parts)
@router.get("/names")
def list_asset_names(
include_inactive: bool = Query(False),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Every asset as {id, hostname} — for host pickers. No cap, no counts.
The host dropdown on the Vulnerabilities page used to fetch the full asset
list at `limit=1000`, which is the endpoint's hard maximum. With 1194
assets in the estate the list simply stopped at the letter "M" and the
remaining 194 hosts could not be selected at all — a silent cap on a
picker, which is the worst place for one.
Fetching the full rows was also far more than a picker needs: a whole
AssetResponse each, with the open-vulnerability count behind it. Two
columns, one query, ordered the same way the table is (case-insensitively,
so "alpine" does not outrank "Webserver").
Declared BEFORE the `/{asset_id}` route on purpose — FastAPI matches in
declaration order, and "names" would otherwise be read as an asset id.
"""
q = _apply_asset_filters(db.query(Asset.id, Asset.hostname), db,
include_inactive=include_inactive)
rows = q.order_by(func.lower(Asset.hostname)).all()
return [{"id": r.id, "hostname": r.hostname} for r in rows]
@router.get("/export")
def export_assets_csv(
status: Optional[AssetStatus] = Query(None),
source: Optional[AssetSource] = Query(None),
search: Optional[str] = Query(None, description="Suche in Hostname, IP"),
include_inactive: bool = Query(False, description="Also include DECOMMISSIONED assets (INACTIVE are shown by default)"),
include_containers: bool = Query(True, description="false hides container images (unless source=CONTAINER)"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Stream ALL matching assets as CSV - full export, no pagination cap.
Same filters as GET /assets (search, source, status/include_inactive), but
returns every row at once with the complete field set. Streamed via
StreamingResponse + yield_per so large inventories do not sit in memory.
"""
query = db.query(Asset)
query = _apply_asset_filters(query, db, status=status, source=source,
search=search, include_inactive=include_inactive,
include_containers=include_containers)
# Eager-load the related columns the CSV reads (policy / assignee / groups)
# so a full export does not fall back to a lazy SELECT per row under
# yield_per. selectinload is yield_per-safe (joinedload is not).
query = query.options(
selectinload(Asset.policy),
selectinload(Asset.assigned_user),
selectinload(Asset.groups),
)
query = query.order_by(asc(func.lower(Asset.hostname)))
# One grouped query for the open-vulnerability counts (avoids an N+1
# per-row COUNT over the whole export).
vuln_counts = dict(
db.query(Vulnerability.asset_id, func.count(Vulnerability.id))
.filter(Vulnerability.status == VulnerabilityStatus.open)
.group_by(Vulnerability.asset_id)
.all()
)
def _fmt_dt(value):
return value.isoformat() if value else ""
def row_iter():
buf = io.StringIO()
writer = csv.writer(buf, quoting=csv.QUOTE_MINIMAL)
writer.writerow([
"ID", "Hostname", "IP Address", "OS", "OS Version", "Kernel Version",
"VMware Build", "Status", "Source", "Criticality",
"Compliance Frameworks", "Last Scan", "Last Seen",
"Policy", "Assigned User", "Groups", "Open Vulnerabilities",
"Network Exposure Score", "Exposed Services", "Exposure Updated At",
"High Value Score", "Risk Dimensions", "Risk Dimensions Updated At",
"Description", "Location", "Owner",
"Wazuh Agent ID", "Nessus Host UUID", "Intune Device ID",
"Defender Machine ID", "AAD Device ID", "VMware UUID",
"Created At", "Updated At",
"Intune Serial", "IGEL Unit ID", "IGEL Serial",
"Netdisco Device IP", "Netdisco Serial", "VMware Serial", "XenServer Host UUID",
])
yield buf.getvalue()
buf.seek(0); buf.truncate(0)
for asset in query.yield_per(500):
writer.writerow([
asset.id,
asset.hostname or "",
asset.ip_address or "",
asset.operating_system or "",
asset.os_version or "",
asset.kernel_version or "",
asset.vmware_build or "",
asset.status.value if hasattr(asset.status, "value") else str(asset.status),
asset.source.value if hasattr(asset.source, "value") else str(asset.source),
asset.criticality or "",
" | ".join(str(x) for x in _csv_json_list(asset.compliance_frameworks)),
_fmt_dt(asset.last_scan),
_fmt_dt(asset.last_seen),
asset.policy.name if asset.policy else "",
asset.assigned_user.username if asset.assigned_user else "",
" | ".join(g.name for g in asset.groups) if asset.groups else "",
vuln_counts.get(asset.id, 0),
asset.network_exposure_score if asset.network_exposure_score is not None else "",
_fmt_exposed_services(_csv_json_list(asset.exposed_services)),
_fmt_dt(asset.exposure_updated_at),
asset.high_value_score if asset.high_value_score is not None else "",
_fmt_risk_dimensions(_csv_json_list(asset.risk_dimensions)),
_fmt_dt(asset.risk_dimensions_updated_at),
asset.description or "",
asset.location or "",
asset.owner or "",
asset.wazuh_agent_id or "",
asset.nessus_host_uuid or "",
asset.intune_device_id or "",
asset.defender_machine_id or "",
asset.aad_device_id or "",
asset.vmware_uuid or "",
_fmt_dt(asset.created_at),
_fmt_dt(asset.updated_at),
asset.intune_serial or "",
asset.igel_unit_id or "",
asset.igel_serial or "",
asset.netdisco_device_ip or "",
asset.netdisco_serial or "",
asset.vmware_serial or "",
asset.xen_host_uuid or "",
])
yield buf.getvalue()
buf.seek(0); buf.truncate(0)
fname = f"assets_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
return StreamingResponse(
row_iter(),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
)
@router.get("/{asset_id}", response_model=AssetResponse)
def get_asset_detail(
asset_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Asset-Details"""
asset = db.query(Asset).filter(Asset.id == asset_id).first()
if not asset:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Asset not found"
)
vuln_count = db.query(Vulnerability).filter(
Vulnerability.asset_id == asset_id,
Vulnerability.status == VulnerabilityStatus.open
).count()
asset.vulnerability_count = vuln_count
return asset
@router.post("", status_code=status.HTTP_201_CREATED, response_model=AssetResponse)
def create_asset(
asset_data: AssetCreateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor)
):
"""
Erstellt manuelles Asset (für Systeme ohne Wazuh-Agent)
"""
# Kein Duplikat-Check auf Hostname oder IP-Adresse: beides sind keine
# Identifikatoren. Ab Werk vergebene Namen ("RackPDU") stehen mehrfach im
# Bestand; Gastnetze, VPN-Pools, VLAN-Segmente und recycelte DHCP-Leases
# fuehren dieselbe Adresse mehrfach. Der Operator konnte die betroffenen
# Systeme sonst gar nicht erst anlegen. Was ein Asset identifiziert, steht
# in services/asset_matching.py.
# Erstelle Asset
asset = Asset(
**asset_data.model_dump(),
source=AssetSource.MANUAL,
status=AssetStatus.ACTIVE
)
db.add(asset)
db.commit()
db.refresh(asset)
# Audit-Log
audit_log = AuditLog(
user_id=current_user.id,
event_type=AuditEventType.ASSET_CREATED,
event_description=f"Manual asset created: {asset.hostname}",
resource_type="asset",
resource_id=str(asset.id),
timestamp=datetime.now()
)
db.add(audit_log)
db.commit()
asset.vulnerability_count = 0
return asset
@router.put("/{asset_id}", response_model=AssetResponse)
def update_asset(
asset_id: int,
update_data: AssetUpdateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor)
):
"""Aktualisiert Asset-Daten"""
asset = db.query(Asset).filter(Asset.id == asset_id).first()
if not asset:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Asset not found"
)
# Update nur gesetzte Felder
import json as _json
# Lifecycle transitions do not go through setattr: apply_status() writes the
# dedicated ASSET_DEACTIVATED / ASSET_REACTIVATED audit entry, so a manual
# decommission reads the same way in the trail as a sync-driven one.
status_change: Optional[AssetStatus] = None
changes = update_data.model_dump(exclude_unset=True)
before = _asset_state(asset, [f for f in changes if f != "status"])
for field, value in changes.items():
if field == "status":
if value is not None:
status_change = value if isinstance(value, AssetStatus) else AssetStatus(value)
continue
if field == "compliance_frameworks":
# Stored as TEXT JSON list
value = _json.dumps(value or []) if value is not None else None
if field == "criticality" and value is not None:
value = value.lower()
if value not in ("low", "normal", "high", "critical"):
raise HTTPException(400, "criticality must be one of: low, normal, high, critical")
setattr(asset, field, value)
if status_change is not None:
from app.services.asset_lifecycle import apply_status
# force=True: the operator-final guard exists to stop a sync from
# reviving a retired asset, not the operator who retired it.
apply_status(
db, asset, status_change,
f"manual status change by {current_user.username}",
force=True,
user_id=current_user.id,
)
_audit_asset_update(db, current_user, asset, before)
db.commit()
db.refresh(asset)
vuln_count = db.query(Vulnerability).filter(
Vulnerability.asset_id == asset_id,
Vulnerability.status == VulnerabilityStatus.open
).count()
asset.vulnerability_count = vuln_count
return asset
@router.delete("/{asset_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_asset(
asset_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin)
):
"""
Löscht Asset (Admin-only)
ACHTUNG: Löscht auch alle zugehörigen Vulnerabilities (CASCADE)
"""
asset = db.query(Asset).filter(Asset.id == asset_id).first()
if not asset:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Asset not found"
)
# Revisionssicher audit — snapshot the full asset + cascade impact
# BEFORE deletion so the trail says WHO deleted WHAT (and how much
# history it took with it), analog to the CVE status-change entries.
import json as _json
vuln_count = db.query(Vulnerability).filter(
Vulnerability.asset_id == asset.id
).count()
snapshot = {
"hostname": asset.hostname,
"ip_address": asset.ip_address,
"operating_system": asset.operating_system,
"source": asset.source.value if hasattr(asset.source, "value") else str(asset.source),
"status": asset.status.value if hasattr(asset.status, "value") else str(asset.status),
"wazuh_agent_id": asset.wazuh_agent_id,
"vulnerabilities_cascade_deleted": vuln_count,
"deleted_by": current_user.username,
}
audit_log = AuditLog(
user_id=current_user.id,
event_type=AuditEventType.ASSET_DELETED,
event_description=(
f"Asset '{asset.hostname}' ({asset.ip_address or 'no-ip'}) deleted by "
f"{current_user.username} — {vuln_count} vulnerabilities cascade-removed"
)[:500],
resource_type="asset",
resource_id=str(asset.id),
old_value=_json.dumps(snapshot),
new_value="deleted",
timestamp=datetime.now()
)
db.add(audit_log)
db.commit() # persist audit first so it survives even if delete fails
db.delete(asset)
db.commit()
return None
@router.patch("/{asset_id}/assign")
def assign_asset(
asset_id: int,
assign_data: AssetAssignRequest,
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor)
):
"""Assigns a user to an asset"""
asset = db.query(Asset).filter(Asset.id == asset_id).first()
if not asset:
raise HTTPException(status_code=404, detail="Asset not found")
if assign_data.user_id is not None:
user = db.query(User).filter(User.id == assign_data.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
before = _assignment(asset)
asset.assigned_user_id = assign_data.user_id
# Handle single group assignment (legacy support)
from app.models.group import Group
if assign_data.group_id is None:
# Clear groups when explicitly set to null
asset.groups = []
else:
group = db.query(Group).filter(Group.id == assign_data.group_id).first()
if group:
asset.groups = [group]
db.flush()
db.expire(asset, ["assigned_user"]) # reload it from the new id
record_change(db, current_user.id, AuditEventType.ASSET_UPDATED, "asset", asset.id,
f"Asset updated: {asset.hostname}", before, _assignment(asset))
db.commit()
db.refresh(asset)
vuln_count = db.query(Vulnerability).filter(
Vulnerability.asset_id == asset_id,
Vulnerability.status == VulnerabilityStatus.open
).count()
asset.vulnerability_count = vuln_count
asset.assigned_user_name = asset.assigned_user.username if asset.assigned_user else None
return asset
@router.get("/{asset_id}/vulnerabilities")
def get_asset_vulnerabilities(
asset_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Alle Vulnerabilities eines Assets"""
asset = db.query(Asset).filter(Asset.id == asset_id).first()
if not asset:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Asset not found"
)
vulns = db.query(Vulnerability).filter(
Vulnerability.asset_id == asset_id
).all()
return [{
"id": v.id,
"cve_id": v.cve_id,
"severity": v.severity,
"status": v.status,
"cvss_score": v.cvss_score,
"package_name": v.package_name,
"detected_at": v.detected_at
} for v in vulns]
# --- Wazuh Sync Endpoint ---
import json
from app.integrations.wazuh_client import WazuhClient, WazuhAPIError
from app.models.setting import Setting
from app.services.asset_matching import like_literal, pick_one
@router.post("/sync_wazuh", response_model=dict, dependencies=[audited("Wazuh asset sync")])
def sync_wazuh_assets(
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor)
):
"""
Synchronizes assets from Wazuh Manager.
fetch active agents -> update/create local assets.
Recorded in the sync-run ledger as the "assets" phase; when the
scheduled scan calls this, the record merges into that run.
"""
from app.services.sync_run_service import record_sync_run
with record_sync_run("wazuh") as run:
run.stats.setdefault("phase", "assets")
stats = _sync_wazuh_assets(db)
run.stats.update(stats)
return stats
def _sync_wazuh_assets(db: Session) -> dict:
# 1. Get Wazuh Config (transparently decrypted)
from app.auth.setting_crypto import read_setting_value
raw_wazuh = read_setting_value(db, "wazuh_config")
if not raw_wazuh:
raise HTTPException(status_code=400, detail="Wazuh configuration not found.")
try:
config = json.loads(raw_wazuh)
api_url = config.get("api_url")
username = config.get("username")
password = config.get("password")
except json.JSONDecodeError:
raise HTTPException(status_code=500, detail="Invalid Wazuh configuration format.")
if not all([api_url, username, password]):
raise HTTPException(status_code=400, detail="Incomplete Wazuh configuration.")
indexer_url = config.get("indexer_url")
indexer_username = config.get("indexer_username")
indexer_password = config.get("indexer_password")
stats = {"created": 0, "updated": 0, "total_from_wazuh": 0, "assets_inactivated": 0, "assets_reactivated": 0}
seen_wazuh_agent_ids: set = set() # for sync-driven asset reconciliation
# 2. Fetch Agents from Wazuh
try:
with WazuhClient(
base_url=api_url,
username=username,
password=password,
indexer_url=indexer_url,
indexer_username=indexer_username,
indexer_password=indexer_password,
verify_ssl=bool(config.get("verify_ssl", True))
) as client:
# ALL agents, disconnected ones included. Fetching active-only was
# a data-loss bug, not a filter: the reconcile below inactivates
# every agent id this sync does not report, and an INACTIVE asset
# is hidden from every CVE view — so a laptop switched off over
# the weekend lost its findings within one sync, while Wazuh
# itself kept showing them. How long an offline host stays visible
# is now the grace window's decision (asset_inactive_after_days),
# not the TCP connection's.
from app.services.asset_lifecycle import (
advance_last_seen,
apply_status,
created,
parse_source_timestamp,
status_for_last_seen,
threshold_days,
)
grace_days = threshold_days(db)
agents = []
while True:
page = client.get_agents(limit=500, offset=len(agents))
agents.extend(page)
# ponytail: 20k-agent ceiling guards against a server that
# ignores `offset` and would otherwise loop forever. Raise it
# if a deployment ever gets that big.
if len(page) < 500 or len(agents) >= 20000:
break
stats["total_from_wazuh"] = len(agents)
stats["never_connected_skipped"] = 0
# Every agent id this run reports — the discriminator for the
# hostname fallback below.
live_agent_ids = {a.get("id") for a in agents if a.get("id")}
for agent in agents:
agent_id = agent.get("id")
if agent_id == "000": continue # Skip manager itself
# Registered but never checked in: no inventory, no keepalive,
# nothing to scan. Left out of seen_ids on purpose so a stale
# asset behind such an id still ages out normally.
last_keepalive = parse_source_timestamp(agent.get("lastKeepAlive"))
if last_keepalive is None and agent.get("status") == "never_connected":
stats["never_connected_skipped"] += 1
continue
seen_wazuh_agent_ids.add(agent_id)
hostname = agent.get("name")
ip = agent.get("ip")
os_info = agent.get("os", {})
os_name = os_info.get("name")
os_version = os_info.get("version")
# Check if exists by Wazuh ID
asset = db.query(Asset).filter(Asset.wazuh_agent_id == agent_id).first()
if not asset and hostname:
# Check by hostname to avoid duplicates if manually added
# …but only if the name is free. This stage is what keeps
# a re-registered agent on its asset: authd's <force>
# replaces an agent of the same name (default since 4.3),
# the host comes back under a NEW id, and the name is the
# only way back to its history. A replaced agent is gone
# from the list, so its id is not live — while two agents
# that are BOTH in this run are two hosts, and adopting
# the first one's asset would move the second one's
# findings onto it and take the id away (two APC PDUs, both
# named "RackPDU" from the factory, is all it takes).
# Several free same-named assets: the address decides, or
# the agent gets its own (pick_one).
# Case-insensitive like every other source: Intune listed
# "PC-LAB01", the agent reports "pc-lab01", and an exact
# compare forked the device into two assets.
free = [a for a in db.query(Asset)
.filter(Asset.hostname.ilike(like_literal(hostname),
escape="\\")).order_by(Asset.id)
if not (a.wazuh_agent_id and a.wazuh_agent_id in live_agent_ids)]
asset = pick_one(free, ip, hostname)
if asset:
# Update
asset.wazuh_agent_id = agent_id
asset.ip_address = ip or asset.ip_address
asset.operating_system = os_name or asset.operating_system
asset.os_version = os_version or asset.os_version
# Wazuh's own clock, not ours: `datetime.now()` recorded
# when TrueVuln ran a sync, which made every registered
# agent look permanently fresh and left no usable stamp to
# measure a grace window against.
# Forward only: another source (Intune) may have seen the
# host after the agent died.
advance_last_seen(asset, last_keepalive or datetime.now(), "wazuh")
asset.wazuh_last_seen = last_keepalive
asset.source = AssetSource.WAZUH
# Audited, not a bare assignment: this line revives an
# asset the reconcile deactivated one sync earlier (agent
# merely disconnected, keepalive still inside the window),
# and a silent revival left the audit log ending on a
# deactivation for an asset the UI showed as ACTIVE.
apply_status(
db, asset,
status_for_last_seen(asset.last_seen, grace_days),
f"Wazuh agent sync (last keepalive {last_keepalive}, "
f"last seen {asset.last_seen} by {asset.last_seen_source}, "
f"grace window {grace_days}d)",
)
stats["updated"] += 1
else:
# Create
# Default Group Logic
default_group_setting = db.query(Setting).filter(Setting.key == "default_group_id").first()
group_id = int(default_group_setting.value) if default_group_setting and default_group_setting.value else None
first_seen = last_keepalive or datetime.now()
new_asset = Asset(
hostname=hostname,
ip_address=ip,
wazuh_agent_id=agent_id,
operating_system=os_name,
os_version=os_version,
source=AssetSource.WAZUH,
# A host discovered for the first time but offline for
# months arrives INACTIVE — it should not import as a
# live system just because we happened to meet it now.
status=status_for_last_seen(first_seen, grace_days),
last_seen=first_seen,
last_seen_source="wazuh",
wazuh_last_seen=last_keepalive,
)
# Handle default group
if group_id:
from app.models.group import Group
default_group = db.query(Group).filter(Group.id == group_id).first()
if default_group:
new_asset.groups.append(default_group)
db.add(new_asset)
created(db, new_asset, "Wazuh")
stats["created"] += 1
# The manager nodes themselves. They run no agent — they
# inventory other hosts and nobody inventories them — so they were
# never assets and their own CVEs (54 at NVD, several of them RCE
# on the server) went unseen. The API reports its own version
# without an agent; no extra credentials, same session.
try:
# An all-in-one install reports no IP for its single node —
# there is no cluster to address. The API URL points at exactly
# that machine, so it is the honest fallback rather than
# leaving the column empty.
api_host = None
try:
import ipaddress
import socket
from urllib.parse import urlparse
host = urlparse(config.get("api_url") or "").hostname
if host:
try:
ipaddress.ip_address(host)
api_host = host # already an address
except ValueError:
# A hostname in a column called IP ADDRESS breaks
# sorting and filtering, so resolve it. If DNS has
# no answer the field stays empty rather than
# holding something that is not an address.
api_host = socket.gethostbyname(host)
except Exception as e:
logger.debug("could not resolve the Wazuh API host: %s", e)
api_host = None
nodes = client.get_cluster_nodes()
for node in nodes:
nid = f"node:{node['name']}"[:64]
if not node.get("ip") and len(nodes) == 1:
node["ip"] = api_host
seen_wazuh_agent_ids.add(nid)
a = db.query(Asset).filter(Asset.wazuh_agent_id == nid).first()
if not a:
# Never an agent's asset: node names ship as "node01",
# and taking it overwrote a real agent's id.
# Several case variants of the name: the address decides.
a = pick_one(db.query(Asset).filter(
Asset.hostname.ilike(like_literal(node["name"]), escape="\\"),
Asset.wazuh_agent_id.is_(None)).order_by(Asset.id).all(),
node.get("ip"), node["name"])
label = f"Wazuh {node.get('type') or 'node'} node"
if a:
a.wazuh_agent_id = nid
a.operating_system = label
a.os_version = node.get("version") or a.os_version
a.ip_address = node.get("ip") or a.ip_address
a.last_seen = datetime.now()
a.last_seen_source = "wazuh"
apply_status(db, a, AssetStatus.ACTIVE,
"Wazuh manager node reported by the cluster sync")
stats["updated"] += 1
else:
a = Asset(hostname=node["name"], ip_address=node.get("ip"),
wazuh_agent_id=nid, operating_system=label,
os_version=node.get("version"),
source=AssetSource.WAZUH,
status=AssetStatus.ACTIVE,
last_seen=datetime.now(),
last_seen_source="wazuh")
db.add(a)
created(db, a, "Wazuh")
stats["created"] += 1
# Flush inside the guard: the agent loop's commit comes
# after this block, so anything rejected by the database here
# would surface there instead and take the whole sync down
# with a 500 — which is exactly what a too-long node id did.
db.flush()
except Exception as e:
db.rollback()
logger.warning("wazuh cluster-node sync failed: %s", e)
db.commit()
# Sync-driven reconciliation: any ACTIVE WAZUH-sourced asset whose
# wazuh_agent_id was not returned by this sync (deleted on the
# Wazuh server, or no longer in any agent list) is flipped to
# INACTIVE. Vice-versa for re-activation. Audit-logged.
from app.services.asset_lifecycle import reconcile_missing_from_sync
recon = reconcile_missing_from_sync(
db,
source=AssetSource.WAZUH,
seen_ids=seen_wazuh_agent_ids,
id_field=Asset.wazuh_agent_id,
reason="not in latest Wazuh agents list",
)
stats["assets_inactivated"] = recon["inactivated"]
stats["assets_reactivated"] = recon["reactivated"]
from app.services.asset_lifecycle import unlink_gone_wazuh_agents
stats["wazuh_unlinked"] = unlink_gone_wazuh_agents(db, seen_wazuh_agent_ids)
if recon["inactivated"] or recon["reactivated"] or stats["wazuh_unlinked"]:
db.commit()
except Exception as e:
logger.error(f"Wazuh Sync Failed: {e}")
# The real reason goes to the caller (editors only): "check the
# server logs" left an operator with a failing Wazuh API for a day.
raise HTTPException(status_code=502, detail=f"Wazuh sync failed: {e}")
return stats
@router.post("/{asset_id}/rescan", response_model=dict)
def rescan_asset(
asset_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(RequireEditor)
):
"""
Triggert einen gezielten Rescan eines einzelnen Assets via Wazuh.
"""
from app.routers.vulnerabilities import sync_agent_vulnerabilities
asset = db.query(Asset).filter(Asset.id == asset_id).first()
if not asset:
raise HTTPException(status_code=404, detail="Asset not found")
if asset.source != AssetSource.WAZUH or not asset.wazuh_agent_id:
raise HTTPException(status_code=400, detail="Rescan only possible for Wazuh-managed assets")
# 1. Wazuh Config holen (transparently decrypted)
from app.auth.setting_crypto import read_setting_value
raw_wazuh = read_setting_value(db, "wazuh_config")
if not raw_wazuh:
raise HTTPException(status_code=400, detail="Wazuh configuration missing")
config = json.loads(raw_wazuh)
wazuh = WazuhClient(
base_url=config.get("api_url"),
username=config.get("username"),
password=config.get("password"),
indexer_url=config.get("indexer_url"),
indexer_username=config.get("indexer_username"),
indexer_password=config.get("indexer_password"),
verify_ssl=bool(config.get("verify_ssl", True))
)
try:
# Sync Vulnerabilities for this specific agent
sync_agent_vulnerabilities(db, wazuh, asset.wazuh_agent_id, asset)
# Audit Log
audit = AuditLog(
user_id=current_user.id,
event_type=AuditEventType.SCAN_TRIGGERED,
event_description=f"Manual rescan started for asset {asset.hostname} (ID: {asset.wazuh_agent_id})",
resource_type="asset",
resource_id=str(asset_id),
timestamp=datetime.now()
)
db.add(audit)
db.commit()
return {"message": f"Rescan for {asset.hostname} successfully completed.", "last_scan": asset.last_scan}
except Exception as e:
logger.error(f"Rescan error: {e}")
raise HTTPException(status_code=500, detail=f"Rescan failed: {str(e)}")