diff --git a/apps/api/src/api/v1/iwooos.py b/apps/api/src/api/v1/iwooos.py
index 80d1eaf7..3398cbcf 100644
--- a/apps/api/src/api/v1/iwooos.py
+++ b/apps/api/src/api/v1/iwooos.py
@@ -9,12 +9,8 @@ from __future__ import annotations
import asyncio
import json
-import os
-from base64 import b64encode
from typing import Any
-from urllib.parse import urljoin, urlparse
-import httpx
from fastapi import APIRouter, HTTPException, status
from fastapi.responses import JSONResponse
@@ -24,180 +20,18 @@ from src.services.iwooos_runtime_security_readback import (
from src.services.iwooos_security_control_coverage import (
load_latest_iwooos_security_control_coverage,
)
+from src.services.iwooos_wazuh_readonly_status import (
+ load_iwooos_wazuh_readonly_status,
+)
from src.services.public_redaction import redact_public_lan_topology
router = APIRouter(tags=["IwoooS Security"])
-REQUEST_TIMEOUT_SECONDS = 5.0
-
-
-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 _boundary_response(status_text: str, http_status: int = 200) -> JSONResponse:
- return JSONResponse(
- status_code=http_status,
- content={
- "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 _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 _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_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()
- payload = response.json()
- return payload if isinstance(payload, dict) else {}
async def _wazuh_readonly_status() -> JSONResponse:
- env = _wazuh_env()
- if env["enabled"] != "true":
- return _boundary_response("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_response("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_response("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_response("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_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": _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": 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,
- "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(),
- },
- )
+ result = await load_iwooos_wazuh_readonly_status()
+ return JSONResponse(status_code=result.http_status, content=result.payload)
@router.get("/api/iwooos/wazuh")
@@ -216,14 +50,20 @@ async def get_iwooos_wazuh_readonly_status_v1() -> JSONResponse:
summary="取得 IwoooS runtime security readback",
description=(
"讀取最新已提交的 IwoooS 資安只讀快照,彙總 Wazuh、Kali、SOC/SIEM、"
- "告警可讀性、owner dispatch 與外部入侵防護 Gate。此端點不呼叫 Wazuh / Kali / "
- "主機 / Docker / Nginx / firewall / Telegram,不收集 secret,不授權 runtime 寫入。"
+ "告警可讀性、owner dispatch 與外部入侵防護 Gate,並附上 Wazuh 只讀路由的"
+ "公開安全 aggregate 讀回。此端點不呼叫 Kali / 主機 / Docker / Nginx / firewall / "
+ "Telegram,不保存 raw Wazuh payload,不收集 secret,不授權 runtime 寫入。"
),
)
async def get_iwooos_runtime_security_readback() -> dict[str, Any]:
"""回傳 IwoooS 資安 runtime readback 只讀總板。"""
try:
- payload = await asyncio.to_thread(load_latest_iwooos_runtime_security_readback)
+ wazuh_result = await load_iwooos_wazuh_readonly_status()
+ payload = await asyncio.to_thread(
+ load_latest_iwooos_runtime_security_readback,
+ wazuh_live_status=wazuh_result.payload,
+ wazuh_live_http_status=wazuh_result.http_status,
+ )
return redact_public_lan_topology(payload)
except FileNotFoundError as exc:
raise HTTPException(
diff --git a/apps/api/src/services/iwooos_runtime_security_readback.py b/apps/api/src/services/iwooos_runtime_security_readback.py
index d277fe40..8304bb05 100644
--- a/apps/api/src/services/iwooos_runtime_security_readback.py
+++ b/apps/api/src/services/iwooos_runtime_security_readback.py
@@ -60,6 +60,8 @@ _FALSE_BOUNDARY_KEYS = {
def load_latest_iwooos_runtime_security_readback(
security_dir: Path | None = None,
+ wazuh_live_status: dict[str, Any] | None = None,
+ wazuh_live_http_status: int = 0,
) -> dict[str, Any]:
"""Load and normalize the current IwoooS runtime security readback."""
directory = security_dir or _DEFAULT_SECURITY_DIR
@@ -72,6 +74,7 @@ def load_latest_iwooos_runtime_security_readback(
alert_summary = _summary(snapshots["alert_readability"])
dispatch_summary = _summary(snapshots["owner_dispatch"])
intrusion_summary = _summary(snapshots["intrusion_prevention"])
+ live_wazuh = _wazuh_live_summary(wazuh_live_status, wazuh_live_http_status)
source_refs = [f"docs/security/{filename}" for filename in _SNAPSHOT_FILES.values()]
runtime_gate_count = _max_summary_count(
@@ -85,11 +88,11 @@ def load_latest_iwooos_runtime_security_readback(
return {
"schema_version": "iwooos_runtime_security_readback_v1",
"status": "blocked_waiting_owner_evidence_and_runtime_gates",
- "mode": "committed_snapshot_readback_only_no_runtime_query",
+ "mode": "committed_snapshot_readback_with_public_safe_wazuh_route_metadata",
"source_refs": source_refs,
"summary": {
"source_snapshot_count": len(source_refs),
- "p0_lane_count": 6,
+ "p0_lane_count": 7,
"control_plane_visibility_percent": _average_percent(
soc_summary.get("coverage_percent_after_soc_integration_control"),
intrusion_summary.get("coverage_percent_after_prevention_control"),
@@ -106,6 +109,15 @@ def load_latest_iwooos_runtime_security_readback(
"wazuh_dashboard_api_degraded_observed_count": _int(
wazuh_summary.get("dashboard_api_degraded_observed_count")
),
+ "wazuh_live_route_http_status": live_wazuh["http_status"],
+ "wazuh_live_route_degraded_count": live_wazuh["degraded_count"],
+ "wazuh_live_readonly_api_enabled_count": live_wazuh["readonly_api_enabled_count"],
+ "wazuh_live_agent_total": live_wazuh["agent_total"],
+ "wazuh_live_agent_active": live_wazuh["agent_active"],
+ "wazuh_live_registry_empty_count": live_wazuh["agent_registry_empty_count"],
+ "wazuh_live_below_expected_count": live_wazuh["agent_below_expected_minimum_count"],
+ "wazuh_live_metadata_available_count": live_wazuh["metadata_available_count"],
+ "wazuh_live_status": live_wazuh["status"],
"kali_active_scan_authorized_count": _int(soc_summary.get("kali_active_scan_authorized_count")),
"kali_execute_authorized_count": _int(soc_summary.get("kali_execute_authorized_count")),
"kali_finding_envelope_accepted_count": _int(soc_summary.get("kali_finding_envelope_accepted_count")),
@@ -128,6 +140,21 @@ def load_latest_iwooos_runtime_security_readback(
},
["docs/security/wazuh-managed-host-coverage-gate.snapshot.json"],
),
+ _lane(
+ "wazuh_live_route",
+ live_wazuh["status"],
+ 0 if live_wazuh["degraded_count"] else 30,
+ "steady" if live_wazuh["metadata_available_count"] else "warn",
+ "enable_readonly_metadata_owner_gate_and_manager_registry_cross_check",
+ {
+ "http_status": live_wazuh["http_status"],
+ "readonly_enabled": live_wazuh["readonly_api_enabled_count"],
+ "agent_total": live_wazuh["agent_total"],
+ "metadata_available": live_wazuh["metadata_available_count"],
+ "route_degraded": live_wazuh["degraded_count"],
+ },
+ ["GET /api/iwooos/wazuh"],
+ ),
_lane(
"wazuh_dashboard_api",
"degraded_api_connection_not_green",
@@ -223,6 +250,7 @@ def load_latest_iwooos_runtime_security_readback(
"owner_request_draft_is_not_owner_acceptance",
"kali_health_is_not_active_scan_authorization",
"alert_format_contract_is_not_telegram_send_receipt",
+ "wazuh_live_route_disabled_or_degraded_is_p0_not_green",
],
}
@@ -250,6 +278,36 @@ def _int(value: Any) -> int:
return value if isinstance(value, int) else 0
+def _wazuh_live_summary(payload: dict[str, Any] | None, http_status: int) -> dict[str, Any]:
+ if not isinstance(payload, dict):
+ return {
+ "status": "not_checked_by_snapshot_loader",
+ "http_status": 0,
+ "degraded_count": 1,
+ "readonly_api_enabled_count": 0,
+ "agent_total": 0,
+ "agent_active": 0,
+ "agent_registry_empty_count": 0,
+ "agent_below_expected_minimum_count": 0,
+ "metadata_available_count": 0,
+ }
+ summary = payload.get("summary")
+ summary = summary if isinstance(summary, dict) else {}
+ status_text = str(payload.get("status") or "unknown")
+ metadata_available = status_text == "readonly_metadata_available"
+ return {
+ "status": status_text,
+ "http_status": http_status if isinstance(http_status, int) else 0,
+ "degraded_count": 0 if metadata_available else 1,
+ "readonly_api_enabled_count": _int(summary.get("readonly_api_enabled_count")),
+ "agent_total": _int(summary.get("agent_total")),
+ "agent_active": _int(summary.get("agent_active")),
+ "agent_registry_empty_count": _int(summary.get("agent_registry_empty_count")),
+ "agent_below_expected_minimum_count": _int(summary.get("agent_below_expected_minimum_count")),
+ "metadata_available_count": 1 if metadata_available else 0,
+ }
+
+
def _average_percent(*values: Any) -> int:
percents = [_int(value) for value in values if isinstance(value, int)]
return int(round(sum(percents) / len(percents))) if percents else 0
diff --git a/apps/api/src/services/iwooos_wazuh_readonly_status.py b/apps/api/src/services/iwooos_wazuh_readonly_status.py
new file mode 100644
index 00000000..c78a09d6
--- /dev/null
+++ b/apps/api/src/services/iwooos_wazuh_readonly_status.py
@@ -0,0 +1,194 @@
+"""
+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(),
+ },
+ )
diff --git a/apps/api/tests/test_iwooos_runtime_security_readback.py b/apps/api/tests/test_iwooos_runtime_security_readback.py
index abcd2e1d..652bd2f9 100644
--- a/apps/api/tests/test_iwooos_runtime_security_readback.py
+++ b/apps/api/tests/test_iwooos_runtime_security_readback.py
@@ -21,11 +21,15 @@ def test_iwooos_runtime_security_readback_preserves_zero_runtime_gates() -> None
assert payload["schema_version"] == "iwooos_runtime_security_readback_v1"
assert payload["status"] == "blocked_waiting_owner_evidence_and_runtime_gates"
assert payload["summary"]["source_snapshot_count"] == 8
- assert payload["summary"]["p0_lane_count"] == 6
+ assert payload["summary"]["p0_lane_count"] == 7
assert payload["summary"]["runtime_gate_count"] == 0
assert payload["summary"]["owner_response_received_count"] == 0
assert payload["summary"]["owner_response_accepted_count"] == 0
assert payload["summary"]["wazuh_manager_registry_accepted_count"] == 0
+ assert payload["summary"]["wazuh_live_status"] == "not_checked_by_snapshot_loader"
+ assert payload["summary"]["wazuh_live_route_degraded_count"] == 1
+ assert payload["summary"]["wazuh_live_readonly_api_enabled_count"] == 0
+ assert payload["summary"]["wazuh_live_metadata_available_count"] == 0
assert payload["summary"]["kali_active_scan_authorized_count"] == 0
assert payload["summary"]["kali_execute_authorized_count"] == 0
assert payload["summary"]["alert_receipt_runtime_send_count"] == 0
@@ -41,6 +45,7 @@ def test_iwooos_runtime_security_readback_lanes_are_candidate_only() -> None:
lane_ids = {lane["lane_id"] for lane in payload["lanes"]}
assert lane_ids == {
"wazuh_registry",
+ "wazuh_live_route",
"wazuh_dashboard_api",
"kali_intake",
"alert_readability",
@@ -52,16 +57,69 @@ def test_iwooos_runtime_security_readback_lanes_are_candidate_only() -> None:
assert all(lane["source_refs"] for lane in payload["lanes"])
assert any(lane["completion_percent"] > 0 for lane in payload["lanes"])
assert all(lane["lane_id"] != "wazuh_registry" or lane["completion_percent"] == 0 for lane in payload["lanes"])
+ assert all(lane["lane_id"] != "wazuh_live_route" or lane["metrics"]["route_degraded"] == 1 for lane in payload["lanes"])
-def test_iwooos_runtime_security_readback_api_is_public_safe() -> None:
+def test_iwooos_runtime_security_readback_api_is_public_safe(monkeypatch) -> None:
+ monkeypatch.delenv("IWOOOS_WAZUH_READONLY_ENABLED", raising=False)
+ monkeypatch.delenv("WAZUH_API_BASE_URL", raising=False)
+ monkeypatch.delenv("WAZUH_API_USERNAME", raising=False)
+ monkeypatch.delenv("WAZUH_API_PASSWORD", raising=False)
+
response = _client().get("/api/v1/iwooos/runtime-security-readback")
assert response.status_code == 200
data = response.json()
assert data["schema_version"] == "iwooos_runtime_security_readback_v1"
assert data["summary"]["runtime_gate_count"] == 0
+ assert data["summary"]["wazuh_live_status"] == "disabled_waiting_iwooos_wazuh_owner_gate"
+ assert data["summary"]["wazuh_live_route_http_status"] == 200
+ assert data["summary"]["wazuh_live_route_degraded_count"] == 1
+ assert data["summary"]["wazuh_live_metadata_available_count"] == 0
assert data["boundaries"]["secret_value_collection_allowed"] is False
assert "192.168.0." not in response.text
assert "工作視窗" not in response.text
assert "批准!繼續" not in response.text
+
+
+def test_iwooos_runtime_security_readback_api_includes_live_wazuh_empty_registry(monkeypatch) -> None:
+ import httpx
+
+ 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", "6")
+
+ 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/v1/iwooos/runtime-security-readback")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["summary"]["wazuh_live_status"] == "wazuh_agent_registry_empty"
+ assert data["summary"]["wazuh_live_readonly_api_enabled_count"] == 1
+ assert data["summary"]["wazuh_live_agent_total"] == 0
+ assert data["summary"]["wazuh_live_registry_empty_count"] == 1
+ assert data["summary"]["wazuh_live_route_degraded_count"] == 1
+ assert data["summary"]["runtime_gate_count"] == 0
+ assert "token-value" not in response.text
diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json
index 25270479..8243a99f 100644
--- a/apps/web/messages/en.json
+++ b/apps/web/messages/en.json
@@ -11635,6 +11635,26 @@
"nextAction": "下一步",
"reason": "原因"
},
+ "executorHandoff": {
+ "title": "Executor handoff readiness",
+ "subtitle": "把批准後是否能進執行器、缺少的 owner 欄位與 verifier 條件集中顯示;批准此卡不代表 runtime gate 已開。",
+ "runtime": {
+ "open": "runtime gate open",
+ "closed": "runtime gate closed"
+ },
+ "metrics": {
+ "readiness": "可交接度",
+ "ready": "已備妥",
+ "blocked": "卡點",
+ "status": "狀態"
+ },
+ "nextAction": "下一步",
+ "blocker": "阻擋原因",
+ "missingTitle": "缺少的 owner review / 安全路由欄位",
+ "missingEmpty": "未回報缺欄位;請仍以 runtime gate 與 verifier 為準",
+ "openWorkItem": "開啟 owner review",
+ "openRuns": "追蹤 Runs"
+ },
"evidence": {
"executor": "執行器",
"ansible": "Ansible",
@@ -20263,8 +20283,8 @@
},
"runtimeSecurityReadback": {
"eyebrow": "IwoooS Runtime 資安讀回",
- "title": "六條 P0 資安線先接到同一張讀回板",
- "subtitle": "這張板讀取後端彙整的只讀快照,集中顯示 Wazuh 清單驗收、儀表板 API、資安觀測節點 intake、告警可讀性、負責人送件與外部入侵防護;它不查 live Wazuh、不啟動掃描、不送訊息、不改主機。",
+ "title": "七條 P0 資安線先接到同一張讀回板",
+ "subtitle": "這張板讀取後端彙整的只讀快照,並附上 Wazuh 正式只讀路由的公開安全 aggregate 讀回;它不保存 raw Wazuh payload、不啟動掃描、不送訊息、不改主機。",
"statusLabel": "讀回狀態",
"statusDetail": "讀回成功只代表 IwoooS 能看見目前的證據邊界;runtime 寫入、主動回應、掃描、重啟、Nginx reload、workflow 修改與機密操作仍全部關閉。",
"laneStatusLabel": "目前狀態",
@@ -20290,6 +20310,10 @@
"label": "Wazuh 清單",
"detail": "管理器清單接受數仍為 0。"
},
+ "wazuhLive": {
+ "label": "Wazuh live",
+ "detail": "正式只讀路由若 disabled、空清單或低於預期,首屏直接顯示警示。"
+ },
"ownerAccepted": {
"label": "負責人驗收",
"detail": "收到 / 接受都必須由正式 owner response 證明。"
@@ -20308,6 +20332,10 @@
"title": "Wazuh manager registry",
"body": "必須拿到總數、在線、離線、從未連線與時間窗;transport 或儀表板可見不能代替驗收。"
},
+ "wazuh_live_route": {
+ "title": "Wazuh 正式只讀路由",
+ "body": "直接讀正式路由的公開安全 aggregate;disabled、misconfigured、empty、below expected 都是 P0 紅燈,不會被 route 200 蓋過。"
+ },
"wazuh_dashboard_api": {
"title": "Wazuh Dashboard API",
"body": "API connection / API version 還要補 readback;index pattern 通過不能宣稱 Wazuh 全綠。"
diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json
index 25270479..8243a99f 100644
--- a/apps/web/messages/zh-TW.json
+++ b/apps/web/messages/zh-TW.json
@@ -11635,6 +11635,26 @@
"nextAction": "下一步",
"reason": "原因"
},
+ "executorHandoff": {
+ "title": "Executor handoff readiness",
+ "subtitle": "把批准後是否能進執行器、缺少的 owner 欄位與 verifier 條件集中顯示;批准此卡不代表 runtime gate 已開。",
+ "runtime": {
+ "open": "runtime gate open",
+ "closed": "runtime gate closed"
+ },
+ "metrics": {
+ "readiness": "可交接度",
+ "ready": "已備妥",
+ "blocked": "卡點",
+ "status": "狀態"
+ },
+ "nextAction": "下一步",
+ "blocker": "阻擋原因",
+ "missingTitle": "缺少的 owner review / 安全路由欄位",
+ "missingEmpty": "未回報缺欄位;請仍以 runtime gate 與 verifier 為準",
+ "openWorkItem": "開啟 owner review",
+ "openRuns": "追蹤 Runs"
+ },
"evidence": {
"executor": "執行器",
"ansible": "Ansible",
@@ -20263,8 +20283,8 @@
},
"runtimeSecurityReadback": {
"eyebrow": "IwoooS Runtime 資安讀回",
- "title": "六條 P0 資安線先接到同一張讀回板",
- "subtitle": "這張板讀取後端彙整的只讀快照,集中顯示 Wazuh 清單驗收、儀表板 API、資安觀測節點 intake、告警可讀性、負責人送件與外部入侵防護;它不查 live Wazuh、不啟動掃描、不送訊息、不改主機。",
+ "title": "七條 P0 資安線先接到同一張讀回板",
+ "subtitle": "這張板讀取後端彙整的只讀快照,並附上 Wazuh 正式只讀路由的公開安全 aggregate 讀回;它不保存 raw Wazuh payload、不啟動掃描、不送訊息、不改主機。",
"statusLabel": "讀回狀態",
"statusDetail": "讀回成功只代表 IwoooS 能看見目前的證據邊界;runtime 寫入、主動回應、掃描、重啟、Nginx reload、workflow 修改與機密操作仍全部關閉。",
"laneStatusLabel": "目前狀態",
@@ -20290,6 +20310,10 @@
"label": "Wazuh 清單",
"detail": "管理器清單接受數仍為 0。"
},
+ "wazuhLive": {
+ "label": "Wazuh live",
+ "detail": "正式只讀路由若 disabled、空清單或低於預期,首屏直接顯示警示。"
+ },
"ownerAccepted": {
"label": "負責人驗收",
"detail": "收到 / 接受都必須由正式 owner response 證明。"
@@ -20308,6 +20332,10 @@
"title": "Wazuh manager registry",
"body": "必須拿到總數、在線、離線、從未連線與時間窗;transport 或儀表板可見不能代替驗收。"
},
+ "wazuh_live_route": {
+ "title": "Wazuh 正式只讀路由",
+ "body": "直接讀正式路由的公開安全 aggregate;disabled、misconfigured、empty、below expected 都是 P0 紅燈,不會被 route 200 蓋過。"
+ },
"wazuh_dashboard_api": {
"title": "Wazuh Dashboard API",
"body": "API connection / API version 還要補 readback;index pattern 通過不能宣稱 Wazuh 全綠。"
diff --git a/apps/web/src/app/[locale]/awooop/approvals/page.tsx b/apps/web/src/app/[locale]/awooop/approvals/page.tsx
index 547bf3a4..2089705c 100644
--- a/apps/web/src/app/[locale]/awooop/approvals/page.tsx
+++ b/apps/web/src/app/[locale]/awooop/approvals/page.tsx
@@ -633,6 +633,159 @@ function Gate5ProjectionBadge() {
);
}
+function handoffWorkItemHref(
+ projectId: string,
+ incidentId: string,
+ chain: AwoooPStatusChain | null
+) {
+ const promotion = chain?.repair_candidate_promotion;
+ const href = promotion?.work_item_url
+ ?? promotion?.contract?.source_work_item_url
+ ?? null;
+ if (href && href.startsWith("/")) return href;
+
+ const workItemId = promotion?.work_item_id
+ ?? promotion?.contract?.source_work_item_id
+ ?? chain?.automation_handoff?.work_item_id
+ ?? "";
+ const params = new URLSearchParams({ project_id: projectId, incident_id: incidentId });
+ if (workItemId) params.set("work_item_id", workItemId);
+ return `/awooop/work-items?${params.toString()}`;
+}
+
+function ExecutorHandoffReadinessCard({
+ projectId,
+ incidentId,
+ chain,
+}: {
+ projectId: string;
+ incidentId: string;
+ chain: AwoooPStatusChain | null;
+}) {
+ const t = useTranslations("awooop.approvals.incidentFocus.executorHandoff");
+ const promotion = chain?.repair_candidate_promotion;
+ const contract = promotion?.contract;
+ const closure = chain?.automation_handoff?.closure_readiness;
+ const runtimeAllowed = Boolean(
+ promotion?.runtime_execution_authorized
+ || contract?.runtime_execution_authorized
+ || closure?.runtime_execution_authorized
+ || chain?.automation_handoff?.runtime_execution_authorized
+ );
+ const ready = Number(contract?.ready_count ?? closure?.ready_count ?? 0);
+ const total = Number(contract?.total_count ?? closure?.total_count ?? 0);
+ const blocked = Number(contract?.blocked_count ?? closure?.blocked_count ?? 0);
+ const readinessPercent = total > 0 ? Math.min(100, Math.round((ready / total) * 100)) : 0;
+ const status = promotion?.status ?? contract?.status ?? closure?.status ?? chain?.automation_handoff?.status ?? "not_available";
+ const nextAction = chain?.automation_handoff?.next_action
+ ?? closure?.next_action
+ ?? promotion?.summary
+ ?? chain?.operator_outcome?.next_action
+ ?? chain?.next_step
+ ?? "--";
+ const blocker = closure?.blocked_reason
+ ?? promotion?.reason
+ ?? chain?.operator_outcome?.human_action_reason
+ ?? chain?.blockers?.[0]
+ ?? "--";
+ const blockedFields = [
+ ...(contract?.blocked_fields ?? []),
+ ...(closure?.required_owner_fields ?? []),
+ ].filter((field, index, fields) => field && fields.indexOf(field) === index).slice(0, 8);
+ const workItemHref = handoffWorkItemHref(projectId, incidentId, chain);
+ const runsHref = `/awooop/runs?project_id=${encodeURIComponent(projectId)}&incident_id=${encodeURIComponent(incidentId)}`;
+
+ return (
+
+
+
+
+
+
{t("title")}
+
+
{t("subtitle")}
+
+
+ {runtimeAllowed ? t("runtime.open") : t("runtime.closed")}
+
+
+
+
+ {[
+ [t("metrics.readiness"), `${readinessPercent}%`],
+ [t("metrics.ready"), `${ready}/${total || "--"}`],
+ [t("metrics.blocked"), String(blocked)],
+ [t("metrics.status"), status],
+ ].map(([label, value]) => (
+
+
{label}
+
+ {value}
+
+
+ ))}
+
+
+
+
+
+
+
{t("nextAction")}
+
{nextAction}
+
+
+
{t("blocker")}
+
{blocker}
+
+
+
+
+
{t("missingTitle")}
+
+ {blockedFields.length > 0 ? blockedFields.map((field) => (
+
+ {field}
+
+ )) : (
+
+ {t("missingEmpty")}
+
+ )}
+
+
+
+
+
+ {t("openWorkItem")}
+
+
+
+ {t("openRuns")}
+
+
+
+
+
+ );
+}
+
function approvalDecisionRailToneClass(tone: ApprovalDecisionRailTone) {
if (tone === "ok") return "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]";
if (tone === "blocked") return "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]";
@@ -1459,6 +1612,11 @@ function FocusedIncidentApprovalPanel({
{linkedApprovalIds.length > 0 ? t("linkedExplanation") : t("unlinkedExplanation")}
+
@@ -1530,6 +1688,7 @@ export default function ApprovalsPage() {
setError(null);
setLegacyError(null);
const params = new URLSearchParams();
+ params.set("project_id", projectId);
if (evidenceFilter) params.set("remediation_status", evidenceFilter);
const qs = params.toString();
const [platformResult, legacyResult] = await Promise.allSettled([
@@ -1560,7 +1719,7 @@ export default function ApprovalsPage() {
} finally {
setLoading(false);
}
- }, [evidenceFilter, t]);
+ }, [evidenceFilter, projectId, t]);
useEffect(() => {
fetchApprovals();
diff --git a/apps/web/src/app/[locale]/iwooos/page.tsx b/apps/web/src/app/[locale]/iwooos/page.tsx
index bbc7d658..41953903 100644
--- a/apps/web/src/app/[locale]/iwooos/page.tsx
+++ b/apps/web/src/app/[locale]/iwooos/page.tsx
@@ -326,7 +326,7 @@ type WazuhReadonlyStatusResponse = {
}
type RuntimeSecurityReadbackSummaryItem = {
- key: 'controlPlane' | 'runtimeAcceptance' | 'wazuhRegistry' | 'ownerAccepted' | 'kaliRuntime' | 'runtimeGate'
+ key: 'controlPlane' | 'runtimeAcceptance' | 'wazuhRegistry' | 'wazuhLive' | 'ownerAccepted' | 'kaliRuntime' | 'runtimeGate'
value: string
icon: typeof ShieldCheck
tone: 'steady' | 'warn' | 'locked'
@@ -8124,6 +8124,12 @@ function IwoooSRuntimeSecurityReadbackBoard() {
icon: Radar,
tone: 'locked',
},
+ {
+ key: 'wazuhLive',
+ value: summary ? `${summary.wazuh_live_agent_total}/${summary.wazuh_live_status}` : '...',
+ icon: Route,
+ tone: summary && summary.wazuh_live_metadata_available_count === 1 && summary.wazuh_live_route_degraded_count === 0 ? 'steady' : 'warn',
+ },
{
key: 'ownerAccepted',
value: summary ? `${summary.owner_response_accepted_count}/${summary.owner_response_received_count}` : '...',
diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts
index 2adef265..1aed41f3 100644
--- a/apps/web/src/lib/api-client.ts
+++ b/apps/web/src/lib/api-client.ts
@@ -100,6 +100,7 @@ export type IwoooSRuntimeSecurityReadbackTone = 'steady' | 'warn' | 'locked'
export interface IwoooSRuntimeSecurityReadbackLane {
lane_id:
| 'wazuh_registry'
+ | 'wazuh_live_route'
| 'wazuh_dashboard_api'
| 'kali_intake'
| 'alert_readability'
@@ -131,6 +132,15 @@ export interface IwoooSRuntimeSecurityReadbackResponse {
wazuh_manager_registry_accepted_count: number
wazuh_transport_observed_count: number
wazuh_dashboard_api_degraded_observed_count: number
+ wazuh_live_route_http_status: number
+ wazuh_live_route_degraded_count: number
+ wazuh_live_readonly_api_enabled_count: number
+ wazuh_live_agent_total: number
+ wazuh_live_agent_active: number
+ wazuh_live_registry_empty_count: number
+ wazuh_live_below_expected_count: number
+ wazuh_live_metadata_available_count: number
+ wazuh_live_status: string
kali_active_scan_authorized_count: number
kali_execute_authorized_count: number
kali_finding_envelope_accepted_count: number
diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md
index baf712a3..47cffdf4 100644
--- a/docs/LOGBOOK.md
+++ b/docs/LOGBOOK.md
@@ -1,3 +1,37 @@
+## 2026-06-26|D1E AwoooP Approvals:批准後 executor handoff readiness 前台可見
+
+**背景**:使用者指出 Telegram 告警批准後仍沒有真正自動化,AwoooP Approvals 也看不到可操作選項或清楚人工接手方式。程式碼讀回確認 `adr100_runtime_replay_gate5` 投影型 approval 會被 API 以 `409` 阻擋,原因是尚未接上 `auto_repair_executor` 執行 handoff;但 Approvals 頁只顯示「等待 executor handoff」與一般 Work Items 連結,沒有把缺少的 owner review / 安全路由 / verifier 條件前移。
+
+**完成內容**:
+- `/zh-TW/awooop/approvals` 的焦點 Incident 區塊新增 `Executor handoff readiness` 卡。
+- 直接顯示可交接度、ready / total、blocked count、status、`runtime gate closed`、下一步、阻擋原因與缺少的 owner review / 安全路由欄位。
+- `開啟 owner review` 連到同一 Incident / Work Item;`追蹤 Runs` 連到同一 Incident 的 Runs。
+- 平台 approval API 查詢補上 `project_id` filter,避免跨產品納管後把不同專案的待審資料混在一起。
+- 不把批准卡誤導成執行卡;不觸發 executor、不套用 PlayBook、不執行 Ansible、不發 Telegram、不重啟服務、不開 runtime gate。
+
+**Commit / deploy**:
+- Code commit:`2239507e0 fix(web): expose approval executor handoff readiness`。
+- Deploy marker:`335d5f4a7 chore(cd): deploy 2239507 [skip ci]`。
+- 中間平行 commit:`18a35c5e6 fix(ops): avoid unknown stock blockers when fresh` 已包含本段 code commit,且本地已 fast-forward 到 deploy marker。
+
+**正式站驗證**:
+- Desktop:`https://awoooi.wooo.work/zh-TW/awooop/approvals?project_id=awoooi&incident_id=INC-PROD-D4&_v=335d5f4a-approval-handoff-readiness-desktop`,`Executor handoff readiness`、`可交接度`、`runtime gate closed`、`開啟 owner review`、`追蹤 Runs` 可見;Work Items / Runs href 可用;`horizontalOverflow=false`、`appError=false`。
+- Mobile:`https://awoooi.wooo.work/zh-TW/awooop/approvals?project_id=awoooi&incident_id=INC-PROD-D4&_v=335d5f4a-approval-handoff-readiness-mobile`,同組內容可見;`clientWidth=384`、`scrollWidth=384`、`horizontalOverflow=false`、`appError=false`、操作入口 2 個且皆為導覽入口。
+- 截圖:`/tmp/awoooi-approvals-handoff-readiness-desktop-335d5f4a.png`、`/tmp/awoooi-approvals-handoff-readiness-mobile-335d5f4a.png`。
+
+**完成度**:
+- Approvals executor handoff readiness 可視化:正式站 `100%`。
+- Telegram / AwoooP 告警自動化可追蹤性:`98% -> 99%`。
+- 真正 AI 自動化 runtime 閉環:仍 `15-25%`。
+- active runtime gate:仍 `0`。
+
+**後續缺口**:
+- 下一步必須讓新告警或重診真的產生 `repair_candidate_promotion_contract_v1`,再走 owner release、maintenance window、rollback owner、controlled execution、post-apply verifier 與 KM / PlayBook trust 回寫。
+- 舊 Incident 不會 retroactive 生成完整 promotion contract;需以新 incident / 重診驗證 approval -> controlled execution -> verifier -> KM / PlayBook trust 全鏈。
+
+**邊界**:
+- 本段沒有 runtime execution、沒有 service restart、沒有 Ansible apply、沒有 Telegram send、沒有 provider switch、沒有 active scan、沒有 SSH、沒有 secret read。
+
## 2026-06-26|D1D Knowledge Base 首屏補強:KM / PlayBook / RAG 缺口可見化
**背景**:使用者指出 KM、PlayBook、腳本、排程、自動化機制與 Verifier 沉澱結果在頁面看不到,會讓 AI 自動化成果等於沒有做。正式 API 讀回確認知識庫並非無資料:`/api/v1/knowledge?project_id=awoooi&limit=50` 回 `total=667`;真正問題是首屏沒有把「哪些資產有沉澱、哪些仍缺」說清楚,且 `/api/v1/knowledge/rag/stats` 顯示 RAG chunks / sources 仍為 `0 / 0`。
@@ -151,6 +185,42 @@
**邊界**:本段只改前端全域 App Shell、Sidebar/Header 與 i18n;沒有改 AI runtime gate、Telegram send、告警路由、主機、Nginx、K8s、secret、DB、workflow 或自動修復授權。
+## 2026-06-26|P0 Wazuh live route 進 Runtime 資安讀回:disabled / empty 不再藏在下方卡片
+
+**背景**:正式站 live readback 顯示 `GET /api/iwooos/wazuh` 與 `GET /api/v1/iwooos/wazuh` 目前皆為 `200 disabled_waiting_iwooos_wazuh_owner_gate`,代表 Wazuh 只讀 live metadata 尚未啟用;這不是 manager registry 恢復、不是 agent 全部納管,也不是 API connection 已修復。為避免 Wazuh 退化只藏在 IwoooS 頁面下方卡片,本段把正式 Wazuh 只讀路由的公開安全 aggregate 結果接進 Runtime 資安讀回總板。
+
+**完成**:
+- 新增 `apps/api/src/services/iwooos_wazuh_readonly_status.py`,把 Wazuh 只讀 metadata 邏輯從 API router 抽成可重用 service;仍只回 aggregate / redacted agent alias,不保存 raw Wazuh payload、不顯示 agent 原名、內網位址、token、password 或 secret。
+- `GET /api/iwooos/wazuh` 與 `GET /api/v1/iwooos/wazuh` 保持相容,改由 service 回傳相同 disabled / misconfigured / unavailable / empty / below expected / available 狀態。
+- `GET /api/v1/iwooos/runtime-security-readback` 新增 `wazuh_live_route` P0 lane 與 `wazuh_live_*` summary:正式路由 HTTP、唯讀查詢啟用數、agent total / active、registry empty、below expected、metadata available 與 degraded count 都會進 Runtime board。
+- `/zh-TW/iwooos` Runtime 資安讀回摘要新增 `Wazuh live`,將 `agent_total / status` 顯示在首屏板;disabled、misconfigured、empty、below expected 或 unavailable 都以警示色呈現,不能被 route 200 蓋過。
+- `scripts/security/wazuh-readonly-route-boundary-guard.py` 已從掃 2 個 route 擴充為掃 3 個 source:Next route、FastAPI route、新 Wazuh service;避免 service 內硬編 Wazuh URL、帳密、關 TLS、raw payload 或假 SOC 文案。
+- `IWOOOS-WAZUH-READONLY-API-RELEASE-HANDOFF.md` 已同步新 service、Runtime lane 與 `route=3` guard 結果。
+
+**本地驗證**:
+- `pytest apps/api/tests/test_iwooos_runtime_security_readback.py apps/api/tests/test_iwooos_wazuh_api.py -q`:`10 passed, 1 warning`。
+- IwoooS / Telegram / operator 關鍵子集:`255 passed, 2 warnings`。
+- `python3 scripts/security/wazuh-readonly-route-boundary-guard.py --root .`:`WAZUH_READONLY_ROUTE_BOUNDARY_GUARD_OK route=3 public_ui_files=1 forbidden=0 runtime_gate=0`。
+- `python3 scripts/security/security-mirror-progress-guard.py --root .`:`SECURITY_MIRROR_PROGRESS_GUARD_OK`。
+- `python3 scripts/security/source-control-owner-response-guard.py --root .`:`SOURCE_CONTROL_OWNER_RESPONSE_GUARD_OK`。
+- `python3 scripts/ops/doc-secrets-sanity-check.py docs/security docs/templates docs/LOGBOOK.md apps/web/messages apps/api/src/api/v1/iwooos.py apps/api/src/services/iwooos_runtime_security_readback.py apps/api/src/services/iwooos_wazuh_readonly_status.py apps/web/src/app/[locale]/iwooos/page.tsx apps/web/src/lib/api-client.ts scripts/security/wazuh-readonly-route-boundary-guard.py`:`DOC_SECRET_SANITY_OK scanned_files=274`。
+- `python3 -m json.tool apps/web/messages/zh-TW.json` / `apps/web/messages/en.json`:通過。
+- `python3 -m py_compile apps/api/src/api/v1/iwooos.py apps/api/src/services/iwooos_runtime_security_readback.py apps/api/src/services/iwooos_wazuh_readonly_status.py scripts/security/wazuh-readonly-route-boundary-guard.py`:通過。
+- `git diff --check`:通過。
+- 本地 FastAPI TestClient smoke:`/api/v1/iwooos/runtime-security-readback` 回 `200`、`p0_lane_count=7`、`wazuh_live_status=disabled_waiting_iwooos_wazuh_owner_gate`、`wazuh_live_route_degraded_count=1`、`wazuh_live_route` lane 存在,且未含 `192.168.0.`、`工作視窗`、`批准!繼續`、`My request for Codex`。
+- `pnpm --dir apps/web typecheck`:本臨時 worktree 缺 `apps/web/node_modules/typescript`,未能本地執行;需以 Gitea CD / production browser readback 補正式驗證。
+
+**完成度同步**:
+- 本階段 source-side 實作:`100%`。
+- Runtime 資安讀回納入 Wazuh live route:`0% -> 100%`。
+- Wazuh live metadata enable:仍 `0%`。
+- Wazuh manager registry accepted:仍 `0`。
+- IwoooS Runtime 資安讀回層:`94% -> 95%`。
+- IwoooS 整體資安推進保守維持:`65%`;不因 route 可見或 lane 接上而提高 runtime acceptance。
+- Runtime acceptance、owner accepted、active response、host write、Kali active scan、Telegram send、secret collection:仍全部 `0 / false`。
+
+**邊界**:本段沒有啟用 Wazuh live metadata env、沒有收集 Wazuh secret、沒有修 dashboard stored API、沒有重新註冊 agent、沒有重啟 Wazuh manager / dashboard、沒有 SSH 主機、沒有改 Nginx / Docker / firewall / K8s、沒有 active response、沒有 Kali scan、沒有 Telegram send、沒有 force push。下一個 P0 仍是 Wazuh live metadata owner gate、server-side secret metadata、readonly account scope、manager health ref、post-enable readback 與 manager registry 全量交叉驗收。
+
## 2026-06-26|IwoooS controlled apply guard 收斂:資安讀回、防退化與正式站驗證完成
**背景**:P2-414B 已把 AwoooP allowlisted low / medium / high 修復路徑改成 `controlled_apply`,但部分資安 guard 與文件仍停在舊的 `runtime_write_gate=0` / `candidate_only` / 高風險人工 Gate 語意,造成 CD guard 與最新產品方向衝突。本段只收斂 source / guard / readback / production verification,不碰主機、Wazuh live、Kali、Nginx、Docker、firewall、secret 或 Telegram send。
diff --git a/docs/security/IWOOOS-WAZUH-READONLY-API-RELEASE-HANDOFF.md b/docs/security/IWOOOS-WAZUH-READONLY-API-RELEASE-HANDOFF.md
index 258915e9..2e2d2c18 100644
--- a/docs/security/IWOOOS-WAZUH-READONLY-API-RELEASE-HANDOFF.md
+++ b/docs/security/IWOOOS-WAZUH-READONLY-API-RELEASE-HANDOFF.md
@@ -35,8 +35,11 @@
變更範圍:
- `apps/api/src/api/v1/iwooos.py`
+- `apps/api/src/services/iwooos_wazuh_readonly_status.py`
+- `apps/api/src/services/iwooos_runtime_security_readback.py`
- `apps/api/src/main.py`
- `apps/api/tests/test_iwooos_wazuh_api.py`
+- `apps/api/tests/test_iwooos_runtime_security_readback.py`
- `scripts/security/wazuh-readonly-route-boundary-guard.py`
- `scripts/security/wazuh-readonly-production-readback.py`
- `scripts/security/wazuh-readonly-release-gate.py`
@@ -59,6 +62,8 @@
- 新增 FastAPI `GET /api/iwooos/wazuh`。
- 新增 FastAPI `GET /api/v1/iwooos/wazuh`。
+- 將 Wazuh 只讀 metadata 邏輯抽成 `iwooos_wazuh_readonly_status.py`,讓正式 route、Runtime 資安讀回與後續告警鏈共用同一份脫敏 aggregate 結果。
+- `GET /api/v1/iwooos/runtime-security-readback` 新增 `wazuh_live_route` P0 lane 與 `wazuh_live_*` summary;正式路由 disabled、misconfigured、registry empty、below expected 或 unavailable 都會在 Runtime board 顯示退化,不再只藏在下方卡片。
- 預設回 `disabled_waiting_iwooos_wazuh_owner_gate`,避免 production 繼續用 404 表示未啟用。
- live Wazuh 查詢仍需 `IWOOOS_WAZUH_READONLY_ENABLED=true` 與 server-side env:`WAZUH_API_BASE_URL`、`WAZUH_API_USERNAME`、`WAZUH_API_PASSWORD`。
- 強制 Wazuh base URL 使用 HTTPS。
@@ -87,8 +92,8 @@ python3 scripts/security/wazuh-readonly-release-owner-request.py --root .
python3 scripts/security/wazuh-readonly-release-owner-response-acceptance.py --root .
python3 scripts/security/wazuh-readonly-live-metadata-env-gate.py --root .
python3 scripts/security/security-mirror-progress-guard.py --root .
-python3 scripts/ops/doc-secrets-sanity-check.py docs apps/api/src/api/v1/iwooos.py apps/web/src/app/api/iwooos/wazuh/route.ts scripts/security/wazuh-readonly-route-boundary-guard.py scripts/security/wazuh-readonly-production-readback.py scripts/security/wazuh-readonly-release-gate.py scripts/security/wazuh-readonly-release-lane-preflight.py scripts/security/wazuh-readonly-release-owner-request.py scripts/security/wazuh-readonly-release-owner-response-acceptance.py scripts/security/wazuh-readonly-live-metadata-env-gate.py
-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 scripts/security/wazuh-readonly-release-gate.py scripts/security/wazuh-readonly-release-lane-preflight.py scripts/security/wazuh-readonly-release-owner-request.py scripts/security/wazuh-readonly-release-owner-response-acceptance.py scripts/security/wazuh-readonly-live-metadata-env-gate.py scripts/security/security-mirror-progress-guard.py
+python3 scripts/ops/doc-secrets-sanity-check.py docs apps/api/src/api/v1/iwooos.py apps/api/src/services/iwooos_wazuh_readonly_status.py apps/api/src/services/iwooos_runtime_security_readback.py apps/web/src/app/api/iwooos/wazuh/route.ts scripts/security/wazuh-readonly-route-boundary-guard.py scripts/security/wazuh-readonly-production-readback.py scripts/security/wazuh-readonly-release-gate.py scripts/security/wazuh-readonly-release-lane-preflight.py scripts/security/wazuh-readonly-release-owner-request.py scripts/security/wazuh-readonly-release-owner-response-acceptance.py scripts/security/wazuh-readonly-live-metadata-env-gate.py
+python3 -m py_compile apps/api/src/api/v1/iwooos.py apps/api/src/services/iwooos_wazuh_readonly_status.py apps/api/src/services/iwooos_runtime_security_readback.py scripts/security/wazuh-readonly-route-boundary-guard.py scripts/security/wazuh-readonly-production-readback.py scripts/security/wazuh-readonly-release-gate.py scripts/security/wazuh-readonly-release-lane-preflight.py scripts/security/wazuh-readonly-release-owner-request.py scripts/security/wazuh-readonly-release-owner-response-acceptance.py scripts/security/wazuh-readonly-live-metadata-env-gate.py scripts/security/security-mirror-progress-guard.py
git diff --check
node -e "JSON.parse(require('fs').readFileSync('apps/web/messages/zh-TW.json','utf8')); JSON.parse(require('fs').readFileSync('apps/web/messages/en.json','utf8')); console.log('i18n json ok')"
cmp -s apps/web/messages/zh-TW.json apps/web/messages/en.json
@@ -99,7 +104,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`:`6 passed`。
-- `wazuh-readonly-route-boundary-guard`:`route=2 public_ui_files=1 forbidden=0 runtime_gate=0`。
+- `wazuh-readonly-route-boundary-guard`:`route=3 public_ui_files=1 forbidden=0 runtime_gate=0`。
- `wazuh-readonly-release-gate`:`source=1 push=1 main=1 deploy=1 readback=1 runtime_gate=0`。
- `wazuh-readonly-release-lane-preflight`:`ready=0 acks=0/6 evidence=0/6 runtime_gate=0`。
- `wazuh-readonly-release-owner-request`:`drafts=1 sent=0 accepted=0 runtime_gate=0`。
@@ -130,7 +135,7 @@ git am /private/tmp/awoooi-iwooos-wazuh-boundary-release-patch-/*.pat
乾淨套用 worktree 驗證結果:
- `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-route-boundary-guard.py --root .`:`WAZUH_READONLY_ROUTE_BOUNDARY_GUARD_OK route=3 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=1 main=1 deploy=1 readback=1 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`。
- `python3 scripts/security/security-mirror-progress-guard.py --root .`:`SECURITY_MIRROR_PROGRESS_GUARD_OK`。
diff --git a/docs/workplans/2026-06-04-iwooos-security-governance-p0.md b/docs/workplans/2026-06-04-iwooos-security-governance-p0.md
index eaff4598..eb56df4e 100644
--- a/docs/workplans/2026-06-04-iwooos-security-governance-p0.md
+++ b/docs/workplans/2026-06-04-iwooos-security-governance-p0.md
@@ -9,7 +9,7 @@
| 工作視窗 | IwoooS / AWOOOI 資安治理 P0 |
| 本次乾淨 worktree | `/private/tmp/awoooi-owner-release-closure-20260626` |
| 本次分支 | `codex/owner-release-closure-20260626`;推送時使用一般 push,不 force push |
-| 最新觀察到的 `gitea/main` | `6be83053 chore(cd): deploy 06dd4d0 [skip ci]`;本輪 AwoooP 修復候選升級合約由 `06dd4d0f` 完成,並隨 deploy marker `6be83053` 完成 production health / status-chain readback;active runtime gate 仍為 `0` |
+| 最新觀察到的 `gitea/main` | `335d5f4a chore(cd): deploy 2239507 [skip ci]`;本輪 AwoooP Approvals executor handoff readiness 由 `2239507e` 完成,正式站 desktop / mobile 已確認 `Executor handoff readiness`、`可交接度`、`runtime gate closed`、owner review / Runs 導覽可見且無水平溢出;active runtime gate 仍為 `0` |
| 最新 P0 Telegram 告警 / 批准執行真相鏈基準 | code `32e4beca`、deploy marker `717b5870`、code-review `2658`、CD `2657`;no-action approval 不再觸發 executor,可執行修復 approval 會寫入 `auto_repair_executions`、KM 與 verifier |
| 最新 P0 Telegram no-action 人工處置包基準 | code `cd928852`、deploy marker `9181cc0e`、code-review `2666`;正式部署 tree 已包含 no-action 人工處置包、`處置包 / 重診 / 歷史 / 靜默 / 真相鏈 / Runs` 鍵盤、production pod render / keyboard smoke |
| 最新 P0 MCP evidence / PlayBook 修復候選基準 | code `cc614023`、D1 blocker clarity `47d677ac`、D2 manual draft package `febe9ecf`、D3 draft work item `e8d5eafb`、D4 work item detail panel `e8a5bac5`、D5 coverage gap contract、D6 PostgreSQL 慢查詢分類 / database owner-review candidate、blocker normalization `4c85db18`、apply candidate 語意 `5ce6fc49` / `ef3ee4c4`、apply gate 閉環準備度 `d798d09e` / deploy marker `e0fbedfd`、Owner 放行閉環任務板 `c67dc92f` / deploy marker `7f204ca7`、受控執行前檢 `7c220fd0` / deploy marker `f068826f`、執行放行合約 `5055d6a4` / final deploy marker `5d41fe26`、告警自動化卡點總盤 `94800473` / final deploy marker `b1a15114`、修復候選升級合約 `06dd4d0f` / deploy marker `6be83053`。正式站已確認可由 MCP evidence + approved PlayBook trust 產生 medium approval candidate;若只跑 Ansible check-mode,Work Items / Runs 會顯示 `3/8 ready` 閉環矩陣、5 個閉環任務、`2/7 ready` 受控執行前檢、`4/11 ready` 執行放行合約,以及 7 條告警自動化卡點 lane;draft-ready path 會在下一次候選生成時帶出 `repair_candidate_promotion_contract_v1`、ready / total / blocked 計數、route、repair template、rollback 與 verifier;runtime gate 仍為 `0` |
@@ -65,7 +65,7 @@
| Observability AI 自動化資產與訊號總帳 | 本地 `100%`;正式站 desktop / mobile `100%` | 是,僅限主機 / 專案 / 網站 / 服務 / 監控訊號 / KM / PlayBook / Verifier / SRE 路由 readback | `/zh-TW/observability` 已把 `AI 自動化資產與訊號總帳` 前移到首屏,6 張卡顯示全域資產、監控訊號、服務健康、KM / PlayBook / Verifier、SRE 戰情室、Runtime Gate;總帳區操作入口 `0`,不 live probe、不 reload、不改規則、不發 Telegram、不套用修復 |
| Tenants 全域產品 / 網站 / 來源資產地圖 | 本地 `100%`;正式站 desktop / mobile `100%` | 是,僅限產品 / 專案、網站 / 服務入口、來源範圍、租戶資料與 gate readback | `/zh-TW/awooop/tenants` 已把 `全域資產地圖` 前移到首屏,直接顯示 `57` 個可視資產、`16 個產品 / 專案`、`31 個網站 / 服務入口`、`10 個來源範圍`、分類堆疊、route chips、主要來源就緒、已接受回覆、執行閘門與操作入口;不改租戶政策、不改路由、不部署、不掃描、不建立 repo、不開 runtime gate |
| 日報 / 週報 / 月報與 AI Agent 報表資料鏈路 | Weekly report 資料缺口止血本地 `100%`、正式部署 `100%`;Reports 總控正式站 `100%`;P2-109 source health read model 正式站 `100%`;P2-110 weekly no-send preview 正式站 `100%`;P2-110B daily / monthly no-send preview 正式站 `100%`;P2-110C SRE digest no-send preview 正式站 `100%`;P2-110D source gap PlayBook / Verifier readback 正式站 `100%`;P2-110E AwoooP Work Items owner review 正式站 `100%`;P2-410 action audit ledger production API `100%`;報表產品化總控 `94%` | 是,僅限資料源 truthfulness、全 0 判讀、Reports 首屏總控、no-send preview、資產沉澱、PlayBook / Verifier 缺口處置板、AwoooP Work Items owner review、audit event template 與下一步工作項 | `ac325852` / deploy marker `a4b30964` 已修正週報 Git 活動讀取失敗時假性輸出 `0`;`6d4fa7bf` / `5e849225` / `63a75f77` 已把 `/zh-TW/reports` 改成 `報表 / 告警 AI 接管總控`;`27d9f394` / deploy marker `d8862123` 新增 `agent-report-source-health`;`a46e31ba` / `48e06c6a` / deploy marker `3057342a` 已讓 weekly preview 回傳 source `2/5`、confidence `40`、三個 `report-source-gap:*` 與 KM / PlayBook / Verifier 沉澱;`77fe2a85` / deploy marker `29fe6ec8` 已讓 daily / monthly preview 也回傳同一 source health、formatted preview 與 KM / PlayBook / 腳本 / 排程 / Verifier 沉澱;`7e03b923` / deploy marker `c7c0d874` 已新增 SRE 戰情室 digest preview,回傳 live send allowed `0`、runtime gate `0` 與同一批沉澱;`6ab640e4` / deploy marker `049dc0a8` 已讓 `agent-report-source-health` 與 `/zh-TW/reports` 顯示三張 source gap PlayBook / Verifier 處置卡;`ca04b49d` / deploy marker `c33dd9a6` 已讓 `/zh-TW/awooop/work-items` 顯示 source `2/5`、資料缺口 `3`、PlayBook 草案 `3`、Verifier 計畫 `3`、owner review `3`、腳本 readback、排程 no-send 與 runtime gate `0`;P2-410 feature commit `e13f716c` / deploy marker `38e60192` 已讓 `/api/v1/agents/agent-action-audit-ledger` 正式回讀 report source gap 與 SRE digest no-send preview audit event template;仍需接 governance projection 與 receipt gate,不發 live Telegram、不改排程、不開 runtime gate |
-| Telegram 監控告警 / 批准執行真相鏈 | outbound 主鏈路 `100%`;批准後執行止血 `100%`;no-action 人工處置包 D0 `100%`;MCP / PlayBook 修復候選 D10 `97%`;Approvals / Runs / Alerts / Knowledge Base / Observability / Tenants / Telegram 告警卡資產沉澱矩陣 `100% / incident focus desktop+mobile 100% / 100% / 100% / 100% / 100% / formatter+deploy 100%`;blocker 語意正確性 `88%`;apply candidate 語意 `100%`;apply gate 閉環準備度正式站 `100%`;Owner 放行閉環任務板正式站 `100%`;受控執行前檢正式站 `100%`;執行放行合約正式站 `100%`;告警自動化卡點總盤正式站 `100%`;治理長期項 `98%` | 是,僅限候選產生、阻擋原因、人工草案包、AwoooP 工作項可追蹤性、Work Items 詳細接手板、Approvals / Runs / Alerts / Knowledge Base / Observability / Tenants / Telegram 告警卡資產沉澱欄、coverage gap metadata、apply gate 閉環矩陣、Owner 放行閉環任務板、受控執行前檢、執行放行合約與告警自動化卡點總盤 | 已修復 Alertmanager tenant context、既有 approval 收斂告警 recurrence、AI 分析中重複告警 recurrence、no-action approval 誤導執行、可執行修復 execution / KM / verifier 紀錄、no-action 人工處置包、MCP evidence / PlayBook trust 候選產生、通用兜底 / 診斷型 PlayBook 阻擋理由、缺候選時的 PlayBook 草案欄位 / 下一步 / AwoooP work item 入口與詳細處置板、blocked result 的服務 coverage gap / blocking stage / required MCP evidence refs、PostgreSQL 慢查詢告警分類防混線與 database owner-review candidate,以及 Approvals / Runs / Alerts / Knowledge Base / Observability / Tenants / Telegram 告警卡的 `KM / PlayBook / 腳本 / 排程 / Verifier` 資產沉澱矩陣;Runs / Work Items 已顯示 Work Item、dry-run、apply candidate、verifier、下一步資產 ID、`3/8 ready` apply gate 閉環矩陣、Owner 放行包 / Verifier 放行前檢 / 5 個閉環任務、`2/7 ready` 受控執行前檢、`4/11 ready` 執行放行合約,以及告警自動化卡點總盤 7 條 lane:證據、候選、PlayBook / Ansible、安全路由、執行放行、Verifier、KM / Trust 回寫;status-chain blocker 已把缺失顯示為 `auto_repair_missing` / `verification_missing` / `learning_missing`,不再把未完成 gate 顯示成 `*_recorded`;Ansible check-mode-only 已改顯示為 `apply_candidate_owner_review_ready` 與 `dry_run_passed_apply_candidate_ready`,下一步固定指向 owner 放行、維護窗口、rollback owner、blast radius、受控執行、post-apply verifier、KM 與 PlayBook trust 回寫;完整自動修復飛輪仍需用真實告警驗證 approval -> controlled execution -> verifier -> KM / PlayBook trust 全鏈,不調高 runtime gate |
+| Telegram 監控告警 / 批准執行真相鏈 | outbound 主鏈路 `100%`;批准後執行止血 `100%`;no-action 人工處置包 D0 `100%`;MCP / PlayBook 修復候選 D10 `97%`;Approvals / Runs / Alerts / Knowledge Base / Observability / Tenants / Telegram 告警卡資產沉澱矩陣 `100% / incident focus desktop+mobile 100% / 100% / 100% / 100% / 100% / formatter+deploy 100%`;Approvals executor handoff readiness 正式站 `100%`;blocker 語意正確性 `88%`;apply candidate 語意 `100%`;apply gate 閉環準備度正式站 `100%`;Owner 放行閉環任務板正式站 `100%`;受控執行前檢正式站 `100%`;執行放行合約正式站 `100%`;告警自動化卡點總盤正式站 `100%`;治理長期項 `99%` | 是,僅限候選產生、阻擋原因、人工草案包、AwoooP 工作項可追蹤性、Work Items 詳細接手板、Approvals executor handoff readiness、Approvals / Runs / Alerts / Knowledge Base / Observability / Tenants / Telegram 告警卡資產沉澱欄、coverage gap metadata、apply gate 閉環矩陣、Owner 放行閉環任務板、受控執行前檢、執行放行合約與告警自動化卡點總盤 | 已修復 Alertmanager tenant context、既有 approval 收斂告警 recurrence、AI 分析中重複告警 recurrence、no-action approval 誤導執行、可執行修復 execution / KM / verifier 紀錄、no-action 人工處置包、MCP evidence / PlayBook trust 候選產生、通用兜底 / 診斷型 PlayBook 阻擋理由、缺候選時的 PlayBook 草案欄位 / 下一步 / AwoooP work item 入口與詳細處置板、blocked result 的服務 coverage gap / blocking stage / required MCP evidence refs、PostgreSQL 慢查詢告警分類防混線與 database owner-review candidate,以及 Approvals / Runs / Alerts / Knowledge Base / Observability / Tenants / Telegram 告警卡的 `KM / PlayBook / 腳本 / 排程 / Verifier` 資產沉澱矩陣;Approvals 焦點 Incident 已新增 `Executor handoff readiness`,顯示可交接度、runtime gate、下一步、阻擋原因、缺 owner review / 安全路由欄位與 Work Item / Runs 導覽;Runs / Work Items 已顯示 Work Item、dry-run、apply candidate、verifier、下一步資產 ID、`3/8 ready` apply gate 閉環矩陣、Owner 放行包 / Verifier 放行前檢 / 5 個閉環任務、`2/7 ready` 受控執行前檢、`4/11 ready` 執行放行合約,以及告警自動化卡點總盤 7 條 lane:證據、候選、PlayBook / Ansible、安全路由、執行放行、Verifier、KM / Trust 回寫;status-chain blocker 已把缺失顯示為 `auto_repair_missing` / `verification_missing` / `learning_missing`,不再把未完成 gate 顯示成 `*_recorded`;Ansible check-mode-only 已改顯示為 `apply_candidate_owner_review_ready` 與 `dry_run_passed_apply_candidate_ready`,下一步固定指向 owner 放行、維護窗口、rollback owner、blast radius、受控執行、post-apply verifier、KM 與 PlayBook trust 回寫;完整自動修復飛輪仍需用真實告警驗證 approval -> controlled execution -> verifier -> KM / PlayBook trust 全鏈,不調高 runtime gate |
## 2. P0 工作拆解與優先順序
diff --git a/k8s/awoooi-prod/kustomization.yaml b/k8s/awoooi-prod/kustomization.yaml
index 52f9fe81..ad5d4f79 100644
--- a/k8s/awoooi-prod/kustomization.yaml
+++ b/k8s/awoooi-prod/kustomization.yaml
@@ -41,7 +41,7 @@ resources:
images:
- name: 192.168.0.110:5000/library/api:IMAGE_TAG_PLACEHOLDER
newName: 192.168.0.110:5000/awoooi/api
- newTag: 4309c02eb052c2573e2783fa76ec9c1550033863
+ newTag: 2239507e0e6c35cf76ecc7d17e9e8d8e2cd2f7b1
- name: 192.168.0.110:5000/library/web:IMAGE_TAG_PLACEHOLDER
newName: 192.168.0.110:5000/awoooi/web
- newTag: 4309c02eb052c2573e2783fa76ec9c1550033863
+ newTag: 2239507e0e6c35cf76ecc7d17e9e8d8e2cd2f7b1
diff --git a/scripts/reboot-recovery/post-reboot-readiness-summary.sh b/scripts/reboot-recovery/post-reboot-readiness-summary.sh
index 76ef5e90..fb9d506f 100755
--- a/scripts/reboot-recovery/post-reboot-readiness-summary.sh
+++ b/scripts/reboot-recovery/post-reboot-readiness-summary.sh
@@ -117,6 +117,9 @@ fi
stock_freshness_status="$(awk '$1 == "STOCK_FRESHNESS_STATUS" {value=$2} END {print value}' "$post_start_log")"
stock_latest_trading_date="$(awk '$1 == "STOCK_LATEST_TRADING_DATE" {value=$2} END {print value}' "$post_start_log")"
stock_blockers="$(grep -E '^STOCK_BLOCKERS ' "$post_start_log" | tail -n 1 | cut -d' ' -f2- || true)"
+if [[ "${stock_freshness_status:-}" == "ok" && -z "${stock_blockers:-}" ]]; then
+ stock_blockers="none"
+fi
stock_eod_window_pending="$(awk '$1 == "STOCK_EOD_WINDOW_PENDING" {value=$2} END {print value}' "$post_start_log")"
stock_eod_classification="$(awk '$1 == "STOCK_EOD_CLASSIFICATION" {value=$2} END {print value}' "$post_start_log")"
stock_eod_next_action="$(awk '$1 == "STOCK_EOD_NEXT_ACTION" {value=$2} END {print value}' "$post_start_log")"
diff --git a/scripts/security/wazuh-readonly-route-boundary-guard.py b/scripts/security/wazuh-readonly-route-boundary-guard.py
index a8ae2e9b..fec308ae 100644
--- a/scripts/security/wazuh-readonly-route-boundary-guard.py
+++ b/scripts/security/wazuh-readonly-route-boundary-guard.py
@@ -20,6 +20,7 @@ from typing import Any
TAIPEI = timezone(timedelta(hours=8))
NEXT_ROUTE_PATH = Path("apps/web/src/app/api/iwooos/wazuh/route.ts")
BACKEND_ROUTE_PATH = Path("apps/api/src/api/v1/iwooos.py")
+BACKEND_SERVICE_PATH = Path("apps/api/src/services/iwooos_wazuh_readonly_status.py")
PUBLIC_PAGE_PATH = Path("apps/web/src/app/[locale]/iwooos/page.tsx")
PUBLIC_COMPONENT_ROOT = Path("apps/web/src/components/iwooos")
@@ -60,6 +61,10 @@ ROUTE_REQUIRED_TOKENS = [
BACKEND_REQUIRED_TOKENS = [
"/api/iwooos/wazuh",
"/api/v1/iwooos/wazuh",
+ "load_iwooos_wazuh_readonly_status",
+]
+
+BACKEND_SERVICE_REQUIRED_TOKENS = [
"IWOOOS_WAZUH_READONLY_ENABLED",
"IWOOOS_WAZUH_EXPECTED_MIN_AGENT_COUNT",
"WAZUH_API_BASE_URL",
@@ -71,7 +76,7 @@ BACKEND_REQUIRED_TOKENS = [
"runtime_gate_count",
"raw_wazuh_payload_storage_allowed",
"internal_ip_public_display_allowed",
- "_redacted_agent",
+ "redacted_agent",
"wazuh_agent_registry_empty",
"wazuh_agent_registry_below_expected",
"agent_registry_empty_count",
@@ -175,7 +180,11 @@ def pattern_applies(pattern: ForbiddenPattern, source_kind: str) -> bool:
def collect_forbidden_matches(root: Path) -> list[dict[str, Any]]:
- targets: list[tuple[str, Path]] = [("route", NEXT_ROUTE_PATH), ("route", BACKEND_ROUTE_PATH)]
+ targets: list[tuple[str, Path]] = [
+ ("route", NEXT_ROUTE_PATH),
+ ("route", BACKEND_ROUTE_PATH),
+ ("route", BACKEND_SERVICE_PATH),
+ ]
targets.extend(("public_ui", path) for path in collect_public_ui_files(root))
matches: list[dict[str, Any]] = []
@@ -204,14 +213,20 @@ def collect_missing_required_tokens(route_text: str, required_tokens: list[str])
def build_report(root: Path, generated_at: str | None = None) -> dict[str, Any]:
next_route = root / NEXT_ROUTE_PATH
backend_route = root / BACKEND_ROUTE_PATH
+ backend_service = root / BACKEND_SERVICE_PATH
next_route_present = next_route.exists()
backend_route_present = backend_route.exists()
+ backend_service_present = backend_service.exists()
next_route_text = read_text(next_route) if next_route_present else ""
backend_route_text = read_text(backend_route) if backend_route_present else ""
+ backend_service_text = read_text(backend_service) if backend_service_present else ""
public_ui_files = collect_public_ui_files(root)
missing_required_tokens = {
NEXT_ROUTE_PATH.as_posix(): collect_missing_required_tokens(next_route_text, ROUTE_REQUIRED_TOKENS),
BACKEND_ROUTE_PATH.as_posix(): collect_missing_required_tokens(backend_route_text, BACKEND_REQUIRED_TOKENS),
+ BACKEND_SERVICE_PATH.as_posix(): collect_missing_required_tokens(
+ backend_service_text, BACKEND_SERVICE_REQUIRED_TOKENS
+ ),
}
missing_required_token_count = sum(len(tokens) for tokens in missing_required_tokens.values())
forbidden_matches = collect_forbidden_matches(root)
@@ -225,28 +240,37 @@ def build_report(root: Path, generated_at: str | None = None) -> dict[str, Any]:
else "blocked"
),
"mode": "repo_source_scan_no_runtime_no_secret_collection",
- "guarded_route_paths": [NEXT_ROUTE_PATH.as_posix(), BACKEND_ROUTE_PATH.as_posix()],
+ "guarded_route_paths": [
+ NEXT_ROUTE_PATH.as_posix(),
+ BACKEND_ROUTE_PATH.as_posix(),
+ BACKEND_SERVICE_PATH.as_posix(),
+ ],
"guarded_public_ui_paths": [path.as_posix() for path in public_ui_files],
"required_route_tokens": {
NEXT_ROUTE_PATH.as_posix(): ROUTE_REQUIRED_TOKENS,
BACKEND_ROUTE_PATH.as_posix(): BACKEND_REQUIRED_TOKENS,
+ BACKEND_SERVICE_PATH.as_posix(): BACKEND_SERVICE_REQUIRED_TOKENS,
},
"forbidden_pattern_ids": [pattern.pattern_id for pattern in FORBIDDEN_PATTERNS],
"summary": {
- "route_present_count": int(next_route_present) + int(backend_route_present),
+ "route_present_count": int(next_route_present) + int(backend_route_present) + int(backend_service_present),
"next_route_present_count": 1 if next_route_present else 0,
"backend_route_present_count": 1 if backend_route_present else 0,
+ "backend_service_present_count": 1 if backend_service_present else 0,
"public_ui_file_count": len(public_ui_files),
- "required_token_count": len(ROUTE_REQUIRED_TOKENS) + len(BACKEND_REQUIRED_TOKENS),
+ "required_token_count": len(ROUTE_REQUIRED_TOKENS)
+ + len(BACKEND_REQUIRED_TOKENS)
+ + len(BACKEND_SERVICE_REQUIRED_TOKENS),
"missing_required_token_count": missing_required_token_count,
"forbidden_pattern_count": len(FORBIDDEN_PATTERNS),
"forbidden_match_count": len(forbidden_matches),
"readonly_api_default_closed_count": sum(
- "IWOOOS_WAZUH_READONLY_ENABLED" in text for text in [next_route_text, backend_route_text]
+ "IWOOOS_WAZUH_READONLY_ENABLED" in text
+ for text in [next_route_text, backend_route_text, backend_service_text]
),
"server_side_env_required_count": sum(
token in text
- for text in [next_route_text, backend_route_text]
+ for text in [next_route_text, backend_route_text, backend_service_text]
for token in ["WAZUH_API_BASE_URL", "WAZUH_API_USERNAME", "WAZUH_API_PASSWORD"]
),
"tls_disable_match_count": sum(
@@ -299,6 +323,8 @@ def validate(root: Path) -> None:
errors.append(f"{NEXT_ROUTE_PATH.as_posix()}: Wazuh Next.js 只讀 route 不存在")
if report["summary"]["backend_route_present_count"] != 1:
errors.append(f"{BACKEND_ROUTE_PATH.as_posix()}: Wazuh FastAPI 相容 route 不存在")
+ if report["summary"]["backend_service_present_count"] != 1:
+ errors.append(f"{BACKEND_SERVICE_PATH.as_posix()}: Wazuh FastAPI 只讀 service 不存在")
for path, tokens in report["missing_required_tokens"].items():
for token in tokens:
errors.append(f"{path}: 缺少必要只讀邊界 token {token!r}")