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:
+26
-12
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user