diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index a7885c69..5e766a6f 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -19151,6 +19151,59 @@ } } }, + "wazuhManagedHostCoverage": { + "eyebrow": "Wazuh 主機納管覆蓋 Gate", + "title": "先承認哪些節點還沒有被管理器清單驗收", + "subtitle": "這張卡把 Wazuh 納管拆成應納管範圍、直接觀察到的代理、覆蓋缺口與管理器清單驗收。現在只能確認部分節點有代理與連線,不能宣稱所有主機都已恢復。", + "checkLabel": "檢核", + "stateLabel": "狀態", + "boundaryTitle": "主機覆蓋邊界", + "boundaryIntro": "以下鍵值固定:連線存在、代理服務啟動或儀表板可開都不能替代管理器清單;重新註冊、重啟、主機寫入、機密調整與主動回應都需要獨立維護窗口與回滾負責人。", + "summary": { + "scope": { + "label": "應納管範圍", + "detail": "目前以 6 個脫敏節點別名作為覆蓋矩陣。" + }, + "directActive": { + "label": "直接觀察", + "detail": "只代表部分節點有代理服務與傳輸連線。" + }, + "coverageGap": { + "label": "覆蓋缺口", + "detail": "包含無傳輸與尚未取得合法只讀讀回的節點。" + }, + "registry": { + "label": "清單驗收", + "detail": "管理器清單接受數仍為 0。" + } + }, + "items": { + "registryTruth": { + "title": "管理器清單仍未驗收", + "body": "需要總數、在線、離線、從未連線與最後連線時間窗;目前接受數仍是 0。" + }, + "coreTransport": { + "title": "部分核心節點仍有代理連線", + "body": "這能降低全倒風險,但只能算傳輸層證據,不能直接代表納管完成。" + }, + "devGap": { + "title": "有節點沒有可見代理傳輸", + "body": "需要確認它是否應納管、代理是否安裝、服務是否啟動,以及是否需要維護窗口。" + }, + "blockedReadback": { + "title": "部分節點仍缺合法只讀讀回", + "body": "需要只讀 access 或脫敏 owner export;不能用猜測或舊截圖補成綠燈。" + }, + "dashboardApi": { + "title": "儀表板讀取層仍退化", + "body": "已儲存 API、權限、速率限制與憑證信任都要有脫敏修復讀回。" + }, + "repairBoundary": { + "title": "修復動作要另走維護閘門", + "body": "重新註冊、重啟、主機寫入、掃描、機密調整或主動回應都不能由這張卡授權。" + } + } + }, "wazuhLiveMetadataEnvGate": { "eyebrow": "Wazuh 即時中繼資料環境閘門", "title": "Wazuh 查詢要等正式路由、負責人與機密中繼資料都過關", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index a7885c69..5e766a6f 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -19151,6 +19151,59 @@ } } }, + "wazuhManagedHostCoverage": { + "eyebrow": "Wazuh 主機納管覆蓋 Gate", + "title": "先承認哪些節點還沒有被管理器清單驗收", + "subtitle": "這張卡把 Wazuh 納管拆成應納管範圍、直接觀察到的代理、覆蓋缺口與管理器清單驗收。現在只能確認部分節點有代理與連線,不能宣稱所有主機都已恢復。", + "checkLabel": "檢核", + "stateLabel": "狀態", + "boundaryTitle": "主機覆蓋邊界", + "boundaryIntro": "以下鍵值固定:連線存在、代理服務啟動或儀表板可開都不能替代管理器清單;重新註冊、重啟、主機寫入、機密調整與主動回應都需要獨立維護窗口與回滾負責人。", + "summary": { + "scope": { + "label": "應納管範圍", + "detail": "目前以 6 個脫敏節點別名作為覆蓋矩陣。" + }, + "directActive": { + "label": "直接觀察", + "detail": "只代表部分節點有代理服務與傳輸連線。" + }, + "coverageGap": { + "label": "覆蓋缺口", + "detail": "包含無傳輸與尚未取得合法只讀讀回的節點。" + }, + "registry": { + "label": "清單驗收", + "detail": "管理器清單接受數仍為 0。" + } + }, + "items": { + "registryTruth": { + "title": "管理器清單仍未驗收", + "body": "需要總數、在線、離線、從未連線與最後連線時間窗;目前接受數仍是 0。" + }, + "coreTransport": { + "title": "部分核心節點仍有代理連線", + "body": "這能降低全倒風險,但只能算傳輸層證據,不能直接代表納管完成。" + }, + "devGap": { + "title": "有節點沒有可見代理傳輸", + "body": "需要確認它是否應納管、代理是否安裝、服務是否啟動,以及是否需要維護窗口。" + }, + "blockedReadback": { + "title": "部分節點仍缺合法只讀讀回", + "body": "需要只讀 access 或脫敏 owner export;不能用猜測或舊截圖補成綠燈。" + }, + "dashboardApi": { + "title": "儀表板讀取層仍退化", + "body": "已儲存 API、權限、速率限制與憑證信任都要有脫敏修復讀回。" + }, + "repairBoundary": { + "title": "修復動作要另走維護閘門", + "body": "重新註冊、重啟、主機寫入、掃描、機密調整或主動回應都不能由這張卡授權。" + } + } + }, "wazuhLiveMetadataEnvGate": { "eyebrow": "Wazuh 即時中繼資料環境閘門", "title": "Wazuh 查詢要等正式路由、負責人與機密中繼資料都過關", diff --git a/apps/web/src/app/[locale]/iwooos/page.tsx b/apps/web/src/app/[locale]/iwooos/page.tsx index 4dde616e..00bb5220 100644 --- a/apps/web/src/app/[locale]/iwooos/page.tsx +++ b/apps/web/src/app/[locale]/iwooos/page.tsx @@ -295,6 +295,14 @@ type WazuhOwnerEvidencePreflightItem = { tone: 'steady' | 'warn' | 'locked' } +type WazuhManagedHostCoverageItem = { + key: string + check: string + state: string + icon: typeof ShieldCheck + tone: 'steady' | 'warn' | 'locked' +} + type WazuhReadonlyStatusResponse = { status?: string configured?: boolean @@ -2334,6 +2342,44 @@ const wazuhOwnerEvidencePreflightBoundaries = [ 'not_authorization=true', ] as const +const wazuhManagedHostCoverageSummary = [ + { key: 'scope', value: '6', icon: Server, tone: 'warn' }, + { key: 'directActive', value: '2', icon: Activity, tone: 'warn' }, + { key: 'coverageGap', value: '4', icon: FileWarning, tone: 'locked' }, + { key: 'registry', value: '0', icon: Lock, tone: 'locked' }, +] as const + +const wazuhManagedHostCoverageItems: WazuhManagedHostCoverageItem[] = [ + { key: 'registryTruth', check: 'HC-1', state: '接受 0', icon: Lock, tone: 'locked' }, + { key: 'coreTransport', check: 'HC-2', state: '2 有連線', icon: Activity, tone: 'warn' }, + { key: 'devGap', check: 'HC-3', state: '1 無連線', icon: FileWarning, tone: 'locked' }, + { key: 'blockedReadback', check: 'HC-4', state: '3 待讀回', icon: SearchCheck, tone: 'warn' }, + { key: 'dashboardApi', check: 'HC-5', state: '讀取退化', icon: Radar, tone: 'warn' }, + { key: 'repairBoundary', check: 'HC-6', state: '需獨立維護窗', icon: ClipboardCheck, tone: 'locked' }, +] as const + +const wazuhManagedHostCoverageBoundaries = [ + 'wazuh_managed_host_coverage_gate_visible=true', + 'wazuh_managed_host_coverage_expected_host_scope_count=6', + 'wazuh_managed_host_coverage_direct_agent_active_observed_count=2', + 'wazuh_managed_host_coverage_direct_agent_missing_or_no_transport_count=1', + 'wazuh_managed_host_coverage_ssh_readback_blocked_count=3', + 'wazuh_managed_host_coverage_manager_registry_accepted_count=0', + 'wazuh_managed_host_coverage_dashboard_api_degraded_observed_count=1', + 'wazuh_managed_host_coverage_live_metadata_env_enabled_count=0', + 'wazuh_managed_host_coverage_runtime_gate_count=0', + 'wazuh_agent_reenroll_authorized=false', + 'wazuh_agent_restart_authorized=false', + 'wazuh_manager_restart_authorized=false', + 'wazuh_active_response_authorized=false', + 'host_write_authorized=false', + 'secret_value_collection_allowed=false', + 'raw_wazuh_payload_storage_allowed=false', + 'internal_ip_public_display_allowed=false', + 'agent_identity_public_display_allowed=false', + 'not_authorization=true', +] as const + const socSiemKaliWazuhIntegrationSummary = [ { key: 'frameworks', value: '7', icon: ClipboardCheck, tone: 'steady' }, { key: 'domains', value: '16', icon: Network, tone: 'steady' }, @@ -8393,6 +8439,137 @@ function IwoooSWazuhOwnerEvidencePreflightBoard() { ) } +function IwoooSWazuhManagedHostCoverageBoard() { + const t = useTranslations('iwooos.wazuhManagedHostCoverage') + const textWrap = { overflowWrap: 'anywhere' as const, wordBreak: 'break-word' as const } + + return ( + + + + + + + {t('eyebrow')} + + {t('title')} + + {t('subtitle')} + + + + + {wazuhManagedHostCoverageSummary.map(item => { + const Icon = item.icon + return ( + + + {t(`summary.${item.key}.label` as never)} + + + + {item.value} + + + {t(`summary.${item.key}.detail` as never)} + + + ) + })} + + + + + {wazuhManagedHostCoverageItems.map(item => { + const Icon = item.icon + return ( + + + + {t('checkLabel')} {item.check} + + + + + + {t(`items.${item.key}.title` as never)} + + + {t('stateLabel')}:{item.state} + + + + {t(`items.${item.key}.body` as never)} + + + ) + })} + + + + + {t('boundaryTitle')} + + + {t('boundaryIntro')} + + + {wazuhManagedHostCoverageBoundaries.map(item => ( + + {item} + + ))} + + + + + ) +} + function IwoooSSocSiemKaliWazuhIntegrationBoard() { const t = useTranslations('iwooos.socSiemKaliWazuhIntegration') const textWrap = { overflowWrap: 'anywhere' as const, wordBreak: 'break-word' as const } @@ -21319,6 +21496,7 @@ export default function IwoooSPage({ params }: { params: { locale: string } }) { + diff --git a/docs/security/WAZUH-MANAGED-HOST-COVERAGE-GATE.md b/docs/security/WAZUH-MANAGED-HOST-COVERAGE-GATE.md new file mode 100644 index 00000000..4a28dd38 --- /dev/null +++ b/docs/security/WAZUH-MANAGED-HOST-COVERAGE-GATE.md @@ -0,0 +1,47 @@ +# Wazuh 主機納管覆蓋 Gate + +## 目的 + +本 Gate 用來防止 Wazuh 用戶端消失事故被「半套修復」誤報為完成。它只接受脫敏、可交叉檢查的納管覆蓋證據,不接受 Dashboard 畫面、TCP transport、agent service active 或口頭回覆替代 manager registry truth。 + +## 目前只讀判定 + +| 項目 | 數值 | 判定 | +| --- | ---: | --- | +| 應納管節點範圍 | 6 | 已建立覆蓋矩陣 | +| 直接觀察 agent active / transport | 2 | 只能代表部分節點仍有連線 | +| 直接觀察無 agent transport | 1 | 需要 owner 判定安裝、服務或範圍 | +| SSH 只讀受阻 | 3 | 需要合法只讀 access 或脫敏 owner export | +| Manager registry accepted | 0 | 不得宣稱所有用戶端恢復 | +| Dashboard API 退化 | 1 | 需修 stored API / RBAC / rate-limit / TLS | +| Runtime gate | 0 | 不授權主機寫入或 active response | + +## 不得誤判 + +- Transport 連線數不等於 Wazuh manager registry 已驗收。 +- Dashboard 可訪問不等於 agent 清單正常。 +- 只讀 route 回 200 不等於 Wazuh live metadata 已啟用。 +- 任何重新註冊 agent、重啟 Wazuh、修改主機、調整防火牆、修改機密或 active response 都必須另走維護窗口、rollback owner 與人工批准。 + +## 綠燈前必備證據 + +1. Manager registry agent counts:總數、在線、離線、從未連線、最後連線時間窗。 +2. 逐主機 agent scope matrix:只用公開別名,不列內網位址、agent 原名或 raw payload。 +3. Dashboard API / RBAC / TLS 修復讀回:stored API、rate-limit、run_as、TLS trust 都要有脫敏參照。 +4. 唯讀認證中繼資料:只收 secret name、來源、owner、rotation / rollback owner,不收明文值、雜湊或片段。 +5. Owner response:owner role / team、decision、decision reason、affected scope、redacted evidence refs、followup owner、rollback owner。 +6. IwoooS 啟用後讀回:不得回傳 raw log、agent 原名、內網位址、secret 或 host output。 + +## 後續優先順序 + +| 優先 | 工作 | 完成度 | +| --- | --- | ---: | +| P0-A | Manager registry 只讀計數與逐主機矩陣 | 0% | +| P0-B | Dashboard stored API / RBAC / rate-limit / TLS 修復 | 0% | +| P0-C | 直接無 transport 節點的合法只讀後檢 | 0% | +| P0-D | SSH 受阻節點 owner export 或只讀 access | 0% | +| P0-E | IwoooS live metadata env owner gate | 0% | + +## 邊界 + +本文件與 `wazuh-managed-host-coverage-gate.snapshot.json` 都是 repo 內只讀治理證據;不連線 Wazuh、不收 secret、不重新註冊 agent、不重啟服務、不修改主機、不發 active response、不做 Kali active scan。 diff --git a/docs/security/wazuh-managed-host-coverage-gate.snapshot.json b/docs/security/wazuh-managed-host-coverage-gate.snapshot.json new file mode 100644 index 00000000..be9a02af --- /dev/null +++ b/docs/security/wazuh-managed-host-coverage-gate.snapshot.json @@ -0,0 +1,132 @@ +{ + "execution_boundaries": { + "host_write_authorized": false, + "kali_active_scan_authorized": false, + "not_authorization": true, + "raw_wazuh_payload_storage_allowed": false, + "runtime_execution_authorized": false, + "secret_value_collection_allowed": false, + "wazuh_active_response_authorized": false, + "wazuh_agent_reenroll_authorized": false, + "wazuh_agent_restart_authorized": false, + "wazuh_api_live_query_authorized": false, + "wazuh_manager_restart_authorized": false + }, + "forbidden_actions": [ + "wazuh_agent_reenroll", + "wazuh_agent_restart", + "wazuh_manager_restart", + "wazuh_dashboard_secret_patch", + "active_response_enable", + "host_write", + "firewall_change", + "nginx_reload", + "kali_active_scan" + ], + "forbidden_completion_claims": [ + "所有 Wazuh 用戶端已恢復", + "所有主機已納入 Wazuh", + "Wazuh agent registry 已驗收", + "Dashboard 可見等於 registry 已恢復", + "transport 連線等於全數納管" + ], + "generated_at": "2026-06-25T11:45:31+08:00", + "host_scope_matrix": [ + { + "manager_registry_accepted": false, + "next_gate": "manager_registry_cross_check", + "node_id": "managed_core_node_a", + "readback_status": "agent_active_transport_observed", + "role": "核心服務節點" + }, + { + "manager_registry_accepted": false, + "next_gate": "manager_registry_cross_check", + "node_id": "managed_core_node_b", + "readback_status": "agent_active_transport_observed", + "role": "資料服務節點" + }, + { + "manager_registry_accepted": false, + "next_gate": "agent_install_or_service_owner_decision", + "node_id": "managed_dev_node_a", + "readback_status": "no_agent_transport_observed", + "role": "開發工作節點" + }, + { + "manager_registry_accepted": false, + "next_gate": "read_only_access_or_owner_export", + "node_id": "managed_dev_node_b", + "readback_status": "ssh_readback_blocked", + "role": "開發工作節點" + }, + { + "manager_registry_accepted": false, + "next_gate": "read_only_access_or_owner_export", + "node_id": "managed_control_node_a", + "readback_status": "ssh_readback_blocked", + "role": "控制平面節點" + }, + { + "manager_registry_accepted": false, + "next_gate": "read_only_access_or_owner_export", + "node_id": "managed_control_node_b", + "readback_status": "ssh_readback_blocked", + "role": "控制平面節點" + } + ], + "mode": "snapshot_only_no_runtime_no_secret_collection", + "operator_interpretation": [ + "目前只能確認部分節點有 agent service 與 transport;manager registry 仍沒有可驗收讀回。", + "Dashboard API、RBAC、rate-limit 或 TLS 退化會讓 UI 代理清單看起來消失,但不能用 UI 畫面單獨判定 agent 全部恢復。", + "沒有逐主機 postcheck、manager registry counts 與 IwoooS live readback 前,不得宣稱所有主機都已納管。", + "重新註冊 agent、重啟 Wazuh、修改主機或改機密都必須走獨立維護窗口與 rollback owner。" + ], + "required_evidence_before_green": [ + { + "accepted": false, + "evidence_id": "manager_registry_agent_counts" + }, + { + "accepted": false, + "evidence_id": "per_host_agent_scope_matrix" + }, + { + "accepted": false, + "evidence_id": "dashboard_api_rbac_tls_repair_readback" + }, + { + "accepted": false, + "evidence_id": "readonly_credential_metadata_without_secret" + }, + { + "accepted": false, + "evidence_id": "owner_response_and_rollback_owner" + }, + { + "accepted": false, + "evidence_id": "post_enable_iwooos_readback" + } + ], + "schema_version": "wazuh_managed_host_coverage_gate_v1", + "scope": "wazuh_managed_host_coverage", + "status": "blocked_waiting_full_host_registry_readback", + "summary": { + "active_response_authorized_count": 0, + "agent_reenroll_authorized_count": 0, + "agent_restart_authorized_count": 0, + "dashboard_api_degraded_observed_count": 1, + "direct_agent_active_observed_count": 2, + "direct_agent_missing_or_no_transport_count": 1, + "direct_agent_transport_observed_count": 2, + "expected_host_scope_count": 6, + "host_write_authorized_count": 0, + "live_metadata_env_enabled_count": 0, + "manager_api_unauthenticated_response_count": 1, + "manager_registry_accepted_count": 0, + "manager_service_active_observed_count": 1, + "manager_transport_established_connection_count": 6, + "runtime_gate_count": 0, + "ssh_readback_blocked_count": 3 + } +} diff --git a/scripts/security/security-mirror-progress-guard.py b/scripts/security/security-mirror-progress-guard.py index ef509860..00bcbf24 100755 --- a/scripts/security/security-mirror-progress-guard.py +++ b/scripts/security/security-mirror-progress-guard.py @@ -123,6 +123,10 @@ def validate(root: Path) -> None: str(root / "scripts" / "security" / "wazuh-agent-visibility-owner-evidence-preflight.py") ) wazuh_agent_visibility_owner_evidence_preflight["validate"](root) + wazuh_managed_host_coverage_gate = runpy.run_path( + str(root / "scripts" / "security" / "wazuh-managed-host-coverage-gate.py") + ) + wazuh_managed_host_coverage_gate["validate"](root) telegram_alert_readability_guard = runpy.run_path( str(root / "scripts" / "security" / "telegram-alert-readability-guard.py") ) @@ -29471,6 +29475,13 @@ def validate(root: Path) -> None: json.dumps(web_messages_en["iwooos"], ensure_ascii=False), ] ) + for expected in [ + "iwooos-wazuh-managed-host-coverage-board", + "wazuhManagedHostCoverage", + "wazuh_managed_host_coverage_manager_registry_accepted_count=0", + "wazuh_managed_host_coverage_runtime_gate_count=0", + ]: + assert_text_contains("iwooos_frontend_product_text.wazuh_managed_host_coverage", frontend_product_text, expected) for forbidden in [ "工作視窗", "內部對話", diff --git a/scripts/security/wazuh-managed-host-coverage-gate.py b/scripts/security/wazuh-managed-host-coverage-gate.py new file mode 100644 index 00000000..aaae0737 --- /dev/null +++ b/scripts/security/wazuh-managed-host-coverage-gate.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +""" +Wazuh 主機納管覆蓋 Gate。 + +本工具只驗證 repo 內的脫敏 snapshot;不連線 Wazuh、不讀 secret、 +不重新註冊 agent、不重啟服務、不修改主機,也不啟用 active response。 +""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Any + + +SNAPSHOT_PATH = Path("docs/security/wazuh-managed-host-coverage-gate.snapshot.json") +SCHEMA_VERSION = "wazuh_managed_host_coverage_gate_v1" + +HOST_SCOPE_MATRIX = [ + { + "node_id": "managed_core_node_a", + "role": "核心服務節點", + "readback_status": "agent_active_transport_observed", + "manager_registry_accepted": False, + "next_gate": "manager_registry_cross_check", + }, + { + "node_id": "managed_core_node_b", + "role": "資料服務節點", + "readback_status": "agent_active_transport_observed", + "manager_registry_accepted": False, + "next_gate": "manager_registry_cross_check", + }, + { + "node_id": "managed_dev_node_a", + "role": "開發工作節點", + "readback_status": "no_agent_transport_observed", + "manager_registry_accepted": False, + "next_gate": "agent_install_or_service_owner_decision", + }, + { + "node_id": "managed_dev_node_b", + "role": "開發工作節點", + "readback_status": "ssh_readback_blocked", + "manager_registry_accepted": False, + "next_gate": "read_only_access_or_owner_export", + }, + { + "node_id": "managed_control_node_a", + "role": "控制平面節點", + "readback_status": "ssh_readback_blocked", + "manager_registry_accepted": False, + "next_gate": "read_only_access_or_owner_export", + }, + { + "node_id": "managed_control_node_b", + "role": "控制平面節點", + "readback_status": "ssh_readback_blocked", + "manager_registry_accepted": False, + "next_gate": "read_only_access_or_owner_export", + }, +] + +REQUIRED_EVIDENCE_BEFORE_GREEN = [ + "manager_registry_agent_counts", + "per_host_agent_scope_matrix", + "dashboard_api_rbac_tls_repair_readback", + "readonly_credential_metadata_without_secret", + "owner_response_and_rollback_owner", + "post_enable_iwooos_readback", +] + +FORBIDDEN_COMPLETION_CLAIMS = [ + "所有 Wazuh 用戶端已恢復", + "所有主機已納入 Wazuh", + "Wazuh agent registry 已驗收", + "Dashboard 可見等於 registry 已恢復", + "transport 連線等於全數納管", +] + +FORBIDDEN_ACTIONS = [ + "wazuh_agent_reenroll", + "wazuh_agent_restart", + "wazuh_manager_restart", + "wazuh_dashboard_secret_patch", + "active_response_enable", + "host_write", + "firewall_change", + "nginx_reload", + "kali_active_scan", +] + +FORBIDDEN_TEXT_PATTERNS = [ + re.compile(r"\b(?:10|127|172\.(?:1[6-9]|2\d|3[01])|192\.168)\.\d{1,3}\.\d{1,3}\b"), + re.compile(r"Authorization\s*:", re.IGNORECASE), + re.compile(r"Bearer\s+[A-Za-z0-9._-]{10,}", re.IGNORECASE), + re.compile(r"Basic\s+[A-Za-z0-9+/=]{10,}", re.IGNORECASE), + re.compile(r"password\s*[:=]\s*['\"][^'\"]+['\"]", re.IGNORECASE), + re.compile(r"token\s*[:=]\s*['\"][^'\"]+['\"]", re.IGNORECASE), + re.compile(r"cookie\s*[:=]\s*['\"][^'\"]+['\"]", re.IGNORECASE), + re.compile(r"client\.keys", re.IGNORECASE), + re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"), +] + + +def load_json(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def assert_equal(label: str, actual: Any, expected: Any) -> None: + if actual != expected: + raise SystemExit(f"BLOCKED {label}: expected {expected!r}, got {actual!r}") + + +def assert_false(label: str, actual: Any) -> None: + assert_equal(label, actual, False) + + +def assert_zero(label: str, actual: Any) -> None: + assert_equal(label, actual, 0) + + +def collect_string_values(value: Any) -> list[str]: + if isinstance(value, str): + return [value] + if isinstance(value, list): + values: list[str] = [] + for item in value: + values.extend(collect_string_values(item)) + return values + if isinstance(value, dict): + values = [] + for item in value.values(): + values.extend(collect_string_values(item)) + return values + return [] + + +def validate_no_forbidden_text(snapshot: dict[str, Any]) -> None: + for text in collect_string_values(snapshot): + for pattern in FORBIDDEN_TEXT_PATTERNS: + if pattern.search(text): + raise SystemExit("BLOCKED wazuh_managed_host_coverage_gate: snapshot contains forbidden sensitive text") + + +def build_snapshot(generated_at: str) -> dict[str, Any]: + return { + "schema_version": SCHEMA_VERSION, + "generated_at": generated_at, + "status": "blocked_waiting_full_host_registry_readback", + "mode": "snapshot_only_no_runtime_no_secret_collection", + "scope": "wazuh_managed_host_coverage", + "summary": { + "expected_host_scope_count": len(HOST_SCOPE_MATRIX), + "manager_service_active_observed_count": 1, + "manager_api_unauthenticated_response_count": 1, + "manager_transport_established_connection_count": 6, + "direct_agent_active_observed_count": 2, + "direct_agent_transport_observed_count": 2, + "direct_agent_missing_or_no_transport_count": 1, + "ssh_readback_blocked_count": 3, + "manager_registry_accepted_count": 0, + "dashboard_api_degraded_observed_count": 1, + "live_metadata_env_enabled_count": 0, + "active_response_authorized_count": 0, + "host_write_authorized_count": 0, + "agent_reenroll_authorized_count": 0, + "agent_restart_authorized_count": 0, + "runtime_gate_count": 0, + }, + "host_scope_matrix": HOST_SCOPE_MATRIX, + "required_evidence_before_green": [ + {"evidence_id": evidence_id, "accepted": False} + for evidence_id in REQUIRED_EVIDENCE_BEFORE_GREEN + ], + "forbidden_completion_claims": FORBIDDEN_COMPLETION_CLAIMS, + "forbidden_actions": FORBIDDEN_ACTIONS, + "operator_interpretation": [ + "目前只能確認部分節點有 agent service 與 transport;manager registry 仍沒有可驗收讀回。", + "Dashboard API、RBAC、rate-limit 或 TLS 退化會讓 UI 代理清單看起來消失,但不能用 UI 畫面單獨判定 agent 全部恢復。", + "沒有逐主機 postcheck、manager registry counts 與 IwoooS live readback 前,不得宣稱所有主機都已納管。", + "重新註冊 agent、重啟 Wazuh、修改主機或改機密都必須走獨立維護窗口與 rollback owner。", + ], + "execution_boundaries": { + "wazuh_api_live_query_authorized": False, + "wazuh_agent_reenroll_authorized": False, + "wazuh_agent_restart_authorized": False, + "wazuh_manager_restart_authorized": False, + "wazuh_active_response_authorized": False, + "host_write_authorized": False, + "secret_value_collection_allowed": False, + "raw_wazuh_payload_storage_allowed": False, + "kali_active_scan_authorized": False, + "runtime_execution_authorized": False, + "not_authorization": True, + }, + } + + +def validate(root: Path) -> None: + snapshot = load_json(root / SNAPSHOT_PATH) + assert_equal("schema_version", snapshot.get("schema_version"), SCHEMA_VERSION) + assert_equal("status", snapshot.get("status"), "blocked_waiting_full_host_registry_readback") + assert_equal("mode", snapshot.get("mode"), "snapshot_only_no_runtime_no_secret_collection") + assert_equal("scope", snapshot.get("scope"), "wazuh_managed_host_coverage") + + summary = snapshot.get("summary", {}) + assert_equal("summary.expected_host_scope_count", summary.get("expected_host_scope_count"), len(HOST_SCOPE_MATRIX)) + assert_equal("summary.manager_service_active_observed_count", summary.get("manager_service_active_observed_count"), 1) + assert_equal("summary.manager_api_unauthenticated_response_count", summary.get("manager_api_unauthenticated_response_count"), 1) + assert_equal("summary.manager_transport_established_connection_count", summary.get("manager_transport_established_connection_count"), 6) + assert_equal("summary.direct_agent_active_observed_count", summary.get("direct_agent_active_observed_count"), 2) + assert_equal("summary.direct_agent_transport_observed_count", summary.get("direct_agent_transport_observed_count"), 2) + assert_equal("summary.direct_agent_missing_or_no_transport_count", summary.get("direct_agent_missing_or_no_transport_count"), 1) + assert_equal("summary.ssh_readback_blocked_count", summary.get("ssh_readback_blocked_count"), 3) + assert_zero("summary.manager_registry_accepted_count", summary.get("manager_registry_accepted_count")) + assert_equal("summary.dashboard_api_degraded_observed_count", summary.get("dashboard_api_degraded_observed_count"), 1) + for key in [ + "live_metadata_env_enabled_count", + "active_response_authorized_count", + "host_write_authorized_count", + "agent_reenroll_authorized_count", + "agent_restart_authorized_count", + "runtime_gate_count", + ]: + assert_zero(f"summary.{key}", summary.get(key)) + + assert_equal("host_scope_matrix", snapshot.get("host_scope_matrix"), HOST_SCOPE_MATRIX) + for item in snapshot.get("host_scope_matrix", []): + assert_false(f"host_scope_matrix.{item.get('node_id')}.manager_registry_accepted", item.get("manager_registry_accepted")) + + required = snapshot.get("required_evidence_before_green", []) + assert_equal("required_evidence_before_green.count", len(required), len(REQUIRED_EVIDENCE_BEFORE_GREEN)) + assert_equal( + "required_evidence_before_green.ids", + [item.get("evidence_id") for item in required], + REQUIRED_EVIDENCE_BEFORE_GREEN, + ) + for item in required: + assert_false(f"required_evidence_before_green.{item.get('evidence_id')}.accepted", item.get("accepted")) + + assert_equal("forbidden_completion_claims", snapshot.get("forbidden_completion_claims"), FORBIDDEN_COMPLETION_CLAIMS) + assert_equal("forbidden_actions", snapshot.get("forbidden_actions"), FORBIDDEN_ACTIONS) + boundaries = snapshot.get("execution_boundaries", {}) + for key, value in boundaries.items(): + if key == "not_authorization": + assert_equal(f"execution_boundaries.{key}", value, True) + else: + assert_false(f"execution_boundaries.{key}", value) + validate_no_forbidden_text(snapshot) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Wazuh 主機納管覆蓋 Gate") + parser.add_argument("--root", type=Path, default=Path.cwd()) + parser.add_argument("--output", type=Path) + parser.add_argument("--generated-at", default="2026-06-25T11:45:31+08:00") + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + + root = args.root.resolve() + if args.output: + snapshot = build_snapshot(args.generated_at) + output = args.output if args.output.is_absolute() else root / args.output + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(snapshot, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + validate(root) + snapshot = load_json(root / SNAPSHOT_PATH) + if args.json: + print(json.dumps(snapshot, ensure_ascii=False, sort_keys=True)) + return + summary = snapshot["summary"] + print( + "WAZUH_MANAGED_HOST_COVERAGE_GATE_OK " + f"scope={summary['expected_host_scope_count']} " + f"direct_active={summary['direct_agent_active_observed_count']} " + f"no_transport={summary['direct_agent_missing_or_no_transport_count']} " + f"ssh_blocked={summary['ssh_readback_blocked_count']} " + f"registry={summary['manager_registry_accepted_count']} " + f"runtime_gate={summary['runtime_gate_count']}" + ) + + +if __name__ == "__main__": + main()
+ {t('subtitle')} +
+ {t(`summary.${item.key}.detail` as never)} +
+ {t(`items.${item.key}.body` as never)} +
+ {t('boundaryIntro')} +
+ {item} +