""" 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