This is the Ansible implementation of the Linux server hardening guide: same sections, same controls, but automated.
It targets Debian 11/12 and Ubuntu 22.04/24.04.
You can run the whole playbook or any subset with --tags.
Project layout
The one that I write here for you is a βall-in-oneβ layout: it includes the inventory, group_vars and everything the playbook needs under the same βlinux-hardeningβ directory.
linux-hardening/
βββ ansible.cfg
βββ inventory.yml
βββ group_vars/
β βββ hardened_servers.yml
βββ linux-hardening.yml
βββ templates/
βββ sshd-hardening.conf.j2
βββ sysctl-hardening.conf.j2
βββ audit-hardening.rules.j2
βββ fail2ban-jail.local.j2
βββ unattended-upgrades.conf.j2
βββ pam-faillock.j2
This of course is not the only way to do it: for example, if you followed my Ansible AWX and Gitlab guides, you will have your inventory and your playbooks divided in different GitLab groups, and this is a way better scalable separation for a real production environment.
The same playbook, in my GitLab / AWX layout
Playbook repo:
playbooks/linux-hardening(one repo per automation, everything at the repo root):playbooks/linux-hardening/ βββ linux-hardening.yml βββ collections/ β βββ requirements.yml # community.general, ansible.posix βββ templates/ βββ sshd-hardening.conf.j2 βββ sysctl-hardening.conf.j2 βββ audit-hardening.rules.j2 βββ fail2ban-jail.local.j2 βββ unattended-upgrades.conf.j2 βββ pam-faillock.j2Inventory repo:
inventories/prod(the hosts and the playbookβs variables live here):inventories/prod/ βββ openstack/ βββ openstack.yml # hosts, with a `hardened_servers` group βββ group_vars/ βββ hardened_servers.yml # the hardening variablesWhat moves, whatβs dropped, whatβs new compared to the all-in-one above:
All-in-one file In the GitLab layout linux-hardening.ymlβ playbook repo (root) templates/β playbook repo (root, next to the playbook) inventory.ymlβ‘οΈ becomes the inventory in the inventory repo group_vars/hardened_servers.ymlβ‘οΈ moves to the inventory repo (group_vars belong to the inventory) ansible.cfgβ not needed: AWX manages its own config (none) β new: collections/requirements.ymlat the repo root: AWX auto-installs the collections on sync (replaces the manualansible-galaxy collection install)
collections/requirements.yml(androles/requirements.ymlif the playbook uses external roles) must sit at the repo root: AWX installs Galaxy deps only from the root, never from subfolders.Then for wiring everything together (read-only credential, Projects, Inventory Source, Job Templateβ¦) you can check out Connect AWX to GitLab.
How to use it
Two ways to run it: straight from the command line (CLI), or via AWX pulled from Gitlab (check out both my guides if you want to implement this βreal productionβ approach).
Expand the one you need.
With Ansible CLI
One-time prerequisites on your control node
# Ansible itself + the collections used in the playbook sudo apt install -y ansible ansible-galaxy collection install community.general ansible-galaxy collection install ansible.posixDry-run first (always)
ansible-playbook linux-hardening.yml --check --diffReads what would change without applying. Always do this first on a new host, especially with PAM and SSH: verify the diff for
/etc/pam.d/su,/etc/securetty, and the sshd drop-in before committing.Apply the full playbook
ansible-playbook linux-hardening.ymlApply just one section (using tags)
# Only the firewall ansible-playbook linux-hardening.yml --tags firewall # Just sysctl + SSH + sudo ansible-playbook linux-hardening.yml --tags "sysctl,ssh,sudo" # Everything except auditd (rules can be noisy on first apply) ansible-playbook linux-hardening.yml --skip-tags auditdMake auditd immutable (only after the ruleset is stable)
# In group_vars/hardened_servers.yml: set auditd_immutable: true ansible-playbook linux-hardening.yml --tags auditdAfter this,
auditctlcan no longer modify rules until the next reboot. Donβt apply this on a new host youβre still tuning.Opt a host out of automatic security updates
# In group_vars/hardened_servers.yml (or host_vars/<host>.yml for one host): # unattended_upgrades_enabled: false ansible-playbook linux-hardening.yml --tags updatesThe playbook will stop and disable the timer on the next run. Set the flag back to
trueto re-enable.
With Ansible AWX
The playbook lives in GitLab under a path, e.g.
automation/playbooks/LinuxHardening/and is pulled by an AWX Project: see Connect AWX to GitLab for the wiring. The CLI flags that you see in the Ansible CLI example, are mapped directly to the fields of a Job Template.Set up the Job Template: Resources β Templates β Add β Job Template:
- Project:
PlaybooksΒ· Playbook:LinuxHardening/linux-hardening.yml- Inventory: one that contains the
hardened_serversgroup (this playbook targetshosts: hardened_servers, but you could have different groups of machines of course)- Credentials: the Machine credential for the targets
- Instance Groups:
execution-vms(run on the execution node)Dry-run first (always): set Job Type β Check and enable Show Changes: thatβs the
--check --diffabove. Launch once and read the diff before a real run!Apply the full playbook: set Job Type β Run β Launch.
Just one section (tags): use the Job Tags / Skip Tags fields (tick Prompt on launch to choose per-run):
- Job Tags
firewallβ only the firewall- Job Tags
sysctl,ssh,sudoβ those three- Skip Tags
auditdβ everything except auditdToggles like
auditd_immutable/unattended_upgrades_enabled: theyβre variables, set them in the inventoryβsgroup_varsfor thehardened_serversgroup, or pass them in the Job Templateβs Variables as extra vars (but I donβt recommend to do that):auditd_immutable: trueFor a nicer UI, you can consider to expose them as a Survey on the Job Template (a checkbox per option).
Same cautions as the CLI: dry-run on a new host first, keep
disable_password_auth: falseuntil key login works, and donβt setauditd_immutable: truewhile youβre still tuning.
ansible.cfg
[defaults]
inventory = inventory.yml
host_key_checking = False
retry_files_enabled = False
stdout_callback = yaml
deprecation_warnings = False
forks = 10
[privilege_escalation]
become = True
become_method = sudo
become_user = root
become_ask_pass = False
[ssh_connection]
pipelining = True
control_path = /tmp/ansible-ssh-p-%%rinventory.yml
---
all:
children:
hardened_servers:
hosts:
vps-01:
ansible_host: 10.0.0.10
ansible_user: admin
vps-02:
ansible_host: 10.0.0.11
ansible_user: admingroup_vars/hardened_servers.yml
All the policy values centralised here.
Adapt to your site policy before running.
---
# ββ SSH ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
ssh_port: 22
# IMPORTANT: leave true until every user has a working public key.
# Switch to false once you've verified key-based login works for everyone.
ssh_password_auth: true
# ββ Firewall (UFW) βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# SSH is added automatically (rate-limited) β add other ports your server serves.
ufw_extra_allow:
# - { port: 80, proto: tcp, rule: allow }
# - { port: 443, proto: tcp, rule: allow }
# Optional: restrict SSH to a management subnet (defense in depth on top of fail2ban).
# Leave empty to allow SSH from anywhere.
ssh_management_cidrs: []
# - 10.0.0.0/24
# ββ Password policy (pam_pwquality) ββββββββββββββββββββββββββββββββββββββββ
password_min_length: 14
password_max_days: 365
password_min_days: 1
password_warn_age: 7
password_history: 5
password_enforce_for_root: true
# ββ Account lockout (pam_faillock) βββββββββββββββββββββββββββββββββββββββββ
faillock_deny: 5
faillock_unlock_time: 900 # 15 min
faillock_fail_interval: 900
# ββ Shell environment ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
shell_tmout_seconds: 900 # 15 min; raise to 3600 if too aggressive
system_umask: "027"
# ββ Sudo βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
sudo_timestamp_timeout: 5 # 0 = always prompt; CIS L1 strict
# ββ Auditd βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Set true ONLY after the ruleset is stable. Once true, the rules become
# immutable until reboot β you cannot change them without a reboot.
auditd_immutable: false
# ββ Fail2ban βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
fail2ban_bantime: 1h
fail2ban_findtime: 10m
fail2ban_maxretry: 5
fail2ban_ignoreip:
- 127.0.0.1/8
- ::1
# - 10.0.0.0/24
# ββ Unattended-upgrades ββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Master switch: set to false to skip auto-updates entirely (e.g. on hosts
# managed by a separate patching pipeline). When false, the playbook also
# disables the existing timer if it was previously enabled.
unattended_upgrades_enabled: true
unattended_auto_reboot: false # NEVER true in production
unattended_mail: "root"
# ββ Time sync ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
ntp_pool: 2.debian.pool.ntp.org
# ββ Banner βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
login_banner: |
WARNING: Authorized uses only. All activity may be monitored and reported.
Unauthorized access is prohibited and will be prosecuted to the full extent of the law.linux-hardening.yml
The main playbook.
Organised section by section, mirroring the hardening guide.
---
- name: Linux server hardening (Debian/Ubuntu) β based on CIS L1 + extras
hosts: hardened_servers
become: true
gather_facts: true
pre_tasks:
- name: Sanity check β only Debian/Ubuntu supported
ansible.builtin.assert:
that:
- ansible_facts.os_family == "Debian"
fail_msg: "This playbook only supports Debian/Ubuntu."
tags: always
- name: Update apt cache
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
tags: always
tasks:
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Β§2 β Packages and services
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- name: Β§2.1 β Purge unnecessary packages
ansible.builtin.apt:
name:
- telnet
- telnetd
- rsh-client
- rsh-redone-client
- talk
- nis
- ldap-utils
- xinetd
state: absent
purge: true
autoremove: true
tags: [packages]
- name: Β§2.2 β Install hardening tooling
ansible.builtin.apt:
name:
- rsyslog
- chrony
- auditd
- audispd-plugins
- unattended-upgrades
- apt-listchanges
- fail2ban
- libpam-pwquality
- libpam-modules
- ufw
- apparmor
- apparmor-utils
- apparmor-profiles
- apparmor-profiles-extra
state: present
tags: [packages]
- name: Β§2.3 β Disable services not needed on a server
ansible.builtin.systemd:
name: "{{ item }}"
enabled: false
state: stopped
loop:
- postfix
- bluetooth
failed_when: false # Skip cleanly if not installed
tags: [packages]
- name: Β§2.4 β Time sync via chrony
ansible.builtin.copy:
dest: /etc/chrony/chrony.conf
owner: root
group: root
mode: "0644"
content: |
pool {{ ntp_pool }} iburst
driftfile /var/lib/chrony/chrony.drift
makestep 1.0 3
rtcsync
notify: restart chrony
tags: [packages]
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Β§3 β Kernel modules and filesystem types
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- name: Β§3.1 β Disable unused filesystem modules
ansible.builtin.copy:
dest: /etc/modprobe.d/cis-filesystem.conf
owner: root
group: root
mode: "0644"
content: |
install cramfs /bin/true
install freevxfs /bin/true
install jffs2 /bin/true
install hfs /bin/true
install hfsplus /bin/true
install udf /bin/true
tags: [kernel-modules]
- name: Β§3.1 β Unload modules already in memory
community.general.modprobe:
name: "{{ item }}"
state: absent
loop:
- cramfs
- freevxfs
- jffs2
- hfs
- hfsplus
- udf
failed_when: false
tags: [kernel-modules]
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Β§4 β Kernel and network parameters (sysctl)
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- name: Β§4 β Deploy sysctl hardening
ansible.builtin.template:
src: sysctl-hardening.conf.j2
dest: /etc/sysctl.d/20-hardening.conf
owner: root
group: root
mode: "0644"
notify: reload sysctl
tags: [sysctl]
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Β§5 β Firewall (UFW)
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# CRITICAL ORDER: allow SSH BEFORE switching to default deny, or you
# will lock yourself out. Ansible handles this in a single play but
# the task order below still matters.
- name: Β§5 β UFW default policies
community.general.ufw:
direction: "{{ item.direction }}"
policy: "{{ item.policy }}"
loop:
- { direction: incoming, policy: deny }
- { direction: outgoing, policy: allow }
tags: [firewall]
- name: Β§5 β UFW allow SSH (rate-limited)
community.general.ufw:
rule: limit
port: "{{ ssh_port }}"
proto: tcp
tags: [firewall]
- name: Β§5 β UFW restrict SSH to management CIDRs (if any)
community.general.ufw:
rule: allow
from_ip: "{{ item }}"
to_port: "{{ ssh_port }}"
proto: tcp
loop: "{{ ssh_management_cidrs }}"
when: ssh_management_cidrs | length > 0
tags: [firewall]
- name: Β§5 β UFW extra application ports
community.general.ufw:
rule: "{{ item.rule }}"
port: "{{ item.port }}"
proto: "{{ item.proto }}"
loop: "{{ ufw_extra_allow }}"
tags: [firewall]
- name: Β§5 β Enable UFW + logging
community.general.ufw:
state: enabled
logging: "on"
tags: [firewall]
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Β§6 β Filesystem mount options
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- name: Β§6.1 β /dev/shm with noexec,nodev,nosuid
ansible.posix.mount:
path: /dev/shm
src: tmpfs
fstype: tmpfs
opts: defaults,noexec,nodev,nosuid
state: mounted
tags: [mount]
# NOTE: /tmp and /var/tmp mount options are intentionally NOT applied
# automatically β `noexec /tmp` breaks too many edge cases (snap, some
# apt postinst scripts, Java tmpdirs). Apply manually after testing.
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Β§7 β SSH server hardening
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- name: Β§7 β Deploy sshd hardening drop-in
ansible.builtin.template:
src: sshd-hardening.conf.j2
dest: /etc/ssh/sshd_config.d/99-hardening.conf
owner: root
group: root
mode: "0600"
validate: "/usr/sbin/sshd -t -f %s" # blocks lockout from typos
notify: restart ssh
tags: [ssh]
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Β§8 β PAM, password, and account policy
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- name: Β§8.1 β pam_pwquality config
ansible.builtin.copy:
dest: /etc/security/pwquality.conf
owner: root
group: root
mode: "0644"
content: |
minlen = {{ password_min_length }}
minclass = 4
dcredit = -1
ucredit = -1
ocredit = -1
lcredit = -1
retry = 3
{% if password_enforce_for_root %}
enforce_for_root
{% endif %}
tags: [pam]
- name: Β§8.1 β Enable pwquality via pam-auth-update
ansible.builtin.command: pam-auth-update --enable pwquality --force
changed_when: false
tags: [pam]
- name: Β§8.2 β pam_faillock config
ansible.builtin.copy:
dest: /etc/security/faillock.conf
owner: root
group: root
mode: "0644"
content: |
deny = {{ faillock_deny }}
unlock_time = {{ faillock_unlock_time }}
fail_interval = {{ faillock_fail_interval }}
tags: [pam]
- name: Β§8.2 β Deploy faillock pam-configs profile
ansible.builtin.template:
src: pam-faillock.j2
dest: /usr/share/pam-configs/faillock
owner: root
group: root
mode: "0644"
tags: [pam]
- name: Β§8.2 β Enable faillock via pam-auth-update
ansible.builtin.command: pam-auth-update --enable faillock --force
changed_when: false
tags: [pam]
- name: Β§8.3 / Β§8.5 β Password hashing + aging in login.defs
ansible.builtin.lineinfile:
path: /etc/login.defs
regexp: "^#?\\s*{{ item.key }}\\b"
line: "{{ item.key }} {{ item.value }}"
state: present
loop:
- { key: ENCRYPT_METHOD, value: SHA512 }
- { key: SHA_CRYPT_MIN_ROUNDS, value: "65536" }
- { key: SHA_CRYPT_MAX_ROUNDS, value: "65536" }
- { key: PASS_MAX_DAYS, value: "{{ password_max_days }}" }
- { key: PASS_MIN_DAYS, value: "{{ password_min_days }}" }
- { key: PASS_WARN_AGE, value: "{{ password_warn_age }}" }
- { key: UMASK, value: "{{ system_umask }}" }
tags: [pam]
- name: Β§8.6 β Create empty wheel group for su restriction
ansible.builtin.group:
name: wheel
state: present
tags: [pam]
- name: Β§8.6 β Restrict su to wheel via pam_wheel
ansible.builtin.lineinfile:
path: /etc/pam.d/su
regexp: "^#?\\s*auth\\s+required\\s+pam_wheel\\.so"
line: "auth required pam_wheel.so use_uid group=wheel"
state: present
tags: [pam]
- name: Β§8.7 β Empty /etc/securetty (forbid root tty login)
ansible.builtin.copy:
dest: /etc/securetty
owner: root
group: root
mode: "0600"
content: ""
tags: [pam]
- name: Β§8.8 β Belt-and-braces interactive umask
ansible.builtin.copy:
dest: /etc/profile.d/set_umask.sh
owner: root
group: root
mode: "0644"
content: |
umask {{ system_umask }}
tags: [pam, banners]
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Β§9 β Sudo hardening
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- name: Β§9 β Deploy sudo hardening (validated with visudo)
ansible.builtin.copy:
dest: /etc/sudoers.d/00-hardening
owner: root
group: root
mode: "0440"
validate: "visudo -cf %s"
content: |
Defaults use_pty
Defaults logfile="/var/log/sudo.log"
Defaults timestamp_timeout={{ sudo_timestamp_timeout }}
Defaults !visiblepw
Defaults env_reset
Defaults requiretty
tags: [sudo]
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Β§10 β Auditing (auditd)
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- name: Β§10 β Deploy audit rules
ansible.builtin.template:
src: audit-hardening.rules.j2
dest: /etc/audit/rules.d/hardening.rules
owner: root
group: root
mode: "0640"
notify: reload audit rules
tags: [auditd]
- name: Β§10 β Enable + start auditd
ansible.builtin.systemd:
name: auditd
enabled: true
state: started
tags: [auditd]
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Β§11 β Logging
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- name: Β§11.1 β rsyslog file mode
ansible.builtin.lineinfile:
path: /etc/rsyslog.conf
regexp: "^\\$FileCreateMode"
line: "$FileCreateMode 0640"
state: present
notify: restart rsyslog
tags: [logging]
- name: Β§11.2 β Tighten permissions on existing logs
ansible.builtin.shell: |
find /var/log -type f -exec chmod g-wx,o-rwx {} +
find /var/log -type d -exec chmod g-wx,o-rwx {} +
changed_when: false
tags: [logging]
- name: Β§11.5 β Cron config files mode
ansible.builtin.file:
path: "{{ item }}"
owner: root
group: root
mode: "0700"
loop:
- /etc/cron.hourly
- /etc/cron.daily
- /etc/cron.weekly
- /etc/cron.monthly
- /etc/cron.d
tags: [logging]
- name: Β§11.5 β /etc/crontab mode
ansible.builtin.file:
path: /etc/crontab
owner: root
group: root
mode: "0600"
tags: [logging]
- name: Β§11.5 β Create cron.allow / at.allow, remove cron.deny / at.deny
block:
- ansible.builtin.file:
path: "{{ item }}"
owner: root
group: root
mode: "0600"
state: touch
loop:
- /etc/cron.allow
- /etc/at.allow
changed_when: false
- ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- /etc/cron.deny
- /etc/at.deny
tags: [logging]
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Β§12 β Fail2ban
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- name: Β§12 β Deploy fail2ban jail.local
ansible.builtin.template:
src: fail2ban-jail.local.j2
dest: /etc/fail2ban/jail.local
owner: root
group: root
mode: "0644"
notify: restart fail2ban
tags: [fail2ban]
- name: Β§12 β Enable + start fail2ban
ansible.builtin.systemd:
name: fail2ban
enabled: true
state: started
tags: [fail2ban]
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Β§13 β Automatic security updates (master switch: unattended_upgrades_enabled)
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- name: Β§13 β Configure and enable unattended-upgrades
when: unattended_upgrades_enabled | bool
tags: [updates]
block:
- name: Β§13 β Deploy unattended-upgrades config
ansible.builtin.template:
src: unattended-upgrades.conf.j2
dest: /etc/apt/apt.conf.d/50unattended-upgrades
owner: root
group: root
mode: "0644"
- name: Β§13 β Enable apt periodic config
ansible.builtin.copy:
dest: /etc/apt/apt.conf.d/20auto-upgrades
owner: root
group: root
mode: "0644"
content: |
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
- name: Β§13 β Ensure unattended-upgrades timer is active
ansible.builtin.systemd:
name: unattended-upgrades
enabled: true
state: started
- name: Β§13 β Disable unattended-upgrades if explicitly opted out
when: not (unattended_upgrades_enabled | bool)
tags: [updates]
ansible.builtin.systemd:
name: unattended-upgrades
enabled: false
state: stopped
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Β§14 β Banners, MOTD, shell environment
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- name: Β§14.1 β Login banner /etc/issue + /etc/issue.net
ansible.builtin.copy:
dest: "{{ item }}"
owner: root
group: root
mode: "0644"
content: "{{ login_banner }}"
loop:
- /etc/issue
- /etc/issue.net
tags: [banners]
- name: Β§14.2 β Disable Ubuntu MOTD news
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- /etc/default/motd-news
tags: [banners]
- name: Β§14.2 β Strip exec bit from motd-news fetcher
ansible.builtin.file:
path: /etc/update-motd.d/50-motd-news
mode: "0644"
failed_when: false
tags: [banners]
- name: Β§14.3 β Deploy TMOUT (auto-logout, readonly)
ansible.builtin.copy:
dest: /etc/profile.d/tmout.sh
owner: root
group: root
mode: "0644"
content: |
# Auto-logout after {{ shell_tmout_seconds }} seconds of inactivity
declare -xr TMOUT={{ shell_tmout_seconds }}
tags: [banners]
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Β§15 β AppArmor
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- name: Β§15 β Ensure AppArmor is enabled + started
ansible.builtin.systemd:
name: apparmor
enabled: true
state: started
tags: [apparmor]
- name: Β§15 β Enforce critical profiles
ansible.builtin.command: aa-enforce {{ item }}
loop:
- /etc/apparmor.d/usr.sbin.sshd
- /etc/apparmor.d/usr.sbin.rsyslogd
changed_when: false
failed_when: false
tags: [apparmor]
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Β§10 β Make auditd ruleset immutable (ONLY if explicitly requested)
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- name: Β§10 β Append -e 2 to make audit rules immutable (LAST!)
ansible.builtin.lineinfile:
path: /etc/audit/rules.d/hardening.rules
line: "-e 2"
state: present
when: auditd_immutable | bool
notify: reload audit rules
tags: [auditd, immutable]
handlers:
- name: restart ssh
ansible.builtin.systemd:
name: ssh
state: restarted
- name: restart chrony
ansible.builtin.systemd:
name: chrony
state: restarted
- name: restart rsyslog
ansible.builtin.systemd:
name: rsyslog
state: restarted
- name: restart fail2ban
ansible.builtin.systemd:
name: fail2ban
state: restarted
- name: reload sysctl
ansible.builtin.command: sysctl --system
changed_when: false
- name: reload audit rules
ansible.builtin.command: augenrules --load
changed_when: falsetemplates/sshd-hardening.conf.j2
# Managed by Ansible β do not edit by hand
# Drop-in loaded by /etc/ssh/sshd_config
# Logging
LogLevel VERBOSE
# Authentication
PermitRootLogin no
PermitUserEnvironment no
PermitEmptyPasswords no
HostbasedAuthentication no
IgnoreRhosts yes
MaxAuthTries 4
LoginGraceTime 60
# PAM (account/session)
UsePAM yes
PubkeyAuthentication yes
PasswordAuthentication {{ "yes" if ssh_password_auth else "no" }}
KbdInteractiveAuthentication no
# Sessions
ClientAliveInterval 300
ClientAliveCountMax 3
MaxSessions 10
MaxStartups 10:30:60
# Banner
Banner /etc/issue.net
# Strong algorithms only
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,hmac-sha2-512,hmac-sha2-256
KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512,ecdh-sha2-nistp521,ecdh-sha2-nistp384,ecdh-sha2-nistp256,diffie-hellman-group-exchange-sha256templates/sysctl-hardening.conf.j2
# Managed by Ansible β see linux-server-hardening guide Β§4
# βββ Network: routing & redirects βββββββββββββββββββββββββ
net.ipv4.ip_forward = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv4.conf.all.secure_redirects = 0
net.ipv4.conf.default.secure_redirects = 0
# βββ Network: source routing ββββββββββββββββββββββββββββββ
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.default.accept_source_route = 0
# βββ Network: spoofing & anti-DoS βββββββββββββββββββββββββ
net.ipv4.icmp_echo_ignore_broadcasts = 1
net.ipv4.icmp_ignore_bogus_error_responses = 1
net.ipv4.tcp_syncookies = 1
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
net.ipv4.conf.all.log_martians = 1
net.ipv4.conf.default.log_martians = 1
# βββ IPv6 hardening βββββββββββββββββββββββββββββββββββββββ
net.ipv6.conf.all.accept_redirects = 0
net.ipv6.conf.default.accept_redirects = 0
net.ipv6.conf.all.accept_source_route = 0
net.ipv6.conf.default.accept_source_route = 0
net.ipv6.conf.all.forwarding = 0
# βββ Kernel: exploit mitigations & info leaks βββββββββββββ
kernel.randomize_va_space = 2
fs.suid_dumpable = 0
kernel.dmesg_restrict = 1
kernel.kptr_restrict = 2
kernel.perf_event_paranoid = 3
kernel.yama.ptrace_scope = 1
# βββ Filesystem: link & FIFO protections ββββββββββββββββββ
fs.protected_hardlinks = 1
fs.protected_symlinks = 1
fs.protected_fifos = 2
fs.protected_regular = 2templates/audit-hardening.rules.j2
# Managed by Ansible β see linux-server-hardening guide Β§10
# Order matters: -e 2 (set by playbook when auditd_immutable=true) MUST be last.
# βββ Authentication & account changes βββββββββββββββββββ
-w /etc/passwd -p wa -k identity
-w /etc/group -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/gshadow -p wa -k identity
-w /etc/sudoers -p wa -k scope
-w /etc/sudoers.d/ -p wa -k scope
# βββ Login records ββββββββββββββββββββββββββββββββββββββ
-w /var/log/lastlog -p wa -k logins
-w /var/log/faillog -p wa -k logins
-w /var/run/utmp -p wa -k session
-w /var/log/wtmp -p wa -k session
-w /var/log/btmp -p wa -k session
# βββ Privilege escalation βββββββββββββββββββββββββββββββ
-w /usr/bin/sudo -p x -k privileged
-w /bin/su -p x -k privileged
# βββ Kernel module loading ββββββββββββββββββββββββββββββ
-w /sbin/insmod -p x -k modules
-w /sbin/rmmod -p x -k modules
-w /sbin/modprobe -p x -k modules
-a always,exit -F arch=b64 -S init_module,delete_module -k modules
# βββ Time changes βββββββββββββββββββββββββββββββββββββββ
-a always,exit -F arch=b64 -S adjtimex,settimeofday -k time-change
-a always,exit -F arch=b64 -S clock_settime -k time-change
-w /etc/localtime -p wa -k time-change
# βββ DAC permission changes βββββββββββββββββββββββββββββ
-a always,exit -F arch=b64 -S chmod,fchmod,fchmodat -F auid>=1000 -F auid!=4294967295 -k perm_mod
-a always,exit -F arch=b64 -S chown,fchown,fchownat,lchown -F auid>=1000 -F auid!=4294967295 -k perm_mod
-a always,exit -F arch=b64 -S setxattr,lsetxattr,fsetxattr,removexattr -F auid>=1000 -F auid!=4294967295 -k perm_mod
# βββ Unauthorized access attempts βββββββββββββββββββββββ
-a always,exit -F arch=b64 -S open,openat,creat,truncate,ftruncate -F exit=-EACCES -F auid>=1000 -F auid!=4294967295 -k access
-a always,exit -F arch=b64 -S open,openat,creat,truncate,ftruncate -F exit=-EPERM -F auid>=1000 -F auid!=4294967295 -k access
# βββ Mount events βββββββββββββββββββββββββββββββββββββββ
-a always,exit -F arch=b64 -S mount -F auid>=1000 -F auid!=4294967295 -k mounts
# βββ Network env ββββββββββββββββββββββββββββββββββββββββ
-w /etc/hosts -p wa -k system-locale
-w /etc/network/ -p wa -k system-locale
-w /etc/netplan/ -p wa -k system-localetemplates/fail2ban-jail.local.j2
# Managed by Ansible β see linux-server-hardening guide Β§12
[DEFAULT]
bantime = {{ fail2ban_bantime }}
findtime = {{ fail2ban_findtime }}
maxretry = {{ fail2ban_maxretry }}
backend = systemd
ignoreip = {{ fail2ban_ignoreip | join(' ') }}
[sshd]
enabled = truetemplates/unattended-upgrades.conf.j2
// Managed by Ansible β see linux-server-hardening guide Β§13
Unattended-Upgrade::Allowed-Origins {
"${distro_id}:${distro_codename}-security";
"${distro_id}ESMApps:${distro_codename}-apps-security";
"${distro_id}ESM:${distro_codename}-infra-security";
};
Unattended-Upgrade::Automatic-Reboot "{{ 'true' if unattended_auto_reboot else 'false' }}";
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::MinimalSteps "true";
Unattended-Upgrade::Mail "{{ unattended_mail }}";
Unattended-Upgrade::MailReport "on-change";templates/pam-faillock.j2
Name: Account lockout via pam_faillock
Default: yes
Priority: 0
Auth-Type: Primary
Auth:
[default=die] pam_faillock.so authfail
Auth-Initial:
required pam_faillock.so preauth
Account-Type: Primary
Account:
required pam_faillock.soSafety considerations
CAUTION
First run on a production host: open a second SSH session before applying, just in case. The sshd drop-in is validated with
sshd -tbefore being installed (so a typo canβt lock you out), but always have an out-of-band path (console, BMC, second open shell) just in case.
IMPORTANT
ssh_password_authdefaults totruefor safety. Switch it tofalseingroup_vars/only after every user on every host has a working public key: otherwise the next run locks out everyone without keys.
INFO
The
wheelgroup is intentionally empty after the playbook runs:subecomes unusable until you add a user. This is deliberate (forcing everyone throughsudo). If you actually want one user to retainsuaccess, add them manually withusermod -aG wheel <user>after the run.
What to do next
- Apply the playbook to a fresh VM, then run
lynis audit systemandoscap evalto measure your CIS L1 coverage. - Add an Ansible CI job (e.g. GitHub Actions or Jenkins) that re-runs the playbook in
--checkmode weekly: anychangedreport = configuration drift on a host, investigate and re-apply.