diff --git a/apps/api/src/api/v1/iwooos.py b/apps/api/src/api/v1/iwooos.py index 4fcc95b5..2c28ef50 100644 --- a/apps/api/src/api/v1/iwooos.py +++ b/apps/api/src/api/v1/iwooos.py @@ -32,6 +32,9 @@ from src.services.iwooos_wazuh_readonly_status import ( from src.services.iwooos_wazuh_live_metadata_gate import ( load_latest_iwooos_wazuh_live_metadata_gate, ) +from src.services.iwooos_wazuh_managed_host_coverage import ( + load_latest_iwooos_wazuh_managed_host_coverage, +) from src.services.iwooos_wazuh_owner_evidence_preflight import ( load_latest_iwooos_wazuh_owner_evidence_preflight, ) @@ -116,6 +119,34 @@ async def get_iwooos_wazuh_owner_evidence_preflight() -> dict[str, Any]: ) from exc +@router.get( + "/api/v1/iwooos/wazuh-managed-host-coverage", + response_model=dict[str, Any], + summary="取得 Wazuh 受管主機覆蓋只讀讀回", + description=( + "讀取已提交的 Wazuh 受管主機覆蓋快照,回傳公開別名主機矩陣、manager registry " + "接受數、缺口數、必要驗收證據與 0 / false 邊界。此端點不查 Wazuh API、" + "不讀主機、不重新註冊 agent、不重啟 Wazuh、不保存原始載荷、不收機密明文、" + "不啟用主動回應、不改 Nginx / Docker / K8s / firewall。" + ), +) +async def get_iwooos_wazuh_managed_host_coverage() -> dict[str, Any]: + """回傳 Wazuh 受管主機覆蓋公開安全只讀狀態。""" + try: + payload = await asyncio.to_thread(load_latest_iwooos_wazuh_managed_host_coverage) + 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_wazuh_managed_host_coverage.py b/apps/api/src/services/iwooos_wazuh_managed_host_coverage.py new file mode 100644 index 00000000..838088c5 --- /dev/null +++ b/apps/api/src/services/iwooos_wazuh_managed_host_coverage.py @@ -0,0 +1,248 @@ +""" +IwoooS Wazuh managed host coverage readback. + +This service exposes the committed Wazuh managed-host coverage snapshot as a +public-safe, alias-only payload. It never queries Wazuh, reads secrets, reenrolls +agents, restarts services, writes hosts, or enables active response. +""" + +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-managed-host-coverage-gate.snapshot.json" +_EXPECTED_SCHEMA = "wazuh_managed_host_coverage_gate_v1" + +_REQUIRED_FALSE_BOUNDARIES = { + "host_write_authorized", + "kali_active_scan_authorized", + "raw_wazuh_payload_storage_allowed", + "runtime_execution_authorized", + "secret_value_collection_allowed", + "wazuh_active_response_authorized", + "wazuh_agent_reenroll_authorized", + "wazuh_agent_restart_authorized", + "wazuh_api_live_query_authorized", + "wazuh_manager_restart_authorized", +} + +_STATUS_LABELS = { + "agent_active_transport_observed": "代理服務與傳輸連線已觀察,仍待管理器清單驗收", + "no_agent_transport_observed": "未觀察到代理傳輸,需確認安裝、服務與負責人決策", + "ssh_readback_blocked": "合法只讀讀回仍受阻,需 owner export 或維護窗口", +} + +_NEXT_GATE_LABELS = { + "manager_registry_cross_check": "補管理器清單交叉驗收", + "agent_install_or_service_owner_decision": "補代理安裝狀態或服務負責人決策", + "read_only_access_or_owner_export": "補只讀 access 或脫敏 owner export", +} + + +def load_latest_iwooos_wazuh_managed_host_coverage( + security_dir: Path | None = None, +) -> dict[str, Any]: + """Load Wazuh managed-host coverage as an alias-only public readback.""" + directory = security_dir or _DEFAULT_SECURITY_DIR + snapshot = _load_snapshot(directory) + _require_boundaries(snapshot) + + summary = _summary(snapshot) + matrix = _host_scope_matrix(snapshot) + evidence = _required_evidence(snapshot) + merged_summary = { + "expected_host_scope_count": _int(summary.get("expected_host_scope_count")), + "manager_service_active_observed_count": _int(summary.get("manager_service_active_observed_count")), + "manager_api_unauthenticated_response_count": _int(summary.get("manager_api_unauthenticated_response_count")), + "manager_transport_established_connection_count": _int( + summary.get("manager_transport_established_connection_count") + ), + "direct_agent_active_observed_count": _int(summary.get("direct_agent_active_observed_count")), + "direct_agent_transport_observed_count": _int(summary.get("direct_agent_transport_observed_count")), + "direct_agent_missing_or_no_transport_count": _int( + summary.get("direct_agent_missing_or_no_transport_count") + ), + "ssh_readback_blocked_count": _int(summary.get("ssh_readback_blocked_count")), + "manager_registry_accepted_count": _int(summary.get("manager_registry_accepted_count")), + "manager_registry_gap_count": max( + _int(summary.get("expected_host_scope_count")) - _int(summary.get("manager_registry_accepted_count")), + 0, + ), + "dashboard_api_degraded_observed_count": _int(summary.get("dashboard_api_degraded_observed_count")), + "live_metadata_env_enabled_count": _int(summary.get("live_metadata_env_enabled_count")), + "active_response_authorized_count": _int(summary.get("active_response_authorized_count")), + "host_write_authorized_count": _int(summary.get("host_write_authorized_count")), + "agent_reenroll_authorized_count": _int(summary.get("agent_reenroll_authorized_count")), + "agent_restart_authorized_count": _int(summary.get("agent_restart_authorized_count")), + "runtime_gate_count": _int(summary.get("runtime_gate_count")), + "host_scope_matrix_count": len(matrix), + "required_evidence_before_green_count": len(evidence), + "required_evidence_accepted_count": sum(1 for item in evidence if item.get("accepted") is True), + } + + return { + "schema_version": "iwooos_wazuh_managed_host_coverage_readback_v1", + "status": snapshot.get("status", "blocked_waiting_full_host_registry_readback"), + "mode": "committed_snapshot_readback_alias_only_no_wazuh_live_query", + "source_refs": [ + f"docs/security/{_SNAPSHOT_FILE}", + "scripts/security/wazuh-managed-host-coverage-gate.py", + ], + "summary": merged_summary, + "host_scope_matrix": matrix, + "required_evidence_before_green": evidence, + "operator_interpretation": _list_of_strings(snapshot.get("operator_interpretation")), + "forbidden_completion_claims": _list_of_strings(snapshot.get("forbidden_completion_claims")), + "forbidden_actions": _list_of_strings(snapshot.get("forbidden_actions")), + "boundary_markers": _boundary_markers(merged_summary), + "boundaries": { + "wazuh_api_live_query_authorized": False, + "wazuh_active_response_authorized": False, + "wazuh_agent_reenroll_authorized": False, + "wazuh_agent_restart_authorized": False, + "wazuh_manager_restart_authorized": False, + "host_write_authorized": False, + "kali_active_scan_authorized": False, + "secret_value_collection_allowed": False, + "raw_wazuh_payload_storage_allowed": False, + "runtime_execution_authorized": False, + "not_authorization": True, + }, + "no_false_green_rules": [ + "Wazuh Dashboard 可見不等於 manager registry 已恢復", + "transport 連線、agent service active 或 HTTP 200 不可替代逐主機 registry matrix", + "manager_registry_accepted_count 維持 0 時不得宣稱所有主機已納管", + "重新註冊、重啟、active response、主機寫入、Nginx、firewall、Kali 掃描與機密調整都不是此讀回授權", + ], + } + + +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 _list_of_strings(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + return [item for item in value if isinstance(item, str)] + + +def _host_scope_matrix(payload: dict[str, Any]) -> list[dict[str, Any]]: + raw_items = payload.get("host_scope_matrix") + if not isinstance(raw_items, list): + raise ValueError("Wazuh 受管主機覆蓋 host_scope_matrix 缺失") + + matrix: list[dict[str, Any]] = [] + for raw_item in raw_items: + if not isinstance(raw_item, dict): + raise ValueError("Wazuh 受管主機覆蓋 host_scope_matrix 必須是 object list") + node_id = str(raw_item.get("node_id", "")) + role = str(raw_item.get("role", "")) + readback_status = str(raw_item.get("readback_status", "")) + next_gate = str(raw_item.get("next_gate", "")) + if not node_id.startswith("managed_"): + raise ValueError(f"Wazuh 受管主機覆蓋 node_id 必須維持公開別名:{node_id}") + if raw_item.get("manager_registry_accepted") is not False: + raise ValueError(f"Wazuh 受管主機覆蓋 {node_id} manager_registry_accepted 必須維持 false") + matrix.append( + { + "node_id": node_id, + "role": role, + "readback_status": readback_status, + "readback_status_label": _STATUS_LABELS.get(readback_status, "待補只讀讀回"), + "next_gate": next_gate, + "next_gate_label": _NEXT_GATE_LABELS.get(next_gate, "待補負責人驗收"), + "manager_registry_accepted": False, + } + ) + return matrix + + +def _required_evidence(payload: dict[str, Any]) -> list[dict[str, Any]]: + raw_items = payload.get("required_evidence_before_green") + if not isinstance(raw_items, list): + raise ValueError("Wazuh 受管主機覆蓋 required_evidence_before_green 缺失") + evidence: list[dict[str, Any]] = [] + for raw_item in raw_items: + if not isinstance(raw_item, dict): + raise ValueError("Wazuh 受管主機覆蓋 required_evidence_before_green 必須是 object list") + evidence_id = str(raw_item.get("evidence_id", "")) + accepted = raw_item.get("accepted") is True + evidence.append({"evidence_id": evidence_id, "accepted": accepted}) + return evidence + + +def _boundary_markers(summary: dict[str, int]) -> list[str]: + return [ + "wazuh_managed_host_coverage_gate_visible=true", + f"wazuh_managed_host_coverage_expected_host_scope_count={summary['expected_host_scope_count']}", + f"wazuh_managed_host_coverage_host_scope_matrix_count={summary['host_scope_matrix_count']}", + f"wazuh_managed_host_coverage_direct_agent_active_observed_count={summary['direct_agent_active_observed_count']}", + f"wazuh_managed_host_coverage_direct_agent_missing_or_no_transport_count={summary['direct_agent_missing_or_no_transport_count']}", + f"wazuh_managed_host_coverage_ssh_readback_blocked_count={summary['ssh_readback_blocked_count']}", + f"wazuh_managed_host_coverage_manager_registry_accepted_count={summary['manager_registry_accepted_count']}", + f"wazuh_managed_host_coverage_manager_registry_gap_count={summary['manager_registry_gap_count']}", + f"wazuh_managed_host_coverage_required_evidence_before_green_count={summary['required_evidence_before_green_count']}", + f"wazuh_managed_host_coverage_required_evidence_accepted_count={summary['required_evidence_accepted_count']}", + f"wazuh_managed_host_coverage_dashboard_api_degraded_observed_count={summary['dashboard_api_degraded_observed_count']}", + f"wazuh_managed_host_coverage_live_metadata_env_enabled_count={summary['live_metadata_env_enabled_count']}", + f"wazuh_managed_host_coverage_runtime_gate_count={summary['runtime_gate_count']}", + f"wazuh_agent_reenroll_authorized_count={summary['agent_reenroll_authorized_count']}", + f"wazuh_agent_restart_authorized_count={summary['agent_restart_authorized_count']}", + "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", + "runtime_execution_authorized=false", + "not_authorization=true", + ] + + +def _require_boundaries(payload: dict[str, Any]) -> None: + summary = _summary(payload) + for key in ( + "manager_registry_accepted_count", + "live_metadata_env_enabled_count", + "active_response_authorized_count", + "host_write_authorized_count", + "agent_reenroll_authorized_count", + "agent_restart_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_wazuh_managed_host_coverage.py b/apps/api/tests/test_iwooos_wazuh_managed_host_coverage.py new file mode 100644 index 00000000..ba53c84f --- /dev/null +++ b/apps/api/tests/test_iwooos_wazuh_managed_host_coverage.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from src.api.v1.iwooos import router +from src.services.iwooos_wazuh_managed_host_coverage import ( + load_latest_iwooos_wazuh_managed_host_coverage, +) + + +def _client() -> TestClient: + app = FastAPI() + app.include_router(router) + return TestClient(app) + + +def test_iwooos_wazuh_managed_host_coverage_keeps_registry_gate_closed() -> None: + payload = load_latest_iwooos_wazuh_managed_host_coverage() + + assert payload["schema_version"] == "iwooos_wazuh_managed_host_coverage_readback_v1" + assert payload["status"] == "blocked_waiting_full_host_registry_readback" + assert payload["mode"] == "committed_snapshot_readback_alias_only_no_wazuh_live_query" + assert payload["summary"]["expected_host_scope_count"] == 6 + assert payload["summary"]["host_scope_matrix_count"] == 6 + assert payload["summary"]["direct_agent_active_observed_count"] == 2 + assert payload["summary"]["direct_agent_missing_or_no_transport_count"] == 1 + assert payload["summary"]["ssh_readback_blocked_count"] == 3 + assert payload["summary"]["manager_registry_accepted_count"] == 0 + assert payload["summary"]["manager_registry_gap_count"] == 6 + assert payload["summary"]["required_evidence_before_green_count"] == 6 + assert payload["summary"]["required_evidence_accepted_count"] == 0 + assert payload["summary"]["runtime_gate_count"] == 0 + assert payload["summary"]["active_response_authorized_count"] == 0 + assert payload["summary"]["host_write_authorized_count"] == 0 + assert payload["summary"]["agent_reenroll_authorized_count"] == 0 + assert payload["summary"]["agent_restart_authorized_count"] == 0 + + boundaries = payload["boundaries"] + assert boundaries["not_authorization"] is True + for key, value in boundaries.items(): + if key == "not_authorization": + continue + assert value is False + + +def test_iwooos_wazuh_managed_host_coverage_alias_matrix_is_complete() -> None: + payload = load_latest_iwooos_wazuh_managed_host_coverage() + + matrix = payload["host_scope_matrix"] + assert [item["node_id"] for item in matrix] == [ + "managed_core_node_a", + "managed_core_node_b", + "managed_dev_node_a", + "managed_dev_node_b", + "managed_control_node_a", + "managed_control_node_b", + ] + assert all(item["node_id"].startswith("managed_") for item in matrix) + assert all(item["manager_registry_accepted"] is False for item in matrix) + assert matrix[0]["readback_status"] == "agent_active_transport_observed" + assert matrix[0]["next_gate"] == "manager_registry_cross_check" + assert matrix[2]["readback_status"] == "no_agent_transport_observed" + assert matrix[3]["readback_status"] == "ssh_readback_blocked" + + +def test_iwooos_wazuh_managed_host_coverage_api_is_public_safe() -> None: + response = _client().get("/api/v1/iwooos/wazuh-managed-host-coverage") + + assert response.status_code == 200 + data = response.json() + assert data["schema_version"] == "iwooos_wazuh_managed_host_coverage_readback_v1" + assert data["summary"]["expected_host_scope_count"] == 6 + assert data["summary"]["manager_registry_accepted_count"] == 0 + assert data["summary"]["manager_registry_gap_count"] == 6 + assert data["summary"]["required_evidence_accepted_count"] == 0 + assert data["summary"]["runtime_gate_count"] == 0 + assert len(data["host_scope_matrix"]) == 6 + assert any(marker == "wazuh_managed_host_coverage_host_scope_matrix_count=6" for marker in data["boundary_markers"]) + assert any(marker == "wazuh_managed_host_coverage_manager_registry_accepted_count=0" for marker in data["boundary_markers"]) + assert any(marker == "wazuh_managed_host_coverage_manager_registry_gap_count=6" for marker in data["boundary_markers"]) + assert any(marker == "wazuh_managed_host_coverage_required_evidence_accepted_count=0" for marker in data["boundary_markers"]) + assert any(rule.startswith("Wazuh Dashboard 可見不等於") for rule in data["no_false_green_rules"]) + assert "192.168.0." not in response.text + assert "工作視窗" not in response.text + assert "批准!繼續" not in response.text + assert "source_thread_id" not in response.text + assert "owenhytsai/" 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 becb1679..0f5e479e 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -20729,8 +20729,20 @@ "subtitle": "這張卡把 Wazuh 納管拆成應納管範圍、直接觀察到的代理、覆蓋缺口與管理器清單驗收。現在只能確認部分節點有代理與連線,不能宣稱所有主機都已恢復。", "checkLabel": "檢核", "stateLabel": "狀態", + "loadingBoundary": "正在讀取 Wazuh 受管主機覆蓋只讀 API", + "matrixTitle": "公開別名主機矩陣", + "matrixSubtitle": "只顯示節點別名、角色、讀回狀態與下一個 Gate", + "matrixAcceptedLabel": "清單驗收", + "matrixNextGateLabel": "下一個 Gate", + "matrixLoading": "正在讀取 alias-only 受管主機矩陣。", + "matrixFallback": "受管主機矩陣尚未由正式 API 讀回,維持 fallback 靜態邊界。", "boundaryTitle": "主機覆蓋邊界", "boundaryIntro": "以下鍵值固定:連線存在、代理服務啟動或儀表板可開都不能替代管理器清單;重新註冊、重啟、主機寫入、機密調整與主動回應都需要獨立維護窗口與回滾負責人。", + "status": { + "loading": "正在讀取 Wazuh 主機覆蓋只讀 API", + "failed": "Wazuh 主機覆蓋 API 尚未部署或讀取失敗,維持 fallback 邊界", + "ready": "Wazuh 主機覆蓋只讀 API 已接上,manager registry accepted 仍為 0" + }, "summary": { "scope": { "label": "應納管範圍", @@ -20742,7 +20754,7 @@ }, "coverageGap": { "label": "覆蓋缺口", - "detail": "包含無傳輸與尚未取得合法只讀讀回的節點。" + "detail": "以 manager registry 驗收為準,6 個節點目前都不能算完成。" }, "registry": { "label": "清單驗收", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index becb1679..0f5e479e 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -20729,8 +20729,20 @@ "subtitle": "這張卡把 Wazuh 納管拆成應納管範圍、直接觀察到的代理、覆蓋缺口與管理器清單驗收。現在只能確認部分節點有代理與連線,不能宣稱所有主機都已恢復。", "checkLabel": "檢核", "stateLabel": "狀態", + "loadingBoundary": "正在讀取 Wazuh 受管主機覆蓋只讀 API", + "matrixTitle": "公開別名主機矩陣", + "matrixSubtitle": "只顯示節點別名、角色、讀回狀態與下一個 Gate", + "matrixAcceptedLabel": "清單驗收", + "matrixNextGateLabel": "下一個 Gate", + "matrixLoading": "正在讀取 alias-only 受管主機矩陣。", + "matrixFallback": "受管主機矩陣尚未由正式 API 讀回,維持 fallback 靜態邊界。", "boundaryTitle": "主機覆蓋邊界", "boundaryIntro": "以下鍵值固定:連線存在、代理服務啟動或儀表板可開都不能替代管理器清單;重新註冊、重啟、主機寫入、機密調整與主動回應都需要獨立維護窗口與回滾負責人。", + "status": { + "loading": "正在讀取 Wazuh 主機覆蓋只讀 API", + "failed": "Wazuh 主機覆蓋 API 尚未部署或讀取失敗,維持 fallback 邊界", + "ready": "Wazuh 主機覆蓋只讀 API 已接上,manager registry accepted 仍為 0" + }, "summary": { "scope": { "label": "應納管範圍", @@ -20742,7 +20754,7 @@ }, "coverageGap": { "label": "覆蓋缺口", - "detail": "包含無傳輸與尚未取得合法只讀讀回的節點。" + "detail": "以 manager registry 驗收為準,6 個節點目前都不能算完成。" }, "registry": { "label": "清單驗收", diff --git a/apps/web/src/app/[locale]/iwooos/page.tsx b/apps/web/src/app/[locale]/iwooos/page.tsx index 9b413489..83e3efc4 100644 --- a/apps/web/src/app/[locale]/iwooos/page.tsx +++ b/apps/web/src/app/[locale]/iwooos/page.tsx @@ -44,6 +44,7 @@ import { type IwoooSSecurityControlCoverageResponse, type IwoooSWazuhLiveMetadataGateItem, type IwoooSWazuhLiveMetadataGateResponse, + type IwoooSWazuhManagedHostCoverageResponse, type IwoooSWazuhOwnerEvidencePreflightItem, type IwoooSWazuhOwnerEvidencePreflightResponse, } from '@/lib/api-client' @@ -2433,13 +2434,6 @@ 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' }, @@ -9481,6 +9475,74 @@ function IwoooSWazuhOwnerEvidencePreflightBoard() { function IwoooSWazuhManagedHostCoverageBoard() { const t = useTranslations('iwooos.wazuhManagedHostCoverage') 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 loadCoverage() { + setLoading(true) + setFailed(false) + try { + const payload = await apiClient.getIwoooSWazuhManagedHostCoverage() + if (mounted) { + setData(payload) + } + } catch { + if (mounted) { + setData(null) + setFailed(true) + } + } finally { + if (mounted) { + setLoading(false) + } + } + } + + loadCoverage() + return () => { + mounted = false + } + }, []) + + const summary = data?.summary + const summaryItems = [ + { + key: 'scope', + value: summary ? String(summary.expected_host_scope_count) : loading ? '...' : '6', + icon: Server, + tone: 'warn', + }, + { + key: 'directActive', + value: summary ? String(summary.direct_agent_active_observed_count) : loading ? '...' : '2', + icon: Activity, + tone: 'warn', + }, + { + key: 'coverageGap', + value: summary ? String(summary.manager_registry_gap_count) : loading ? '...' : '6', + icon: FileWarning, + tone: 'locked', + }, + { + key: 'registry', + value: summary ? String(summary.manager_registry_accepted_count) : loading ? '...' : '0', + icon: Lock, + tone: 'locked', + }, + ] as const + const boundaryMarkers = data?.boundary_markers?.length + ? data.boundary_markers + : loading + ? [t('loadingBoundary')] + : wazuhManagedHostCoverageBoundaries + const hostMatrix = data?.host_scope_matrix ?? [] + const statusText = loading ? t('status.loading') : failed ? t('status.failed') : t('status.ready') + const statusTone: 'steady' | 'warn' | 'locked' = loading || failed ? 'warn' : 'locked' return (
{t('subtitle')}

+
+ + {statusText} +
- {wazuhManagedHostCoverageSummary.map(item => { + {summaryItems.map(item => { const Icon = item.icon return (
@@ -9568,6 +9634,69 @@ function IwoooSWazuhManagedHostCoverageBoard() { })}
+
+
+
{t('matrixTitle')}
+
+ {t('matrixSubtitle')} +
+
+
+ {(hostMatrix.length ? hostMatrix : []).map(item => ( +
+
+ {item.node_id} + + {t('matrixAcceptedLabel')}:0 + +
+
{item.role}
+

+ {item.readback_status_label} +

+

+ {t('matrixNextGateLabel')}:{item.next_gate_label} +

+ + {item.next_gate} + +
+ ))} + {!hostMatrix.length && ( +
+ {loading ? t('matrixLoading') : t('matrixFallback')} +
+ )} +
+
+
- {wazuhManagedHostCoverageBoundaries.map(item => ( + {boundaryMarkers.map(item => ( + operator_interpretation: string[] + forbidden_completion_claims: string[] + forbidden_actions: string[] + boundary_markers: string[] + boundaries: Record + no_false_green_rules: string[] +} + export interface IwoooSSecurityControlCoverageDomain { domain_id: | 'high_value_asset_control' @@ -508,6 +558,11 @@ export const apiClient = { return handleResponse(res) }, + async getIwoooSWazuhManagedHostCoverage() { + const res = await fetch(`${API_BASE_URL}/iwooos/wazuh-managed-host-coverage`, { 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/docs/LOGBOOK.md b/docs/LOGBOOK.md index 3de18dad..eff84b1a 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -47036,6 +47036,45 @@ production browser smoke: - `P0-02` owner-provided redacted evidence intake:開始接收六條 lane 的脫敏 evidence refs,但仍先維持 request / received / accepted / runtime 0,直到 reviewer validation 成立。 - `P0-03` Wazuh manager registry accepted:只讀交叉驗收所有 expected host / product / agent scope,不把 Dashboard 可開、API 200 或前台 lane 可見當作全主機納管恢復。 +## 2026-06-27 — 14:40 IwoooS Wazuh 受管主機覆蓋 API / 前台讀回本地完成 + +**時間與來源**: +- 2026-06-27 14:26-14:40 Asia/Taipei。 +- 來源:`docs/security/wazuh-managed-host-coverage-gate.snapshot.json`、`scripts/security/wazuh-managed-host-coverage-gate.py`、本地 API / 前台 / guard 驗證。 + +**完成內容**: +- 新增 `GET /api/v1/iwooos/wazuh-managed-host-coverage`,以公開別名回傳 Wazuh 受管主機覆蓋矩陣、manager registry 接受數、缺口數、必要驗收證據與 0 / false 邊界。 +- `/zh-TW/iwooos` 的 Wazuh 主機納管覆蓋卡改為正式 API 讀回優先,並新增只讀 API 狀態、公開別名主機矩陣與下一個 Gate。 +- `scripts/security/security-mirror-progress-guard.py` 納入 API route、service schema、client method、public-safe 測試、前台矩陣與 no-false-green marker。 +- `zh-TW` 與目前鏡像訊息都維持繁體中文;未加入工作視窗對話、個人 namespace、內網位址或 secret。 + +**本地驗證結果**: +- `python3 -m py_compile apps/api/src/services/iwooos_wazuh_managed_host_coverage.py apps/api/src/api/v1/iwooos.py scripts/security/security-mirror-progress-guard.py`:通過。 +- `node -e "JSON.parse(...zh-TW.json); JSON.parse(...en.json)"`:通過。 +- `DATABASE_URL=sqlite:///test.db python3.11 -m pytest apps/api/tests/test_iwooos_wazuh_managed_host_coverage.py apps/api/tests/test_iwooos_runtime_security_readback.py apps/api/tests/test_iwooos_security_control_coverage.py apps/api/tests/test_iwooos_wazuh_api.py apps/api/tests/test_iwooos_owner_evidence_intake_preflight.py -q`:`21 passed`。 +- `python3 scripts/security/wazuh-managed-host-coverage-gate.py --root .`:`WAZUH_MANAGED_HOST_COVERAGE_GATE_OK scope=6 direct_active=2 no_transport=1 ssh_blocked=3 registry=0 runtime_gate=0`。 +- `python3 scripts/security/iwooos-frontend-display-redaction-guard.py --root .`:`IWOOOS_FRONTEND_DISPLAY_REDACTION_GUARD_OK`。 +- `python3 scripts/security/security-mirror-progress-guard.py --root .`:`SECURITY_MIRROR_PROGRESS_GUARD_OK`。 +- `pnpm --dir apps/web typecheck`:通過。 + +**完成度與同步狀態**: +- 本段「Wazuh 受管主機覆蓋 API / 前台讀回」本地:`0% -> 85%`。尚待 commit、push、CD、production API readback 與 desktop / mobile browser smoke。 +- IwoooS 整體:暫不拉高,仍保守維持 `67%`,等正式讀回完成後再調整。 +- Wazuh manager registry accepted:仍為 `0`;此段只建立可驗證讀回與前台 no-false-green,不代表所有主機已納管或 Wazuh 已修復完成。 + +**仍維持 0 / false**: +- `manager_registry_accepted_count=0`、`required_evidence_accepted_count=0`、`live_metadata_env_enabled_count=0`、`runtime_gate_count=0`、`active_response_authorized_count=0`、`host_write_authorized_count=0`、`agent_reenroll_authorized_count=0`、`agent_restart_authorized_count=0`。 +- `runtime_execution_authorized=false`、`wazuh_api_live_query_authorized=false`、`wazuh_active_response_authorized=false`、`wazuh_agent_reenroll_authorized=false`、`wazuh_agent_restart_authorized=false`、`wazuh_manager_restart_authorized=false`、`host_write_authorized=false`、`kali_active_scan_authorized=false`、`secret_value_collection_allowed=false`、`not_authorization=true`。 + +**做過的命令類型**: +- 寫入:repo API / test / frontend / i18n / guard / LOGBOOK。 +- 只讀:git fetch / diff、repo snapshot 驗證、本地測試與型別檢查。 +- 未做:沒有 host / Docker / systemd / Nginx / firewall / K8s / DB / Wazuh runtime 寫操作;沒有讀 secret 明文;沒有重新註冊 agent;沒有 Wazuh restart;沒有 Wazuh active response;沒有 Kali active scan;沒有 force push。 + +**下一步**: +- commit / push 到 Gitea 後等待 CD,正式驗證 `GET /api/v1/iwooos/wazuh-managed-host-coverage` 與 `/zh-TW/iwooos` desktop / mobile。 +- 若 production 讀回通過,再更新 deploy marker、Gitea runs、overflow、forbidden hits 與 IwoooS 完成度。 + ## 2026-06-27 — 14:25 IwoooS rollout risk 前台與 guard 更新完成 **時間與來源**: diff --git a/scripts/security/security-mirror-progress-guard.py b/scripts/security/security-mirror-progress-guard.py index 167214c2..8ebf073f 100755 --- a/scripts/security/security-mirror-progress-guard.py +++ b/scripts/security/security-mirror-progress-guard.py @@ -335,6 +335,16 @@ def validate(root: Path) -> None: platform_operator_service = ( root / "apps" / "api" / "src" / "services" / "platform_operator_service.py" ).read_text(encoding="utf-8") + iwooos_api_router = (root / "apps" / "api" / "src" / "api" / "v1" / "iwooos.py").read_text( + encoding="utf-8" + ) + iwooos_api_client = (root / "apps" / "web" / "src" / "lib" / "api-client.ts").read_text(encoding="utf-8") + iwooos_wazuh_managed_host_coverage_service = ( + root / "apps" / "api" / "src" / "services" / "iwooos_wazuh_managed_host_coverage.py" + ).read_text(encoding="utf-8") + iwooos_wazuh_managed_host_coverage_test = ( + root / "apps" / "api" / "tests" / "test_iwooos_wazuh_managed_host_coverage.py" + ).read_text(encoding="utf-8") tenants_api_contract = ( root / "apps" / "api" / "src" / "api" / "v1" / "platform" / "tenants.py" ).read_text(encoding="utf-8") @@ -29513,6 +29523,14 @@ def validate(root: Path) -> None: json.dumps(web_messages_en["iwooos"], ensure_ascii=False), ] ) + wazuh_managed_host_coverage_source_text = "\n".join( + [ + iwooos_api_router, + iwooos_api_client, + iwooos_wazuh_managed_host_coverage_service, + iwooos_wazuh_managed_host_coverage_test, + ] + ) for expected in [ "iwooos-wazuh-owner-evidence-preflight-board", "wazuhOwnerEvidencePreflight", @@ -29526,11 +29544,33 @@ def validate(root: Path) -> None: "wazuh_agent_visibility_owner_evidence_registry_export_accepted_count=0", "wazuh_agent_visibility_owner_evidence_runtime_gate_count=0", "iwooos-wazuh-managed-host-coverage-board", + "iwooos-wazuh-managed-host-coverage-matrix", "wazuhManagedHostCoverage", + "getIwoooSWazuhManagedHostCoverage", + "apiClient.getIwoooSWazuhManagedHostCoverage", + "Wazuh 主機覆蓋只讀 API 已接上", "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 expected in [ + "/api/v1/iwooos/wazuh-managed-host-coverage", + "iwooos_wazuh_managed_host_coverage_readback_v1", + "test_iwooos_wazuh_managed_host_coverage_api_is_public_safe", + "managed_core_node_a", + "manager_registry_cross_check", + "wazuh_managed_host_coverage_host_scope_matrix_count=6", + "wazuh_managed_host_coverage_manager_registry_accepted_count=0", + "wazuh_managed_host_coverage_manager_registry_gap_count=6", + "wazuh_managed_host_coverage_required_evidence_accepted_count=0", + "wazuh_agent_reenroll_authorized=false", + "wazuh_agent_restart_authorized=false", + ]: + assert_text_contains( + "iwooos_wazuh_managed_host_coverage_source", + wazuh_managed_host_coverage_source_text, + expected, + ) for forbidden in [ "工作視窗", "內部對話",