""" IwoooS Wazuh read-only metadata status. This module returns public-safe aggregate metadata only. It never stores raw Wazuh payloads, never exposes agent identity or LAN topology, and never authorizes active response or host writes. """ from __future__ import annotations import os from base64 import b64encode from dataclasses import dataclass from typing import Any from urllib.parse import urljoin, urlparse import httpx REQUEST_TIMEOUT_SECONDS = 5.0 @dataclass(frozen=True) class WazuhReadonlyStatus: payload: dict[str, Any] http_status: int = 200 def wazuh_env() -> dict[str, str]: return { "enabled": os.getenv("IWOOOS_WAZUH_READONLY_ENABLED", "").strip().lower(), "base_url": os.getenv("WAZUH_API_BASE_URL", "").strip(), "username": os.getenv("WAZUH_API_USERNAME", "").strip(), "password": os.getenv("WAZUH_API_PASSWORD", "").strip(), "expected_min_agent_count": os.getenv("IWOOOS_WAZUH_EXPECTED_MIN_AGENT_COUNT", "").strip(), } def expected_min_agent_count(value: str) -> int: try: return max(0, int(value)) except ValueError: return 0 def https_url(value: str) -> str | None: parsed = urlparse(value) if parsed.scheme != "https" or not parsed.netloc: return None return value.rstrip("/") + "/" def boundaries() -> dict[str, bool]: return { "active_response_authorized": False, "host_write_authorized": False, "secret_value_collection_allowed": False, "raw_wazuh_payload_storage_allowed": False, "agent_identity_public_display_allowed": False, "internal_ip_public_display_allowed": False, "not_authorization": True, } def boundary_status(status_text: str, http_status: int = 200) -> WazuhReadonlyStatus: return WazuhReadonlyStatus( http_status=http_status, payload={ "schema_version": "iwooos_wazuh_readonly_status_v1", "status": status_text, "mode": "metadata_only_no_active_response_no_raw_payload", "configured": False, "summary": { "wazuh_platform_reported_count": 1, "readonly_api_enabled_count": 0, "wazuh_manager_query_accepted_count": 0, "wazuh_event_accepted_count": 0, "host_forensics_accepted_count": 0, "active_response_authorized_count": 0, "host_write_authorized_count": 0, "runtime_gate_count": 0, "expected_min_agent_count": expected_min_agent_count(wazuh_env()["expected_min_agent_count"]), "agent_registry_empty_count": 0, "agent_below_expected_minimum_count": 0, "agent_visibility_no_false_green_count": 1, }, "boundaries": boundaries(), }, ) def redacted_agent(agent: dict[str, Any], index: int) -> dict[str, Any]: os_info = agent.get("os") if isinstance(agent.get("os"), dict) else {} return { "alias": f"agent-{index + 1:02d}", "status": agent.get("status", "unknown"), "os": os_info.get("platform") or os_info.get("name") or "unknown", "last_seen_present": bool(agent.get("lastKeepAlive")), } def int_or_default(value: Any, default: int) -> int: return value if isinstance(value, int) else default def agent_visibility_status(agent_total: int, expected_minimum: int) -> str: if agent_total <= 0: return "wazuh_agent_registry_empty" if expected_minimum > 0 and agent_total < expected_minimum: return "wazuh_agent_registry_below_expected" return "readonly_metadata_available" async def fetch_json(client: httpx.AsyncClient, url: str, headers: dict[str, str]) -> dict[str, Any]: response = await client.get(url, headers=headers) response.raise_for_status() payload = response.json() return payload if isinstance(payload, dict) else {} async def load_iwooos_wazuh_readonly_status() -> WazuhReadonlyStatus: env = wazuh_env() if env["enabled"] != "true": return boundary_status("disabled_waiting_iwooos_wazuh_owner_gate") base_url = https_url(env["base_url"]) if not base_url or not env["username"] or not env["password"]: return boundary_status("misconfigured_missing_server_side_wazuh_env", 503) try: auth_header = b64encode(f"{env['username']}:{env['password']}".encode("utf-8")).decode("ascii") async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS) as client: auth = await fetch_json( client, urljoin(base_url, "security/user/authenticate"), {"Authorization": f"Basic {auth_header}"}, ) token = (auth.get("data") or {}).get("token") if not token: return boundary_status("wazuh_auth_token_missing", 502) bearer_headers = {"Authorization": f"Bearer {token}"} status_payload = await fetch_json( client, urljoin(base_url, "agents/summary/status"), bearer_headers, ) agents_payload = await fetch_json( client, urljoin(base_url, "agents?limit=100&select=id,status,os.name,os.platform,lastKeepAlive"), bearer_headers, ) except (httpx.HTTPError, ValueError): return boundary_status("wazuh_readonly_metadata_unavailable", 502) connection = ((status_payload.get("data") or {}).get("connection") or {}) affected_items = ((agents_payload.get("data") or {}).get("affected_items") or []) if not isinstance(affected_items, list): affected_items = [] expected_minimum = expected_min_agent_count(env["expected_min_agent_count"]) agent_total = int_or_default(connection.get("total"), len(affected_items)) agent_active = int_or_default(connection.get("active"), 0) agent_disconnected = int_or_default(connection.get("disconnected"), 0) agent_pending = int_or_default(connection.get("pending"), 0) agent_registry_empty = agent_total <= 0 agent_below_expected = expected_minimum > 0 and agent_total < expected_minimum return WazuhReadonlyStatus( payload={ "schema_version": "iwooos_wazuh_readonly_status_v1", "status": agent_visibility_status(agent_total, expected_minimum), "mode": "metadata_only_no_active_response_no_raw_payload", "configured": True, "summary": { "wazuh_platform_reported_count": 1, "readonly_api_enabled_count": 1, "agent_total": agent_total, "agent_active": agent_active, "agent_disconnected": agent_disconnected, "agent_pending": agent_pending, "expected_min_agent_count": expected_minimum, "agent_registry_empty_count": 1 if agent_registry_empty else 0, "agent_below_expected_minimum_count": 1 if agent_below_expected else 0, "agent_visibility_no_false_green_count": 1, "wazuh_manager_query_accepted_count": 0, "wazuh_event_accepted_count": 0, "host_forensics_accepted_count": 0, "active_response_authorized_count": 0, "host_write_authorized_count": 0, "runtime_gate_count": 0, }, "agents": [redacted_agent(agent, index) for index, agent in enumerate(affected_items[:20])], "boundaries": boundaries(), }, )