Files
vulncheck/app/services/compliance_service.py
T
vulncheck a5c5361cea fix: wazuh sync scan-row + ssvc badges + compliance disconnected diag
Three colleague-reported issues addressed in one commit.

A) 'Sync Data (Wazuh)' button left no entry in /scans history
   while the Nessus button did (since commit 4bca41e). Mirrored the
   pattern: run_wazuh_vulnerability_sync now opens a per-agent Scan
   row (scan_type=WAZUH, status RUNNING→COMPLETED/FAILED) before
   fetching vulns and closes it after the source-aware backfill,
   recording vulnerabilities_found = len(active_cves). Skip-backfill
   branch records 0 + error_message explaining why.

B) Vulns-list SSVC badge only fired for exploitation_status != 'none'.
   ~99% of Vulnrichment-curated CVEs have exploitation_status='none'
   (CISA flags 'no known exploitation' for most), so colleague's
   495 SSVC-source-pinned CVEs showed zero badges. Added two more
   pills surfacing the actually-interesting SSVC dimensions:
     TI-TOTAL  ssvc_technical_impact='total' (attacker → full takeover)
     AUTO      ssvc_automatable='yes' (reliable mass exploitation)
   These render alongside the existing POC/ACTIVE/WIDESPREAD pill.

C) Compliance refresh appears to ignore disconnected agents — likely
   not a code filter but Wazuh returning empty /sca/{agent_id}
   responses for offline agents. Added per-asset logging when
   policies_synced=0 and an assets_no_data counter in the stats so
   the operator can see how many agents Wazuh actually has SCA data
   for. No code change to the filter — disconnected agents are still
   queried, just transparently reported as data-less if Wazuh has
   nothing on them.
2026-05-20 15:13:07 +02:00

285 lines
9.4 KiB
Python

"""
Compliance service — Wazuh SCA → compliance_results / compliance_checks.
Two refresh strategies:
- ``refresh_asset_compliance(db, asset_id)`` — one asset, all policies
- ``refresh_all_compliance(db)`` — every Wazuh-linked asset
The per-asset call upserts one row per (asset, policy) and overwrites
the score + counts in place. Old policies that disappear from Wazuh
(e.g. CIS benchmark removed) stay in the table; admins can delete via
the API if they care.
ComplianceCheck rows are NOT populated by these refreshes — that's an
on-demand operation via ``fetch_policy_checks(db, asset_id, policy_id)``
which the deep-dive endpoint calls.
"""
from __future__ import annotations
import json
import logging
from datetime import datetime
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from app.integrations.wazuh_client import WazuhClient, WazuhAPIError
from app.models.asset import Asset
from app.models.compliance import ComplianceResult, ComplianceCheck
logger = logging.getLogger(__name__)
def _load_wazuh_client(db: Session) -> Optional[WazuhClient]:
"""Build a WazuhClient from the persisted wazuh_config setting.
Returns None when no config is set — caller treats that as no-op."""
from app.auth.setting_crypto import read_setting_value
raw = read_setting_value(db, "wazuh_config")
if not raw:
return None
try:
cfg = json.loads(raw)
except (json.JSONDecodeError, TypeError):
logger.warning("compliance: wazuh_config is not valid JSON")
return None
return 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=cfg.get("verify_ssl", False),
)
def _parse_end_scan(raw: Any) -> Optional[datetime]:
"""Wazuh end_scan is an ISO 8601 string or epoch — defensive parse."""
if not raw:
return None
if isinstance(raw, (int, float)):
try:
return datetime.utcfromtimestamp(raw)
except (OverflowError, OSError, ValueError):
return None
if isinstance(raw, str):
# Wazuh sometimes ships with trailing 'Z' or microseconds — try a
# couple of formats before giving up.
for fmt in (
"%Y-%m-%dT%H:%M:%SZ",
"%Y-%m-%dT%H:%M:%S.%fZ",
"%Y-%m-%dT%H:%M:%S",
):
try:
return datetime.strptime(raw, fmt)
except ValueError:
continue
return None
def _upsert_result(
db: Session,
asset_id: int,
policy: Dict[str, Any],
) -> ComplianceResult:
"""Insert or overwrite the per-policy summary row."""
policy_id = str(policy.get("policy_id") or policy.get("id") or "").strip()
if not policy_id:
raise ValueError("policy entry missing policy_id")
row = (
db.query(ComplianceResult)
.filter(
ComplianceResult.asset_id == asset_id,
ComplianceResult.policy_id == policy_id,
)
.first()
)
if row is None:
row = ComplianceResult(asset_id=asset_id, policy_id=policy_id)
db.add(row)
row.policy_name = (policy.get("name") or policy.get("policy") or policy_id)[:255]
row.policy_description = policy.get("description")
# Wazuh keys are slightly inconsistent across versions — try both spellings.
row.pass_count = int(policy.get("pass") or policy.get("passed") or 0)
row.fail_count = int(policy.get("fail") or policy.get("failed") or 0)
row.not_applicable_count = int(
policy.get("invalid") or policy.get("not_applicable") or 0
)
row.total_checks = int(
policy.get("total_checks")
or (row.pass_count + row.fail_count + row.not_applicable_count)
)
row.end_scan = _parse_end_scan(policy.get("end_scan"))
row.last_synced = datetime.utcnow()
row.recalc_score()
return row
def refresh_asset_compliance(
db: Session,
asset_id: int,
client: Optional[WazuhClient] = None,
) -> Dict[str, Any]:
"""
Refresh all SCA policy results for one asset.
Returns a stats dict: {asset_id, policies_synced, errors[]}.
"""
asset = db.query(Asset).filter(Asset.id == asset_id).first()
if not asset:
raise ValueError(f"asset {asset_id} not found")
if not asset.wazuh_agent_id:
# Nessus-only / manual asset — SCA not available.
return {
"asset_id": asset_id,
"policies_synced": 0,
"errors": ["asset has no wazuh_agent_id; SCA only works via Wazuh"],
}
owned_client = False
if client is None:
client = _load_wazuh_client(db)
owned_client = True
if client is None:
return {
"asset_id": asset_id,
"policies_synced": 0,
"errors": ["wazuh_config not set"],
}
stats: Dict[str, Any] = {"asset_id": asset_id, "policies_synced": 0, "errors": []}
try:
policies = client.get_sca_policies(asset.wazuh_agent_id)
for p in policies:
try:
_upsert_result(db, asset_id, p)
stats["policies_synced"] += 1
except Exception as e:
stats["errors"].append(f"policy {p.get('policy_id')}: {e}")
logger.warning(
"compliance: upsert failed for asset=%s policy=%s: %s",
asset_id, p.get("policy_id"), e,
)
db.commit()
except WazuhAPIError as e:
stats["errors"].append(f"wazuh API: {e}")
db.rollback()
except Exception as e:
logger.exception("compliance refresh failed for asset=%s", asset_id)
stats["errors"].append(str(e))
db.rollback()
finally:
if owned_client:
try:
client.close()
except Exception:
pass
return stats
def refresh_all_compliance(db: Session) -> Dict[str, Any]:
"""Loop over every Wazuh-linked asset and refresh its SCA results."""
client = _load_wazuh_client(db)
if client is None:
return {
"assets_synced": 0,
"policies_synced": 0,
"errors": ["wazuh_config not set"],
}
# No status filter — Wazuh keeps SCA results for disconnected agents
# in the indexer too, so we let the API decide what's available.
# An asset that returns 0 policies is logged but not treated as
# an error.
assets = db.query(Asset).filter(Asset.wazuh_agent_id.isnot(None)).all()
overall = {
"assets_synced": 0,
"policies_synced": 0,
"assets_no_data": 0,
"errors": [],
}
try:
for asset in assets:
r = refresh_asset_compliance(db, asset.id, client=client)
overall["assets_synced"] += 1
overall["policies_synced"] += r["policies_synced"]
overall["errors"].extend(r["errors"])
if r["policies_synced"] == 0 and not r["errors"]:
overall["assets_no_data"] += 1
logger.info(
"compliance: asset %s (%s) returned 0 SCA policies — "
"agent likely disconnected or SCA module not enabled",
asset.id, asset.hostname,
)
finally:
try:
client.close()
except Exception:
pass
logger.info(
"compliance: refreshed %d assets, %d policy results, %d errors",
overall["assets_synced"], overall["policies_synced"], len(overall["errors"]),
)
return overall
def fetch_policy_checks(
db: Session,
asset_id: int,
policy_id: str,
) -> List[ComplianceCheck]:
"""
On-demand fetch + persist of per-check rows for one (asset, policy).
Drops any previously-cached rows for the policy and rewrites — cheaper
than a per-check upsert and the check_id is internal to a policy
version, no good cross-run key.
"""
asset = db.query(Asset).filter(Asset.id == asset_id).first()
if not asset or not asset.wazuh_agent_id:
return []
result_row = (
db.query(ComplianceResult)
.filter(
ComplianceResult.asset_id == asset_id,
ComplianceResult.policy_id == policy_id,
)
.first()
)
if result_row is None:
return []
client = _load_wazuh_client(db)
if client is None:
return []
try:
raw = client.get_sca_checks(asset.wazuh_agent_id, policy_id)
finally:
try:
client.close()
except Exception:
pass
# Wipe + rewrite — small enough table per policy that we don't need
# finer-grained sync.
db.query(ComplianceCheck).filter(ComplianceCheck.result_id == result_row.id).delete()
persisted: List[ComplianceCheck] = []
for c in raw:
chk = ComplianceCheck(
result_id=result_row.id,
check_id=str(c.get("id") or c.get("check_id") or "")[:64] or "?",
title=(c.get("title") or "")[:500] or None,
description=c.get("description"),
rationale=c.get("rationale"),
remediation=c.get("remediation"),
result=(c.get("result") or "?")[:20],
severity=(c.get("severity") or None) and str(c["severity"])[:20],
)
db.add(chk)
persisted.append(chk)
db.commit()
return persisted