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 loginhighssh-kbd-interactive-authenticationbaseline + stigauto fix
  • Drop SSH connections that haven't logged in within 60 secondslowssh-login-grace-timebaseline + cisCIS 5.1.13auto fix
  • Allow at most 3 SSH authentication attemptslowssh-max-auth-triesbaseline + cisCIS 5.1.16auto fix
  • Key-only SSH: disable password loginhighssh-password-authenticationbaseline + stigauto fix

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 60lowssh-max-startupsbaseline + cisCIS 5.1.18auto fix
  • One address can hold at most 10 unauthenticated SSH connectionslowssh-per-source-max-startupsbaselineauto fix

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 roothighssh-permit-root-loginbaseline + stig + cisCIS 5.1.20auto fix
  • Root password is lockedmediumssh-root-password-lockedbaseline + stigUBTU-24-400110manual fix

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 passwordshighssh-permit-empty-passwordsbaseline + stig + cisUBTU-24-300031CIS 5.1.19auto fix
  • Ignore user-supplied SSH environment fileshighssh-permit-user-environmentbaseline + stig + cisUBTU-24-300031CIS 5.1.21auto fix

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 keepalivemediumssh-client-alive-count-maxbaseline + stigUBTU-24-600000auto fix
  • Drop idle SSH connections after 10 minutesmediumssh-client-alive-intervalbaseline + stigUBTU-24-600010auto fix

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 forwardinghighssh-x11-forwardingbaseline + stigUBTU-24-300022auto fix

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 ciphersmediumssh-stig-ciphersstigUBTU-24-100820auto fix
  • SSH uses FIPS 140-3 approved key exchangemediumssh-stig-kexstigUBTU-24-100840auto fix
  • SSH uses FIPS 140-3 approved MACsmediumssh-stig-macsstigUBTU-24-100830auto fix

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 activehighfw-activebaselineauto fix
  • SSH is rate-limited per source addressmediumfw-ssh-rate-limitedbaselineauto fixUbuntu/Debian only
  • ufw is enabledmediumfw-ufw-enabledstig + cisUBTU-24-100310CIS 4.2.3auto fixUbuntu/Debian only
  • ufw is installedmediumfw-ufw-installedstig + cisUBTU-24-100300CIS 4.2.1auto fixUbuntu/Debian only
  • ufw rate-limits every listening portmediumfw-ufw-limits-listenersstig + cisUBTU-24-600200CIS 4.2.6auto fixUbuntu/Debian only

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 loadedmediumapparmor-enabledbaseline + stigUBTU-24-100510manual fixUbuntu/Debian only
  • AppArmor is installedmediumapparmor-installedbaseline + stig + cisUBTU-24-100500CIS 1.3.1.1auto fixUbuntu/Debian only
  • No AppArmor profiles in complain modelowapparmor-no-complainbaseline + stig + cisCIS 1.3.1.3manual fixUbuntu/Debian only

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 boothighselinux-enforcingbaselinemanual fixRHEL family only

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 installedmediumupdates-dnf-automatic-installedbaselineauto fixRHEL family only
  • Updates install automatically every daymediumupdates-dnf-automatic-timerbaselineauto fixRHEL family only
  • Security updates install automatically every daymediumupdates-unattended-enabledbaselineauto fixUbuntu/Debian only
  • unattended-upgrades is installedmediumupdates-unattended-installedbaselineauto fixUbuntu/Debian only

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 NTPmediumtime-syncedbaseline + cisCIS 2.3.2.2, 2.3.3.3auto fix

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 runningmediumaudit-auditd-activebaseline + stigUBTU-24-100410auto fix
  • auditd is installedmediumaudit-auditd-installedbaseline + stigUBTU-24-100400auto fix
  • Audit every use of sudomediumaudit-sudo-execbaseline + stigUBTU-24-900170auto fix
  • Audit changes to /etc/groupmediumaudit-watch-groupbaseline + stigUBTU-24-200290auto fix
  • Audit changes to /etc/gshadowmediumaudit-watch-gshadowbaseline + stigUBTU-24-200310auto fix
  • Audit changes to /etc/security/opasswdmediumaudit-watch-opasswdbaseline + stigUBTU-24-200320auto fix
  • Audit changes to /etc/passwdmediumaudit-watch-passwdbaseline + stigUBTU-24-200280auto fix
  • Audit changes to /etc/shadowmediumaudit-watch-shadowbaseline + stigUBTU-24-200300auto fix
  • Audit changes to /etc/sudoersmediumaudit-watch-sudoersbaseline + stigUBTU-24-900510auto fix
  • Audit changes to /etc/sudoers.dmediumaudit-watch-sudoers-dbaseline + stigUBTU-24-900520auto fix

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 rebootlowaudit-immutablestigUBTU-24-909000auto fix

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 againstmediumaide-databasebaseline + cisCIS 6.3.1auto fix
  • AIDE file integrity checking is installedmediumaide-installedbaseline + cisCIS 6.3.1auto fix

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 expirehightls-cert-expirybaseline + stigmanual fix
  • Web servers offer only TLS 1.2 and 1.3mediumtls-protocolsbaseline + stigmanual fix

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 flagshighdocker-flag-conflictsbaseline + stigmanual fix
  • No traffic between containers on the default bridgemediumdocker-iccbaseline + stigauto fix
  • Containers keep running across daemon restartslowdocker-live-restorebaseline + stigauto fix
  • Container logs use the json-file driverlowdocker-log-driverbaseline + stigauto fix
  • At most 3 rotated log files per containerlowdocker-log-max-filebaseline + stigauto fix
  • Container log files are capped at 10 MBlowdocker-log-max-sizebaseline + stigauto fix
  • Containers cannot gain new privileges by defaultmediumdocker-no-new-privilegesbaseline + stigauto fix
  • The Docker API isn't exposed over TCP without TLShighdocker-no-tcp-socketbaseline + stigmanual fix
  • Running containers don't run as rootlowdocker-root-containersbaseline + stigmanual fix

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 addressesmediumdocker-published-portsbaseline + stigmanual fix

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 loglowkernel-dmesg-restrictbaseline + stigUBTU-24-600140auto fix
  • Kernel pointers are hidden from userslowkernel-kptr-restrictbaselineauto fix
  • Full address space layout randomizationmediumkernel-randomize-va-spacebaseline + stig + cisUBTU-24-700310CIS 1.5.1auto fix
  • TCP SYN cookies are onmediumkernel-tcp-syncookiesbaseline + stig + cisUBTU-24-600190CIS 3.3.10auto fix
  • Only privileged users can load BPF programsmediumkernel-unprivileged-bpf-disabledbaselineauto fix
  • Processes can only trace their own childrenlowkernel-yama-ptrace-scopebaseline + cisCIS 1.5.2auto fix

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 kernellowkernel-lockdownbaselinemanual fix
  • The kernel only loads signed moduleslowkernel-module-sig-enforcebaselinemanual fix

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 loadedlowkernel-dccpbaselineauto fix
  • The RDS network protocol can't be loadedlowkernel-rdsbaselineauto fix
  • The TIPC network protocol can't be loadedlowkernel-tipcbaselineauto fix
  • USB mass storage can't be loadedmediumkernel-usb-storagebaseline + stig + cisUBTU-24-300039CIS 1.1.1.9auto fix

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 dumpslowkernel-suid-dumpablebaseline + cisCIS 1.5.3auto fix

/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 noexeclowmounts-dev-shmbaseline + cisCIS 1.1.2.2.1, 1.1.2.2.2, 1.1.2.2.3, 1.1.2.2.4auto fix
  • /tmp is its own filesystem with nodev, nosuid and noexeclowmounts-tmpbaseline + cisCIS 1.1.2.1.1, 1.1.2.1.2, 1.1.2.1.3, 1.1.2.1.4auto fix

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 expiresmediumaccounts-inactivestig + cisUBTU-24-200260CIS 5.4.1.5auto fix
  • No account has an empty passwordhighaccounts-no-empty-passwordsbaseline + stig + cisUBTU-24-300027CIS 7.2.2manual fix
  • sudo asks for a passwordmediumaccounts-sudo-passwordstigUBTU-24-300020manual fix

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 daysmediumaccounts-pass-max-daysstig + cisUBTU-24-400310CIS 5.4.1.1auto fix
  • Passwords can be changed at most once a daymediumaccounts-pass-min-daysstigUBTU-24-400300auto fix
  • New passwords need a digitmediumaccounts-pw-dcreditstigUBTU-24-400280auto fix
  • New passwords can't be dictionary wordsmediumaccounts-pw-dictcheckstig + cisUBTU-24-300014CIS 5.3.3.2.6auto fix
  • New passwords change at least 8 charactersmediumaccounts-pw-difokstig + cisUBTU-24-400290CIS 5.3.3.2.1auto fix
  • New passwords need a lowercase lettermediumaccounts-pw-lcreditstigUBTU-24-400270auto fix
  • New passwords are at least 15 charactersmediumaccounts-pw-minlenstig + cisUBTU-24-400320CIS 5.3.3.2.2auto fix
  • New passwords need a special charactermediumaccounts-pw-ocreditstigUBTU-24-400330auto fix
  • New passwords need an uppercase lettermediumaccounts-pw-ucreditstigUBTU-24-400260auto fix
  • Password quality checks are installedmediumaccounts-pwquality-installedbaseline + stigUBTU-24-100600auto fix

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 directoriesmediumfiles-no-stray-suidbaseline + cisCIS 7.1.13manual fix
  • Every file belongs to an existing user and grouplowfiles-no-unownedbaseline + cisCIS 7.1.12manual fix
  • No world-writable files or unprotected shared directoriesmediumfiles-no-world-writablebaseline + cisCIS 7.1.11manual fix

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:rootmediumperm-crontabbaselineauto fix
  • /etc/group is 0644 or stricter, root:rootmediumperm-etc-groupbaselineauto fix
  • /etc/group- backup is 0644 or stricter, root:rootmediumperm-etc-group-backupbaselineauto fix
  • /etc/gshadow is 0640 or stricter, root:shadowhighperm-etc-gshadowbaselineauto fixUbuntu/Debian only
  • /etc/gshadow has mode 0000, root:roothighperm-etc-gshadow-rhelbaselineauto fixRHEL family only
  • /etc/passwd is 0644 or stricter, root:rootmediumperm-etc-passwdbaselineauto fix
  • /etc/passwd- backup is 0644 or stricter, root:rootmediumperm-etc-passwd-backupbaselineauto fix
  • /etc/shadow is 0640 or stricter, root:shadowhighperm-etc-shadowbaselineauto fixUbuntu/Debian only
  • /etc/shadow- backup is 0640 or stricter, root:shadowhighperm-etc-shadow-backupbaselineauto fixUbuntu/Debian only
  • /etc/shadow has mode 0000, root:roothighperm-etc-shadow-rhelbaselineauto fixRHEL family only
  • GRUB config is 0600, root:rootmediumperm-grub-cfgbaselineauto fixUbuntu/Debian only
  • GRUB config is 0600, root:rootmediumperm-grub2-cfgbaselineauto fixRHEL family only
  • /etc/ssh/sshd_config is 0600, root:rootmediumperm-sshd-configbaselineauto fix
  • /etc/sudoers is 0440 or stricter, root:rootmediumperm-sudoersbaselineauto fix

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.