#!/usr/bin/env python3 """Preflight owner responses for post-reboot next gates. Read-only by design. This script validates an owner response JSON file against the current post-reboot owner packets. It never sends requests, reads secrets, writes credential markers, or modifies host/runtime state. """ from __future__ import annotations import argparse import json import re import subprocess import sys from pathlib import Path from typing import Any ROOT = Path(__file__).resolve().parents[2] OWNER_PACKET_GENERATOR = ( ROOT / "scripts" / "reboot-recovery" / "post-reboot-next-gate-owner-packets.py" ) EXPECTED_SCHEMA = "awoooi_post_reboot_next_gate_owner_response_v1" EXPECTED_OWNER_PACKET_SCHEMA = "awoooi_post_reboot_next_gate_owner_packets_v1" PLACEHOLDER_VALUES = { "", "pending", "todo", "tbd", "n/a", "na", "owner_role_here", "owner_team_here", "decision_reason_here", "redacted_evidence_ref_here", "non_secret_evidence_ref_here", "registry_export_ref_here", "followup_owner_here", "reviewer_here", } ESCROW_ITEM_IDS = { "restic_repository_password", "offsite_provider_credentials", "break_glass_admin_credentials", "dns_registrar_recovery", "oauth_ai_provider_recovery", } EXPECTED_HOST_ALIASES = { "core-110", "gateway-188", "k3s-control-120", "k3s-control-121", "security-observer-112", "dev-workstation-111", } FORBIDDEN_BOOLEAN_FIELDS = { "runtime_action_requested", "runtime_action_authorized", "host_write_requested", "host_write_authorized", "secret_value_included", "secret_value_collection_allowed", "credential_marker_write_requested", "credential_marker_write_authorized", "wazuh_active_response_requested", "wazuh_active_response_authorized", "agent_reenroll_requested", "wazuh_restart_requested", "kali_active_scan_requested", } SECRET_VALUE_PATTERNS = [ re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"), re.compile(r"\bBearer\s+[A-Za-z0-9._~+/=-]{12,}", re.IGNORECASE), re.compile(r"\bAuthorization\s*:\s*", re.IGNORECASE), re.compile(r"\bgh[pousr]_[A-Za-z0-9_]{20,}"), re.compile(r"\bsk-[A-Za-z0-9]{20,}"), re.compile(r"\bAIza[0-9A-Za-z_-]{20,}"), re.compile(r"\b[0-9]{8,10}:[A-Za-z0-9_-]{20,}\b"), re.compile(r"\b(password|token|secret)\s*[:=]\s*[^,\s]+", re.IGNORECASE), re.compile(r"\bclient\.keys\b", re.IGNORECASE), ] def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Validate post-reboot owner response evidence without opening runtime gates.", ) parser.add_argument("--response-file", type=Path, help="Owner response JSON to validate.") parser.add_argument( "--owner-packet-file", type=Path, help="Use an existing owner packet JSON instead of generating one.", ) parser.add_argument( "--summary-file", type=Path, help="Generate owner packets from an existing readiness summary file.", ) parser.add_argument("--json", action="store_true", help="Print machine-readable JSON.") parser.add_argument( "--no-color", action="store_true", help="Pass --no-color when generating owner packets.", ) return parser.parse_args() def load_json(path: Path, label: str = "response_file") -> dict[str, Any]: try: payload = json.loads(path.read_text(encoding="utf-8")) except FileNotFoundError as exc: raise SystemExit(f"{label}_not_found={path}") from exc except json.JSONDecodeError as exc: raise SystemExit(f"{label}_json_invalid={exc}") from exc if not isinstance(payload, dict): raise SystemExit(f"{label}_json_not_object") return payload def generate_owner_packet(no_color: bool, summary_file: Path | None) -> dict[str, Any]: cmd = [str(OWNER_PACKET_GENERATOR)] if summary_file: cmd.extend(["--summary-file", str(summary_file)]) if no_color: cmd.append("--no-color") completed = subprocess.run( cmd, cwd=ROOT, check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, ) if completed.returncode != 0: raise SystemExit( "owner_packet_generation_failed " f"rc={completed.returncode}\n{completed.stdout}" ) try: packet = json.loads(completed.stdout) except json.JSONDecodeError as exc: raise SystemExit(f"owner_packet_json_invalid={exc}") from exc if not isinstance(packet, dict): raise SystemExit("owner_packet_json_not_object") return packet def load_owner_packet(args: argparse.Namespace) -> dict[str, Any]: if args.owner_packet_file: return load_json(args.owner_packet_file, label="owner_packet_file") return generate_owner_packet(no_color=args.no_color, summary_file=args.summary_file) def as_list(value: Any) -> list[Any]: if value is None: return [] if isinstance(value, list): return value return [value] def normalized(value: Any) -> str: if value is None: return "" return str(value).strip() def is_placeholder(value: Any) -> bool: text = normalized(value) lower = text.lower() if lower in PLACEHOLDER_VALUES: return True if re.fullmatch(r"<[^<>]+>", text): return True if lower in {"yyyy-mm-dd", "yyyy/mm/dd"}: return True return lower.startswith( ( "vault-item-id-for-", "sealed-envelope-id-for-", "recovery-checklist-id-for-", "ticket-id-for-", ) ) def collect_strings(value: Any, path: str = "$") -> list[tuple[str, str]]: strings: list[tuple[str, str]] = [] if isinstance(value, str): strings.append((path, value)) elif isinstance(value, dict): for key, child in value.items(): strings.extend(collect_strings(child, f"{path}.{key}")) elif isinstance(value, list): for index, child in enumerate(value): strings.extend(collect_strings(child, f"{path}[{index}]")) return strings def find_forbidden_strings(response: dict[str, Any]) -> list[str]: failures: list[str] = [] for path, value in collect_strings(response): if path.endswith(".item_id") and value in ESCROW_ITEM_IDS: continue if path.endswith(".gate_id") and value in { "credential_escrow_evidence", "wazuh_manager_registry_export", "host_188_hygiene_maintenance_window", }: continue for pattern in SECRET_VALUE_PATTERNS: if pattern.search(value): failures.append(f"forbidden_payload_at={path}") break return failures def find_forbidden_booleans(value: Any, path: str = "$") -> list[str]: failures: list[str] = [] if isinstance(value, dict): for key, child in value.items(): child_path = f"{path}.{key}" if key in FORBIDDEN_BOOLEAN_FIELDS and child is not False: failures.append(f"{child_path}={child!r}") failures.extend(find_forbidden_booleans(child, child_path)) elif isinstance(value, list): for index, child in enumerate(value): failures.extend(find_forbidden_booleans(child, f"{path}[{index}]")) return failures def owner_packet_gate_ids(packet: dict[str, Any]) -> set[str]: if packet.get("schema_version") != EXPECTED_OWNER_PACKET_SCHEMA: raise SystemExit(f"owner_packet_schema={packet.get('schema_version')!r}") return { str(item.get("packet_id")) for item in as_list(packet.get("owner_packets")) if isinstance(item, dict) and item.get("packet_id") } def response_by_gate(response: dict[str, Any]) -> dict[str, dict[str, Any]]: responses = as_list(response.get("responses")) by_gate: dict[str, dict[str, Any]] = {} for item in responses: if not isinstance(item, dict): continue gate_id = normalized(item.get("gate_id")) if gate_id: by_gate[gate_id] = item return by_gate def validate_common(gate_id: str, item: dict[str, Any]) -> list[str]: failures: list[str] = [] for key in ( "owner_role", "owner_team", "decision", "decision_reason", "affected_scope", "followup_owner", ): if is_placeholder(item.get(key)): failures.append(f"{gate_id}.{key}_missing") decision = normalized(item.get("decision")).lower() if decision not in {"accepted", "rejected", "needs_supplement"}: failures.append(f"{gate_id}.decision_invalid={decision!r}") evidence_refs = [ ref for ref in as_list(item.get("redacted_evidence_refs")) if not is_placeholder(ref) ] if not evidence_refs: failures.append(f"{gate_id}.redacted_evidence_refs_missing") return failures def validate_credential_escrow(item: dict[str, Any]) -> list[str]: failures: list[str] = [] escrow_items = as_list(item.get("escrow_items")) seen = { normalized(entry.get("item_id")) for entry in escrow_items if isinstance(entry, dict) } missing = sorted(ESCROW_ITEM_IDS - seen) if missing: failures.append(f"credential_escrow_evidence.missing_items={missing}") for entry in escrow_items: if not isinstance(entry, dict): failures.append("credential_escrow_evidence.escrow_item_not_object") continue item_id = normalized(entry.get("item_id")) if item_id not in ESCROW_ITEM_IDS: failures.append(f"credential_escrow_evidence.unknown_item={item_id!r}") for key in ("non_secret_evidence_ref", "recovery_owner", "reviewer", "last_reviewed_at"): if is_placeholder(entry.get(key)): failures.append(f"credential_escrow_evidence.{item_id}.{key}_missing") if entry.get("contains_secret_value") is not False: failures.append(f"credential_escrow_evidence.{item_id}.contains_secret_value_not_false") return failures def validate_wazuh_registry(item: dict[str, Any]) -> list[str]: failures: list[str] = [] for key in ( "registry_export_ref", "registry_time_window", "dashboard_api_connection_status", "dashboard_api_version_status", "reviewer", ): if is_placeholder(item.get(key)): failures.append(f"wazuh_manager_registry_export.{key}_missing") aliases = {normalized(alias) for alias in as_list(item.get("expected_host_aliases"))} missing_aliases = sorted(EXPECTED_HOST_ALIASES - aliases) if missing_aliases: failures.append(f"wazuh_manager_registry_export.missing_aliases={missing_aliases}") if not isinstance(item.get("manager_registry_count"), int): failures.append("wazuh_manager_registry_export.manager_registry_count_not_int") if normalized(item.get("dashboard_api_connection_status")).lower() != "ok": failures.append("wazuh_manager_registry_export.dashboard_api_connection_not_ok") if normalized(item.get("dashboard_api_version_status")).lower() != "ok": failures.append("wazuh_manager_registry_export.dashboard_api_version_not_ok") return failures def evaluate(packet: dict[str, Any], response: dict[str, Any] | None) -> dict[str, Any]: expected_gates = owner_packet_gate_ids(packet) result: dict[str, Any] = { "schema_version": "awoooi_post_reboot_owner_response_preflight_v1", "expected_gate_count": len(expected_gates), "expected_gates": sorted(expected_gates), "owner_response_received_count": 0, "owner_response_accepted_count": 0, "runtime_gate_count": 0, "runtime_action_authorized": False, "host_write_authorized": False, "secret_value_collection_allowed": False, "status": "blocked_waiting_owner_response_file", "blockers": [], } if response is None: result["blockers"] = ["owner_response_file_missing"] return result failures: list[str] = [] if response.get("schema_version") != EXPECTED_SCHEMA: failures.append(f"schema_version={response.get('schema_version')!r}") failures.extend(find_forbidden_strings(response)) failures.extend(find_forbidden_booleans(response)) by_gate = response_by_gate(response) gate_ids = set(by_gate) unknown_gates = sorted(gate_ids - expected_gates) missing_gates = sorted(expected_gates - gate_ids) if unknown_gates: failures.append(f"unknown_gate_ids={unknown_gates}") if missing_gates: failures.append(f"missing_gate_responses={missing_gates}") received = 0 accepted = 0 for gate_id in sorted(expected_gates & gate_ids): item = by_gate[gate_id] gate_failures = validate_common(gate_id, item) if gate_id == "credential_escrow_evidence": gate_failures.extend(validate_credential_escrow(item)) elif gate_id == "wazuh_manager_registry_export": gate_failures.extend(validate_wazuh_registry(item)) else: gate_failures.append(f"{gate_id}.unsupported_for_response_preflight") if gate_failures: failures.extend(gate_failures) else: received += 1 if normalized(item.get("decision")).lower() == "accepted": accepted += 1 result["owner_response_received_count"] = received result["owner_response_accepted_count"] = accepted result["blockers"] = failures if failures: result["status"] = "blocked_waiting_owner_response_content" elif accepted == len(expected_gates): result["status"] = "ready_for_independent_reviewer_acceptance" else: result["status"] = "blocked_waiting_owner_acceptance" return result def main() -> int: args = parse_args() packet = load_owner_packet(args) response = load_json(args.response_file, label="response_file") if args.response_file else None result = evaluate(packet, response) if args.json: print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True)) else: prefix = ( "POST_REBOOT_OWNER_RESPONSE_PREFLIGHT_OK" if result["status"] == "ready_for_independent_reviewer_acceptance" else "POST_REBOOT_OWNER_RESPONSE_PREFLIGHT_BLOCKED" ) print( f"{prefix} status={result['status']} " f"expected_gates={result['expected_gate_count']} " f"received={result['owner_response_received_count']} " f"accepted={result['owner_response_accepted_count']} " f"runtime_gate={result['runtime_gate_count']} " f"blockers={len(result['blockers'])}" ) return 0 if __name__ == "__main__": sys.exit(main())