#!/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..`` 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 : ") 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())