#!/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 已讀回但不開 runtime", "required_evidence": "production API 與前台 smoke 已讀回 reviewer passed;此讀回只更新 read-only posture,不查 live Wazuh、不保存 raw payload、不開 runtime gate。", "failure_lane": "post_enable_iwooos_readback_passed_no_runtime", }, { "check_id": "RV-11", "title": "Manager registry acceptance evidence 可收件但不自動上修", "required_evidence": "新增 no-persist acceptance evidence validator;reviewer packet 與 acceptance evidence 通過後才可進 committed readback,runtime gate 仍維持 0。", "failure_lane": "waiting_manager_registry_acceptance_evidence", }, ] 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", "post_enable_iwooos_readback_passed", "manager_registry_acceptance_evidence_review", "manager_registry_acceptance_evidence_intake_ready", "accepted_for_manager_registry_acceptance_review_only", "commit_manager_registry_accepted_readback_evidence", ] 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": "manager_registry_accepted_readback_committed_no_runtime_no_secret_collection", "mode": "committed_manager_registry_accepted_readback_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": 1, "owner_registry_export_accepted_count": 1, "reviewer_validation_ready_count": 1, "reviewer_validation_passed_count": 1, "reviewer_validation_failed_count": 0, "reviewer_validation_quarantined_count": 0, "manager_registry_accepted_count": len(EXPECTED_SCOPE_ALIASES), "manager_registry_acceptance_intake_endpoint_available_count": 1, "manager_registry_acceptance_evidence_received_count": 1, "manager_registry_acceptance_evidence_review_ready_count": 1, "post_enable_readback_passed_count": 1, "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": True, "accepted": True, "quarantined": False, "next_gate": "manager_registry_acceptance_evidence_review", } 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 passed 只代表脫敏 owner export refs 通過 no-persist 驗證。", "post-enable IwoooS readback passed 只代表 production API / 前台已讀回 reviewer passed,不代表 live Wazuh 查詢或 runtime action。", "manager registry accepted readback 只代表 6 個公開別名的脫敏 evidence 已通過 committed review,不代表 runtime gate 已開。", "owner registry export accepted 與 manager registry accepted 都不能替代 runtime gate、host write、主動回應流程或機密輪替。", "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"), "manager_registry_accepted_readback_committed_no_runtime_no_secret_collection", ) assert_equal( "mode", snapshot.get("mode"), "committed_manager_registry_accepted_readback_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", "post_enable_readback_passed_count", "manager_registry_acceptance_intake_endpoint_available_count", ]: assert_equal(f"summary.{key}", summary.get(key), 1) for key in [ "reviewer_validation_failed_count", "reviewer_validation_quarantined_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)) assert_equal( "summary.manager_registry_accepted_count", summary.get("manager_registry_accepted_count"), len(EXPECTED_SCOPE_ALIASES), ) for key in [ "manager_registry_acceptance_evidence_received_count", "manager_registry_acceptance_evidence_review_ready_count", ]: assert_equal(f"summary.{key}", summary.get(key), 1) 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_equal(f"evidence_slots.{slot.get('slot_id')}.received", slot.get("received"), True) assert_equal(f"evidence_slots.{slot.get('slot_id')}.accepted", slot.get("accepted"), True) assert_false(f"evidence_slots.{slot.get('slot_id')}.quarantined", slot.get("quarantined")) assert_equal( f"evidence_slots.{slot.get('slot_id')}.next_gate", slot.get("next_gate"), "manager_registry_acceptance_evidence_review", ) 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"post_enable={summary['post_enable_readback_passed_count']} " f"acceptance_endpoint={summary['manager_registry_acceptance_intake_endpoint_available_count']} " f"runtime_gate={summary['runtime_gate_count']}" ) if __name__ == "__main__": main()