feat(iwooos): flag empty Wazuh agent registry

This commit is contained in:
ogt
2026-06-25 09:55:19 +08:00
parent d4f3953847
commit 8698f8311e
8 changed files with 241 additions and 14 deletions

View File

@@ -27,9 +27,17 @@ def _wazuh_env() -> dict[str, str]:
"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:
@@ -54,6 +62,10 @@ def _boundary_response(status_text: str, http_status: int = 200) -> JSONResponse
"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(),
},
@@ -82,6 +94,18 @@ def _redacted_agent(agent: dict[str, Any], index: int) -> dict[str, Any]:
}
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_min_agent_count: int) -> str:
if agent_total <= 0:
return "wazuh_agent_registry_empty"
if expected_min_agent_count > 0 and agent_total < expected_min_agent_count:
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()
@@ -128,20 +152,31 @@ async def _wazuh_readonly_status() -> JSONResponse:
affected_items = ((agents_payload.get("data") or {}).get("affected_items") or [])
if not isinstance(affected_items, list):
affected_items = []
expected_min_agent_count = _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_min_agent_count > 0 and agent_total < expected_min_agent_count
return JSONResponse(
content={
"schema_version": "iwooos_wazuh_readonly_status_v1",
"status": "readonly_metadata_available",
"status": _agent_visibility_status(agent_total, expected_min_agent_count),
"mode": "metadata_only_no_active_response_no_raw_payload",
"configured": True,
"summary": {
"wazuh_platform_reported_count": 1,
"readonly_api_enabled_count": 1,
"agent_total": connection.get("total", len(affected_items)),
"agent_active": connection.get("active", 0),
"agent_disconnected": connection.get("disconnected", 0),
"agent_pending": connection.get("pending", 0),
"agent_total": agent_total,
"agent_active": agent_active,
"agent_disconnected": agent_disconnected,
"agent_pending": agent_pending,
"expected_min_agent_count": expected_min_agent_count,
"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,

View File

@@ -19,6 +19,7 @@ def test_iwooos_wazuh_compat_route_returns_disabled_boundary_by_default(monkeypa
monkeypatch.delenv("WAZUH_API_BASE_URL", raising=False)
monkeypatch.delenv("WAZUH_API_USERNAME", raising=False)
monkeypatch.delenv("WAZUH_API_PASSWORD", raising=False)
monkeypatch.delenv("IWOOOS_WAZUH_EXPECTED_MIN_AGENT_COUNT", raising=False)
response = _client().get("/api/iwooos/wazuh")
@@ -28,6 +29,7 @@ def test_iwooos_wazuh_compat_route_returns_disabled_boundary_by_default(monkeypa
assert data["status"] == "disabled_waiting_iwooos_wazuh_owner_gate"
assert data["configured"] is False
assert data["summary"]["runtime_gate_count"] == 0
assert data["summary"]["agent_visibility_no_false_green_count"] == 1
assert data["boundaries"]["active_response_authorized"] is False
assert data["boundaries"]["host_write_authorized"] is False
assert data["boundaries"]["raw_wazuh_payload_storage_allowed"] is False
@@ -39,6 +41,7 @@ def test_iwooos_wazuh_v1_route_rejects_missing_server_side_env(monkeypatch: pyte
monkeypatch.setenv("WAZUH_API_BASE_URL", "")
monkeypatch.setenv("WAZUH_API_USERNAME", "")
monkeypatch.setenv("WAZUH_API_PASSWORD", "")
monkeypatch.delenv("IWOOOS_WAZUH_EXPECTED_MIN_AGENT_COUNT", raising=False)
response = _client().get("/api/v1/iwooos/wazuh")
@@ -54,6 +57,7 @@ def test_iwooos_wazuh_rejects_non_https_base_url(monkeypatch: pytest.MonkeyPatch
monkeypatch.setenv("WAZUH_API_BASE_URL", "http://wazuh.example.test:55000")
monkeypatch.setenv("WAZUH_API_USERNAME", "readonly")
monkeypatch.setenv("WAZUH_API_PASSWORD", "placeholder")
monkeypatch.delenv("IWOOOS_WAZUH_EXPECTED_MIN_AGENT_COUNT", raising=False)
response = _client().get("/api/iwooos/wazuh")
@@ -68,6 +72,7 @@ def test_iwooos_wazuh_live_response_is_metadata_only(monkeypatch: pytest.MonkeyP
monkeypatch.setenv("WAZUH_API_BASE_URL", "https://wazuh.example.test:55000")
monkeypatch.setenv("WAZUH_API_USERNAME", "readonly")
monkeypatch.setenv("WAZUH_API_PASSWORD", "placeholder")
monkeypatch.delenv("IWOOOS_WAZUH_EXPECTED_MIN_AGENT_COUNT", raising=False)
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/security/user/authenticate":
@@ -113,6 +118,9 @@ def test_iwooos_wazuh_live_response_is_metadata_only(monkeypatch: pytest.MonkeyP
assert data["status"] == "readonly_metadata_available"
assert data["configured"] is True
assert data["summary"]["agent_total"] == 2
assert data["summary"]["agent_registry_empty_count"] == 0
assert data["summary"]["agent_below_expected_minimum_count"] == 0
assert data["summary"]["agent_visibility_no_false_green_count"] == 1
assert data["summary"]["runtime_gate_count"] == 0
assert data["agents"] == [
{
@@ -125,3 +133,104 @@ def test_iwooos_wazuh_live_response_is_metadata_only(monkeypatch: pytest.MonkeyP
assert "host-110-private-name" not in response.text
assert "192.168.0.110" not in response.text
assert "token-value" not in response.text
def test_iwooos_wazuh_marks_empty_agent_registry_as_degraded(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("IWOOOS_WAZUH_READONLY_ENABLED", "true")
monkeypatch.setenv("WAZUH_API_BASE_URL", "https://wazuh.example.test:55000")
monkeypatch.setenv("WAZUH_API_USERNAME", "readonly")
monkeypatch.setenv("WAZUH_API_PASSWORD", "placeholder")
monkeypatch.delenv("IWOOOS_WAZUH_EXPECTED_MIN_AGENT_COUNT", raising=False)
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/security/user/authenticate":
return httpx.Response(200, json={"data": {"token": "token-value"}})
if request.url.path == "/agents/summary/status":
return httpx.Response(
200,
json={"data": {"connection": {"total": 0, "active": 0, "disconnected": 0, "pending": 0}}},
)
if request.url.path == "/agents":
return httpx.Response(200, json={"data": {"affected_items": []}})
return httpx.Response(404)
transport = httpx.MockTransport(handler)
original_async_client = httpx.AsyncClient
def client_factory(*args, **kwargs):
kwargs["transport"] = transport
return original_async_client(*args, **kwargs)
monkeypatch.setattr(httpx, "AsyncClient", client_factory)
response = _client().get("/api/iwooos/wazuh")
assert response.status_code == 200
data = response.json()
assert data["status"] == "wazuh_agent_registry_empty"
assert data["summary"]["agent_total"] == 0
assert data["summary"]["agent_registry_empty_count"] == 1
assert data["summary"]["agent_below_expected_minimum_count"] == 0
assert data["summary"]["agent_visibility_no_false_green_count"] == 1
assert data["summary"]["runtime_gate_count"] == 0
assert data["agents"] == []
assert "token-value" not in response.text
def test_iwooos_wazuh_marks_agent_count_below_expected_as_degraded(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("IWOOOS_WAZUH_READONLY_ENABLED", "true")
monkeypatch.setenv("WAZUH_API_BASE_URL", "https://wazuh.example.test:55000")
monkeypatch.setenv("WAZUH_API_USERNAME", "readonly")
monkeypatch.setenv("WAZUH_API_PASSWORD", "placeholder")
monkeypatch.setenv("IWOOOS_WAZUH_EXPECTED_MIN_AGENT_COUNT", "2")
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/security/user/authenticate":
return httpx.Response(200, json={"data": {"token": "token-value"}})
if request.url.path == "/agents/summary/status":
return httpx.Response(
200,
json={"data": {"connection": {"total": 1, "active": 1, "disconnected": 0, "pending": 0}}},
)
if request.url.path == "/agents":
return httpx.Response(
200,
json={
"data": {
"affected_items": [
{
"id": "001",
"name": "private-host-name",
"ip": "192.168.0.110",
"status": "active",
"os": {"platform": "linux"},
"lastKeepAlive": "2026-06-24T13:00:00Z",
}
]
}
},
)
return httpx.Response(404)
transport = httpx.MockTransport(handler)
original_async_client = httpx.AsyncClient
def client_factory(*args, **kwargs):
kwargs["transport"] = transport
return original_async_client(*args, **kwargs)
monkeypatch.setattr(httpx, "AsyncClient", client_factory)
response = _client().get("/api/iwooos/wazuh")
assert response.status_code == 200
data = response.json()
assert data["status"] == "wazuh_agent_registry_below_expected"
assert data["summary"]["expected_min_agent_count"] == 2
assert data["summary"]["agent_total"] == 1
assert data["summary"]["agent_registry_empty_count"] == 0
assert data["summary"]["agent_below_expected_minimum_count"] == 1
assert data["summary"]["runtime_gate_count"] == 0
assert "private-host-name" not in response.text
assert "192.168.0.110" not in response.text
assert "token-value" not in response.text