boma/scripts/check-vault.py
sjat 9d4a49d49d feat(vault): CHANGEME placeholder convention + check-vault flags them
Streamline the recurring secret-entry friction: the agent stubs a needed secret as
vault.<service>.<key>=CHANGEME with a what/how-to-obtain comment, wires the code,
and commits; the operator fills it via make edit-vault (real value never hits chat).
check-vault now lists outstanding CHANGEME placeholders so none are forgotten.
Convention documented in CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:40:37 +02:00

116 lines
4.5 KiB
Python
Executable file

#!/usr/bin/env python3
"""Validate an ansible-vault file's structure without exposing secret values.
Decrypts in-memory via ``ansible-vault view`` (using the configured
``vault_password_file`` from ansible.cfg — so rbw must be unlocked), parses the
YAML, and checks:
- the file is ansible-vault encrypted on disk;
- it decrypts to valid YAML that is a mapping;
- top-level keys are within the allowed set (``vault`` + the ``vault__confirm``
canary) — secrets belong under the nested ``vault:`` map (CLAUDE.md);
- ``vault.<service>.<key>`` leaves are all non-empty strings;
- reports leaves still set to the ``CHANGEME`` sentinel — a secret the agent stubbed
that the operator must fill via ``make edit-vault``.
Prints a REDACTED view (comments + key tree, values masked) so a human can eyeball
format and comments. Secret values are never printed.
Unlike the stdlib-only utility scripts (TODO 14), this one deliberately depends on
the ansible venv (PyYAML) + ``ansible-vault`` + rbw — it is a vault tool, not a
run-anywhere helper. Invoked by ``make check-vault`` / ``make edit-vault``.
Usage: check-vault.py [VAULT_FILE]
"""
import pathlib
import subprocess
import sys
try:
import yaml
except ImportError:
sys.exit("check-vault: needs PyYAML — run inside the ansible venv (make check-vault)")
DEFAULT = "inventories/production/group_vars/all/vault.yml"
ALLOWED_TOPLEVEL = {"vault", "vault__confirm"}
SENTINEL = "CHANGEME" # placeholder the agent writes for a secret the operator must fill
def main() -> int:
path = sys.argv[1] if len(sys.argv) > 1 else DEFAULT
p = pathlib.Path(path)
if not p.exists():
sys.exit(f"check-vault: {path} not found")
if not p.read_text(errors="replace").startswith("$ANSIBLE_VAULT"):
sys.exit(f"check-vault: {path} is not ansible-vault encrypted")
# Decrypt in-memory via the venv's ansible-vault (picks up vault_password_file).
av = pathlib.Path(sys.executable).parent / "ansible-vault"
av = str(av) if av.exists() else "ansible-vault"
r = subprocess.run([av, "view", path], capture_output=True, text=True)
if r.returncode != 0:
sys.exit(f"check-vault: cannot decrypt {path} (is rbw unlocked?)\n{r.stderr.strip()[:300]}")
text = r.stdout
try:
data = yaml.safe_load(text)
except yaml.YAMLError as e:
sys.exit(f"check-vault: invalid YAML after decrypt: {e}")
if not isinstance(data, dict):
sys.exit("check-vault: vault root is not a mapping")
errors = []
placeholders = []
extra = set(data) - ALLOWED_TOPLEVEL
if extra:
errors.append(
f"unexpected top-level key(s) {sorted(extra)} — secrets belong under "
f"the `vault:` map (CLAUDE.md)")
if "vault" not in data:
errors.append("missing top-level `vault:` map")
elif not isinstance(data["vault"], dict):
errors.append("`vault:` is not a mapping")
else:
for svc, kv in data["vault"].items():
if not isinstance(kv, dict):
errors.append(f"vault.{svc} is not a mapping of <key>: <secret>")
continue
for k, v in kv.items():
if not isinstance(v, str) or not v.strip():
errors.append(f"vault.{svc}.{k} is empty or not a string")
elif v.strip() == SENTINEL:
placeholders.append(f"vault.{svc}.{k}")
# Redacted structure (comments + masked values) for human review.
print(f"# {path} — redacted structure (secret values masked)")
for line in text.splitlines():
s = line.strip()
if not s or s.startswith("#") or s.endswith(":"):
print(line)
elif ":" in line:
print(line.split(":", 1)[0] + ': "***"')
else:
print(" ***")
if isinstance(data.get("vault"), dict):
print("\n# services under vault: " + ", ".join(sorted(data["vault"])))
if placeholders:
print("\n# placeholders awaiting real values (see the comments above for how to obtain each):")
for ph in placeholders:
print(f"# - {ph}")
if errors:
print("\ncheck-vault: FAIL", file=sys.stderr)
for e in errors:
print(f" - {e}", file=sys.stderr)
return 1
if placeholders:
print(f"\ncheck-vault: OK — {len(placeholders)} placeholder(s) still need real values "
f"(fill via `make edit-vault`): {', '.join(placeholders)}")
return 0
print("\ncheck-vault: OK")
return 0
if __name__ == "__main__":
sys.exit(main())