You've run your CIS RHEL 9 Ansible playbook. The tasks complete. You check Tenable VM expecting green — and several controls are still flagging on certain hosts. The playbook ran. The task reported ok or changed. So why is Tenable still flagging it?
The answer is almost always the same: your servers were built from different base images, and those images shipped with different default values in their configuration files.
When you use ansible.builtin.lineinfile to enforce a CIS control, the module is looking for a specific string pattern and replacing or inserting a line. The assumption built into most playbooks is that the target line either doesn't exist yet, or exists in a predictable format.
That assumption breaks when different base images have already written different default values into those files. A line that lineinfile expects to find and replace doesn't match the regex — so the module inserts a new line instead of replacing the existing one. Both lines are now present. The wrong one may take precedence depending on how the config file is parsed. Tenable still flags the control. The playbook still reports changed.
Key insight: Ansible reporting changed does not mean the file is in the state you intended. It means Ansible made a change. Those are different things.
Here's a common pattern that fails silently across mixed images. You're enforcing ClientAliveInterval in /etc/ssh/sshd_config:
- name: "5.1.8 | Ensure SSH ClientAliveInterval is configured"
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^ClientAliveInterval'
line: 'ClientAliveInterval 15'
state: present
On Server 1 (built from Image A), ClientAliveInterval doesn't exist in the file. lineinfile inserts it. Control passes.
On Server 2 (built from Image B), the default image already wrote #ClientAliveInterval 0 — commented out with a value. Your regexp ^ClientAliveInterval doesn't match commented lines. lineinfile inserts a new line. Now the file has both. Depending on sshd's parsing, the commented default may not matter — but Tenable's audit file checks for the exact string and may still flag it.
Two approaches depending on your environment:
Option 1 — Broaden the regexp to catch commented variants
- name: "5.1.8 | Ensure SSH ClientAliveInterval is configured"
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?\s*ClientAliveInterval'
line: 'ClientAliveInterval 15'
state: present
The ^#?\s* prefix catches both uncommented and commented variants. The module replaces whichever form is present. One line in the file, correct value.
Option 2 — Use blockinfile or template for the entire config
For configs with multiple interdependent settings, lineinfile is the wrong tool. A Jinja2 template that renders the entire sshd_config from variables gives you deterministic output regardless of what the base image shipped with. Every host gets the same file. No regexp ambiguity.
- name: "Deploy hardened sshd_config"
ansible.builtin.template:
src: sshd_config.j2
dest: /etc/ssh/sshd_config
owner: root
group: root
mode: '0600'
notify: Reload sshd
After applying the updated playbook, verify the file state directly rather than relying on Ansible's return status:
# Check what's actually in the file
grep -i "clientaliveinterval" /etc/ssh/sshd_config
# Verify sshd accepts the config
sshd -t && echo "Config valid"
Then re-run your Tenable credentialed scan against the affected hosts. The control should pass. If it doesn't, compare what Tenable's audit file is checking against what's actually in the file — the audit check pattern may be more specific than your regexp.
Before any CIS hardening run across a mixed fleet, audit the current state of the files you're targeting. A quick Ansible fact gather or a shell task that cats the relevant config sections across all hosts tells you what you're dealing with before you start changing things.
- name: "Audit current SSH config across fleet"
ansible.builtin.shell: grep -E "^#?ClientAliveInterval|^#?ClientAliveCountMax" /etc/ssh/sshd_config
register: ssh_audit
changed_when: false
- name: "Show current values"
ansible.builtin.debug:
msg: ": "
Run that first. Know your starting state. Then harden with confidence.
These posts come from real production environments — not labs, not documentation. If that's the kind of writing you want more of, follow Root & Secure on Medium.