Implement reproducible S1 handoff contracts
Some checks failed
CI Smoke / source-contract (push) Failing after 2s
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a02994-7685-7940-bf34-3555b8256018
This commit is contained in:
codex 2026-08-23 12:02:23 +02:00
parent c8cb1c8edf
commit b93af8cc78
44 changed files with 2035 additions and 342 deletions

View file

@ -0,0 +1,240 @@
#!/usr/bin/env python3
"""Validate and resolve the executable S1 host-baseline contract."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
import yaml
SCHEMA_VERSION = "2.0"
PROFILE_MODES = {"ufw", "external"}
REQUIRED_PACKAGES = {"curl", "git", "vim", "htop", "ufw", "fail2ban"}
REQUIRED_SERVICES = {"fail2ban", "ssh.socket"}
REQUIRED_SSH = {
"PasswordAuthentication": "no",
"PermitRootLogin": "no",
"PubkeyAuthentication": "yes",
"ChallengeResponseAuthentication": "no",
}
CONSUMER_MARKERS = {
"ansible/roles/base/tasks/main.yml": [
"baseline_required_packages",
"baseline_ssh_directives",
"baseline_user",
"baseline_security",
"baseline_firewall",
],
"goss/baseline.yaml.j2": [
"baseline_required_packages",
"baseline_required_services",
"baseline_ssh_directives",
"baseline_user",
"baseline_security",
"baseline_firewall",
],
}
class BaselineError(ValueError):
"""The baseline model or its repository consumers are inconsistent."""
def _strings(
value: Any, label: str, errors: list[str], *, allow_empty: bool = False
) -> list[str]:
if not isinstance(value, list) or (not value and not allow_empty) or not all(
isinstance(item, str) and item.strip() for item in value
):
errors.append(f"{label} must be a non-empty string list")
return []
if len(set(value)) != len(value):
errors.append(f"{label} contains duplicates")
return value
def validate_spec(payload: Any) -> dict[str, Any]:
if not isinstance(payload, dict):
raise BaselineError("baseline must be a YAML object")
errors: list[str] = []
if str(payload.get("version")) != SCHEMA_VERSION:
errors.append(f"version must be {SCHEMA_VERSION!r}")
defaults = payload.get("defaults")
profiles = payload.get("profiles")
if not isinstance(defaults, dict):
errors.append("defaults must be an object")
defaults = {}
if not isinstance(profiles, dict) or not profiles:
errors.append("profiles must be a non-empty object")
profiles = {}
packages = _strings(defaults.get("packages"), "defaults.packages", errors)
services = _strings(defaults.get("services"), "defaults.services", errors)
missing_packages = sorted(REQUIRED_PACKAGES - set(packages))
missing_services = sorted(REQUIRED_SERVICES - set(services))
if missing_packages:
errors.append(f"defaults.packages omits governed packages {', '.join(missing_packages)}")
if missing_services:
errors.append(f"defaults.services omits governed services {', '.join(missing_services)}")
ssh = defaults.get("ssh_directives")
if not isinstance(ssh, dict) or not ssh or not all(
isinstance(key, str) and isinstance(value, str) for key, value in (ssh or {}).items()
):
errors.append("defaults.ssh_directives must be a non-empty string map")
elif {key: ssh.get(key) for key in REQUIRED_SSH} != REQUIRED_SSH:
errors.append("defaults.ssh_directives weakens a governed SSH directive")
user = defaults.get("user")
if not isinstance(user, dict):
errors.append("defaults.user must be an object")
else:
for key in ("name", "shell", "sudo"):
if not isinstance(user.get(key), str) or not user[key]:
errors.append(f"defaults.user.{key} must be a non-empty string")
if user.get("sudo") != "NOPASSWD":
errors.append("defaults.user.sudo must be NOPASSWD")
security = defaults.get("security")
if not isinstance(security, dict):
errors.append("defaults.security must be an object")
else:
if not isinstance(security.get("histcontrol"), str):
errors.append("defaults.security.histcontrol must be a string")
_strings(
security.get("fail2ban_jails"),
"defaults.security.fail2ban_jails",
errors,
)
for name, profile in profiles.items():
label = f"profiles.{name}"
if not isinstance(profile, dict):
errors.append(f"{label} must be an object")
continue
_strings(
profile.get("services", []),
f"{label}.services",
errors,
allow_empty=True,
)
firewall = profile.get("firewall")
if not isinstance(firewall, dict):
errors.append(f"{label}.firewall must be an object")
continue
mode = firewall.get("mode")
if mode not in PROFILE_MODES:
errors.append(f"{label}.firewall.mode must be one of {sorted(PROFILE_MODES)}")
expected_managed = mode == "ufw"
if firewall.get("managed") is not expected_managed:
errors.append(f"{label}.firewall.managed must be {expected_managed}")
verification = firewall.get("verification")
if not isinstance(verification, dict):
errors.append(f"{label}.firewall.verification must be an object")
else:
if not isinstance(verification.get("command"), str):
errors.append(f"{label}.firewall.verification.command must be a string")
_strings(
verification.get("stdout"),
f"{label}.firewall.verification.stdout",
errors,
)
if mode == "external":
replacement = firewall.get("replacement_control")
if not isinstance(replacement, dict):
errors.append(f"{label}.firewall.replacement_control must be an object")
else:
for key in ("description", "owner", "removal_condition"):
if not isinstance(replacement.get(key), str) or not replacement[key]:
errors.append(
f"{label}.firewall.replacement_control.{key} must be a string"
)
for required_profile, required_mode in {
"ufw-managed": "ufw",
"external-firewall": "external",
}.items():
if profiles.get(required_profile, {}).get("firewall", {}).get("mode") != required_mode:
errors.append(
f"profiles.{required_profile} must declare firewall mode {required_mode}"
)
if errors:
raise BaselineError("baseline contract failed:\n- " + "\n- ".join(errors))
return payload
def load_spec(path: Path) -> dict[str, Any]:
try:
payload = yaml.safe_load(path.read_text(encoding="utf-8"))
except (OSError, yaml.YAMLError) as exc:
raise BaselineError(f"cannot read {path}: {exc}") from exc
return validate_spec(payload)
def profile_hostvars(payload: dict[str, Any], profile_name: str) -> dict[str, Any]:
validate_spec(payload)
try:
profile = payload["profiles"][profile_name]
except KeyError as exc:
raise BaselineError(f"unknown baseline profile {profile_name!r}") from exc
defaults = payload["defaults"]
services = list(dict.fromkeys(defaults["services"] + profile["services"]))
return {
"baseline_profile": profile_name,
"baseline_required_packages": defaults["packages"],
"baseline_required_services": services,
"baseline_ssh_directives": defaults["ssh_directives"],
"baseline_user": defaults["user"],
"baseline_security": defaults["security"],
"baseline_firewall": profile["firewall"],
"ufw_manage": profile["firewall"]["managed"],
}
def validate_repo_consumers(root: Path) -> None:
errors: list[str] = []
for relative, markers in CONSUMER_MARKERS.items():
path = root / relative
try:
text = path.read_text(encoding="utf-8")
except OSError as exc:
errors.append(f"cannot read {relative}: {exc}")
continue
missing = [marker for marker in markers if marker not in text]
if missing:
errors.append(f"{relative} does not consume {', '.join(missing)}")
if errors:
raise BaselineError("baseline consumer parity failed:\n- " + "\n- ".join(errors))
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"spec", nargs="?", type=Path, default=Path("spec/server-baseline.yaml")
)
parser.add_argument("--profile", help="resolve one profile as Ansible hostvars")
parser.add_argument("--check-repo", action="store_true")
args = parser.parse_args()
try:
payload = load_spec(args.spec)
if args.check_repo:
validate_repo_consumers(Path(__file__).resolve().parents[1])
result: dict[str, Any] = {
"ok": True,
"version": payload["version"],
"profiles": sorted(payload["profiles"]),
}
if args.profile:
result["hostvars"] = profile_hostvars(payload, args.profile)
except BaselineError as exc:
print(exc, file=sys.stderr)
return 1
print(json.dumps(result, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Fail when a declared secret-bearing Git path is not SOPS/age encrypted."""
from __future__ import annotations
import argparse
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def is_protected_path(path: str) -> bool:
normalized = path.strip("/")
name = Path(normalized).name.lower()
return normalized.startswith("secrets/") or (
normalized.startswith("inventory/")
and name.startswith("secrets")
and name.endswith((".yaml", ".yml", ".json"))
)
def is_encrypted_content(path: str, content: str) -> bool:
if path.endswith((".age", ".gpg")):
return bool(content)
return any(
line.strip() == "sops:" or line.lstrip().startswith('"sops"')
for line in content.splitlines()
)
def _git(*args: str) -> str:
return subprocess.check_output(["git", *args], cwd=ROOT, text=True)
def staged_files() -> list[str]:
return [
path
for path in _git("diff", "--cached", "--name-only", "--diff-filter=ACMR").splitlines()
if is_protected_path(path)
]
def tracked_files() -> list[str]:
return [
path
for path in _git("ls-files").splitlines()
if is_protected_path(path) and (ROOT / path).is_file()
]
def validate_paths(paths: list[str], *, staged: bool) -> list[str]:
failures = []
for relative in sorted(set(paths)):
try:
content = (
_git("show", f":{relative}")
if staged
else (ROOT / relative).read_text(encoding="utf-8")
)
except (OSError, subprocess.CalledProcessError):
failures.append(f"{relative}: cannot read protected content")
continue
if not is_encrypted_content(relative, content):
failures.append(f"{relative}: plaintext or missing SOPS metadata")
return failures
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
mode = parser.add_mutually_exclusive_group(required=True)
mode.add_argument("--staged", action="store_true")
mode.add_argument("--tracked", action="store_true")
args = parser.parse_args()
paths = staged_files() if args.staged else tracked_files()
failures = validate_paths(paths, staged=args.staged)
if failures:
print("Unencrypted secret-bearing paths:\n- " + "\n- ".join(failures), file=sys.stderr)
return 1
print(f"secret path check passed ({len(paths)} protected file(s))")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,7 +1,7 @@
#!/usr/bin/env bash
# hcloud_new_server.sh — Add a host to inventory and provision it on Hetzner
# Usage:
# scripts/hcloud_new_server.sh <NAME> [--type cpx11] [--region nbg1] [--role web] [--image ubuntu-24.04] [--user admin]
# scripts/hcloud_new_server.sh <NAME> [options] [--apply]
#
# Prereqs:
# - age + SOPS installed, with access to decrypt your Hetzner token
@ -23,6 +23,7 @@ REGION="nbg1"
ROLE="generic"
IMAGE="ubuntu-24.04"
USER="admin"
APPLY=false
while [[ $# -gt 0 ]]; do
case "$1" in
@ -31,6 +32,7 @@ while [[ $# -gt 0 ]]; do
--role) ROLE="$2"; shift 2;;
--image) IMAGE="$2"; shift 2;;
--user) USER="$2"; shift 2;;
--apply) APPLY=true; shift;;
*) fail "Unknown arg: $1 (usage: scripts/hcloud_new_server.sh <NAME> [--type cpx11] [--region nbg1] [--role web] [--image ubuntu-24.04] [--user admin])";;
esac
done
@ -58,18 +60,27 @@ python3 scripts/new_host.py \
--region "$REGION" \
--role "$ROLE" \
--image "$IMAGE" \
--user "$USER"
--user "$USER" \
--reuse-existing
ok "Inventory updated: $NAME → inventory/servers.yaml"
# --- Decrypt Hetzner token and apply Terraform ---
HCLOUD_TOKEN="$(sops -d --extract '["hetzner"]["token"]' secrets/hetzner-token.sops.yaml 2>/dev/null)"
[[ -n "$HCLOUD_TOKEN" ]] || fail "Could not decrypt ops.hcloud_token from secrets/hetzner-token.sops.yaml. Ensure SOPS_AGE_KEY or keys.txt is set and token exists."
HCLOUD_TOKEN="$(sops -d --extract '["hetzner"]["token"]' secrets/hetzner-token.yaml 2>/dev/null)"
[[ -n "$HCLOUD_TOKEN" ]] || fail "Could not decrypt hetzner.token from secrets/hetzner-token.yaml. Ensure SOPS_AGE_KEY or keys.txt is set."
pushd terraform/hetzner >/dev/null
terraform init -upgrade
export HCLOUD_TOKEN
export TF_VAR_hcloud_token="$HCLOUD_TOKEN"
terraform plan
if [[ "$APPLY" != true ]]; then
info "Plan complete; no provider mutation performed. Re-run with --apply only after review and approval."
popd >/dev/null
exit 0
fi
terraform apply -auto-approve
# Try to show IP of the created host

View file

@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""Validate the S1 host inventory without contacting a provider or host."""
from __future__ import annotations
import argparse
import ipaddress
import json
import re
import sys
from pathlib import Path
from typing import Any
import yaml
SCHEMA_VERSION = "1.0"
PROVIDERS = {"hosteurope", "hetzner"}
LIFECYCLE_MODES = {"adopted", "provider-managed"}
BASELINE_PROFILES = {"ufw-managed", "external-firewall"}
HETZNER_REQUIRED = {"server_type", "region", "image", "role"}
HETZNER_OPTIONAL = {"labels"}
NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$")
USER_RE = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$")
class InventoryError(ValueError):
"""The inventory does not satisfy the S1 contract."""
def _nonempty_string(value: Any) -> bool:
return isinstance(value, str) and bool(value.strip())
def validate_inventory(payload: Any) -> dict[str, Any]:
if not isinstance(payload, dict):
raise InventoryError("inventory must be a YAML object")
if str(payload.get("schema_version")) != SCHEMA_VERSION:
raise InventoryError(f"schema_version must be {SCHEMA_VERSION!r}")
servers = payload.get("servers")
if not isinstance(servers, list) or not servers:
raise InventoryError("servers must be a non-empty list")
errors: list[str] = []
names: set[str] = set()
for index, server in enumerate(servers):
label = f"servers[{index}]"
if not isinstance(server, dict):
errors.append(f"{label} must be an object")
continue
name = server.get("name")
if not _nonempty_string(name) or not NAME_RE.fullmatch(name):
errors.append(f"{label}.name must be a stable host identifier")
name = label
elif name in names:
errors.append(f"{label}.name duplicates {name!r}")
else:
names.add(name)
label = str(name)
provider = server.get("provider")
lifecycle = server.get("lifecycle_mode")
profile = server.get("baseline_profile")
ssh_user = server.get("ssh_user")
if provider not in PROVIDERS:
errors.append(f"{label}: provider must be one of {sorted(PROVIDERS)}")
if lifecycle not in LIFECYCLE_MODES:
errors.append(
f"{label}: lifecycle_mode must be one of {sorted(LIFECYCLE_MODES)}"
)
if profile not in BASELINE_PROFILES:
errors.append(
f"{label}: baseline_profile must be one of {sorted(BASELINE_PROFILES)}"
)
if not _nonempty_string(ssh_user) or not USER_RE.fullmatch(ssh_user):
errors.append(f"{label}: ssh_user must be a valid Unix user name")
if lifecycle == "adopted":
address = server.get("ip")
try:
ipaddress.ip_address(address)
except (TypeError, ValueError):
errors.append(f"{label}: adopted hosts require a literal ip address")
if "provisioning" in server:
errors.append(
f"{label}: adopted hosts must not carry provider provisioning fields"
)
elif lifecycle == "provider-managed":
if provider != "hetzner":
errors.append(
f"{label}: provider-managed is currently implemented only for hetzner"
)
if "ip" in server:
errors.append(
f"{label}: provider-managed addresses come from provider output; remove ip"
)
provisioning = server.get("provisioning")
if not isinstance(provisioning, dict):
errors.append(f"{label}: provider-managed hosts require provisioning")
else:
missing = sorted(
key
for key in HETZNER_REQUIRED
if not _nonempty_string(provisioning.get(key))
)
unknown = sorted(
set(provisioning) - HETZNER_REQUIRED - HETZNER_OPTIONAL
)
if missing:
errors.append(
f"{label}: provisioning missing {', '.join(missing)}"
)
if unknown:
errors.append(
f"{label}: provisioning has unknown fields {', '.join(unknown)}"
)
labels = provisioning.get("labels", [])
if not isinstance(labels, list) or not all(
_nonempty_string(item) for item in labels
):
errors.append(
f"{label}: provisioning.labels must be a list of strings"
)
if errors:
raise InventoryError("inventory contract failed:\n- " + "\n- ".join(errors))
return payload
def load_inventory(path: Path) -> dict[str, Any]:
try:
payload = yaml.safe_load(path.read_text(encoding="utf-8"))
except (OSError, yaml.YAMLError) as exc:
raise InventoryError(f"cannot read {path}: {exc}") from exc
return validate_inventory(payload)
def managed_hetzner_servers(payload: dict[str, Any]) -> list[dict[str, Any]]:
validate_inventory(payload)
return [
server
for server in payload["servers"]
if server["provider"] == "hetzner"
and server["lifecycle_mode"] == "provider-managed"
]
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"inventory", nargs="?", type=Path, default=Path("inventory/servers.yaml")
)
parser.add_argument(
"--managed-hetzner", action="store_true", help="print selected names"
)
args = parser.parse_args()
try:
payload = load_inventory(args.inventory)
except InventoryError as exc:
print(exc, file=sys.stderr)
return 1
selected = managed_hetzner_servers(payload)
result = {
"ok": True,
"schema_version": SCHEMA_VERSION,
"server_count": len(payload["servers"]),
"managed_hetzner": [server["name"] for server in selected],
}
print(json.dumps(result, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -5,7 +5,8 @@ if [[ -z "$NAME" ]]; then
echo "Usage: scripts/new-server.sh <name>"
exit 1
fi
yq -i '.servers += [{ "name": "'$NAME'", "labels": [], "role": "generic", "region": "nbg1", "type": "cpx21", "image": "ubuntu-24.04", "ssh_user": "admin"}]' inventory/servers.yaml
python3 scripts/new_host.py --name "$NAME" --type cpx21 --region nbg1 \
--role generic --image ubuntu-24.04 --user admin
git add inventory/servers.yaml
git commit -m "Add server ${NAME}"
echo "Added ${NAME}. Run: make apply"

View file

@ -14,6 +14,11 @@ def main():
p.add_argument("--role", default="test")
p.add_argument("--image", default="ubuntu-24.04")
p.add_argument("--user", default="admin")
p.add_argument(
"--reuse-existing",
action="store_true",
help="succeed only when an existing record exactly matches the request",
)
args = p.parse_args()
inv_path = os.path.join("inventory", "servers.yaml")
@ -23,20 +28,33 @@ def main():
data = yaml.safe_load(f) or {}
servers = data.setdefault("servers", [])
# Prevent duplicates
if any(s.get("name") == args.name for s in servers):
print(f"ERROR: host '{args.name}' already exists in {inv_path}", file=sys.stderr)
candidate = {
"name": args.name,
"provider": "hetzner",
"lifecycle_mode": "provider-managed",
"ssh_user": args.user,
"baseline_profile": "ufw-managed",
"provisioning": {
"server_type": args.type,
"region": args.region,
"role": args.role,
"image": args.image,
"labels": [],
},
}
existing = next((s for s in servers if s.get("name") == args.name), None)
if existing is not None:
if args.reuse_existing and existing == candidate:
print(f"Reusing matching host '{args.name}' from {inv_path}")
return
print(
f"ERROR: host '{args.name}' already exists with a different declaration",
file=sys.stderr,
)
sys.exit(1)
servers.append({
"name": args.name,
"labels": [],
"role": args.role,
"region": args.region,
"type": args.type,
"image": args.image,
"ssh_user": args.user,
})
data.setdefault("schema_version", "1.0")
servers.append(candidate)
with open(inv_path, "w", encoding="utf-8") as f:
yaml.safe_dump(data, f, sort_keys=False)

163
scripts/s1_handoff.py Normal file
View file

@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""Run the fail-closed S1 verification gate and emit a metadata-only receipt."""
from __future__ import annotations
import argparse
import hashlib
import json
import shutil
import subprocess
import sys
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from baseline_contract import load_spec, profile_hostvars, validate_repo_consumers
from inventory_contract import load_inventory
from s1_receipt import validate_receipt
ROOT = Path(__file__).resolve().parents[1]
def _sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def _git(*args: str) -> str:
return subprocess.check_output(["git", *args], cwd=ROOT, text=True).strip()
def _timestamp(value: datetime) -> str:
return value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _selected_hosts(inventory: dict[str, Any], requested: list[str]) -> list[dict[str, Any]]:
hosts = inventory["servers"]
if not requested:
return hosts
known = {host["name"]: host for host in hosts}
missing = sorted(set(requested) - set(known))
if missing:
raise ValueError(f"unknown hosts: {', '.join(missing)}")
return [known[name] for name in requested]
def build_receipt(
*,
revision: str,
inventory_digest: str,
hosts: list[dict[str, Any]],
results: dict[str, int] | None,
observed_at: datetime,
freshness_hours: int,
evidence: list[dict[str, str]],
) -> dict[str, Any]:
ran = results is not None
passed = ran and all(results[host["name"]] == 0 for host in hosts)
receipt: dict[str, Any] = {
"schema_version": "1.0",
"receipt_id": str(uuid.uuid4()),
"event_type": "verification",
"synthetic": False,
"created_at": _timestamp(datetime.now(timezone.utc)),
"source_revision": revision,
"inventory_sha256": inventory_digest,
"status": "pass" if passed else ("fail" if ran else "not-run"),
"hosts": [host["name"] for host in hosts],
"profiles": {host["name"]: host["baseline_profile"] for host in hosts},
"observed_at": _timestamp(observed_at),
"fresh_until": _timestamp(observed_at + timedelta(hours=freshness_hours)),
"evidence": evidence,
}
if ran:
receipt["host_exit_status"] = results
validate_receipt(receipt)
return receipt
def _new_reports(started: float, host_names: list[str]) -> list[dict[str, str]]:
evidence = []
for path in sorted((ROOT / "reports").glob("goss-*.tap")):
if path.stat().st_mtime < started:
continue
if not any(f"goss-{name}-" in path.name for name in host_names):
continue
evidence.append(
{
"kind": "goss-tap",
"path": str(path.relative_to(ROOT)),
"sha256": _sha256(path),
}
)
return evidence
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--host", action="append", default=[])
parser.add_argument("--freshness-hours", type=int, default=24)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--output", type=Path)
args = parser.parse_args()
if not 1 <= args.freshness_hours <= 168:
parser.error("--freshness-hours must be between 1 and 168")
inventory_path = ROOT / "inventory" / "servers.yaml"
spec_path = ROOT / "spec" / "server-baseline.yaml"
inventory = load_inventory(inventory_path)
baseline = load_spec(spec_path)
validate_repo_consumers(ROOT)
hosts = _selected_hosts(inventory, args.host)
for host in hosts:
profile_hostvars(baseline, host["baseline_profile"])
revision = _git("rev-parse", "HEAD")
dirty = _git("status", "--porcelain")
if dirty and not args.dry_run:
print("handoff gate requires a clean checkout so the receipt pins all inputs", file=sys.stderr)
return 2
observed = datetime.now(timezone.utc)
results: dict[str, int] | None = None
evidence: list[dict[str, str]] = []
if not args.dry_run:
if shutil.which("ansible-playbook") is None:
print("handoff gate requires ansible-playbook", file=sys.stderr)
return 2
started = datetime.now(timezone.utc).timestamp()
results = {}
for host in hosts:
completed = subprocess.run(
["ansible-playbook", "playbooks/verify.yaml", "--limit", host["name"]],
cwd=ROOT / "ansible",
check=False,
)
results[host["name"]] = completed.returncode
evidence = _new_reports(started, [host["name"] for host in hosts])
receipt = build_receipt(
revision=revision,
inventory_digest=_sha256(inventory_path),
hosts=hosts,
results=results,
observed_at=observed,
freshness_hours=args.freshness_hours,
evidence=evidence,
)
output = args.output
if output is None:
stamp = observed.strftime("%Y%m%dT%H%M%SZ")
output = ROOT / "reports" / f"s1-handoff-{stamp}.json"
elif not output.is_absolute():
output = ROOT / output
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(json.dumps({"receipt": str(output), "status": receipt["status"]}, sort_keys=True))
return 0 if receipt["status"] in {"pass", "not-run"} else 1
if __name__ == "__main__":
raise SystemExit(main())

190
scripts/s1_receipt.py Normal file
View file

@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""Validate Railiance S1 receipts and reject secret-shaped content."""
from __future__ import annotations
import argparse
import json
import re
import sys
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
SCHEMA_VERSION = "1.0"
EVENT_TYPES = {
"plan",
"apply",
"convergence",
"verification",
"rotation",
"provisioning-chain",
}
STATUSES = {"pass", "fail", "not-run"}
DIGEST_RE = re.compile(r"^[0-9a-f]{64}$")
REVISION_RE = re.compile(r"^[0-9a-f]{7,40}$")
FORBIDDEN_KEYS = {
"api_key",
"credential",
"credentials",
"password",
"private_key",
"secret",
"secret_value",
"token",
"tokens",
}
FORBIDDEN_VALUE_PATTERNS = (
re.compile(r"-----BEGIN (?:OPENSSH|RSA|EC|AGE) PRIVATE KEY-----"),
re.compile(r"\bhc_[A-Za-z0-9_-]{16,}\b"),
)
class ReceiptError(ValueError):
"""A receipt is incomplete, ambiguous, or unsafe to retain."""
def utc_now() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _parse_time(value: Any, label: str) -> datetime:
if not isinstance(value, str):
raise ReceiptError(f"{label} must be an RFC3339 string")
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise ReceiptError(f"{label} must be RFC3339") from exc
if parsed.tzinfo is None:
raise ReceiptError(f"{label} must include a timezone")
return parsed
def _scan_safe(value: Any, path: str = "$") -> None:
if isinstance(value, dict):
for key, child in value.items():
normalized = str(key).lower().replace("-", "_")
if normalized in FORBIDDEN_KEYS or normalized.endswith(
("_api_key", "_credential", "_password", "_private_key", "_secret", "_token")
):
raise ReceiptError(f"forbidden secret-shaped key at {path}.{key}")
_scan_safe(child, f"{path}.{key}")
elif isinstance(value, list):
for index, child in enumerate(value):
_scan_safe(child, f"{path}[{index}]")
elif isinstance(value, str):
for pattern in FORBIDDEN_VALUE_PATTERNS:
if pattern.search(value):
raise ReceiptError(f"forbidden credential-shaped value at {path}")
def _require_digest(payload: dict[str, Any], key: str) -> None:
if not DIGEST_RE.fullmatch(str(payload.get(key, ""))):
raise ReceiptError(f"{key} must be a lowercase sha256 digest")
def _validate_hosts(payload: dict[str, Any]) -> None:
hosts = payload.get("hosts")
profiles = payload.get("profiles")
if not isinstance(hosts, list) or not hosts or not all(
isinstance(host, str) and host for host in hosts
):
raise ReceiptError("passing host receipts require hosts")
if len(set(hosts)) != len(hosts):
raise ReceiptError("hosts must be unique")
if not isinstance(profiles, dict) or set(profiles) != set(hosts):
raise ReceiptError("profiles must map every and only listed host")
def validate_receipt(payload: Any) -> dict[str, Any]:
if not isinstance(payload, dict):
raise ReceiptError("receipt must be a JSON object")
_scan_safe(payload)
if payload.get("schema_version") != SCHEMA_VERSION:
raise ReceiptError(f"schema_version must be {SCHEMA_VERSION}")
try:
uuid.UUID(str(payload.get("receipt_id")))
except (ValueError, TypeError, AttributeError) as exc:
raise ReceiptError("receipt_id must be a UUID") from exc
event_type = payload.get("event_type")
status = payload.get("status")
if event_type not in EVENT_TYPES:
raise ReceiptError(f"event_type must be one of {sorted(EVENT_TYPES)}")
if status not in STATUSES:
raise ReceiptError(f"status must be one of {sorted(STATUSES)}")
if not isinstance(payload.get("synthetic"), bool):
raise ReceiptError("synthetic must be boolean")
_parse_time(payload.get("created_at"), "created_at")
if not REVISION_RE.fullmatch(str(payload.get("source_revision", ""))):
raise ReceiptError("source_revision must be a 7-40 character git revision")
_require_digest(payload, "inventory_sha256")
if status == "pass" and event_type == "plan":
_require_digest(payload, "plan_summary_sha256")
elif status == "pass" and event_type == "apply":
resource_ids = payload.get("provider_resource_ids")
if not isinstance(resource_ids, list) or not resource_ids:
raise ReceiptError("passing apply receipts require provider_resource_ids")
elif status == "pass" and event_type in {"convergence", "verification"}:
_validate_hosts(payload)
if event_type == "verification":
observed = _parse_time(payload.get("observed_at"), "observed_at")
fresh = _parse_time(payload.get("fresh_until"), "fresh_until")
if fresh <= observed:
raise ReceiptError("fresh_until must be after observed_at")
evidence = payload.get("evidence")
if not isinstance(evidence, list) or len(evidence) < len(payload["hosts"]):
raise ReceiptError("passing verification receipts require host evidence")
for item in evidence:
if not isinstance(item, dict) or not DIGEST_RE.fullmatch(
str(item.get("sha256", ""))
):
raise ReceiptError("verification evidence requires sha256 digests")
elif status == "pass" and event_type == "rotation":
if payload.get("decryption_verified") is not True:
raise ReceiptError("passing rotation receipts require decryption_verified=true")
for key in ("files", "before_recipients", "after_recipients"):
if not isinstance(payload.get(key), list) or not payload[key]:
raise ReceiptError(f"passing rotation receipts require {key}")
elif status == "pass" and event_type == "provisioning-chain":
if payload.get("synthetic") is not True:
raise ReceiptError("combined provisioning-chain receipts are synthetic only")
phases = payload.get("phases")
expected = ["plan", "apply", "cloud-init", "convergence", "verification"]
if not isinstance(phases, list) or [p.get("phase") for p in phases] != expected:
raise ReceiptError(f"provisioning-chain phases must be {expected}")
if any(phase.get("status") != "pass" for phase in phases):
raise ReceiptError("passing provisioning-chain receipts require every phase to pass")
return payload
def load_receipt(path: Path) -> dict[str, Any]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ReceiptError(f"cannot read {path}: {exc}") from exc
return validate_receipt(payload)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("receipts", nargs="+", type=Path)
args = parser.parse_args()
failures = []
for path in args.receipts:
try:
load_receipt(path)
except ReceiptError as exc:
failures.append(f"{path}: {exc}")
if failures:
print("receipt validation failed:\n- " + "\n- ".join(failures), file=sys.stderr)
return 1
print(json.dumps({"ok": True, "validated": len(args.receipts)}, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -2,8 +2,8 @@
set -euo pipefail
case "${1:-}" in
edit) sops inventory/group_vars/secrets.sops.yaml ;;
rotate) sops --rotate --in-place inventory/group_vars/secrets.sops.yaml ;;
edit) sops secrets/hetzner-token.yaml ;;
rotate) python3 scripts/sops_rotation.py --check ;;
*)
echo "Usage: scripts/sops.sh [edit|rotate]"
;;

233
scripts/sops_rotation.py Normal file
View file

@ -0,0 +1,233 @@
#!/usr/bin/env python3
"""Check or execute a bounded, metadata-only SOPS recipient rotation."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import shutil
import subprocess
import sys
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import yaml
from check_secret_paths import is_protected_path
from s1_receipt import validate_receipt
ROOT = Path(__file__).resolve().parents[1]
class RotationError(ValueError):
"""Rotation inputs, metadata, or approval are unsafe or incomplete."""
def _sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def _git(*args: str) -> str:
return subprocess.check_output(["git", *args], cwd=ROOT, text=True).strip()
def protected_files(root: Path = ROOT) -> list[Path]:
candidates = list((root / "secrets").rglob("*")) if (root / "secrets").exists() else []
inventory = list((root / "inventory").rglob("secrets*"))
return sorted(
path
for path in candidates + inventory
if path.is_file() and is_protected_path(str(path.relative_to(root)))
)
def load_policy(path: Path) -> list[dict[str, Any]]:
try:
payload = yaml.safe_load(path.read_text(encoding="utf-8"))
rules = payload["creation_rules"]
except (OSError, yaml.YAMLError, KeyError, TypeError) as exc:
raise RotationError(f"cannot read SOPS policy {path}: {exc}") from exc
if not isinstance(rules, list) or not rules:
raise RotationError("SOPS policy has no creation_rules")
return rules
def expected_recipients(rules: list[dict[str, Any]], relative: str) -> list[str]:
for rule in rules:
pattern = rule.get("path_regex")
if not isinstance(pattern, str) or re.fullmatch(pattern, relative) is None:
continue
recipients = []
for group in rule.get("key_groups", []):
recipients.extend(group.get("age", []))
recipients = sorted(set(recipients))
if not recipients:
raise RotationError(f"{relative}: matching policy has no age recipients")
return recipients
raise RotationError(f"{relative}: no .sops.yaml creation rule matches")
def actual_recipients(path: Path) -> list[str]:
try:
payload = yaml.safe_load(path.read_text(encoding="utf-8"))
age_entries = payload["sops"]["age"]
recipients = sorted({entry["recipient"] for entry in age_entries})
except (OSError, yaml.YAMLError, KeyError, TypeError) as exc:
raise RotationError(f"{path}: missing readable SOPS age metadata") from exc
if not recipients:
raise RotationError(f"{path}: SOPS metadata has no age recipients")
return recipients
def rotation_plan(root: Path = ROOT) -> list[dict[str, Any]]:
rules = load_policy(root / ".sops.yaml")
plan = []
for path in protected_files(root):
relative = str(path.relative_to(root))
before = actual_recipients(path)
after = expected_recipients(rules, relative)
plan.append(
{
"path": relative,
"sha256": _sha256(path),
"before_recipients": before,
"after_recipients": after,
"changed": before != after,
}
)
if not plan:
raise RotationError("no protected SOPS files found")
return plan
def _load_approval(path: Path, plan: list[dict[str, Any]]) -> None:
try:
payload = yaml.safe_load(path.read_text(encoding="utf-8"))
except (OSError, yaml.YAMLError) as exc:
raise RotationError(f"cannot read approval file: {exc}") from exc
if not isinstance(payload, dict) or payload.get("approved") is not True:
raise RotationError("approval file must contain approved: true")
if not payload.get("approved_by") or not payload.get("approved_at"):
raise RotationError("approval file requires approved_by and approved_at")
expected = [
{
"path": item["path"],
"before_recipients": item["before_recipients"],
"after_recipients": item["after_recipients"],
}
for item in plan
if item["changed"]
]
if payload.get("changes") != expected:
raise RotationError("approval changes do not exactly match the current rotation plan")
def _verify_decryption(paths: list[Path]) -> bool:
if shutil.which("sops") is None:
raise RotationError("sops is required for non-printing decryption verification")
for path in paths:
completed = subprocess.run(
["sops", "--decrypt", str(path)],
cwd=ROOT,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
if completed.returncode != 0:
raise RotationError(f"decryption verification failed for {path.relative_to(ROOT)}")
return True
def _apply(plan: list[dict[str, Any]]) -> None:
if shutil.which("sops") is None:
raise RotationError("sops is required for rotation")
for item in plan:
if not item["changed"]:
continue
completed = subprocess.run(
["sops", "updatekeys", "--yes", item["path"]],
cwd=ROOT,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
if completed.returncode != 0:
raise RotationError(f"sops updatekeys failed for {item['path']}")
def build_receipt(plan: list[dict[str, Any]], verified: bool, applied: bool) -> dict[str, Any]:
all_before = sorted({r for item in plan for r in item["before_recipients"]})
all_after = sorted({r for item in plan for r in item["after_recipients"]})
receipt = {
"schema_version": "1.0",
"receipt_id": str(uuid.uuid4()),
"event_type": "rotation",
"synthetic": False,
"created_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"source_revision": _git("rev-parse", "HEAD"),
"inventory_sha256": _sha256(ROOT / "inventory" / "servers.yaml"),
"status": "pass" if verified else "not-run",
"applied": applied,
"decryption_verified": verified,
"files": [item["path"] for item in plan],
"before_recipients": all_before,
"after_recipients": all_after,
"file_metadata": plan,
}
validate_receipt(receipt)
return receipt
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check", action="store_true", help="fail on recipient drift")
parser.add_argument("--verify-decryption", action="store_true")
parser.add_argument("--apply", action="store_true")
parser.add_argument("--approval-file", type=Path)
parser.add_argument("--receipt", type=Path)
args = parser.parse_args()
try:
plan = rotation_plan()
if args.check and any(item["changed"] for item in plan):
raise RotationError("recipient drift detected")
if args.apply:
if args.approval_file is None:
raise RotationError("--apply requires --approval-file")
if not any(item["changed"] for item in plan):
raise RotationError("--apply requires at least one recipient change")
_load_approval(args.approval_file, plan)
_apply(plan)
plan = rotation_plan()
if any(item["changed"] for item in plan):
raise RotationError("recipient drift remains after rotation")
verified = _verify_decryption(protected_files()) if args.verify_decryption or args.apply else False
receipt = build_receipt(plan, verified, args.apply)
if args.receipt:
destination = args.receipt if args.receipt.is_absolute() else ROOT / args.receipt
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(
json.dumps(
{
"ok": True,
"files": len(plan),
"changes": sum(1 for item in plan if item["changed"]),
"decryption_verified": verified,
"applied": args.apply,
},
sort_keys=True,
)
)
return 0
except RotationError as exc:
print(f"rotation failed closed: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())