Skip to content

Latest commit

 

History

65 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

debian-hardening

One idempotent Bash script that turns a fresh Debian server into a sensible baseline: a sudo user with your SSH key, key-only SSH, a UFW firewall, Fail2Ban and automatic security updates — without locking you out.

lint e2e Bash Debian ShellCheck License

Why

Every new server starts with the same chores: create a user, copy your key, disable root login and passwords, set up a firewall, install Fail2Ban, enable unattended upgrades. Doing it by hand is slow and easy to get subtly wrong (and one wrong SSH setting locks you out). This packages that baseline into a single script you can read, audit and re-run.

It is intentionally small and dependency-free: just Bash + the standard Debian tools. No Ansible, no Python — drop it on the box and run it.

What it does

Step Detail
Admin user Optional: create a sudo user, install your SSH public key, and grant passwordless sudo (the user has no password, so otherwise couldn't escalate).
SSH Drop-in 99-hardening.conf: no root login, key-only auth, custom port, plus CIS extras (MaxAuthTries, X11Forwarding no, LoginGraceTime, idle timeout). Validates with sshd -t before reloading.
Firewall UFW: default deny incoming, allow SSH (and any extra ports you pass).
Fail2Ban sshd jail, backend = systemd, banaction = ufw, ban 1h / maxretry 5, journal match on the ssh unit (works with OpenSSH ≥ 9.8's sshd-session).
Updates unattended-upgrades for automatic security patches.
Kernel (sysctl) Drop-in /etc/sysctl.d/99-hardening.conf: no ICMP redirects (in or out), no source routing, reverse-path filtering, martian logging, SYN cookies, restricted dmesg / kernel pointers, no setuid core dumps. Deliberately leaves accept_ra (IPv6 SLAAC on VPSes) and ip_forward (routers / Docker hosts) alone.
Account policies Password aging per CIS 5.4 (PASS_MAX_DAYS 365, PASS_MIN_DAYS 1, PASS_WARN_AGE 7 in login.defs) applied also to existing password-holding accounts, and a 30-day post-expiry inactivity lock for accounts created from now on (useradd -D -f 30). Key-only accounts (locked hash — like the admin user step 1 creates) are never touched, and existing accounts don't get the inactivity lock: one whose password expired long ago would be locked on the spot.
Mount options /dev/shm remounted — and pinned in /etc/fstab — with nodev,nosuid,noexec (CIS 1.1.2.2): world-writable shared memory stops being a launchpad for droppers. An existing fstab entry keeps its custom options (size=…); only the missing flags are added. /tmp is deliberately left alone — a noexec /tmp breaks well-behaved installers, and Debian doesn't ship it as a separate mount.
Warning banners CIS 1.7: a fixed legal notice in /etc/issue, /etc/issue.net and /etc/motd — the stock files advertise the exact OS (Debian GNU/Linux 13 \n \l) to anyone who connects, before any login. sshd presents it pre-auth via its own drop-in (Banner /etc/issue.net), validated with sshd -t before reload so a bad config never goes live. The warning is also what makes session monitoring legally defensible.
Sudo hardening CIS 5.3: Defaults use_pty (every sudo command runs in its own pseudo-terminal, so a malicious command can't inject keystrokes into the calling tty once sudo exits) and Defaults logfile="/var/log/sudo.log" (sudo activity in one dedicated file instead of scattered through auth.log — the first thing a forensics pass wants). Installed as a drop-in validated with visudo -cf before it goes live, so a bad rule can never break sudo.
SSH session policies CIS 5.2, in its own drop-in (97-hardening-policies.conf): step 2 hardens who gets in, this one limits what a session may do once inside. AllowTcpForwarding no + AllowAgentForwarding no (an account with a locked-down shell is still a SOCKS pivot into the network otherwise), MaxSessions 4 and MaxStartups 10:30:60 (multiplexing and connection-slot caps), LogLevel VERBOSE (logins record the key fingerprint — in a shared-key world, "who exactly connected" stops being guesswork), PermitUserEnvironment no, HostbasedAuthentication no, IgnoreRhosts yes, PermitEmptyPasswords no. Validated with sshd -t, reverted if rejected.
Core dump limits CIS 1.5: a core dump is the crashed process's memory written to disk — keys, passwords, session tokens included. Three doors, three locks: a limits.d drop-in sets hard core 0 for every account (root gets its own line* never matches root in limits.conf, a classic gap), a coredump.conf.d drop-in caps systemd-coredump off (Storage=none, ProcessSizeMax=0 — if that collector is ever installed it bypasses ulimit entirely), and the setuid door (fs.suid_dumpable=0) was already locked by the sysctl step. hard means a session can't raise the limit back up.
Umask & shell timeout CIS 5.4: the stock umask 022 makes every new file world-readable — logs, dumps, home directories. UMASK 027 in login.defs (pam_umask applies it to every PAM session) plus a profile.d drop-in (login shells pick it up even where pam_umask is absent). And the third classic gap — the unlocked terminal someone walked away from — gets readonly TMOUT=900: idle interactive shells log out after 15 minutes, and readonly means the session can't unset or raise it.
Cron restrictions CIS 5.1: scheduled jobs are persistence 101 — a foothold that re-runs itself survives reboots and cleanups. Two moves: the spool goes root-only (/etc/crontab to 600, the cron.* drop-in dirs to 700 — by default they're world-readable, leaking commands, paths and timings to any local user), and crontab/at switch from Debian's deny-list model to an allow-list with just root (cron.allow/at.allow, cron.deny/at.deny removed). Existing user crontabs keep running — the allow-list gates the crontab(1) command, not the daemon — so nothing already deployed breaks; unprivileged users just can't schedule anew. An admin-curated cron.allow is respected (root is ensured, other entries kept).
Password policy CIS 5.3/5.4: the aging step decides when a password must change; this one decides what it may be and how it is stored. libpam-pwquality gates every PAM password change — minlen 14, all four character classes (minclass 4), maxrepeat 3, dictionary words rejected — and enforce_for_root closes the classic hole where root "fixing" an account types temp123 straight past the policy. The hashing side: Debian already defaults to yescrypt through PAM, but chpasswd/newusers read ENCRYPT_METHOD from login.defs — pinned to YESCRYPT so no path quietly falls back to a weaker crypt.
File integrity CIS 1.4: every other step hardens a file; this one notices when someone changes it afterwards. AIDE fingerprints the system binaries (/bin, /sbin, /usr/bin, /usr/sbin, /boot) and the whole of /etc — permissions, ownership, size, mtime/ctime and SHA-256+512 — into a baseline database, and a systemd timer re-checks daily. A backdoored sudo, an edited /etc/passwd or a new SUID binary shows up as drift instead of staying invisible. Own config (/etc/aide/hardening.conf) and DB (/var/lib/aide/hardening.db) scoped to what matters after a compromise, so the baseline builds fast enough to check daily. On a real box, copy the baseline somewhere the attacker can't reach — otherwise they can just regenerate it.
Rootkit detection rkhunter scans for known rootkits, backdoors and local exploits, plus suspicious file properties and hidden files — a second detection layer alongside AIDE (AIDE = generic file integrity, rkhunter = known-threat signatures). A property baseline is taken at hardening time and re-checked daily by a systemd timer; Debian's own cron.daily job and its network signature auto-update are turned off so the check runs once, from the timer, offline. Installed without recommends so it doesn't drag in a mail-transport agent.
Module blacklist CIS 1.1.1 / 3.4: rarely-used filesystems (cramfs, freevxfs, jffs2, hfs, hfsplus, udf) and network protocols (dccp, sctp, rds, tipc) made unloadable in modprobe.d — every one is kernel code reachable from userspace (a mount(2) or socket(2) away), and several have carried privilege-escalation CVEs. Two directives per module because they close different doors: install <m> /bin/false defeats an explicit modprobe, blacklist <m> stops the alias auto-load path. Already-loaded modules get a best-effort unload. usb-storage is deliberately spared (site-dependent per CIS — it bites the restore-from-USB path), as are squashfs/overlayfs (snaps, container runtimes).
Account lockout CIS 5.3.2: pam_faillock locks an account after 5 failed password attempts for 15 minutes (deny=5, unlock_time=900, audit). The password-quality step makes each guess expensive and Fail2Ban blocks the SSH source; this closes the third face — the account itself, so brute force over any PAM path (su, console login, keyboard-interactive SSH) hits a wall. Wired the Debian way via two pam-auth-update profiles (a high-priority preauth gate above pam_unix, an authfail tally below it) so the generated common-auth stays deterministic and the tool never clobbers a hand-edit. Key-only SSH never touches the PAM auth stack, so the admin user this script creates (locked password, key login) can't be locked out by it — the lockout only bites password authentication.
File permissions CIS 6.1: exact owner/group/mode on the account database — passwd/group 644 root:root, shadow/gshadow 640 root:shadow, including the - backups the shadow suite writes (same secrets, routinely forgotten) — plus three sweeps over the root filesystem: the world-writable bit cleared on files (any local user could rewrite them), orphan files adopted by root:root (a recycled UID would silently inherit them), and the sticky bit added to world-writable directories (without it anyone can delete anyone's files). SUID/SGID binaries are inventoried, never stripped — site-dependent per CIS, and blindly removing bits breaks sudo/passwd/ping. Scratch dirs (/tmp, /var/tmp) are excluded from the file sweeps: transient by design and already guarded by the sticky bit.
SSH access control CIS 5.2: only members of a dedicated ssh-users group may log in over SSH (AllowGroups ssh-users) — sshd rejects anyone else before the auth stack runs, so a service account or a stale login can't be brute-forced over SSH if it can't reach SSH at all. The lockout guard: the admin user is added to the group first, and if there is no --admin-user the step is skipped entirely — an AllowGroups that no live account satisfies would lock everyone out. Own drop-in (96-hardening-access.conf), validated with sshd -t and reverted on failure, like every other sshd change here.
Service sandboxing A systemd hardening drop-in for the fail2ban unit — NoNewPrivileges, PrivateTmp, ProtectSystem=full, ProtectHome, ProtectKernelTunables, ProtectControlGroups, RestrictSUIDSGID. A network daemon that shells out to ufw is a juicy foothold if it's ever exploited; systemd boxes it in for free. The set is conservative on purpose — fail2ban still needs the network and to run ufw, so no PrivateNetwork / ProtectSystem=strict / kernel-module lockout that would break it. If the unit won't start with the drop-in it's reverted (systemctl daemon-reload + restart, then a start check) — a hardening step must never leave the intrusion-prevention service down.
Journald persistence CIS 4.2.2: a journald.conf.d drop-in makes systemd-journald keep logs on disk (Storage=persistent) so they survive a reboot — the volatile default keeps them in a /run tmpfs and loses everything on restart, so the one event you most want after an incident (the compromise that forced the reboot) is gone. Plus Compress=yes and SystemMaxUse=200M so the persistent journal can't fill the disk. /var/log/journal is created up front so the very next boot is already persistent instead of one reboot behind.
Su restriction CIS 5.7: su hands out a whole root shell against a shared password; sudo logs every command and is revocable per user. A pam_wheel line in /etc/pam.d/su gates su on membership of a dedicated group, checked by real uid (use_uidgetlogin() can be spoofed through utmp), so a stolen root password alone no longer buys a shell. The group is created empty on purpose (as CIS suggests): sudo is the sanctioned path, and any member is a deliberate, auditable exception. Root keeps its own supam_rootok sits above the check.
System accounts CIS 5.4.2 / 6.2.9: a service account with a real shell is a login waiting to happen — a valid su target, a valid SSH target while password auth lives, and the landing spot of choice once the daemon running as it is compromised. Every account with uid <= 999 gets two independent gates: a /usr/sbin/nologin shell and a locked password. Either alone leaks — a locked password still lets in anything that skips PAM's auth stage (an SSH key dropped in ~/.ssh, su - from root, a cron entry), and a nologin shell still lets a password-only check "succeed" for services that authenticate without spawning a shell. Together the account can own files and run daemons, but nobody can be it interactively. Never touched: root (locking it bricks single-user recovery), the --admin-user, anything with uid >= 1000 (humans), and the sync/shutdown/halt trio whose whole purpose is a login shell — the exceptions CIS itself carves out. Reversible per account with passwd -u.
Log file permissions CIS 4.2.3: logs are the forensic record and a reconnaissance goldmine — auth.log says who logs in from where and which attempts fail, dpkg.log lists the exact package versions to shop CVEs for (and ships world-readable on stock Debian). Everything under /var/log loses group-write and all world access (g-wx,o-rwx), keeping owner bits and ownership so services keep writing their own logs. The deliberate exceptions are the utmp family, world-readable by design so who/last work for non-root users: wtmp/lastlog stay 664 root:utmp, btmp gets 660 — failed logins famously record usernames typed into the password prompt. Second half: an rsyslog.d drop-in pins FileCreateMode 0640 so tomorrow's files are born restricted too (loaded after rsyslog.conf, it wins over a drifted mode there — a sweep without it rots on the next logrotate cycle). Skipped gracefully on journald-only boxes, whose journal files are 0640 by design.
Logrotate permissions CIS 4.4: rotation is the third way a log is born (after the service and rsyslog, both covered above) — logrotate's create directive decides the mode of every file it re-creates, and stock Debian offends on its own: the global create is bare (it clones the rotated file's mode, drift included) and the dpkg/alternatives snippets say create 644 root root, so the very dpkg.log the sweep just tightened comes back world-readable on the next monthly cycle. The step pins the global to create 0640 (mode only — owner/group keep coming from each file, so services keep writing their own logs) and strips g-wx,o-rwx from every loose create in logrotate.d, keeping owner/group arguments untouched. The wtmp/btmp snippets keep their by-design utmp split (664/660 root:utmp) — the same exception the sweep makes. Edits are validated with logrotate's own parser and restored if it rejects them; verify forces a real rotation and proves the re-created files are born 0640. Skipped gracefully when logrotate isn't installed.
Audit daemon CIS 4.1: the logging chapter's last piece — journald keeps the logs, the sweep guards them, logrotate re-creates them right; auditd records WHO touched the crown jewels, at the kernel, as it happens (AIDE notices a change by the next daily check; the audit trail names the process, uid and syscall the moment it happened). A staged ruleset (/etc/audit/rules.d/hardening.rules, 0640 root:root) watches the account database (-k identity), sudoers (-k scope), sshd config including this repo's own drop-ins (-k sshd), time manipulation — log-forgery 101 — on both syscall arches plus /etc/localtime (-k time-change), kernel module syscalls (-k modules), and the audit config and logs themselves. auditd.conf keeps history instead of rotating it away (max_log_file_action = keep_logs) and shouts to syslog when disk runs low. The split that keeps it honest: the promise is configuration — package installed, rules staged, service enabled at boot — because the audit netlink is not namespaced: a container (this repo's CI node, WSL) gets EPERM by construction, while any real server compiles and loads the staged rules on its next boot. So verify runs the real toolchain instead of pretending: augenrules must land every key in the merged audit.rules. No -e 2 immutable flag on purpose — the script stays re-runnable; flip it yourself once the ruleset is final.
Home directory permissions CIS 6.2: a 755 home — still the default on many distros — hands every local account a reading pass over ~/.ssh, ~/.aws, ~/.kube and the shell history with the password someone typed at the wrong prompt; group-write is worse, because a writable ~/.bashrc is code execution as that user at the next login. Every interactive home (uid ≥ 1000 with a real shell, plus root) is trimmed to 750 or tighter — mode only, ownership untouched: adopting a home to root would break the login it exists for; a home already at 700/750 isn't touched at all. And the legacy dotfiles that grant access with no password are removed: .netrc (cleartext logins that ftp/curl happily read), .rhosts/.shosts (rlogin-era trust files), .forward (silently reroutes a user's mail). All four are relics; none has a legitimate place on a hardened server.
Process isolation Your neighbour's process is private — two doors, because each one alone leaks. /proc with hidepid: on a stock box ps aux hands every local account a full inventory of what runs and as whom, plus any secret sitting in someone else's command line (mysql -pSecret, a curl with a token in the URL, a backup script's --password=). hidepid=2 shows a user only their own processes; root — and an optional monitoring group via PROCESS_HIDEPID_GID — still sees everything. Pinned in /etc/fstab (Debian ships no /proc line, so without the pin the hardening dies at the next reboot) and remounted live only when the option isn't already in effect; an admin who chose hidepid=1 keeps that choice instead of getting a contradictory second copy of the key. kernel.yama.ptrace_scope=1: hidepid hides the process list, ptrace_scope stops reading a process's memory — with the default 0, anything running as you can attach to your browser and lift its session cookies, or to ssh-agent/gpg-agent and lift the keys, with no root at all; 1 allows only a direct parent, which is all a debugger launched from your shell needs. Its own drop-in (/etc/sysctl.d/99-hardening-process.conf), not the step-6 sysctl file, so --no-process-isolation and --no-sysctl stay independent. Both halves warn instead of failing where the kernel won't allow them (a /proc that can't be remounted, a kernel built without Yama).
Guess cost Every wrong password guess gets expensive, offline and online. The password steps so far decide what a password may be, when it changes and how many live misses lock the account — this one prices the guesses themselves. Offline: YESCRYPT_COST_FACTOR 11 in login.defs (the maximum) makes each attempt against a stolen shadow file cost ~1 s of CPU instead of milliseconds — a cracking rig drops from millions of candidates a second to a handful; chpasswd, newusers and a PAM password change all read the pin (verified empirically), so a fresh hash is born $y$jFT$… instead of the stock $y$j9T$…. Online: pam_faildelay makes every failed live authentication wait FAIL_DELAY 5 seconds before control returns — even a guesser that never touches sshd (console, su, serial) gets ~12 tries a minute. Wired via a pam-auth-update profile at priority 128, which lands it on the failure path: after pam_unix, above faillock's [default=die] and the closing requisite pam_deny — appended below those, the delay simply never runs. A correct password pays neither price: libpam only applies the delay when authentication ultimately fails, and existing hashes keep their minted cost until the next change.
Root PATH integrity CIS 6.2.8: every command root types is looked up along PATH in order, so whoever can write to a directory early in that list chooses what root actually runs. An empty entry (::, or a leading/trailing colon) means the current directory — root cd's into /tmp, types ls, and runs whatever a local user left there named ls; a group/world-writable directory anywhere in the list is the same trap and it survives reboots. THREE sources, because pinning only login.defs is a hollow promise on Debian — verified on the node before the step was written: login.defs ENV_SUPATH/ENV_PATH covers a real login and su - from an unprivileged account, but /etc/profile overwrites it for every login shell with a literal PATH="…" per uid branch (that is the PATH an admin actually sees, and it wins), and /etc/crontab's own PATH= is what root's scheduled jobs get — neither of the other two reaches them. All three are sanitized with the same rule. Nothing is imposed: each source keeps the entries the admin put there and loses only the unsafe ones, and the two kinds of offender are treated differently on purpose — a fixable one (a directory that exists but is group/world-writable) gets its mode tightened and stays in the PATH, an unfixable one (empty, relative, missing) is dropped. Deleting a directory the admin put there would break every locally installed binary, so the order is fix-then-drop — and getting it backwards was a real bug this step had: sanitizing first deleted a loose /usr/local/bin from root's PATH instead of fixing it. stat -Lc, never stat -c: on any modern Debian /bin and /sbin are symlinks into /usr (usrmerge) and a symlink is always mode 777, so the obvious check reports every stock system as broken — and a chmod on /bin would change the link, not the directory.
Apt updater sandboxing A systemd drop-in confines the unattended apt-daily-upgrade unit — the network-facing, root, on-a-timer package installer — exactly as step 22 confines fail2ban. But apt is the anti-sandbox workload, and the interesting part is what it will not tolerate, both measured on the node before the step was written: ProtectSystem=full/strict breaks it (dpkg writes /usr, /boot, /etc on every install — where fail2ban tolerated it with a ReadWritePaths carve-out, apt tolerates none), and RestrictSUIDSGID=true breaks it too (dpkg installs setuid binaries — reinstalling passwd under that flag failed with dpkg status 100). So the shipped set is only the process-level confinement that cannot collide with installing packages: NoNewPrivileges, PrivateTmp, ProtectHome, ProtectControlGroups — measured to let a real upgrade run to Result=success. If systemd rejects the drop-in it is reverted, and both forbidden flags are asserted off by verify and audit so a well-meaning edit that adds them gets caught.
Password history CIS 5.3.3: a forced password change is pointless if the user can rotate straight back to the old one. pam_pwhistory keeps the last 24 hashes per account in /etc/security/opasswd and refuses any new password that matches one — closing the password arc: pwquality decides what may be a password, aging when it changes, faillock counts live misses, guess-cost prices each try, and this one bars the way back. Wired the Debian way via a pam-auth-update profile at priority 512, which lands the line exactly between pam_pwquality (1024 — strength is judged first, so history is never consulted about a password that would be rejected anyway) and pam_unix (256 — reuse is refused before the change lands). enforce_for_root is not decoration, measured both ways: without it, a reuse performed by root — chpasswd included, it traverses this same stack — still prints "Password has been already used" and then goes through anyway; a warning nobody acts on is not a control. opasswd holds password hashes — shadow-grade sensitivity — so the step owns its existence and pins it root:root 0600.
SSH crypto policy CIS 5.2: what the transport may negotiate, pinned in its own drop-in. Stock OpenSSH 10 still negotiates hmac-sha1 and the 64-bit-tag umac-64 when a client asks — measured on a fresh node: forcing -o Ciphers=aes256-ctr -o MACs=hmac-sha1 gets a session — and its key-exchange list drags the NIST P-curves behind the modern hybrids. The MAC pin matters precisely because ctr ciphers stay in the list: with an AEAD cipher (chacha20/GCM) OpenSSH skips MAC selection entirely — the MAC field is decorative — so the real downgrade path is a client asking for aes256-ctr + hmac-sha1, and a stock server obliges (measured both ways: refused after, accepted before). Key exchange keeps only the post-quantum hybrids (mlkem768x25519, sntrup761x25519) and curve25519 — harvest now, decrypt later is the reason the hybrids exist: a session recorded today should not become plaintext the day the recording's owner buys the hardware. Precedence, also measured: Debian includes sshd_config.d at the top of sshd_config and sshd honours the first occurrence of a keyword, so this drop-in beats any legacy-compat crypto line someone left in the main file — the CI plants exactly that line and verifies the drop-in wins. Validated with sshd -t and reverted if rejected, like every sshd change here.
Legacy protocol purge CIS 2.2/2.3: telnet, rsh, talk, NIS, tftp and the inetd superservers are purged — protocols whose design is the vulnerability: cleartext credentials (telnet/rlogin/talk), trust by source IP (rsh), the password map served to whoever asks (NIS), no authentication at all (tftp). Code that is not installed cannot be exploited or misconfigured. Two traps, both measured on the node: apt-get purge exits 100 on a package name the sources never heard of (rsh-client has no installation candidate on Debian 13) — under set -e that would abort the whole run over a package that was never there, so dpkg is asked first and only packages it has actually seen get purged (config-files counts: a removed-but-not-purged package still leaves its config behind). And the transitional trap: telnet on Debian 13 is a dummy package whose payload is inetutils-telnet via update-alternatives — purging the dummy alone leaves /usr/bin/telnet working, so the list names both halves of every such pair (netkit, inetutils, redone and hpa variants, 22 names). The CI plants telnet + xinetd and verifies both the dpkg state and the binaries are gone.
Filesystem protections CIS 1.5.x: the fs.protected_* sysctls that shut down the symlink/hardlink TOCTOU class in a world-writable directory the whole box shares. protected_symlinks = 1 (a privileged process only follows a symlink in a sticky dir like /tmp when the owners line up — the trick of swapping /tmp/foo for a link to /etc/shadow between a root process's check and its open stops working), protected_hardlinks = 1 (you can only hardlink to a file you could already read/write — no pinning someone else's secret to outlive their delete), protected_fifos = 1 and protected_regular = 2 (the same idea for FIFOs and regular files a root-run process might write to a predictable path). Its own drop-in (/etc/sysctl.d/99-hardening-fs.conf), independent of the step-6 sysctl file. Modern kernels ship these already on, so the CI plants all four at 0 before hardening — the step must prove it tightens them, not confirm a default.
Account database hygiene CIS 6.2: the logins hiding in the account data, invisible to every PAM-stack step because the hole is in the files, not the modules — all three measured on a stock node before the step was written. Legacy NIS compat entries (+/- lines) are removed from passwd/shadow/group: inert under nsswitch files, but the day a service flips to compat, an unrestricted +::0:0::: splices in every NIS account — uid 0 included (step 36 purged the NIS packages; this removes the trigger data). A password hash sitting in world-readable /etc/passwd still authenticates from there — and hands every local account an offline cracking target — so pwconv moves it into /etc/shadow, password intact (verify proves the same password still logs in: moved, not broken). And an empty password field in /etc/shadow is a free login: Debian ships pam_unix with nullok, so pressing Enter is that account's password — those accounts get locked (passwd -l, reversible with passwd -u once a real password is set). Order matters: + lines out first (they pollute every field scan), then pwconv (an empty passwd field becomes an empty shadow field), then the empty-password lock catches what pwconv surfaced.
Exploit mitigations CIS 1.5.1 + kernel attack surface: the sysctls that price the standard exploit primitives, in their own drop-in. kernel.randomize_va_space = 2 — full ASLR; the classic regression is a debugging session that left it at 0, and the CI plants exactly that and logs the smoking gun: with ASLR off, two fresh processes print the same stack base (verify proves they differ afterwards — the behavioural check, not a file read). kernel.kexec_load_disabled = 1 — nobody soft-boots a replacement kernel past secure boot; one-way by design, not even root can clear it without a real reboot. kernel.unprivileged_bpf_disabled = 1 — Debian ships 2 (off, but root can flip it back live); 1 is the one-way latch, so the JIT-spray/verifier-bug surface needs a visible reboot to re-arm. net.core.bpf_jit_harden = 2 — the JIT's constants blinded for every user, where the kernel exposes the knob (some builds don't — this repo's WSL lab kernel doesn't, measured — so the live write warns and the drop-in still carries the pin). kernel.perf_event_paranoid = 3 — perf is a measurement rig pointed at the kernel; root-only. Live writes warn, never abort: the sysctl-step shape.
/tmp confinement CIS 1.1.2.1: the mount-options step done to the other world-writable directory every process can reach. /tmp gains nodev,nosuid,noexec — pinned in /etc/fstab (an entry wins over systemd's stock tmp.mount, and an existing entry only has its options edited: /tmp on its own partition keeps its filesystem) and applied live: a remount if /tmp is already a mount (the Debian 13 default — tmpfs with nosuid,nodev but no noexec), a fresh shadowing tmpfs (mode=1777,strictatime,size=50%) if it's a plain directory. The /dev/shm step once left /tmp alone citing "breaks installers" — measured since: apt and dpkg never execute from /tmp (maintainer scripts run from /var/lib/dpkg), and the CI proves it by reinstalling a real package on the confined node every run. What it actually stops is the dropper's move: stage the payload in the one directory anyone can write, run it from there — verify stages /bin/true in /tmp and the kernel refuses. The genuine casualty is the vendor .run bundle that unpacks-and-executes from /tmp: that's what --no-tmp-confinement is for.
Time synchronization CIS 2.1: the clock is a security control, not a convenience — certificate validity windows, Kerberos ticket lifetimes, TOTP codes and log correlation are all comparisons against it, and drift fails them silently: nothing errors, logins just start failing and timelines stop meaning anything. systemd-timesyncd installed (Debian 13 ships it as its own package, and a minimal install does not have it — measured on the node image), servers pinned explicitly in a drop-in (0-3.debian.pool.ntp.org + cross-provider fallbacks — the stock config carries servers only as commented-out defaults, and a fallback default is not a decision), enabled at boot. Defers to an admin's running chrony/ntpsec — one clock daemon is the point, and purging a working one out from under a box is worse than the gap. The honesty fork, measured while designing the step: timesyncd ships ConditionVirtualization=!container, so inside a container systemd itself keeps it inactive by design (one kernel clock, and it belongs to the host) — verify asserts ConditionResult=no, the honest skip, not a crash; on a real machine it asserts active plus the behavioural flag file timesyncd touches on its first successful sync (/run/systemd/timesync/synchronized). Same config-promise split as the auditd step.
Apt source trust CIS 1.2: the package manager is the one channel that installs root-owned code by design, and apt's signature check is what makes it a channel and not a hole. Two gates, both measured on a stock node against a local repository with no Release file: Acquire::AllowInsecureRepositories gates the index (stock apt refuses the repo at update; loosened — the classic broken-mirror workaround — it is fetched behind a one-line warning) and APT::Get::AllowUnauthenticated gates the install (stock apt refuses; loosened, the package installs with "Authentication warning overridden" — and this gate is also what stands between a stale insecure index and dpkg). Both pinned strict in their own drop-in, with AllowDowngradeToInsecureRepositories, AllowWeakRepositories and Check-Valid-Until (an expired Release file refused — the freeze attack: a validly-signed stale snapshot that keeps a known-vulnerable version installable). The defaults are already strict; the point of pinning is precedence: apt.conf.d is read in lexical order and the last setting wins, so the 99- pin overrides any loosening left in an earlier file — the CI plants exactly that, and verify reads the effective config (apt-config dump, the sshd -T of apt). Behavioural, both gates: a hand-built .deb + Packages index with no Release file is refused at update (and accepted with the gate opened on the command line — a refusal only means something next to an acceptance), and a real install from that index is refused as unauthenticated (measured: apt-get -s says Inst, rc 0, regardless — the simulation cannot carry this check). Sources marked trusted=yes bypass every gate (measured: the same unsigned repo installs silently under it) and settings loosened elsewhere are reported, never edited: a per-source trust override is an explicit admin decision.
/var/tmp confinement CIS 1.1.4.x: the third world-writable directory every process can reach, and the one that persists across reboots — which is exactly why a dropper that wants to survive one stages there. Debian keeps /var/tmp as a plain directory on the root filesystem, so there is nothing to remount; the step bind-mounts the directory onto itself and tightens the bind to nodev,nosuid,noexec — mount options belong to a mount, not a directory, and a bind gives the directory a mount of its own without a partition (the honest equivalent of the separate partition CIS 1.1.4.1 asks for, with the same 1.1.4.2-4 result). Measured on a privileged debian:13: the bind plus remount lands the three options; a binary staged there dies with Permission denied; a file written before the bind is the same file after it (a bind, not a tmpfs — persistence intact, and the 30-day systemd-tmpfiles sweep still finds it); the fstab line /var/tmp /var/tmp none bind,nodev,nosuid,noexec 0 0 applies all of it in one go at boot; and the trap: mount -a on top of a manual bind stacks a second mount on the same target, so the step mounts only when /var/tmp is not a mountpoint yet and remounts only when an option is actually missing. Verify asserts the bind (findmnt shows the source as DEVICE[/var/tmp]), the three live options and the dropper's move refused.
Attack-surface services CIS 2.2: the network daemons a server runs because a package pulled them in, not because anyone asked — purged. avahi-daemon (mDNS/DNS-SD on udp/5353: announces the box and answers "who is here?" to the whole broadcast domain — discovery is reconnaissance done for the attacker, and a spoofing vector; the Linux twin of the Windows sibling's LLMNR step), cups (a print server on a box that never prints, root, with a driver parser exposed on tcp/631 — the Linux side of the PrintNightmare story) and rpcbind (the portmapper on 111, NFS/NIS bootstrap and a classic UDP reflector). Measured trap on debian:13: apt-get purge cups removes the metapackage and leaves cups-daemon — the actual cupsd — installed, so the daemon packages are named explicitly (cups-daemon, cups-browsed). Units are stopped and disabled first, then purged; a known-but-absent package is a quiet rc 0, so the step is idempotent by construction. Every other CIS 2.2 server package (samba, nfs, bind, dhcp, ftp, snmp, squid, mail) is a business decision: the audit reports them, the step never removes them — that is what --no-service-purge and the audit's WARN are for. The CI installs the three on the node before hardening and verify asserts each package gone plus no listener on 5353/631/111.
Kernel attack surface Step 39 priced the exploit primitives; this step closes whole interfaces — kernel code any local process can reach and a headless server never needs, each one a bug class with a name. kernel.io_uring_disabled = 2 (kernels ≥ 6.6): io_uring is the most productive kernel bug class of the 2020s — Google reported that 60% of the exploits submitted to its kernel bounty in 2022 targeted it and switched it off on production servers, ChromeOS and Android; 2 refuses io_uring_setup(2) for everyone, root included (measured: at 1 root still gets a ring; verify proves the refusal from the admin account with a bare perl syscall() — no compiler on the node). kernel.sysrq = 0: the magic SysRq hotkeys from a console, serial line or BMC — kill every process, reboot without sync, dump memory to the log; Debian ships 438; the pin closes the keyboard path, root's /proc/sysrq-trigger bypasses the mask by design. dev.tty.ldisc_autoload = 0: any tty owner could request a line discipline and have its module autoloaded — a string of unprivileged LPEs (n_hdlc, slip) came from exactly that path. kernel.unprivileged_userns_clone = 0: an unprivileged user namespace hands any local account a fake CAP_SYS_ADMIN — the amplifier under most modern LPEs; Debian/Ubuntu kernels carry the knob, upstream builds don't (the WSL lab kernel, measured), so the drop-in pins it for the kernels that do and the live write notes — and user.max_user_namespaces = 0 is deliberately not the substitute (it forbids root too: containers, PrivateUsers=). Own drop-in, --no-kernel-surface to skip. The CI plants every interface open before hardening and verify asserts each pin effective plus the two refusals (io_uring EPERM, unshare -Ur denied where the knob exists).
SUID diet CIS 6.1.13 says audit the setuid binaries; a headless server can do better. A setuid-root binary is code that runs as root on behalf of any local account — the classic local-privilege-escalation surface — and Debian ships five of them for a human at a terminal changing their own finger info, shell or group: chfn, chsh, gpasswd, newgrp (setuid root) and expiry (setgid shadow). Key-only SSH, no interactive users, shells set by the admin: none has a job here, so the bit comes off. The trap, measured on debian:13: chmod u-s /usr/bin/chfn lasts exactly until the next apt-get install --reinstall passwd — mode back to 4755, no warning. dpkg-statoverride --update --add root root 0755 … is the mechanism dpkg itself honours: the override survived the reinstall. It is also fussy — a second --add aborts (rc 2), so an existing override with another mode is removed first and the right one left alone (idempotent). su, sudo, passwd, mount, umount, ssh-keysign stay (each is load-bearing) — plus exim4 (Debian's default MTA, pulled in by rkhunter/aide for their mail reports; setuid root is how an MTA delivers) and the D-Bus activation helper, both measured on the hardened node — and the step reports every setuid-root binary outside that known list — a newcomer with a boundary of its own shows up. --no-suid-diet to skip. Verify asserts the five modes, the five pins, a behavioural probe (gpasswd from the admin account now runs as the caller: it cannot even open /etc/gshadow) and the trap made a test: it reinstalls the passwd package on the node and checks chfn is still 755.
Per-session process limits A compromised or careless account with no process cap can take the box down with one line — the fork bomb — or with a runaway build that never stops spawning. Debian ships every session unlimited (measured: ulimit -u and ulimit -Hu both unlimited for a fresh user). One limits.d drop-in caps nproc at 4096 (soft and hard) for every PAM session, and on Debian 13 sshd, login, su and sudo all carry pam_limits in their stock stacks (measured), so the cap lands where a compromised account arrives; the step reports any stack that lost it. Root's own logins are left alone* never matches root (the step-11 lesson) and root is not the threat model — and systemd services are not PAM sessions, so daemons keep their own DefaultTasksMax. Measured against the obvious escape: rlimits are inherited, so sudo from a capped session stays capped at 4096 — an explicit root … unlimited line does not lift it (pam_limits in sudo's stack cannot raise what the parent already lowered); the cap follows the session, sudo included. The hard limit is the promise: measured, a capped session can lower and re-raise its soft limit up to the hard one, but ulimit -u 8192 above it is Operation not permitted; under a soft limit of 20, launching 40 background sleeps left the shell unable to even fork grep. --no-process-limits to skip. Verify reads the effective limits inside a real SSH session of the admin account, proves the session cannot raise its cap, and proves a sudo shell from that session inherits it.

Lockout guard

The script will not disable SSH password authentication unless it finds an authorized_keys for the target user (or root) — so a typo can't lock you out. Pass --pubkey to install a key first, or --force-no-password if you have console access and know what you're doing.

What this demonstrates

  • Automation & idempotency — every step checks state before acting; safe to re-run. Honours a real --dry-run.
  • Linux security baseline — SSH hardening, host firewall, brute-force protection and patch automation, the way you'd actually set up a server.
  • Defensive scriptingset -euo pipefail, root/OS preflight checks, config validation (sshd -t) before reload, and a lockout safeguard.

Usage

git clone https://github.com/DannyRuizB/debian-hardening.git
cd debian-hardening
chmod +x harden.sh

# See exactly what would change — touches nothing:
sudo ./harden.sh --dry-run

# Typical run: create an admin user with your key, harden everything,
# open ports 80/443 for a web server:
sudo ./harden.sh \
  --admin-user danny \
  --pubkey "$(cat ~/.ssh/id_ed25519.pub)" \
  --allow-port 80/tcp --allow-port 443/tcp

Options

--ssh-port N           SSH port to allow/protect (default: 22)
--admin-user NAME      create/ensure this sudo user before locking SSH
--pubkey "ssh-... "    public key to install for --admin-user
--allow-port N[/proto] extra port to open in UFW (repeatable)
--no-<step>            skip any single step — one flag per table row above,
                       e.g. --no-ssh, --no-aide, --no-module-blacklist
                       (the full list: ./harden.sh -h)
--no-passwordless-sudo don't grant --admin-user passwordless sudo
--force-no-password    disable password auth even with no key (DANGEROUS)
--dry-run              print what would change, do nothing
-y, --yes              don't ask for confirmation
-h, --help             show help

Verify after running

sshd -T | grep -Ei 'passwordauth|permitroot|^port'
sudo ufw status verbose
sudo fail2ban-client status sshd

Targets

Written for Debian 12 (Bookworm) and Debian 13 (Trixie), and should work on Debian-based distros that ship ufw, fail2ban and unattended-upgrades. The script is checked in CI (bash -n, ShellCheck and a bats unit suite); always run it with --dry-run first against a host you can reach by console.

Tests

A bats suite covers the script's parsing and helpers without touching the host. main is guarded behind a BASH_SOURCE check, so the tests source the script to exercise parse_args (flags, defaults, --allow-port accumulation, --no-* toggles) and has_authorized_key (the lockout guard's key check, with getent stubbed to a temp home), plus the CLI surface (--help, unknown options, the root check).

bats test/          # needs bats-core (apt install bats, or brew install bats-core)

End-to-end (it actually hardens a box)

Linting proves the script parses; the e2e workflow proves it hardens. On every push it boots a disposable Debian 13 systemd container, runs harden.sh inside it for real, runs it again to prove idempotence (the config files' hashes must not change), and then attacks the result from the outside with test/verify.sh: root login refused, password auth not offered, UFW active, and a live brute-force burst that must end with the attacker banned by Fail2Ban. Reproduce it locally:

cd test
./node.sh up && ./node.sh wait
docker exec db-harden-node bash /root/harden.sh --admin-user opsadmin \
  --pubkey "$(cat .ssh_ci/id_ci.pub)" -y
./verify.sh
./node.sh down

This e2e caught a real bug: on OpenSSH ≥ 9.8 the auth work moved to an sshd-session process, so Fail2Ban's stock _COMM=sshd journal match missed every failure and never banned. The jail now matches on the ssh unit instead.

The same run also covers flag behaviour with test/scenarios.sh — the lockout guard (no key → password auth stays on; --force-no-password overrides it), a custom --ssh-port (sshd and UFW stay in sync), --allow-port, and --no-fail2ban.

Security lab

Beyond the CI checks, test/ is a small hands-on security lab against the hardened node — each script self-contained and local:

Script What it does
redteam.sh Attacks the node with a valid key and the correct passwords — 7 attacks repelled, 0 leaks (REDTEAM.md)
forensics.sh Blue-team: stages an attack and reconstructs it from the node's logs — who, which accounts, how Fail2Ban responded (FORENSICS.md)
before_after.sh Same attack against a stock Debian node vs the hardened one, side by side (BEFORE_AFTER.md)
attacks.sh Catalogue of recon/login techniques — banner grab, port scan, user enumeration, dictionary — each bouncing off its control (ATTACKS.md)
audit.sh Grades the node against a CIS-style checklist and scores it — drove the SSH drop-in's extra hardening (now 100%) (AUDIT.md)
scenarios.sh Asserts the flags behave (lockout guard, custom port, extra ports, skips) (SCENARIOS.md)

⚠️ Always run with --dry-run first on a host you can reach by console (e.g. the Proxmox/hypervisor shell) the first time, in case of a custom SSH setup.

About

Built by Danny Ruiz — systems & network administrator (ASIR, Administración de Sistemas Informáticos en Red). More projects →

License

MIT — see LICENSE.

About

Idempotent Bash baseline hardening for fresh Debian servers — sudo user, key-only SSH, UFW, Fail2Ban, auto-updates. Won't lock you out.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages