M7/D6: secrets rotation doc + log redaction filter
All checks were successful
continuous-integration/drone/push Build is passing

docs/secrets.md documents the 3 secret classes (A1 external, A2 internal-generated, B recipe-app),
the sops-nix decryption chain, and rotation procedures for each (cert version bump, sops re-encrypt +
swarm-secret version bump, recipe-app ephemeral). run_recipe_ci streams each stage's output through a
redaction filter that masks any /run/secrets/* value (>=8 chars) before it reaches Drone logs —
belt-and-suspenders over 'harness never prints secrets + abra doesn't echo'. Live streaming + exit
code preserved (locally tested). Recipe-ci clones cc-ci fresh per build, so this applies next run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 07:44:53 +01:00
parent b832a8d844
commit fc07d15800
2 changed files with 127 additions and 3 deletions

View File

@ -33,6 +33,40 @@ STAGE_FILES = {
}
def _redact_values() -> list[str]:
"""Values to scrub from published logs (D6 redaction filter, plan §4.4). The infra secrets
materialised at /run/secrets/* — if any subprocess ever echoes one, mask it. Only >=8-char
values, so it never false-positives on short strings / SHAs."""
vals = set()
for p in glob.glob("/run/secrets/*"):
try:
v = open(p).read().strip()
except OSError:
continue
if len(v) >= 8:
vals.add(v)
return sorted(vals, key=len, reverse=True)
_REDACT = _redact_values()
def run_stage_redacted(cmd: list[str], env: dict | None = None) -> int:
"""Run a stage subprocess, streaming its output live (so Drone logs stay tail-able) but masking
any known infra-secret value first. Belt-and-suspenders: the harness already never prints
secrets and abra doesn't echo generated ones."""
proc = subprocess.Popen(cmd, cwd=ROOT, env=env, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True, bufsize=1)
assert proc.stdout is not None
for line in proc.stdout:
for v in _REDACT:
if v in line:
line = line.replace(v, "***REDACTED***")
sys.stdout.write(line)
sys.stdout.flush()
return proc.wait()
def _gitea_token() -> str | None:
tok = os.environ.get("GITEA_TOKEN")
if not tok and os.path.exists("/run/secrets/bridge_gitea_token"):
@ -94,7 +128,7 @@ def main() -> int:
continue
print(f"\n===== STAGE: {stage} =====", flush=True)
# each stage is its own pytest invocation => its own reported result (D2 separate stages)
rc = subprocess.call([sys.executable, "-m", "pytest", "-v", "-rA", path], cwd=ROOT)
rc = run_stage_redacted([sys.executable, "-m", "pytest", "-v", "-rA", path])
ran += 1
if rc != 0:
overall = rc
@ -135,8 +169,7 @@ def run_recipe_local(recipe: str, local_tests: str | None) -> int | None:
lifecycle.deploy_app(recipe, domain, version=os.environ.get("VERSION") or None)
lifecycle.wait_healthy(domain)
env = dict(os.environ, CCCI_APP_DOMAIN=domain, CCCI_BASE_URL=f"https://{domain}")
return subprocess.call([sys.executable, "-m", "pytest", "-v", "-rA", local_tests],
cwd=ROOT, env=env)
return run_stage_redacted([sys.executable, "-m", "pytest", "-v", "-rA", local_tests], env=env)
finally:
lifecycle.teardown_app(domain, verify=False)