diff --git a/apps/api/src/api/v1/iwooos.py b/apps/api/src/api/v1/iwooos.py index b26baa77..eefb8fff 100644 --- a/apps/api/src/api/v1/iwooos.py +++ b/apps/api/src/api/v1/iwooos.py @@ -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, diff --git a/apps/api/tests/test_iwooos_wazuh_api.py b/apps/api/tests/test_iwooos_wazuh_api.py index c00b21ee..08a71218 100644 --- a/apps/api/tests/test_iwooos_wazuh_api.py +++ b/apps/api/tests/test_iwooos_wazuh_api.py @@ -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 diff --git a/apps/web/src/app/api/iwooos/wazuh/route.ts b/apps/web/src/app/api/iwooos/wazuh/route.ts index a9ebc414..243541b1 100644 --- a/apps/web/src/app/api/iwooos/wazuh/route.ts +++ b/apps/web/src/app/api/iwooos/wazuh/route.ts @@ -8,6 +8,9 @@ const READONLY_ENABLED = process.env.IWOOOS_WAZUH_READONLY_ENABLED === 'true'; const WAZUH_API_BASE_URL = process.env.WAZUH_API_BASE_URL?.trim() ?? ''; const WAZUH_API_USERNAME = process.env.WAZUH_API_USERNAME?.trim() ?? ''; const WAZUH_API_PASSWORD = process.env.WAZUH_API_PASSWORD?.trim() ?? ''; +const EXPECTED_MIN_AGENT_COUNT = parseExpectedMinAgentCount( + process.env.IWOOOS_WAZUH_EXPECTED_MIN_AGENT_COUNT?.trim() ?? '', +); const REQUEST_TIMEOUT_MS = 5000; type WazuhConnectionSummary = { @@ -48,6 +51,10 @@ function boundaryResponse(status: string, httpStatus = 200) { active_response_authorized_count: 0, host_write_authorized_count: 0, runtime_gate_count: 0, + expected_min_agent_count: EXPECTED_MIN_AGENT_COUNT, + agent_registry_empty_count: 0, + agent_below_expected_minimum_count: 0, + agent_visibility_no_false_green_count: 1, }, boundaries: { active_response_authorized: false, @@ -86,6 +93,25 @@ function requireHttpsBaseUrl(value: string): URL | null { } } +function parseExpectedMinAgentCount(value: string) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? Math.max(0, parsed) : 0; +} + +function numberOrDefault(value: unknown, fallback: number) { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback; +} + +function agentVisibilityStatus(agentTotal: number) { + if (agentTotal <= 0) { + return 'wazuh_agent_registry_empty'; + } + if (EXPECTED_MIN_AGENT_COUNT > 0 && agentTotal < EXPECTED_MIN_AGENT_COUNT) { + return 'wazuh_agent_registry_below_expected'; + } + return 'readonly_metadata_available'; +} + function redactedAgent(agent: WazuhAgent, index: number) { return { alias: `agent-${String(index + 1).padStart(2, '0')}`, @@ -141,19 +167,29 @@ export async function GET() { const affectedItems = agents.data?.affected_items ?? []; const connection = status.data?.connection ?? {}; + const agentTotal = numberOrDefault(connection.total, affectedItems.length); + const agentActive = numberOrDefault(connection.active, 0); + const agentDisconnected = numberOrDefault(connection.disconnected, 0); + const agentPending = numberOrDefault(connection.pending, 0); + const agentRegistryEmpty = agentTotal <= 0; + const agentBelowExpected = EXPECTED_MIN_AGENT_COUNT > 0 && agentTotal < EXPECTED_MIN_AGENT_COUNT; return NextResponse.json({ schema_version: 'iwooos_wazuh_readonly_status_v1', - status: 'readonly_metadata_available', + status: agentVisibilityStatus(agentTotal), 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.total ?? affectedItems.length, - agent_active: connection.active ?? 0, - agent_disconnected: connection.disconnected ?? 0, - agent_pending: connection.pending ?? 0, + agent_total: agentTotal, + agent_active: agentActive, + agent_disconnected: agentDisconnected, + agent_pending: agentPending, + expected_min_agent_count: EXPECTED_MIN_AGENT_COUNT, + agent_registry_empty_count: agentRegistryEmpty ? 1 : 0, + agent_below_expected_minimum_count: agentBelowExpected ? 1 : 0, + agent_visibility_no_false_green_count: 1, wazuh_manager_query_accepted_count: 0, wazuh_event_accepted_count: 0, host_forensics_accepted_count: 0, diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index e0ff5b36..bf3b9e05 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -1,3 +1,28 @@ +## 2026-06-25|Wazuh agent registry no-false-green + +**背景**:Wazuh 用戶端消失事故不能只靠 Dashboard 畫面或 agent service active 判定。原本 IwoooS 只讀 API 在 Wazuh metadata 可查時會回 `readonly_metadata_available`,但如果 manager registry 回 `agent_total=0` 或低於預期下限,前台與驗收工具仍可能誤讀成正常。 + +**完成**: +- FastAPI `/api/iwooos/wazuh` 與 `/api/v1/iwooos/wazuh` 新增 agent registry no-false-green 判斷。 +- Next.js `/api/iwooos/wazuh` 同步補齊相同判斷,避免 route parity 漏洞。 +- 新增 server-side `IWOOOS_WAZUH_EXPECTED_MIN_AGENT_COUNT`,只作為期望下限,不含 agent 身分、內網 IP 或 secret。 +- 當 registry 為空時回 `wazuh_agent_registry_empty`;當 agent count 低於期望下限時回 `wazuh_agent_registry_below_expected`。 +- summary 新增 `expected_min_agent_count`、`agent_registry_empty_count`、`agent_below_expected_minimum_count`、`agent_visibility_no_false_green_count`,且 `runtime_gate_count` 仍固定為 `0`。 +- `wazuh-readonly-route-boundary-guard.py` 納入上述必要 token,`wazuh-readonly-production-readback.py` 允許正式讀回這兩種退化狀態。 + +**驗證**: +- `DATABASE_URL=postgresql://postgres:postgres@localhost:5432/awoooi_test pytest apps/api/tests/test_iwooos_wazuh_api.py -q`:`6 passed`。 +- `python3 scripts/security/wazuh-readonly-route-boundary-guard.py --root .`:通過。 +- `python3 -m py_compile apps/api/src/api/v1/iwooos.py scripts/security/wazuh-readonly-route-boundary-guard.py scripts/security/wazuh-readonly-production-readback.py`:通過。 + +**完成度同步**: +- Wazuh agent registry no-false-green source-side:`100%`。 +- Wazuh P0-A manager registry 只讀驗收:`40% -> 48%` source-side、`0%` live registry accepted。 +- SOC / Wazuh no-false-green 納管:`63% -> 66%`。 +- production deploy、Wazuh manager registry live readback、Dashboard stored API 修復、owner response、active response / host write:仍維持 `0%`。 + +**邊界**:本輪沒有查 live Wazuh API、沒有 SSH、沒有讀 secret、沒有重啟 Wazuh / Docker / Nginx / firewall、沒有重新註冊 agent、沒有 active response,也沒有把工作視窗對話放進文件或前端。 + ## 2026-06-25|AwoooP Runs AI 事件卡送達讀回前台 **背景**:AwoooP 已有 `GET /api/v1/platform/runs/ai-alert-cards` source-side readback,但 operator 還需要在 Runs 頁直接看見 Wazuh 事件卡是否有送達、失敗、等待或仍無資料。本輪補前端只讀面板,不顯示完整 Telegram 文字或 raw Wazuh payload。 diff --git a/docs/security/IWOOOS-WAZUH-READONLY-API-RELEASE-HANDOFF.md b/docs/security/IWOOOS-WAZUH-READONLY-API-RELEASE-HANDOFF.md index 86d1109a..e504d2f1 100644 --- a/docs/security/IWOOOS-WAZUH-READONLY-API-RELEASE-HANDOFF.md +++ b/docs/security/IWOOOS-WAZUH-READONLY-API-RELEASE-HANDOFF.md @@ -53,6 +53,8 @@ - live Wazuh 查詢仍需 `IWOOOS_WAZUH_READONLY_ENABLED=true` 與 server-side env:`WAZUH_API_BASE_URL`、`WAZUH_API_USERNAME`、`WAZUH_API_PASSWORD`。 - 強制 Wazuh base URL 使用 HTTPS。 - 回傳資料只允許 metadata:agent alias、status、OS 類別、last_seen_present 與 aggregate counts。 +- 新增 agent registry no-false-green:若 Wazuh manager registry 回 `agent_total=0`,API 必須回 `wazuh_agent_registry_empty`;若低於 server-side `IWOOOS_WAZUH_EXPECTED_MIN_AGENT_COUNT`,API 必須回 `wazuh_agent_registry_below_expected`。 +- summary 會回 `expected_min_agent_count`、`agent_registry_empty_count`、`agent_below_expected_minimum_count`、`agent_visibility_no_false_green_count`;這些只描述 aggregate 狀態,不公開 agent 身分或內網位址。 - 不回傳 raw Wazuh payload、agent 原名、內網 IP、token、password 或 secret。 - 新增 source guard,阻擋硬編 Wazuh 內網 URL / port、帳密、關 TLS、假 SOC dashboard、假 CVE、raw payload 與 legacy dashboard component 回流。 - 新增 production readback 腳本,部署後可直接驗證 public API 不再 404、schema / status / boundary 正確,且沒有 raw payload、內網 IP、agent 原名或 secret 洩漏。 @@ -86,7 +88,7 @@ NEXT_PUBLIC_API_URL=https://awoooi.wooo.work NEXT_PRIVATE_BUILD_WORKER_COUNT=1 S 驗證結果: -- `pytest apps/api/tests/test_iwooos_wazuh_api.py`:`4 passed`。 +- `pytest apps/api/tests/test_iwooos_wazuh_api.py`:`6 passed`。 - `wazuh-readonly-route-boundary-guard`:`route=2 public_ui_files=1 forbidden=0 runtime_gate=0`。 - `wazuh-readonly-release-gate`:`source=1 push=0 deploy=0 readback=0 runtime_gate=0`。 - `wazuh-readonly-release-lane-preflight`:`ready=0 acks=0/6 evidence=0/6 runtime_gate=0`。 @@ -117,7 +119,7 @@ git am /private/tmp/awoooi-iwooos-wazuh-boundary-release-patch-/*.pat 乾淨套用 worktree 驗證結果: -- `pytest apps/api/tests/test_iwooos_wazuh_api.py`:`4 passed`。 +- `pytest apps/api/tests/test_iwooos_wazuh_api.py`:`6 passed`。 - `python3 scripts/security/wazuh-readonly-route-boundary-guard.py --root .`:`WAZUH_READONLY_ROUTE_BOUNDARY_GUARD_OK route=2 public_ui_files=1 forbidden=0 runtime_gate=0`。 - `python3 scripts/security/wazuh-readonly-release-gate.py --root .`:`WAZUH_READONLY_RELEASE_GATE_OK source=1 push=0 deploy=0 readback=0 runtime_gate=0`。 - `python3 scripts/security/wazuh-readonly-release-lane-preflight.py --root .`:`WAZUH_READONLY_RELEASE_LANE_PREFLIGHT_OK ready=0 acks=0/6 evidence=0/6 runtime_gate=0`。 @@ -184,6 +186,8 @@ python3 scripts/security/wazuh-readonly-production-readback.py --json 若 owner gate 與 server-side env 已正式啟用: - 成功時可回 `readonly_metadata_available`。 +- manager registry 為空時必須回 `wazuh_agent_registry_empty`,不得顯示綠燈。 +- manager registry 低於 `IWOOOS_WAZUH_EXPECTED_MIN_AGENT_COUNT` 時必須回 `wazuh_agent_registry_below_expected`,不得顯示綠燈。 - Wazuh 不可達時可回 `wazuh_readonly_metadata_unavailable`。 - 任何情況都不得回 raw payload、agent 原名、內網 IP、secret。 - 任何情況都不得因 route 可用而自動打開 active response、host write、Kali active scan 或 SOAR action。 @@ -199,6 +203,7 @@ python3 scripts/security/wazuh-readonly-production-readback.py --json | Wazuh release lane preflight | `100%` | 已完成;owner acks `0/6`、evidence `0/6`、正式 release ready `0` | | Wazuh release owner request / acceptance | `100%` | 已完成只讀草稿與收件帳本;request sent `0`、response accepted `0` | | Wazuh live metadata env gate | `100%` | 已完成只讀 gate;route readback / owner / secret metadata / live query 仍 `0` | +| Wazuh agent registry no-false-green | `100%` | source-side 已能區分 registry 空與低於預期;production live registry accepted 仍 `0` | | IwoooS 前台 Wazuh live metadata env gate 卡片 | `100%` | source-side 與本機桌機 / 手機驗證完成;production deploy 仍 `0` | | 乾淨套用 proof | `100%` | patch set 可落在最新 `gitea/main` 並通過同組 guard;最終 hash 以 release 前 readback 為準 | | Gitea push | `0%` | 受控 workspace HTTPS credential 缺失 | diff --git a/docs/security/WAZUH-AGENT-DISAPPEARANCE-INCIDENT-READBACK-2026-06-24.md b/docs/security/WAZUH-AGENT-DISAPPEARANCE-INCIDENT-READBACK-2026-06-24.md index 616ff48f..5f80bba5 100644 --- a/docs/security/WAZUH-AGENT-DISAPPEARANCE-INCIDENT-READBACK-2026-06-24.md +++ b/docs/security/WAZUH-AGENT-DISAPPEARANCE-INCIDENT-READBACK-2026-06-24.md @@ -38,6 +38,7 @@ 3. 缺少 production 只讀 API:`agent_total`、`agent_active`、`agent_disconnected`、`last_seen_present` 都沒有 live readback。 4. 缺少 no-false-green 告警:Dashboard 429/500、Wazuh API 401、agent 連線存在、IwoooS route 404 這些狀態沒有被合成一張 AI 事件卡。 5. 缺少 owner evidence:誰在 2026-06-23 14:48 後建立、重啟、登入或調整 112/Wazuh,尚未有脫敏 owner 回覆。 +6. 原 source-side API 缺少 agent count 退化分類:若 Wazuh manager registry 回 `agent_total=0` 或低於預期下限,不能再沿用正常 `readonly_metadata_available` 狀態。 ## 3.1 新增 no-false-green guard @@ -55,6 +56,8 @@ 解除條件必須是 Wazuh API 只讀中繼資料或 owner 提供的脫敏 registry evidence,不能用 Dashboard 看起來正常、agent service active、TCP 連線存在或 UI 卡片可見替代。 +本輪再補 IwoooS Wazuh API source-side no-false-green:`/api/iwooos/wazuh` 與 `/api/v1/iwooos/wazuh` 在 `agent_total=0` 時回 `wazuh_agent_registry_empty`,在低於 server-side `IWOOOS_WAZUH_EXPECTED_MIN_AGENT_COUNT` 時回 `wazuh_agent_registry_below_expected`。這只改判讀與 metadata,不代表 production 已部署、manager registry 已驗收或 active response 已授權。 + ## 4. 立即凍結邊界 在 manager 端 agent registry 被只讀驗收前,以下全部維持禁止: @@ -70,7 +73,7 @@ | 優先 | 工作 | 驗收條件 | 目前完成度 | |------|------|----------|------------| -| P0-A | Wazuh manager agent registry 只讀驗收 | owner 提供脫敏 `agent_total / active / disconnected / last_seen` ref,或經 server-side secret metadata 啟用 IwoooS 只讀 API | `40%` | +| P0-A | Wazuh manager agent registry 只讀驗收 | owner 提供脫敏 `agent_total / active / disconnected / last_seen` ref,或經 server-side secret metadata 啟用 IwoooS 只讀 API;source-side 已能把 registry 空或低於預期判成退化 | `48%` source-side、`0%` live registry accepted | | P0-B | Dashboard stored API / rate-limit / TLS trust 修復 gate | 查明 `/api/check-stored-api` 429/500 根因;維修前有 owner、rollback、postcheck;維修後 Dashboard 與 API count 一致 | `35%` | | P0-C | IwoooS live metadata route 正式部署 | `/api/iwooos/wazuh` 不再 404,回傳 schema `iwooos_wazuh_readonly_status_v1`,不洩漏 agent identity / internal IP / secret | `55%` source-side、`0%` production | | P0-D | Wazuh agent disappearance alert card | 產出 `ai_automation_alert_card_v1`,包含 agent count delta、Dashboard API status、manager health、next gate、owner;本輪已新增 `wazuh_dashboard_api_readback_degraded` formatter / test / guard、AwoooP `/runs/ai-alert-cards` delivery readback contract 與 Runs 前台面板 | `92%` source-side、`0%` production receipt | @@ -92,5 +95,5 @@ - 真正 agent registry 驗收:`0%`。 - IwoooS live readback production:`0%`。 - Dashboard stored API 修復:`0%`。 -- SOC / Wazuh no-false-green 納管:`63%`。 +- SOC / Wazuh no-false-green 納管:`66%`。 - active response / host write / auto block:`0%`,保持關閉。 diff --git a/scripts/security/wazuh-readonly-production-readback.py b/scripts/security/wazuh-readonly-production-readback.py index 4a594613..40e6cc53 100644 --- a/scripts/security/wazuh-readonly-production-readback.py +++ b/scripts/security/wazuh-readonly-production-readback.py @@ -26,6 +26,8 @@ ALLOWED_STATUSES = { "misconfigured_missing_server_side_wazuh_env", "wazuh_auth_token_missing", "wazuh_readonly_metadata_unavailable", + "wazuh_agent_registry_empty", + "wazuh_agent_registry_below_expected", "readonly_metadata_available", } FORBIDDEN_RESPONSE_PATTERNS = [ diff --git a/scripts/security/wazuh-readonly-route-boundary-guard.py b/scripts/security/wazuh-readonly-route-boundary-guard.py index be6b7505..a8ae2e9b 100644 --- a/scripts/security/wazuh-readonly-route-boundary-guard.py +++ b/scripts/security/wazuh-readonly-route-boundary-guard.py @@ -33,6 +33,7 @@ class ForbiddenPattern: ROUTE_REQUIRED_TOKENS = [ "IWOOOS_WAZUH_READONLY_ENABLED", + "IWOOOS_WAZUH_EXPECTED_MIN_AGENT_COUNT", "WAZUH_API_BASE_URL", "WAZUH_API_USERNAME", "WAZUH_API_PASSWORD", @@ -49,12 +50,18 @@ ROUTE_REQUIRED_TOKENS = [ "not_authorization: true", "redactedAgent", "alias: `agent-", + "wazuh_agent_registry_empty", + "wazuh_agent_registry_below_expected", + "agent_registry_empty_count", + "agent_below_expected_minimum_count", + "agent_visibility_no_false_green_count", ] BACKEND_REQUIRED_TOKENS = [ "/api/iwooos/wazuh", "/api/v1/iwooos/wazuh", "IWOOOS_WAZUH_READONLY_ENABLED", + "IWOOOS_WAZUH_EXPECTED_MIN_AGENT_COUNT", "WAZUH_API_BASE_URL", "WAZUH_API_USERNAME", "WAZUH_API_PASSWORD", @@ -65,6 +72,11 @@ BACKEND_REQUIRED_TOKENS = [ "raw_wazuh_payload_storage_allowed", "internal_ip_public_display_allowed", "_redacted_agent", + "wazuh_agent_registry_empty", + "wazuh_agent_registry_below_expected", + "agent_registry_empty_count", + "agent_below_expected_minimum_count", + "agent_visibility_no_false_green_count", ]