Skip to main content
RunBook Academy

← All runbooks in Ansible

critical riskcluster affecting~180 min

Runbook: Respond to a leaked secret

1 · Prerequisites

Confirm every item is in place before any state change.

2 · Pre-checks

Read-only diagnostic commands. If any of these don't match expected output, stop and investigate further.

  • · The exact secret is identified: which credential, what it grants, and to which system
  • · The owner of that credential and the process for rotating it are known BEFORE any containment step is attempted
  • · Every consumer of the secret is enumerated - controllers, CI, applications, scheduled jobs, third parties
  • · The exposure window is estimated: when it was first committed or logged, and who could have read it since
  • · The security owner has been told, because this is an incident and not a repository cleanup task
  • · It is understood and agreed that rotation comes FIRST and history rewriting comes LAST

3 · Procedure

Execute each step in order. Verify the expected output of a step before moving to the next.

  1. 1Declare the incident and record the exposure window
  2. 2Enumerate every consumer of the secret before changing anything
  3. 3ROTATE: issue a new credential at the source system
  4. 4Distribute the new credential to every consumer and confirm each one works
  5. 5REVOKE: invalidate the old credential at the source system - this is the step that ends the exposure
  6. 6Verify the old credential is dead by attempting to use it and requiring failure
  7. 7Hunt for every other copy: logs, backups, fact caches, CI artefacts, terminal scrollback, tickets, chat
  8. 8Audit: what did the leaked credential do during the exposure window
  9. 9ONLY NOW clean the repository history, and expect it to be a coordination exercise
  10. 10Fix the mechanism that let it escape: pre-commit scanning, no_log scoping, or moving the secret out of the repository
  11. 11Write the timeline, including the interval between exposure and revocation

4 · Verification

Confirm the procedure actually fixed the problem.

  • The new credential works from every consumer, verified individually rather than assumed
  • The old credential FAILS when used deliberately against the source system - this check must be run and must be able to fail
  • The source systems own audit log shows the old credential is revoked, not merely superseded
  • A search for the secret value across logs, caches, backups and artefacts returns nothing that is still live
  • The activity audit for the exposure window is complete, or its gaps are recorded
  • If history was rewritten, every fork, clone and mirror is accounted for - and the credential was already dead before the rewrite began
  • A commit containing a test secret is now blocked by the scanning gate

5 · Rollback

If verification fails, undo the procedure in reverse order.

  • There is no rollback for exposure - once a secret has been readable it must be treated as read
  • If the new credential fails for a consumer, roll that consumer back to the old credential ONLY if the old one has not yet been revoked
  • After revocation there is no way back: the old credential is dead and every consumer must use the new one
  • POINT OF NO RETURN: revocation. Do not revoke until every consumer is confirmed working on the new credential, and do not delay revocation for a consumer that is merely inconvenient
  • History rewriting is not reversible in a shared repository without coordination; the old commits persist in every clone that has not been re-cloned
  • If the rewrite goes wrong, the fallback is that the secret is already dead - which is exactly why rotation comes first

6 · Escalation

When the runbook isn't enough, contact:

  • · Escalate to the security owner immediately on discovery; this is an incident, not a maintenance task
  • · Escalate to the credential owner for rotation - do not attempt to rotate a credential you do not own
  • · Escalate if the secret grants access to a third party or customer system; notification obligations may apply and they have deadlines
  • · Escalate if the exposure window cannot be bounded, or if the repository is public or mirrored outside your control
  • · Escalate if a consumer cannot be updated before revocation - the decision to accept an outage rather than extend the exposure belongs to the service owner

A credential has escaped. It is in a commit, or a run log, or a screenshot in a ticket, or a --diff output somebody pasted into chat.

The order of the response is the whole runbook, and it is the opposite of what feels urgent.

When to use this runbook

  • A credential was committed in plaintext.
  • A secret appeared in a run log, a --diff output, or a CI artefact.
  • A vault password was shared over an insecure channel.
  • A screenshot or paste containing a credential left the team.
  • A departing operator had credentials that were never rotated.

Blast radius

Everything the credential grants access to, for the entire period from first exposure to revocation. That period is the number the incident is measured by, and every step in this runbook is arranged to shorten it.

If the leaked secret is the vault password, the blast radius is every secret the vault contains - because whoever had the password had the plaintext of all of them. That case fans out into a rotation of every credential in the vault, each through its own owner’s process.

Step 1: Declare, and bound the window

Read-only / Safewhen did it first appear
cd /srv/automation/repo

# When was the value introduced, and by whom?
git log --all --oneline -S'REPLACE_ME_LEAKED_VALUE' -- . | tail -5
git log --all --format='%H %ci %an %s' -S'REPLACE_ME_LEAKED_VALUE' | tail -3

# Which refs currently contain it?
git grep -l 'REPLACE_ME_LEAKED_VALUE' $(git rev-list --all) 2>/dev/null | head

git log -S searches for commits that changed the number of occurrences of a string - it finds the commit that introduced the value even if the file has since been deleted.

Record: first exposure timestamp, who committed it, whether the repository is public, whether it is mirrored, and how many people have clone access. Those five facts determine everything about the urgency.

Step 2: Enumerate the consumers - before touching anything

Rotation breaks every consumer that still holds the old value. Find them first, or the rotation becomes an outage.

Read-only / Safewho uses this credential
# In the repository
grep -rn 'db_password\|DB_PASSWORD' --include='*.yml' --include='*.j2' \
--include='*.env' . --exclude-dir=.git

# On the controllers
sudo grep -rl 'REPLACE_ME_LEAKED_VALUE' /home/ansible /srv/automation 2>/dev/null

# In CI
echo 'Pipeline variables, deploy keys, cached artefacts'

# On managed hosts - the rendered config that uses it
ansible all -b -m shell \
-a 'grep -rl "REPLACE_ME_LEAKED_VALUE" /etc /opt 2>/dev/null | head' -o

Write the list down. Every entry needs to be updated in Step 4 and verified in Step 5, and the one you forget is the one that produces the outage at revocation.

Step 3: ROTATE - issue a new credential

At the source system, through its owner’s process.

Configuration changeissue the replacement
# Database credential - through the database owner
echo "New password generated by the DBA and delivered via the password manager"

# API token - through the provider
echo "New token issued in the provider console; scope reviewed at the same time"

# SSH key - see the SSH credential rotation runbook, which handles the fleet
ssh-keygen -t ed25519 -f ./id_ed25519_new -C 'reissued 2026-08-11' -N ''

# Vault password - see the vault credential rotation runbook

Two things worth doing while you are here, because you will not get another convenient moment:

  • Review the scope. A leaked credential that turned out to have far more access than it needed is a second finding. Issue the replacement with the narrowest scope that works.
  • Put the new value in the password manager first, before using it anywhere. A replacement credential that exists only in one file has simply moved the single point of failure.

Step 4: Distribute, and confirm each consumer

Work down the list from Step 2. For each consumer, install the new credential and confirm it works before moving on.

Configuration changeupdate the repository copy
# Encrypt the new value under the vault
ansible-vault encrypt_string --vault-id prod@/home/ansible/.vault-pass \
--encrypt-vault-id prod 'REPLACE_ME_NEW_VALUE' --name 'db_password' \
>> inventories/production/group_vars/db/vault.yml

ansible-vault view --vault-id prod@/home/ansible/.vault-pass \
inventories/production/group_vars/db/vault.yml | head -3
Read-only / Safeconfirm the new credential works
# Against the source system, using the NEW value only
PGPASSWORD='REPLACE_ME_NEW_VALUE' psql -h db01.example.com -U app -c 'SELECT 1'

# From each consumer
ansible-playbook -i inventories/production site.yml \
--limit app --tags config --check --diff

Both credentials are valid at this point. That overlap is deliberate: it is what makes Step 4 reversible and Step 5 safe. It is also the period during which the exposure is still open, so do not linger in it.

Step 5: REVOKE - this is the step that ends the exposure

Destructiverevoke at the source
# Database: drop or change the old password so it cannot authenticate
echo "DBA revokes the old credential at the database"

# API token: revoke in the provider console; note the revocation timestamp

# SSH key: remove from authorized_keys fleet-wide - the SSH rotation runbook

# Record the exact revocation time. It is the end of the exposure window.
date -u +%Y-%m-%dT%H:%M:%SZ | tee revocation-timestamp.txt

Step 6: Prove the old credential is dead

A verification that cannot fail is not a verification. This one can, and must be run.

Read-only / Saferequire failure
if PGPASSWORD='REPLACE_ME_LEAKED_VALUE' \
   psql -h db01.example.com -U app -c 'SELECT 1' >/dev/null 2>&1; then
echo 'STILL VALID - THE EXPOSURE IS NOT CLOSED'
exit 1
else
echo 'revoked OK'
fi

Then confirm at the source system’s own audit trail, not just from the client side:

Read-only / Safethe source system agrees
echo "Provider console: token status shows Revoked, with a timestamp"
echo "Database: the old role or password no longer exists"
echo "Record both, with timestamps, in the incident notes"

“Superseded” is not “revoked”. A token that has been replaced but not invalidated still works, and a rotation that only issued a new credential has not closed anything.

Step 7: Hunt for the other copies

The commit is rarely the only place. Secrets propagate.

Read-only / Safeevery place a secret lands
VALUE='REPLACE_ME_LEAKED_VALUE'

# Controller run logs
sudo grep -rl "$VALUE" /var/log/ansible/ 2>/dev/null

# Fact cache
sudo grep -rl "$VALUE" ~/.ansible/facts_cache /var/cache/ansible 2>/dev/null

# Backup files that template/copy left on managed hosts
ansible all -b -m shell \
-a "grep -rl '$VALUE' /etc --include='*.conf.*' 2>/dev/null | head" -o

# Shell history on the controller
sudo grep -l "$VALUE" /home/*/.bash_history /root/.bash_history 2>/dev/null

# CI artefacts and job logs
echo 'Pipeline job logs, cached artefacts, container image layers'

# Tickets, chat, wiki, screenshots
echo 'Search the ticket system and chat archive for the value'

Step 8: Audit the exposure window

Read-only / Safewhat did the credential do
# The source system's authentication log for the window
echo 'Database: connection log for the app user between first exposure and revocation'
echo 'Provider: API access log for the token, same window'

# Unexpected source addresses are the finding
echo 'Compare source addresses against the known consumers from Step 2'

Look for use from an address that is not one of your consumers. Absence of evidence is weak - many systems do not log successful authentication in enough detail - and that limitation belongs in the incident notes rather than being read as “no misuse occurred”.

Step 9: Only now, clean the repository

The credential is already dead. This step is hygiene: it stops the next person finding a value and wasting time on it, and it removes an obvious artefact from the repository.

Read-only / Safescope the rewrite
git log --all --oneline -S'REPLACE_ME_LEAKED_VALUE' | wc -l
git for-each-ref --format='%(refname)' | wc -l
echo 'Count the forks, mirrors and long-lived clones - each needs re-cloning'
Destructiverewrite with git-filter-repo
# git-filter-repo is the maintained tool; filter-branch is deprecated and
# is documented upstream as unsafe and extremely slow for this purpose.
git clone --mirror https://git.example.com/infra/automation.git automation-mirror
cd automation-mirror

printf 'REPLACE_ME_LEAKED_VALUE==>REDACTED\n' > /tmp/replacements.txt
git filter-repo --replace-text /tmp/replacements.txt

# Review before pushing - this rewrites every affected commit hash
git log --oneline -5

If the repository is public, also request that the hosting provider purge cached views of the affected commits. Do not treat that request as having closed anything - the value was already scraped.

Step 10: Fix the mechanism

The credential leaked through a path. Close it.

Configuration changepre-commit secret scanning
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
  rev: v8.28.0
  hooks:
    - id: gitleaks
Configuration changescope no_log to the tasks that need it
- name: Configure the application database connection
ansible.builtin.template:
  src: app.conf.j2
  dest: /etc/app/app.conf
  owner: app
  group: app
  mode: '0640'
no_log: true
notify: Restart app

Other mechanism fixes, in rough order of value:

  • Move the secret out of the repository entirely, to an external secret manager looked up at run time. Then a repository leak is not a credential leak.
  • CI secret scanning as a merge gate, so the pre-commit hook is not the only barrier.
  • Review who can read the repository. A credential in a vault- encrypted file is only as protected as the vault password’s distribution.
  • Stop using --diff with templates that render secrets, or set no_log on those tasks.

Step 11: Write the timeline

The number that matters:

First exposure       2026-07-02 14:11 (commit 8b2e1a4, pushed to main)
Discovered           2026-08-11 09:40
Rotation issued      2026-08-11 10:25
Consumers confirmed  2026-08-11 11:50
REVOKED              2026-08-11 11:58
History rewritten    2026-08-12 16:00 (scheduled, coordinated)

Exposure window: 40 days, 21 hours.
Time from discovery to revocation: 2 hours 18 minutes.

Both durations are findings. The first measures detection; the second measures response. They have different fixes - scanning gates for the first, rehearsal and a written consumer list for the second - and recording them separately is what makes each improvable.

Common patterns

SymptomLikely causeResolution
History cleaned, credential still validThe rewrite was done firstRevoke now; the rewrite achieved nothing on its own
Rotation caused an outageA consumer was not enumerated firstStep 2 before Step 3; keep the consumer list current
Old credential still works after “rotation”It was superseded, not revokedRevoke at the source; verify with a deliberate attempt
Secret reappears after the rewriteSomeone pushed from an un-recloned cloneRe-clone everywhere; the credential is dead regardless
Secret found in a config backup on a hostbackup: true kept a rendered copySearch managed hosts, not just the repository
Secret found in a run logTask had no no_logScope no_log; treat the log as sensitive; rotate
Play became undiagnosable after the fixno_log set at play levelScope it to the tasks that handle secrets
Vault password leakedEvery secret inside it is exposedRotate all of them, each through its owner

Escalation

Escalate when:

  • On discovery. Immediately. This is an incident.
  • Rotation requires a credential owner you are not.
  • The secret grants access to a third-party or customer system - notification obligations have deadlines.
  • The exposure window cannot be bounded, or the repository is public or mirrored outside your control.
  • A consumer cannot be updated before revocation. Extending the exposure to avoid an outage is the service owner’s decision, and the answer is usually the outage.

References

  1. Protecting sensitive data with Ansible Vault
  2. git-filter-repo
  3. git filter-branch (and why not to use it)
  4. ansible-vault CLI