feat(secrets): webhook URL and git tokens sealed; run output masks its secrets (#5037)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 47s
CI / unit (push) Successful in 1m5s
CI / publish (push) Successful in 54s

#5037 "Secrets at rest and in run output: webhook URL and git token
encrypted, askpass via env, secret values scrubbed from logs".

- webhook.url joins SECRET_KEYS; existing values are sealed at startup by
  migrate_plaintext_secrets. Settings -> Notifications no longer echoes the
  URL: it names the saved host, keeps it when left blank, and has a Remove
  box.
- Git source http_token is sealed inside ansible.sources on save and at
  startup, opened in get_sources, and counted by the undecryptable-secrets
  scan as ansible.sources[<name>].http_token.
- The askpass script holds no token: git runs it with STEWARD_GIT_TOKEN in
  its environment and printf prints it as data.
- The executor masks every secret the run is handed (become/vault
  passwords, bootstrap passwords, sensitive run vars, and the per-host agent
  tokens the host_agent plugin now passes as redact) in each line before it
  is broadcast, logged or stored.
- Bundled "Write agent config" tasks are no_log; a test fails if any bundled
  task templating steward_token lacks it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 11:14:37 -04:00
co-authored by Claude Opus 5.5
parent ddf13110e9
commit faa605cead
11 changed files with 252 additions and 35 deletions
+45 -3
View File
@@ -293,6 +293,45 @@ def cancel_run(run_id: str) -> bool:
# ── Run execution ─────────────────────────────────────────────────────────────
# ── Secret scrubbing ──────────────────────────────────────────────────────────
# Ansible echoes values back: a failed task prints its args, -v prints vars,
# --diff prints templated files. Each line is masked before it is broadcast,
# written to the log artifact or stored on the run, so a secret the run was
# handed never leaves it.
MASK = "***"
_MIN_SECRET_LEN = 4 # masking "on" or "1" would mangle the output and hide nothing
def _strings(value) -> list[str]:
if isinstance(value, str):
return [value]
if isinstance(value, dict):
return [s for v in value.values() for s in _strings(v)]
if isinstance(value, (list, tuple, set)):
return [s for v in value for s in _strings(v)]
return []
def run_secrets(creds: dict | None, connection: dict | None,
secret_vars: dict | None, redact: list[str] | None) -> list[str]:
"""Every secret value this run is handed: the global become/vault
passwords, the bootstrap passwords, the sensitive run variables and any
the caller names (a per-host agent token)."""
creds, connection = creds or {}, connection or {}
values = [creds.get("become_password"), creds.get("vault_password"),
connection.get("password"), connection.get("become_password"),
*_strings(secret_vars), *(redact or [])]
found = {v for v in values if isinstance(v, str) and len(v) >= _MIN_SECRET_LEN}
return sorted(found, key=len, reverse=True) # longest first: no partial masks
def scrub(line: str, secrets: list[str]) -> str:
for value in secrets:
if value in line:
line = line.replace(value, MASK)
return line
async def start_run(
app: "Quart",
run_id: str,
@@ -303,6 +342,7 @@ async def start_run(
inventory_content: str | None = None,
connection: dict | None = None,
secret_vars: dict | None = None,
redact: list[str] | None = None,
) -> None:
"""Execute ansible-playbook as a subprocess and update the DB run row.
@@ -315,10 +355,12 @@ async def start_run(
never persisted on the AnsibleRun row, never placed on argv, and not logged.
secret_vars are run-time playbook variables flagged sensitive; like
connection they reach the subprocess (merged into the extra-vars file) but
are never persisted.
are never persisted. Every value in run_secrets() — redact names more — is
masked in each output line before it goes anywhere.
"""
from steward.models.ansible import AnsibleRunStatus
secrets = run_secrets(app.config.get("ANSIBLE", {}), connection, secret_vars, redact)
_run_lines[run_id] = []
db_output = ""
truncated = False
@@ -432,7 +474,7 @@ async def start_run(
assert proc.stdout is not None
async for raw_line in proc.stdout:
line = raw_line.decode(errors="replace").rstrip("\n\r")
line = scrub(raw_line.decode(errors="replace").rstrip("\n\r"), secrets)
_broadcast(run_id, line)
if logf is not None:
logf.write(line + "\n")
@@ -478,7 +520,7 @@ async def start_run(
final_status = "cancelled" if run_id in _cancelled else "failed"
# Surface WHY in the run output instead of an empty "failed" (e.g. ENOSPC
# when the temp dir can't be created — the run never reaches Ansible).
msg = f"[run error] {type(exc).__name__}: {exc}"
msg = scrub(f"[run error] {type(exc).__name__}: {exc}", secrets)
_broadcast(run_id, msg)
if len(failures) < _FAILURE_CAP:
failures.append(msg[:500])