Files
awoooi/scripts/security/wazuh-agent-visibility-owner-evidence-preflight.py
ogt 856fbcddb9
Some checks failed
Code Review / ai-code-review (push) Successful in 12s
Ansible / Reboot Recovery Contract / validate (push) Has been cancelled
feat(iwooos): tighten Wazuh owner evidence preflight
2026-06-25 18:33:42 +08:00

368 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()