#!/usr/bin/env python3 """ Wazuh agent visibility owner evidence 收件預檢。 本工具只驗證 repo 內 committed snapshot;不查 Wazuh、不 SSH、不讀 secret、不保存 raw log、不重新註冊 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-agent-visibility-owner-evidence-preflight.snapshot.json") SCHEMA_VERSION = "wazuh_agent_visibility_owner_evidence_preflight_v1" REQUIRED_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_CHECKS = [ "欄位齊全且皆為脫敏 metadata。", "agent_total 不可小於 active + disconnected + never_connected。", "collection_method 只能是既有唯讀 API 或 manager 端脫敏 CLI 匯出,不可要求 secret 明文。", "registry_export_scope_aliases 必須完整覆蓋 6 個公開節點別名。", "per_host_registry_matrix 每列只能使用公開別名,不得包含內網位址、agent 原名或 raw payload。", "last_seen 時間窗需能覆蓋事故觀察區間。", "manager health ref 與 dashboard API status ref 不可互相替代。", "Dashboard API connection 若仍是 pending / spinning,不得接受為 Wazuh 可用。", "Dashboard API version 必須獨立驗證;index pattern 三綠勾不可替代 API version。", "index pattern 已通過只能代表索引 pattern 可讀,不可替代 manager registry counts。", "Dashboard API 退化根因必須至少分類為 stored API、RBAC / run_as、rate-limit、TLS trust 或 readonly account scope 其中之一。", "dashboard API repair postcheck 必須包含 API connection、API version、manager registry counts 與 IwoooS readback。", "redacted evidence refs 不得包含 raw payload、截圖原文或主機完整輸出。", "owner decision 不可直接授權 active response、host write 或 secret rotation。", "rollback owner 與 postcheck plan 必須存在。", ] OUTCOME_LANES = [ "waiting_owner_evidence", "request_missing_fields", "quarantine_sensitive_payload", "reject_runtime_action_request", "request_dashboard_api_status_supplement", "request_dashboard_api_repair_postcheck", "reject_index_pattern_only_green", "ready_for_reviewer_validation", ] FORBIDDEN_PAYLOADS = [ "raw_wazuh_payload", "raw_log", "full_journal", "unredacted_screenshot", "agent_name", "internal_ip", "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", "host_write", "firewall_change", "nginx_reload", ] 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-----"), ] REGISTRY_EXPORT_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", ] REGISTRY_EXPORT_ALLOWED_COLLECTION_METHODS = [ "wazuh_api_readonly_redacted_counts", "manager_agent_control_redacted_export", ] PER_HOST_REGISTRY_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", ] REGISTRY_EXPORT_REDACTION_REQUIREMENTS = [ "只允許公開節點別名,不允許內網位址、主機原名或 agent 原名。", "agent id 僅能用不可逆 evidence ref,不得放完整值、雜湊、前後綴或 client key。", "每個缺口必須有 gap reason,不得以 Dashboard 空白或口頭說明補成綠燈。", "Dashboard API connection / version / index pattern 必須分欄呈現,不得合併成單一 healthy。", "Dashboard API 修復證明只能收脫敏 ref,不得收 stored API secret、token、密碼或完整 request。", "只收計數、狀態桶、時間窗與證據 ref,不收 raw API payload、完整 CLI output 或截圖原文。", ] 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_agent_visibility_owner_evidence_preflight: " "snapshot contains forbidden sensitive text" ) def build_snapshot() -> dict[str, Any]: return { "schema_version": SCHEMA_VERSION, "status": "owner_evidence_preflight_ready_no_runtime_action", "mode": "redacted_metadata_only_no_secret_no_wazuh_query", "scope": "wazuh_agent_visibility_registry_truth", "required_fields": REQUIRED_FIELDS, "reviewer_checks": REVIEWER_CHECKS, "outcome_lanes": OUTCOME_LANES, "forbidden_payloads": FORBIDDEN_PAYLOADS, "registry_export_contract": { "expected_scope_aliases": REGISTRY_EXPORT_SCOPE_ALIASES, "allowed_collection_methods": REGISTRY_EXPORT_ALLOWED_COLLECTION_METHODS, "per_host_required_fields": PER_HOST_REGISTRY_REQUIRED_FIELDS, "redaction_requirements": REGISTRY_EXPORT_REDACTION_REQUIREMENTS, "registry_export_received_count": 0, "registry_export_accepted_count": 0, }, "summary": { "required_field_count": len(REQUIRED_FIELDS), "reviewer_check_count": len(REVIEWER_CHECKS), "outcome_lane_count": len(OUTCOME_LANES), "forbidden_payload_count": len(FORBIDDEN_PAYLOADS), "expected_scope_alias_count": len(REGISTRY_EXPORT_SCOPE_ALIASES), "per_host_required_field_count": len(PER_HOST_REGISTRY_REQUIRED_FIELDS), "registry_export_received_count": 0, "registry_export_accepted_count": 0, "owner_evidence_received_count": 0, "owner_evidence_accepted_count": 0, "owner_evidence_rejected_count": 0, "owner_evidence_quarantined_count": 0, "runtime_gate_count": 0, "active_response_authorized_count": 0, "host_write_authorized_count": 0, "secret_value_collection_allowed_count": 0, }, "execution_boundaries": { "wazuh_api_live_query_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, "runtime_execution_authorized": False, "not_authorization": True, }, "operator_interpretation": [ "這是 Wazuh agent registry 脫敏證據收件前的預檢,不代表已收到或已接受 owner evidence。", "agent service active、TCP 連線存在、Dashboard 可見或口頭宣稱都不可替代 manager registry counts。", "逐主機 registry export 必須使用固定公開節點別名與狀態桶,不能把 agent 原名或內網識別資訊帶到前台。", "若 evidence 夾帶 raw log、未脫敏截圖、內網位址、agent 原名或 secret,必須隔離,不得渲染到前台。", "Dashboard index pattern 三綠勾不可替代 API connection、API version 或 manager registry 驗收。", "任何 active response、host write、firewall、Nginx、Docker、K8s 或 secret 變更都要切獨立人工批准。", ], } 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"), "owner_evidence_preflight_ready_no_runtime_action", ) assert_equal( "mode", snapshot.get("mode"), "redacted_metadata_only_no_secret_no_wazuh_query", ) assert_equal("required_fields", snapshot.get("required_fields"), REQUIRED_FIELDS) assert_equal("reviewer_checks", snapshot.get("reviewer_checks"), REVIEWER_CHECKS) assert_equal("outcome_lanes", snapshot.get("outcome_lanes"), OUTCOME_LANES) assert_equal("forbidden_payloads", snapshot.get("forbidden_payloads"), FORBIDDEN_PAYLOADS) contract = snapshot.get("registry_export_contract", {}) assert_equal( "registry_export_contract.expected_scope_aliases", contract.get("expected_scope_aliases"), REGISTRY_EXPORT_SCOPE_ALIASES, ) assert_equal( "registry_export_contract.allowed_collection_methods", contract.get("allowed_collection_methods"), REGISTRY_EXPORT_ALLOWED_COLLECTION_METHODS, ) assert_equal( "registry_export_contract.per_host_required_fields", contract.get("per_host_required_fields"), PER_HOST_REGISTRY_REQUIRED_FIELDS, ) assert_equal( "registry_export_contract.redaction_requirements", contract.get("redaction_requirements"), REGISTRY_EXPORT_REDACTION_REQUIREMENTS, ) assert_zero("registry_export_contract.registry_export_received_count", contract.get("registry_export_received_count")) assert_zero("registry_export_contract.registry_export_accepted_count", contract.get("registry_export_accepted_count")) summary = snapshot.get("summary", {}) assert_equal("summary.required_field_count", summary.get("required_field_count"), len(REQUIRED_FIELDS)) assert_equal("summary.reviewer_check_count", summary.get("reviewer_check_count"), len(REVIEWER_CHECKS)) assert_equal("summary.outcome_lane_count", summary.get("outcome_lane_count"), len(OUTCOME_LANES)) assert_equal("summary.forbidden_payload_count", summary.get("forbidden_payload_count"), len(FORBIDDEN_PAYLOADS)) assert_equal( "summary.expected_scope_alias_count", summary.get("expected_scope_alias_count"), len(REGISTRY_EXPORT_SCOPE_ALIASES), ) assert_equal( "summary.per_host_required_field_count", summary.get("per_host_required_field_count"), len(PER_HOST_REGISTRY_REQUIRED_FIELDS), ) for key in [ "registry_export_received_count", "registry_export_accepted_count", "owner_evidence_received_count", "owner_evidence_accepted_count", "owner_evidence_rejected_count", "owner_evidence_quarantined_count", "runtime_gate_count", "active_response_authorized_count", "host_write_authorized_count", "secret_value_collection_allowed_count", ]: assert_zero(f"summary.{key}", summary.get(key)) 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 agent visibility owner evidence 收件預檢") parser.add_argument("--root", type=Path, default=Path.cwd()) parser.add_argument("--output", type=Path) parser.add_argument("--json", action="store_true") args = parser.parse_args() root = args.root.resolve() if args.output: snapshot = build_snapshot() output = args.output if args.output.is_absolute() else root / args.output output.parent.mkdir(parents=True, exist_ok=True) output.write_text(json.dumps(snapshot, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") validate(root) snapshot = load_json(root / SNAPSHOT_PATH) if args.json: print(json.dumps(snapshot, ensure_ascii=False, sort_keys=True)) return summary = snapshot["summary"] print( "WAZUH_AGENT_VISIBILITY_OWNER_EVIDENCE_PREFLIGHT_OK " f"fields={summary['required_field_count']} " f"checks={summary['reviewer_check_count']} " f"aliases={summary['expected_scope_alias_count']} " f"export_received={summary['registry_export_received_count']} " f"received={summary['owner_evidence_received_count']} " f"accepted={summary['owner_evidence_accepted_count']} " f"runtime_gate={summary['runtime_gate_count']}" ) if __name__ == "__main__": main()