Skip to main content
RunBook Academy

AnsibleXLIX · Compliance, Validation and CertificatesCompliance, validation and certificates

Compliance on a schedule, read by someone

Expert⏱ ~28 minansible-playbook

What you'll learn

  • Schedule an audit run so that it is read rather than merely produced
  • Alert on drift rate and newly failing controls rather than on every finding
  • Distinguish expected noise from a signal, and tune it out without hiding it
  • Recognise when a compliance job has become an unread control

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 last lesson of this part is about the failure it cares about most, and it is not a technical one.

An audit job nobody reads is worse than no audit, because it manufactures the belief that someone is looking.

An estate with no compliance automation has a known gap. Somebody may eventually address it. An estate with a nightly compliance run producing a report that nobody has opened in seven months has a gap that is invisible, is documented as covered, and appears in the control register with a green tick.

The second one is worse, and everything in this lesson is about not building it.

Running the audit on a timer

Mechanically simple. The audit playbook cannot enforce — that separation is from lesson 1 — so scheduling it needs no change authorisation.

Read-only / Safethe scheduled invocation
#!/bin/sh
set -eu
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)"

ansible-playbook -i inventories/prod compliance-audit.yml \
--check --diff \
-e "compliance_run_id=${RUN_ID}" \
-e "compliance_policy_version=$(git -C . rev-parse --short HEAD)"

rc=$?
printf 'run=%s rc=%s\n' "$RUN_ID" "$rc" >> /var/log/compliance/runs.log
exit "$rc"

A systemd timer is generally better than cron for this: it records the last run in a way that is queryable, handles a missed run explicitly through Persistent=true, and puts the job output in the journal without a mail alias. cron works, and the ansible.builtin.cron module manages it if that is the estate convention.

The scheduling detail that matters more than either: stagger the run across the fleet or accept the load spike. 3,000 hosts audited at 03:00 is 3,000 SSH connections and 3,000 fact gatherings in a window, which is a self-inflicted load event with a compliance justification.

Alert on the rate, not on the diffs

An estate of 300 hosts has drift. Some of it is being worked on, some is excepted, and some is genuinely new. Alerting on every finding produces a message per finding per run, which produces a filter rule, which produces silence.

The useful signals are derived:

SignalWhy it is the right thing to alert on
Newly failing controlsA host that passed yesterday and fails today is an event with a cause
Drift rateFindings per day, trending. A rise means a process changed
Hosts newly non-compliantPoints at a host, which points at a change
Controls with rising failure countsPoints at a control, which may be wrong
Hosts unmeasured across N runsThe most serious finding, and the quietest
Exceptions past their review dateThe mechanism by which exceptions become permanent

None of those is “host web41 has PermitRootLogin yes”. That is a finding, it belongs in the report, and it is not an alert — because it was also true yesterday, and the day before, and alerting on it every night is how the channel becomes noise.

Tuning out noise without hiding it

Some findings are expected. A build host that legitimately has compilers installed, an appliance whose vendor requires a setting the baseline forbids, a group mid-migration.

Two ways to make those stop generating alerts, and only one is safe.

The wrong one: remove the control for those hosts, or add a when that skips them. The finding disappears from the report. Six months later nobody knows the control is not being evaluated there, and the report claims coverage it does not have.

The right one: the exception mechanism from lesson 1 — recorded in inventory, with a reason, a compensating control, an approver and a review date. The control still runs and still produces its finding; the finding is classified as excepted rather than non-compliant.

Read-only / Safethree categories, not two
- name: Classify this finding
ansible.builtin.set_fact:
  finding_status: >-
    {{
      'compliant' if control_passed
      else ('excepted' if control_name in (compliance_exceptions | default({}))
            else 'non_compliant')
    }}

- name: An exception past its review date is a finding in its own right
ansible.builtin.assert:
  that:
    - compliance_exceptions[control_name].review_date is version(
        ansible_date_time.date, '>=')
  fail_msg: >-
    Exception for {{ control_name }} on {{ inventory_hostname }}
    expired on {{ compliance_exceptions[control_name].review_date }}
  quiet: true
when: finding_status == 'excepted'

The second task is what stops exceptions becoming permanent. An exception with no expiry is a decision never to apply the control, written in a way that avoids anyone having to say so.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A nightly compliance job is monitored by alerting on a non-zero exit code from ansible-playbook. Two months later the estate has drifted substantially and nothing has fired. Why?

  2. Q2. Which of these are appropriate things to alert on from a scheduled compliance run? Select all that apply.

  3. Q3. The right way to stop a legitimately non-compliant appliance from generating findings is an exception recorded in inventory with a reason, an approver and a review date - not a when condition that skips the control for that host.

  4. Q4. A compliance report has been produced nightly for seven months and lists the same twelve findings each time. What is the design defect that will lead to it going unread?

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