2026-06-14 09:36:15 +02:00
|
|
|
#!/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);
|
2026-06-14 15:40:37 +02:00
|
|
|
- ``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``.
|
2026-06-14 09:36:15 +02:00
|
|
|
|
|
|
|
|
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"}
|
2026-06-14 15:40:37 +02:00
|
|
|
SENTINEL = "CHANGEME" # placeholder the agent writes for a secret the operator must fill
|
2026-06-14 09:36:15 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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 = []
|
2026-06-14 15:40:37 +02:00
|
|
|
placeholders = []
|
2026-06-14 09:36:15 +02:00
|
|
|
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")
|
2026-06-14 15:40:37 +02:00
|
|
|
elif v.strip() == SENTINEL:
|
|
|
|
|
placeholders.append(f"vault.{svc}.{k}")
|
2026-06-14 09:36:15 +02:00
|
|
|
|
|
|
|
|
# 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"])))
|
|
|
|
|
|
2026-06-14 15:40:37 +02:00
|
|
|
if placeholders:
|
|
|
|
|
print("\n# placeholders awaiting real values (see the comments above for how to obtain each):")
|
|
|
|
|
for ph in placeholders:
|
|
|
|
|
print(f"# - {ph}")
|
|
|
|
|
|
2026-06-14 09:36:15 +02:00
|
|
|
if errors:
|
|
|
|
|
print("\ncheck-vault: FAIL", file=sys.stderr)
|
|
|
|
|
for e in errors:
|
|
|
|
|
print(f" - {e}", file=sys.stderr)
|
|
|
|
|
return 1
|
2026-06-14 15:40:37 +02:00
|
|
|
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
|
2026-06-14 09:36:15 +02:00
|
|
|
print("\ncheck-vault: OK")
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
sys.exit(main())
|