#!/usr/bin/env python3 """Validate post-reboot owner packet JSON stays fail-closed. Read-only by design. The guard validates an owner packet artifact produced by post-reboot-next-gate-owner-packets.py, or runs that generator when no packet file is provided. It never sends requests, reads secrets, writes markers, or modifies host/runtime state. """ from __future__ import annotations import argparse import json 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_packets_v1" KNOWN_GATES = { "credential_escrow_evidence", "host_188_hygiene_maintenance_window", "product_data_freshness_recovery", "stockplatform_eod_window_completion", "wazuh_manager_registry_export", } EXPECTED_NO_FALSE_GREEN_RULES = { "product_route_green_does_not_equal_product_data_green", "service_green_does_not_equal_dr_complete", "backup_fresh_does_not_equal_credential_escrow_complete", "host_188_service_green_does_not_equal_host_hygiene_green", "wazuh_route_or_transport_does_not_equal_manager_registry_accepted", } GLOBAL_ZERO_FIELDS = { ("status", "runtime_action_authorized"): 0, ("status", "dispatch_authorized"): 0, ("status", "request_sent_count"): 0, ("status", "owner_response_received_count"): 0, ("status", "owner_response_accepted_count"): 0, ("status", "host_write_authorized"): 0, ("status", "secret_value_collection_allowed"): 0, ("status", "runtime_gate_count"): 0, ("counts", "request_sent_count"): 0, ("counts", "owner_response_received_count"): 0, ("counts", "owner_response_accepted_count"): 0, ("counts", "runtime_action_authorized_count"): 0, } GATE_REQUIRED_FORBIDDEN_PAYLOADS = { "credential_escrow_evidence": { "password", "token", "secret_value", "hash", "prefix", "suffix", "raw_credential", }, "wazuh_manager_registry_export": { "agent_real_name", "internal_ip", "client_keys", "raw_wazuh_payload", "token", "password", "authorization_header", }, "product_data_freshness_recovery": { "raw_db_dump", "secret_value", "api_token", "manual_fake_row", "handwritten_freshness_override", }, "stockplatform_eod_window_completion": { "raw_db_dump", "secret_value", "api_token", "manual_fake_row", "handwritten_freshness_override", }, } GATE_REQUIRED_FORBIDDEN_ACTIONS = { "credential_escrow_evidence": { "mark_placeholder", "write_fake_marker", "store_secret", "disable_alert", }, "host_188_hygiene_maintenance_window": { "pg_resetwal", "certbot_renew", "nginx_reload", "db_restore", "docker_restart", "host_file_write", }, "wazuh_manager_registry_export": { "active_response", "agent_reenroll", "wazuh_restart", "secret_patch", "host_write", "kali_active_scan", }, "product_data_freshness_recovery": { "manual_db_update", "truncate_restore", "fake_freshness_marker", "skip_data_gate", "declare_product_data_green_before_status_ok", }, "stockplatform_eod_window_completion": { "manual_db_update", "truncate_restore", "fake_freshness_marker", "skip_data_gate", "declare_product_data_green_before_status_ok", }, } PACKET_FALSE_FIELDS = ( "request_sent", "response_received", "response_accepted", "runtime_action_authorized", ) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Validate post-reboot owner packet JSON contract.", ) parser.add_argument( "--packet-file", type=Path, help="Validate an existing owner packet JSON file.", ) parser.add_argument( "--no-color", action="store_true", help="Pass --no-color when running the owner packet generator.", ) return parser.parse_args() def run_owner_packet_generator(no_color: bool) -> dict[str, Any]: cmd = [str(OWNER_PACKET_GENERATOR)] 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( "POST_REBOOT_OWNER_PACKET_CONTRACT_GUARD_FAILED " f"generator_rc={completed.returncode}\n{completed.stdout}" ) try: return json.loads(completed.stdout) except json.JSONDecodeError as exc: raise SystemExit( "POST_REBOOT_OWNER_PACKET_CONTRACT_GUARD_FAILED " f"generator_json_invalid={exc}" ) from exc def load_packet(args: argparse.Namespace) -> dict[str, Any]: if args.packet_file: try: return json.loads(args.packet_file.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: raise SystemExit( "POST_REBOOT_OWNER_PACKET_CONTRACT_GUARD_FAILED " f"packet_json_invalid={exc}" ) from exc return run_owner_packet_generator(no_color=args.no_color) def as_list(value: Any) -> list[Any]: if value is None: return [] if isinstance(value, list): return value return [value] def get_nested(packet: dict[str, Any], path: tuple[str, str]) -> Any: parent = packet.get(path[0], {}) if not isinstance(parent, dict): return None return parent.get(path[1]) def validate_packet(packet: dict[str, Any]) -> list[str]: failures: list[str] = [] if packet.get("schema_version") != EXPECTED_SCHEMA: failures.append(f"schema_version={packet.get('schema_version')!r}") owner_packets = as_list(packet.get("owner_packets")) counts = packet.get("counts", {}) if not isinstance(counts, dict): failures.append("counts_not_object") counts = {} gate_ids = {str(item.get("packet_id", "")) for item in owner_packets if isinstance(item, dict)} unknown_gates = sorted(gate_ids - KNOWN_GATES) if unknown_gates: failures.append(f"unknown_gate_ids={unknown_gates}") source = packet.get("source", {}) if not isinstance(source, dict): failures.append("source_not_object") source = {} expected_gates = set(str(item) for item in as_list(source.get("next_required_gates"))) if expected_gates != gate_ids: failures.append(f"gate_ids={sorted(gate_ids)}") expected_counts = { "next_gate_count": len(gate_ids), "p0_gate_count": len(gate_ids), } for key, expected in expected_counts.items(): if counts.get(key) != expected: failures.append(f"{key}={counts.get(key)!r}") for path, expected in GLOBAL_ZERO_FIELDS.items(): actual = get_nested(packet, path) if actual != expected: failures.append(f"{'.'.join(path)}={actual!r}") no_false_green_rules = set( str(item) for item in as_list(packet.get("no_false_green_rules")) ) missing_rules = sorted(EXPECTED_NO_FALSE_GREEN_RULES - no_false_green_rules) if missing_rules: failures.append(f"missing_no_false_green_rules={missing_rules}") for raw_packet in owner_packets: if not isinstance(raw_packet, dict): failures.append("owner_packet_not_object") continue packet_id = str(raw_packet.get("packet_id", "")) if raw_packet.get("priority") != "P0": failures.append(f"{packet_id}.priority={raw_packet.get('priority')!r}") for key in PACKET_FALSE_FIELDS: if raw_packet.get(key) is not False: failures.append(f"{packet_id}.{key}={raw_packet.get(key)!r}") required_payloads = GATE_REQUIRED_FORBIDDEN_PAYLOADS.get(packet_id, set()) actual_payloads = set(str(item) for item in as_list(raw_packet.get("forbidden_payloads"))) missing_payloads = sorted(required_payloads - actual_payloads) if missing_payloads: failures.append(f"{packet_id}.missing_forbidden_payloads={missing_payloads}") required_actions = GATE_REQUIRED_FORBIDDEN_ACTIONS.get(packet_id, set()) actual_actions = set(str(item) for item in as_list(raw_packet.get("forbidden_actions"))) missing_actions = sorted(required_actions - actual_actions) if missing_actions: failures.append(f"{packet_id}.missing_forbidden_actions={missing_actions}") return failures def main() -> int: args = parse_args() packet = load_packet(args) failures = validate_packet(packet) if failures: print( "POST_REBOOT_OWNER_PACKET_CONTRACT_GUARD_FAILED " + " ".join(failures) ) return 1 counts = packet.get("counts", {}) status = packet.get("status", {}) print( "POST_REBOOT_OWNER_PACKET_CONTRACT_GUARD_OK " f"gates={counts.get('next_gate_count')} " f"request_sent={counts.get('request_sent_count')} " f"accepted={counts.get('owner_response_accepted_count')} " f"runtime_gate={status.get('runtime_gate_count')}" ) return 0 if __name__ == "__main__": sys.exit(main())