#!/usr/bin/env python3 """Validate a restricted GuestOps incident and rollback exercise record.""" from __future__ import annotations import argparse import datetime as dt import json from pathlib import Path import re from urllib.parse import urlparse GATE_B_SCENARIOS = { "alert-and-escalate", "disable-worker-writes", "google-uncertain-send", "restore-readiness", "image-rollback", "controlled-recovery", } GATE_C_SCENARIOS = GATE_B_SCENARIOS | { "pms-ambiguous-write", "payment-ambiguous-create", } def require(condition: bool, message: str) -> None: if not condition: raise ValueError(message) def utc_timestamp(value: object, field: str) -> dt.datetime: require(isinstance(value, str) and value.endswith("Z"), f"{field} must be a UTC timestamp ending in Z.") try: parsed = dt.datetime.fromisoformat(value.removesuffix("Z") + "+00:00") except ValueError as error: raise ValueError(f"{field} is not a valid timestamp.") from error require(parsed.tzinfo == dt.timezone.utc, f"{field} must be UTC.") return parsed def safe_name(value: object, field: str) -> str: name = str(value or "").strip() require(2 <= len(name) <= 120 and "@" not in name, f"{field} requires a name without an email address.") return name def validate(record: object) -> None: require(isinstance(record, dict), "Exercise record must be a JSON object.") require(record.get("schemaVersion") == 1, "Unsupported exercise record schema.") require(record.get("system") == "guestops-incident-exercise", "Exercise record system must be guestops-incident-exercise.") gate = record.get("targetGate") require(gate in ("B", "C"), "targetGate must be B or C.") require(record.get("dataClassification") == "synthetic-only", "Incident exercises must use synthetic data only.") require(re.fullmatch(r"[0-9a-f]{40}", str(record.get("releaseCommit", ""))) is not None, "releaseCommit must be a full lowercase Git SHA.") require(re.fullmatch(r"[0-9a-f]{64}", str(record.get("releaseRecordSha256", ""))) is not None, "releaseRecordSha256 must be a SHA-256 digest.") environment = str(record.get("environment", "")) parsed_url = urlparse(environment) require(parsed_url.scheme == "https" and parsed_url.hostname and parsed_url.path in ("", "/") and not parsed_url.query and not parsed_url.fragment and parsed_url.username is None and parsed_url.password is None, "environment must be an HTTPS origin without credentials, path, query or fragment.") operator = safe_name(record.get("operator"), "operator") commander = safe_name(record.get("incidentCommander"), "incidentCommander") reviewer = safe_name(record.get("reviewedBy"), "reviewedBy") require(len({operator.casefold(), commander.casefold(), reviewer.casefold()}) == 3, "operator, incidentCommander and reviewedBy must be different people.") started = utc_timestamp(record.get("startedAt"), "startedAt") ended = utc_timestamp(record.get("endedAt"), "endedAt") reviewed = utc_timestamp(record.get("reviewedAt"), "reviewedAt") require(started <= ended <= reviewed, "Exercise timestamps are out of order.") targets = record.get("targetsMinutes") observed = record.get("observedMinutes") require(isinstance(targets, dict) and isinstance(observed, dict), "targetsMinutes and observedMinutes are required.") metric_keys = {"detection", "containment", "recovery"} require(set(targets) == metric_keys and set(observed) == metric_keys, "Timing records require exactly detection, containment and recovery.") for metric in sorted(metric_keys): target = targets[metric] actual = observed[metric] require(isinstance(target, (int, float)) and not isinstance(target, bool) and 0 < target <= 1440, f"{metric} target must be greater than zero and no more than 1440 minutes.") require(isinstance(actual, (int, float)) and not isinstance(actual, bool) and 0 <= actual <= target, f"Observed {metric} time must meet its pre-agreed target.") scenarios = record.get("scenarios") require(isinstance(scenarios, list), "scenarios must be a list.") ids = [item.get("id") for item in scenarios if isinstance(item, dict)] required = GATE_C_SCENARIOS if gate == "C" else GATE_B_SCENARIOS require(len(ids) == len(scenarios) and len(ids) == len(set(ids)) and set(ids) == required, f"Gate {gate} requires the exact incident scenario set.") for item in scenarios: scenario_id = item["id"] require(item.get("status") == "pass", f"Scenario {scenario_id} has not passed.") evidence = item.get("evidence") require(isinstance(evidence, list) and 1 <= len(evidence) <= 10 and all( isinstance(value, str) and 3 <= len(value) <= 200 and "@" not in value for value in evidence ), f"Scenario {scenario_id} requires safe opaque evidence references without email addresses.") final_state = record.get("postExerciseState") require(isinstance(final_state, dict) and final_state == { "externalWrites": "disabled", "faqMode": "off", "unresolvedOperations": 0, }, "Exercise must end with writes disabled, FAQ mode off and no unresolved operations.") def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("record", type=Path) args = parser.parse_args() validate(json.loads(args.record.read_text(encoding="utf-8"))) print("Incident exercise record is structurally complete and passed. This validates the record, not its restricted evidence.") if __name__ == "__main__": try: main() except (OSError, ValueError, json.JSONDecodeError) as error: print(f"Incident exercise record rejected: {error}", file=__import__("sys").stderr) raise SystemExit(1)