root@sudosecurity:~/hardline/controls $ cat controls .md
Hardline controls
Every Hardline hardening control by phase, from SSH and firewall to kernel and accounts: why it matters, what it checks, how to undo it, plus STIG IDs.
All 102 built-in controls by phase. Every finding in hardline audit links here, and hardline explain <id> prints the same text in your terminal.
Phase 1 · SSH
Key-only SSH login
Passwords can be guessed and are sprayed at every public SSH server all day. Keys can't. Hardline turns off PasswordAuthentication and KbdInteractiveAuthentication and caps login attempts at 3.
Before it does, it counts the keys in every login user's authorized_keys. A user without a key keeps password login through a Match User block, so nobody gets locked out. Add a key, then re-run apply.
- Key-only SSH: disable keyboard-interactive login
- Drop SSH connections that haven't logged in within 60 seconds
- Allow at most 3 SSH authentication attempts
- Key-only SSH: disable password login
SSH brute-force limits
Password-guessing bots open many connections and leave them waiting at the login prompt. MaxStartups 10:30:60 makes sshd start dropping 30% of new unauthenticated connections once 10 are pending, and refuse every new one at 60. PerSourceMaxStartups 10 (OpenSSH 8.5 and later) stops one address from taking all of those slots. Logged-in sessions never count, so a fresh login for hardline confirm still gets in. Together with MaxAuthTries 3, LoginGraceTime 60 and the firewall's per-source SSH rate limit from phase 2, a bot gets very few guesses per minute.
OpenSSH 9.8 and later (Debian 13, Ubuntu 26.04) also turn on PerSourcePenalties by default, which briefly blocks addresses that keep failing. Hardline doesn't install fail2ban: it isn't in the RHEL base repositories, and these limits cover the same attack without another daemon that can ban you.
- Start dropping unauthenticated SSH connections at 10, refuse all at 60
- One address can hold at most 10 unauthenticated SSH connections
No direct root login
Log in as yourself and use sudo, so every root action is tied to a person and goes through the audit log. Hardline sets PermitRootLogin no and checks that root's password is locked (passwd -S root).
- No direct SSH login as root
- Root password is locked
Empty passwords and user environments
PermitEmptyPasswords no stops an account with a blank password from logging in at all. PermitUserEnvironment no stops users from setting variables such as LD_PRELOAD through ~/.ssh/environment, which can get around restrictions on what they're allowed to run.
- Refuse SSH logins with empty passwords
- Ignore user-supplied SSH environment files
Idle session timeout
An SSH session left open on an unattended laptop is a root shell waiting for someone. ClientAliveInterval 600 and ClientAliveCountMax 1 drop a session after 10 minutes with no response.
- End unresponsive SSH sessions after one keepalive
- Drop idle SSH connections after 10 minutes
X11 forwarding
Servers don't need to show windows on your desktop. With X11 forwarding on, a compromised server can reach back into your local X session and log your keystrokes. Hardline sets X11Forwarding no.
- Disable SSH X11 forwarding
STIG ciphers, MACs and key exchange
stig profile only. Limits SSH to the FIPS 140-3 approved algorithms the Ubuntu 24.04 STIG requires. Very old clients might not be able to connect afterwards, so baseline keeps OpenSSH's modern defaults.
- SSH uses FIPS 140-3 approved ciphers
- SSH uses FIPS 140-3 approved key exchange
- SSH uses FIPS 140-3 approved MACs
Phase 2 · Firewall
Host firewall
Hardline turns on a default-deny inbound firewall, allows every port that's listening right now, and rate-limits SSH per source address. That way, enabling the firewall doesn't take down a running service.
--firewall ufw (the default) uses ufw. --firewall nftables writes a single table inet hardline and never flushes rules that belong to Docker, LXD or anything else. On RHEL, Rocky and AlmaLinux, Hardline installs firewalld if needed, opens SSH and every listening port in the default zone with firewall-offline-cmd, checks the result with --check-config, then starts firewalld. Firewall changes are guarded the same way SSH changes are.
- A host firewall is active
- SSH is rate-limited per source address
- ufw is enabled
- ufw is installed
- ufw rate-limits every listening port
Phase 3 · Access control
AppArmor
AppArmor limits what each profiled program can touch, even when it runs as root. Hardline makes sure it's installed, enabled and enforcing. A profile in complain mode only logs what it would block. Hardline reports those profiles but doesn't switch them to enforce, since that can break the program.
- AppArmor is enabled with policy loaded
- AppArmor is installed
- No AppArmor profiles in complain mode
SELinux
RHEL, Rocky Linux and AlmaLinux 9 only. SELinux confines every service to what its policy allows, even when it runs as root. Hardline checks it is Enforcing now and set to enforcing in /etc/selinux/config, so a reboot doesn't quietly turn it off. Hardline only reports this one: switching it on from disabled needs a full relabel and a reboot, and is best done in a maintenance window.
- SELinux is enforcing, now and at boot
Automatic security updates
Most servers that get broken into were running software with a fix already published. Hardline makes sure updates install by themselves. On Ubuntu and Debian it installs unattended-upgrades and writes /etc/apt/apt.conf.d/52hardline-auto-upgrades (APT::Periodic::Update-Package-Lists "1"; and APT::Periodic::Unattended-Upgrade "1";). It checks the merged result with apt-config dump, so a later file that turns them off gets caught. Ubuntu's default config installs security updates only.
On RHEL, Rocky and Alma it installs dnf-automatic and enables dnf-automatic-install.timer, which downloads and installs available updates once a day. Set upgrade_type = security in /etc/dnf/automatic.conf for security-only. Kernel updates still need a reboot; plan one, or use a livepatch service.
- dnf-automatic is installed
- Updates install automatically every day
- Security updates install automatically every day
- unattended-upgrades is installed
Clock synchronization
Logs from different servers only line up if the clocks agree. TLS certificates, TOTP codes and Kerberos also fail when a clock drifts. Hardline expects NTPSynchronized=yes from timedatectl show and a running NTP client (the kernel keeps reporting "synchronized" for a while after a client stops), whichever client it is (systemd-timesyncd on Ubuntu 24.04 and older and on Debian, chrony on Ubuntu 26.04 and RHEL). Phase 3 turns on chrony if it's installed, otherwise systemd-timesyncd (installing it first; minimal Debian images ship without any client). It never installs a second client, because two clients fight over the clock. If the clock still doesn't sync, outbound UDP port 123 is probably blocked.
- The clock is synchronized with NTP
Phase 4 · Auditing
Audit logging
auditd records who changed accounts, groups, sudoers and passwords, and every sudo command. Rules go in /etc/audit/rules.d/hardline.rules and are loaded with augenrules. Hardline normalises them the same way auditctl -l prints them, so the comparison is exact.
- auditd is enabled and running
- auditd is installed
- Audit every use of sudo
- Audit changes to /etc/group
- Audit changes to /etc/gshadow
- Audit changes to /etc/security/opasswd
- Audit changes to /etc/passwd
- Audit changes to /etc/shadow
- Audit changes to /etc/sudoers
- Audit changes to /etc/sudoers.d
Immutable audit rules
stig profile only. -e 2 locks the audit rules until the next reboot, so someone with root access can't quietly switch auditing off. That also applies to you: undoing this control needs a reboot, and Hardline warns before it applies it.
- Audit rules are immutable until reboot
File integrity (AIDE)
AIDE records a hash of every system binary and config file, then reports anything added, changed or removed since. It's how you find out a compromised server's sshd or sudo was swapped. Phase 4 installs the package and builds the baseline database from the files on disk at that moment (aideinit -y -f, or aide --init on RHEL), which takes a few minutes of disk reads. Build it again with the same command after you finish hardening and after planned upgrades, so the baseline matches a state you trust. Rollback deletes the database Hardline built.
Ubuntu and Debian run the check every day once the database exists (dailyaidecheck.timer). On RHEL, schedule aide --check yourself.
- AIDE has a baseline database to compare against
- AIDE file integrity checking is installed
Phase 5 · TLS
TLS on nginx and Apache
Hardline is read-only here. It finds the HTTPS listeners in your nginx or Apache config, checks that only TLS 1.2 and 1.3 are offered, and warns when a certificate expires within 21 days. Web server configs vary too much to edit safely, so each finding says what to change.
- TLS certificates are not about to expire
- Web servers offer only TLS 1.2 and 1.3
Phase 6 · Containers
Docker daemon
Hardline merges a few safe defaults into /etc/docker/daemon.json: no traffic between containers on the default bridge, no-new-privileges, live-restore, and capped json-file logs. Your other settings are left alone. It refuses to apply a setting that is also passed as a dockerd flag, because Docker won't start when a setting is in both. Docker isn't restarted for you. Restart it when it's convenient.
Containers running as root are reported but not changed. So is a Docker API exposed on tcp:// without tlsverify: anyone who can reach it is root on the host.
- daemon.json options aren't also passed as dockerd flags
- No traffic between containers on the default bridge
- Containers keep running across daemon restarts
- Container logs use the json-file driver
- At most 3 rotated log files per container
- Container log files are capped at 10 MB
- Containers cannot gain new privileges by default
- The Docker API isn't exposed over TCP without TLS
- Running containers don't run as root
Published container ports
Docker writes its own iptables rules, so -p 5432:5432 is open to the internet even with ufw set to deny everything. Hardline lists every port published on all addresses. Bind them to 127.0.0.1 (-p 127.0.0.1:5432:5432) and put a reverse proxy in front of anything that has to be public.
- No container ports published on all addresses
Phase 7 · Kernel
Kernel settings
A few sysctl settings that make common local attacks harder: full address space randomization, kernel pointers and the kernel log hidden from normal users, symlink and hardlink protection in shared directories, and SYN cookies against SYN floods. Unprivileged BPF is turned off (mode 2, which root can still change back without a reboot), and Yama limits ptrace to a process's own children, so one compromised service can't read another's memory. Most are already Ubuntu's defaults. Hardline checks the running value, so a stray file in /etc/sysctl.d that weakens one gets caught.
Fixes go in /etc/sysctl.d/99-zz-hardline.conf, which sorts after Ubuntu's own files so it wins at boot, and are loaded right away with sysctl -p. Rollback puts back the values that were running before.
- Only root can read the kernel log
- Kernel pointers are hidden from users
- Hardlink attacks on other users' files are blocked
- Symlink attacks in shared directories are blocked
- Full address space layout randomization
- Only privileged users can load BPF programs
- Processes can only trace their own children
Kernel lockdown and module signing
Lockdown stops even root from changing the running kernel: no unsigned modules, no writes to /dev/mem or /dev/port, no unsigned kexec and no hibernation. It's what keeps a root compromise from turning into a hidden kernel rootkit. Hardline reads the mode from /sys/kernel/security/lockdown and whether unsigned modules are refused from /sys/module/module/parameters/sig_enforce.
Both need a reboot and can stop unsigned drivers from loading, so Hardline only reports them. With Secure Boot on, Ubuntu and RHEL turn on integrity lockdown by themselves, and that covers module signing too.
- Kernel lockdown is on, so root can't modify the running kernel
- The kernel only loads signed modules
Kernel modules
Linux loads drivers for USB storage and a few rarely used network protocols (DCCP, RDS, TIPC) automatically when something asks for them. Old bugs in these protocols have let local users get root, and a server almost never needs them. Hardline writes install <module> /bin/false and blacklist <module> to /etc/modprobe.d/hardline.conf, so the kernel can't load them.
A module that is already loaded stays loaded until the next reboot. Rollback deletes the file.
- The DCCP network protocol can't be loaded
- The RDS network protocol can't be loaded
- The TIPC network protocol can't be loaded
- USB mass storage can't be loaded
Core dumps of setuid programs
A core dump is a copy of a crashed program's memory, which can contain password hashes, keys and session tokens. With fs.suid_dumpable = 0, programs that changed privileges (setuid binaries, daemons that dropped root) never dump, so a local user can't crash sudo or passwd to read root's memory. Ubuntu ships 2 for its crash reporter. Hardline sets 0 in /etc/sysctl.d/99-zz-hardline.conf.
- Setuid programs never write core dumps
/tmp and /dev/shm mount options
Every user can write to /tmp and /dev/shm, so they're where an attacker drops a payload. Mounting them nodev,nosuid,noexec stops device files, setuid programs and directly executed binaries there. Phase 7 adds tmpfs lines with those options to its own block at the end of /etc/fstab (between # BEGIN hardline and # END hardline) and remounts them. /dev/shm changes at once. When /tmp is still a directory on the root disk (most Ubuntu and Rocky servers), it becomes a tmpfs at the next boot, sized at half of RAM. If fstab already has your own line for either path, Hardline leaves it alone and reports what to change. rollback --phase 7 restores fstab and remounts with the old options.
noexec on /tmp breaks the few installers that run scripts from it; point them at another directory with TMPDIR=/var/tmp.
noexec doesn't stop sh /tmp/x.sh or python3 /tmp/x.py. It's one layer, not a sandbox.
- /dev/shm is mounted with nodev, nosuid and noexec
- /tmp is its own filesystem with nodev, nosuid and noexec
Phase 8 · Accounts
Accounts
Every account needs a password or a lock. An empty password field in /etc/shadow lets anyone log in at the console. Hardline lists those accounts and leaves the choice to you: sudo passwd <user> or sudo passwd -l <user>.
The stig profile also wants sudo to ask for a password and new accounts to be disabled 35 days after their password expires (INACTIVE=35 in /etc/default/useradd). Cloud images give the default user passwordless sudo, so Hardline only reports that one. Set a password for that user before you remove NOPASSWD, or you lose sudo.
- New accounts are disabled 35 days after their password expires
- No account has an empty password
- sudo asks for a password
Password rules
Hardline installs libpam-pwquality, which checks new passwords whenever someone runs passwd. SSH keys aren't affected.
The stig profile sets the DISA values: at least 15 characters, with an uppercase letter, a lowercase letter, a digit and a symbol, at least 8 characters different from the old password, no dictionary words, and a minimum age of 1 day and a maximum of 60 in /etc/login.defs. Hardline edits only those lines and leaves the rest of each file alone. The ages apply to accounts created afterwards. pwquality.conf comes with the package, so on a fresh server run apply twice: once to install it, and again to set the values.
- Passwords expire after 60 days
- Passwords can be changed at most once a day
- New passwords need a digit
- New passwords can't be dictionary words
- New passwords change at least 8 characters
- New passwords need a lowercase letter
- New passwords are at least 15 characters
- New passwords need a special character
- New passwords need an uppercase letter
- Password quality checks are installed
File permissions
Hardline walks the local disk filesystems once (ext4, xfs, btrfs, zfs, …; not container storage, snaps, /proc, /sys, /dev or /run, and it never follows symlinks) and looks for three things. World-writable files, and world-writable directories without the sticky bit, let any user change or swap them. Setuid and setgid programs outside /usr/bin, /usr/sbin, /usr/lib, /usr/libexec, /bin, /sbin and /lib run with their owner's rights for anyone, which is how many local backdoors work. Files whose user or group no longer exists pass to whoever is given that ID next.
All three are report-only: Hardline lists the first five paths and the total, and hardline explain gives the find command for the full list. The walk stops after 3,000,000 files, so a huge data disk can't stall an audit or watch.
- No setuid or setgid programs outside the system directories
- Every file belongs to an existing user and group
- No world-writable files or unprotected shared directories
Permissions on key system files
The account databases, sshd_config, sudoers, crontab and the GRUB config should only be writable by root, and the password hashes in /etc/shadow and /etc/gshadow should be unreadable to normal users. The modes follow the CIS Benchmarks (7.1.x, 5.1.1, 2.4.1.x, 1.4.2) and the DISA STIG: 0644 for passwd and group, 0640 root:shadow for shadow on Ubuntu/Debian (0000 root:root on RHEL), 0600 for sshd_config, crontab and grub.cfg, 0440 for sudoers.
apply --phase 8 --commit only removes permission bits, never adds them, and sets the owner and group. It never follows symlinks. hardline rollback --phase 8 restores each file's previous mode and owner from the snapshot.
- /etc/crontab is 0600, root:root
- /etc/group is 0644 or stricter, root:root
- /etc/group- backup is 0644 or stricter, root:root
- /etc/gshadow is 0640 or stricter, root:shadow
- /etc/gshadow has mode 0000, root:root
- /etc/passwd is 0644 or stricter, root:root
- /etc/passwd- backup is 0644 or stricter, root:root
- /etc/shadow is 0640 or stricter, root:shadow
- /etc/shadow- backup is 0640 or stricter, root:shadow
- /etc/shadow has mode 0000, root:root
- GRUB config is 0600, root:root
- GRUB config is 0600, root:root
- /etc/ssh/sshd_config is 0600, root:root
- /etc/sudoers is 0440 or stricter, root:root
Add a control
Each control is a TOML file plus a test, and needs either a STIG reference or a written reason it's baseline-only. Start with CONTRIBUTING.md.