Skip to main content
RunBook Academy

AnsibleLII · Anti-PatternsAnti-patterns of construction

Anti-pattern: secrets in Git

Advanced⏱ ~28 minbashgit

What you'll learn

  • Recognise the four shapes this anti-pattern takes, including the two that look safe
  • Explain why remediation is credential rotation rather than repository editing
  • Scope a rotation under the partial-failure conditions it will actually run in
  • Recognise the related reflex - disabling a control because it was inconvenient - in host key checking

Prerequisites

Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11

Not yet marked complete on this device.

Ansible repositories are unusually attractive targets. A single clone can contain the credentials for an entire estate — database passwords, API tokens, the automation account’s access to every host — organised neatly by environment, with a README explaining what each one is for.

That is fine, and it is what vault is for. This lesson is about the four ways the protection gets defeated, and about why the remediation is much larger than people expect.

The four shapes

1. Plaintext, uncommented

Read-only / Safethe obvious form
# group_vars/production/main.yml
db_password: REPLACE_ME
api_token: REPLACE_ME
grafana_admin_password: REPLACE_ME

Easy to find, easy to lint for, and it is not the one that gets you.

2. The vault password committed alongside the vault

The variables are properly encrypted. ansible.cfg says vault_password_file = .vault_pass. And .vault_pass is in the repository, because somebody wanted CI to work.

The encryption is now decoration. Anyone with a clone has both halves.

3. A secret in a file nobody classified as a secret

The one that actually happens. It is not in group_vars; it is in:

  • A dynamic-inventory config file with an API token in it
  • A Makefile or wrapper script with export ANSIBLE_VAULT_PASSWORD=…
  • A test fixture with “fake” credentials that turned out to be real
  • A .env file that a .gitignore rule stopped covering when it moved
  • A commented-out line kept “just in case”
  • A CI configuration file with an inline token

4. A secret that was removed

Committed on Tuesday, removed on Wednesday, and still in the history — which is the property that makes this anti-pattern different from every other one in this part.

The failure: remediation is a fleet-wide rotation

This is the part that gets underestimated, because “delete the file and force-push” feels proportionate to “committed a file by mistake”.

The actual work:

  1. Enumerate what leaked. Not just the obvious value - every credential in every commit in the affected range, because the same push often carried more than one.
  2. Determine the blast radius per credential: which hosts, which services, which third parties accept it. A database password may be in six application configs; an API token may be in use by two other teams.
  3. Rotate at the source: generate new credentials in the database, the cloud console, the vendor portal.
  4. Distribute the new values to every consumer - which is an Ansible run, against production, under time pressure.
  5. Retire the old credential, which is a separate step and the one most often skipped. A rotated-but-not-revoked credential is still valid.
  6. Verify. Prove the old value no longer authenticates, per service, rather than assuming the revocation applied.
  7. Rewrite the history if you wish, as hygiene. It is the last step and the least important one.

Prevention that works

  1. A pre-commit hook that scans staged content. It catches the mistake before the push, which is the only point at which the cost is still small. Part XXXVIII covers the CI half; the local hook is what saves you.
  2. Secret scanning in CI as the second line, so a bypassed hook is still caught - though by then it is pushed, and the clock has started.
  3. A .gitignore for the vault password file plus a check that it is genuinely absent. .gitignore does not untrack a file that is already tracked, which is a common and expensive surprise.
  4. The vault password from an environment variable or a file outside the repository, per Part XXI - and in CI, from the CI system secret store rather than from a file in the checkout.
  5. A review habit: any diff that adds a long high-entropy string gets read carefully, whatever the filename.
Read-only / Safeaudit what is currently tracked
cd /srv/ansible

# Is the vault password file tracked, despite .gitignore?
git ls-files | grep -E 'vault[-_]?pass|\.vault'

# Files that should be encrypted but are not.
grep -rLE '^\$ANSIBLE_VAULT' --include='vault*.yml'    group_vars/ host_vars/ 2>/dev/null

# Any occurrence of a likely secret assignment across all history.
git log -p --all -S'password' -- '*.yml' | head -40

The same reflex, elsewhere: host_key_checking = False

This belongs in the same lesson because it comes from the same decision, made in the same five seconds: a control was inconvenient, so it was removed rather than configured.

Read-only / Safethe line, and what it costs
[defaults]
host_key_checking = False

It appears because the first run against new hosts prompted for host key acceptance and the run was in CI where nothing could answer. That is a real problem with a real solution, and this is not it.

The failure. SSH host key verification is what detects a man-in-the-middle. Disabled, the controller will connect to whatever answers on that address and hand it the automation account’s credentials, the vault-decrypted variables for that host, and whatever become password the play uses. A DNS change, an ARP spoof, or an IP reassigned to a different machine is now indistinguishable from the real host.

Note the asymmetry with the secrets case: this one has no incident until it has a very large one, and no signal in between.

The corrected forms, in order of preference: populate known_hosts from a trusted source at provisioning time; use ssh-keyscan as an explicit, reviewed step whose output is checked in and versioned; or manage entries with ansible.builtin.known_hosts driven from inventory. Part XLVII covers all three, and Part XX covers which settings should never be weakened.

Knowledge check

Knowledge check · 4 questions

  1. Q1. An API token is committed, noticed within an hour, and removed in the next commit with the history rewritten and force-pushed. What is the correct assessment?

  2. Q2. Which of these are genuine instances of this anti-pattern that a naive scan of group_vars would miss? Select all that apply.

  3. Q3. The main cost of committing a secret is the leak itself, so a repository that is private and access-controlled carries little risk.

  4. Q4. Why does this lesson treat `host_key_checking = False` as the same anti-pattern as committing a secret?

Passing score: 75%. Answers are checked in this browser.