Founder dry run 2 (receipt 20261107T140929Z): session, mounts and envelope checks passed; the role list failed with TypeError. Fake bao now prints the real CLI shape. Read-only; no live change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 149903@bnt-lap001 Assistant-Session: 4c20a536-1452-4a85-bc09-e1987d9817d6
139 lines
5.6 KiB
Python
Executable file
139 lines
5.6 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Disable the OpenBao `auth/keycape` alias mount (NK-WP-0054-T04 finding), founder-run and silent.
|
|
|
|
The alias mount grants `platform-admin` through KeyCape client `openbao-admin` without binding aal2, while the
|
|
supported login, `auth/netkingdom/role/platform-admin`, binds aal2. Disabling the mount removes its role and
|
|
revokes every token it issued. The KeyCape client stays, because `auth/netkingdom` uses it too.
|
|
|
|
Run only inside the governed attended login (railiance-platform `scripts/openbao-attended-exec.py`). The session
|
|
must come from `auth/netkingdom`; a session from the alias itself is refused, because disabling it would revoke
|
|
that session mid-run. Read-only unless `--apply`. Prints nothing; writes one non-secret receipt (0600, new file).
|
|
|
|
keycape-alias-disable.sh --receipt /abs/path.json [--apply]
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
|
|
MOUNT = "keycape/"
|
|
KEEP = "netkingdom/"
|
|
SCHEMA = "net-kingdom.openbao-keycape-alias-disable.v1"
|
|
|
|
|
|
TOKENISH = re.compile(r"\b[a-z]{1,4}\.[A-Za-z0-9_-]{16,}|[A-Za-z0-9_-]{24,}")
|
|
STEP = {"name": "start"}
|
|
|
|
|
|
class Refused(Exception):
|
|
pass
|
|
|
|
|
|
def bao_json(*args: str) -> dict:
|
|
STEP["name"] = " ".join(args[:2])
|
|
out = subprocess.run(["bao", args[0], "-format=json", *args[1:]], capture_output=True, check=True, timeout=30)
|
|
return json.loads(out.stdout)
|
|
|
|
|
|
def error_summary(error: BaseException) -> dict:
|
|
"""Error class, failing step and bao's status lines only; token-shaped strings redacted."""
|
|
summary = {"error": type(error).__name__, "step": STEP["name"]}
|
|
if isinstance(error, subprocess.CalledProcessError):
|
|
text = (error.stderr or b"").decode("utf-8", "replace")
|
|
lines = [ln.strip() for ln in text.splitlines() if ln.strip().startswith(("Code:", "* ", "URL:", "Error"))]
|
|
summary["exit"] = error.returncode
|
|
summary["detail"] = [TOKENISH.sub("[redacted]", ln)[:200] for ln in lines[:4]]
|
|
return summary
|
|
|
|
|
|
def require_attended() -> None:
|
|
if pathlib.Path.home().parent.name != ".warden-attended-login" or os.getenv("BAO_TOKEN") or os.getenv("VAULT_TOKEN"):
|
|
raise Refused("attended_envelope_required")
|
|
|
|
|
|
def mounts() -> dict:
|
|
return {path: {"type": m.get("type"), "accessor_present": bool(m.get("accessor"))}
|
|
for path, m in bao_json("read", "sys/auth")["data"].items()}
|
|
|
|
|
|
def session() -> dict:
|
|
data = bao_json("read", "auth/token/lookup-self")["data"]
|
|
return {"path": data.get("path"), "policies": sorted(data.get("policies") or [])}
|
|
|
|
|
|
def alias_view() -> dict:
|
|
view: dict = {}
|
|
try:
|
|
listed = bao_json("list", "auth/keycape/role") # `bao list -format=json` prints a bare array
|
|
keys = listed if isinstance(listed, list) else listed.get("data", {}).get("keys", [])
|
|
view["roles"] = sorted(str(k) for k in keys)
|
|
except subprocess.CalledProcessError:
|
|
view["roles"] = None
|
|
try:
|
|
cfg = bao_json("read", "auth/keycape/config")["data"]
|
|
view["oidc_client_id"] = cfg.get("oidc_client_id")
|
|
view["default_role"] = cfg.get("default_role")
|
|
except subprocess.CalledProcessError:
|
|
view["oidc_client_id"] = None
|
|
return view
|
|
|
|
|
|
def run(apply: bool) -> dict:
|
|
require_attended()
|
|
me = session()
|
|
if not str(me["path"]).startswith("auth/" + KEEP):
|
|
raise Refused("session_not_from_auth_netkingdom")
|
|
before = mounts()
|
|
result = {"session": me, "mounts_before": sorted(before), "applied": False}
|
|
if MOUNT not in before:
|
|
return {**result, "status": "already_disabled"}
|
|
if before[MOUNT]["type"] != "oidc":
|
|
raise Refused("keycape_mount_not_oidc")
|
|
if KEEP not in before:
|
|
raise Refused("auth_netkingdom_missing")
|
|
result["alias"] = alias_view()
|
|
if not apply:
|
|
return {**result, "status": "dry_run_ok"}
|
|
STEP["name"] = "auth disable"
|
|
subprocess.run(["bao", "auth", "disable", MOUNT.rstrip("/")], capture_output=True, check=True, timeout=60)
|
|
after = mounts()
|
|
still_me = session() # our own token, from auth/netkingdom, must survive
|
|
ok = MOUNT not in after and KEEP in after and still_me["path"] == me["path"]
|
|
return {**result, "applied": True, "mounts_after": sorted(after), "session_after_ok": still_me["path"] == me["path"],
|
|
"status": "disabled_verified" if ok else "readback_mismatch"}
|
|
|
|
|
|
def write_receipt(path: str, body: dict) -> None:
|
|
fd = os.open(pathlib.Path(path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
json.dump({"schema": SCHEMA, "observed_at": datetime.now(timezone.utc).isoformat(),
|
|
"credential_values_emitted": False, **body}, handle, indent=2, sort_keys=True)
|
|
handle.write("\n")
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--receipt", required=True)
|
|
ap.add_argument("--apply", action="store_true")
|
|
args = ap.parse_args(argv)
|
|
if not os.path.isabs(args.receipt):
|
|
return 2
|
|
try:
|
|
body = run(args.apply)
|
|
except Refused as error:
|
|
body = {"status": str(error), "applied": False}
|
|
except Exception as error: # noqa: BLE001 - every failure must still leave a receipt
|
|
body = {"status": "bao_failed" if isinstance(error, subprocess.CalledProcessError) else "helper_failed",
|
|
"applied": None if STEP["name"] == "auth disable" else False, **error_summary(error)}
|
|
write_receipt(args.receipt, body)
|
|
return 0 if body["status"] in ("dry_run_ok", "disabled_verified", "already_disabled") else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|