feat(secrets): webhook URL and git tokens sealed; run output masks its secrets (#5037)
#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:
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user