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
+26 -12
View File
@@ -108,6 +108,13 @@ def is_editable_source(source: dict) -> bool:
return source.get("type") == "local" and source.get("name") != BUILTIN_SOURCE_NAME
def _open_token(src: dict) -> str:
"""The source's git token in the clear (it is stored sealed)."""
from steward.core.crypto import decrypt_secret
from steward.core.settings import source_token_key
return decrypt_secret(src.get("http_token") or "", context=source_token_key(src.get("name", "?")))
def get_sources(ansible_cfg: dict) -> list[dict]:
"""Return resolved source list from ansible config section.
@@ -149,7 +156,7 @@ def get_sources(ansible_cfg: dict) -> list[dict]:
"url": src.get("url"),
"branch": src.get("branch", "main"),
"pull_interval_seconds": int(src.get("pull_interval_seconds", 3600)),
"http_token": src.get("http_token", "") if src_type == "git" else "",
"http_token": _open_token(src) if src_type == "git" else "",
})
return result
@@ -323,11 +330,25 @@ def validate_playbook_yaml(content: str) -> tuple[bool, str | None]:
return True, None
# The askpass script holds no secret: git runs it with the token in its
# environment, and printf prints it as data, so no character in a token can
# become shell syntax.
ASKPASS_TOKEN_ENV = "STEWARD_GIT_TOKEN"
ASKPASS_SCRIPT = (
"#!/bin/sh\n"
'case "$1" in\n'
" Username*) printf '%s\\n' oauth2 ;;\n"
f" Password*) printf '%s\\n' \"${ASKPASS_TOKEN_ENV}\" ;;\n"
"esac\n"
)
async def git_pull(source: dict) -> None:
"""Clone the git repo if absent; pull if already present.
When source['http_token'] is set, injects credentials via a temporary
GIT_ASKPASS script so they never touch .git/config or process args.
When source['http_token'] is set, git asks a temporary GIT_ASKPASS script,
which prints the token from the child's environment, so it never touches
.git/config, process args or a file.
"""
path = Path(source["path"])
token = source.get("http_token", "")
@@ -337,18 +358,11 @@ async def git_pull(source: dict) -> None:
if token:
fd, askpass_path = tempfile.mkstemp(prefix="steward-askpass-", suffix=".sh")
try:
script = (
"#!/bin/sh\n"
'case "$1" in\n'
' Username*) echo "oauth2" ;;\n'
f' Password*) echo "{token}" ;;\n'
"esac\n"
)
os.write(fd, script.encode())
os.write(fd, ASKPASS_SCRIPT.encode())
finally:
os.close(fd)
os.chmod(askpass_path, stat.S_IRWXU)
env = {**os.environ, "GIT_ASKPASS": askpass_path}
env = {**os.environ, "GIT_ASKPASS": askpass_path, ASKPASS_TOKEN_ENV: token}
try:
if not (path / ".git").exists():