R-624: the bench (only) seeds vaultwarden through its admin invite; run secrets redacted and shredded

`09` §3 decision 146. vaultwarden's fixture tries the household's own
/identity/accounts/register first (400 while sign-up is closed, R-512); on
the BENCH ONLY it then signs in to /admin with the ADMIN_TOKEN the bench
generated for this run, invites the drill address and registers it — the
route measured on 9202 2026-09-15 (E1-vaultwarden-spike). The token goes to
curl on stdin, the admin cookie in a 0600 header file that is shredded.
The dead /api/accounts/register (404 on 1.36) is gone.

bench_admin_seed_allowed(): the venue is the bench's (upgrade_boxport.Venue
VENUE="bench"), FELHOM_BENCH_ADMIN_SEED=1, and /opt/docker/stacks does not
exist (every Felhom box has it). Any one missing refuses; the edge stays
inconclusive with what was tried.

upgrade-test.py: the run's .env is written 0600 and shredded after the
teardown; every printed line and every evidence file is redacted of the
generated deploy secrets and the fixture's own password/key.
zipline needs no held secret: its first-run /api/setup already makes the
SUPERADMIN with a per-run password (measured 2026-09-30), now redacted too.
Tests: BenchAdminSeedGuard, SecretHygiene (red-proved).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-06 11:26:23 +02:00
parent b0939cf309
commit aed80ee143
4 changed files with 382 additions and 14 deletions
+92 -2
View File
@@ -205,10 +205,93 @@ def render(app: str, images: dict, workdir: Path, env: dict, template: str = Non
out.append(line)
workdir.mkdir(parents=True, exist_ok=True)
(workdir / "docker-compose.yml").write_text(apply_bench_overrides(app, pg_mounts_for("\n".join(out) + "\n")))
(workdir / ".env").write_text("".join(f"{k}={v}\n" for k, v in env.items()))
write_secret_file(workdir / ".env", "".join(f"{k}={v}\n" for k, v in env.items()))
return workdir / "docker-compose.yml"
# --- R-624 (`09` §3 decision 146): the run's secrets never reach a file that outlives the run, or any output --------
#
# The bench GENERATES each app's deploy secrets per run (build_env) — vaultwarden's ADMIN_TOKEN among them, which the
# bench-only admin seed uses. They live in memory and in ONE file compose must read: the run's `.env`, written 0600 and
# shredded after the teardown. Every line the run prints and every evidence file it leaves is passed through redact()
# (pinned by scripts/test_upgrade_bench.py: SecretHygiene).
REDACTED = "<redacted>"
SECRET_FIELD_TYPES = ("password", "secret", "secret_input")
SEED_SECRET_KEYS = ("pw", "key", "password", "token", "admin_token")
def write_secret_file(path: Path, text: str):
"""Write `text` to `path` readable by its owner only (0600 from the first byte, not chmod after)."""
path = Path(path)
try:
path.unlink()
except FileNotFoundError:
pass
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w") as fh:
fh.write(text)
def shred_file(path: Path):
"""Overwrite with zeros, fsync, remove. A missing file is fine."""
try:
n = os.path.getsize(path)
with open(path, "r+b") as fh:
fh.write(b"\0" * n)
fh.flush()
os.fsync(fh.fileno())
os.unlink(path)
except OSError:
pass
def secret_values(felhom_text: str, env: dict, seeded=None) -> list:
"""Every value this run must never print: each deploy field the template generates or types as a secret, and the
fixture's own secret fields (its password / key). Longest first, so a value inside another is not half-redacted.
Values shorter than 8 characters are not secrets the bench made (and would redact ordinary words)."""
out = set()
for f in cvp.parse_deploy_fields(felhom_text) if cvp else []:
if f.get("generate") or f.get("type") in SECRET_FIELD_TYPES:
v = env.get(f["env_var"])
if v:
out.add(str(v))
if isinstance(seeded, dict):
for k, v in seeded.items():
if k in SEED_SECRET_KEYS and isinstance(v, str):
out.add(v)
return sorted((v for v in out if len(v) >= 8), key=len, reverse=True)
def redact(text, secrets_: list):
if not text or not secrets_:
return text
for v in secrets_:
text = text.replace(v, REDACTED)
return text
def redact_tree(root: Path, secrets_: list) -> list:
"""Redact every file under the run's evidence directory in place; returns the files that held a secret."""
hit = []
if not secrets_:
return hit
enc = [(v.encode(), REDACTED.encode()) for v in secrets_]
for p in sorted(Path(root).rglob("*")):
if not p.is_file() or p.is_symlink():
continue
try:
b = p.read_bytes()
except OSError:
continue
nb = b
for v, r in enc:
nb = nb.replace(v, r)
if nb != b:
p.write_bytes(nb)
hit.append(str(p))
return hit
def compose(workdir: Path, project: str, *args, timeout=1800):
return cvp._sh(["docker", "compose", "-p", project, "-f", str(workdir / "docker-compose.yml"),
"--env-file", str(workdir / ".env")] + list(args), timeout=timeout)
@@ -877,9 +960,10 @@ def run_edge(edge_id: str) -> dict:
"duration_s": 0, "measured_at": None, "evidence": f"evidence/{edge_id}"}
t0 = time.time()
log = []
secrets_ = secret_values(felhom, env) # R-624: grows by the fixture's own secrets after the seed
def say(msg):
line = f"[{datetime.now(timezone.utc).strftime('%H:%M:%S')}] {msg}"
line = redact(f"[{datetime.now(timezone.utc).strftime('%H:%M:%S')}] {msg}", secrets_)
print(line, flush=True)
log.append(line)
@@ -911,6 +995,7 @@ def run_edge(edge_id: str) -> dict:
say("no fixture for this app — inconclusive")
return rec
seeded = fixture.seed(container_ip, say)
secrets_[:] = sorted(set(secrets_) | set(secret_values(felhom, env, seeded)), key=len, reverse=True)
if seeded is None:
rec["verdict"] = "inconclusive"
rec["abort_detail"] = "no non-browser seed route" + (
@@ -1038,6 +1123,11 @@ def run_edge(edge_id: str) -> dict:
(ev / "compose-final.log").write_text((lg.stdout + lg.stderr)[-400000:]) # post-abort state only — see to-full.log
(ev / "verdict.json").write_text(json.dumps(rec, indent=2))
compose(workdir, project, "down", "-v", "--remove-orphans", timeout=900)
# R-624: no evidence file keeps a secret this run made, and the run's .env does not outlive it.
held = redact_tree(ev, secrets_)
if held:
print(f"[redact] a run secret was removed from {len(held)} evidence file(s): {held}", flush=True)
shred_file(workdir / ".env")
SOAK_SECONDS = 600