From 119267256abc0e50af2ee2186a12a0dd6dedd2c2 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 28 Jun 2026 11:28:31 +0800 Subject: [PATCH] feat(iwooos): add wazuh runtime owner review readback --- apps/api/src/api/v1/iwooos.py | 71 ++ .../iwooos_runtime_security_readback.py | 92 ++- ...azuh_runtime_gate_owner_review_readback.py | 727 ++++++++++++++++++ .../test_iwooos_runtime_security_readback.py | 213 ++++- apps/web/messages/en.json | 13 +- apps/web/messages/zh-TW.json | 13 +- apps/web/src/app/[locale]/iwooos/page.tsx | 8 + apps/web/src/lib/api-client.ts | 82 ++ ...e-gate-owner-review-readback.snapshot.json | 250 ++++++ 9 files changed, 1462 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/services/iwooos_wazuh_runtime_gate_owner_review_readback.py create mode 100644 docs/security/wazuh-runtime-gate-owner-review-readback.snapshot.json diff --git a/apps/api/src/api/v1/iwooos.py b/apps/api/src/api/v1/iwooos.py index 09df0f54..bf823ed1 100644 --- a/apps/api/src/api/v1/iwooos.py +++ b/apps/api/src/api/v1/iwooos.py @@ -53,6 +53,12 @@ from src.services.iwooos_wazuh_runtime_controlled_apply_preflight import ( from src.services.iwooos_wazuh_runtime_controlled_apply_preflight import ( validate_iwooos_wazuh_runtime_controlled_apply_packet as validate_wazuh_runtime_controlled_apply_packet_payload, ) +from src.services.iwooos_wazuh_runtime_gate_owner_review_readback import ( + load_latest_iwooos_wazuh_runtime_gate_owner_review_readback, +) +from src.services.iwooos_wazuh_runtime_gate_owner_review_readback import ( + validate_iwooos_wazuh_runtime_gate_owner_review_packet as validate_wazuh_runtime_gate_owner_review_packet_payload, +) from src.services.public_redaction import redact_public_lan_topology router = APIRouter(tags=["IwoooS Security"]) @@ -327,6 +333,71 @@ async def validate_iwooos_wazuh_runtime_controlled_apply_packet( ) from exc +@router.get( + "/api/v1/iwooos/wazuh-runtime-gate-owner-review-readback", + response_model=dict[str, Any], + summary="取得 Wazuh runtime gate owner-review 只讀讀回", + description=( + "讀取已提交的 Wazuh runtime gate owner-review readback contract,回傳 owner-review " + "decision、target selector、source-of-truth diff、check-mode / dry-run evidence、rollback、" + "post-apply verifier、KM / PlayBook writeback 與 0 / false 邊界。此端點不查 Wazuh API、" + "不讀主機、不重新註冊 agent、不重啟服務、不保存機密、不啟用主動回應、不改 Nginx / " + "Docker / K8s / firewall。" + ), +) +async def get_iwooos_wazuh_runtime_gate_owner_review_readback() -> dict[str, Any]: + """回傳 Wazuh runtime gate owner-review 公開安全只讀狀態。""" + try: + payload = await asyncio.to_thread( + load_latest_iwooos_wazuh_runtime_gate_owner_review_readback + ) + 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 runtime gate owner-review readback 無效:{exc}", + ) from exc + + +@router.post( + "/api/v1/iwooos/wazuh-runtime-gate-owner-review-readback/validate-owner-review-packet", + response_model=dict[str, Any], + summary="驗證 Wazuh runtime gate 脫敏 owner-review packet", + description=( + "針對單次 owner / reviewer 提供的 redacted Wazuh runtime gate owner-review packet " + "進行 no-persist readiness validation,回傳 accepted-for-readback / needs supplement / " + "quarantined / rejected runtime action 分流。此端點不保存 payload、不查 Wazuh API、不讀主機、" + "不重新註冊 agent、不重啟服務、不讀或回傳機密明文、不啟用主動回應、不改 Nginx / Docker / " + "K8s / firewall,也不更新 runtime gate 總帳。" + ), +) +async def validate_iwooos_wazuh_runtime_gate_owner_review_packet( + owner_review_packet: dict[str, Any], +) -> dict[str, Any]: + """回傳單次 Wazuh runtime gate owner-review packet 的公開安全驗證結果。""" + try: + payload = await asyncio.to_thread( + validate_wazuh_runtime_gate_owner_review_packet_payload, + owner_review_packet, + ) + 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 runtime gate owner-review packet 驗證器無效:{exc}", + ) from exc + + @router.get( "/api/v1/iwooos/runtime-security-readback", response_model=dict[str, Any], diff --git a/apps/api/src/services/iwooos_runtime_security_readback.py b/apps/api/src/services/iwooos_runtime_security_readback.py index 7d2877d7..99e9c13d 100644 --- a/apps/api/src/services/iwooos_runtime_security_readback.py +++ b/apps/api/src/services/iwooos_runtime_security_readback.py @@ -23,6 +23,7 @@ _SNAPSHOT_FILES = { "wazuh_live_metadata_gate": "wazuh-readonly-live-metadata-env-gate.snapshot.json", "wazuh_owner_evidence_preflight": "wazuh-agent-visibility-owner-evidence-preflight.snapshot.json", "wazuh_runtime_apply_preflight": "wazuh-runtime-controlled-apply-preflight.snapshot.json", + "wazuh_runtime_owner_review": "wazuh-runtime-gate-owner-review-readback.snapshot.json", "kali_status": "kali-integration-status.snapshot.json", "soc_control": "soc-siem-kali-wazuh-integration-control.snapshot.json", "alert_readability": "telegram-alert-readability-guard.snapshot.json", @@ -37,6 +38,7 @@ _EXPECTED_SCHEMAS = { "wazuh_live_metadata_gate": "iwooos_wazuh_readonly_live_metadata_env_gate_v1", "wazuh_owner_evidence_preflight": "wazuh_agent_visibility_owner_evidence_preflight_v1", "wazuh_runtime_apply_preflight": "wazuh_runtime_controlled_apply_preflight_v1", + "wazuh_runtime_owner_review": "wazuh_runtime_gate_owner_review_readback_v1", "kali_status": "kali_integration_status_v1", "soc_control": "soc_siem_kali_wazuh_integration_control_v1", "alert_readability": "telegram_alert_readability_guard_v1", @@ -86,6 +88,7 @@ def load_latest_iwooos_runtime_security_readback( runtime_apply_preflight_summary = _summary( snapshots["wazuh_runtime_apply_preflight"] ) + runtime_owner_review_summary = _summary(snapshots["wazuh_runtime_owner_review"]) soc_summary = _summary(snapshots["soc_control"]) alert_summary = _summary(snapshots["alert_readability"]) dispatch_summary = _summary(snapshots["owner_dispatch"]) @@ -108,7 +111,7 @@ def load_latest_iwooos_runtime_security_readback( "source_refs": source_refs, "summary": { "source_snapshot_count": len(source_refs), - "p0_lane_count": 10, + "p0_lane_count": 11, "control_plane_visibility_percent": _average_percent( soc_summary.get("coverage_percent_after_soc_integration_control"), intrusion_summary.get("coverage_percent_after_prevention_control"), @@ -234,6 +237,41 @@ def load_latest_iwooos_runtime_security_readback( "wazuh_runtime_apply_runtime_gate_count": _int( runtime_apply_preflight_summary.get("runtime_gate_count") ), + "wazuh_runtime_owner_review_target_selector_count": _int( + runtime_owner_review_summary.get("target_selector_count") + ), + "wazuh_runtime_owner_review_source_diff_count": _int( + runtime_owner_review_summary.get("source_of_truth_diff_count") + ), + "wazuh_runtime_owner_review_check_mode_plan_count": _int( + runtime_owner_review_summary.get("check_mode_plan_count") + ), + "wazuh_runtime_owner_review_dry_run_evidence_count": _int( + runtime_owner_review_summary.get("dry_run_evidence_count") + ), + "wazuh_runtime_owner_review_rollback_plan_count": _int( + runtime_owner_review_summary.get("rollback_plan_count") + ), + "wazuh_runtime_owner_review_post_apply_verifier_count": _int( + runtime_owner_review_summary.get("post_apply_verifier_count") + ), + "wazuh_runtime_owner_review_km_writeback_count": _int( + runtime_owner_review_summary.get("km_playbook_writeback_count") + ), + "wazuh_runtime_owner_review_packet_received_count": _int( + runtime_owner_review_summary.get("owner_review_packet_received_count") + ), + "wazuh_runtime_owner_review_packet_review_ready_count": _int( + runtime_owner_review_summary.get( + "owner_review_packet_review_ready_count" + ) + ), + "wazuh_runtime_owner_review_packet_accepted_count": _int( + runtime_owner_review_summary.get("owner_review_packet_accepted_count") + ), + "wazuh_runtime_owner_review_runtime_gate_count": _int( + runtime_owner_review_summary.get("runtime_gate_count") + ), "kali_active_scan_authorized_count": _int( soc_summary.get("kali_active_scan_authorized_count") ), @@ -433,6 +471,57 @@ def load_latest_iwooos_runtime_security_readback( "docs/security/wazuh-runtime-controlled-apply-preflight.snapshot.json" ], ), + _lane( + "wazuh_runtime_gate_owner_review", + snapshots["wazuh_runtime_owner_review"].get( + "status", + "runtime_gate_owner_review_packet_committed_no_runtime_action", + ), + 55 + if _int( + runtime_owner_review_summary.get( + "owner_review_packet_accepted_count" + ) + ) + else 0, + "steady" + if _int( + runtime_owner_review_summary.get( + "owner_review_packet_accepted_count" + ) + ) + else "locked", + "進入 allowlisted check-mode 與 dry-run readback;runtime gate 仍關閉", + { + "target_selectors": runtime_owner_review_summary.get( + "target_selector_count", 0 + ), + "source_diff": runtime_owner_review_summary.get( + "source_of_truth_diff_count", 0 + ), + "check_mode": runtime_owner_review_summary.get( + "check_mode_plan_count", 0 + ), + "dry_run": runtime_owner_review_summary.get( + "dry_run_evidence_count", 0 + ), + "rollback": runtime_owner_review_summary.get( + "rollback_plan_count", 0 + ), + "post_apply_verifier": runtime_owner_review_summary.get( + "post_apply_verifier_count", 0 + ), + "owner_review_accepted": runtime_owner_review_summary.get( + "owner_review_packet_accepted_count", 0 + ), + "runtime_gate": runtime_owner_review_summary.get( + "runtime_gate_count", 0 + ), + }, + [ + "docs/security/wazuh-runtime-gate-owner-review-readback.snapshot.json" + ], + ), _lane( "wazuh_dashboard_api", "degraded_api_connection_not_green", @@ -562,6 +651,7 @@ def load_latest_iwooos_runtime_security_readback( "Wazuh 即時中繼資料必須先通過負責人、機密中繼資料、唯讀範圍與啟用後讀回", "Wazuh 負責人證據預檢 ready 不代表已收件、已接受或可啟用 active response", "Wazuh controlled apply preflight ready 不代表 runtime gate 已開或已執行修復", + "Wazuh runtime gate owner-review accepted 只代表 review readiness,不代表已查 live Wazuh 或可寫主機", ], } diff --git a/apps/api/src/services/iwooos_wazuh_runtime_gate_owner_review_readback.py b/apps/api/src/services/iwooos_wazuh_runtime_gate_owner_review_readback.py new file mode 100644 index 00000000..5c02db54 --- /dev/null +++ b/apps/api/src/services/iwooos_wazuh_runtime_gate_owner_review_readback.py @@ -0,0 +1,727 @@ +""" +IwoooS Wazuh runtime gate owner-review readback. + +This service exposes a committed owner-review readiness contract and a +no-persist validator for redacted runtime-gate owner-review packets. It never +queries live Wazuh, reads host data, reads secrets, persists raw payloads, or +authorizes runtime actions. +""" + +from __future__ import annotations + +import json +import re +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-runtime-gate-owner-review-readback.snapshot.json" +_EXPECTED_SCHEMA = "wazuh_runtime_gate_owner_review_readback_v1" + +_REQUIRED_FALSE_BOUNDARIES = { + "active_scan_authorized", + "alertmanager_reload_authorized", + "auto_block_authorized", + "credentialed_scan_authorized", + "firewall_change_authorized", + "host_write_authorized", + "kali_execute_authorized", + "kali_scan_authorized", + "nginx_reload_authorized", + "production_write_authorized", + "runtime_execution_authorized", + "runtime_gate_open", + "secret_value_collection_allowed", + "telegram_send_authorized", + "wazuh_active_response_authorized", + "wazuh_api_live_query_authorized", +} + +_SENSITIVE_TEXT_PATTERNS = { + "internal_ip": re.compile( + r"\b(?:10|127|172\.(?:1[6-9]|2\d|3[01])|192\.168)\.\d{1,3}\.\d{1,3}\b" + ), + "authorization_header": re.compile(r"Authorization\s*:", re.IGNORECASE), + "bearer_token": re.compile(r"Bearer\s+[A-Za-z0-9._-]{10,}", re.IGNORECASE), + "basic_auth": re.compile(r"Basic\s+[A-Za-z0-9+/=]{10,}", re.IGNORECASE), + "password_assignment": re.compile( + r"password\s*[:=]\s*['\"][^'\"]+['\"]", re.IGNORECASE + ), + "token_assignment": re.compile(r"token\s*[:=]\s*['\"][^'\"]+['\"]", re.IGNORECASE), + "cookie_assignment": re.compile( + r"cookie\s*[:=]\s*['\"][^'\"]+['\"]", re.IGNORECASE + ), + "client_keys": re.compile(r"client\.keys", re.IGNORECASE), + "private_key": re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"), + "raw_session_text": re.compile( + r"(工作視窗|批准!繼續|source_thread_id|raw session)", re.IGNORECASE + ), +} + +_FORBIDDEN_KEY_FRAGMENTS = { + "authorization_header", + "basic_auth", + "bearer_token", + "client_keys", + "cookie", + "env_file", + "full_cli_output", + "full_journal", + "hostname", + "internal_ip", + "password", + "private_key", + "raw_agent_identity", + "raw_dashboard_request", + "raw_env", + "raw_hostname", + "raw_log", + "raw_runtime_volume", + "raw_wazuh_payload", + "session", + "stored_api_password", + "token", + "unredacted_screenshot", + "wazuh_api_password", +} + +_RUNTIME_ACTION_KEYS = { + "active_response_enable", + "agent_reenroll", + "agent_restart", + "ansible_apply", + "ansible_playbook_run", + "apply_now", + "argocd_sync", + "credentialed_scan", + "database_migration", + "docker_restart", + "execute_now", + "exploit_attempt", + "firewall_change", + "force_push", + "host_write", + "k8s_apply", + "kali_active_scan", + "nginx_reload", + "repo_ref_delete", + "runtime_execution_authorized", + "secret_rotation", + "systemd_restart", + "wazuh_active_response", + "wazuh_agent_reenroll", + "wazuh_agent_restart", + "wazuh_api_live_query", + "wazuh_manager_restart", + "workflow_trigger", +} + + +def load_latest_iwooos_wazuh_runtime_gate_owner_review_readback( + security_dir: Path | None = None, +) -> dict[str, Any]: + """Load the public-safe Wazuh runtime gate owner-review 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")), + "target_selector_count": _int(summary.get("target_selector_count")), + "source_of_truth_diff_count": _int(summary.get("source_of_truth_diff_count")), + "check_mode_plan_count": _int(summary.get("check_mode_plan_count")), + "dry_run_evidence_count": _int(summary.get("dry_run_evidence_count")), + "rollback_plan_count": _int(summary.get("rollback_plan_count")), + "post_apply_verifier_count": _int(summary.get("post_apply_verifier_count")), + "km_playbook_writeback_count": _int(summary.get("km_playbook_writeback_count")), + "maintenance_window_review_count": _int( + summary.get("maintenance_window_review_count") + ), + "owner_review_packet_received_count": _int( + summary.get("owner_review_packet_received_count") + ), + "owner_review_packet_review_ready_count": _int( + summary.get("owner_review_packet_review_ready_count") + ), + "owner_review_packet_accepted_count": _int( + summary.get("owner_review_packet_accepted_count") + ), + "owner_review_packet_supplement_required_count": _int( + summary.get("owner_review_packet_supplement_required_count") + ), + "owner_review_packet_quarantined_count": _int( + summary.get("owner_review_packet_quarantined_count") + ), + "owner_review_runtime_action_rejected_count": _int( + summary.get("owner_review_runtime_action_rejected_count") + ), + "forbidden_payload_count": _int(summary.get("forbidden_payload_count")), + "forbidden_action_count": _int(summary.get("forbidden_action_count")), + "runtime_gate_count": _int(summary.get("runtime_gate_count")), + "wazuh_api_live_query_authorized_count": _int( + summary.get("wazuh_api_live_query_authorized_count") + ), + "wazuh_active_response_authorized_count": _int( + summary.get("wazuh_active_response_authorized_count") + ), + "host_write_authorized_count": _int(summary.get("host_write_authorized_count")), + "secret_value_collection_allowed_count": _int( + summary.get("secret_value_collection_allowed_count") + ), + } + + return { + "schema_version": "iwooos_wazuh_runtime_gate_owner_review_readback_v1", + "source_schema_version": snapshot["schema_version"], + "status": snapshot.get( + "status", "runtime_gate_owner_review_packet_committed_no_runtime_action" + ), + "mode": snapshot.get( + "mode", "committed_owner_review_readback_no_live_wazuh_no_secret_collection" + ), + "source_refs": [ + f"docs/security/{_SNAPSHOT_FILE}", + "docs/security/wazuh-runtime-controlled-apply-preflight.snapshot.json", + ], + "owner_review_packet_validation_endpoint": ( + "/api/v1/iwooos/wazuh-runtime-gate-owner-review-readback/validate-owner-review-packet" + ), + "owner_review_packet_validation_mode": "no_persist_owner_review_readback_no_runtime_action", + "summary": merged_summary, + "target_selectors": _target_selectors(snapshot.get("target_selectors")), + "required_owner_review_fields": _strings( + snapshot.get("required_owner_review_fields") + ), + "review_items": _review_items(snapshot.get("review_items")), + "outcome_lanes": _strings(snapshot.get("outcome_lanes")), + "forbidden_payloads": _strings(snapshot.get("forbidden_payloads")), + "forbidden_actions": _strings(snapshot.get("forbidden_actions")), + "boundary_markers": _boundary_markers(merged_summary), + "boundaries": { + "payload_persisted": False, + "wazuh_api_live_query_authorized": False, + "wazuh_active_response_authorized": False, + "wazuh_agent_reenroll_authorized": False, + "wazuh_agent_restart_authorized": False, + "wazuh_manager_restart_authorized": False, + "host_write_authorized": False, + "active_scan_authorized": False, + "kali_execute_authorized": False, + "nginx_reload_authorized": False, + "secret_value_collection_allowed": False, + "runtime_execution_authorized": False, + "runtime_gate_open": False, + "not_authorization": True, + }, + "no_false_green_rules": _strings(snapshot.get("no_false_green_rules")), + } + + +def validate_iwooos_wazuh_runtime_gate_owner_review_packet( + owner_review_packet: dict[str, Any], + security_dir: Path | None = None, +) -> dict[str, Any]: + """Validate one redacted runtime-gate owner-review packet without applying it.""" + contract = load_latest_iwooos_wazuh_runtime_gate_owner_review_readback(security_dir) + snapshot = _load_snapshot(security_dir or _DEFAULT_SECURITY_DIR) + required_fields = _strings(snapshot.get("required_owner_review_fields")) + expected_aliases = { + item["node_alias"] + for item in contract["target_selectors"] + if item.get("node_alias") + } + + findings: list[dict[str, Any]] = [] + if not isinstance(owner_review_packet, dict): + findings.append( + _finding( + "ORG-01", + "blocker", + "request_runtime_gate_owner_review_supplement", + "runtime gate owner-review packet must be a JSON object.", + [], + ) + ) + return _validation_result( + contract, "request_runtime_gate_owner_review_supplement", findings + ) + + sensitive_hits = _collect_sensitive_hits(owner_review_packet) + if sensitive_hits: + findings.append( + _finding( + "ORG-04", + "critical", + "quarantine_sensitive_payload", + "runtime gate owner-review packet contains forbidden or likely unredacted content; response omits raw values.", + [hit["path"] for hit in sensitive_hits[:12]], + {"categories": sorted({hit["category"] for hit in sensitive_hits})}, + ) + ) + return _validation_result(contract, "quarantine_sensitive_payload", findings) + + runtime_hits = _collect_runtime_action_hits(owner_review_packet) + if runtime_hits: + findings.append( + _finding( + "ORG-05", + "critical", + "reject_runtime_action_request", + "runtime gate owner-review packet requested runtime execution; this validator only records review readiness.", + runtime_hits[:12], + ) + ) + return _validation_result(contract, "reject_runtime_action_request", findings) + + missing_fields = [ + field + for field in required_fields + if not _present(owner_review_packet.get(field)) + ] + if missing_fields: + findings.append( + _finding( + "ORG-01", + "blocker", + "request_runtime_gate_owner_review_supplement", + "runtime gate owner-review packet is missing required fields.", + missing_fields, + ) + ) + + alias_issue = _validate_aliases( + owner_review_packet.get("target_selector_aliases"), expected_aliases + ) + if alias_issue: + findings.append( + _finding( + "ORG-02", + "blocker", + "request_target_selector_fix", + alias_issue, + ["target_selector_aliases"], + ) + ) + + if ( + owner_review_packet.get("owner_review_intent") + != "commit_runtime_gate_owner_review_readback_only" + ): + findings.append( + _finding( + "ORG-03", + "blocker", + "request_runtime_gate_owner_review_decision_fix", + "owner_review_intent must be commit_runtime_gate_owner_review_readback_only.", + ["owner_review_intent"], + ) + ) + + if ( + owner_review_packet.get("owner_review_decision") + != "accept_controlled_apply_review_readiness_only" + ): + findings.append( + _finding( + "ORG-06", + "blocker", + "request_runtime_gate_owner_review_decision_fix", + "owner_review_decision must be accept_controlled_apply_review_readiness_only and must not claim runtime gate opening.", + ["owner_review_decision"], + ) + ) + + if owner_review_packet.get("runtime_boundary_ack") != "runtime_gate_remains_closed": + findings.append( + _finding( + "ORG-07", + "blocker", + "request_runtime_boundary_ack_fix", + "runtime_boundary_ack must state runtime_gate_remains_closed.", + ["runtime_boundary_ack"], + ) + ) + + if owner_review_packet.get("secret_boundary_ack") != "no_secret_value_collected": + findings.append( + _finding( + "ORG-08", + "blocker", + "request_runtime_boundary_ack_fix", + "secret_boundary_ack must state no_secret_value_collected.", + ["secret_boundary_ack"], + ) + ) + + if ( + owner_review_packet.get("live_wazuh_query_boundary_ack") + != "no_live_wazuh_query_performed" + ): + findings.append( + _finding( + "ORG-09", + "blocker", + "request_runtime_boundary_ack_fix", + "live_wazuh_query_boundary_ack must state no_live_wazuh_query_performed.", + ["live_wazuh_query_boundary_ack"], + ) + ) + + outcome = ( + _first_blocking_lane(findings) + or "accepted_for_runtime_gate_owner_review_readback_only" + ) + if outcome == "accepted_for_runtime_gate_owner_review_readback_only": + findings.append( + _finding( + "ORG-10", + "info", + "runtime_gate_owner_review_readback_ready", + "owner-review packet passed no-persist readiness validation; runtime gate remains closed.", + [ + "owner_review_decision", + "check_mode_plan_ref", + "post_apply_verifier_ref", + ], + ) + ) + return _validation_result(contract, outcome, findings) + + +def _load_snapshot(directory: Path) -> dict[str, Any]: + path = directory / _SNAPSHOT_FILE + if not path.is_file(): + raise FileNotFoundError( + f"{path}: Wazuh runtime gate owner-review snapshot not found" + ) + 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 _target_selectors(value: Any) -> list[dict[str, Any]]: + if not isinstance(value, list): + return [] + selectors: list[dict[str, Any]] = [] + for item in value: + if not isinstance(item, dict): + continue + selectors.append( + { + "node_alias": str(item.get("node_alias", "")), + "scope": str(item.get("scope", "")), + "selector_kind": str(item.get("selector_kind", "")), + "runtime_write_allowed": item.get("runtime_write_allowed") is True, + "owner_review_scope": str(item.get("owner_review_scope", "")), + } + ) + return selectors + + +def _review_items(value: Any) -> list[dict[str, Any]]: + if not isinstance(value, list): + return [] + items: list[dict[str, Any]] = [] + for item in value: + if not isinstance(item, dict): + continue + items.append( + { + "item_id": str(item.get("item_id", "")), + "title": str(item.get("title", "")), + "state_key": str(item.get("state_key", "")), + "accepted": item.get("accepted") is True, + "required_fields": _strings(item.get("required_fields")), + "next_gate": str(item.get("next_gate", "")), + } + ) + return items + + +def _boundary_markers(summary: dict[str, int]) -> list[str]: + return [ + "wazuh_runtime_gate_owner_review_visible=true", + "wazuh_runtime_gate_owner_review_validation_api_available=true", + f"wazuh_runtime_gate_owner_review_target_selector_count={summary['target_selector_count']}", + f"wazuh_runtime_gate_owner_review_source_diff_count={summary['source_of_truth_diff_count']}", + f"wazuh_runtime_gate_owner_review_check_mode_plan_count={summary['check_mode_plan_count']}", + f"wazuh_runtime_gate_owner_review_dry_run_evidence_count={summary['dry_run_evidence_count']}", + f"wazuh_runtime_gate_owner_review_packet_received_count={summary['owner_review_packet_received_count']}", + f"wazuh_runtime_gate_owner_review_packet_accepted_count={summary['owner_review_packet_accepted_count']}", + f"wazuh_runtime_gate_owner_review_runtime_gate_count={summary['runtime_gate_count']}", + "wazuh_api_live_query_authorized=false", + "wazuh_active_response_authorized=false", + "host_write_authorized=false", + "secret_value_collection_allowed=false", + "not_authorization=true", + ] + + +def _require_boundaries(payload: dict[str, Any]) -> None: + summary = _summary(payload) + for key in ( + "owner_review_packet_supplement_required_count", + "owner_review_packet_quarantined_count", + "owner_review_runtime_action_rejected_count", + "runtime_gate_count", + "wazuh_api_live_query_authorized_count", + "wazuh_active_response_authorized_count", + "host_write_authorized_count", + "secret_value_collection_allowed_count", + ): + if _int(summary.get(key)) != 0: + raise ValueError( + f"Wazuh runtime gate owner-review summary.{key} must remain 0" + ) + + expected_alias_count = _int(summary.get("expected_scope_alias_count")) + target_selector_count = _int(summary.get("target_selector_count")) + target_selectors = _target_selectors(payload.get("target_selectors")) + if ( + target_selector_count != expected_alias_count + or len(target_selectors) != expected_alias_count + ): + raise ValueError( + "Wazuh runtime gate owner-review target selectors must match expected alias count" + ) + if any(item.get("runtime_write_allowed") is True for item in target_selectors): + raise ValueError( + "Wazuh runtime gate owner-review target selectors must not allow runtime writes" + ) + + readiness_keys = ( + "source_of_truth_diff_count", + "check_mode_plan_count", + "dry_run_evidence_count", + "rollback_plan_count", + "post_apply_verifier_count", + "km_playbook_writeback_count", + "maintenance_window_review_count", + "owner_review_packet_received_count", + "owner_review_packet_review_ready_count", + "owner_review_packet_accepted_count", + ) + if any(_int(summary.get(key)) <= 0 for key in readiness_keys): + raise ValueError( + "Wazuh runtime gate owner-review readiness counters must be positive" + ) + + boundaries = payload.get("execution_boundaries") + if not isinstance(boundaries, dict): + raise ValueError("Wazuh runtime gate owner-review execution_boundaries missing") + for key in _REQUIRED_FALSE_BOUNDARIES: + if boundaries.get(key) is not False: + raise ValueError( + f"Wazuh runtime gate owner-review execution_boundaries.{key} must remain false" + ) + if boundaries.get("not_authorization") is not True: + raise ValueError( + "Wazuh runtime gate owner-review not_authorization must remain true" + ) + + +def _validation_result( + contract: dict[str, Any], + outcome_lane: str, + findings: list[dict[str, Any]], +) -> dict[str, Any]: + accepted = outcome_lane == "accepted_for_runtime_gate_owner_review_readback_only" + quarantined = outcome_lane == "quarantine_sensitive_payload" + rejected_runtime = outcome_lane == "reject_runtime_action_request" + supplement_required = not accepted and not quarantined and not rejected_runtime + return { + "schema_version": "iwooos_wazuh_runtime_gate_owner_review_packet_validation_result_v1", + "contract_schema_version": contract["schema_version"], + "status": outcome_lane, + "mode": "no_persist_runtime_gate_owner_review_no_runtime_no_secret_collection", + "outcome_lane": outcome_lane, + "accepted_for_runtime_gate_owner_review_readback_only": accepted, + "quarantined": quarantined, + "runtime_action_rejected": rejected_runtime, + "summary": { + "owner_review_packet_received_count": 1, + "owner_review_packet_review_ready_count": 1 if accepted else 0, + "owner_review_packet_accepted_count": 1 if accepted else 0, + "owner_review_packet_supplement_required_count": 1 + if supplement_required + else 0, + "owner_review_packet_quarantined_count": 1 if quarantined else 0, + "owner_review_runtime_action_rejected_count": 1 if rejected_runtime else 0, + "runtime_gate_count": 0, + "wazuh_api_live_query_authorized_count": 0, + "wazuh_active_response_authorized_count": 0, + "host_write_authorized_count": 0, + "secret_value_collection_allowed_count": 0, + "finding_count": len(findings), + }, + "validation_findings": findings, + "boundary_markers": [ + "wazuh_runtime_gate_owner_review_packet_validation_received_count=1", + f"wazuh_runtime_gate_owner_review_packet_validation_accepted_count={1 if accepted else 0}", + f"wazuh_runtime_gate_owner_review_packet_validation_quarantined_count={1 if quarantined else 0}", + f"wazuh_runtime_gate_owner_review_packet_validation_runtime_action_rejected_count={1 if rejected_runtime else 0}", + "wazuh_runtime_gate_owner_review_packet_validation_runtime_gate_count=0", + "wazuh_runtime_gate_owner_review_packet_validation_no_persist=true", + "wazuh_api_live_query_authorized=false", + "wazuh_active_response_authorized=false", + "host_write_authorized=false", + "secret_value_collection_allowed=false", + "not_authorization=true", + ], + "boundaries": { + "payload_persisted": False, + "wazuh_api_live_query_authorized": False, + "wazuh_active_response_authorized": False, + "wazuh_agent_reenroll_authorized": False, + "wazuh_agent_restart_authorized": False, + "wazuh_manager_restart_authorized": False, + "host_write_authorized": False, + "active_scan_authorized": False, + "kali_execute_authorized": False, + "nginx_reload_authorized": False, + "secret_value_collection_allowed": False, + "runtime_execution_authorized": False, + "runtime_gate_open": False, + "not_authorization": True, + }, + "next_gate": "stage_allowlisted_check_mode_dry_run_before_runtime_gate" + if accepted + else "runtime_gate_owner_review_packet_fix_and_resubmit", + } + + +def _finding( + check_id: str, + severity: str, + lane: str, + message: str, + field_paths: list[str], + extra: dict[str, Any] | None = None, +) -> dict[str, Any]: + payload: dict[str, Any] = { + "check_id": check_id, + "severity": severity, + "lane": lane, + "message": message, + "field_paths": field_paths, + } + if extra: + payload.update(extra) + return payload + + +def _present(value: Any) -> bool: + if value is None: + return False + if isinstance(value, str): + return bool(value.strip()) + if isinstance(value, list | dict | tuple | set): + return bool(value) + return True + + +def _validate_aliases(value: Any, expected_aliases: set[str]) -> str | None: + aliases = value if isinstance(value, list) else [] + if not aliases or not all(isinstance(item, str) for item in aliases): + return "target_selector_aliases must be an array of public alias strings." + alias_set = set(aliases) + if len(aliases) != len(alias_set): + return "target_selector_aliases must not contain duplicates." + if alias_set != expected_aliases: + missing = sorted(expected_aliases - alias_set) + extra = sorted(alias_set - expected_aliases) + return f"target_selector_aliases must match expected public aliases; missing={missing} extra={extra}" + return None + + +def _collect_sensitive_hits(value: Any, path: str = "$") -> list[dict[str, str]]: + hits: list[dict[str, str]] = [] + if isinstance(value, dict): + for key, item in value.items(): + key_text = str(key) + key_lower = key_text.lower() + for fragment in _FORBIDDEN_KEY_FRAGMENTS: + if fragment in key_lower: + hits.append( + { + "path": f"{path}.{key_text}", + "category": f"forbidden_key:{fragment}", + } + ) + hits.extend(_collect_sensitive_hits(item, f"{path}.{key_text}")) + return hits + if isinstance(value, list): + for index, item in enumerate(value): + hits.extend(_collect_sensitive_hits(item, f"{path}[{index}]")) + return hits + if isinstance(value, str): + for category, pattern in _SENSITIVE_TEXT_PATTERNS.items(): + if pattern.search(value): + hits.append({"path": path, "category": category}) + return hits + + +def _collect_runtime_action_hits(value: Any, path: str = "$") -> list[str]: + hits: list[str] = [] + if isinstance(value, dict): + for key, item in value.items(): + key_text = str(key) + normalized_key = key_text.lower().replace("-", "_").replace(" ", "_") + if normalized_key in _RUNTIME_ACTION_KEYS and item not in ( + False, + None, + "", + [], + {}, + ): + hits.append(f"{path}.{key_text}") + hits.extend(_collect_runtime_action_hits(item, f"{path}.{key_text}")) + return hits + if isinstance(value, list): + for index, item in enumerate(value): + hits.extend(_collect_runtime_action_hits(item, f"{path}[{index}]")) + return hits + if isinstance(value, str): + normalized = value.lower().replace("-", "_").replace(" ", "_") + if normalized in _RUNTIME_ACTION_KEYS: + hits.append(path) + return hits + + +def _first_blocking_lane(findings: list[dict[str, Any]]) -> str | None: + severity_order = {"critical": 0, "blocker": 1, "warn": 2, "info": 3} + blocking = [ + finding + for finding in findings + if finding.get("severity") in {"critical", "blocker"} + ] + if not blocking: + return None + blocking.sort( + key=lambda finding: severity_order.get(str(finding.get("severity")), 99) + ) + return str( + blocking[0].get("lane") or "request_runtime_gate_owner_review_supplement" + ) diff --git a/apps/api/tests/test_iwooos_runtime_security_readback.py b/apps/api/tests/test_iwooos_runtime_security_readback.py index d683aa0b..698c9898 100644 --- a/apps/api/tests/test_iwooos_runtime_security_readback.py +++ b/apps/api/tests/test_iwooos_runtime_security_readback.py @@ -10,6 +10,9 @@ from src.services.iwooos_runtime_security_readback import ( from src.services.iwooos_wazuh_runtime_controlled_apply_preflight import ( load_latest_iwooos_wazuh_runtime_controlled_apply_preflight, ) +from src.services.iwooos_wazuh_runtime_gate_owner_review_readback import ( + load_latest_iwooos_wazuh_runtime_gate_owner_review_readback, +) def _client() -> TestClient: @@ -44,13 +47,44 @@ def _valid_runtime_controlled_apply_packet() -> dict[str, object]: } +def _valid_runtime_gate_owner_review_packet() -> dict[str, object]: + return { + "owner_review_intent": "commit_runtime_gate_owner_review_readback_only", + "owner_reviewer_role": "iwooos-security-owner", + "owner_review_decision": "accept_controlled_apply_review_readiness_only", + "owner_review_decision_reason": "redacted owner-review packet accepts review readiness only; runtime gate remains closed", + "target_selector_aliases": [ + "managed_core_node_a", + "managed_core_node_b", + "managed_core_node_c", + "managed_edge_node_a", + "managed_edge_node_b", + "managed_lab_node_a", + ], + "source_of_truth_diff_ref": "docs/security/wazuh-runtime-gate-owner-review-readback.snapshot.json#source-diff", + "check_mode_plan_ref": "playbooks/wazuh-controlled-apply-check-mode#redacted-plan", + "dry_run_evidence_ref": "evidence/iwooos/wazuh-runtime-owner-review-dry-run-redacted-v1", + "blast_radius_statement": "public aliases only; no live Wazuh query and no host write in this owner-review readback", + "maintenance_window_ref": "maintenance/iwooos-wazuh-low-traffic-window-redacted-v1", + "rollback_plan_ref": "playbooks/wazuh-controlled-apply-rollback#redacted-plan", + "rollback_owner": "iwooos-security-owner", + "post_apply_verifier_ref": "verifiers/iwooos-wazuh-post-apply-readback#public-safe", + "km_playbook_writeback_ref": "km/playbook-trust/wazuh-runtime-gate-owner-review-v1", + "followup_owner": "iwooos-security-reviewer", + "audit_receipt_ref": "audit/iwooos-wazuh-runtime-gate-owner-review-redacted-v1", + "runtime_boundary_ack": "runtime_gate_remains_closed", + "secret_boundary_ack": "no_secret_value_collected", + "live_wazuh_query_boundary_ack": "no_live_wazuh_query_performed", + } + + def test_iwooos_runtime_security_readback_preserves_zero_runtime_gates() -> None: payload = load_latest_iwooos_runtime_security_readback() assert payload["schema_version"] == "iwooos_runtime_security_readback_v1" assert payload["status"] == "blocked_waiting_owner_evidence_and_runtime_gates" - assert payload["summary"]["source_snapshot_count"] == 11 - assert payload["summary"]["p0_lane_count"] == 10 + assert payload["summary"]["source_snapshot_count"] == 12 + assert payload["summary"]["p0_lane_count"] == 11 assert payload["summary"]["runtime_gate_count"] == 0 assert payload["summary"]["owner_response_received_count"] == 0 assert payload["summary"]["owner_response_accepted_count"] == 0 @@ -101,6 +135,21 @@ def test_iwooos_runtime_security_readback_preserves_zero_runtime_gates() -> None assert payload["summary"]["wazuh_runtime_apply_km_writeback_count"] == 1 assert payload["summary"]["wazuh_runtime_apply_owner_review_ready_count"] == 1 assert payload["summary"]["wazuh_runtime_apply_runtime_gate_count"] == 0 + assert payload["summary"]["wazuh_runtime_owner_review_target_selector_count"] == 6 + assert payload["summary"]["wazuh_runtime_owner_review_source_diff_count"] == 1 + assert payload["summary"]["wazuh_runtime_owner_review_check_mode_plan_count"] == 1 + assert payload["summary"]["wazuh_runtime_owner_review_dry_run_evidence_count"] == 1 + assert payload["summary"]["wazuh_runtime_owner_review_rollback_plan_count"] == 1 + assert ( + payload["summary"]["wazuh_runtime_owner_review_post_apply_verifier_count"] == 1 + ) + assert payload["summary"]["wazuh_runtime_owner_review_km_writeback_count"] == 1 + assert payload["summary"]["wazuh_runtime_owner_review_packet_received_count"] == 1 + assert ( + payload["summary"]["wazuh_runtime_owner_review_packet_review_ready_count"] == 1 + ) + assert payload["summary"]["wazuh_runtime_owner_review_packet_accepted_count"] == 1 + assert payload["summary"]["wazuh_runtime_owner_review_runtime_gate_count"] == 0 assert payload["summary"]["kali_active_scan_authorized_count"] == 0 assert payload["summary"]["kali_execute_authorized_count"] == 0 assert payload["summary"]["alert_receipt_runtime_send_count"] == 0 @@ -120,6 +169,7 @@ def test_iwooos_runtime_security_readback_lanes_are_candidate_only() -> None: "wazuh_live_metadata_gate", "wazuh_owner_evidence_preflight", "wazuh_runtime_controlled_apply_preflight", + "wazuh_runtime_gate_owner_review", "wazuh_dashboard_api", "kali_intake", "alert_readability", @@ -152,6 +202,15 @@ def test_iwooos_runtime_security_readback_lanes_are_candidate_only() -> None: or (lane["completion_percent"] == 45 and lane["metrics"]["runtime_gate"] == 0) for lane in payload["lanes"] ) + assert all( + lane["lane_id"] != "wazuh_runtime_gate_owner_review" + or ( + lane["completion_percent"] == 55 + and lane["metrics"]["owner_review_accepted"] == 1 + and lane["metrics"]["runtime_gate"] == 0 + ) + for lane in payload["lanes"] + ) def test_iwooos_runtime_security_readback_api_is_public_safe(monkeypatch) -> None: @@ -178,6 +237,8 @@ def test_iwooos_runtime_security_readback_api_is_public_safe(monkeypatch) -> Non assert data["summary"]["wazuh_owner_evidence_runtime_gate_count"] == 0 assert data["summary"]["wazuh_runtime_apply_preflight_ready_count"] == 1 assert data["summary"]["wazuh_runtime_apply_runtime_gate_count"] == 0 + assert data["summary"]["wazuh_runtime_owner_review_packet_accepted_count"] == 1 + assert data["summary"]["wazuh_runtime_owner_review_runtime_gate_count"] == 0 assert data["boundaries"]["secret_value_collection_allowed"] is False assert "192.168.0." not in response.text assert "工作視窗" not in response.text @@ -463,3 +524,151 @@ def test_iwooos_wazuh_runtime_controlled_apply_preflight_validator_rejects_runti assert data["runtime_action_rejected"] is True assert data["summary"]["controlled_apply_runtime_action_rejected_count"] == 1 assert data["summary"]["runtime_gate_count"] == 0 + + +def test_iwooos_wazuh_runtime_gate_owner_review_readback_api_is_public_safe( + monkeypatch, +) -> None: + monkeypatch.delenv("IWOOOS_WAZUH_READONLY_ENABLED", raising=False) + monkeypatch.delenv("WAZUH_API_BASE_URL", raising=False) + monkeypatch.delenv("WAZUH_API_USERNAME", raising=False) + monkeypatch.delenv("WAZUH_API_PASSWORD", raising=False) + + payload = load_latest_iwooos_wazuh_runtime_gate_owner_review_readback() + assert ( + payload["schema_version"] + == "iwooos_wazuh_runtime_gate_owner_review_readback_v1" + ) + assert ( + payload["status"] + == "runtime_gate_owner_review_packet_committed_no_runtime_action" + ) + assert payload["summary"]["owner_review_packet_accepted_count"] == 1 + assert payload["summary"]["target_selector_count"] == 6 + assert payload["summary"]["runtime_gate_count"] == 0 + assert payload["boundaries"]["runtime_execution_authorized"] is False + + response = _client().get("/api/v1/iwooos/wazuh-runtime-gate-owner-review-readback") + + assert response.status_code == 200 + data = response.json() + assert ( + data["schema_version"] == "iwooos_wazuh_runtime_gate_owner_review_readback_v1" + ) + assert data["summary"]["owner_review_packet_received_count"] == 1 + assert data["summary"]["owner_review_packet_review_ready_count"] == 1 + assert data["summary"]["owner_review_packet_accepted_count"] == 1 + assert data["summary"]["runtime_gate_count"] == 0 + assert data["summary"]["wazuh_api_live_query_authorized_count"] == 0 + assert data["summary"]["wazuh_active_response_authorized_count"] == 0 + assert data["summary"]["host_write_authorized_count"] == 0 + assert data["summary"]["secret_value_collection_allowed_count"] == 0 + assert data["boundaries"]["payload_persisted"] is False + assert data["boundaries"]["wazuh_api_live_query_authorized"] is False + assert data["boundaries"]["wazuh_active_response_authorized"] is False + assert data["boundaries"]["host_write_authorized"] is False + assert data["boundaries"]["runtime_gate_open"] is False + assert data["boundaries"]["not_authorization"] is True + assert len(data["target_selectors"]) == 6 + assert len(data["review_items"]) == 7 + assert any( + marker == "wazuh_runtime_gate_owner_review_validation_api_available=true" + for marker in data["boundary_markers"] + ) + assert "192.168.0." not in response.text + assert "工作視窗" not in response.text + assert "批准!繼續" not in response.text + assert "WAZUH_API_PASSWORD" not in response.text + + +def test_iwooos_wazuh_runtime_gate_owner_review_validator_accepts_redacted_packet( + monkeypatch, +) -> None: + monkeypatch.delenv("IWOOOS_WAZUH_READONLY_ENABLED", raising=False) + monkeypatch.delenv("WAZUH_API_BASE_URL", raising=False) + monkeypatch.delenv("WAZUH_API_USERNAME", raising=False) + monkeypatch.delenv("WAZUH_API_PASSWORD", raising=False) + + client = _client() + before = client.get( + "/api/v1/iwooos/wazuh-runtime-gate-owner-review-readback" + ).json() + response = client.post( + "/api/v1/iwooos/wazuh-runtime-gate-owner-review-readback/validate-owner-review-packet", + json=_valid_runtime_gate_owner_review_packet(), + ) + after = client.get("/api/v1/iwooos/wazuh-runtime-gate-owner-review-readback").json() + + assert response.status_code == 200 + data = response.json() + assert ( + data["schema_version"] + == "iwooos_wazuh_runtime_gate_owner_review_packet_validation_result_v1" + ) + assert data["status"] == "accepted_for_runtime_gate_owner_review_readback_only" + assert data["accepted_for_runtime_gate_owner_review_readback_only"] is True + assert data["summary"]["owner_review_packet_received_count"] == 1 + assert data["summary"]["owner_review_packet_review_ready_count"] == 1 + assert data["summary"]["owner_review_packet_accepted_count"] == 1 + assert data["summary"]["runtime_gate_count"] == 0 + assert data["summary"]["wazuh_api_live_query_authorized_count"] == 0 + assert data["summary"]["wazuh_active_response_authorized_count"] == 0 + assert data["summary"]["host_write_authorized_count"] == 0 + assert data["summary"]["secret_value_collection_allowed_count"] == 0 + assert data["boundaries"]["payload_persisted"] is False + assert data["boundaries"]["runtime_execution_authorized"] is False + assert data["boundaries"]["runtime_gate_open"] is False + assert before["summary"] == after["summary"] + assert "192.168.0." not in response.text + assert "工作視窗" not in response.text + assert "批准!繼續" not in response.text + + +def test_iwooos_wazuh_runtime_gate_owner_review_validator_quarantines_sensitive_payload( + monkeypatch, +) -> None: + monkeypatch.delenv("IWOOOS_WAZUH_READONLY_ENABLED", raising=False) + monkeypatch.delenv("WAZUH_API_BASE_URL", raising=False) + monkeypatch.delenv("WAZUH_API_USERNAME", raising=False) + monkeypatch.delenv("WAZUH_API_PASSWORD", raising=False) + + packet = _valid_runtime_gate_owner_review_packet() + packet[ + "redacted_evidence_ref" + ] = "redacted note includes 10.1.2.3 and Authorization: Bearer abcdefghijklmnop" + response = _client().post( + "/api/v1/iwooos/wazuh-runtime-gate-owner-review-readback/validate-owner-review-packet", + json=packet, + ) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "quarantine_sensitive_payload" + assert data["quarantined"] is True + assert data["summary"]["owner_review_packet_quarantined_count"] == 1 + assert data["summary"]["runtime_gate_count"] == 0 + assert "10.1.2.3" not in response.text + assert "Bearer abcdefghijklmnop" not in response.text + + +def test_iwooos_wazuh_runtime_gate_owner_review_validator_rejects_runtime_action( + monkeypatch, +) -> None: + monkeypatch.delenv("IWOOOS_WAZUH_READONLY_ENABLED", raising=False) + monkeypatch.delenv("WAZUH_API_BASE_URL", raising=False) + monkeypatch.delenv("WAZUH_API_USERNAME", raising=False) + monkeypatch.delenv("WAZUH_API_PASSWORD", raising=False) + + packet = _valid_runtime_gate_owner_review_packet() + packet["wazuh_active_response"] = True + response = _client().post( + "/api/v1/iwooos/wazuh-runtime-gate-owner-review-readback/validate-owner-review-packet", + json=packet, + ) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "reject_runtime_action_request" + assert data["runtime_action_rejected"] is True + assert data["summary"]["owner_review_runtime_action_rejected_count"] == 1 + assert data["summary"]["runtime_gate_count"] == 0 diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 17961173..d4835e76 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -20429,8 +20429,8 @@ }, "runtimeSecurityReadback": { "eyebrow": "IwoooS Runtime 資安讀回", - "title": "十條 P0 資安線先接到同一張讀回板", - "subtitle": "這張板讀取後端彙整的只讀快照,並附上 Wazuh 正式只讀路由與 controlled apply preflight 的公開安全彙總讀回;它不保存原始 Wazuh 載荷、不啟動掃描、不送訊息、不改主機。", + "title": "十一條 P0 資安線先接到同一張讀回板", + "subtitle": "這張板讀取後端彙整的只讀快照,並附上 Wazuh 正式只讀路由、controlled apply preflight 與 owner-review readback 的公開安全彙總;它不保存原始 Wazuh 載荷、不啟動掃描、不送訊息、不改主機。", "statusLabel": "讀回狀態", "statusDetail": "讀回成功只代表 IwoooS 能看見目前的證據邊界;runtime 寫入、主動回應、掃描、重啟、Nginx reload、workflow 修改與機密操作仍全部關閉。", "laneStatusLabel": "目前狀態", @@ -20449,6 +20449,7 @@ "wazuh_live_metadata_gate": "等待即時中繼資料負責人回覆", "wazuh_owner_evidence_preflight": "負責人證據預檢已就緒,尚未授權執行期動作", "wazuh_runtime_controlled_apply_preflight": "受控執行預檢已就緒,執行期閘門仍關閉", + "wazuh_runtime_gate_owner_review": "owner-review packet 已接受為 review readiness,執行期閘門仍關閉", "wazuh_dashboard_api": "API 連線退化,禁止顯示綠燈", "kali_intake": "部分執行期健康已整合,仍待完整驗收", "alert_readability": "格式合約已就緒,尚未有實發收件證據", @@ -20481,6 +20482,10 @@ "label": "受控預檢", "detail": "target selector、diff、check-mode、rollback、verifier 與 writeback 已可審查;runtime 仍為 0。" }, + "ownerReview": { + "label": "Owner review", + "detail": "review packet 已接受為 readback readiness;不代表 live query、active response 或 host write。" + }, "ownerAccepted": { "label": "負責人驗收", "detail": "收到 / 接受都必須由正式 owner response 證明。" @@ -20515,6 +20520,10 @@ "title": "Wazuh 受控執行預檢", "body": "target selector、source-of-truth diff、check-mode / dry-run、rollback、post-apply verifier 與 KM / PlayBook writeback 已成為可審查 packet;它仍不查 live Wazuh、不開 active response、不寫主機。" }, + "wazuh_runtime_gate_owner_review": { + "title": "Wazuh Runtime Gate Owner Review", + "body": "owner-review decision、target selector、diff、check-mode / dry-run evidence、rollback、verifier 與 writeback 已讀回;它只代表 review readiness,不查 live Wazuh、不開 active response、不寫主機。" + }, "wazuh_dashboard_api": { "title": "Wazuh Dashboard API", "body": "API connection / API version 還要補 readback;index pattern 通過不能宣稱 Wazuh 全綠。" diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 17961173..d4835e76 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -20429,8 +20429,8 @@ }, "runtimeSecurityReadback": { "eyebrow": "IwoooS Runtime 資安讀回", - "title": "十條 P0 資安線先接到同一張讀回板", - "subtitle": "這張板讀取後端彙整的只讀快照,並附上 Wazuh 正式只讀路由與 controlled apply preflight 的公開安全彙總讀回;它不保存原始 Wazuh 載荷、不啟動掃描、不送訊息、不改主機。", + "title": "十一條 P0 資安線先接到同一張讀回板", + "subtitle": "這張板讀取後端彙整的只讀快照,並附上 Wazuh 正式只讀路由、controlled apply preflight 與 owner-review readback 的公開安全彙總;它不保存原始 Wazuh 載荷、不啟動掃描、不送訊息、不改主機。", "statusLabel": "讀回狀態", "statusDetail": "讀回成功只代表 IwoooS 能看見目前的證據邊界;runtime 寫入、主動回應、掃描、重啟、Nginx reload、workflow 修改與機密操作仍全部關閉。", "laneStatusLabel": "目前狀態", @@ -20449,6 +20449,7 @@ "wazuh_live_metadata_gate": "等待即時中繼資料負責人回覆", "wazuh_owner_evidence_preflight": "負責人證據預檢已就緒,尚未授權執行期動作", "wazuh_runtime_controlled_apply_preflight": "受控執行預檢已就緒,執行期閘門仍關閉", + "wazuh_runtime_gate_owner_review": "owner-review packet 已接受為 review readiness,執行期閘門仍關閉", "wazuh_dashboard_api": "API 連線退化,禁止顯示綠燈", "kali_intake": "部分執行期健康已整合,仍待完整驗收", "alert_readability": "格式合約已就緒,尚未有實發收件證據", @@ -20481,6 +20482,10 @@ "label": "受控預檢", "detail": "target selector、diff、check-mode、rollback、verifier 與 writeback 已可審查;runtime 仍為 0。" }, + "ownerReview": { + "label": "Owner review", + "detail": "review packet 已接受為 readback readiness;不代表 live query、active response 或 host write。" + }, "ownerAccepted": { "label": "負責人驗收", "detail": "收到 / 接受都必須由正式 owner response 證明。" @@ -20515,6 +20520,10 @@ "title": "Wazuh 受控執行預檢", "body": "target selector、source-of-truth diff、check-mode / dry-run、rollback、post-apply verifier 與 KM / PlayBook writeback 已成為可審查 packet;它仍不查 live Wazuh、不開 active response、不寫主機。" }, + "wazuh_runtime_gate_owner_review": { + "title": "Wazuh Runtime Gate Owner Review", + "body": "owner-review decision、target selector、diff、check-mode / dry-run evidence、rollback、verifier 與 writeback 已讀回;它只代表 review readiness,不查 live Wazuh、不開 active response、不寫主機。" + }, "wazuh_dashboard_api": { "title": "Wazuh Dashboard API", "body": "API connection / API version 還要補 readback;index pattern 通過不能宣稱 Wazuh 全綠。" diff --git a/apps/web/src/app/[locale]/iwooos/page.tsx b/apps/web/src/app/[locale]/iwooos/page.tsx index e047830e..f90ca79c 100644 --- a/apps/web/src/app/[locale]/iwooos/page.tsx +++ b/apps/web/src/app/[locale]/iwooos/page.tsx @@ -347,6 +347,7 @@ type RuntimeSecurityReadbackSummaryItem = { | 'wazuhLive' | 'metadataGate' | 'controlledApplyPreflight' + | 'ownerReview' | 'ownerAccepted' | 'kaliRuntime' | 'runtimeGate' @@ -8244,6 +8245,7 @@ const runtimeSecurityLaneStatusKeys = new Set 0 ? 'steady' : 'locked', }, + { + key: 'ownerReview', + value: summary ? String(summary.wazuh_runtime_owner_review_packet_accepted_count) : '...', + icon: ClipboardCheck, + tone: summary && summary.wazuh_runtime_owner_review_packet_accepted_count > 0 ? 'steady' : 'locked', + }, { key: 'ownerAccepted', value: summary ? `${summary.owner_response_accepted_count}/${summary.owner_response_received_count}` : '...', diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index 0a47f57e..769d57ab 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -105,6 +105,7 @@ export interface IwoooSRuntimeSecurityReadbackLane { | 'wazuh_live_metadata_gate' | 'wazuh_owner_evidence_preflight' | 'wazuh_runtime_controlled_apply_preflight' + | 'wazuh_runtime_gate_owner_review' | 'wazuh_dashboard_api' | 'kali_intake' | 'alert_readability' @@ -171,6 +172,17 @@ export interface IwoooSRuntimeSecurityReadbackResponse { wazuh_runtime_apply_km_writeback_count: number wazuh_runtime_apply_owner_review_ready_count: number wazuh_runtime_apply_runtime_gate_count: number + wazuh_runtime_owner_review_target_selector_count: number + wazuh_runtime_owner_review_source_diff_count: number + wazuh_runtime_owner_review_check_mode_plan_count: number + wazuh_runtime_owner_review_dry_run_evidence_count: number + wazuh_runtime_owner_review_rollback_plan_count: number + wazuh_runtime_owner_review_post_apply_verifier_count: number + wazuh_runtime_owner_review_km_writeback_count: number + wazuh_runtime_owner_review_packet_received_count: number + wazuh_runtime_owner_review_packet_review_ready_count: number + wazuh_runtime_owner_review_packet_accepted_count: number + wazuh_runtime_owner_review_runtime_gate_count: number kali_active_scan_authorized_count: number kali_execute_authorized_count: number kali_finding_envelope_accepted_count: number @@ -343,6 +355,71 @@ export interface IwoooSWazuhRuntimeControlledApplyPreflightResponse { no_false_green_rules: string[] } +export interface IwoooSWazuhRuntimeGateOwnerReviewItem { + item_id: + | 'owner_decision' + | 'target_selector' + | 'source_of_truth_diff' + | 'check_mode_dry_run' + | 'rollback_and_maintenance' + | 'post_apply_verifier' + | 'learning_writeback' + title: string + state_key: string + accepted: boolean + required_fields: string[] + next_gate: string +} + +export interface IwoooSWazuhRuntimeGateOwnerReviewReadbackResponse { + schema_version: 'iwooos_wazuh_runtime_gate_owner_review_readback_v1' + source_schema_version: 'wazuh_runtime_gate_owner_review_readback_v1' + status: string + mode: string + source_refs: string[] + owner_review_packet_validation_endpoint: string + owner_review_packet_validation_mode: string + summary: { + expected_scope_alias_count: number + target_selector_count: number + source_of_truth_diff_count: number + check_mode_plan_count: number + dry_run_evidence_count: number + rollback_plan_count: number + post_apply_verifier_count: number + km_playbook_writeback_count: number + maintenance_window_review_count: number + owner_review_packet_received_count: number + owner_review_packet_review_ready_count: number + owner_review_packet_accepted_count: number + owner_review_packet_supplement_required_count: number + owner_review_packet_quarantined_count: number + owner_review_runtime_action_rejected_count: number + forbidden_payload_count: number + forbidden_action_count: number + runtime_gate_count: number + wazuh_api_live_query_authorized_count: number + wazuh_active_response_authorized_count: number + host_write_authorized_count: number + secret_value_collection_allowed_count: number + } + target_selectors: Array<{ + node_alias: string + scope: string + selector_kind: string + runtime_write_allowed: boolean + owner_review_scope: string + }> + required_owner_review_fields: string[] + review_items: IwoooSWazuhRuntimeGateOwnerReviewItem[] + outcome_lanes: string[] + forbidden_payloads: string[] + forbidden_actions: string[] + boundary_markers: string[] + boundaries: Record + no_false_green_rules: string[] +} + export interface IwoooSWazuhManagedHostCoverageHost { node_id: string role: string @@ -701,6 +778,11 @@ export const apiClient = { return handleResponse(res) }, + async getIwoooSWazuhRuntimeGateOwnerReviewReadback() { + const res = await fetch(`${API_BASE_URL}/iwooos/wazuh-runtime-gate-owner-review-readback`, { cache: 'no-store' }) + return handleResponse(res) + }, + async getIwoooSWazuhManagedHostCoverage() { const res = await fetch(`${API_BASE_URL}/iwooos/wazuh-managed-host-coverage`, { cache: 'no-store' }) return handleResponse(res) diff --git a/docs/security/wazuh-runtime-gate-owner-review-readback.snapshot.json b/docs/security/wazuh-runtime-gate-owner-review-readback.snapshot.json new file mode 100644 index 00000000..84e30c6f --- /dev/null +++ b/docs/security/wazuh-runtime-gate-owner-review-readback.snapshot.json @@ -0,0 +1,250 @@ +{ + "schema_version": "wazuh_runtime_gate_owner_review_readback_v1", + "generated_at": "2026-06-28T11:05:00+08:00", + "status": "runtime_gate_owner_review_packet_committed_no_runtime_action", + "mode": "committed_owner_review_readback_no_live_wazuh_no_secret_collection", + "summary": { + "expected_scope_alias_count": 6, + "target_selector_count": 6, + "source_of_truth_diff_count": 1, + "check_mode_plan_count": 1, + "dry_run_evidence_count": 1, + "rollback_plan_count": 1, + "post_apply_verifier_count": 1, + "km_playbook_writeback_count": 1, + "maintenance_window_review_count": 1, + "owner_review_packet_received_count": 1, + "owner_review_packet_review_ready_count": 1, + "owner_review_packet_accepted_count": 1, + "owner_review_packet_supplement_required_count": 0, + "owner_review_packet_quarantined_count": 0, + "owner_review_runtime_action_rejected_count": 0, + "forbidden_payload_count": 18, + "forbidden_action_count": 20, + "runtime_gate_count": 0, + "wazuh_api_live_query_authorized_count": 0, + "wazuh_active_response_authorized_count": 0, + "host_write_authorized_count": 0, + "secret_value_collection_allowed_count": 0 + }, + "target_selectors": [ + { + "node_alias": "managed_core_node_a", + "scope": "wazuh_manager_registry_accepted_alias", + "selector_kind": "public_alias_only", + "runtime_write_allowed": false, + "owner_review_scope": "runtime_gate_review_only" + }, + { + "node_alias": "managed_core_node_b", + "scope": "wazuh_manager_registry_accepted_alias", + "selector_kind": "public_alias_only", + "runtime_write_allowed": false, + "owner_review_scope": "runtime_gate_review_only" + }, + { + "node_alias": "managed_core_node_c", + "scope": "wazuh_manager_registry_accepted_alias", + "selector_kind": "public_alias_only", + "runtime_write_allowed": false, + "owner_review_scope": "runtime_gate_review_only" + }, + { + "node_alias": "managed_edge_node_a", + "scope": "wazuh_manager_registry_accepted_alias", + "selector_kind": "public_alias_only", + "runtime_write_allowed": false, + "owner_review_scope": "runtime_gate_review_only" + }, + { + "node_alias": "managed_edge_node_b", + "scope": "wazuh_manager_registry_accepted_alias", + "selector_kind": "public_alias_only", + "runtime_write_allowed": false, + "owner_review_scope": "runtime_gate_review_only" + }, + { + "node_alias": "managed_lab_node_a", + "scope": "wazuh_manager_registry_accepted_alias", + "selector_kind": "public_alias_only", + "runtime_write_allowed": false, + "owner_review_scope": "runtime_gate_review_only" + } + ], + "required_owner_review_fields": [ + "owner_review_intent", + "owner_reviewer_role", + "owner_review_decision", + "owner_review_decision_reason", + "target_selector_aliases", + "source_of_truth_diff_ref", + "check_mode_plan_ref", + "dry_run_evidence_ref", + "blast_radius_statement", + "maintenance_window_ref", + "rollback_plan_ref", + "rollback_owner", + "post_apply_verifier_ref", + "km_playbook_writeback_ref", + "followup_owner", + "audit_receipt_ref", + "runtime_boundary_ack", + "secret_boundary_ack", + "live_wazuh_query_boundary_ack" + ], + "review_items": [ + { + "item_id": "owner_decision", + "title": "Owner review decision for runtime-gate readiness", + "state_key": "owner_review_decision_committed", + "accepted": true, + "required_fields": [ + "owner_reviewer_role", + "owner_review_decision", + "owner_review_decision_reason" + ], + "next_gate": "staged allowlisted check-mode before any future runtime gate change" + }, + { + "item_id": "target_selector", + "title": "Public alias target selector", + "state_key": "target_selector_reviewed", + "accepted": true, + "required_fields": [ + "target_selector_aliases" + ], + "next_gate": "target selector remains public aliases only" + }, + { + "item_id": "source_of_truth_diff", + "title": "Source-of-truth diff reference", + "state_key": "source_of_truth_diff_reviewed", + "accepted": true, + "required_fields": [ + "source_of_truth_diff_ref" + ], + "next_gate": "diff must be re-read before any future controlled apply" + }, + { + "item_id": "check_mode_dry_run", + "title": "Check-mode and dry-run evidence", + "state_key": "check_mode_dry_run_reviewed", + "accepted": true, + "required_fields": [ + "check_mode_plan_ref", + "dry_run_evidence_ref" + ], + "next_gate": "dry-run evidence stays redacted and no host output is stored" + }, + { + "item_id": "rollback_and_maintenance", + "title": "Rollback and maintenance window", + "state_key": "rollback_maintenance_reviewed", + "accepted": true, + "required_fields": [ + "rollback_plan_ref", + "rollback_owner", + "maintenance_window_ref" + ], + "next_gate": "rollback owner and maintenance window must be revalidated before runtime" + }, + { + "item_id": "post_apply_verifier", + "title": "Post-apply verifier", + "state_key": "post_apply_verifier_reviewed", + "accepted": true, + "required_fields": [ + "post_apply_verifier_ref" + ], + "next_gate": "future runtime action requires production post-apply verifier readback" + }, + { + "item_id": "learning_writeback", + "title": "KM and PlayBook trust writeback", + "state_key": "learning_writeback_reviewed", + "accepted": true, + "required_fields": [ + "km_playbook_writeback_ref", + "audit_receipt_ref" + ], + "next_gate": "writeback receipt must be committed after verifier" + } + ], + "outcome_lanes": [ + "accepted_for_runtime_gate_owner_review_readback_only", + "request_runtime_gate_owner_review_supplement", + "request_runtime_gate_owner_review_decision_fix", + "request_target_selector_fix", + "request_runtime_boundary_ack_fix", + "quarantine_sensitive_payload", + "reject_runtime_action_request" + ], + "forbidden_payloads": [ + "secret_value", + "token_value", + "private_key", + "cookie", + "session", + "authorization_header", + "client.keys", + "raw_wazuh_payload", + "raw_agent_identity", + "raw_hostname", + "internal_ip", + "full_cli_output", + "full_journal", + "raw_dashboard_request", + "unredacted_screenshot", + "private_namespace", + "raw_env_file", + "raw_runtime_volume" + ], + "forbidden_actions": [ + "wazuh_api_live_query", + "wazuh_active_response", + "wazuh_agent_restart", + "wazuh_agent_reenroll", + "wazuh_manager_restart", + "host_write", + "systemd_restart", + "docker_restart", + "nginx_reload", + "firewall_change", + "kali_active_scan", + "credentialed_scan", + "exploit_attempt", + "secret_rotation", + "k8s_apply", + "argocd_sync", + "database_migration", + "force_push", + "repo_ref_delete", + "workflow_trigger" + ], + "execution_boundaries": { + "active_scan_authorized": false, + "alertmanager_reload_authorized": false, + "auto_block_authorized": false, + "credentialed_scan_authorized": false, + "firewall_change_authorized": false, + "host_write_authorized": false, + "kali_execute_authorized": false, + "kali_scan_authorized": false, + "nginx_reload_authorized": false, + "production_write_authorized": false, + "runtime_execution_authorized": false, + "runtime_gate_open": false, + "secret_value_collection_allowed": false, + "telegram_send_authorized": false, + "wazuh_active_response_authorized": false, + "wazuh_api_live_query_authorized": false, + "not_authorization": true + }, + "no_false_green_rules": [ + "Owner review packet accepted does not open runtime gate.", + "Committed owner review readback only records review readiness and does not query live Wazuh.", + "Target selectors are public aliases only and do not authorize host writes.", + "Check-mode and dry-run evidence references do not authorize active response.", + "Maintenance window, rollback, verifier, and writeback readiness must be revalidated before any future runtime action." + ] +}