Skip to main content
RunBook Academy

← All checklists in Ansible

QuarterlySecurity

Checklist: Ansible secrets review

18 items ·13 critical ·4 warn ·1 info

Run this quarterly, and additionally whenever somebody leaves the team, a credential is suspected of exposure, or a new external secret store is introduced. Run it from a clean checkout of the default branch, on a machine that holds the production vault password - the decryption item cannot run anywhere else.

Set VAULT_PASS_FILE to the path of your vault password file before running the decryption sweep; the command reads it from the environment rather than having a path baked in.

What a failure means

There are two shapes of failure here and they need different responses.

A finding in the working tree - an unencrypted vault file, a task with no no_log, a plaintext default - is a defect to fix. Fix it and move on.

A finding in the log, the shell history or the Git history is an exposure. The credential has already been written somewhere it should not be, and cleaning up the file does not undo that. Rotate the credential; treat the cleanup as secondary.

Access this needs

The repository scans need a checkout only. The decryption sweep needs the vault password. The log scan needs read access to the Ansible log, which on most controllers means the automation account. Nothing here writes anything, and the two commands that could print a secret - the log scan and the shell history scan - both mask the value before printing.

Where the evidence goes

Record the date, the person who ran it, the findings and their disposition in the security review record. For any item that is attested rather than commanded

  • the holder list, the escrow, the leaver removals - record who attested it. Those three are the ones an auditor will ask about, and “we check it” is not an answer without a name and a date.

Sign-off

  • Reviewer: ________________ Date: ___________
  • Security owner: ___________ Date: ___________

Critical13 items

  1. grep -rInE '^[[:space:]]*[a-z0-9_]*(pass|passwd|password|secret|token|api[-_]?key|private[-_]?key|credential)[a-z0-9_]*[[:space:]]*:[[:space:]]*[^[:space:]{]' \
      --include='*.yml' --include='*.yaml' --include='*.j2' --exclude-dir=.git .
  2. find . -path ./.git -prune -o -type f \( -name '*vault*' -o -path '*/vault/*' \) -print \
      | while read -r f; do
          head -c 14 "$f" | grep -q '\$ANSIBLE_VAULT' \
            || echo "FINDING: $f is named as a vault file but is not encrypted"
        done
  3. grep -rlZ --include='*.yml' '^\$ANSIBLE_VAULT' inventories/ group_vars/ host_vars/ 2>/dev/null \
      | xargs -0 -r -n1 sh -c '
          ansible-vault view --vault-password-file "$VAULT_PASS_FILE" "$1" >/dev/null 2>&1 \
            || echo "FINDING: $1 cannot be decrypted with the configured vault password"' sh
  4. python3 - <<'PY'
    import glob, re, yaml
    SECRET = re.compile(r'(pass|passwd|password|secret|token|api[-_]?key|private[-_]?key|credential)', re.I)
    KEYS = ('tasks', 'pre_tasks', 'post_tasks', 'handlers', 'block', 'rescue', 'always')
    def tasks(node):
        if isinstance(node, list):
            for i in node: yield from tasks(i)
        elif isinstance(node, dict):
            for k in KEYS:
                yield from tasks(node.get(k) or [])
            if 'hosts' not in node:
                yield node
    for f in sorted(glob.glob('playbooks/**/*.yml', recursive=True)
                    + glob.glob('roles/**/tasks/*.yml', recursive=True)
                    + glob.glob('roles/**/handlers/*.yml', recursive=True)):
        try: doc = yaml.safe_load(open(f))
        except Exception: continue
        for t in tasks(doc):
            if not isinstance(t, dict) or t.get('no_log'): continue
            if SECRET.search(yaml.safe_dump(t)):
                print(f"FINDING: {f}: task {t.get('name')!r} handles a secret without no_log")
    PY
  5. grep -rInE '(command|shell|raw):.*(pass|password|token|secret|key)=' playbooks/ roles/
  6. grep -hE 'ansible(-playbook)?\b.*(--extra-vars|[[:space:]]-e[[:space:]]).*(pass|secret|token|key)' ~/.bash_history 2>/dev/null | sed 's/=[^ ]*/=REDACTED/g'
  7. p=$(ansible-config dump | sed -n 's/^DEFAULT_LOG_PATH([^)]*) = //p')
    [ -r "$p" ] && grep -nIE '(password|passwd|secret|token|api_key|private_key)"?[:=]' "$p" \
      | sed 's/\(password\|passwd\|secret\|token\|api_key\|private_key\)"\?[:=][^,}"]*/\1=REDACTED/gI'
  8. git log --all -p -S 'password' -- inventories/ group_vars/ host_vars/ | grep -nE '^\+.*(password|secret|token)' | head -50
  9. grep -rn 'gitleaks\|trufflehog\|detect-secrets\|secret' .gitlab-ci.yml .github/workflows/ 2>/dev/null
  10. ansible-config dump | grep -E '^DEFAULT_VAULT_IDENTITY_LIST|^DEFAULT_VAULT_ID_MATCH'; grep -rn 'vault_id' ansible.cfg playbooks/ 2>/dev/null

Warning4 items

  1. ansible-config dump | awk '/^DEFAULT_VAULT_ID_MATCH/ && !/= True/ {print "FINDING: " $0}'
  2. grep -rlZ --include='*.yml' '^\$ANSIBLE_VAULT' . 2>/dev/null | xargs -0 -r -n1 echo "would be rekeyed:"
  3. ansible-config dump | grep -E '^CACHE_PLUGIN|^FACT_CACHING'

Info1 item

  1. grep -rn 'lookup(.*hashi_vault\|community.hashi_vault\|aws_secret\|azure_keyvault' playbooks/ roles/ 2>/dev/null