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:
+92
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user