HackBlue

Tools · Python and Bash for security automation · 20 / 20

Applying a security baseline with Ansible: idempotent hardening for lab Linux hosts

Write an Ansible playbook that applies SSH hardening, a firewall, automatic updates, and audit rules, run it in check mode first, and keep it in Git as the documented baseline.

11 min read · practice only with authorization

Why configuration management

Hardening by hand drifts: one server gets the change and another does not. Ansible describes the desired state in YAML and applies it over SSH, without agents. Modules are idempotent, meaning running the playbook twice changes nothing the second time, which makes it safe to run regularly and to see drift in the output.

A small baseline

The playbook below sets key SSH options, installs and configures UFW, enables unattended upgrades, and adds an audit rule. A handler restarts SSH only when its configuration changes, and validate checks the sshd configuration before replacing it.

Snippet
- hosts: lab_linux
  become: true
  tasks:
    - name: SSH hardening options
      ansible.builtin.lineinfile:
        path: /etc/ssh/sshd_config
        regexp: '^#?{{ item.key }}\s'
        line: '{{ item.key }} {{ item.value }}'
        validate: '/usr/sbin/sshd -t -f %s'
      loop:
        - { key: PermitRootLogin, value: 'no' }
        - { key: PasswordAuthentication, value: 'no' }
        - { key: MaxAuthTries, value: '3' }
      notify: restart ssh
    - name: Firewall packages
      ansible.builtin.apt: { name: [ufw, unattended-upgrades, auditd], state: present, update_cache: true }
    - name: Allow SSH
      community.general.ufw: { rule: allow, port: '22', proto: tcp }
    - name: Enable firewall with default deny incoming
      community.general.ufw: { state: enabled, policy: deny, direction: incoming }
    - name: Audit sudoers changes
      ansible.builtin.copy:
        dest: /etc/audit/rules.d/sudoers.rules
        content: "-w /etc/sudoers -p wa -k sudoers_change\n-w /etc/sudoers.d/ -p wa -k sudoers_change\n"
      notify: reload audit
  handlers:
    - name: restart ssh
      ansible.builtin.service: { name: ssh, state: restarted }
    - name: reload audit
      ansible.builtin.command: augenrules --load

Lab: check, apply, verify

Create an inventory with two lab VMs reachable by SSH key. Run the playbook with --check --diff to preview, then apply it. Run it a second time and confirm every task reports ok rather than changed. Manually change PermitRootLogin on one host, rerun, and watch Ansible report and fix the drift. Make sure your SSH key login works before disabling password authentication.

Snippet
cat > inventory.ini <<'EOF'
[lab_linux]
192.168.56.10
192.168.56.11
EOF
ansible-galaxy collection install community.general
ansible-playbook -i inventory.ini baseline.yml --check --diff
ansible-playbook -i inventory.ini baseline.yml
ansible-playbook -i inventory.ini baseline.yml | tail -4   # changed=0 expected

Growing the baseline

Move tasks into roles, store the playbook in Git with code review, and run it on a schedule or from CI. Community roles based on CIS benchmarks exist, but read them before applying, because some settings can lock you out or break applications.

Diagram

Baseline lifecycle

  1. Describe

    Write the desired state as tasks in Git.

  2. Preview

    --check --diff shows what would change.

  3. Apply

    Run the playbook; handlers restart only when needed.

  4. Re-run

    changed=0 means no drift; changes reveal drift.

Check yourself

1. What does idempotent mean for an Ansible task?

2. Which flags preview changes without applying them?

3. Why use validate on the sshd_config task?