305 lines
9.4 KiB
Python
Executable File
305 lines
9.4 KiB
Python
Executable File
#!/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())
|