368 lines
14 KiB
Python
368 lines
14 KiB
Python
#!/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()
|