You've run your CIS RHEL 9 Ansible playbook. The tasks complete without errors. You re-run the Tenable VM compliance scan expecting green — and several controls are still failing 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, you're typically looking for a specific string pattern and replacing or inserting a line. The assumption baked 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 values to the same file. A RHEL 9 image from one vendor might ship with PASS_MAX_DAYS 99999 in /etc/login.defs. Another image ships with PASS_MAX_DAYS 365 — same key, different whitespace, different value. A third image has the line commented out entirely.
Your lineinfile task with regexp: '^PASS_MAX_DAYS' will match the first two but silently behave differently depending on the existing value. On the commented-out variant it may insert a duplicate line rather than replacing the commented one, leaving Tenable to find a non-compliant value still present.
Key insight: Ansible reporting changed does not mean the file is now in a compliant state. It means Ansible made a change. Those are different things.
Here's a common pattern that fails silently across mixed images:
- name: "5.4.1.1 | Ensure password expiration is 365 days or less"
ansible.builtin.lineinfile:
path: /etc/login.defs
regexp: '^PASS_MAX_DAYS'
line: 'PASS_MAX_DAYS 365'
state: present
This works fine on a clean image. But on a system where PASS_MAX_DAYS was previously commented out as #PASS_MAX_DAYS 99999, the regexp doesn't match the commented line, so Ansible appends a new PASS_MAX_DAYS 365 line — but the old commented value remains. Depending on how Tenable's plugin evaluates the file, it may still flag the control as non-compliant.
The most reliable approach is to broaden your regexp to catch commented variants and alternate whitespace, and combine it with a backup or block replacement where the control is critical:
- name: "5.4.1.1 | Ensure password expiration is 365 days or less"
ansible.builtin.lineinfile:
path: /etc/login.defs
regexp: '^#?\s*PASS_MAX_DAYS'
line: 'PASS_MAX_DAYS 365'
state: present
The ^#?\s* prefix catches commented lines and any leading whitespace variation. This makes the task idempotent across images regardless of how the base was configured.
For controls where the entire block needs to be controlled — SSH configuration is a good example — prefer ansible.builtin.blockinfile or a templated config file over lineinfile. This removes the dependency on what the base image shipped entirely.
Before running a CIS playbook across a mixed fleet, inventory your base images and diff the relevant config files. A quick ad-hoc command against your host groups will surface the variance before you assume uniformity that doesn't exist:
ansible all -m shell -a "grep PASS_MAX_DAYS /etc/login.defs" -i inventory
Know what you're working with before you automate against it. The playbook is only as reliable as the assumptions it makes about the environment.
>_ Have questions or feedback on this post?
Reach out at info@rootandsecure.io or connect on LinkedIn.
Working through CIS hardening or Ansible automation in your environment? Book a free intake call and let's work through it together.