Skip to main content
RunBook Academy

LinuxLXXIV · Configuration DriftCoverage

The limits of drift detection - measuring what nothing manages

Advanced⏱ ~15 mindpkg-querycommfind

What you'll learn

  • Explain why a clean drift report is a statement about coverage, not about the host
  • Measure how much of a host is owned by a package or declared by configuration management
  • Classify unmanaged files into must-manage, deliberately-unmanaged and unknown
  • Report coverage alongside drift so a green report cannot be misread

Prerequisites

Verified against Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL 9.x · Rocky Linux 9.x · AlmaLinux 9.x · Linux kernel 6.1 LTS / 6.6 LTS · systemd 255+ · OpenSSH 8.7p1 (RHEL 9) / 9.6p1 (Ubuntu 24.04) · nftables 1.0.x · chrony 4.x · Pacemaker 2.1.x · Corosync 3.1.x · 2026-08-11

Not yet marked complete on this device.

Every drift control in this part reports the same thing when everything is fine: nothing. And “nothing” carries two completely different meanings that no report distinguishes.

  • Nothing has drifted from what we declared.
  • Nothing we declared has drifted, and we declared very little.

A configuration management tool reporting “no changes” has made a precise, narrow statement: it compared reality against its own declarations and found agreement. It has said nothing whatsoever about the parts of the host it was never told about — and in most environments that is the larger part.

The unmanaged file is where drift lives, because it is the only place drift can live undetected.

Three states, not two

Every file on a host is in one of three states, and drift detection distinguishes only the first two:

StateDetected byDrifts silently?
Declared by configuration managementCM dry-runNo
Owned by a packagedpkg --verify, rpm -VNo
NeitherNothing, by definitionYes, indefinitely

The third row is not a small residue. It contains everything configuration management wrote and later stopped managing, everything an operator created, every file an application writes into /etc, every leftover from a deleted playbook task, and every .dpkg-old backup of a customisation somebody used to care about.

Measuring coverage

Coverage is a number you can compute today, on any host, with tools that are already installed. Start with the package-owned set, because it is the easy half:

# Every path under /etc that some installed package owns
export LC_ALL=C
dpkg-query -W -f='${binary:Package}\n' 2>/dev/null \
  | xargs dpkg -L 2>/dev/null \
  | grep '^/etc/' | sort -u > /var/tmp/etc-package-owned.txt

# Every regular file actually present under /etc
find /etc -xdev -type f 2>/dev/null | sort -u > /var/tmp/etc-present.txt

# The difference is the set nothing in the package system owns
comm -23 /var/tmp/etc-present.txt /var/tmp/etc-package-owned.txt \
  > /var/tmp/etc-unowned.txt

wc -l /var/tmp/etc-present.txt /var/tmp/etc-unowned.txt
Read-only / Safe139 of 897 files under /etc belong to no package
$ wc -l /var/tmp/etc-present.txt /var/tmp/etc-unowned.txt
  897 /var/tmp/etc-present.txt
139 /var/tmp/etc-unowned.txt
1036 total

Illustrative output

Those 139 files are not automatically a problem. They are the candidate set: the files that package verification can never report on, so they are covered only if configuration management declares them, and you now have a concrete list to check that against.

For the configuration-management half, the reliable technique is to mark what the tool manages so it can be counted later. A header on every templated file works and costs nothing:

# Files declared by CM, if your templates carry a marker line
grep -rl 'MANAGED BY ANSIBLE' /etc 2>/dev/null | sort -u \
  > /var/tmp/etc-cm-managed.txt

# Unowned by a package AND not marked as managed: the real gap
export LC_ALL=C
comm -23 /var/tmp/etc-unowned.txt /var/tmp/etc-cm-managed.txt

An extended attribute is the tidier version, and does not require the file format to tolerate comments — linux-extended-and-immutable-attributes covers user.origin, which several configuration management tools already set for exactly this purpose:

# Substitute your own values before running:
F=/etc/nginx/conf.d/upstream.conf

getfattr -d -m 'user\.' "$F" 2>/dev/null

Classify the gap, do not just measure it

A list of 139 unmanaged files is not actionable and will be ignored. Sorting them into three buckets is, and the bucket assignment is a decision that gets recorded:

BucketMeaningAction
Must be managedSecurity-relevant or service-defining: firewall, PAM, sudo, SSH, TLS material, anything the compliance baseline namesDeclare it in CM, this quarter
Deliberately unmanagedGenuinely host-specific or application-owned state that belongs on the host: machine ID, generated keys, local cachesRecord the decision, exclude explicitly
UnknownNobody knows what wrote it or whether it mattersThe backlog. This bucket is the real finding

Two filters make the initial triage fast. Modification time groups files by the event that created them:

# Unowned files changed in the last 90 days - recent activity first
export LC_ALL=C
while IFS= read -r f; do
  [ -f "$f" ] && printf '%s %s\n' "$(stat -c '%y' "$f" | cut -d' ' -f1)" "$f"
done < /var/tmp/etc-unowned.txt | sort -r | head -20
# Backup and leftover files: each one marks a decision nobody reviewed
grep -E '\.(dpkg-old|dpkg-dist|dpkg-new|rpmsave|rpmnew|ucf-old|bak|orig|save|[0-9]{8})$' \
  /var/tmp/etc-unowned.txt

That second filter is consistently productive. sshd_config.bak, sudoers.orig, nginx.conf.20250114 — each is a snapshot somebody took before a change, left in place, and forgot. They are unmanaged by definition, they frequently contain credentials that were rotated afterwards, and in the case of sudoers.d and sshd_config.d drop-in directories a stray backup file may still be read by the service.

Reporting coverage next to drift

The fix for a misreadable green report is to publish the denominator with it:

DRIFT REPORT - role: web - 2026-08-11
Hosts:                    12
Drift findings:           0        (CM dry-run, all hosts)
Package verification:     2 findings (web03: modified binary in nginx)
Coverage:
  /etc files present, median per host        897
  owned by a package                         758  (84%)
  declared by CM but not package-owned        61
  neither - UNMANAGED                         78
    of which: classified deliberately         44
              MUST be managed, open tickets    7
              UNKNOWN                         27

The zero on the drift line and the 27 on the unknown line have to be read together. Alone, the zero says the fleet is clean. Next to the 27, it says what it actually means: nothing has drifted from the 84% we can see, and there are 27 files per host that nothing has ever checked.

Knowledge check

Knowledge check · 5 questions

  1. Q1. A configuration management dry-run reports "no changes" across the fleet. What has been established?

  2. Q2. A file that no package owns is necessarily a drift finding that needs to be brought under management.

  3. Q3. Which files are invisible to BOTH `dpkg --verify` and a CM dry-run? Select all that apply.

  4. Q4. Why must `LC_ALL=C` be set before sorting the inputs to a `comm` comparison?

  5. Q5. Which figure is the most useful thing to track over time in a drift programme?

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