Files
vulncheck/app/integrations/wazuh_client.py
T
vulncheck 0b71453c48 chore(release): drop personal attribution from comments and docstrings
Comments across the codebase credited one individual by role and, in places,
described that person's own machines: which SQL Server versions a host ran,
which devices were enrolled, what a particular dashboard showed, how many
findings sat open on which server. In a public repository that reads as a
profile of someone's unpatched estate.

The observations are why the code looks the way it does, so they stay. Every
CVE id, version, build number, count and date is preserved, as are the
verbatim quotes that motivated specific sort and filter rules — only the
attribution changes, to "field report", "observed", "a host". A local
variable in tests/test_autodesk_year.py was renamed for the same reason; its
value and every assertion around it are byte-identical.

PROJECT_OVERVIEW.md additionally loses a subtitle naming the kind of
organisation this was built for, and a support section pointing at an
internal team, both replaced with neutral wording.

Comments, docstrings and markdown prose only: 74 files, 200 lines, one-for-one
swaps. detect_changes reports 104 touched symbols and zero affected execution
flows, and all 55 test scripts pass. Nothing here needs re-testing.
2026-08-26 15:34:05 +02:00

864 lines
33 KiB
Python

"""
Wazuh API Client
Secure integration with Wazuh Manager for vulnerability and agent management.
Features:
- JWT-based authentication
- Automatic token refresh
- Retry logic with exponential backoff
- Request throttling (Rate Limiting)
- SSL verification
- Comprehensive error handling
Wazuh API Documentation:
https://documentation.wazuh.com/current/user-manual/api/reference.html
"""
import os
import logging
import time
from typing import Optional, Dict, List, Any
from datetime import datetime, timedelta
import httpx
from tenacity import (
retry,
stop_after_attempt,
wait_exponential,
retry_if_exception_type
)
logger = logging.getLogger(__name__)
class WazuhAPIError(Exception):
"""Base exception for Wazuh API errors"""
pass
class WazuhAuthenticationError(WazuhAPIError):
"""Authentication error"""
pass
class WazuhRateLimitError(WazuhAPIError):
"""Rate limit exceeded"""
pass
class WazuhClient:
"""
Wazuh API Client with automatic authentication and retry logic
Environment Variables:
WAZUH_API_URL: Wazuh Manager URL (e.g. https://wazuh.example.com:55000)
WAZUH_API_USERNAME: API Username
WAZUH_API_PASSWORD: API Password
WAZUH_VERIFY_SSL: Verify SSL certificate (default: true)
"""
def __init__(
self,
base_url: Optional[str] = None,
username: Optional[str] = None,
password: Optional[str] = None,
indexer_url: Optional[str] = None,
indexer_username: Optional[str] = None,
indexer_password: Optional[str] = None,
verify_ssl: bool = True
):
"""
Initializes the Wazuh API Client
Args:
base_url: Wazuh Manager URL (or from ENV: WAZUH_API_URL)
username: API Username (or from ENV: WAZUH_API_USERNAME)
password: API Password (or from ENV: WAZUH_API_PASSWORD)
verify_ssl: Verify SSL certificate (or from ENV: WAZUH_VERIFY_SSL)
"""
self.base_url = (base_url or os.getenv("WAZUH_API_URL", "")).rstrip("/")
self.username = username or os.getenv("WAZUH_API_USERNAME")
self.password = password or os.getenv("WAZUH_API_PASSWORD")
self.verify_ssl = verify_ssl if verify_ssl is not None else os.getenv("WAZUH_VERIFY_SSL", "true").lower() == "true"
# Indexer Connection (OpenSearch)
self.indexer_url = (indexer_url or os.getenv("WAZUH_INDEXER_URL", "")).rstrip("/")
self.indexer_username = indexer_username or os.getenv("WAZUH_INDEXER_USERNAME")
self.indexer_password = indexer_password or os.getenv("WAZUH_INDEXER_PASSWORD")
if not all([self.base_url, self.username, self.password]):
raise ValueError(
"Wazuh API Credentials missing. Please set WAZUH_API_URL, "
"WAZUH_API_USERNAME and WAZUH_API_PASSWORD."
)
self._token: Optional[str] = None
self._token_expires_at: Optional[datetime] = None
# HTTP Client mit Timeouts und Connection-Pooling
self.client = httpx.Client(
base_url=self.base_url,
verify=self.verify_ssl,
timeout=httpx.Timeout(30.0, connect=10.0),
limits=httpx.Limits(max_connections=10, max_keepalive_connections=5)
)
logger.info(f"Wazuh Client initialized: {self.base_url}")
def _authenticate(self) -> str:
"""
Authenticates against Wazuh API and retrieves JWT token
Returns:
JWT token as string
Raises:
WazuhAuthenticationError: If authentication fails
"""
try:
response = self.client.post(
"/security/user/authenticate",
auth=(self.username, self.password)
)
response.raise_for_status()
data = response.json()
token = data.get("data", {}).get("token")
if not token:
raise WazuhAuthenticationError("No token received in response")
# Token Expiry (Wazuh: default 900 seconds = 15 minutes)
# We refresh 2 minutes before
self._token = token
self._token_expires_at = datetime.now() + timedelta(minutes=13)
logger.info("Wazuh authentication successful")
return token
except httpx.HTTPStatusError as e:
logger.error(f"Wazuh Auth failed: {e.response.status_code} - {e.response.text}")
raise WazuhAuthenticationError(f"Authentication failed: {e}")
except Exception as e:
logger.error(f"Wazuh Auth error: {e}")
raise WazuhAuthenticationError(f"Authentication failed: {e}")
def _ensure_authenticated(self) -> str:
"""
Stellt sicher, dass ein gültiger Token vorhanden ist
Returns:
Gültiger JWT-Token
"""
if not self._token or not self._token_expires_at or datetime.now() >= self._token_expires_at:
logger.info("Token expired or missing - New Auth Request")
return self._authenticate()
return self._token
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=2, max=10),
retry=retry_if_exception_type((httpx.TimeoutException, httpx.ConnectError))
)
def _request(
self,
method: str,
endpoint: str,
params: Optional[Dict[str, Any]] = None,
json_data: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Führt HTTP-Request mit Authentifizierung und Retry-Logik aus
Args:
method: HTTP-Methode (GET, POST, PUT, DELETE)
endpoint: API-Endpoint (z.B. "/agents")
params: URL-Parameter
json_data: JSON-Body für POST/PUT
Returns:
JSON-Response als Dictionary
Raises:
WazuhAPIError: Bei API-Fehlern
WazuhRateLimitError: Bei Rate-Limiting
"""
token = self._ensure_authenticated()
headers = {"Authorization": f"Bearer {token}"}
try:
response = self.client.request(
method=method,
url=endpoint,
headers=headers,
params=params,
json=json_data
)
# Rate Limiting Check
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", 60))
logger.warning(f"Wazuh Rate Limit reached. Retry after {retry_after}s")
raise WazuhRateLimitError(f"Rate Limit - Retry after {retry_after}s")
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as e:
logger.error(f"Wazuh API Error: {e.response.status_code} - {e.response.text}")
raise WazuhAPIError(f"API Request failed: {e}")
except Exception as e:
logger.error(f"Wazuh Request Error: {e}")
raise WazuhAPIError(f"Request failed: {e}")
def _indexer_request(
self,
method: str,
endpoint: str,
json_data: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""Führt Request gegen den Wazuh Indexer (OpenSearch) aus"""
if not self.indexer_url:
raise WazuhAPIError("Indexer URL not configured")
try:
response = self.client.request(
method=method,
url=f"{self.indexer_url}{endpoint}",
auth=(self.indexer_username, self.indexer_password),
json=json_data
)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Wazuh Indexer Request Error: {e}")
raise WazuhAPIError(f"Indexer Request failed: {e}")
# ============================================
# Agent Management
# ============================================
def get_agents(
self,
status: Optional[str] = None,
limit: int = 500,
offset: int = 0
) -> List[Dict[str, Any]]:
"""
Holt Liste aller Wazuh Agents
Args:
status: Filter nach Status (active, disconnected, never_connected)
limit: Maximale Anzahl Ergebnisse
offset: Offset für Pagination
Returns:
Liste von Agent-Dictionaries
"""
params = {"limit": limit, "offset": offset}
if status:
params["status"] = status
response = self._request("GET", "/agents", params=params)
return response.get("data", {}).get("affected_items", [])
def get_agent_by_id(self, agent_id: str) -> Dict[str, Any]:
"""
Holt Details zu einem spezifischen Agent
Args:
agent_id: Wazuh Agent ID (z.B. "001")
Returns:
Agent-Details als Dictionary
"""
response = self._request("GET", f"/agents/{agent_id}")
items = response.get("data", {}).get("affected_items", [])
if not items:
raise WazuhAPIError(f"Agent {agent_id} not found")
return items[0]
# ============================================
# Vulnerability Management
# ============================================
_STATES_INDEX = "/wazuh-states-vulnerabilities-*"
def _paged_hits(self, agent_id, query, page_size, offset, max_total):
"""Classic from/size paging. OpenSearch rejects from+size beyond
index.max_result_window (default 10000) with a 400, so this path stops
at that ceiling instead of blowing up. Used only when an explicit
offset is requested; full syncs scroll instead."""
RESULT_WINDOW = 10_000
hits: List[Dict[str, Any]] = []
total_hits = 0
page_offset = offset
while True:
body = {"size": page_size, "from": page_offset, "query": query,
"track_total_hits": True}
response = self._indexer_request("POST", f"{self._STATES_INDEX}/_search", json_data=body)
page_hits = response.get("hits", {}).get("hits", []) or []
total_hits = response.get("hits", {}).get("total", {}).get("value", 0)
hits.extend(page_hits)
if not page_hits or len(hits) >= total_hits or len(hits) >= max_total:
break
page_offset += page_size
if page_offset + page_size > RESULT_WINDOW:
logger.warning(
f"Agent {agent_id}: stopping at the {RESULT_WINDOW}-doc result window "
f"({total_hits - len(hits)} left); use offset=0 to scroll them all"
)
break
return hits, total_hits
def _scroll_hits(self, agent_id, query, page_size, max_total):
"""Scroll the full result set — no result-window ceiling. Falls back to
from/size (window-bounded) if the indexer refuses scroll."""
hits: List[Dict[str, Any]] = []
total_hits = 0
scroll_id = None
try:
body = {"size": page_size, "query": query, "track_total_hits": True}
response = self._indexer_request(
"POST", f"{self._STATES_INDEX}/_search?scroll=2m", json_data=body
)
except WazuhAPIError as e:
logger.warning(
f"Agent {agent_id}: scroll unavailable ({e}); falling back to windowed paging"
)
return self._paged_hits(agent_id, query, page_size, 0, max_total)
try:
while True:
scroll_id = response.get("_scroll_id") or scroll_id
page_hits = response.get("hits", {}).get("hits", []) or []
total_hits = response.get("hits", {}).get("total", {}).get("value", total_hits)
hits.extend(page_hits)
logger.info(
f"Agent {agent_id}: scroll page returned {len(page_hits)} hits "
f"(total: {total_hits}, accumulated: {len(hits)})"
)
if not page_hits or len(hits) >= total_hits:
break
if len(hits) >= max_total:
logger.warning(
f"Agent {agent_id}: hit MAX_TOTAL cap of {max_total}; "
f"{total_hits - len(hits)} vulnerabilities skipped"
)
break
if not scroll_id:
break
response = self._indexer_request(
"POST", "/_search/scroll",
json_data={"scroll": "2m", "scroll_id": scroll_id},
)
finally:
if scroll_id:
try:
self._indexer_request(
"DELETE", "/_search/scroll", json_data={"scroll_id": [scroll_id]}
)
except Exception:
pass # context expires on its own
return hits, total_hits
def get_vulnerabilities(
self,
agent_id: str,
limit: int = 5000,
offset: int = 0
) -> List[Dict[str, Any]]:
"""
Holt alle Vulnerabilities eines Agents
Args:
agent_id: Wazuh Agent ID
limit: Maximale Anzahl Ergebnisse
offset: Offset für Pagination
Returns:
Liste von Vulnerability-Dictionaries
Response-Format (Beispiel):
{
"cve": "CVE-2024-1234",
"cvss": {
"cvss3": {
"base_score": 9.8,
"vector": {
"attack_vector": "network",
"availability": "high",
...
}
}
},
"name": "openssh-server",
"version": "1:8.2p1-4ubuntu0.5",
"architecture": "amd64",
"condition": "Package less than 1:8.2p1-4ubuntu0.11",
"published": "2024-01-15T10:00:00Z",
"severity": "Critical",
"external_references": ["https://nvd.nist.gov/vuln/detail/CVE-2024-1234"]
}
"""
# Falls Indexer konfiguriert ist (Wazuh 4.8+), nutze diesen
if self.indexer_url:
return self.query_vulnerabilities_from_indexer(agent_id, limit, offset)
params = {"limit": limit, "offset": offset}
response = self._request("GET", f"/vulnerability/{agent_id}", params=params)
return response.get("data", {}).get("affected_items", [])
def query_vulnerabilities_from_indexer(
self,
agent_id: str,
limit: int = 5000,
offset: int = 0
) -> List[Dict[str, Any]]:
"""
Holt Vulnerabilities aus dem Wazuh Indexer (OpenSearch)
Erforderlich ab Wazuh 4.8.
Filtert CVEs deren LETZTER Alert-Status "Solved" ist.
Wichtig: Der alte Code hat alle CVEs gefiltert, die IRGENDWANN mal
"Solved" waren. Das ist falsch, weil eine CVE erneut auftreten kann.
Jetzt wird per top_hits nur der aktuellste Status pro CVE geprueft.
"""
# Step 1: Get CVEs whose LATEST alert status is "Solved"
solved_cves = set()
try:
solved_query = {
"size": 0,
"query": {
"bool": {
"must": [
{"term": {"agent.id": agent_id}},
{"exists": {"field": "data.vulnerability.status"}}
]
}
},
"aggs": {
"per_cve": {
"terms": {"field": "data.vulnerability.cve", "size": 10000},
"aggs": {
"latest_status": {
"top_hits": {
"size": 1,
"sort": [{"@timestamp": {"order": "desc"}}],
"_source": ["data.vulnerability.status"]
}
}
}
}
}
}
solved_response = self._indexer_request(
"POST",
"/wazuh-alerts-*/_search",
json_data=solved_query
)
buckets = solved_response.get("aggregations", {}).get("per_cve", {}).get("buckets", [])
for bucket in buckets:
latest_hit = bucket.get("latest_status", {}).get("hits", {}).get("hits", [])
if latest_hit:
latest_status = latest_hit[0].get("_source", {}).get("data", {}).get("vulnerability", {}).get("status")
if latest_status == "Solved":
solved_cves.add(bucket["key"])
if solved_cves:
logger.info(f"Agent {agent_id}: Found {len(solved_cves)} CVEs with latest status 'Solved' to exclude")
except Exception as e:
logger.warning(f"Could not query solved CVEs from alerts: {e}")
# Step 2: Query active vulnerabilities from states index.
# Paged via scroll (see _scroll_hits) — from/size is hard-capped by
# OpenSearch's index.max_result_window (default 10000) and 400s beyond
# it, which made big agents look like "0 vulns" and skip their backfill.
# MAX_TOTAL is a memory guard, not a protocol limit: 50k covers every
# real Wazuh fleet without unbounded growth.
MAX_TOTAL = 50_000
PAGE_SIZE = max(1, min(limit, 5000))
hits: List[Dict[str, Any]] = []
total_hits = 0
base_query = {"bool": {"must": [{"term": {"agent.id": agent_id}}]}}
try:
if offset:
# Explicit offset requested → keep the classic from/size path,
# bounded below the result window (see _paged_hits).
hits, total_hits = self._paged_hits(
agent_id, base_query, PAGE_SIZE, offset, MAX_TOTAL
)
else:
# Full sync: scroll. from/size is capped by OpenSearch's
# index.max_result_window (default 10000) — agents with more
# findings 400'd at from=10000, the whole agent then looked like
# "0 vulns" and its backfill was skipped (observed: agents with
# 24k/27k findings). Scroll has no such ceiling and needs no
# unique sort field.
hits, total_hits = self._scroll_hits(
agent_id, base_query, PAGE_SIZE, MAX_TOTAL
)
results = []
skipped_no_cve = 0
skipped_solved = 0
for hit in hits:
source = hit.get("_source", {})
vuln = source.get("vulnerability", {})
pkg = source.get("package", {})
score = vuln.get("score", {})
cve_id = vuln.get("id")
if not cve_id:
skipped_no_cve += 1
continue
# Skip if the LATEST alert status for this CVE is "Solved"
if cve_id in solved_cves:
skipped_solved += 1
logger.debug(f"Agent {agent_id}: Skipping {cve_id} - latest alert status is Solved")
continue
# Try multiple score field paths for compatibility
raw_score = score.get("base") or score.get("base_score")
# Wazuh-Indexer emits -1 / out-of-range placeholders for
# packages it could not score. Clamp anything outside the
# CVSSv3 spec range [0.0, 10.0] back to None so downstream
# CPR maths cannot produce negative results (observed:
# CPR=-9.3 driven by cvss_score=-1).
base_score = None
try:
if raw_score is not None:
f = float(raw_score)
if 0.0 <= f <= 10.0:
base_score = f
except (TypeError, ValueError):
base_score = None
# Fixed-version extraction. Wazuh 4.x writes this in a
# few different fields depending on the feed; try them
# in order, then fall back to regex-parsing the
# condition string ("Package less than X.Y.Z").
fixed_version = (
vuln.get("fix")
or vuln.get("fixed_version")
or (pkg.get("fix") if isinstance(pkg, dict) else None)
)
if not fixed_version:
cond = vuln.get("condition") or (pkg.get("condition") if isinstance(pkg, dict) else None)
if isinstance(cond, str):
# "Package less than 1.2.3", "less than 4.5", etc.
import re as _re
m = _re.search(r"less than\s+([0-9][\w\.\-:+~]*)", cond, _re.IGNORECASE)
if m:
fixed_version = m.group(1)
# Mapping auf das interne Format
results.append({
"cve": cve_id,
"cvss": {
"cvss3": {
"base_score": base_score,
}
},
"name": pkg.get("name"),
"version": pkg.get("version"),
"vendor": pkg.get("vendor"),
"fixed_version": fixed_version,
"severity": vuln.get("severity"),
"title": vuln.get("title") or vuln.get("description"),
"detected_at": vuln.get("detected_at") or source.get("@timestamp")
})
if skipped_no_cve:
logger.warning(f"Agent {agent_id}: Skipped {skipped_no_cve} entries without CVE ID")
if skipped_solved:
logger.info(f"Agent {agent_id}: Skipped {skipped_solved} CVEs with latest status 'Solved'")
logger.info(f"Agent {agent_id}: Returning {len(results)} vulnerabilities from indexer")
return results
except Exception as e:
logger.error(f"Fehler beim Abfragen des Indexers fuer Agent {agent_id}: {e}")
return []
def get_vulnerability_summary(self, agent_id: str) -> Dict[str, int]:
"""
Holt Vulnerability-Zusammenfassung für einen Agent
Args:
agent_id: Wazuh Agent ID
Returns:
Dictionary mit Counts pro Severity-Level
Beispiel: {"critical": 5, "high": 12, "medium": 30, "low": 45}
"""
response = self._request("GET", f"/vulnerability/{agent_id}/summary")
return response.get("data", {}).get("affected_items", [{}])[0]
# ============================================
# Syscollector (System Inventory)
# ============================================
def trigger_syscollector_scan(self, agent_id: str) -> Dict[str, Any]:
"""
Triggers a syscollector scan for an agent
This is important for patch verification:
After a system update, Wazuh must rescan installed packages
to check if vulnerabilities have been fixed.
Args:
agent_id: Wazuh Agent ID
Returns:
Response with Scan Status
Note:
The scan runs asynchronously. It takes approx. 1-5 minutes until results are available.
Status can be checked with get_agent_by_id() (lastKeepAlive timestamp).
"""
# Wazuh provides no direct "Rescan" endpoint
# Workaround: Restart the Agent (forces Syscollector-Rescan)
# Check: PUT /agents/{agent_id}/restart
response = self._request("PUT", f"/agents/{agent_id}/restart")
logger.info(f"Syscollector scan triggered for agent {agent_id}")
return response
def get_packages(self, agent_id: str) -> List[Dict[str, Any]]:
"""
Holt Liste aller installierten Packages eines Agents
Args:
agent_id: Wazuh Agent ID
Returns:
Liste von Package-Dictionaries
"""
response = self._request("GET", f"/syscollector/{agent_id}/packages")
return response.get("data", {}).get("affected_items", [])
def get_cluster_nodes(self) -> List[Dict[str, Any]]:
"""The manager nodes themselves: [{name, type, version, ip}].
Master and worker nodes normally run no agent — they inventory other
hosts and nobody inventories them — so they never appeared as assets
and their own CVEs went unseen. The API knows its own version without
an agent being involved. Falls back to /manager/info on a standalone
install, where the cluster endpoint is disabled.
"""
nodes: List[Dict[str, Any]] = []
try:
resp = self._request("GET", "/cluster/nodes")
for n in resp.get("data", {}).get("affected_items", []) or []:
if n.get("name"):
nodes.append({"name": n.get("name"), "type": n.get("type") or "node",
"version": (n.get("version") or "").lstrip("v"),
"ip": n.get("ip")})
except Exception as e:
logger.debug("cluster/nodes unavailable (standalone?): %s", e)
if nodes:
return nodes
try:
info = self._request("GET", "/manager/info").get("data", {})
items = info.get("affected_items") or [info]
for i in items:
if i.get("name") or i.get("version"):
nodes.append({"name": i.get("name") or "wazuh-manager",
"type": i.get("type") or "master",
"version": (i.get("version") or "").lstrip("v"),
"ip": None})
except Exception as e:
logger.warning("wazuh: could not read manager version: %s", e)
return nodes
def get_ports(self, agent_id: str) -> List[Dict[str, Any]]:
"""Open ports / listeners from syscollector.
Each item: {local_ip, local_port, protocol, state, process,
pid, ...}. We use the listening sockets to gauge network
exposure (Plan: risk-exposure indicator).
"""
response = self._request("GET", f"/syscollector/{agent_id}/ports")
return response.get("data", {}).get("affected_items", [])
_EXT_INDEX = "/wazuh-states-inventory-browser-extensions-*"
def get_browser_extensions(self, agent_id: str) -> List[Dict[str, Any]]:
"""Installed browser extensions for one agent, from IT Hygiene.
These live in the INDEXER, not the manager API — syscollector has no
endpoint for them. Without this the browser extensions are a blind
spot: the Acrobat extension for Chrome ships its own CVEs
(CVE-2026-48294) that no other inventory can reach.
Returns the package block enriched with the browser, e.g.
{name, version, id, enabled, browser, profile}. Empty list when the
indexer is not configured — the caller then simply scans nothing.
"""
if not self.indexer_url:
return []
body = {
"size": 1000,
"query": {"term": {"agent.id": str(agent_id)}},
"_source": ["agent.id", "browser.name", "browser.profile.name",
"package.name", "package.version", "package.id",
"package.enabled", "package.vendor",
"package.from_webstore"],
}
try:
resp = self._indexer_request(
"POST", f"{self._EXT_INDEX}/_search", json_data=body)
except WazuhAPIError as e:
# IT Hygiene is optional and only exists from 4.14 — a missing
# index must not take the whole scan down with it.
logger.debug("browser extensions unavailable for %s: %s", agent_id, e)
return []
out: List[Dict[str, Any]] = []
for hit in (resp.get("hits", {}) or {}).get("hits", []) or []:
src = hit.get("_source") or {}
pkg = src.get("package") or {}
if not pkg.get("name"):
continue
out.append({
"name": pkg.get("name"),
"version": pkg.get("version"),
"id": pkg.get("id"),
"enabled": pkg.get("enabled"),
"vendor": pkg.get("vendor"),
"browser": ((src.get("browser") or {}).get("name") or "").lower(),
"profile": (((src.get("browser") or {}).get("profile") or {})
.get("name")),
})
return out
def get_os_info(self, agent_id: str) -> Dict[str, Any]:
"""
Holt OS-Informationen eines Agents
Args:
agent_id: Wazuh Agent ID
Returns:
OS-Details (Name, Version, Kernel, etc.)
"""
response = self._request("GET", f"/syscollector/{agent_id}/os")
items = response.get("data", {}).get("affected_items", [])
return items[0] if items else {}
# ============================================
# SCA — Security Configuration Assessment
# ============================================
def get_sca_policies(self, agent_id: str) -> List[Dict[str, Any]]:
"""
List all SCA policies an agent has been evaluated against.
Wazuh API: ``GET /sca/{agent_id}``
Returns per-policy summary including pass/fail/invalid counts +
score. Example item:
{
"policy_id": "cis_win11",
"name": "CIS Microsoft Windows 11 Enterprise Benchmark v1.0.0",
"description": "...",
"pass": 142, "fail": 38, "invalid": 0, "total_checks": 180,
"score": 78, "end_scan": "2026-05-19T01:00:00Z", ...
}
"""
response = self._request("GET", f"/sca/{agent_id}")
return response.get("data", {}).get("affected_items", []) or []
def get_sca_checks(
self,
agent_id: str,
policy_id: str,
limit: int = 500,
) -> List[Dict[str, Any]]:
"""
Per-check detail for one (agent, policy).
Wazuh API: ``GET /sca/{agent_id}/checks/{policy_id}``
Used by the optional deep-dive endpoint — not called by the
default compliance refresh which only persists per-policy
summaries. Returns up to ``limit`` checks; the default 500 is
enough for the largest CIS benchmarks (~250-400 checks).
"""
response = self._request(
"GET",
f"/sca/{agent_id}/checks/{policy_id}",
params={"limit": limit},
)
return response.get("data", {}).get("affected_items", []) or []
# ============================================
# Helper-Funktionen
# ============================================
def wait_for_scan_completion(
self,
agent_id: str,
timeout: int = 600,
check_interval: int = 10
) -> bool:
"""
Wartet, bis ein Syscollector-Scan abgeschlossen ist
Args:
agent_id: Wazuh Agent ID
timeout: Maximale Wartezeit in Sekunden (default: 10 Min)
check_interval: Prüfintervall in Sekunden
Returns:
True wenn Scan abgeschlossen, False bei Timeout
"""
start_time = time.time()
initial_scan_time = self._get_last_scan_time(agent_id)
while time.time() - start_time < timeout:
time.sleep(check_interval)
current_scan_time = self._get_last_scan_time(agent_id)
if current_scan_time and current_scan_time > initial_scan_time:
logger.info(f"Scan for agent {agent_id} completed")
return True
logger.warning(f"Scan timeout for agent {agent_id}")
return False
def _get_last_scan_time(self, agent_id: str) -> Optional[datetime]:
"""Holt Zeitpunkt des letzten Syscollector-Scans"""
try:
agent = self.get_agent_by_id(agent_id)
last_keepalive = agent.get("lastKeepAlive")
if last_keepalive:
return datetime.fromisoformat(last_keepalive.replace("Z", "+00:00"))
except Exception as e:
logger.error(f"Error fetching scan time: {e}")
return None
def close(self):
"""Closes HTTP client and releases resources"""
self.client.close()
logger.info("Wazuh Client closed")
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
# ============================================
# Convenience-Funktionen
# ============================================
def get_wazuh_client() -> WazuhClient:
"""
Factory-Funktion für Dependency Injection in FastAPI
Usage:
@app.get("/sync")
def sync_vulnerabilities(wazuh: WazuhClient = Depends(get_wazuh_client)):
agents = wazuh.get_agents(status="active")
...
"""
return WazuhClient()