Skip to main content
RunBook Academy

AnsibleXLIX · Compliance, Validation and CertificatesCompliance, validation and certificates

Reporting drift versus correcting it

Advanced⏱ ~28 minansible-playbook

What you'll learn

  • Design a role with an audit entry point and a separate enforce entry point
  • Explain the governance reason for separating the audit schedule from the enforce trigger
  • Stage a remediation: report, enforce a subset, widen
  • Check the blast radius of a control before enforcing it fleet-wide

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.

The same role, run two ways.

Audit: tells you that 47 of 312 hosts have PermitRootLogin yes. Nothing changes. The output is a finding.

Enforce: changes PermitRootLogin on 47 hosts and restarts sshd.

The commands differ by one flag. The decisions differ by everything, and this lesson is about not letting the small distance between the commands disguise the large distance between the decisions.

Audit mode is --check --diff

For a role built out of modules that support check mode, the audit run is the same role with two flags.

Read-only / Safethe audit run
ansible-playbook -i inventories/prod compliance.yml \
--limit all \
--check --diff

The recap becomes the report: changed is the count of non-compliant hosts, and --diff shows what each one differs by. That is a genuinely good property — the same code that fixes the estate measures it — and it is why compliance automation is usually built this way.

It has two limits, one covered in the next lesson and one here.

The next lesson’s limit: check mode is not uniformly honest across modules, so a green audit is not the same as a compliant estate.

This lesson’s limit: --diff prints file contents. An audit run over managed configuration files prints those files to whoever reads the output — and an audit run is precisely the kind of run whose output goes into a log, a ticket, and a compliance evidence package. If any managed file contains a credential, the audit has just published it. Part XXI covers no_log and the mechanics; the compliance-specific point is that the audit is the run most likely to be archived and shared.

Enforce is a separate decision

The reason to keep enforcement separate is not technical. Technically it is one flag. It is organisational, and the argument is this: an audit is a measurement and an enforce is a change, and changes to 300 production hosts have a process.

The separation buys three things:

  1. Different authorisation. Reading the estate needs read access. Changing 300 hosts needs change approval. Wiring them to the same trigger means the audit schedule holds change authority.
  2. Different frequency. Audit hourly, enforce during a window.
  3. A gap in which somebody looks. The finding exists for a period before anything acts on it, and that period is where a control that would break production gets caught.

Designing a role for both

The design that works keeps one set of task definitions and controls whether they execute.

Configuration changeone role, two entry points
# roles/ssh_baseline/defaults/main.yml
compliance_enforce: false
ssh_permit_root_login: 'prohibit-password'

# roles/ssh_baseline/tasks/main.yml
- name: Measure the current sshd configuration
ansible.builtin.slurp:
  src: /etc/ssh/sshd_config
register: sshd_current
check_mode: false

- name: Record the finding
ansible.builtin.set_fact:
  ssh_finding_compliant: >-
    {{ (sshd_current.content | b64decode)
       is search('^PermitRootLogin\s+' ~ ssh_permit_root_login, multiline=True) }}

- name: Apply the SSH baseline
ansible.builtin.lineinfile:
  path: /etc/ssh/sshd_config
  regexp: '^#?\s*PermitRootLogin'
  line: "PermitRootLogin {{ ssh_permit_root_login }}"
  validate: '/usr/sbin/sshd -t -f %s'
when: compliance_enforce | bool
notify: reload sshd

Three details carry the design.

check_mode: false on the measurement task. Without it, the slurp that gathers current state is skipped during an audit run, and every subsequent condition evaluates against nothing. A read-only gathering task inside a check run needs this. It was verified by execution for the next lesson and the mechanism is the same.

The enforcing task is guarded by a variable, not by --check. This is what makes “audit” a mode of the role rather than a mode of the command line. Someone who forgets --check on an audit run gets an audit, not an enforcement.

validate on lineinfile. The command is run against a temporary copy before the real file is replaced, so a baseline that would produce an unparseable sshd_config fails the task instead of the host. For SSH specifically this is not optional, and it is the difference between a bad baseline and an unreachable fleet.

Staged remediation

The sequence that keeps a control from becoming an incident. Each stage answers a question the previous one raised.

StageWhat runsThe question it answers
1. ReportAudit across the fleetHow many hosts, and are they alike?
2. Enforce oneEnforce on a single representative hostDoes applying this break anything?
3. Enforce a subsetOne environment or one groupDoes it break anything at scale or in combination?
4. WidenGroup by group, with validation between
5. ContinuousAudit on a schedule, enforce on exceptionIs the estate staying compliant?

Stage 1 is not a formality. The distribution of the finding is the most useful thing the audit produces:

  • 47 of 312 hosts non-compliant, all in one group — probably one build generation, and probably safe to enforce as a group.
  • 47 of 312, scattered across every group — something is un-converging them, and enforcing will fix the symptom while whatever causes it continues.
  • 311 of 312 non-compliant — the baseline is probably wrong, or the check is. Enforcing would change the entire estate on the strength of a rule nobody has validated.

That third row is worth pausing on. A control that reports almost universal non-compliance is far more likely to be a bad control than an estate that is uniformly wrong, and the instinct to fix 311 hosts is the instinct to skip stage 2.

Knowledge check

Knowledge check · 4 questions

  1. Q1. An audit run reports 311 of 312 hosts non-compliant with a newly written control. What is the most appropriate next step?

  2. Q2. Which of these should be checked before a control is enforced anywhere? Select all that apply.

  3. Q3. A read-only task that gathers current state for a compliance check needs check_mode: false, or it will be skipped during an audit run and every condition depending on it will evaluate against nothing.

  4. Q4. A dashboard shows hourly compliance findings and has a remediate button that re-runs the same job without --check. What is the core problem?

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