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:
@@ -77,9 +77,9 @@ async def test_git_pull_creates_askpass_when_token_set(tmp_path):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_git_pull_askpass_contains_token(tmp_path):
|
||||
"""The askpass script content includes the token before cleanup."""
|
||||
from steward.ansible.sources import git_pull
|
||||
async def test_git_pull_askpass_reads_the_token_from_its_environment(tmp_path):
|
||||
"""The askpass script holds no token; git's environment carries it."""
|
||||
from steward.ansible.sources import ASKPASS_TOKEN_ENV, git_pull
|
||||
|
||||
repo_path = tmp_path / "repo"
|
||||
(repo_path / ".git").mkdir(parents=True)
|
||||
@@ -89,17 +89,19 @@ async def test_git_pull_askpass_contains_token(tmp_path):
|
||||
"path": str(repo_path),
|
||||
"url": "https://git.example.com/org/repo.git",
|
||||
"branch": "main",
|
||||
"http_token": "secret-token",
|
||||
"http_token": 'tok"$(id)`x`',
|
||||
}
|
||||
|
||||
script_content_captured = {}
|
||||
captured = {}
|
||||
|
||||
async def fake_subprocess(*args, **kwargs):
|
||||
askpass = kwargs.get("env", {}).get("GIT_ASKPASS", "")
|
||||
env = kwargs.get("env", {})
|
||||
askpass = env.get("GIT_ASKPASS", "")
|
||||
if askpass and os.path.exists(askpass):
|
||||
script_content_captured["content"] = Path(askpass).read_text()
|
||||
captured["content"] = Path(askpass).read_text()
|
||||
mode = stat.S_IMODE(os.stat(askpass).st_mode)
|
||||
script_content_captured["executable"] = bool(mode & stat.S_IXUSR)
|
||||
captured["executable"] = bool(mode & stat.S_IXUSR)
|
||||
captured["token"] = env.get(ASKPASS_TOKEN_ENV)
|
||||
proc = MagicMock()
|
||||
proc.returncode = 0
|
||||
proc.communicate = AsyncMock(return_value=(b"", b""))
|
||||
@@ -108,9 +110,46 @@ async def test_git_pull_askpass_contains_token(tmp_path):
|
||||
with patch("asyncio.create_subprocess_exec", side_effect=fake_subprocess):
|
||||
await git_pull(source)
|
||||
|
||||
assert "secret-token" in script_content_captured.get("content", "")
|
||||
assert "oauth2" in script_content_captured.get("content", "")
|
||||
assert script_content_captured.get("executable") is True
|
||||
assert "tok" not in captured["content"]
|
||||
assert "oauth2" in captured["content"]
|
||||
assert captured["executable"] is True
|
||||
assert captured["token"] == 'tok"$(id)`x`'
|
||||
|
||||
|
||||
def test_askpass_prints_a_hostile_token_as_data(tmp_path):
|
||||
"""Run the real script: shell syntax in a token comes back verbatim."""
|
||||
import subprocess
|
||||
from steward.ansible.sources import ASKPASS_SCRIPT, ASKPASS_TOKEN_ENV
|
||||
|
||||
script = tmp_path / "askpass.sh"
|
||||
script.write_text(ASKPASS_SCRIPT)
|
||||
script.chmod(0o700)
|
||||
token = 'a"b$(touch pwned)`id` c\\'
|
||||
env = {"PATH": os.environ.get("PATH", ""), ASKPASS_TOKEN_ENV: token}
|
||||
out = subprocess.run([str(script), "Password for 'https://x'"], env=env, cwd=tmp_path,
|
||||
capture_output=True, text=True, check=True).stdout
|
||||
assert out == token + "\n"
|
||||
assert not (tmp_path / "pwned").exists()
|
||||
user = subprocess.run([str(script), "Username for 'https://x'"], env=env,
|
||||
capture_output=True, text=True, check=True).stdout
|
||||
assert user == "oauth2\n"
|
||||
|
||||
|
||||
def test_sealed_tokens_open_in_get_sources(tmp_path, monkeypatch):
|
||||
from steward.ansible.sources import get_sources
|
||||
from steward.core import crypto
|
||||
from steward.core.settings import seal_source_tokens
|
||||
|
||||
monkeypatch.setattr(crypto, "_fernet", crypto._make_fernet("unit-test-key"))
|
||||
stored, count = seal_source_tokens([
|
||||
{"name": "repo", "type": "git", "url": "https://x/r.git", "http_token": "mytoken123"},
|
||||
{"name": "plain", "type": "git", "url": "https://x/p.git"},
|
||||
])
|
||||
assert count == 1 and crypto.is_encrypted(stored[0]["http_token"])
|
||||
assert seal_source_tokens(stored) == (stored, 0) # sealing twice changes nothing
|
||||
src = next(s for s in get_sources({"cache_dir": str(tmp_path), "sources": stored})
|
||||
if s["name"] == "repo")
|
||||
assert src["http_token"] == "mytoken123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""A run's secrets never reach its output: each line is masked before it is
|
||||
broadcast, written to the log artifact or stored on the run."""
|
||||
from steward.ansible.executor import MASK, run_secrets, scrub
|
||||
|
||||
|
||||
def test_run_secrets_gathers_everything_the_run_is_handed():
|
||||
secrets = run_secrets(
|
||||
{"become_password": "become-pw", "vault_password": "vault-pw", "ssh_user": "steward"},
|
||||
{"user": "root", "password": "boot-pw"},
|
||||
{"db": {"password": "nested-pw"}, "api_keys": ["key-one"], "port": 5432},
|
||||
["agent-token-abc"],
|
||||
)
|
||||
assert set(secrets) == {"become-pw", "vault-pw", "boot-pw", "nested-pw", "key-one",
|
||||
"agent-token-abc"}
|
||||
assert "steward" not in secrets and "root" not in secrets
|
||||
|
||||
|
||||
def test_short_values_are_not_masked():
|
||||
assert run_secrets(None, None, {"flag": "on", "n": "1"}, None) == []
|
||||
|
||||
|
||||
def test_scrub_masks_every_occurrence_longest_first():
|
||||
secrets = run_secrets(None, None, {"a": "hunter2", "b": "hunter2-extended"}, None)
|
||||
line = 'fatal: [h]: FAILED! => {"msg": "hunter2-extended / hunter2"}'
|
||||
assert scrub(line, secrets) == f'fatal: [h]: FAILED! => {{"msg": "{MASK} / {MASK}"}}'
|
||||
|
||||
|
||||
def test_scrub_leaves_clean_lines_alone():
|
||||
assert scrub("ok: [web1]", ["agent-token-abc"]) == "ok: [web1]"
|
||||
|
||||
|
||||
def test_bundled_tasks_that_template_the_agent_token_are_no_log():
|
||||
"""Every bundled task whose args mention steward_token sets no_log
|
||||
(assert-style checks excepted: they print a fixed fail_msg)."""
|
||||
import pathlib
|
||||
import yaml
|
||||
import steward.ansible
|
||||
|
||||
def missing(plays) -> list[str]:
|
||||
out = []
|
||||
for play in plays or []:
|
||||
for task in (play.get("tasks") or []) + (play.get("handlers") or []):
|
||||
body = {k: v for k, v in task.items() if k not in ("name", "no_log")}
|
||||
if "ansible.builtin.assert" in body or "assert" in body:
|
||||
continue
|
||||
if "steward_token" in yaml.safe_dump(body) and not task.get("no_log"):
|
||||
out.append(task.get("name", "?"))
|
||||
return out
|
||||
|
||||
# The check can fail.
|
||||
assert missing([{"tasks": [{"name": "leaky", "copy": {"content": "{{ steward_token }}"}}]}]) == ["leaky"]
|
||||
|
||||
bundled = pathlib.Path(steward.ansible.__file__).parent / "bundled"
|
||||
files = sorted(bundled.rglob("*.yml"))
|
||||
assert files
|
||||
leaks = [f"{f.name}: {t}" for f in files for t in missing(yaml.safe_load(f.read_text()))]
|
||||
assert not leaks, leaks
|
||||
Reference in New Issue
Block a user