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
+10 -4
View File
@@ -11,7 +11,7 @@ from steward.core.settings import (
get_all_settings, set_setting, delete_setting,
get_stored_plugin_names, find_orphaned_plugins,
to_smtp_cfg, to_webhook_cfg, to_ansible_cfg, to_plugins_cfg,
to_oidc_cfg, to_ldap_cfg, to_thresholds_cfg, to_access_cfg, DEFAULTS,
to_oidc_cfg, to_ldap_cfg, to_thresholds_cfg, to_access_cfg, DEFAULTS, seal_source_tokens,
)
settings_bp = Blueprint("settings", __name__, url_prefix="/settings")
@@ -208,7 +208,10 @@ async def save_thresholds():
async def notifications():
async with current_app.db_sessionmaker() as db:
settings = await get_all_settings(db)
return await render_template("settings/notifications.html", settings=settings)
from urllib.parse import urlsplit
webhook_host = urlsplit(settings.get("webhook.url") or "").hostname or ""
return await render_template("settings/notifications.html", settings=settings,
webhook_host=webhook_host)
@settings_bp.post("/notifications/")
@@ -226,7 +229,10 @@ async def save_notifications():
if form.get("smtp.password"):
await set_setting(db, "smtp.password", form.get("smtp.password"))
await set_setting(db, "smtp.recipients", recipients)
await set_setting(db, "webhook.url", form.get("webhook.url", ""))
if "webhook.remove" in form:
await set_setting(db, "webhook.url", "")
elif form.get("webhook.url", "").strip():
await set_setting(db, "webhook.url", form.get("webhook.url").strip())
await set_setting(db, "webhook.template", form.get("webhook.template", ""))
await _reload_app_config()
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
@@ -246,7 +252,7 @@ async def _get_ansible_sources() -> list[dict]:
async def _save_ansible_sources(sources: list[dict]) -> None:
async with current_app.db_sessionmaker() as db:
async with db.begin():
await set_setting(db, "ansible.sources", sources)
await set_setting(db, "ansible.sources", seal_source_tokens(sources)[0])
await _reload_app_config()