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
+43
View File
@@ -160,6 +160,9 @@ SECRET_KEYS: set[str] = {
"ansible.ssh_private_key",
"ansible.become_password",
"ansible.vault_password",
# A webhook URL carries its own credential (Discord, Slack and ntfy put the
# token in the path).
"webhook.url",
}
@@ -201,6 +204,31 @@ def _is_undecryptable(stored: Any, key: str = "") -> bool:
)
# Git sources live as a list under one setting, so their tokens are sealed
# field by field rather than through SECRET_KEYS.
SOURCES_KEY = "ansible.sources"
def seal_source_tokens(sources: Any) -> tuple[Any, int]:
"""Encrypt each source's plaintext http_token; returns (sources, how many)."""
from steward.core.crypto import encrypt_secret, is_encrypted
if not isinstance(sources, list):
return sources, 0
sealed, count = [], 0
for src in sources:
token = src.get("http_token") if isinstance(src, dict) else None
if isinstance(token, str) and token and not is_encrypted(token):
src = {**src, "http_token": encrypt_secret(token)}
count += 1
sealed.append(src)
return sealed, count
def source_token_key(name: str) -> str:
"""How a source's token is named in logs and the undecryptable-secrets banner."""
return f"{SOURCES_KEY}[{name}].http_token"
# ─────────────────────────────────────────────────────────────────────────────
# Async helpers (use inside request handlers / scheduled tasks)
# ─────────────────────────────────────────────────────────────────────────────
@@ -507,6 +535,13 @@ def migrate_plaintext_secrets(db_url: str) -> int:
if isinstance(val, str) and val and not is_encrypted(val):
row.value_json = json.dumps(encrypt_secret(val))
converted += 1
row = (await session.execute(
select(AppSetting).where(AppSetting.key == SOURCES_KEY))).scalar_one_or_none()
if row is not None:
sources, sealed = seal_source_tokens(json.loads(row.value_json))
if sealed:
row.value_json = json.dumps(sources)
converted += sealed
finally:
await engine.dispose()
return converted
@@ -535,6 +570,14 @@ def scan_undecryptable_secrets(db_url: str) -> list[str]:
for row in result.scalars():
if _is_undecryptable(json.loads(row.value_json), row.key):
bad.append(row.key)
row = (await session.execute(
select(AppSetting).where(AppSetting.key == SOURCES_KEY))).scalar_one_or_none()
sources = json.loads(row.value_json) if row is not None else []
for src in sources if isinstance(sources, list) else []:
if isinstance(src, dict):
key = source_token_key(src.get("name", "?"))
if _is_undecryptable(src.get("http_token"), key):
bad.append(key)
finally:
await engine.dispose()
return bad