GuestOps/deploy/account_security_acceptance.py
2026-09-30 10:24:27 +01:00

67 lines
5.1 KiB
Python

#!/usr/bin/env python3
"""Validate restricted milestone 19 account-security acceptance evidence."""
from __future__ import annotations
import argparse
import datetime as dt
import json
from pathlib import Path
import re
from urllib.parse import urlparse
SCENARIOS={
"authenticator-enrollment","totp-window-replay","recovery-codes","administrator-recovery",
"smtp-delivery-revocation","role-boundaries","legacy-staff","preferences","restart-recovery",
"monitoring","audit-review","secret-free-interfaces",
}
def require(value:bool,message:str)->None:
if not value: raise ValueError(message)
def 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:return dt.datetime.fromisoformat(value.removesuffix("Z")+"+00:00")
except ValueError as error:raise ValueError(f"{field} is not a valid timestamp.") from error
def person(value:object,field:str)->str:
result=str(value or "").strip();require(2<=len(result)<=120 and "@" not in result,f"{field} requires a name without an email address.");return result
def refs(value:object,field:str)->None:
require(isinstance(value,list) and 1<=len(value)<=12 and all(isinstance(item,str) and 3<=len(item)<=200 and "@" not in item and "http" not in item.casefold() for item in value),f"{field} requires safe opaque evidence references.")
def validate(record:object)->None:
require(isinstance(record,dict),"Acceptance record must be a JSON object.")
require(record.get("schemaVersion")==1,"Unsupported account-security acceptance schema.")
require(record.get("system")=="guestops-account-security","system must be guestops-account-security.")
require(record.get("milestone")==19,"milestone must be 19.")
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.")
origin=urlparse(str(record.get("environment","")));require(origin.scheme=="https" and origin.hostname and origin.path in ("","/") and not origin.query and not origin.fragment and origin.username is None,"environment must be an HTTPS origin without credentials, path, query or fragment.")
operator=person(record.get("operator"),"operator");reviewer=person(record.get("reviewedBy"),"reviewedBy");security=person(record.get("securityReviewedBy"),"securityReviewedBy")
require(len({operator.casefold(),reviewer.casefold(),security.casefold()})==3,"operator, reviewer, and security reviewer must be different people.")
started=timestamp(record.get("startedAt"),"startedAt");ended=timestamp(record.get("endedAt"),"endedAt");reviewed=timestamp(record.get("reviewedAt"),"reviewedAt");security_at=timestamp(record.get("securityReviewedAt"),"securityReviewedAt")
require(started<=ended<=reviewed and ended<=security_at,"Acceptance timestamps are out of order.")
refs(record.get("securityReviewEvidence"),"securityReviewEvidence")
controls=record.get("productionControls");require(isinstance(controls,dict),"productionControls is required.")
require(set(controls)=={"smtpEnabled","requireMfa","enabledAfterSecurityReview"},"productionControls has unexpected fields.")
require(all(isinstance(controls[key],bool) for key in controls),"productionControls values must be booleans.")
require(controls["enabledAfterSecurityReview"],"Production SMTP and MFA enforcement must be enabled only after independent security review.")
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)];require(len(ids)==len(scenarios)==len(set(ids)) and set(ids)==SCENARIOS,"Acceptance record requires the exact milestone 19 scenario set.")
for item in scenarios:
require(item.get("status")=="pass",f"Scenario {item['id']} has not passed.");refs(item.get("evidence"),f"Scenario {item['id']}")
findings=record.get("findings");require(isinstance(findings,list),"findings must be a list.")
for finding in findings:
require(isinstance(finding,dict) and finding.get("status") in ("resolved","accepted"),"Every finding must be resolved or explicitly accepted.")
require(3<=len(str(finding.get("id","")))<=80,"Every finding requires an opaque ID.")
person(finding.get("owner"),f"Finding {finding.get('id')} owner");refs(finding.get("evidence"),f"Finding {finding.get('id')}")
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("Milestone 19 account-security acceptance record is structurally complete. This validates the record, not its restricted evidence.")
if __name__=="__main__":
try:main()
except (OSError,ValueError,json.JSONDecodeError) as error:
print(f"Account-security acceptance record rejected: {error}",file=__import__("sys").stderr);raise SystemExit(1)