366 lines
12 KiB
Python
Executable File
366 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Guard post-reboot recovery declarations against false green claims.
|
|
|
|
Read-only by design. It consumes post-reboot-readiness-summary.sh output, or
|
|
runs that summary when no file is provided. It never repairs services, writes
|
|
markers, sends owner requests, or changes host/runtime state.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
SUMMARY_SCRIPT = ROOT / "scripts" / "reboot-recovery" / "post-reboot-readiness-summary.sh"
|
|
|
|
SCHEMA_VERSION = "awoooi_post_reboot_declaration_guard_v1"
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="Validate what may be declared after reboot recovery.",
|
|
)
|
|
parser.add_argument(
|
|
"--summary-file",
|
|
type=Path,
|
|
help="Read an existing post-reboot readiness summary key/value file.",
|
|
)
|
|
parser.add_argument(
|
|
"--proposed",
|
|
action="append",
|
|
default=[],
|
|
help="Optional declaration to validate. Repeatable.",
|
|
)
|
|
parser.add_argument(
|
|
"--json",
|
|
action="store_true",
|
|
help="Print JSON instead of a compact status line.",
|
|
)
|
|
parser.add_argument(
|
|
"--output",
|
|
type=Path,
|
|
help="Write JSON payload to this path.",
|
|
)
|
|
parser.add_argument(
|
|
"--no-color",
|
|
action="store_true",
|
|
help="Pass --no-color when running the readiness summary.",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def run_summary(no_color: bool) -> str:
|
|
cmd = [str(SUMMARY_SCRIPT)]
|
|
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 not in (0, 2):
|
|
raise SystemExit(
|
|
"POST_REBOOT_DECLARATION_GUARD_FAILED "
|
|
f"summary_rc={completed.returncode}\n{completed.stdout}"
|
|
)
|
|
return completed.stdout
|
|
|
|
|
|
def load_summary(args: argparse.Namespace) -> str:
|
|
if args.summary_file:
|
|
return args.summary_file.read_text(encoding="utf-8")
|
|
return run_summary(no_color=args.no_color)
|
|
|
|
|
|
def parse_summary(text: str) -> dict[str, str]:
|
|
values: dict[str, str] = {}
|
|
for raw_line in text.splitlines():
|
|
line = raw_line.strip()
|
|
if not line or "=" not in line:
|
|
continue
|
|
key, value = line.split("=", 1)
|
|
values[key.strip()] = value.strip()
|
|
return values
|
|
|
|
|
|
def truthy(value: str | None) -> bool:
|
|
return value in {"1", "true", "True", "yes", "YES"}
|
|
|
|
|
|
def nonzero(value: str | None) -> bool:
|
|
if value is None or value in {"", "unknown"}:
|
|
return False
|
|
try:
|
|
return int(value) != 0
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def split_csv(value: str | None) -> list[str]:
|
|
if not value or value == "none":
|
|
return []
|
|
return [item.strip() for item in value.split(",") if item.strip()]
|
|
|
|
|
|
def build_payload(summary: dict[str, str], proposed: list[str]) -> dict[str, Any]:
|
|
allowed: list[str] = []
|
|
forbidden: list[dict[str, str]] = []
|
|
|
|
service_green = truthy(summary.get("SERVICE_GREEN"))
|
|
product_data_green = truthy(summary.get("PRODUCT_DATA_GREEN"))
|
|
backup_core_green = truthy(summary.get("BACKUP_CORE_GREEN"))
|
|
dr_escrow_blocked = truthy(summary.get("DR_ESCROW_BLOCKED")) or nonzero(
|
|
summary.get("ESCROW_MISSING_COUNT")
|
|
)
|
|
host_188_hygiene_blocked = truthy(summary.get("HOST_188_HYGIENE_BLOCKED"))
|
|
wazuh_registry_accepted = truthy(summary.get("WAZUH_MANAGER_REGISTRY_ACCEPTED"))
|
|
runtime_authorized = truthy(summary.get("RUNTIME_ACTION_AUTHORIZED"))
|
|
next_required_gates = split_csv(summary.get("NEXT_REQUIRED_GATES"))
|
|
overall_declaration = summary.get("OVERALL_DECLARATION", "unknown")
|
|
|
|
if service_green:
|
|
allowed.append("SERVICE_RECOVERY_GREEN")
|
|
else:
|
|
forbidden.append(
|
|
{
|
|
"declaration": "SERVICE_RECOVERY_GREEN",
|
|
"reason": "service_green_not_1",
|
|
}
|
|
)
|
|
|
|
if product_data_green:
|
|
allowed.append("PRODUCT_DATA_GREEN")
|
|
else:
|
|
forbidden.append(
|
|
{
|
|
"declaration": "PRODUCT_DATA_GREEN",
|
|
"reason": "product_data_green_not_1",
|
|
}
|
|
)
|
|
|
|
if backup_core_green:
|
|
allowed.append("BACKUP_CORE_GREEN")
|
|
else:
|
|
forbidden.append(
|
|
{
|
|
"declaration": "BACKUP_CORE_GREEN",
|
|
"reason": "backup_core_green_not_1",
|
|
}
|
|
)
|
|
|
|
if overall_declaration != "unknown":
|
|
allowed.append(overall_declaration)
|
|
|
|
if (
|
|
service_green
|
|
and product_data_green
|
|
and backup_core_green
|
|
and not dr_escrow_blocked
|
|
and not host_188_hygiene_blocked
|
|
and wazuh_registry_accepted
|
|
and not runtime_authorized
|
|
):
|
|
allowed.append("FULL_STACK_GREEN")
|
|
else:
|
|
full_stack_reasons: list[str] = []
|
|
if not service_green:
|
|
full_stack_reasons.append("service_green_not_1")
|
|
if not product_data_green:
|
|
full_stack_reasons.append("product_data_green_not_1")
|
|
if not backup_core_green:
|
|
full_stack_reasons.append("backup_core_green_not_1")
|
|
if dr_escrow_blocked:
|
|
full_stack_reasons.append(
|
|
f"escrow_missing_count:{summary.get('ESCROW_MISSING_COUNT', 'unknown')}"
|
|
)
|
|
if host_188_hygiene_blocked:
|
|
full_stack_reasons.append("host_188_hygiene_blocked:1")
|
|
if not wazuh_registry_accepted:
|
|
full_stack_reasons.append("wazuh_manager_registry_accepted:0")
|
|
if runtime_authorized:
|
|
full_stack_reasons.append("runtime_action_authorized:1")
|
|
forbidden.append(
|
|
{
|
|
"declaration": "FULL_STACK_GREEN",
|
|
"reason": ",".join(full_stack_reasons) or "unknown",
|
|
}
|
|
)
|
|
|
|
if dr_escrow_blocked:
|
|
forbidden.append(
|
|
{
|
|
"declaration": "DR_COMPLETE",
|
|
"reason": f"escrow_missing_count:{summary.get('ESCROW_MISSING_COUNT', 'unknown')}",
|
|
}
|
|
)
|
|
else:
|
|
allowed.append("DR_COMPLETE")
|
|
|
|
if host_188_hygiene_blocked:
|
|
forbidden.append(
|
|
{
|
|
"declaration": "HOST_188_FULLY_GREEN",
|
|
"reason": "host_188_hygiene_blocked:1",
|
|
}
|
|
)
|
|
else:
|
|
allowed.append("HOST_188_FULLY_GREEN")
|
|
|
|
if not wazuh_registry_accepted:
|
|
forbidden.append(
|
|
{
|
|
"declaration": "WAZUH_REGISTRY_RECOVERED",
|
|
"reason": "wazuh_manager_registry_accepted:0",
|
|
}
|
|
)
|
|
else:
|
|
allowed.append("WAZUH_REGISTRY_RECOVERED")
|
|
|
|
if not runtime_authorized:
|
|
forbidden.append(
|
|
{
|
|
"declaration": "RUNTIME_ACTION_AUTHORIZED",
|
|
"reason": "runtime_action_authorized:0",
|
|
}
|
|
)
|
|
else:
|
|
allowed.append("RUNTIME_ACTION_AUTHORIZED")
|
|
|
|
allowed_set = set(allowed)
|
|
forbidden_map = {item["declaration"]: item["reason"] for item in forbidden}
|
|
rejected_proposed = [
|
|
{"declaration": item, "reason": forbidden_map[item]}
|
|
for item in proposed
|
|
if item in forbidden_map
|
|
]
|
|
accepted_proposed = [item for item in proposed if item in allowed_set]
|
|
unknown_proposed = [
|
|
item
|
|
for item in proposed
|
|
if item not in allowed_set and item not in forbidden_map
|
|
]
|
|
|
|
if not service_green:
|
|
status = "blocked_service_recovery"
|
|
elif not product_data_green:
|
|
status = "blocked_product_data_recovery"
|
|
else:
|
|
status = "allowed_with_boundary_blockers"
|
|
if rejected_proposed:
|
|
status = "blocked_false_green_proposal"
|
|
|
|
return {
|
|
"schema_version": SCHEMA_VERSION,
|
|
"generated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
"source": {
|
|
"summary_script": str(SUMMARY_SCRIPT.relative_to(ROOT)),
|
|
"artifact_dir": summary.get("ARTIFACT_DIR", "unknown"),
|
|
"overall_declaration": overall_declaration,
|
|
},
|
|
"status": status,
|
|
"allowed_declarations": sorted(set(allowed)),
|
|
"forbidden_declarations": forbidden,
|
|
"next_required_gates": next_required_gates,
|
|
"counts": {
|
|
"allowed_count": len(set(allowed)),
|
|
"forbidden_count": len(forbidden),
|
|
"next_required_gate_count": len(next_required_gates),
|
|
"proposed_count": len(proposed),
|
|
"accepted_proposed_count": len(accepted_proposed),
|
|
"rejected_proposed_count": len(rejected_proposed),
|
|
"unknown_proposed_count": len(unknown_proposed),
|
|
},
|
|
"proposed": {
|
|
"accepted": accepted_proposed,
|
|
"rejected": rejected_proposed,
|
|
"unknown": unknown_proposed,
|
|
},
|
|
"evidence": {
|
|
"service_green": summary.get("SERVICE_GREEN", "unknown"),
|
|
"product_data_green": summary.get("PRODUCT_DATA_GREEN", "unknown"),
|
|
"stock_freshness_status": summary.get("STOCK_FRESHNESS_STATUS", "unknown"),
|
|
"stock_latest_trading_date": summary.get(
|
|
"STOCK_LATEST_TRADING_DATE",
|
|
"unknown",
|
|
),
|
|
"stock_blockers": summary.get("STOCK_BLOCKERS", "unknown"),
|
|
"stock_eod_window_pending": summary.get(
|
|
"STOCK_EOD_WINDOW_PENDING",
|
|
"unknown",
|
|
),
|
|
"stock_eod_classification": summary.get(
|
|
"STOCK_EOD_CLASSIFICATION",
|
|
"unknown",
|
|
),
|
|
"stock_eod_next_action": summary.get("STOCK_EOD_NEXT_ACTION", "unknown"),
|
|
"backup_core_green": summary.get("BACKUP_CORE_GREEN", "unknown"),
|
|
"escrow_missing_count": summary.get("ESCROW_MISSING_COUNT", "unknown"),
|
|
"host_188_hygiene_blocked": summary.get("HOST_188_HYGIENE_BLOCKED", "unknown"),
|
|
"wazuh_manager_registry_accepted": summary.get(
|
|
"WAZUH_MANAGER_REGISTRY_ACCEPTED",
|
|
"unknown",
|
|
),
|
|
"wazuh_route_code": summary.get("WAZUH_ROUTE_CODE", "unknown"),
|
|
"wazuh_transport_count": summary.get("WAZUH_TRANSPORT_COUNT", "unknown"),
|
|
"wazuh_coverage_scope": summary.get("WAZUH_COVERAGE_SCOPE", "unknown"),
|
|
"wazuh_direct_active": summary.get("WAZUH_DIRECT_ACTIVE", "unknown"),
|
|
"wazuh_no_transport": summary.get("WAZUH_NO_TRANSPORT", "unknown"),
|
|
"wazuh_ssh_blocked": summary.get("WAZUH_SSH_BLOCKED", "unknown"),
|
|
"wazuh_dashboard_api_connection": summary.get(
|
|
"WAZUH_DASHBOARD_API_CONNECTION",
|
|
"unknown",
|
|
),
|
|
"wazuh_dashboard_index_ok": summary.get("WAZUH_DASHBOARD_INDEX_OK", "unknown"),
|
|
"runtime_action_authorized": summary.get("RUNTIME_ACTION_AUTHORIZED", "unknown"),
|
|
},
|
|
"runtime_write_performed": False,
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
summary = parse_summary(load_summary(args))
|
|
payload = build_payload(summary, args.proposed)
|
|
serialized = json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)
|
|
|
|
if args.output:
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
args.output.write_text(serialized + "\n", encoding="utf-8")
|
|
|
|
if args.json:
|
|
print(serialized)
|
|
else:
|
|
prefix = "POST_REBOOT_DECLARATION_GUARD_OK"
|
|
if payload["proposed"]["rejected"]:
|
|
prefix = "POST_REBOOT_DECLARATION_GUARD_REJECTED"
|
|
elif payload["status"] == "blocked_service_recovery":
|
|
prefix = "POST_REBOOT_DECLARATION_GUARD_BLOCKED"
|
|
print(
|
|
f"{prefix} "
|
|
f"status={payload['status']} "
|
|
f"allowed={payload['counts']['allowed_count']} "
|
|
f"forbidden={payload['counts']['forbidden_count']} "
|
|
f"next_gates={payload['counts']['next_required_gate_count']} "
|
|
f"rejected_proposed={payload['counts']['rejected_proposed_count']}"
|
|
)
|
|
|
|
if payload["proposed"]["rejected"]:
|
|
return 2
|
|
if payload["status"] == "blocked_service_recovery":
|
|
return 2
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|