From bfecd87c04d9bddd20a902de58a270a9ab28cd21 Mon Sep 17 00:00:00 2001
From: AWOOOI CD
Date: Fri, 26 Jun 2026 16:10:52 +0000
Subject: [PATCH 1/2] chore(cd): deploy b7045a4 [skip ci]
---
k8s/awoooi-prod/kustomization.yaml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/k8s/awoooi-prod/kustomization.yaml b/k8s/awoooi-prod/kustomization.yaml
index ee8b7d5b..10396dbb 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: fe74d8616e409f7c4485e7aea8194f198037034b
+ newTag: b7045a412c8be3d67490fb64790b12c0380fa23c
- name: 192.168.0.110:5000/library/web:IMAGE_TAG_PLACEHOLDER
newName: 192.168.0.110:5000/awoooi/web
- newTag: fe74d8616e409f7c4485e7aea8194f198037034b
+ newTag: b7045a412c8be3d67490fb64790b12c0380fa23c
From 10a925bab6ad6bb7339f08b50fe8a0ad214ea300 Mon Sep 17 00:00:00 2001
From: Your Name
Date: Sat, 27 Jun 2026 00:11:54 +0800
Subject: [PATCH 2/2] feat(iwooos): expose Wazuh live metadata gate readback
---
apps/api/src/api/v1/iwooos.py | 35 +++
.../iwooos_runtime_security_readback.py | 85 +++++-
.../iwooos_wazuh_live_metadata_gate.py | 280 ++++++++++++++++++
.../test_iwooos_runtime_security_readback.py | 50 +++-
apps/web/messages/en.json | 24 +-
apps/web/messages/zh-TW.json | 24 +-
apps/web/src/app/[locale]/iwooos/page.tsx | 124 +++++++-
apps/web/src/lib/api-client.ts | 61 ++++
.../wazuh-readonly-route-boundary-guard.py | 67 ++++-
9 files changed, 715 insertions(+), 35 deletions(-)
create mode 100644 apps/api/src/services/iwooos_wazuh_live_metadata_gate.py
diff --git a/apps/api/src/api/v1/iwooos.py b/apps/api/src/api/v1/iwooos.py
index 3398cbcf..14831566 100644
--- a/apps/api/src/api/v1/iwooos.py
+++ b/apps/api/src/api/v1/iwooos.py
@@ -23,6 +23,9 @@ from src.services.iwooos_security_control_coverage import (
from src.services.iwooos_wazuh_readonly_status import (
load_iwooos_wazuh_readonly_status,
)
+from src.services.iwooos_wazuh_live_metadata_gate import (
+ load_latest_iwooos_wazuh_live_metadata_gate,
+)
from src.services.public_redaction import redact_public_lan_topology
@@ -44,6 +47,38 @@ async def get_iwooos_wazuh_readonly_status_v1() -> JSONResponse:
return await _wazuh_readonly_status()
+@router.get(
+ "/api/v1/iwooos/wazuh-live-metadata-gate",
+ response_model=dict[str, Any],
+ summary="取得 Wazuh 即時中繼資料負責人閘門讀回",
+ description=(
+ "讀取已提交的 Wazuh 即時中繼資料負責人閘門,並附上 Wazuh 正式只讀路由的"
+ "公開安全彙總。此端點不讀機密明文、不查主機、不保存原始 Wazuh 載荷、"
+ "不啟用主動回應、不改 K8s / ArgoCD / Docker / Nginx / firewall。"
+ ),
+)
+async def get_iwooos_wazuh_live_metadata_gate() -> dict[str, Any]:
+ """回傳 Wazuh 即時中繼資料啟用前負責人閘門只讀狀態。"""
+ try:
+ wazuh_result = await load_iwooos_wazuh_readonly_status()
+ payload = await asyncio.to_thread(
+ load_latest_iwooos_wazuh_live_metadata_gate,
+ 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(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=str(exc),
+ ) from exc
+ except (json.JSONDecodeError, ValueError) as exc:
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=f"IwoooS Wazuh 即時中繼資料閘門無效:{exc}",
+ ) from exc
+
+
@router.get(
"/api/v1/iwooos/runtime-security-readback",
response_model=dict[str, Any],
diff --git a/apps/api/src/services/iwooos_runtime_security_readback.py b/apps/api/src/services/iwooos_runtime_security_readback.py
index 8304bb05..ad6b9df3 100644
--- a/apps/api/src/services/iwooos_runtime_security_readback.py
+++ b/apps/api/src/services/iwooos_runtime_security_readback.py
@@ -20,6 +20,7 @@ _SNAPSHOT_FILES = {
"owner_gap": "s4-9-owner-response-gap-audit.snapshot.json",
"wazuh_coverage": "wazuh-managed-host-coverage-gate.snapshot.json",
"wazuh_runtime": "wazuh-agent-visibility-runtime-gate.snapshot.json",
+ "wazuh_live_metadata_gate": "wazuh-readonly-live-metadata-env-gate.snapshot.json",
"kali_status": "kali-integration-status.snapshot.json",
"soc_control": "soc-siem-kali-wazuh-integration-control.snapshot.json",
"alert_readability": "telegram-alert-readability-guard.snapshot.json",
@@ -31,6 +32,7 @@ _EXPECTED_SCHEMAS = {
"owner_gap": "s4_9_owner_response_gap_audit_v1",
"wazuh_coverage": "wazuh_managed_host_coverage_gate_v1",
"wazuh_runtime": "wazuh_agent_visibility_runtime_gate_v1",
+ "wazuh_live_metadata_gate": "iwooos_wazuh_readonly_live_metadata_env_gate_v1",
"kali_status": "kali_integration_status_v1",
"soc_control": "soc_siem_kali_wazuh_integration_control_v1",
"alert_readability": "telegram_alert_readability_guard_v1",
@@ -70,6 +72,7 @@ def load_latest_iwooos_runtime_security_readback(
owner_gap_summary = _summary(snapshots["owner_gap"])
wazuh_summary = _summary(snapshots["wazuh_coverage"])
+ live_metadata_gate_summary = _summary(snapshots["wazuh_live_metadata_gate"])
soc_summary = _summary(snapshots["soc_control"])
alert_summary = _summary(snapshots["alert_readability"])
dispatch_summary = _summary(snapshots["owner_dispatch"])
@@ -92,7 +95,7 @@ def load_latest_iwooos_runtime_security_readback(
"source_refs": source_refs,
"summary": {
"source_snapshot_count": len(source_refs),
- "p0_lane_count": 7,
+ "p0_lane_count": 8,
"control_plane_visibility_percent": _average_percent(
soc_summary.get("coverage_percent_after_soc_integration_control"),
intrusion_summary.get("coverage_percent_after_prevention_control"),
@@ -118,6 +121,24 @@ def load_latest_iwooos_runtime_security_readback(
"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"],
+ "wazuh_live_metadata_gate_owner_accepted_count": _int(
+ live_metadata_gate_summary.get("live_metadata_owner_response_accepted_count")
+ ),
+ "wazuh_live_metadata_gate_secret_source_accepted_count": _int(
+ live_metadata_gate_summary.get("secret_source_metadata_accepted_count")
+ ),
+ "wazuh_live_metadata_gate_manager_health_accepted_count": _int(
+ live_metadata_gate_summary.get("wazuh_manager_health_ref_accepted_count")
+ ),
+ "wazuh_live_metadata_gate_readonly_scope_accepted_count": _int(
+ live_metadata_gate_summary.get("readonly_account_scope_accepted_count")
+ ),
+ "wazuh_live_metadata_gate_post_enable_readback_count": _int(
+ live_metadata_gate_summary.get("post_enable_readback_passed_count")
+ ),
+ "wazuh_live_metadata_gate_live_query_authorized_count": _int(
+ live_metadata_gate_summary.get("wazuh_api_live_query_authorized_count")
+ ),
"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")),
@@ -132,7 +153,7 @@ def load_latest_iwooos_runtime_security_readback(
"blocked_waiting_manager_registry",
0,
"locked",
- "manager_registry_cross_check",
+ "管理器清單交叉驗收",
{
"expected_hosts": wazuh_summary.get("expected_host_scope_count", 0),
"transport_observed": wazuh_summary.get("manager_transport_established_connection_count", 0),
@@ -145,7 +166,7 @@ def load_latest_iwooos_runtime_security_readback(
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"],
@@ -155,12 +176,42 @@ def load_latest_iwooos_runtime_security_readback(
},
["GET /api/iwooos/wazuh"],
),
+ _lane(
+ "wazuh_live_metadata_gate",
+ snapshots["wazuh_live_metadata_gate"].get("status", "blocked_waiting_live_metadata_owner_response"),
+ 0,
+ "locked",
+ "補齊負責人回覆、機密中繼資料、管理節點健康、唯讀範圍與啟用後讀回",
+ {
+ "route_readback": live_metadata_gate_summary.get("production_route_readback_passed_count", 0),
+ "owner_accepted": live_metadata_gate_summary.get("live_metadata_owner_response_accepted_count", 0),
+ "secret_metadata_accepted": live_metadata_gate_summary.get(
+ "secret_source_metadata_accepted_count",
+ 0,
+ ),
+ "manager_health_accepted": live_metadata_gate_summary.get(
+ "wazuh_manager_health_ref_accepted_count",
+ 0,
+ ),
+ "readonly_scope_accepted": live_metadata_gate_summary.get(
+ "readonly_account_scope_accepted_count",
+ 0,
+ ),
+ "post_enable_readback": live_metadata_gate_summary.get("post_enable_readback_passed_count", 0),
+ "live_query_authorized": live_metadata_gate_summary.get(
+ "wazuh_api_live_query_authorized_count",
+ 0,
+ ),
+ "runtime_gate": live_metadata_gate_summary.get("runtime_gate_count", 0),
+ },
+ ["docs/security/wazuh-readonly-live-metadata-env-gate.snapshot.json"],
+ ),
_lane(
"wazuh_dashboard_api",
"degraded_api_connection_not_green",
0,
"warn",
- "dashboard_api_rbac_tls_repair_readback",
+ "儀表板 API、RBAC 與 TLS 修復後重新讀回",
{
"dashboard_api_degraded": wazuh_summary.get("dashboard_api_degraded_observed_count", 0),
"runtime_gate": wazuh_summary.get("runtime_gate_count", 0),
@@ -176,7 +227,7 @@ def load_latest_iwooos_runtime_security_readback(
snapshots["kali_status"].get("status", "blocked_waiting_kali_scope"),
0,
"locked",
- "kali_scope_and_finding_envelope_accepted",
+ "資安觀測範圍與 finding envelope 先被接受",
{
"active_scan_authorized": soc_summary.get("kali_active_scan_authorized_count", 0),
"execute_authorized": soc_summary.get("kali_execute_authorized_count", 0),
@@ -192,7 +243,7 @@ def load_latest_iwooos_runtime_security_readback(
"contract_ready_no_send_receipt",
_alert_contract_percent(alert_summary),
"warn",
- "alert_route_receipt_available",
+ "補齊告警路由 receipt 與實發驗證",
{
"formatter_markers": alert_summary.get("source_formatter_marker_count", 0),
"required_markers": alert_summary.get("required_output_marker_count", 0),
@@ -205,7 +256,7 @@ def load_latest_iwooos_runtime_security_readback(
snapshots["owner_dispatch"].get("status", "owner_request_draft_ready_not_dispatched"),
0,
"locked",
- "owner_response_packet_delivery",
+ "正式負責人回覆封包送達與接受",
{
"request_drafts": dispatch_summary.get("request_draft_count", 0),
"request_sent": dispatch_summary.get("request_sent_count", 0),
@@ -218,7 +269,7 @@ def load_latest_iwooos_runtime_security_readback(
"candidate_only_no_runtime_containment",
_int(intrusion_summary.get("coverage_percent_after_prevention_control")),
"warn",
- "redacted_evidence_refs_and_maintenance_window",
+ "補脫敏證據參照與維護窗口",
{
"urgent_candidates": intrusion_summary.get("urgent_prevention_candidate_count", 0),
"evidence_received": intrusion_summary.get("evidence_ref_received_count", 0),
@@ -244,13 +295,14 @@ def load_latest_iwooos_runtime_security_readback(
"not_authorization": True,
},
"no_false_green_rules": [
- "dashboard_route_200_is_not_wazuh_registry_recovery",
- "transport_count_is_not_full_host_management",
- "ui_visible_is_not_runtime_authorization",
- "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",
+ "儀表板路由 200 不代表 Wazuh 清單已恢復",
+ "傳輸連線數不代表所有主機都已納管",
+ "前台可見不代表執行期已授權",
+ "負責人送件草案不代表負責人已接受",
+ "資安觀測節點健康不代表主動掃描已授權",
+ "告警格式合約不代表通知已實發或已取得 receipt",
+ "Wazuh 正式只讀路由 disabled 或退化時仍是 P0 紅燈",
+ "Wazuh 即時中繼資料必須先通過負責人、機密中繼資料、唯讀範圍與啟用後讀回",
],
}
@@ -367,6 +419,9 @@ def _require_runtime_boundaries(snapshots: dict[str, dict[str, Any]]) -> None:
"telegram_send_authorized_count",
"host_write_authorized_count",
"secret_value_collection_allowed_count",
+ "wazuh_api_live_query_authorized_count",
+ "wazuh_active_response_authorized_count",
+ "post_enable_readback_passed_count",
):
if key in summary and _int(summary.get(key)) != 0:
raise ValueError(f"{name}: {key} must remain 0")
diff --git a/apps/api/src/services/iwooos_wazuh_live_metadata_gate.py b/apps/api/src/services/iwooos_wazuh_live_metadata_gate.py
new file mode 100644
index 00000000..c06a7f7c
--- /dev/null
+++ b/apps/api/src/services/iwooos_wazuh_live_metadata_gate.py
@@ -0,0 +1,280 @@
+"""
+IwoooS Wazuh live metadata owner gate readback.
+
+This module only exposes committed gate metadata plus public-safe Wazuh route
+aggregate status. It never reads secret values, never queries hosts, and never
+authorizes Wazuh active response, scans, restarts, reloads, or host writes.
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any
+
+from src.services.snapshot_paths import default_security_dir
+
+_DEFAULT_SECURITY_DIR = default_security_dir(Path(__file__))
+_SNAPSHOT_FILE = "wazuh-readonly-live-metadata-env-gate.snapshot.json"
+_EXPECTED_SCHEMA = "iwooos_wazuh_readonly_live_metadata_env_gate_v1"
+
+_REQUIRED_FALSE_BOUNDARIES = {
+ "argocd_sync_authorized",
+ "docker_restart_authorized",
+ "firewall_change_authorized",
+ "host_write_authorized",
+ "k8s_secret_patch_authorized",
+ "kali_active_scan_authorized",
+ "nginx_gateway_workaround_authorized",
+ "production_deploy_authorized",
+ "raw_wazuh_payload_storage_allowed",
+ "repo_write_authorized",
+ "runtime_execution_authorized",
+ "secret_value_collection_allowed",
+ "wazuh_active_response_authorized",
+ "wazuh_api_live_query_authorized",
+}
+
+
+def load_latest_iwooos_wazuh_live_metadata_gate(
+ security_dir: Path | None = None,
+ wazuh_live_status: dict[str, Any] | None = None,
+ wazuh_live_http_status: int = 0,
+) -> dict[str, Any]:
+ """Load the committed Wazuh live metadata owner gate as a public-safe payload."""
+ directory = security_dir or _DEFAULT_SECURITY_DIR
+ snapshot = _load_snapshot(directory)
+ _require_boundaries(snapshot)
+
+ summary = _summary(snapshot)
+ live_route = _live_route_summary(wazuh_live_status, wazuh_live_http_status)
+ merged_summary = {
+ "server_side_env_key_count": _int(summary.get("server_side_env_key_count")),
+ "required_owner_field_count": _int(summary.get("required_owner_field_count")),
+ "reviewer_check_count": _int(summary.get("reviewer_check_count")),
+ "outcome_lane_count": _int(summary.get("outcome_lane_count")),
+ "blocked_action_count": _int(summary.get("blocked_action_count")),
+ "production_route_readback_passed_count": _int(summary.get("production_route_readback_passed_count")),
+ "live_metadata_owner_response_received_count": _int(
+ summary.get("live_metadata_owner_response_received_count")
+ ),
+ "live_metadata_owner_response_accepted_count": _int(
+ summary.get("live_metadata_owner_response_accepted_count")
+ ),
+ "secret_source_metadata_accepted_count": _int(summary.get("secret_source_metadata_accepted_count")),
+ "wazuh_manager_health_ref_accepted_count": _int(
+ summary.get("wazuh_manager_health_ref_accepted_count")
+ ),
+ "readonly_account_scope_accepted_count": _int(summary.get("readonly_account_scope_accepted_count")),
+ "post_enable_readback_passed_count": _int(summary.get("post_enable_readback_passed_count")),
+ "wazuh_api_live_query_authorized_count": _int(summary.get("wazuh_api_live_query_authorized_count")),
+ "wazuh_active_response_authorized_count": _int(summary.get("wazuh_active_response_authorized_count")),
+ "host_write_authorized_count": _int(summary.get("host_write_authorized_count")),
+ "runtime_gate_count": _int(summary.get("runtime_gate_count")),
+ "wazuh_live_route_http_status": live_route["http_status"],
+ "wazuh_live_route_degraded_count": live_route["degraded_count"],
+ "wazuh_live_readonly_api_enabled_count": live_route["readonly_api_enabled_count"],
+ "wazuh_live_agent_total": live_route["agent_total"],
+ "wazuh_live_metadata_available_count": live_route["metadata_available_count"],
+ "wazuh_live_status": live_route["status"],
+ }
+
+ return {
+ "schema_version": "iwooos_wazuh_live_metadata_gate_readback_v1",
+ "status": snapshot.get("status", "blocked_waiting_live_metadata_owner_response"),
+ "mode": "committed_snapshot_readback_with_public_safe_wazuh_route_metadata",
+ "source_refs": [f"docs/security/{_SNAPSHOT_FILE}", "GET /api/iwooos/wazuh"],
+ "summary": merged_summary,
+ "items": _items(merged_summary),
+ "boundary_markers": _boundary_markers(merged_summary),
+ "boundaries": {
+ "runtime_execution_authorized": False,
+ "secret_value_collection_allowed": False,
+ "raw_wazuh_payload_storage_allowed": False,
+ "wazuh_api_live_query_authorized": False,
+ "wazuh_active_response_authorized": False,
+ "host_write_authorized": False,
+ "kali_active_scan_authorized": False,
+ "k8s_secret_patch_authorized": False,
+ "argocd_sync_authorized": False,
+ "docker_restart_authorized": False,
+ "nginx_gateway_workaround_authorized": False,
+ "firewall_change_authorized": False,
+ "not_authorization": True,
+ },
+ "no_false_green_rules": [
+ "正式路由 200 不是即時查詢授權",
+ "Wazuh 儀表板可見不是 manager registry 已驗收",
+ "機密來源只能交付中繼資料,不得交付明文、片段或雜湊",
+ "live metadata query、active response、host write 與 Kali active scan 是不同閘門",
+ "owner response accepted、post-enable readback 與 runtime gate 仍全部維持 0",
+ ],
+ }
+
+
+def _load_snapshot(directory: Path) -> dict[str, Any]:
+ path = directory / _SNAPSHOT_FILE
+ if not path.is_file():
+ raise FileNotFoundError(f"{path}: Wazuh 即時中繼資料閘門快照不存在")
+ with path.open(encoding="utf-8") as handle:
+ payload = json.load(handle)
+ if not isinstance(payload, dict):
+ raise ValueError(f"{path}: expected JSON object")
+ if payload.get("schema_version") != _EXPECTED_SCHEMA:
+ raise ValueError(f"{path}: expected schema_version={_EXPECTED_SCHEMA}")
+ return payload
+
+
+def _summary(payload: dict[str, Any]) -> dict[str, Any]:
+ summary = payload.get("summary")
+ return summary if isinstance(summary, dict) else {}
+
+
+def _int(value: Any) -> int:
+ return value if isinstance(value, int) else 0
+
+
+def _live_route_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,
+ "metadata_available_count": 0,
+ }
+ summary = _summary(payload)
+ 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")),
+ "metadata_available_count": 1 if metadata_available else 0,
+ }
+
+
+def _items(summary: dict[str, Any]) -> list[dict[str, Any]]:
+ return [
+ _item(
+ "release_readback",
+ "ENV-1",
+ "route_readback_passed",
+ "steady" if summary["production_route_readback_passed_count"] == 1 else "warn",
+ {"route_readback": summary["production_route_readback_passed_count"]},
+ ),
+ _item(
+ "server_env_owner",
+ "ENV-2",
+ "waiting_owner_response",
+ "locked",
+ {
+ "owner_received": summary["live_metadata_owner_response_received_count"],
+ "owner_accepted": summary["live_metadata_owner_response_accepted_count"],
+ },
+ ),
+ _item(
+ "secret_metadata",
+ "ENV-3",
+ "metadata_only_waiting_acceptance",
+ "locked",
+ {"secret_metadata_accepted": summary["secret_source_metadata_accepted_count"]},
+ ),
+ _item(
+ "manager_health",
+ "ENV-4",
+ "waiting_manager_health_ref",
+ "warn",
+ {
+ "manager_health_accepted": summary["wazuh_manager_health_ref_accepted_count"],
+ "live_route_degraded": summary["wazuh_live_route_degraded_count"],
+ },
+ ),
+ _item(
+ "readonly_scope",
+ "ENV-5",
+ "waiting_readonly_scope_ref",
+ "warn",
+ {"readonly_scope_accepted": summary["readonly_account_scope_accepted_count"]},
+ ),
+ _item(
+ "post_enable_readback",
+ "ENV-6",
+ "waiting_post_enable_readback",
+ "locked",
+ {
+ "post_enable_readback": summary["post_enable_readback_passed_count"],
+ "live_query_authorized": summary["wazuh_api_live_query_authorized_count"],
+ "runtime_gate": summary["runtime_gate_count"],
+ },
+ ),
+ ]
+
+
+def _item(
+ item_id: str,
+ check: str,
+ state_key: str,
+ tone: str,
+ metrics: dict[str, int],
+) -> dict[str, Any]:
+ return {
+ "item_id": item_id,
+ "check": check,
+ "state_key": state_key,
+ "tone": tone,
+ "metrics": metrics,
+ }
+
+
+def _boundary_markers(summary: dict[str, Any]) -> list[str]:
+ return [
+ f"正式路由讀回={summary['production_route_readback_passed_count']}",
+ f"負責人回覆接受={summary['live_metadata_owner_response_accepted_count']}",
+ f"機密來源中繼資料接受={summary['secret_source_metadata_accepted_count']}",
+ f"管理節點健康參照接受={summary['wazuh_manager_health_ref_accepted_count']}",
+ f"唯讀帳號範圍接受={summary['readonly_account_scope_accepted_count']}",
+ f"啟用後讀回={summary['post_enable_readback_passed_count']}",
+ f"Wazuh 即時查詢授權={summary['wazuh_api_live_query_authorized_count']}",
+ f"Wazuh 主動回應授權={summary['wazuh_active_response_authorized_count']}",
+ f"主機寫入授權={summary['host_write_authorized_count']}",
+ f"執行期閘門={summary['runtime_gate_count']}",
+ "機密明文收集=false",
+ "原始 Wazuh 載荷保存=false",
+ "K8s secret 手動修改=false",
+ "ArgoCD 手動同步=false",
+ "Docker 重啟=false",
+ "Nginx 或 gateway 繞路=false",
+ "防火牆變更=false",
+ "資安觀測節點主動掃描=false",
+ "不是執行授權=true",
+ ]
+
+
+def _require_boundaries(payload: dict[str, Any]) -> None:
+ summary = _summary(payload)
+ for key in (
+ "live_metadata_owner_response_accepted_count",
+ "secret_source_metadata_accepted_count",
+ "wazuh_manager_health_ref_accepted_count",
+ "readonly_account_scope_accepted_count",
+ "post_enable_readback_passed_count",
+ "wazuh_api_live_query_authorized_count",
+ "wazuh_active_response_authorized_count",
+ "host_write_authorized_count",
+ "runtime_gate_count",
+ ):
+ if _int(summary.get(key)) != 0:
+ raise ValueError(f"Wazuh 即時中繼資料閘門 summary.{key} 必須維持 0")
+
+ boundaries = payload.get("execution_boundaries")
+ if not isinstance(boundaries, dict):
+ raise ValueError("Wazuh 即時中繼資料閘門 execution_boundaries 缺失")
+ for key in _REQUIRED_FALSE_BOUNDARIES:
+ if boundaries.get(key) is not False:
+ raise ValueError(f"Wazuh 即時中繼資料閘門 execution_boundaries.{key} 必須維持 false")
+ if boundaries.get("not_authorization") is not True:
+ raise ValueError("Wazuh 即時中繼資料閘門 not_authorization 必須維持 true")
diff --git a/apps/api/tests/test_iwooos_runtime_security_readback.py b/apps/api/tests/test_iwooos_runtime_security_readback.py
index 652bd2f9..c12e829a 100644
--- a/apps/api/tests/test_iwooos_runtime_security_readback.py
+++ b/apps/api/tests/test_iwooos_runtime_security_readback.py
@@ -20,8 +20,8 @@ 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"] == 7
+ assert payload["summary"]["source_snapshot_count"] == 9
+ assert payload["summary"]["p0_lane_count"] == 8
assert payload["summary"]["runtime_gate_count"] == 0
assert payload["summary"]["owner_response_received_count"] == 0
assert payload["summary"]["owner_response_accepted_count"] == 0
@@ -30,6 +30,12 @@ def test_iwooos_runtime_security_readback_preserves_zero_runtime_gates() -> None
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"]["wazuh_live_metadata_gate_owner_accepted_count"] == 0
+ assert payload["summary"]["wazuh_live_metadata_gate_secret_source_accepted_count"] == 0
+ assert payload["summary"]["wazuh_live_metadata_gate_manager_health_accepted_count"] == 0
+ assert payload["summary"]["wazuh_live_metadata_gate_readonly_scope_accepted_count"] == 0
+ assert payload["summary"]["wazuh_live_metadata_gate_post_enable_readback_count"] == 0
+ assert payload["summary"]["wazuh_live_metadata_gate_live_query_authorized_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
@@ -46,6 +52,7 @@ def test_iwooos_runtime_security_readback_lanes_are_candidate_only() -> None:
assert lane_ids == {
"wazuh_registry",
"wazuh_live_route",
+ "wazuh_live_metadata_gate",
"wazuh_dashboard_api",
"kali_intake",
"alert_readability",
@@ -58,6 +65,7 @@ def test_iwooos_runtime_security_readback_lanes_are_candidate_only() -> None:
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"])
+ assert all(lane["lane_id"] != "wazuh_live_metadata_gate" or lane["completion_percent"] == 0 for lane in payload["lanes"])
def test_iwooos_runtime_security_readback_api_is_public_safe(monkeypatch) -> None:
@@ -76,6 +84,7 @@ def test_iwooos_runtime_security_readback_api_is_public_safe(monkeypatch) -> Non
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["summary"]["wazuh_live_metadata_gate_live_query_authorized_count"] == 0
assert data["boundaries"]["secret_value_collection_allowed"] is False
assert "192.168.0." not in response.text
assert "工作視窗" not in response.text
@@ -123,3 +132,40 @@ def test_iwooos_runtime_security_readback_api_includes_live_wazuh_empty_registry
assert data["summary"]["wazuh_live_route_degraded_count"] == 1
assert data["summary"]["runtime_gate_count"] == 0
assert "token-value" not in response.text
+
+
+def test_iwooos_wazuh_live_metadata_gate_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/wazuh-live-metadata-gate")
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["schema_version"] == "iwooos_wazuh_live_metadata_gate_readback_v1"
+ assert data["status"] == "blocked_waiting_live_metadata_owner_response"
+ assert data["summary"]["production_route_readback_passed_count"] == 1
+ assert data["summary"]["live_metadata_owner_response_accepted_count"] == 0
+ assert data["summary"]["secret_source_metadata_accepted_count"] == 0
+ assert data["summary"]["readonly_account_scope_accepted_count"] == 0
+ assert data["summary"]["post_enable_readback_passed_count"] == 0
+ assert data["summary"]["wazuh_api_live_query_authorized_count"] == 0
+ assert data["summary"]["wazuh_active_response_authorized_count"] == 0
+ assert data["summary"]["host_write_authorized_count"] == 0
+ assert data["summary"]["runtime_gate_count"] == 0
+ assert data["summary"]["wazuh_live_route_http_status"] == 200
+ assert data["summary"]["wazuh_live_route_degraded_count"] == 1
+ assert data["summary"]["wazuh_live_status"] == "disabled_waiting_iwooos_wazuh_owner_gate"
+ assert data["boundaries"]["secret_value_collection_allowed"] is False
+ assert data["boundaries"]["wazuh_api_live_query_authorized"] is False
+ assert data["boundaries"]["wazuh_active_response_authorized"] is False
+ assert data["boundaries"]["host_write_authorized"] is False
+ assert data["boundaries"]["not_authorization"] is True
+ assert len(data["items"]) == 6
+ assert any(marker == "正式路由讀回=1" for marker in data["boundary_markers"])
+ assert "192.168.0." not in response.text
+ assert "工作視窗" not in response.text
+ assert "批准!繼續" not in response.text
+ assert "WAZUH_API_PASSWORD" not in response.text
diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json
index 47113c49..55f89c36 100644
--- a/apps/web/messages/en.json
+++ b/apps/web/messages/en.json
@@ -20207,7 +20207,7 @@
},
"runtimeSecurityReadback": {
"eyebrow": "IwoooS Runtime 資安讀回",
- "title": "七條 P0 資安線先接到同一張讀回板",
+ "title": "八條 P0 資安線先接到同一張讀回板",
"subtitle": "這張板讀取後端彙整的只讀快照,並附上 Wazuh 正式只讀路由的公開安全 aggregate 讀回;它不保存 raw Wazuh payload、不啟動掃描、不送訊息、不改主機。",
"statusLabel": "讀回狀態",
"statusDetail": "讀回成功只代表 IwoooS 能看見目前的證據邊界;runtime 寫入、主動回應、掃描、重啟、Nginx reload、workflow 修改與機密操作仍全部關閉。",
@@ -20238,6 +20238,10 @@
"label": "Wazuh live",
"detail": "正式只讀路由若 disabled、空清單或低於預期,首屏直接顯示警示。"
},
+ "metadataGate": {
+ "label": "中繼資料閘門",
+ "detail": "即時中繼資料查詢仍需負責人、機密中繼資料、唯讀範圍與啟用後讀回。"
+ },
"ownerAccepted": {
"label": "負責人驗收",
"detail": "收到 / 接受都必須由正式 owner response 證明。"
@@ -20260,6 +20264,10 @@
"title": "Wazuh 正式只讀路由",
"body": "直接讀正式路由的公開安全 aggregate;disabled、misconfigured、empty、below expected 都是 P0 紅燈,不會被 route 200 蓋過。"
},
+ "wazuh_live_metadata_gate": {
+ "title": "Wazuh 即時中繼資料閘門",
+ "body": "正式路由讀回後仍必須補負責人回覆、機密來源中繼資料、管理節點健康、唯讀範圍與啟用後讀回;即時查詢授權維持 0。"
+ },
"wazuh_dashboard_api": {
"title": "Wazuh Dashboard API",
"body": "API connection / API version 還要補 readback;index pattern 通過不能宣稱 Wazuh 全綠。"
@@ -20570,6 +20578,7 @@
"subtitle": "這張卡把 Wazuh 即時中繼資料啟用前的條件拆開:正式路由讀回、伺服端環境變數負責人、機密來源中繼資料、管理節點健康、唯讀帳號範圍與啟用後讀回都要先補齊;目前即時查詢、主動回應、主機寫入與執行期閘門都是 0。",
"checkLabel": "檢核",
"stateLabel": "狀態",
+ "loadingBoundary": "正在讀取只讀閘門",
"boundaryTitle": "即時中繼資料環境邊界",
"boundaryIntro": "以下鍵值固定:正式路由 200、Wazuh 已建置或 UI 可見都不能直接代表可以查 Wazuh 即時中繼資料;不得收機密明文值、不得改 K8s 機密、不得用 Nginx 或主機操作繞過釋出閘門。",
"summary": {
@@ -20590,6 +20599,19 @@
"detail": "Wazuh API 即時查詢授權目前維持 0。"
}
},
+ "status": {
+ "loading": "正在讀取 Wazuh 即時中繼資料閘門",
+ "failed": "Wazuh 即時中繼資料閘門尚未部署或讀取失敗",
+ "ready": "Wazuh 即時中繼資料閘門已讀回,但執行期仍關閉"
+ },
+ "states": {
+ "route_readback_passed": "路由已讀回",
+ "waiting_owner_response": "待負責人",
+ "metadata_only_waiting_acceptance": "只收中繼資料",
+ "waiting_manager_health_ref": "待健康參照",
+ "waiting_readonly_scope_ref": "待範圍參照",
+ "waiting_post_enable_readback": "待讀回驗證"
+ },
"items": {
"releaseReadback": {
"title": "正式路由已不再 404",
diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json
index 47113c49..55f89c36 100644
--- a/apps/web/messages/zh-TW.json
+++ b/apps/web/messages/zh-TW.json
@@ -20207,7 +20207,7 @@
},
"runtimeSecurityReadback": {
"eyebrow": "IwoooS Runtime 資安讀回",
- "title": "七條 P0 資安線先接到同一張讀回板",
+ "title": "八條 P0 資安線先接到同一張讀回板",
"subtitle": "這張板讀取後端彙整的只讀快照,並附上 Wazuh 正式只讀路由的公開安全 aggregate 讀回;它不保存 raw Wazuh payload、不啟動掃描、不送訊息、不改主機。",
"statusLabel": "讀回狀態",
"statusDetail": "讀回成功只代表 IwoooS 能看見目前的證據邊界;runtime 寫入、主動回應、掃描、重啟、Nginx reload、workflow 修改與機密操作仍全部關閉。",
@@ -20238,6 +20238,10 @@
"label": "Wazuh live",
"detail": "正式只讀路由若 disabled、空清單或低於預期,首屏直接顯示警示。"
},
+ "metadataGate": {
+ "label": "中繼資料閘門",
+ "detail": "即時中繼資料查詢仍需負責人、機密中繼資料、唯讀範圍與啟用後讀回。"
+ },
"ownerAccepted": {
"label": "負責人驗收",
"detail": "收到 / 接受都必須由正式 owner response 證明。"
@@ -20260,6 +20264,10 @@
"title": "Wazuh 正式只讀路由",
"body": "直接讀正式路由的公開安全 aggregate;disabled、misconfigured、empty、below expected 都是 P0 紅燈,不會被 route 200 蓋過。"
},
+ "wazuh_live_metadata_gate": {
+ "title": "Wazuh 即時中繼資料閘門",
+ "body": "正式路由讀回後仍必須補負責人回覆、機密來源中繼資料、管理節點健康、唯讀範圍與啟用後讀回;即時查詢授權維持 0。"
+ },
"wazuh_dashboard_api": {
"title": "Wazuh Dashboard API",
"body": "API connection / API version 還要補 readback;index pattern 通過不能宣稱 Wazuh 全綠。"
@@ -20570,6 +20578,7 @@
"subtitle": "這張卡把 Wazuh 即時中繼資料啟用前的條件拆開:正式路由讀回、伺服端環境變數負責人、機密來源中繼資料、管理節點健康、唯讀帳號範圍與啟用後讀回都要先補齊;目前即時查詢、主動回應、主機寫入與執行期閘門都是 0。",
"checkLabel": "檢核",
"stateLabel": "狀態",
+ "loadingBoundary": "正在讀取只讀閘門",
"boundaryTitle": "即時中繼資料環境邊界",
"boundaryIntro": "以下鍵值固定:正式路由 200、Wazuh 已建置或 UI 可見都不能直接代表可以查 Wazuh 即時中繼資料;不得收機密明文值、不得改 K8s 機密、不得用 Nginx 或主機操作繞過釋出閘門。",
"summary": {
@@ -20590,6 +20599,19 @@
"detail": "Wazuh API 即時查詢授權目前維持 0。"
}
},
+ "status": {
+ "loading": "正在讀取 Wazuh 即時中繼資料閘門",
+ "failed": "Wazuh 即時中繼資料閘門尚未部署或讀取失敗",
+ "ready": "Wazuh 即時中繼資料閘門已讀回,但執行期仍關閉"
+ },
+ "states": {
+ "route_readback_passed": "路由已讀回",
+ "waiting_owner_response": "待負責人",
+ "metadata_only_waiting_acceptance": "只收中繼資料",
+ "waiting_manager_health_ref": "待健康參照",
+ "waiting_readonly_scope_ref": "待範圍參照",
+ "waiting_post_enable_readback": "待讀回驗證"
+ },
"items": {
"releaseReadback": {
"title": "正式路由已不再 404",
diff --git a/apps/web/src/app/[locale]/iwooos/page.tsx b/apps/web/src/app/[locale]/iwooos/page.tsx
index 41953903..db325b00 100644
--- a/apps/web/src/app/[locale]/iwooos/page.tsx
+++ b/apps/web/src/app/[locale]/iwooos/page.tsx
@@ -38,6 +38,8 @@ import {
type IwoooSRuntimeSecurityReadbackResponse,
type IwoooSSecurityControlCoverageDomain,
type IwoooSSecurityControlCoverageResponse,
+ type IwoooSWazuhLiveMetadataGateItem,
+ type IwoooSWazuhLiveMetadataGateResponse,
} from '@/lib/api-client'
type PostureMetric = {
@@ -326,7 +328,15 @@ type WazuhReadonlyStatusResponse = {
}
type RuntimeSecurityReadbackSummaryItem = {
- key: 'controlPlane' | 'runtimeAcceptance' | 'wazuhRegistry' | 'wazuhLive' | 'ownerAccepted' | 'kaliRuntime' | 'runtimeGate'
+ key:
+ | 'controlPlane'
+ | 'runtimeAcceptance'
+ | 'wazuhRegistry'
+ | 'wazuhLive'
+ | 'metadataGate'
+ | 'ownerAccepted'
+ | 'kaliRuntime'
+ | 'runtimeGate'
value: string
icon: typeof ShieldCheck
tone: 'steady' | 'warn' | 'locked'
@@ -2275,13 +2285,6 @@ const wazuhIntrusionReadbackBoundaries = [
'not_authorization=true',
] as const
-const wazuhLiveMetadataEnvGateSummary = [
- { key: 'routeReadback', value: '1', icon: Route, tone: 'steady' },
- { key: 'owner', value: '0', icon: ClipboardCheck, tone: 'locked' },
- { key: 'secretMeta', value: '0', icon: Lock, tone: 'locked' },
- { key: 'liveQuery', value: '0', icon: Radar, tone: 'locked' },
-] as const
-
const wazuhReleaseGateSummary = [
{ key: 'source', value: '1', icon: CheckCircle2, tone: 'steady' },
{ key: 'branch', value: '1', icon: GitBranch, tone: 'steady' },
@@ -2325,6 +2328,15 @@ const wazuhLiveMetadataEnvGateItems: WazuhLiveMetadataEnvGateItem[] = [
{ key: 'postEnable', check: 'ENV-6', state: '待讀回驗證', icon: SearchCheck, tone: 'locked' },
] as const
+const wazuhLiveMetadataEnvGateItemKeyById: Record = {
+ release_readback: 'releaseReadback',
+ server_env_owner: 'serverEnv',
+ secret_metadata: 'secretMetadata',
+ manager_health: 'managerHealth',
+ readonly_scope: 'readonlyScope',
+ post_enable_readback: 'postEnable',
+}
+
const wazuhLiveMetadataEnvGateBoundaries = [
'wazuh_live_metadata_env_gate_visible=true',
'wazuh_live_metadata_env_gate_server_side_env_key_count=4',
@@ -8130,6 +8142,12 @@ function IwoooSRuntimeSecurityReadbackBoard() {
icon: Route,
tone: summary && summary.wazuh_live_metadata_available_count === 1 && summary.wazuh_live_route_degraded_count === 0 ? 'steady' : 'warn',
},
+ {
+ key: 'metadataGate',
+ value: summary ? String(summary.wazuh_live_metadata_gate_live_query_authorized_count) : '...',
+ icon: ClipboardCheck,
+ tone: 'locked',
+ },
{
key: 'ownerAccepted',
value: summary ? `${summary.owner_response_accepted_count}/${summary.owner_response_received_count}` : '...',
@@ -8882,6 +8900,86 @@ function IwoooSWazuhReleaseGateBoard() {
function IwoooSWazuhLiveMetadataEnvGateBoard() {
const t = useTranslations('iwooos.wazuhLiveMetadataEnvGate')
const textWrap = { overflowWrap: 'anywhere' as const, wordBreak: 'break-word' as const }
+ const [data, setData] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [failed, setFailed] = useState(false)
+
+ useEffect(() => {
+ let mounted = true
+
+ async function loadGate() {
+ setLoading(true)
+ setFailed(false)
+ try {
+ const payload = await apiClient.getIwoooSWazuhLiveMetadataGate()
+ if (mounted) {
+ setData(payload)
+ }
+ } catch {
+ if (mounted) {
+ setData(null)
+ setFailed(true)
+ }
+ } finally {
+ if (mounted) {
+ setLoading(false)
+ }
+ }
+ }
+
+ loadGate()
+ return () => {
+ mounted = false
+ }
+ }, [])
+
+ const summary = data?.summary
+ const summaryItems = [
+ {
+ key: 'routeReadback',
+ value: summary ? String(summary.production_route_readback_passed_count) : loading ? '...' : '0',
+ icon: Route,
+ tone: summary?.production_route_readback_passed_count === 1 ? 'steady' : 'warn',
+ },
+ {
+ key: 'owner',
+ value: summary ? String(summary.live_metadata_owner_response_accepted_count) : loading ? '...' : '0',
+ icon: ClipboardCheck,
+ tone: 'locked',
+ },
+ {
+ key: 'secretMeta',
+ value: summary ? String(summary.secret_source_metadata_accepted_count) : loading ? '...' : '0',
+ icon: Lock,
+ tone: 'locked',
+ },
+ {
+ key: 'liveQuery',
+ value: summary ? String(summary.wazuh_api_live_query_authorized_count) : loading ? '...' : '0',
+ icon: Radar,
+ tone: 'locked',
+ },
+ ] as const
+ const gateItems = data?.items?.length
+ ? data.items.map(item => {
+ const key = wazuhLiveMetadataEnvGateItemKeyById[item.item_id]
+ const fallback = wazuhLiveMetadataEnvGateItems.find(candidate => candidate.key === key)
+ return {
+ key,
+ check: item.check,
+ state: t(`states.${item.state_key}` as never),
+ icon: fallback?.icon ?? FileWarning,
+ tone: item.tone,
+ }
+ })
+ : wazuhLiveMetadataEnvGateItems
+ const boundaryMarkers = data?.boundary_markers?.length
+ ? data.boundary_markers
+ : loading
+ ? [t('loadingBoundary')]
+ : wazuhLiveMetadataEnvGateBoundaries
+ const statusText = loading ? t('status.loading') : failed ? t('status.failed') : t('status.ready')
+ const statusTone: 'steady' | 'warn' | 'locked' = loading || failed ? 'warn' : 'locked'
return (
+
+
+ {statusText}
+
- {wazuhLiveMetadataEnvGateSummary.map(item => {
+ {summaryItems.map(item => {
const Icon = item.icon
return (
@@ -8930,7 +9032,7 @@ function IwoooSWazuhLiveMetadataEnvGateBoard() {
gap: 10,
}}
>
- {wazuhLiveMetadataEnvGateItems.map(item => {
+ {gateItems.map(item => {
const Icon = item.icon
return (
- {wazuhLiveMetadataEnvGateBoundaries.map(item => (
+ {boundaryMarkers.map(item => (
+}
+
+export interface IwoooSWazuhLiveMetadataGateResponse {
+ schema_version: 'iwooos_wazuh_live_metadata_gate_readback_v1'
+ status: string
+ mode: string
+ source_refs: string[]
+ summary: {
+ server_side_env_key_count: number
+ required_owner_field_count: number
+ reviewer_check_count: number
+ outcome_lane_count: number
+ blocked_action_count: number
+ production_route_readback_passed_count: number
+ live_metadata_owner_response_received_count: number
+ live_metadata_owner_response_accepted_count: number
+ secret_source_metadata_accepted_count: number
+ wazuh_manager_health_ref_accepted_count: number
+ readonly_account_scope_accepted_count: number
+ post_enable_readback_passed_count: number
+ wazuh_api_live_query_authorized_count: number
+ wazuh_active_response_authorized_count: number
+ host_write_authorized_count: number
+ runtime_gate_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_metadata_available_count: number
+ wazuh_live_status: string
+ }
+ items: IwoooSWazuhLiveMetadataGateItem[]
+ boundary_markers: string[]
+ boundaries: Record
+ no_false_green_rules: string[]
+}
+
export interface IwoooSSecurityControlCoverageDomain {
domain_id:
| 'high_value_asset_control'
@@ -259,6 +315,11 @@ export const apiClient = {
return handleResponse(res)
},
+ async getIwoooSWazuhLiveMetadataGate() {
+ const res = await fetch(`${API_BASE_URL}/iwooos/wazuh-live-metadata-gate`, { cache: 'no-store' })
+ return handleResponse(res)
+ },
+
async getIwoooSSecurityControlCoverage() {
const res = await fetch(`${API_BASE_URL}/iwooos/security-control-coverage`, { cache: 'no-store' })
return handleResponse(res)
diff --git a/scripts/security/wazuh-readonly-route-boundary-guard.py b/scripts/security/wazuh-readonly-route-boundary-guard.py
index fec308ae..b81b362b 100644
--- a/scripts/security/wazuh-readonly-route-boundary-guard.py
+++ b/scripts/security/wazuh-readonly-route-boundary-guard.py
@@ -21,6 +21,7 @@ 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")
+BACKEND_LIVE_METADATA_GATE_SERVICE_PATH = Path("apps/api/src/services/iwooos_wazuh_live_metadata_gate.py")
PUBLIC_PAGE_PATH = Path("apps/web/src/app/[locale]/iwooos/page.tsx")
PUBLIC_COMPONENT_ROOT = Path("apps/web/src/components/iwooos")
@@ -84,6 +85,22 @@ BACKEND_SERVICE_REQUIRED_TOKENS = [
"agent_visibility_no_false_green_count",
]
+BACKEND_LIVE_METADATA_GATE_REQUIRED_TOKENS = [
+ "iwooos_wazuh_live_metadata_gate_readback_v1",
+ "committed_snapshot_readback_with_public_safe_wazuh_route_metadata",
+ "boundary_markers",
+ "secret_value_collection_allowed",
+ "raw_wazuh_payload_storage_allowed",
+ "wazuh_api_live_query_authorized",
+ "wazuh_active_response_authorized",
+ "host_write_authorized",
+ "runtime_gate_count",
+ "not_authorization",
+ "正式路由讀回",
+ "機密明文收集=false",
+ "原始 Wazuh 載荷保存=false",
+]
+
FORBIDDEN_PATTERNS = [
ForbiddenPattern(
@@ -184,6 +201,7 @@ def collect_forbidden_matches(root: Path) -> list[dict[str, Any]]:
("route", NEXT_ROUTE_PATH),
("route", BACKEND_ROUTE_PATH),
("route", BACKEND_SERVICE_PATH),
+ ("route", BACKEND_LIVE_METADATA_GATE_SERVICE_PATH),
]
targets.extend(("public_ui", path) for path in collect_public_ui_files(root))
@@ -214,12 +232,17 @@ 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
+ backend_live_metadata_gate_service = root / BACKEND_LIVE_METADATA_GATE_SERVICE_PATH
next_route_present = next_route.exists()
backend_route_present = backend_route.exists()
backend_service_present = backend_service.exists()
+ backend_live_metadata_gate_service_present = backend_live_metadata_gate_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 ""
+ backend_live_metadata_gate_service_text = (
+ read_text(backend_live_metadata_gate_service) if backend_live_metadata_gate_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),
@@ -227,6 +250,10 @@ def build_report(root: Path, generated_at: str | None = None) -> dict[str, Any]:
BACKEND_SERVICE_PATH.as_posix(): collect_missing_required_tokens(
backend_service_text, BACKEND_SERVICE_REQUIRED_TOKENS
),
+ BACKEND_LIVE_METADATA_GATE_SERVICE_PATH.as_posix(): collect_missing_required_tokens(
+ backend_live_metadata_gate_service_text,
+ BACKEND_LIVE_METADATA_GATE_REQUIRED_TOKENS,
+ ),
}
missing_required_token_count = sum(len(tokens) for tokens in missing_required_tokens.values())
forbidden_matches = collect_forbidden_matches(root)
@@ -236,7 +263,14 @@ def build_report(root: Path, generated_at: str | None = None) -> dict[str, Any]:
"generated_at": generated_at or now_iso(),
"status": (
"pass"
- if next_route_present and backend_route_present and not missing_required_token_count and not forbidden_matches
+ if (
+ next_route_present
+ and backend_route_present
+ and backend_service_present
+ and backend_live_metadata_gate_service_present
+ and not missing_required_token_count
+ and not forbidden_matches
+ )
else "blocked"
),
"mode": "repo_source_scan_no_runtime_no_secret_collection",
@@ -244,33 +278,52 @@ def build_report(root: Path, generated_at: str | None = None) -> dict[str, Any]:
NEXT_ROUTE_PATH.as_posix(),
BACKEND_ROUTE_PATH.as_posix(),
BACKEND_SERVICE_PATH.as_posix(),
+ BACKEND_LIVE_METADATA_GATE_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,
+ BACKEND_LIVE_METADATA_GATE_SERVICE_PATH.as_posix(): BACKEND_LIVE_METADATA_GATE_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) + int(backend_service_present),
+ "route_present_count": (
+ int(next_route_present)
+ + int(backend_route_present)
+ + int(backend_service_present)
+ + int(backend_live_metadata_gate_service_present)
+ ),
+ "live_metadata_gate_service_present_count": 1 if backend_live_metadata_gate_service_present else 0,
"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)
- + len(BACKEND_SERVICE_REQUIRED_TOKENS),
+ + len(BACKEND_SERVICE_REQUIRED_TOKENS)
+ + len(BACKEND_LIVE_METADATA_GATE_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, backend_service_text]
+ for text in [
+ next_route_text,
+ backend_route_text,
+ backend_service_text,
+ backend_live_metadata_gate_service_text,
+ ]
),
"server_side_env_required_count": sum(
token in text
- for text in [next_route_text, backend_route_text, backend_service_text]
+ for text in [
+ next_route_text,
+ backend_route_text,
+ backend_service_text,
+ backend_live_metadata_gate_service_text,
+ ]
for token in ["WAZUH_API_BASE_URL", "WAZUH_API_USERNAME", "WAZUH_API_PASSWORD"]
),
"tls_disable_match_count": sum(
@@ -325,6 +378,10 @@ def validate(root: Path) -> None:
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 不存在")
+ if report["summary"]["live_metadata_gate_service_present_count"] != 1:
+ errors.append(
+ f"{BACKEND_LIVE_METADATA_GATE_SERVICE_PATH.as_posix()}: Wazuh 即時中繼資料閘門 service 不存在"
+ )
for path, tokens in report["missing_required_tokens"].items():
for token in tokens:
errors.append(f"{path}: 缺少必要只讀邊界 token {token!r}")