Runbook: Audit a production execution
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 question is stated precisely: which host, which time window, and what is being asked - what changed, who did it, or from what code
- · The controllers used in that window are identified; a second controller nobody remembers is the usual gap
- · The log retention period covers the window being asked about, on the controller and on the host
- · Access to the controller log path, the Git history and the hosts own logs is available
- · It is understood that Ansible logging is OFF by default, so the absence of a log is not evidence that nothing ran
3 · Procedure
Execute each step in order. Verify the expected output of a step before moving to the next.
- 1Establish what evidence sources exist for the window - controller logs, host logs, Git history, CI history, file mtimes
- 2Search the controller run log for the host and the window
- 3Correlate each run to a commit, an operator and an invocation
- 4Cross-check against the hosts own record: file modification times, package transactions, service restarts
- 5Identify anything that changed on the host with no corresponding automation run - that is either manual change or a gap in the evidence
- 6Assemble a timeline with each entry attributed to a source
- 7State explicitly what could not be established and why
- 8Close the evidence gaps found, so the next audit is answerable
4 · Verification
Confirm the procedure actually fixed the problem.
- ✓Every change found on the host is either attributed to a run, attributed to a person, or explicitly recorded as unattributed
- ✓Every automation run found in the logs is attributed to a commit and an initiator
- ✓The timeline covers the whole window with no unexplained silences that were not investigated
- ✓The limitations section names the evidence that does not exist, rather than implying completeness
5 · Rollback
If verification fails, undo the procedure in reverse order.
- ↶An audit is read-only; nothing to roll back
- ↶If configuration changes are made to close evidence gaps - enabling log_path, adding a callback, extending retention - those are ordinary changes and go through review
- ↶Do not enable a logging change that writes secrets: log output can contain module arguments, so no_log scoping must be reviewed at the same time
- ↶If audit findings lead to a rollback of some change, that is the configuration rollback runbook, not this one
6 · Escalation
When the runbook isn't enough, contact:
- · Escalate to the security owner if the audit finds an execution that nobody can account for
- · Escalate if the evidence needed does not exist and the question is a compliance or incident question rather than an operational one - the honest answer is that it cannot be established, and that has consequences
- · Escalate to the platform owner if runs were made from a controller that is not in the register
- · Escalate if the log shows secrets in plaintext - that is a leak with a defined window, not a logging defect
Someone asks: what did automation do to db01.example.com on the
fourteenth, and who told it to?
Answering that is easy if the estate was set up to answer it and impossible if it was not, and the honest first move is finding out which of those you are in. Ansible ships with logging disabled. On a default installation there is no record of any run beyond what happened to be on someone’s terminal.
This runbook does two jobs. It assembles the best answer the available evidence supports, and it names the evidence that does not exist - so that the next audit is answerable rather than being the same conversation.
When to use this runbook
- A change appeared on a host and nobody knows where it came from.
- A compliance or incident question about what automation did, and when.
- Post-incident: establishing whether automation contributed.
- Verifying that a change record matches what actually happened.
Blast radius
None. Reading logs, Git history and file metadata. Any configuration change made to close a gap is an ordinary change afterwards.
Step 1: Establish what evidence exists
Before searching, find out what there is to search.
ansible-config dump --only-changed | grep -iE 'log_path|callback'
ansible-config dump | grep -iE 'DEFAULT_LOG_PATH|CALLBACKS_ENABLED'Evidence sources, in descending order of usefulness:
| Source | What it gives you | Whether it exists by default |
|---|---|---|
Controller log_path | Every task, every host, timestamped | No - must be configured |
| A JSON callback log | Structured, parseable per-task results | No |
| CI job history | Who ran what, from which commit, when | Usually yes, if runs go through CI |
| Git history | What the code said at that time | Yes |
| Host file mtimes | When a managed file last changed | Yes |
| Package transaction history | What was installed or removed, and when | Yes on dnf; partial on apt |
| Host journal | Service restarts, sudo invocations, sshd logins | Yes, within retention |
| Shell history of the automation account | Ad-hoc commands, if not disabled | Sometimes |
Step 2: Search the controller log
LOGFILE=/var/log/ansible/ansible.log
HOST=db01.example.com
grep -n "$HOST" "$LOGFILE" | grep '2026-08-14' | head -50
# Every distinct run that touched it that day
grep '2026-08-14' "$LOGFILE" | grep -E 'PLAY \[' | sort -uVerified format on 2.21.3 with ANSIBLE_LOG_PATH set:
2026-08-11 23:07:56,233 p=377026 u=ebrandi n=ansible INFO| PLAY [Rolling deployment]
2026-08-11 23:07:56,236 p=377026 u=ebrandi n=ansible INFO| TASK [Deploy the application]
2026-08-11 23:07:56,245 p=377026 u=ebrandi n=ansible INFO| changed: [db01.example.com]
Four useful fields per line: timestamp, p= process id, u= the OS
user who invoked it, and the message.
u= is the single most valuable field for attribution, and it is also
where the estate’s design shows. If every run is u=ansible, the log
tells you a service account did it and nothing about which human. That
is a finding about the estate, not about this incident.
grep "$HOST" "$LOGFILE" | grep '2026-08-14' | grep -E '^.*changed:' \
| sed 's/.*INFO| //' | sort | uniq -cchanged: lines are the ones that matter. ok: means the task ran and
altered nothing - which is the majority of every converge, and filtering
it out is what makes an audit tractable.
Step 3: Correlate each run to code and to a person
cd /srv/automation/repo
# What was on the production branch at that time?
git log --until='2026-08-14 23:59' --format='%H %ci %an %s' -5 main
# What did the role look like at that commit?
git show 4f2a9c1:roles/postgres/tasks/main.yml | head -40# CI, if runs go through a pipeline
echo 'Pipeline history: job id, triggering user, commit, timestamp'
# Interactive invocations: who was on the controller
last -F | grep '2026-08-14'
sudo journalctl -t sudo --since '2026-08-14' --until '2026-08-15' --no-pager \
| grep -i 'ansible'Step 4: Cross-check against the host’s own record
The controller says what it believes it did. The host records what happened. Compare.
HOST=db01.example.com
# Files under /etc modified in the window
ansible "$HOST" -b -m find \
-a 'paths=/etc recurse=true age=-3d age_stamp=mtime file_type=file' -o \
| python3 -m json.tool | grep '"path"' | head -40
# Package transactions
ansible "$HOST" -b -m command -a 'dnf history list --reverse' -o | tail -20
# Service restarts and sudo activity
ansible "$HOST" -b -m command \
-a 'journalctl --since "2026-08-14" --until "2026-08-15" --no-pager -u postgresql' -o
ansible "$HOST" -b -m command \
-a 'journalctl -t sudo --since "2026-08-14" --until "2026-08-15" --no-pager' -o# Files written by template/copy with backup: true leave a dated copy
ansible "$HOST" -b -m find \
-a 'paths=/etc/postgresql patterns="*.conf.*" file_type=file' -o \
| python3 -m json.tool | grep -E '"path"|"mtime"'Backup files are the most underrated audit source in an Ansible estate.
Each one is a timestamped copy of the previous content, written by the
task that changed it, sitting on the host. Where the role sets
backup: true, you can reconstruct not just when a file changed but
what it changed from - without any controller log at all.
Step 5: Find changes with no run behind them
# A file changed on the host at a time when no run touched it
ansible "$HOST" -b -m stat -a 'path=/etc/postgresql/postgresql.conf' -o \
| python3 -m json.tool | grep -E 'mtime|checksum|pw_name'
# Does the controller log show a run at that time?
grep "$HOST" /var/log/ansible/ansible.log | grep '2026-08-14 1[0-9]:'Three possible explanations, and they need distinguishing:
- Manual change. Someone edited the file. The sudo log and the journal may say who.
- A run from a controller you have not searched. This is the common one, and it is why the pre-check asks which controllers existed in that window.
- A run whose log was never written. Logging off, retention expired, or the run went to a terminal only.
Explanation 2 is worth pursuing hard. A second controller - a developer’s laptop, a decommissioned box still holding a valid key, a CI runner nobody registered - is both the answer to the audit and a finding in its own right.
Step 6: Assemble the timeline
Every entry attributed to its source:
2026-08-14 09:12 CI job #4417, commit 4f2a9c1, triggered by a.operator
ansible-playbook site.yml --limit db
Source: pipeline history
2026-08-14 09:14 db01: changed - Render postgresql.conf
Source: controller log /var/log/ansible/ansible.log
2026-08-14 09:14 db01: handler Restart postgresql ran
Source: controller log; corroborated by journal entry
for postgresql.service restart at 09:14:22
2026-08-14 14:41 db01: /etc/postgresql/pg_hba.conf mtime changed
NO corresponding automation run found.
sudo log shows b.operator escalated at 14:39.
Attribution: manual change, unconfirmed.
2026-08-14 22:00 Scheduled converge, u=ansible, commit 4f2a9c1
db01: changed=0
Source: controller log
The 14:41 entry is the useful one. An audit that reports only what it found is less valuable than one that reports what it found and what it could not attribute.
Step 7: State the limitations
This section is not a disclaimer. It is a finding.
Limitations of this audit:
- Controller log retention is 30 days. Anything before 2026-07-15
cannot be established.
- Runs made interactively without CI cannot be attributed to a person
beyond the OS user, because operators SSH directly into the
automation account.
- Ad-hoc `ansible` commands are logged as tasks but not as an
invocation, so the command line that produced them is not recoverable.
- controller02 was decommissioned on 2026-08-01; its logs were not
archived. Any run from it in the window is unrecoverable.
- No callback plugin is configured, so per-task diffs are not available;
only that a task reported changed.
Step 8: Close the gaps
The audit’s real deliverable. Each of these is an ordinary change.
[defaults]
# Every run written to a file, with timestamp, pid and invoking user.
# This is the single highest-value line in this runbook.
log_path = /var/log/ansible/ansible.log
[privilege_escalation]
become = FalseThat one setting produces the timestamped, attributed record that Step 2 searches. Everything else is an improvement on top of it.
For a structured record, ansible.posix.json is a stdout callback -
it replaces the human-readable output rather than adding to it, so it is
set per invocation rather than globally:
ANSIBLE_STDOUT_CALLBACK=ansible.posix.json \
ansible-playbook site.yml --limit db01.example.com --check \
> "run-$(date -u +%Y%m%dT%H%M%SZ).json"Check which callbacks are actually available before writing either into
a config file - ansible-core ships very few, and the rest come from
collections you must pin in requirements.yml:
ansible-doc -t callback -l
ansible-config dump | grep -iE 'CALLBACKS_ENABLED|STDOUT_CALLBACK'# /etc/logrotate.d/ansible
/var/log/ansible/ansible.log {
daily
rotate 400
compress
missingok
notifempty
create 0640 ansible ansible
}Other gaps worth closing:
- Route runs through CI. Then attribution is the pipeline’s job and it is solved by default.
- Forbid direct SSH to the automation account.
sudo -iu ansiblefrom a named account preserves the chain to a person. - Set
backup: trueon every task that writes a managed file. It gives you per-host, timestamped before-content on the host itself. - Register every controller. An unregistered controller is a permanent hole in every future audit.
- Fix non-idempotent tasks. They make the
changedsignal meaningless.
Common patterns
| Symptom | Likely cause | Resolution |
|---|---|---|
| No log entries for the window | Logging was never enabled | Absence is not evidence; say so and enable log_path |
Every run attributed to u=ansible | Humans SSH directly to the automation account | Require sudo -iu ansible from named accounts |
| Host changed with no run behind it | Manual change, or a second controller | Check sudo logs, then hunt for the other controller |
| Every converge shows changes on a host | A non-idempotent task | Fix it; the audit trail is unreadable until then |
| Log exists but has no per-task detail | No structured callback configured | Enable a JSON callback alongside the text log |
| Log is truncated at the incident date | Retention too short, or rotation without enough copies | Extend retention to cover the compliance window |
| Ad-hoc changes cannot be traced | ansible invocations log tasks, not command lines | Route changes through playbooks in version control |
| Secrets found in the log | no_log missing on a task with credential arguments | Rotate; treat the log as sensitive; scope no_log |
Escalation
Escalate when:
- An execution is found that nobody can account for.
- The evidence does not exist and the question is a compliance or security question.
- Runs came from a controller that is not in the register.
- The log contains plaintext secrets.