diff --git a/apps/api/src/api/v1/iwooos.py b/apps/api/src/api/v1/iwooos.py index 2c28ef50..3251afb3 100644 --- a/apps/api/src/api/v1/iwooos.py +++ b/apps/api/src/api/v1/iwooos.py @@ -35,6 +35,9 @@ from src.services.iwooos_wazuh_live_metadata_gate import ( from src.services.iwooos_wazuh_managed_host_coverage import ( load_latest_iwooos_wazuh_managed_host_coverage, ) +from src.services.iwooos_wazuh_manager_registry_reviewer_validation import ( + load_latest_iwooos_wazuh_manager_registry_reviewer_validation, +) from src.services.iwooos_wazuh_owner_evidence_preflight import ( load_latest_iwooos_wazuh_owner_evidence_preflight, ) @@ -147,6 +150,34 @@ async def get_iwooos_wazuh_managed_host_coverage() -> dict[str, Any]: ) from exc +@router.get( + "/api/v1/iwooos/wazuh-manager-registry-reviewer-validation", + response_model=dict[str, Any], + summary="取得 Wazuh manager registry reviewer validation 只讀讀回", + description=( + "讀取已提交的 Wazuh manager registry reviewer validation contract,回傳 owner export " + "必要欄位、reviewer 檢查、evidence slots、結果分流、拒收內容與 0 / false 邊界。" + "此端點不收 raw payload、不查 Wazuh API、不讀主機、不重新註冊 agent、不重啟服務、" + "不保存機密、不啟用主動回應、不改 Nginx / Docker / K8s / firewall。" + ), +) +async def get_iwooos_wazuh_manager_registry_reviewer_validation() -> dict[str, Any]: + """回傳 Wazuh manager registry reviewer validation 公開安全只讀狀態。""" + try: + payload = await asyncio.to_thread(load_latest_iwooos_wazuh_manager_registry_reviewer_validation) + 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 manager registry reviewer validation 無效:{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_manager_registry_reviewer_validation.py b/apps/api/src/services/iwooos_wazuh_manager_registry_reviewer_validation.py new file mode 100644 index 00000000..aba0eecc --- /dev/null +++ b/apps/api/src/services/iwooos_wazuh_manager_registry_reviewer_validation.py @@ -0,0 +1,227 @@ +""" +IwoooS Wazuh manager registry reviewer validation readback. + +This service exposes a committed reviewer-validation contract for future +owner-provided redacted Wazuh manager registry exports. It never receives raw +payloads, queries Wazuh, reads host data, reads secrets, or authorizes runtime +actions. +""" + +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-manager-registry-reviewer-validation.snapshot.json" +_EXPECTED_SCHEMA = "wazuh_manager_registry_reviewer_validation_v1" + +_REQUIRED_FALSE_BOUNDARIES = { + "agent_identity_public_display_allowed", + "host_write_authorized", + "internal_ip_public_display_allowed", + "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", +} + + +def load_latest_iwooos_wazuh_manager_registry_reviewer_validation( + security_dir: Path | None = None, +) -> dict[str, Any]: + """Load the public-safe Wazuh manager registry reviewer-validation contract.""" + directory = security_dir or _DEFAULT_SECURITY_DIR + snapshot = _load_snapshot(directory) + _require_boundaries(snapshot) + + summary = _summary(snapshot) + merged_summary = { + "expected_scope_alias_count": _int(summary.get("expected_scope_alias_count")), + "required_owner_field_count": _int(summary.get("required_owner_field_count")), + "per_host_required_field_count": _int(summary.get("per_host_required_field_count")), + "reviewer_validation_check_count": _int(summary.get("reviewer_validation_check_count")), + "outcome_lane_count": _int(summary.get("outcome_lane_count")), + "evidence_slot_count": _int(summary.get("evidence_slot_count")), + "forbidden_payload_count": _int(summary.get("forbidden_payload_count")), + "forbidden_action_count": _int(summary.get("forbidden_action_count")), + "owner_registry_export_received_count": _int(summary.get("owner_registry_export_received_count")), + "owner_registry_export_accepted_count": _int(summary.get("owner_registry_export_accepted_count")), + "reviewer_validation_ready_count": _int(summary.get("reviewer_validation_ready_count")), + "reviewer_validation_passed_count": _int(summary.get("reviewer_validation_passed_count")), + "reviewer_validation_failed_count": _int(summary.get("reviewer_validation_failed_count")), + "reviewer_validation_quarantined_count": _int(summary.get("reviewer_validation_quarantined_count")), + "manager_registry_accepted_count": _int(summary.get("manager_registry_accepted_count")), + "post_enable_readback_passed_count": _int(summary.get("post_enable_readback_passed_count")), + "runtime_gate_count": _int(summary.get("runtime_gate_count")), + "host_write_authorized_count": _int(summary.get("host_write_authorized_count")), + "active_response_authorized_count": _int(summary.get("active_response_authorized_count")), + "secret_value_collection_allowed_count": _int(summary.get("secret_value_collection_allowed_count")), + } + + return { + "schema_version": "iwooos_wazuh_manager_registry_reviewer_validation_readback_v1", + "source_schema_version": snapshot["schema_version"], + "status": snapshot.get("status", "waiting_owner_registry_export_for_reviewer_validation"), + "mode": "committed_validation_contract_readback_no_runtime_no_secret_collection", + "source_refs": [ + f"docs/security/{_SNAPSHOT_FILE}", + "scripts/security/wazuh-manager-registry-reviewer-validation.py", + ], + "summary": merged_summary, + "expected_scope_aliases": _strings(snapshot.get("expected_scope_aliases")), + "reviewer_validation_checks": _checks(snapshot.get("reviewer_validation_checks")), + "outcome_lanes": _strings(snapshot.get("outcome_lanes")), + "evidence_slots": _evidence_slots(snapshot.get("evidence_slots")), + "forbidden_payloads": _strings(snapshot.get("forbidden_payloads")), + "forbidden_actions": _strings(snapshot.get("forbidden_actions")), + "boundary_markers": _boundary_markers(merged_summary), + "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, + "agent_identity_public_display_allowed": False, + "internal_ip_public_display_allowed": False, + "kali_active_scan_authorized": False, + "runtime_execution_authorized": False, + "not_authorization": True, + }, + "no_false_green_rules": _strings(snapshot.get("no_false_green_rules")), + } + + +def _load_snapshot(directory: Path) -> dict[str, Any]: + path = directory / _SNAPSHOT_FILE + if not path.is_file(): + raise FileNotFoundError(f"{path}: Wazuh manager registry reviewer validation 快照不存在") + 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 _strings(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + return [item for item in value if isinstance(item, str)] + + +def _checks(value: Any) -> list[dict[str, str]]: + if not isinstance(value, list): + return [] + checks: list[dict[str, str]] = [] + for item in value: + if not isinstance(item, dict): + continue + checks.append( + { + "check_id": str(item.get("check_id", "")), + "title": str(item.get("title", "")), + "required_evidence": str(item.get("required_evidence", "")), + "failure_lane": str(item.get("failure_lane", "")), + } + ) + return checks + + +def _evidence_slots(value: Any) -> list[dict[str, Any]]: + if not isinstance(value, list): + return [] + slots: list[dict[str, Any]] = [] + for item in value: + if not isinstance(item, dict): + continue + slots.append( + { + "slot_id": str(item.get("slot_id", "")), + "title": str(item.get("title", "")), + "required_fields": _strings(item.get("required_fields")), + "received": item.get("received") is True, + "accepted": item.get("accepted") is True, + "quarantined": item.get("quarantined") is True, + "next_gate": str(item.get("next_gate", "")), + } + ) + return slots + + +def _boundary_markers(summary: dict[str, int]) -> list[str]: + return [ + "wazuh_manager_registry_reviewer_validation_visible=true", + f"wazuh_manager_registry_reviewer_validation_expected_scope_alias_count={summary['expected_scope_alias_count']}", + f"wazuh_manager_registry_reviewer_validation_required_owner_field_count={summary['required_owner_field_count']}", + f"wazuh_manager_registry_reviewer_validation_per_host_required_field_count={summary['per_host_required_field_count']}", + f"wazuh_manager_registry_reviewer_validation_check_count={summary['reviewer_validation_check_count']}", + f"wazuh_manager_registry_reviewer_validation_outcome_lane_count={summary['outcome_lane_count']}", + f"wazuh_manager_registry_reviewer_validation_evidence_slot_count={summary['evidence_slot_count']}", + f"wazuh_manager_registry_reviewer_validation_forbidden_payload_count={summary['forbidden_payload_count']}", + f"wazuh_manager_registry_reviewer_validation_owner_registry_export_received_count={summary['owner_registry_export_received_count']}", + f"wazuh_manager_registry_reviewer_validation_owner_registry_export_accepted_count={summary['owner_registry_export_accepted_count']}", + f"wazuh_manager_registry_reviewer_validation_passed_count={summary['reviewer_validation_passed_count']}", + f"wazuh_manager_registry_reviewer_validation_quarantined_count={summary['reviewer_validation_quarantined_count']}", + f"wazuh_manager_registry_reviewer_validation_manager_registry_accepted_count={summary['manager_registry_accepted_count']}", + f"wazuh_manager_registry_reviewer_validation_post_enable_readback_passed_count={summary['post_enable_readback_passed_count']}", + f"wazuh_manager_registry_reviewer_validation_runtime_gate_count={summary['runtime_gate_count']}", + "wazuh_api_live_query_authorized=false", + "wazuh_agent_reenroll_authorized=false", + "wazuh_agent_restart_authorized=false", + "wazuh_active_response_authorized=false", + "host_write_authorized=false", + "raw_wazuh_payload_storage_allowed=false", + "secret_value_collection_allowed=false", + "not_authorization=true", + ] + + +def _require_boundaries(payload: dict[str, Any]) -> None: + summary = _summary(payload) + for key in ( + "owner_registry_export_received_count", + "owner_registry_export_accepted_count", + "reviewer_validation_ready_count", + "reviewer_validation_passed_count", + "reviewer_validation_failed_count", + "reviewer_validation_quarantined_count", + "manager_registry_accepted_count", + "post_enable_readback_passed_count", + "runtime_gate_count", + "host_write_authorized_count", + "active_response_authorized_count", + "secret_value_collection_allowed_count", + ): + if _int(summary.get(key)) != 0: + raise ValueError(f"Wazuh manager registry reviewer validation summary.{key} 必須維持 0") + + boundaries = payload.get("execution_boundaries") + if not isinstance(boundaries, dict): + raise ValueError("Wazuh manager registry reviewer validation execution_boundaries 缺失") + for key in _REQUIRED_FALSE_BOUNDARIES: + if boundaries.get(key) is not False: + raise ValueError(f"Wazuh manager registry reviewer validation execution_boundaries.{key} 必須維持 false") + if boundaries.get("not_authorization") is not True: + raise ValueError("Wazuh manager registry reviewer validation not_authorization 必須維持 true") diff --git a/apps/api/tests/test_iwooos_wazuh_manager_registry_reviewer_validation.py b/apps/api/tests/test_iwooos_wazuh_manager_registry_reviewer_validation.py new file mode 100644 index 00000000..d078678c --- /dev/null +++ b/apps/api/tests/test_iwooos_wazuh_manager_registry_reviewer_validation.py @@ -0,0 +1,96 @@ +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_manager_registry_reviewer_validation import ( + load_latest_iwooos_wazuh_manager_registry_reviewer_validation, +) + + +def _client() -> TestClient: + app = FastAPI() + app.include_router(router) + return TestClient(app) + + +def test_iwooos_wazuh_manager_registry_reviewer_validation_contract_is_waiting_only() -> None: + payload = load_latest_iwooos_wazuh_manager_registry_reviewer_validation() + + assert payload["schema_version"] == "iwooos_wazuh_manager_registry_reviewer_validation_readback_v1" + assert payload["source_schema_version"] == "wazuh_manager_registry_reviewer_validation_v1" + assert payload["status"] == "waiting_owner_registry_export_for_reviewer_validation" + assert payload["mode"] == "committed_validation_contract_readback_no_runtime_no_secret_collection" + assert payload["summary"]["expected_scope_alias_count"] == 6 + assert payload["summary"]["required_owner_field_count"] == 28 + assert payload["summary"]["per_host_required_field_count"] == 9 + assert payload["summary"]["reviewer_validation_check_count"] == 10 + assert payload["summary"]["outcome_lane_count"] == 13 + assert payload["summary"]["evidence_slot_count"] == 6 + assert payload["summary"]["forbidden_payload_count"] == 27 + assert payload["summary"]["owner_registry_export_received_count"] == 0 + assert payload["summary"]["owner_registry_export_accepted_count"] == 0 + assert payload["summary"]["reviewer_validation_passed_count"] == 0 + assert payload["summary"]["reviewer_validation_quarantined_count"] == 0 + assert payload["summary"]["manager_registry_accepted_count"] == 0 + assert payload["summary"]["runtime_gate_count"] == 0 + + +def test_iwooos_wazuh_manager_registry_reviewer_validation_evidence_slots_are_closed() -> None: + payload = load_latest_iwooos_wazuh_manager_registry_reviewer_validation() + + assert [item["slot_id"] for item in payload["evidence_slots"]] == [ + "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", + ] + assert all(item["received"] is False for item in payload["evidence_slots"]) + assert all(item["accepted"] is False for item in payload["evidence_slots"]) + assert all(item["quarantined"] is False for item in payload["evidence_slots"]) + assert "managed_core_node_a" in payload["expected_scope_aliases"] + assert "manager_registry_agent_counts" in [item["slot_id"] for item in payload["evidence_slots"]] + + +def test_iwooos_wazuh_manager_registry_reviewer_validation_api_is_public_safe() -> None: + response = _client().get("/api/v1/iwooos/wazuh-manager-registry-reviewer-validation") + + assert response.status_code == 200 + data = response.json() + assert data["schema_version"] == "iwooos_wazuh_manager_registry_reviewer_validation_readback_v1" + assert data["summary"]["owner_registry_export_received_count"] == 0 + assert data["summary"]["owner_registry_export_accepted_count"] == 0 + assert data["summary"]["manager_registry_accepted_count"] == 0 + assert data["summary"]["runtime_gate_count"] == 0 + assert len(data["reviewer_validation_checks"]) == 10 + assert len(data["evidence_slots"]) == 6 + assert any( + marker == "wazuh_manager_registry_reviewer_validation_owner_registry_export_received_count=0" + for marker in data["boundary_markers"] + ) + assert any( + marker == "wazuh_manager_registry_reviewer_validation_owner_registry_export_accepted_count=0" + for marker in data["boundary_markers"] + ) + assert any( + marker == "wazuh_manager_registry_reviewer_validation_manager_registry_accepted_count=0" + for marker in data["boundary_markers"] + ) + assert any( + marker == "wazuh_manager_registry_reviewer_validation_runtime_gate_count=0" + for marker in data["boundary_markers"] + ) + assert any(rule.startswith("reviewer validation contract 可見") for rule in data["no_false_green_rules"]) + assert data["boundaries"]["runtime_execution_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 "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 0f5e479e..145f07f5 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -20788,6 +20788,52 @@ } } }, + "wazuhManagerRegistryReviewerValidation": { + "eyebrow": "Wazuh manager registry reviewer validation", + "title": "Owner export 進來後,先由 reviewer 驗收脫敏清單", + "subtitle": "這張卡固定 Wazuh manager registry owner export 的驗收規則:欄位、計數、公開別名矩陣、Dashboard API 修復讀回、唯讀 credential metadata、拒收內容與下一個 Gate 都先可視化;目前尚未收到、尚未接受,也不開 runtime。", + "loadingBoundary": "正在讀取 Wazuh manager registry reviewer validation API", + "slotReceivedLabel": "已收件", + "slotAcceptedLabel": "已接受", + "slotNextGateLabel": "下一關", + "slotsLoading": "正在讀取 evidence slots。", + "slotsFallback": "Evidence slots 尚未由正式 API 讀回,維持 fallback 停止線。", + "checksLoading": "正在讀取 reviewer checks。", + "checksFallback": "Reviewer checks 尚未由正式 API 讀回,維持 fallback 停止線。", + "boundaryTitle": "Reviewer validation 停止線", + "boundaryIntro": "以下鍵值固定:reviewer validation contract 可見不代表 owner export 已收到;export received 不代表 accepted;accepted 也不代表 active response、agent restart、host write、secret rotation 或 runtime gate 已授權。", + "status": { + "loading": "正在讀取 Wazuh manager registry reviewer validation", + "failed": "Wazuh manager registry reviewer validation API 尚未部署或讀取失敗", + "ready": "Wazuh manager registry reviewer validation 已讀回;owner export、accepted 與 runtime 仍為 0" + }, + "summary": { + "aliases": { + "label": "公開別名", + "detail": "Owner export 必須剛好覆蓋 6 個公開節點別名。" + }, + "checks": { + "label": "Reviewer checks", + "detail": "10 個檢查固定欄位、算術、矩陣、Dashboard API 與停止線。" + }, + "slots": { + "label": "Evidence slots", + "detail": "6 個 slots 對應 manager counts、逐主機矩陣、Dashboard、credential、owner 與 postcheck。" + }, + "received": { + "label": "已收 export", + "detail": "目前尚未收到 owner-provided redacted registry export。" + }, + "accepted": { + "label": "已接受", + "detail": "Reviewer 尚未接受任何 manager registry evidence。" + }, + "runtime": { + "label": "執行期", + "detail": "Runtime gate、active response、agent restart 與 host write 全部維持 0。" + } + } + }, "wazuhLiveMetadataEnvGate": { "eyebrow": "Wazuh 即時中繼資料環境閘門", "title": "Wazuh 查詢要等正式路由、負責人與機密中繼資料都過關", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 0f5e479e..145f07f5 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -20788,6 +20788,52 @@ } } }, + "wazuhManagerRegistryReviewerValidation": { + "eyebrow": "Wazuh manager registry reviewer validation", + "title": "Owner export 進來後,先由 reviewer 驗收脫敏清單", + "subtitle": "這張卡固定 Wazuh manager registry owner export 的驗收規則:欄位、計數、公開別名矩陣、Dashboard API 修復讀回、唯讀 credential metadata、拒收內容與下一個 Gate 都先可視化;目前尚未收到、尚未接受,也不開 runtime。", + "loadingBoundary": "正在讀取 Wazuh manager registry reviewer validation API", + "slotReceivedLabel": "已收件", + "slotAcceptedLabel": "已接受", + "slotNextGateLabel": "下一關", + "slotsLoading": "正在讀取 evidence slots。", + "slotsFallback": "Evidence slots 尚未由正式 API 讀回,維持 fallback 停止線。", + "checksLoading": "正在讀取 reviewer checks。", + "checksFallback": "Reviewer checks 尚未由正式 API 讀回,維持 fallback 停止線。", + "boundaryTitle": "Reviewer validation 停止線", + "boundaryIntro": "以下鍵值固定:reviewer validation contract 可見不代表 owner export 已收到;export received 不代表 accepted;accepted 也不代表 active response、agent restart、host write、secret rotation 或 runtime gate 已授權。", + "status": { + "loading": "正在讀取 Wazuh manager registry reviewer validation", + "failed": "Wazuh manager registry reviewer validation API 尚未部署或讀取失敗", + "ready": "Wazuh manager registry reviewer validation 已讀回;owner export、accepted 與 runtime 仍為 0" + }, + "summary": { + "aliases": { + "label": "公開別名", + "detail": "Owner export 必須剛好覆蓋 6 個公開節點別名。" + }, + "checks": { + "label": "Reviewer checks", + "detail": "10 個檢查固定欄位、算術、矩陣、Dashboard API 與停止線。" + }, + "slots": { + "label": "Evidence slots", + "detail": "6 個 slots 對應 manager counts、逐主機矩陣、Dashboard、credential、owner 與 postcheck。" + }, + "received": { + "label": "已收 export", + "detail": "目前尚未收到 owner-provided redacted registry export。" + }, + "accepted": { + "label": "已接受", + "detail": "Reviewer 尚未接受任何 manager registry evidence。" + }, + "runtime": { + "label": "執行期", + "detail": "Runtime gate、active response、agent restart 與 host write 全部維持 0。" + } + } + }, "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 83e3efc4..c917fd7c 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 IwoooSWazuhManagerRegistryReviewerValidationResponse, type IwoooSWazuhManagedHostCoverageResponse, type IwoooSWazuhOwnerEvidencePreflightItem, type IwoooSWazuhOwnerEvidencePreflightResponse, @@ -2471,6 +2472,32 @@ const wazuhManagedHostCoverageBoundaries = [ 'not_authorization=true', ] as const +const wazuhManagerRegistryReviewerValidationBoundaries = [ + 'wazuh_manager_registry_reviewer_validation_visible=true', + 'wazuh_manager_registry_reviewer_validation_expected_scope_alias_count=6', + 'wazuh_manager_registry_reviewer_validation_required_owner_field_count=28', + 'wazuh_manager_registry_reviewer_validation_per_host_required_field_count=9', + 'wazuh_manager_registry_reviewer_validation_check_count=10', + 'wazuh_manager_registry_reviewer_validation_outcome_lane_count=13', + 'wazuh_manager_registry_reviewer_validation_evidence_slot_count=6', + 'wazuh_manager_registry_reviewer_validation_forbidden_payload_count=27', + 'wazuh_manager_registry_reviewer_validation_owner_registry_export_received_count=0', + 'wazuh_manager_registry_reviewer_validation_owner_registry_export_accepted_count=0', + 'wazuh_manager_registry_reviewer_validation_passed_count=0', + 'wazuh_manager_registry_reviewer_validation_quarantined_count=0', + 'wazuh_manager_registry_reviewer_validation_manager_registry_accepted_count=0', + 'wazuh_manager_registry_reviewer_validation_post_enable_readback_passed_count=0', + 'wazuh_manager_registry_reviewer_validation_runtime_gate_count=0', + 'wazuh_api_live_query_authorized=false', + 'wazuh_agent_reenroll_authorized=false', + 'wazuh_agent_restart_authorized=false', + 'wazuh_active_response_authorized=false', + 'host_write_authorized=false', + 'raw_wazuh_payload_storage_allowed=false', + 'secret_value_collection_allowed=false', + 'not_authorization=true', +] as const + const securityOperatingSystemSummary = [ { key: 'frameworks', value: '20', icon: ClipboardCheck, tone: 'steady' }, { key: 'workstreams', value: '24', icon: ListChecks, tone: 'steady' }, @@ -9738,6 +9765,241 @@ function IwoooSWazuhManagedHostCoverageBoard() { ) } +function IwoooSWazuhManagerRegistryReviewerValidationBoard() { + const t = useTranslations('iwooos.wazuhManagerRegistryReviewerValidation') + 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 loadValidation() { + setLoading(true) + setFailed(false) + try { + const payload = await apiClient.getIwoooSWazuhManagerRegistryReviewerValidation() + if (mounted) { + setData(payload) + } + } catch { + if (mounted) { + setData(null) + setFailed(true) + } + } finally { + if (mounted) { + setLoading(false) + } + } + } + + loadValidation() + return () => { + mounted = false + } + }, []) + + const summary = data?.summary + const summaryItems = [ + { + key: 'aliases', + value: summary ? String(summary.expected_scope_alias_count) : loading ? '...' : '6', + icon: Server, + tone: 'warn', + }, + { + key: 'checks', + value: summary ? String(summary.reviewer_validation_check_count) : loading ? '...' : '10', + icon: ListChecks, + tone: 'steady', + }, + { + key: 'slots', + value: summary ? String(summary.evidence_slot_count) : loading ? '...' : '6', + icon: ClipboardCheck, + tone: 'warn', + }, + { + key: 'received', + value: summary ? String(summary.owner_registry_export_received_count) : loading ? '...' : '0', + icon: FileWarning, + tone: 'locked', + }, + { + key: 'accepted', + value: summary ? String(summary.owner_registry_export_accepted_count) : loading ? '...' : '0', + icon: Lock, + tone: 'locked', + }, + { + key: 'runtime', + value: summary ? String(summary.runtime_gate_count) : loading ? '...' : '0', + icon: ShieldCheck, + tone: 'locked', + }, + ] as const + const boundaryMarkers = data?.boundary_markers?.length + ? data.boundary_markers + : loading + ? [t('loadingBoundary')] + : wazuhManagerRegistryReviewerValidationBoundaries + const evidenceSlots = data?.evidence_slots ?? [] + const visibleChecks = data?.reviewer_validation_checks?.slice(0, 4) ?? [] + const statusText = loading ? t('status.loading') : failed ? t('status.failed') : t('status.ready') + const statusTone: 'steady' | 'warn' | 'locked' = loading || failed ? 'warn' : 'locked' + + return ( +
+
+
+
+
+ + {t('eyebrow')} +
+

{t('title')}

+

+ {t('subtitle')} +

+
+ + {statusText} +
+
+ +
+ {summaryItems.map(item => { + const Icon = item.icon + return ( +
+
+ {t(`summary.${item.key}.label` as never)} + +
+
+ {item.value} +
+

+ {t(`summary.${item.key}.detail` as never)} +

+
+ ) + })} +
+
+ +
+ {evidenceSlots.length ? evidenceSlots.map(slot => ( +
+ {slot.slot_id} +
{slot.title}
+
+ {t('slotReceivedLabel')}:0 + {t('slotAcceptedLabel')}:0 + {t('slotNextGateLabel')}:{slot.next_gate} +
+
+ )) : ( +
+ {loading ? t('slotsLoading') : t('slotsFallback')} +
+ )} +
+ +
+ {visibleChecks.length ? visibleChecks.map(check => ( +
+
+ {check.check_id} + +
+
{check.title}
+

+ {check.required_evidence} +

+ + {check.failure_lane} + +
+ )) : ( +
+ {loading ? t('checksLoading') : t('checksFallback')} +
+ )} +
+ +
+ + {t('boundaryTitle')} + +

+ {t('boundaryIntro')} +

+
+ {boundaryMarkers.map(item => ( + + {item} + + ))} +
+
+
+
+ ) +} + function IwoooSSecurityOperatingSystemBoard() { const t = useTranslations('iwooos.securityOperatingSystem') const textWrap = { overflowWrap: 'anywhere' as const, wordBreak: 'break-word' as const } @@ -23219,6 +23481,7 @@ export default function IwoooSPage({ params }: { params: { locale: string } }) { + diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index a18bc9c4..dcbe4974 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -318,6 +318,62 @@ export interface IwoooSWazuhManagedHostCoverageResponse { no_false_green_rules: string[] } +export interface IwoooSWazuhManagerRegistryReviewerValidationCheck { + check_id: string + title: string + required_evidence: string + failure_lane: string +} + +export interface IwoooSWazuhManagerRegistryReviewerValidationSlot { + slot_id: string + title: string + required_fields: string[] + received: boolean + accepted: boolean + quarantined: boolean + next_gate: string +} + +export interface IwoooSWazuhManagerRegistryReviewerValidationResponse { + schema_version: 'iwooos_wazuh_manager_registry_reviewer_validation_readback_v1' + source_schema_version: 'wazuh_manager_registry_reviewer_validation_v1' + status: string + mode: string + source_refs: string[] + summary: { + expected_scope_alias_count: number + required_owner_field_count: number + per_host_required_field_count: number + reviewer_validation_check_count: number + outcome_lane_count: number + evidence_slot_count: number + forbidden_payload_count: number + forbidden_action_count: number + owner_registry_export_received_count: number + owner_registry_export_accepted_count: number + reviewer_validation_ready_count: number + reviewer_validation_passed_count: number + reviewer_validation_failed_count: number + reviewer_validation_quarantined_count: number + manager_registry_accepted_count: number + post_enable_readback_passed_count: number + runtime_gate_count: number + host_write_authorized_count: number + active_response_authorized_count: number + secret_value_collection_allowed_count: number + } + expected_scope_aliases: string[] + reviewer_validation_checks: IwoooSWazuhManagerRegistryReviewerValidationCheck[] + outcome_lanes: string[] + evidence_slots: IwoooSWazuhManagerRegistryReviewerValidationSlot[] + forbidden_payloads: string[] + forbidden_actions: string[] + boundary_markers: string[] + boundaries: Record + no_false_green_rules: string[] +} + export interface IwoooSSecurityControlCoverageDomain { domain_id: | 'high_value_asset_control' @@ -563,6 +619,11 @@ export const apiClient = { return handleResponse(res) }, + async getIwoooSWazuhManagerRegistryReviewerValidation() { + const res = await fetch(`${API_BASE_URL}/iwooos/wazuh-manager-registry-reviewer-validation`, { 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 054531d4..bcb6e306 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -1,3 +1,43 @@ +## 2026-06-27|IwoooS Wazuh manager registry reviewer validation 本地完成 + +**背景**:Wazuh 受管主機覆蓋已能由正式 API / 前台讀回,但 `manager_registry_accepted_count` 仍為 `0`。本段補上 owner-provided redacted manager registry export 進來後的 reviewer validation contract,避免把 Dashboard 可開、index pattern 正常、HTTP 200、agent transport observed 或前台卡片可見誤判為全主機已納管。 + +**完成內容**: +- 新增 committed snapshot:`docs/security/wazuh-manager-registry-reviewer-validation.snapshot.json`。 +- 新增只讀 guard:`scripts/security/wazuh-manager-registry-reviewer-validation.py`,驗證 6 個公開別名、28 個 owner 欄位、9 個逐主機欄位、10 個 reviewer checks、13 條 outcome lanes、6 個 evidence slots 與 27 類 forbidden payload。 +- 新增 API:`GET /api/v1/iwooos/wazuh-manager-registry-reviewer-validation`,schema 為 `iwooos_wazuh_manager_registry_reviewer_validation_readback_v1`。 +- `/zh-TW/iwooos` 新增 reviewer validation 卡,讀取 API 後顯示 evidence slots、reviewer checks 與 no-false-green boundary markers;API 未回時只顯示保守 fallback。 +- `security-mirror-progress-guard.py` 已納入 API route、service schema、client method、前台測試 id、i18n 文案與 `owner_registry_export_received_count=0`、`owner_registry_export_accepted_count=0`、`manager_registry_accepted_count=0`、`runtime_gate_count=0` source markers。 +- `zh-TW` 與目前鏡像訊息皆為繁體中文;未放入工作視窗對話、個人 namespace、內網位址、agent 原名或 secret。 + +**本地驗證結果**: +- `python3 -m py_compile scripts/security/wazuh-manager-registry-reviewer-validation.py apps/api/src/services/iwooos_wazuh_manager_registry_reviewer_validation.py apps/api/src/api/v1/iwooos.py scripts/security/security-mirror-progress-guard.py`:通過。 +- `DATABASE_URL=sqlite:///test.db python3.11 -m pytest apps/api/tests/test_iwooos_wazuh_manager_registry_reviewer_validation.py apps/api/tests/test_iwooos_wazuh_managed_host_coverage.py apps/api/tests/test_iwooos_runtime_security_readback.py apps/api/tests/test_iwooos_wazuh_api.py -q`:`18 passed`。 +- `node -e "JSON.parse(...zh-TW.json); JSON.parse(...en.json)"`:通過。 +- `python3 scripts/security/wazuh-manager-registry-reviewer-validation.py --root .`:`WAZUH_MANAGER_REGISTRY_REVIEWER_VALIDATION_OK aliases=6 checks=10 slots=6 received=0 accepted=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`:通過。 +- `git diff --check`:通過。 + +**完成度 / 邊界**: +- 本段「Wazuh manager registry reviewer validation contract」本地:`0% -> 85%`。尚待 rebase、commit、push、CD、production API readback 與 desktop / mobile browser smoke。 +- IwoooS 整體暫保守維持 `68%`,等正式讀回完成後再上修。 +- Wazuh manager registry accepted:`35% -> 55%`,目前只是 reviewer validation contract 與前台讀回準備完成,不代表 owner export received / accepted 或所有主機已納管。 + +**仍維持 0 / false**: +- `owner_registry_export_received_count=0`、`owner_registry_export_accepted_count=0`、`reviewer_validation_passed_count=0`、`reviewer_validation_quarantined_count=0`、`manager_registry_accepted_count=0`、`post_enable_readback_passed_count=0`、`runtime_gate_count=0`。 +- `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`。 + +**做過的命令類型**: +- 寫入:repo API / test / frontend / i18n / guard / snapshot / 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。 + +**下一步**: +- rebase 到最新 `gitea/main` 後 commit / push,等待 Gitea CD。 +- production 驗證 `GET /api/v1/iwooos/wazuh-manager-registry-reviewer-validation`、`/zh-TW/iwooos` desktop / mobile、forbidden hits、console errors 與水平溢出。 + ## 2026-06-27|D1M 正式站驗證:修復候選已寫入 check-mode worker 可 claim receipt **正式部署基準 / Runs**: diff --git a/docs/security/wazuh-manager-registry-reviewer-validation.snapshot.json b/docs/security/wazuh-manager-registry-reviewer-validation.snapshot.json new file mode 100644 index 00000000..fd6df4e5 --- /dev/null +++ b/docs/security/wazuh-manager-registry-reviewer-validation.snapshot.json @@ -0,0 +1,308 @@ +{ + "evidence_slots": [ + { + "accepted": false, + "next_gate": "owner_provided_redacted_export", + "quarantined": false, + "received": false, + "required_fields": [ + "agent_total", + "agent_active", + "agent_disconnected", + "agent_never_connected", + "registry_export_summary_ref" + ], + "slot_id": "manager_registry_agent_counts", + "title": "Manager registry agent counts" + }, + { + "accepted": false, + "next_gate": "owner_provided_redacted_export", + "quarantined": false, + "received": false, + "required_fields": [ + "registry_export_scope_aliases", + "per_host_registry_matrix", + "registry_gap_reason_by_alias" + ], + "slot_id": "per_host_agent_scope_matrix", + "title": "逐主機 agent scope matrix" + }, + { + "accepted": false, + "next_gate": "owner_provided_redacted_export", + "quarantined": false, + "received": false, + "required_fields": [ + "dashboard_api_connection_check_status", + "dashboard_api_version_check_status", + "dashboard_index_pattern_statuses", + "dashboard_api_degradation_root_cause", + "dashboard_api_repair_postcheck_ref" + ], + "slot_id": "dashboard_api_rbac_tls_repair_readback", + "title": "Dashboard API / RBAC / TLS 修復讀回" + }, + { + "accepted": false, + "next_gate": "owner_provided_redacted_export", + "quarantined": false, + "received": false, + "required_fields": [ + "collection_method", + "manager_health_ref", + "redacted_evidence_refs" + ], + "slot_id": "readonly_credential_metadata_without_secret", + "title": "唯讀 credential metadata,不含 secret" + }, + { + "accepted": false, + "next_gate": "owner_provided_redacted_export", + "quarantined": false, + "received": false, + "required_fields": [ + "owner_role", + "team", + "decision", + "decision_reason", + "followup_owner", + "rollback_owner" + ], + "slot_id": "owner_response_and_rollback_owner", + "title": "Owner response / rollback owner" + }, + { + "accepted": false, + "next_gate": "owner_provided_redacted_export", + "quarantined": false, + "received": false, + "required_fields": [ + "postcheck_plan", + "redacted_evidence_refs" + ], + "slot_id": "post_enable_iwooos_readback", + "title": "Post-enable IwoooS readback" + } + ], + "execution_boundaries": { + "agent_identity_public_display_allowed": false, + "host_write_authorized": false, + "internal_ip_public_display_allowed": 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 + }, + "expected_scope_aliases": [ + "managed_core_node_a", + "managed_core_node_b", + "managed_dev_node_a", + "managed_dev_node_b", + "managed_control_node_a", + "managed_control_node_b" + ], + "forbidden_actions": [ + "wazuh_api_live_query", + "wazuh_agent_reenroll", + "wazuh_agent_restart", + "wazuh_manager_restart", + "wazuh_active_response", + "wazuh_dashboard_secret_patch", + "host_write", + "firewall_change", + "nginx_reload", + "k8s_or_argocd_change", + "kali_active_scan" + ], + "forbidden_payloads": [ + "raw_wazuh_payload", + "raw_log", + "full_journal", + "full_cli_output", + "unredacted_screenshot", + "agent_name", + "agent_id_plaintext", + "internal_ip", + "hostname", + "authorization_header", + "bearer_token", + "basic_auth", + "password", + "token", + "cookie", + "private_key", + "client_keys", + "raw_dashboard_request", + "dashboard_api_secret", + "stored_api_password", + "api_token", + "active_response_enable", + "agent_reenroll", + "agent_restart", + "host_write", + "firewall_change", + "nginx_reload" + ], + "generated_at": "2026-06-27T15:24:00+08:00", + "mode": "committed_validation_contract_no_runtime_no_secret_collection", + "no_false_green_rules": [ + "reviewer validation contract 可見不代表 owner registry export 已收到。", + "owner registry export received 不代表 manager_registry_accepted_count 可增加。", + "Dashboard 可見、index pattern 三綠勾、HTTP 200 或 transport observed 不可替代 manager registry counts。", + "reviewer accepted 只可更新只讀 posture;active response、agent restart、reenroll、host write、secret rotation 或掃描仍需獨立 runtime gate。" + ], + "outcome_lanes": [ + "waiting_owner_registry_export", + "request_missing_fields", + "request_counts_arithmetic_fix", + "request_alias_scope_parity_fix", + "request_per_host_matrix_supplement", + "request_dashboard_api_repair_postcheck", + "request_readonly_credential_metadata", + "request_owner_accountability_supplement", + "quarantine_sensitive_payload", + "reject_runtime_action_request", + "ready_for_reviewer_validation", + "accepted_for_readonly_posture_only", + "waiting_post_enable_iwooos_readback" + ], + "per_host_required_fields": [ + "node_alias", + "scope_role", + "registry_presence", + "agent_status_bucket", + "last_seen_state", + "manager_group_ref", + "agent_id_redacted_ref", + "gap_reason", + "redacted_evidence_ref" + ], + "required_owner_fields": [ + "owner_role", + "team", + "decision", + "decision_reason", + "affected_scope", + "collection_method", + "agent_total", + "agent_active", + "agent_disconnected", + "agent_never_connected", + "last_seen_window_start", + "last_seen_window_end", + "registry_collected_at", + "registry_export_scope_aliases", + "per_host_registry_matrix", + "registry_gap_reason_by_alias", + "registry_export_summary_ref", + "manager_health_ref", + "dashboard_api_status_ref", + "dashboard_api_connection_check_status", + "dashboard_api_version_check_status", + "dashboard_index_pattern_statuses", + "dashboard_api_degradation_root_cause", + "dashboard_api_repair_postcheck_ref", + "redacted_evidence_refs", + "followup_owner", + "rollback_owner", + "postcheck_plan" + ], + "reviewer_validation_checks": [ + { + "check_id": "RV-01", + "failure_lane": "request_missing_fields", + "required_evidence": "owner_role、team、decision、decision_reason、affected_scope、collection_method 與 redacted evidence refs 必須存在。", + "title": "Export envelope 欄位齊全" + }, + { + "check_id": "RV-02", + "failure_lane": "request_counts_arithmetic_fix", + "required_evidence": "agent_total 不得小於 active + disconnected + never_connected,且需有 registry_export_summary_ref。", + "title": "Registry counts 算術一致" + }, + { + "check_id": "RV-03", + "failure_lane": "request_alias_scope_parity_fix", + "required_evidence": "registry_export_scope_aliases 必須剛好覆蓋 6 個公開別名,不得加入真實主機名或內網識別。", + "title": "Alias scope 與 IwoooS 覆蓋矩陣一致" + }, + { + "check_id": "RV-04", + "failure_lane": "request_per_host_matrix_supplement", + "required_evidence": "每個公開節點別名都要有 9 個 per-host 欄位與 gap reason。", + "title": "逐主機矩陣欄位完整" + }, + { + "check_id": "RV-05", + "failure_lane": "request_dashboard_api_repair_postcheck", + "required_evidence": "API connection、API version、index pattern、degradation root cause 與 repair postcheck 需分欄。", + "title": "Dashboard API 狀態不可用 index pattern 代替" + }, + { + "check_id": "RV-06", + "failure_lane": "request_readonly_credential_metadata", + "required_evidence": "manager_health_ref 與 readonly credential metadata 只能是脫敏來源資訊,不得含 secret value。", + "title": "Manager health 與 readonly credential metadata 可追溯" + }, + { + "check_id": "RV-07", + "failure_lane": "quarantine_sensitive_payload", + "required_evidence": "不得含 raw Wazuh payload、完整 CLI output、未脫敏截圖、agent 原名、內網位址、token、密碼或 client key。", + "title": "Forbidden payload 一律隔離" + }, + { + "check_id": "RV-08", + "failure_lane": "request_owner_accountability_supplement", + "required_evidence": "followup_owner、rollback_owner、維護窗口與 postcheck plan 必須能被 reviewer 追蹤。", + "title": "Owner / followup / rollback 責任可讀" + }, + { + "check_id": "RV-09", + "failure_lane": "reject_runtime_action_request", + "required_evidence": "owner decision 不可夾帶 active response、agent restart、reenroll、host write、Nginx、firewall、K8s 或 secret rotation。", + "title": "收件不等於 runtime 授權" + }, + { + "check_id": "RV-10", + "failure_lane": "waiting_post_enable_iwooos_readback", + "required_evidence": "即使 reviewer 未來接受 evidence,也只能進 read-only posture;必須另有 post-enable readback 才能更新 runtime truth。", + "title": "Post-enable IwoooS readback 仍是下一關" + } + ], + "schema_version": "wazuh_manager_registry_reviewer_validation_v1", + "scope": "wazuh_manager_registry_owner_export_reviewer_validation", + "source_refs": [ + "docs/security/wazuh-agent-visibility-owner-evidence-preflight.snapshot.json", + "docs/security/wazuh-managed-host-coverage-gate.snapshot.json" + ], + "status": "waiting_owner_registry_export_for_reviewer_validation", + "summary": { + "active_response_authorized_count": 0, + "evidence_slot_count": 6, + "expected_scope_alias_count": 6, + "forbidden_action_count": 11, + "forbidden_payload_count": 27, + "host_write_authorized_count": 0, + "manager_registry_accepted_count": 0, + "outcome_lane_count": 13, + "owner_registry_export_accepted_count": 0, + "owner_registry_export_received_count": 0, + "per_host_required_field_count": 9, + "post_enable_readback_passed_count": 0, + "required_owner_field_count": 28, + "reviewer_validation_check_count": 10, + "reviewer_validation_failed_count": 0, + "reviewer_validation_passed_count": 0, + "reviewer_validation_quarantined_count": 0, + "reviewer_validation_ready_count": 0, + "runtime_gate_count": 0, + "secret_value_collection_allowed_count": 0 + } +} diff --git a/scripts/security/security-mirror-progress-guard.py b/scripts/security/security-mirror-progress-guard.py index 8ebf073f..2802634c 100755 --- a/scripts/security/security-mirror-progress-guard.py +++ b/scripts/security/security-mirror-progress-guard.py @@ -127,6 +127,10 @@ def validate(root: Path) -> None: str(root / "scripts" / "security" / "wazuh-managed-host-coverage-gate.py") ) wazuh_managed_host_coverage_gate["validate"](root) + wazuh_manager_registry_reviewer_validation = runpy.run_path( + str(root / "scripts" / "security" / "wazuh-manager-registry-reviewer-validation.py") + ) + wazuh_manager_registry_reviewer_validation["validate"](root) telegram_alert_readability_guard = runpy.run_path( str(root / "scripts" / "security" / "telegram-alert-readability-guard.py") ) @@ -345,6 +349,15 @@ def validate(root: Path) -> None: iwooos_wazuh_managed_host_coverage_test = ( root / "apps" / "api" / "tests" / "test_iwooos_wazuh_managed_host_coverage.py" ).read_text(encoding="utf-8") + iwooos_wazuh_manager_registry_reviewer_validation_service = ( + root / "apps" / "api" / "src" / "services" / "iwooos_wazuh_manager_registry_reviewer_validation.py" + ).read_text(encoding="utf-8") + iwooos_wazuh_manager_registry_reviewer_validation_test = ( + root / "apps" / "api" / "tests" / "test_iwooos_wazuh_manager_registry_reviewer_validation.py" + ).read_text(encoding="utf-8") + wazuh_manager_registry_reviewer_validation_script = ( + root / "scripts" / "security" / "wazuh-manager-registry-reviewer-validation.py" + ).read_text(encoding="utf-8") tenants_api_contract = ( root / "apps" / "api" / "src" / "api" / "v1" / "platform" / "tenants.py" ).read_text(encoding="utf-8") @@ -29529,6 +29542,9 @@ def validate(root: Path) -> None: iwooos_api_client, iwooos_wazuh_managed_host_coverage_service, iwooos_wazuh_managed_host_coverage_test, + iwooos_wazuh_manager_registry_reviewer_validation_service, + iwooos_wazuh_manager_registry_reviewer_validation_test, + wazuh_manager_registry_reviewer_validation_script, ] ) for expected in [ @@ -29551,6 +29567,16 @@ def validate(root: Path) -> None: "Wazuh 主機覆蓋只讀 API 已接上", "wazuh_managed_host_coverage_manager_registry_accepted_count=0", "wazuh_managed_host_coverage_runtime_gate_count=0", + "iwooos-wazuh-manager-registry-reviewer-validation-board", + "iwooos-wazuh-manager-registry-reviewer-validation-slots", + "wazuhManagerRegistryReviewerValidation", + "getIwoooSWazuhManagerRegistryReviewerValidation", + "apiClient.getIwoooSWazuhManagerRegistryReviewerValidation", + "Wazuh manager registry reviewer validation 已讀回", + "wazuh_manager_registry_reviewer_validation_owner_registry_export_received_count=0", + "wazuh_manager_registry_reviewer_validation_owner_registry_export_accepted_count=0", + "wazuh_manager_registry_reviewer_validation_manager_registry_accepted_count=0", + "wazuh_manager_registry_reviewer_validation_runtime_gate_count=0", ]: assert_text_contains("iwooos_frontend_product_text.wazuh_managed_host_coverage", frontend_product_text, expected) for expected in [ @@ -29565,6 +29591,14 @@ def validate(root: Path) -> None: "wazuh_managed_host_coverage_required_evidence_accepted_count=0", "wazuh_agent_reenroll_authorized=false", "wazuh_agent_restart_authorized=false", + "/api/v1/iwooos/wazuh-manager-registry-reviewer-validation", + "wazuh_manager_registry_reviewer_validation_v1", + "iwooos_wazuh_manager_registry_reviewer_validation_readback_v1", + "test_iwooos_wazuh_manager_registry_reviewer_validation_api_is_public_safe", + "wazuh_manager_registry_reviewer_validation_owner_registry_export_received_count=0", + "wazuh_manager_registry_reviewer_validation_owner_registry_export_accepted_count=0", + "wazuh_manager_registry_reviewer_validation_manager_registry_accepted_count=0", + "wazuh_manager_registry_reviewer_validation_runtime_gate_count=0", ]: assert_text_contains( "iwooos_wazuh_managed_host_coverage_source", diff --git a/scripts/security/wazuh-manager-registry-reviewer-validation.py b/scripts/security/wazuh-manager-registry-reviewer-validation.py new file mode 100644 index 00000000..87fd137d --- /dev/null +++ b/scripts/security/wazuh-manager-registry-reviewer-validation.py @@ -0,0 +1,458 @@ +#!/usr/bin/env python3 +""" +Wazuh manager registry reviewer validation gate. + +本工具只驗證 repo 內 committed snapshot;不查 Wazuh、不讀 host、不收 +secret、不保存 raw payload、不重新註冊 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-manager-registry-reviewer-validation.snapshot.json") +SCHEMA_VERSION = "wazuh_manager_registry_reviewer_validation_v1" + +EXPECTED_SCOPE_ALIASES = [ + "managed_core_node_a", + "managed_core_node_b", + "managed_dev_node_a", + "managed_dev_node_b", + "managed_control_node_a", + "managed_control_node_b", +] + +REQUIRED_OWNER_FIELDS = [ + "owner_role", + "team", + "decision", + "decision_reason", + "affected_scope", + "collection_method", + "agent_total", + "agent_active", + "agent_disconnected", + "agent_never_connected", + "last_seen_window_start", + "last_seen_window_end", + "registry_collected_at", + "registry_export_scope_aliases", + "per_host_registry_matrix", + "registry_gap_reason_by_alias", + "registry_export_summary_ref", + "manager_health_ref", + "dashboard_api_status_ref", + "dashboard_api_connection_check_status", + "dashboard_api_version_check_status", + "dashboard_index_pattern_statuses", + "dashboard_api_degradation_root_cause", + "dashboard_api_repair_postcheck_ref", + "redacted_evidence_refs", + "followup_owner", + "rollback_owner", + "postcheck_plan", +] + +PER_HOST_REQUIRED_FIELDS = [ + "node_alias", + "scope_role", + "registry_presence", + "agent_status_bucket", + "last_seen_state", + "manager_group_ref", + "agent_id_redacted_ref", + "gap_reason", + "redacted_evidence_ref", +] + +REVIEWER_VALIDATION_CHECKS = [ + { + "check_id": "RV-01", + "title": "Export envelope 欄位齊全", + "required_evidence": "owner_role、team、decision、decision_reason、affected_scope、collection_method 與 redacted evidence refs 必須存在。", + "failure_lane": "request_missing_fields", + }, + { + "check_id": "RV-02", + "title": "Registry counts 算術一致", + "required_evidence": "agent_total 不得小於 active + disconnected + never_connected,且需有 registry_export_summary_ref。", + "failure_lane": "request_counts_arithmetic_fix", + }, + { + "check_id": "RV-03", + "title": "Alias scope 與 IwoooS 覆蓋矩陣一致", + "required_evidence": "registry_export_scope_aliases 必須剛好覆蓋 6 個公開別名,不得加入真實主機名或內網識別。", + "failure_lane": "request_alias_scope_parity_fix", + }, + { + "check_id": "RV-04", + "title": "逐主機矩陣欄位完整", + "required_evidence": "每個公開節點別名都要有 9 個 per-host 欄位與 gap reason。", + "failure_lane": "request_per_host_matrix_supplement", + }, + { + "check_id": "RV-05", + "title": "Dashboard API 狀態不可用 index pattern 代替", + "required_evidence": "API connection、API version、index pattern、degradation root cause 與 repair postcheck 需分欄。", + "failure_lane": "request_dashboard_api_repair_postcheck", + }, + { + "check_id": "RV-06", + "title": "Manager health 與 readonly credential metadata 可追溯", + "required_evidence": "manager_health_ref 與 readonly credential metadata 只能是脫敏來源資訊,不得含 secret value。", + "failure_lane": "request_readonly_credential_metadata", + }, + { + "check_id": "RV-07", + "title": "Forbidden payload 一律隔離", + "required_evidence": "不得含 raw Wazuh payload、完整 CLI output、未脫敏截圖、agent 原名、內網位址、token、密碼或 client key。", + "failure_lane": "quarantine_sensitive_payload", + }, + { + "check_id": "RV-08", + "title": "Owner / followup / rollback 責任可讀", + "required_evidence": "followup_owner、rollback_owner、維護窗口與 postcheck plan 必須能被 reviewer 追蹤。", + "failure_lane": "request_owner_accountability_supplement", + }, + { + "check_id": "RV-09", + "title": "收件不等於 runtime 授權", + "required_evidence": "owner decision 不可夾帶 active response、agent restart、reenroll、host write、Nginx、firewall、K8s 或 secret rotation。", + "failure_lane": "reject_runtime_action_request", + }, + { + "check_id": "RV-10", + "title": "Post-enable IwoooS readback 仍是下一關", + "required_evidence": "即使 reviewer 未來接受 evidence,也只能進 read-only posture;必須另有 post-enable readback 才能更新 runtime truth。", + "failure_lane": "waiting_post_enable_iwooos_readback", + }, +] + +OUTCOME_LANES = [ + "waiting_owner_registry_export", + "request_missing_fields", + "request_counts_arithmetic_fix", + "request_alias_scope_parity_fix", + "request_per_host_matrix_supplement", + "request_dashboard_api_repair_postcheck", + "request_readonly_credential_metadata", + "request_owner_accountability_supplement", + "quarantine_sensitive_payload", + "reject_runtime_action_request", + "ready_for_reviewer_validation", + "accepted_for_readonly_posture_only", + "waiting_post_enable_iwooos_readback", +] + +EVIDENCE_SLOTS = [ + { + "slot_id": "manager_registry_agent_counts", + "title": "Manager registry agent counts", + "required_fields": ["agent_total", "agent_active", "agent_disconnected", "agent_never_connected", "registry_export_summary_ref"], + }, + { + "slot_id": "per_host_agent_scope_matrix", + "title": "逐主機 agent scope matrix", + "required_fields": ["registry_export_scope_aliases", "per_host_registry_matrix", "registry_gap_reason_by_alias"], + }, + { + "slot_id": "dashboard_api_rbac_tls_repair_readback", + "title": "Dashboard API / RBAC / TLS 修復讀回", + "required_fields": [ + "dashboard_api_connection_check_status", + "dashboard_api_version_check_status", + "dashboard_index_pattern_statuses", + "dashboard_api_degradation_root_cause", + "dashboard_api_repair_postcheck_ref", + ], + }, + { + "slot_id": "readonly_credential_metadata_without_secret", + "title": "唯讀 credential metadata,不含 secret", + "required_fields": ["collection_method", "manager_health_ref", "redacted_evidence_refs"], + }, + { + "slot_id": "owner_response_and_rollback_owner", + "title": "Owner response / rollback owner", + "required_fields": ["owner_role", "team", "decision", "decision_reason", "followup_owner", "rollback_owner"], + }, + { + "slot_id": "post_enable_iwooos_readback", + "title": "Post-enable IwoooS readback", + "required_fields": ["postcheck_plan", "redacted_evidence_refs"], + }, +] + +FORBIDDEN_PAYLOADS = [ + "raw_wazuh_payload", + "raw_log", + "full_journal", + "full_cli_output", + "unredacted_screenshot", + "agent_name", + "agent_id_plaintext", + "internal_ip", + "hostname", + "authorization_header", + "bearer_token", + "basic_auth", + "password", + "token", + "cookie", + "private_key", + "client_keys", + "raw_dashboard_request", + "dashboard_api_secret", + "stored_api_password", + "api_token", + "active_response_enable", + "agent_reenroll", + "agent_restart", + "host_write", + "firewall_change", + "nginx_reload", +] + +FORBIDDEN_ACTIONS = [ + "wazuh_api_live_query", + "wazuh_agent_reenroll", + "wazuh_agent_restart", + "wazuh_manager_restart", + "wazuh_active_response", + "wazuh_dashboard_secret_patch", + "host_write", + "firewall_change", + "nginx_reload", + "k8s_or_argocd_change", + "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_manager_registry_reviewer_validation: snapshot contains forbidden sensitive text" + ) + + +def build_snapshot(generated_at: str) -> dict[str, Any]: + return { + "schema_version": SCHEMA_VERSION, + "generated_at": generated_at, + "status": "waiting_owner_registry_export_for_reviewer_validation", + "mode": "committed_validation_contract_no_runtime_no_secret_collection", + "scope": "wazuh_manager_registry_owner_export_reviewer_validation", + "source_refs": [ + "docs/security/wazuh-agent-visibility-owner-evidence-preflight.snapshot.json", + "docs/security/wazuh-managed-host-coverage-gate.snapshot.json", + ], + "summary": { + "expected_scope_alias_count": len(EXPECTED_SCOPE_ALIASES), + "required_owner_field_count": len(REQUIRED_OWNER_FIELDS), + "per_host_required_field_count": len(PER_HOST_REQUIRED_FIELDS), + "reviewer_validation_check_count": len(REVIEWER_VALIDATION_CHECKS), + "outcome_lane_count": len(OUTCOME_LANES), + "evidence_slot_count": len(EVIDENCE_SLOTS), + "forbidden_payload_count": len(FORBIDDEN_PAYLOADS), + "forbidden_action_count": len(FORBIDDEN_ACTIONS), + "owner_registry_export_received_count": 0, + "owner_registry_export_accepted_count": 0, + "reviewer_validation_ready_count": 0, + "reviewer_validation_passed_count": 0, + "reviewer_validation_failed_count": 0, + "reviewer_validation_quarantined_count": 0, + "manager_registry_accepted_count": 0, + "post_enable_readback_passed_count": 0, + "runtime_gate_count": 0, + "host_write_authorized_count": 0, + "active_response_authorized_count": 0, + "secret_value_collection_allowed_count": 0, + }, + "expected_scope_aliases": EXPECTED_SCOPE_ALIASES, + "required_owner_fields": REQUIRED_OWNER_FIELDS, + "per_host_required_fields": PER_HOST_REQUIRED_FIELDS, + "reviewer_validation_checks": REVIEWER_VALIDATION_CHECKS, + "outcome_lanes": OUTCOME_LANES, + "evidence_slots": [ + { + **slot, + "received": False, + "accepted": False, + "quarantined": False, + "next_gate": "owner_provided_redacted_export", + } + for slot in EVIDENCE_SLOTS + ], + "forbidden_payloads": FORBIDDEN_PAYLOADS, + "forbidden_actions": FORBIDDEN_ACTIONS, + "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, + "agent_identity_public_display_allowed": False, + "internal_ip_public_display_allowed": False, + "kali_active_scan_authorized": False, + "runtime_execution_authorized": False, + "not_authorization": True, + }, + "no_false_green_rules": [ + "reviewer validation contract 可見不代表 owner registry export 已收到。", + "owner registry export received 不代表 manager_registry_accepted_count 可增加。", + "Dashboard 可見、index pattern 三綠勾、HTTP 200 或 transport observed 不可替代 manager registry counts。", + "reviewer accepted 只可更新只讀 posture;active response、agent restart、reenroll、host write、secret rotation 或掃描仍需獨立 runtime gate。", + ], + } + + +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"), "waiting_owner_registry_export_for_reviewer_validation") + assert_equal("mode", snapshot.get("mode"), "committed_validation_contract_no_runtime_no_secret_collection") + assert_equal("scope", snapshot.get("scope"), "wazuh_manager_registry_owner_export_reviewer_validation") + assert_equal("expected_scope_aliases", snapshot.get("expected_scope_aliases"), EXPECTED_SCOPE_ALIASES) + assert_equal("required_owner_fields", snapshot.get("required_owner_fields"), REQUIRED_OWNER_FIELDS) + assert_equal("per_host_required_fields", snapshot.get("per_host_required_fields"), PER_HOST_REQUIRED_FIELDS) + assert_equal("reviewer_validation_checks", snapshot.get("reviewer_validation_checks"), REVIEWER_VALIDATION_CHECKS) + assert_equal("outcome_lanes", snapshot.get("outcome_lanes"), OUTCOME_LANES) + assert_equal("forbidden_payloads", snapshot.get("forbidden_payloads"), FORBIDDEN_PAYLOADS) + assert_equal("forbidden_actions", snapshot.get("forbidden_actions"), FORBIDDEN_ACTIONS) + + summary = snapshot.get("summary", {}) + assert_equal("summary.expected_scope_alias_count", summary.get("expected_scope_alias_count"), len(EXPECTED_SCOPE_ALIASES)) + assert_equal("summary.required_owner_field_count", summary.get("required_owner_field_count"), len(REQUIRED_OWNER_FIELDS)) + assert_equal("summary.per_host_required_field_count", summary.get("per_host_required_field_count"), len(PER_HOST_REQUIRED_FIELDS)) + assert_equal( + "summary.reviewer_validation_check_count", + summary.get("reviewer_validation_check_count"), + len(REVIEWER_VALIDATION_CHECKS), + ) + assert_equal("summary.outcome_lane_count", summary.get("outcome_lane_count"), len(OUTCOME_LANES)) + assert_equal("summary.evidence_slot_count", summary.get("evidence_slot_count"), len(EVIDENCE_SLOTS)) + assert_equal("summary.forbidden_payload_count", summary.get("forbidden_payload_count"), len(FORBIDDEN_PAYLOADS)) + assert_equal("summary.forbidden_action_count", summary.get("forbidden_action_count"), len(FORBIDDEN_ACTIONS)) + for key in [ + "owner_registry_export_received_count", + "owner_registry_export_accepted_count", + "reviewer_validation_ready_count", + "reviewer_validation_passed_count", + "reviewer_validation_failed_count", + "reviewer_validation_quarantined_count", + "manager_registry_accepted_count", + "post_enable_readback_passed_count", + "runtime_gate_count", + "host_write_authorized_count", + "active_response_authorized_count", + "secret_value_collection_allowed_count", + ]: + assert_zero(f"summary.{key}", summary.get(key)) + + evidence_slots = snapshot.get("evidence_slots", []) + assert_equal("evidence_slots.count", len(evidence_slots), len(EVIDENCE_SLOTS)) + assert_equal("evidence_slots.ids", [slot.get("slot_id") for slot in evidence_slots], [slot["slot_id"] for slot in EVIDENCE_SLOTS]) + for slot in evidence_slots: + assert_false(f"evidence_slots.{slot.get('slot_id')}.received", slot.get("received")) + assert_false(f"evidence_slots.{slot.get('slot_id')}.accepted", slot.get("accepted")) + assert_false(f"evidence_slots.{slot.get('slot_id')}.quarantined", slot.get("quarantined")) + + 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 manager registry reviewer validation gate") + parser.add_argument("--root", type=Path, default=Path.cwd()) + parser.add_argument("--output", type=Path) + parser.add_argument("--generated-at", default="2026-06-27T15:24:00+08:00") + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + + root = args.root.resolve() + if args.output: + payload = build_snapshot(args.generated_at) + output_path = args.output if args.output.is_absolute() else root / args.output + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(f"Wrote {output_path}") + elif args.json: + print(json.dumps(build_snapshot(args.generated_at), ensure_ascii=False, indent=2, sort_keys=True)) + else: + validate(root) + snapshot = load_json(root / SNAPSHOT_PATH) + summary = snapshot["summary"] + print( + "WAZUH_MANAGER_REGISTRY_REVIEWER_VALIDATION_OK " + f"aliases={summary['expected_scope_alias_count']} " + f"checks={summary['reviewer_validation_check_count']} " + f"slots={summary['evidence_slot_count']} " + f"received={summary['owner_registry_export_received_count']} " + f"accepted={summary['owner_registry_export_accepted_count']} " + f"runtime_gate={summary['runtime_gate_count']}" + ) + + +if __name__ == "__main__": + main()