Automate Linux Hardening with Ansible
Audit and harden a Linux server against CIS Benchmarks using Ansible.
Introduction
30 Second Summary
Every time you log into an online bank, shop, or health portal, you're trusting that someone secured the servers holding your data. In enterprises, keeping hundreds of Linux servers locked down isn't a one-time task. It's a continuous, auditable process.
In this project, you will build an automated compliance framework using Ansible that audits and hardens a Linux server against CIS Benchmark security controls. You'll measure your server's security posture before and after hardening to prove measurable improvement.
What You'll Build
A complete CIS compliance automation framework that audits security posture, applies hardening remediation, and verifies results using the same Ansible role structure enterprise security teams use at scale.
By the end of this project, you'll have:
- A working Ansible compliance role that checks and remediates SSH, filesystem, and services controls against CIS standards.
- An audit playbook that reports compliance status without making any changes to your system.
- A before/after compliance score proving your hardening measurably improved security posture.
- Secret Mission: Generate an HTML compliance report using Jinja2 and build automated drift detection for continuous compliance.
Are there any prerequisites?
You'll need a Windows machine with WSL installed (Ubuntu distribution) and VS Code. Ansible will be installed in Step 1.
Set Up Ansible and Project Structure
Before you can automate any security hardening, you need an automation engine installed and a project structure to organize your work.
In this step, you will install Ansible on your WSL environment and build a role-based directory layout that mirrors how enterprise security teams manage compliance at scale.
In this step, get ready to:
- Install Ansible on WSL using the official Ubuntu PPA.
- Create the project directory structure that mirrors enterprise Ansible projects.
- Configure Ansible to target localhost and verify connectivity.
Install Ansible on WSL
Ansible is the automation engine that will execute your compliance playbooks. On the job, Ansible is installed on a dedicated "control node" that manages your entire server fleet. Your WSL environment simulates this control node.
- In your WSL terminal, install Ansible using the official Ubuntu PPA by running these commands:
sudo apt update
sudo apt install software-properties-common
sudo add-apt-repository --yes --update ppa:ansible/ansible
sudo apt install ansible
What do these commands do?
- sudo apt update refreshes your package list so your system knows what is available to install.
- sudo apt install software-properties-common adds the tools needed to manage third-party repositories (PPAs).
- sudo add-apt-repository --yes --update ppa:ansible/ansible adds the official Ansible repository so you get the latest stable version.
- sudo apt install ansible installs Ansible itself.
Responding to prompts
The apt install commands may ask you to confirm with Y/n. Type Y and press Enter to proceed.
- Verify Ansible installed correctly by running:
ansible --version
✔️ I see a version number
Ansible is installed and ready to go. You should see output starting with ansible [core ...] followed by configuration details.
ⓧ Command not found
The PPA may not have been added correctly. Try running the full install sequence again from the top:
sudo add-apt-repository --yes --update ppa:ansible/ansible
sudo apt install ansible
If the issue persists, make sure your WSL instance has internet access by running ping google.com and checking for responses.
Create the project directory structure
Enterprise Ansible projects follow a role-based directory layout. This structure lets teams manage hundreds of compliance controls as modular, testable units that different team members can own.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder with all the required subdirectories by running these commands:
mkdir -p cis-hardening/inventory
mkdir -p cis-hardening/roles/cis_baseline/tasks
mkdir -p cis-hardening/roles/cis_baseline/defaults
mkdir -p cis-hardening/roles/cis_baseline/handlers
mkdir -p cis-hardening/roles/cis_baseline/templates
cd cis-hardening
What is this directory structure?
This is the standard Ansible role layout. The roles/cis_baseline/ folder is your reusable compliance role, split into logical sections:
- tasks/ holds the actual automation logic (what to check and fix).
- defaults/ holds configurable variables (the thresholds and values to enforce).
- handlers/ holds actions triggered by changes (like restarting a service).
- templates/ holds Jinja2 templates for generating files dynamically.
- Confirm the directories were created by running:
ls
You should see inventory and roles listed in the output.
Don't see inventory and roles?
Make sure you ran cd cis-hardening after the mkdir commands. If you see the cis-hardening folder itself listed, you are one level above where you need to be.
help me navigate into my cis-hardening project folder
Configure Ansible and verify connectivity
Ansible needs three files to know what to do and where to do it. You will create a configuration file, an inventory file, and a main playbook.
- In VS Code, open the cis-hardening folder by clicking File in the top menu bar.
- Click Open Folder.
- Navigate to Desktop > cis-hardening and click Open.
- Create a new file called ansible.cfg in the project root by right-clicking in the Explorer sidebar and selecting New File.
- Paste this configuration into ansible.cfg:
[defaults]
inventory = inventory/hosts.ini
host_key_checking = False
retry_files_enabled = False
[privilege_escalation]
become = True
become_method = sudo
become_user = root
become_ask_pass = False
What does this configuration do?
- inventory = inventory/hosts.ini tells Ansible where to find the list of servers to manage.
- host_key_checking = False disables SSH key verification for local connections (safe for this local setup).
- The [privilege_escalation] section tells Ansible to run tasks as root using sudo. Security hardening requires root privileges to modify system files.
- Save ansible.cfg.
- Create a new file at inventory/hosts.ini by right-clicking the inventory folder in the Explorer sidebar and selecting New File.
- Name it hosts.ini and paste this content:
[local]
localhost ansible_connection=local
Why localhost?
The ansible_connection=local setting tells Ansible to run commands directly on this machine instead of connecting over SSH. In production, this inventory file would list hundreds of servers grouped by role (webservers, databases, etc.). The same playbooks you write today run unchanged against remote fleets.
- Save hosts.ini.
- Create a new file called site.yml in the project root (the top-level cis-hardening folder).
- Paste the following into site.yml:
---
- name: Apply CIS Benchmark hardening
hosts: local
become: true
roles:
- role: cis_baseline
What does this playbook do?
- hosts: local targets the [local] group you just defined in your inventory.
- become: true enables privilege escalation so tasks run with root permissions.
- roles: lists the roles to apply. For now it references cis_baseline which you will build in the next steps.
- Save site.yml.
- Verify that Ansible can reach your local machine by running this command in your WSL terminal:
ansible all -m ping
✔️ I see localhost | SUCCESS
Ansible is configured and can communicate with your local machine. You should see output containing "ping": "pong" which confirms Ansible executed the ping module successfully.
ⓧ I see an error about the inventory or host
Make sure you are running the command from inside the cis-hardening directory. Ansible reads ansible.cfg from the current working directory.
- Confirm your location by running pwd. You should see a path ending in cis-hardening.
- Check that inventory/hosts.ini exists by running cat inventory/hosts.ini. You should see the [local] group and localhost entry.
✔️ Awesome, I've got everything!
Double check you have saved all three files before moving on.
ⓧ I'd like to double check the full code
Here are all three files you should have at this point:
[defaults]
inventory = inventory/hosts.ini
host_key_checking = False
retry_files_enabled = False
[privilege_escalation]
become = True
become_method = sudo
become_user = root
become_ask_pass = False
[local]
localhost ansible_connection=local
---
- name: Apply CIS Benchmark hardening
hosts: local
become: true
roles:
- role: cis_baseline
Your automation environment is ready. You have Ansible installed, a proper role-based project structure, and a verified connection to your local machine. Next up, you will build the audit playbook that checks your system's security posture against CIS Benchmarks without making any changes.
Build the Baseline Audit Playbook
In Step 1, you built the skeleton of an enterprise Ansible project. Now it's time to teach that skeleton how to ask questions.
Before hardening a server, you need to know where it currently stands. A compliance audit checks the current state of a system without making any changes. In this step, you'll configure the controls your framework checks for and build the audit tasks that inspect your server's security posture.
In this step, get ready to:
- Define configurable CIS control variables for SSH, services, and filesystem permissions.
- Create audit tasks that check system state without modifying anything.
- Run your first audit to see which controls are currently non-compliant.
Define the CIS control variables
Every enterprise environment is different. A database server might need different controls than a web server. To handle this, your role uses configurable variables that toggle entire sections on or off and define the specific values each control enforces.
- Create the role defaults file by running this command from inside your cis-hardening directory:
cat > roles/cis_baseline/defaults/main.yml << 'EOF'
---
# Toggle CIS sections
cis_ssh_enabled: true
cis_services_enabled: true
cis_filesystem_enabled: true
# SSH configuration (CIS Section 5.2)
cis_ssh_permit_root_login: "no"
cis_ssh_max_auth_tries: 4
cis_ssh_client_alive_interval: 300
cis_ssh_client_alive_count_max: 3
cis_ssh_permit_empty_passwords: "no"
cis_ssh_login_grace_time: 60
# Services to disable (CIS Section 2)
cis_disabled_services:
- avahi-daemon
- cups
- rpcbind
# File permissions (CIS Section 6)
cis_file_permissions:
- path: /etc/passwd
mode: "0644"
- path: /etc/shadow
mode: "0640"
- path: /etc/group
mode: "0644"
EOF
What does this file do?
This file defines the default values for your role's variables. Every variable here can be overridden per server group without changing the role itself.
- The three cis_ssh_enabled, cis_services_enabled, and cis_filesystem_enabled toggles let you enable or disable entire control sections per server group.
- The SSH values (MaxAuthTries 4, ClientAliveInterval 300) come directly from CIS Benchmarks Section 5.2.
- The cis_disabled_services list targets services that expand your attack surface when running unnecessarily.
- The cis_file_permissions list enforces correct permissions on credential files like /etc/shadow.
Why do toggles exist in enterprise roles?
No two enterprises are identical. A database server might need different controls than a web server. On the job, you would configure these per server group in group_vars/, letting you apply the same role with different settings across your fleet.
- Verify the file was created by running:
cat roles/cis_baseline/defaults/main.yml
You should see the full YAML content you just wrote, starting with the toggle variables and ending with the file permissions list.
Seeing 'No such file or directory'?
- Make sure you're inside the cis-hardening directory. Run pwd to check your current location.
- Confirm the roles/cis_baseline/defaults/ directory exists by running ls roles/cis_baseline/defaults/. If it's missing, create it with mkdir -p roles/cis_baseline/defaults.
If you're stuck, help me troubleshoot my defaults/main.yml file
✔️ Awesome, I've got everything!
Great. Make sure you've saved roles/cis_baseline/defaults/main.yml before moving on.
ⓧ I'd like to double check the full code
Here is the complete roles/cis_baseline/defaults/main.yml file:
---
# Toggle CIS sections
cis_ssh_enabled: true
cis_services_enabled: true
cis_filesystem_enabled: true
# SSH configuration (CIS Section 5.2)
cis_ssh_permit_root_login: "no"
cis_ssh_max_auth_tries: 4
cis_ssh_client_alive_interval: 300
cis_ssh_client_alive_count_max: 3
cis_ssh_permit_empty_passwords: "no"
cis_ssh_login_grace_time: 60
# Services to disable (CIS Section 2)
cis_disabled_services:
- avahi-daemon
- cups
- rpcbind
# File permissions (CIS Section 6)
cis_file_permissions:
- path: /etc/passwd
mode: "0644"
- path: /etc/shadow
mode: "0640"
- path: /etc/group
mode: "0644"
Create the audit tasks
An audit checks your system's current state without changing anything. This is the mode compliance teams run daily to answer one question: are we compliant right now?
- Create the audit task file by running:
cat > roles/cis_baseline/tasks/audit.yml << 'EOF'
---
# Audit tasks - check current system state without making changes
- name: Check SSH PermitRootLogin setting
ansible.builtin.shell: grep -i "^PermitRootLogin" /etc/ssh/sshd_config
register: ssh_root_login
changed_when: false
failed_when: false
check_mode: false
- name: Check SSH MaxAuthTries setting
ansible.builtin.shell: grep -i "^MaxAuthTries" /etc/ssh/sshd_config
register: ssh_max_auth
changed_when: false
failed_when: false
check_mode: false
- name: Check if unnecessary services are running
ansible.builtin.shell: systemctl is-active {{ item }} 2>/dev/null || echo inactive
loop: "{{ cis_disabled_services }}"
register: services_status
changed_when: false
failed_when: false
check_mode: false
- name: Check file permissions on sensitive files
ansible.builtin.stat:
path: "{{ item.path }}"
loop: "{{ cis_file_permissions }}"
register: file_perms
- name: Display SSH audit results
ansible.builtin.debug:
msg: |
SSH PermitRootLogin: {{ ssh_root_login.stdout | default('NOT SET') }}
SSH MaxAuthTries: {{ ssh_max_auth.stdout | default('NOT SET') }}
- name: Display file permission results
ansible.builtin.debug:
msg: "{{ item.item.path }} has mode {{ item.stat.mode }}"
loop: "{{ file_perms.results }}"
EOF
What does this code do?
- Each shell task uses grep or systemctl to read current values. The register keyword saves the output into a variable for later display.
- The changed_when: false line tells Ansible that these tasks never change anything. This keeps your audit output clean (no false "changed" reports).
- The check_mode: false line ensures these read-only tasks still execute even when you run the playbook with the --check flag. Without it, shell and command tasks get skipped in check mode.
- The stat module retrieves file metadata (including permissions) without modifying anything.
- The debug tasks display the collected results so you can see your system's current compliance state.
- Now wire the audit tasks into the role by creating tasks/main.yml:
cat > roles/cis_baseline/tasks/main.yml << 'EOF'
---
- name: Include audit tasks
ansible.builtin.include_tasks: audit.yml
EOF
Why do we need tasks/main.yml?
When Ansible applies a role, it looks for tasks/main.yml as the entry point. This file acts as a table of contents that imports other task files. Right now it only includes the audit. In the next step, you'll replace this with conditional imports for each hardening section.
- Verify both files were created by running:
ls roles/cis_baseline/tasks/
You should see audit.yml and main.yml listed in the output.
Only see one file or neither?
- Check that you ran both cat > commands from inside the cis-hardening directory.
- If a file is missing, re-run the corresponding cat > command above. Make sure you include the EOF marker on its own line at the end.
If something still looks wrong, help me fix my audit task files.
Run your first compliance audit
The --check flag tells Ansible to run without making changes, and --diff shows what would be different. Together, they let you preview findings without touching the system. This is how you present findings to change advisory boards before touching production.
- Run the audit playbook in check mode:
ansible-playbook site.yml --check --diff
You should see Ansible execute each audit task. The debug output displays your current SSH settings and file permissions. Items showing NOT SET or unexpected values indicate non-compliance.
What should I see?
The output shows each task name followed by its status. The debug tasks print your current SSH configuration and file permissions. You'll likely see that some SSH settings are commented out (showing NOT SET) or have different values than what CIS requires.
This is your baseline. These are the gaps your hardening role will fix in the next step.
Why is this approach valuable in enterprises?
The --check flag generates the evidence trail that Change Management requires. In enterprises using ServiceNow, these audit results feed directly into ITSM dashboards, and failures auto-create incidents. You run this daily to answer one question: are we compliant right now?
Seeing 'ERROR! the role cis_baseline was not found'?
- Make sure you're running the command from inside the cis-hardening directory (not from roles/ or any subdirectory).
- Verify that roles/cis_baseline/tasks/main.yml exists. Ansible needs this file to recognize the role.
- Check that ansible.cfg is in your current directory by running ls ansible.cfg.
Still stuck? Help me run my first Ansible audit playbook.
Your audit framework is working. You now have a clear picture of where your system deviates from CIS Benchmarks. Next up, you'll build the hardening role that actually fixes these gaps.
Create the CIS Hardening Role
In the previous step, you built audit tasks that check compliance without changing anything. Now it is time to create the remediation tasks that actually fix non-compliance.
SSH misconfiguration is the number one attack vector on Linux servers. Every enterprise penetration test finds SSH weaknesses. In this step, you will build a complete Ansible role that hardens SSH, disables unnecessary services, and locks down filesystem permissions.
In this step, get ready to:
- Create SSH hardening tasks that enforce six CIS controls on your SSH configuration.
- Create service and filesystem hardening tasks that reduce attack surface and protect credential files.
- Wire all task files together with conditional imports and a handler for SSH restarts.
Create the SSH hardening tasks
The SSH controls in CIS Benchmarks Section 5.2 are non-negotiable for compliance. Each task uses the lineinfile module to find a line in /etc/ssh/sshd_config and set it to the value defined in your defaults.
- In VS Code, create a new file at roles/cis_baseline/tasks/ssh.yml inside your cis-hardening project.
- Add the following SSH hardening tasks:
---
- name: Ensure SSH PermitRootLogin is disabled
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?\s*PermitRootLogin'
line: "PermitRootLogin {{ cis_ssh_permit_root_login }}"
state: present
notify: Restart sshd
- name: Ensure SSH MaxAuthTries is set
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?\s*MaxAuthTries'
line: "MaxAuthTries {{ cis_ssh_max_auth_tries }}"
state: present
notify: Restart sshd
- name: Ensure SSH ClientAliveInterval is set
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?\s*ClientAliveInterval'
line: "ClientAliveInterval {{ cis_ssh_client_alive_interval }}"
state: present
notify: Restart sshd
- name: Ensure SSH ClientAliveCountMax is set
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?\s*ClientAliveCountMax'
line: "ClientAliveCountMax {{ cis_ssh_client_alive_count_max }}"
state: present
notify: Restart sshd
- name: Ensure SSH PermitEmptyPasswords is disabled
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?\s*PermitEmptyPasswords'
line: "PermitEmptyPasswords {{ cis_ssh_permit_empty_passwords }}"
state: present
notify: Restart sshd
- name: Ensure SSH LoginGraceTime is set
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?\s*LoginGraceTime'
line: "LoginGraceTime {{ cis_ssh_login_grace_time }}"
state: present
notify: Restart sshd
What does this code do?
Each task targets one SSH directive in /etc/ssh/sshd_config. The regexp pattern matches both commented-out lines (#PermitRootLogin) and active lines, so the module handles either state.
The notify: Restart sshd line tells Ansible to restart SSH only if the line actually changed. This is idempotency in action. If the config is already correct, nothing happens.
The actual values ("no", 4, 300) come from your defaults/main.yml variables, keeping configuration separate from logic.
- PermitRootLogin no blocks direct root access over SSH.
- MaxAuthTries 4 limits password guessing attempts.
- ClientAliveInterval 300 disconnects idle sessions after 5 minutes of silence.
- PermitEmptyPasswords no prevents authentication without a password.
- Save ssh.yml.
- Confirm it appears under roles/cis_baseline/tasks/ in the VS Code Explorer sidebar.
File not appearing in the sidebar?
Make sure you saved the file with the exact path roles/cis_baseline/tasks/ssh.yml. If you accidentally saved it one level too high, drag it into the correct tasks/ folder.
Also confirm the file extension is .yml and not .yaml. While Ansible accepts both, the role expects .yml to match the import references we will write later.
help me fix my ssh.yml file location
Why SSH is the top priority for hardening
SSH misconfiguration is the number one finding in enterprise penetration tests. These exact controls appear in CIS Benchmark Section 5.2 and are non-negotiable for compliance.
When these settings drift from their hardened state, it creates the security incidents that operations teams must investigate and remediate.
Create services and filesystem hardening tasks
Every running service is an attack surface. Enterprise security teams identify which services are actually needed per server role and disable the rest.
- Create a new file at roles/cis_baseline/tasks/services.yml and add the following:
---
- name: Ensure unnecessary services are disabled
ansible.builtin.systemd:
name: "{{ item }}"
state: stopped
enabled: false
loop: "{{ cis_disabled_services }}"
failed_when: false
What does this code do?
This single task loops over every service listed in cis_disabled_services (avahi-daemon, cups, rpcbind) and stops it. The enabled: false ensures the service stays off after a reboot.
The failed_when: false is important. Your WSL environment may not have all these services installed. Without this line, Ansible would fail if a service does not exist. This is also enterprise-realistic because not every server has every service.
- Save services.yml.
Next, you will protect the credential files that store user account information. A single misconfigured /etc/shadow file (world-readable) is a critical security incident.
- Create a new file at roles/cis_baseline/tasks/filesystem.yml and add the following:
---
- name: Ensure correct file permissions
ansible.builtin.file:
path: "{{ item.path }}"
mode: "{{ item.mode }}"
loop: "{{ cis_file_permissions }}"
What does this code do?
This task loops over the cis_file_permissions list from your defaults file. It enforces that /etc/passwd is 0644, /etc/shadow is 0640, and /etc/group is 0644.
These permissions protect credential files from unauthorized reading. /etc/shadow holds password hashes and must only be readable by root and the shadow group.
- Save filesystem.yml.
- Confirm both services.yml and filesystem.yml appear under roles/cis_baseline/tasks/ in the Explorer sidebar.
Seeing a YAML syntax error underline?
YAML is strict about indentation. Each nested level must use exactly two spaces (not tabs). Check that name:, state:, and enabled: are all indented to the same level under the module name.
help me fix YAML indentation in my Ansible task file
Wire the role together with main.yml and handlers
Ansible needs a tasks/main.yml file to know what tasks to run when the role is applied. This file acts as the entry point that conditionally imports each task file based on your toggle variables.
- Create a new file at roles/cis_baseline/tasks/main.yml and add the following:
---
- name: Include SSH hardening tasks
ansible.builtin.include_tasks: ssh.yml
when: cis_ssh_enabled | bool
- name: Include services hardening tasks
ansible.builtin.include_tasks: services.yml
when: cis_services_enabled | bool
- name: Include filesystem hardening tasks
ansible.builtin.include_tasks: filesystem.yml
when: cis_filesystem_enabled | bool
What does this code do?
Each include_tasks directive pulls in one of your task files. The when: cis_ssh_enabled | bool condition means that section only runs if its toggle variable is true in your defaults.
This pattern lets you apply the same role with different settings across different server groups. A database server might disable SSH hardening while keeping filesystem controls active. In production, these toggles are set per group in group_vars/ files.
- Save tasks/main.yml.
The SSH tasks use notify: Restart sshd to trigger a handler. Handlers only run when a task reports a change, which ensures services restart only when configuration actually changed.
- Create a new file at roles/cis_baseline/handlers/main.yml and add the following:
---
- name: Restart sshd
ansible.builtin.service:
name: ssh
state: restarted
Why the service name is ssh, not sshd
On Ubuntu and Debian systems, the SSH service is named ssh. On RHEL and CentOS systems, it is named sshd. Since your WSL runs Ubuntu, the handler targets ssh.
- Save handlers/main.yml.
- Verify your complete role structure by running this command in your terminal:
ansible-playbook site.yml --syntax-check
- You should see playbook: site.yml with no errors. This confirms Ansible can find and parse all your role files.
Seeing a syntax error or file not found?
If Ansible reports a file not found, double-check that your directory structure matches: roles/cis_baseline/tasks/main.yml must exist (this is the entry point Ansible looks for).
If you see a YAML parsing error, check the file and line number in the error message. The most common cause is inconsistent indentation or a missing colon after a key name.
help me debug my Ansible role structure error
✔️ Awesome, I've got everything!
Great. Double check you have saved all five files before moving on.
ⓧ I'd like to double check the full code
Here is the complete code for each file in your role:
---
- name: Ensure SSH PermitRootLogin is disabled
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?\s*PermitRootLogin'
line: "PermitRootLogin {{ cis_ssh_permit_root_login }}"
state: present
notify: Restart sshd
- name: Ensure SSH MaxAuthTries is set
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?\s*MaxAuthTries'
line: "MaxAuthTries {{ cis_ssh_max_auth_tries }}"
state: present
notify: Restart sshd
- name: Ensure SSH ClientAliveInterval is set
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?\s*ClientAliveInterval'
line: "ClientAliveInterval {{ cis_ssh_client_alive_interval }}"
state: present
notify: Restart sshd
- name: Ensure SSH ClientAliveCountMax is set
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?\s*ClientAliveCountMax'
line: "ClientAliveCountMax {{ cis_ssh_client_alive_count_max }}"
state: present
notify: Restart sshd
- name: Ensure SSH PermitEmptyPasswords is disabled
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?\s*PermitEmptyPasswords'
line: "PermitEmptyPasswords {{ cis_ssh_permit_empty_passwords }}"
state: present
notify: Restart sshd
- name: Ensure SSH LoginGraceTime is set
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?\s*LoginGraceTime'
line: "LoginGraceTime {{ cis_ssh_login_grace_time }}"
state: present
notify: Restart sshd
---
- name: Ensure unnecessary services are disabled
ansible.builtin.systemd:
name: "{{ item }}"
state: stopped
enabled: false
loop: "{{ cis_disabled_services }}"
failed_when: false
---
- name: Ensure correct file permissions
ansible.builtin.file:
path: "{{ item.path }}"
mode: "{{ item.mode }}"
loop: "{{ cis_file_permissions }}"
---
- name: Include SSH hardening tasks
ansible.builtin.include_tasks: ssh.yml
when: cis_ssh_enabled | bool
- name: Include services hardening tasks
ansible.builtin.include_tasks: services.yml
when: cis_services_enabled | bool
- name: Include filesystem hardening tasks
ansible.builtin.include_tasks: filesystem.yml
when: cis_filesystem_enabled | bool
---
- name: Restart sshd
ansible.builtin.service:
name: ssh
state: restarted
Your hardening role is now complete with SSH, services, and filesystem controls. Next up, you will run the full remediation and measure your compliance improvement with before-and-after scoring.
Apply Hardening and Measure Compliance
In the previous step, you built a complete CIS hardening role with SSH, services, and filesystem controls. The role is ready to go, but your server is still running with its default (insecure) configuration.
Now it is time to actually apply the hardening. You will run the remediation playbook, prove that it is safe to run repeatedly, and then measure your compliance improvement with a scoring script.
In this step, get ready to:
- Run the full hardening remediation with change tracking.
- Prove idempotency by running the playbook a second time.
- Measure compliance improvement with a scoring script.
Run the full remediation playbook
Until now, your hardening role has only been tested in check mode (a dry run). Running the playbook without --check applies the changes for real. The --diff flag logs exactly what changed in each file so you have a full change record.
- Apply the full CIS hardening by running this command in your WSL terminal from the cis-hardening directory:
ansible-playbook site.yml --diff
What does this command do?
This runs site.yml in full remediation mode. Unlike check mode, this actually modifies your system.
The --diff flag shows the before/after state of every file that gets changed. In enterprise environments, this diff output is attached to Change Management tickets as evidence of what was modified.
- Review the terminal output. You should see "changed" status on multiple tasks. The diff output shows lines being added or modified in /etc/ssh/sshd_config and permission changes on files like /etc/passwd and /etc/shadow.
Why track changes with --diff?
In enterprise environments, every change to production must be documented. The --diff output IS your change record. You would attach this to a ServiceNow change ticket as evidence of what was modified, when, and by whom.
Seeing permission denied errors?
Make sure your ansible.cfg has the [privilege_escalation] section with become = True. Without it, Ansible cannot elevate to root to modify system files.
If sudo prompts for a password, run sudo echo test first to cache your credentials, then re-run the playbook.
Help me troubleshoot permission errors when running my Ansible playbook
Prove idempotency with a second run
Idempotency means running the same operation multiple times produces the same result without side effects. This is what makes Ansible safe for daily automated enforcement.
- Run the exact same playbook again by running this command:
ansible-playbook site.yml --diff
- Check the play recap at the bottom of the output. You should see changed=0 for every host. This means nothing was modified because the system already matches the desired state.
Why does zero changes matter?
Idempotency is why Ansible replaced shell scripts in enterprise environments. A shell script that runs twice might break things (appending duplicate lines, restarting services unnecessarily). An Ansible playbook that runs twice does nothing the second time.
This is what enables teams to schedule compliance enforcement as daily cron jobs. They know the playbook is safe to run on every server, every day, without risk.
Still seeing changes on the second run?
If the SSH tasks still report changes, check that your regexp patterns in ssh.yml match the line format being written. If the regex does not match the existing line, lineinfile appends a duplicate instead of recognizing the line is already present.
Help me debug why my Ansible playbook is not idempotent
Create and run the compliance scoring script
Enterprise security teams track compliance as a percentage score over time. A drop in score triggers an incident investigation. You will create a simple scoring script that checks each control and reports a pass/fail result with an overall percentage.
- Create a new file called audit_score.sh in the root of your cis-hardening folder. Copy the following code into it:
#!/bin/bash
# CIS Compliance Score Calculator
TOTAL=0
PASSED=0
echo "=============================="
echo "CIS Compliance Audit Report"
echo "=============================="
echo ""
# Check SSH PermitRootLogin
TOTAL=$((TOTAL + 1))
if grep -q "^PermitRootLogin no" /etc/ssh/sshd_config 2>/dev/null; then
echo "[PASS] SSH PermitRootLogin is disabled"
PASSED=$((PASSED + 1))
else
echo "[FAIL] SSH PermitRootLogin is NOT disabled"
fi
# Check SSH MaxAuthTries
TOTAL=$((TOTAL + 1))
if grep -q "^MaxAuthTries 4" /etc/ssh/sshd_config 2>/dev/null; then
echo "[PASS] SSH MaxAuthTries is set to 4"
PASSED=$((PASSED + 1))
else
echo "[FAIL] SSH MaxAuthTries is NOT set to 4"
fi
# Check SSH PermitEmptyPasswords
TOTAL=$((TOTAL + 1))
if grep -q "^PermitEmptyPasswords no" /etc/ssh/sshd_config 2>/dev/null; then
echo "[PASS] SSH PermitEmptyPasswords is disabled"
PASSED=$((PASSED + 1))
else
echo "[FAIL] SSH PermitEmptyPasswords is NOT disabled"
fi
# Check /etc/passwd permissions
TOTAL=$((TOTAL + 1))
PERMS=$(stat -c "%a" /etc/passwd 2>/dev/null)
if [ "$PERMS" = "644" ]; then
echo "[PASS] /etc/passwd permissions are 0644"
PASSED=$((PASSED + 1))
else
echo "[FAIL] /etc/passwd permissions are $PERMS (expected 0644)"
fi
# Check /etc/shadow permissions
TOTAL=$((TOTAL + 1))
PERMS=$(stat -c "%a" /etc/shadow 2>/dev/null)
if [ "$PERMS" = "640" ]; then
echo "[PASS] /etc/shadow permissions are 0640"
PASSED=$((PASSED + 1))
else
echo "[FAIL] /etc/shadow permissions are $PERMS (expected 0640)"
fi
# Check /etc/group permissions
TOTAL=$((TOTAL + 1))
PERMS=$(stat -c "%a" /etc/group 2>/dev/null)
if [ "$PERMS" = "644" ]; then
echo "[PASS] /etc/group permissions are 0644"
PASSED=$((PASSED + 1))
else
echo "[FAIL] /etc/group permissions are $PERMS (expected 0644)"
fi
echo ""
echo "=============================="
SCORE=$((PASSED * 100 / TOTAL))
echo "Score: $PASSED/$TOTAL ($SCORE%)"
echo "=============================="
What does this script do?
- Each control gets a pass/fail check using grep (for SSH config values) or stat (for file permissions).
- The script increments a PASSED counter for each control that meets the CIS requirement.
- At the end, it calculates a percentage score. This is the same concept that appears on enterprise security dashboards to track compliance over time.
- Make the script executable and run it by running these commands:
chmod +x audit_score.sh
./audit_score.sh
- Review the output. You should see [PASS] for all six controls with a final score of 6/6 (100%).
Why measure compliance as a score?
Security teams track compliance percentages over time on leadership dashboards. A drop in score triggers an incident investigation. Tracking which controls drift most frequently is how teams identify root causes and drive continual service improvement.
Seeing FAIL results after hardening?
If SSH checks are failing, confirm the remediation playbook completed successfully in the first substep. Run grep PermitRootLogin /etc/ssh/sshd_config to see if the line was actually written.
If permission checks are failing, verify with stat -c "%a" /etc/passwd to see the current permission value.
Help me debug why my audit score script is showing failures
✔️ Awesome, I've got everything!
Great. Double check you have saved audit_score.sh and that it runs successfully.
ⓧ I'd like to double check the full code
Here is the complete audit_score.sh file for comparison:
#!/bin/bash
# CIS Compliance Score Calculator
TOTAL=0
PASSED=0
echo "=============================="
echo "CIS Compliance Audit Report"
echo "=============================="
echo ""
# Check SSH PermitRootLogin
TOTAL=$((TOTAL + 1))
if grep -q "^PermitRootLogin no" /etc/ssh/sshd_config 2>/dev/null; then
echo "[PASS] SSH PermitRootLogin is disabled"
PASSED=$((PASSED + 1))
else
echo "[FAIL] SSH PermitRootLogin is NOT disabled"
fi
# Check SSH MaxAuthTries
TOTAL=$((TOTAL + 1))
if grep -q "^MaxAuthTries 4" /etc/ssh/sshd_config 2>/dev/null; then
echo "[PASS] SSH MaxAuthTries is set to 4"
PASSED=$((PASSED + 1))
else
echo "[FAIL] SSH MaxAuthTries is NOT set to 4"
fi
# Check SSH PermitEmptyPasswords
TOTAL=$((TOTAL + 1))
if grep -q "^PermitEmptyPasswords no" /etc/ssh/sshd_config 2>/dev/null; then
echo "[PASS] SSH PermitEmptyPasswords is disabled"
PASSED=$((PASSED + 1))
else
echo "[FAIL] SSH PermitEmptyPasswords is NOT disabled"
fi
# Check /etc/passwd permissions
TOTAL=$((TOTAL + 1))
PERMS=$(stat -c "%a" /etc/passwd 2>/dev/null)
if [ "$PERMS" = "644" ]; then
echo "[PASS] /etc/passwd permissions are 0644"
PASSED=$((PASSED + 1))
else
echo "[FAIL] /etc/passwd permissions are $PERMS (expected 0644)"
fi
# Check /etc/shadow permissions
TOTAL=$((TOTAL + 1))
PERMS=$(stat -c "%a" /etc/shadow 2>/dev/null)
if [ "$PERMS" = "640" ]; then
echo "[PASS] /etc/shadow permissions are 0640"
PASSED=$((PASSED + 1))
else
echo "[FAIL] /etc/shadow permissions are $PERMS (expected 0640)"
fi
# Check /etc/group permissions
TOTAL=$((TOTAL + 1))
PERMS=$(stat -c "%a" /etc/group 2>/dev/null)
if [ "$PERMS" = "644" ]; then
echo "[PASS] /etc/group permissions are 0644"
PASSED=$((PASSED + 1))
else
echo "[FAIL] /etc/group permissions are $PERMS (expected 0644)"
fi
echo ""
echo "=============================="
SCORE=$((PASSED * 100 / TOTAL))
echo "Score: $PASSED/$TOTAL ($SCORE%)"
echo "=============================="
Your compliance framework is fully operational. You can audit, remediate, and measure improvement. Next up, the Secret Mission takes this further with automated HTML reports and drift detection.
Secret mission
Generate an HTML Compliance Report and Drift Detection
Your compliance framework can audit and remediate, but leadership needs evidence. Build a professional HTML compliance report using Jinja2 templating, then automate drift detection so you know the moment a server falls out of compliance.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally in WSL, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The cis-hardening/ project folder on your Desktop.
- Ansible installed via the Ubuntu PPA.
- SSH configuration changes applied to /etc/ssh/sshd_config.
- Cron job for daily drift detection (if you completed the Secret Mission).
- Generated compliance report at /tmp/compliance-report.html.
Keep everything running
No action needed. Choose this if you plan to keep building on this compliance framework or want to reference the role structure later.
- Your cis-hardening/ folder stays on your Desktop.
- Ansible remains installed and ready for future playbooks.
- The SSH hardening changes stay in place, keeping your WSL environment more secure.
- If you completed the Secret Mission, the daily cron job continues running drift detection at 06:00.
Pause - I'll come back to this later
Remove the scheduled automation while keeping your project files intact. This frees up background processes without losing your work.
- If you completed the Secret Mission and want to stop the daily drift detection, open your crontab by running:
crontab -e
- Find the line containing Daily CIS compliance drift detection and delete it.
- Save the file and exit the editor.
- Your project folder, Ansible installation, SSH hardening, and report files all remain in place for when you return.
Delete - I don't want to use this again
Remove all project resources and the tools installed during this project. Follow these steps to clean up completely.
- Remove the project folder by running:
rm -rf ~/Desktop/cis-hardening
- Remove the generated compliance report by running:
rm /tmp/compliance-report.html
- If you completed the Secret Mission, remove the drift detection cron job by running:
crontab -e
- Find the line containing Daily CIS compliance drift detection, delete the entire line, then save and exit.
- Uninstall Ansible and remove the PPA by running:
sudo apt remove --purge ansible -y
sudo apt autoremove -y
sudo add-apt-repository --remove ppa:ansible/ansible
What about the SSH hardening changes?
The SSH configuration changes you applied to /etc/ssh/sshd_config improve your WSL environment's security. Leaving them in place is recommended.
If you want to fully revert, you can restore the default SSH config by reinstalling the openssh-server package. However, this removes security protections that have no downside to keeping.
Nice Work!
Nice Work!
Nice work! You just built a complete CIS compliance automation framework using Ansible that can audit, harden, and verify a Linux server's security posture.
This is the exact skill set enterprise Linux Admin roles require for maintaining OS compliance across server fleets.
You learned how to:
- Build a CIS Benchmark hardening role with modular task files covering SSH, services, and filesystem controls.
- Run playbooks in check mode to audit compliance without making changes, then apply remediation with full diff output for change records.
- Prove idempotency with zero-change second runs and measure compliance improvement with before/after scoring.
- Secret Mission: Generated HTML compliance reports using Jinja2 templates and automated drift detection for continuous compliance monitoring.
Ready to quiz yourself?