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.
285 lines
9.4 KiB
Python
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
|