System Hardening Essentials
- Think in attack surface — every service, port, SUID binary, and weak credential is a door
- Reduce the surface: audit listeners with ss, disable and mask unneeded services
- Harden accounts, passwords, and SSH — the front door — without locking yourself out
- Apply filesystem and kernel hardening, and run the classic risky-file audits
- Detect what prevention misses: fail2ban for automated response, auditd for the forensic record
The hardening mindset: walk the building, lock the doors
Understand attack surface and the four moves every hardening step is one of
Take an attacker's tour of a freshly installed server. A dozen services are listening on the network — several you've never heard of. Password login is open to the internet, as root.
/tmpwill happily execute anything dropped into it. A handful of programs run as root whenever any user asks. None of this is a bug; it's convenience defaults — and every convenience is a door.Hardening is walking the building and locking the doors you don't use. The organizing idea is attack surface: the sum of every way in — running services, open ports, login paths, SUID binaries, weak credentials, writable scripts. You can't make the surface zero (a server must serve something), but every door you remove is one an attacker can't even try.
Every hardening action you'll ever take is one of four moves, in descending order of payoff:
1. Remove — uninstall/disable what isn't needed. A door that doesn't exist can't be picked.
2. Restrict — what remains answers only to the right people (firewall, SSH config, least privilege).
3. Strengthen — make the remaining locks better (key-only auth, password policy, lockout).
4. Monitor — because no lock is perfect, arrange to notice attempts (fail2ban, auditd).
Check yourself
Rank these by hardening payoff for a service you don't need: (a) firewall its port, (b) disable the service, (c) monitor its logs. Why?
Services and listeners — shrink what's exposed
Audit the doors, then remove the ones nobody uses
Start where attackers start: what can be reached over the network? Two reads define the exposure — what's listening right now, and what's set to start at boot (your two-state systemctl instinct applies to security too). The listener audit is the single highest-value command in this lesson:
ss -tlnpState Recv-Q Send-Q Local Address:Port Peer Address:Port Process LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=812,fd=3)) LISTEN 0 128 127.0.0.1:631 0.0.0.0:* users:(("cupsd",pid=901,fd=7)) LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:(("nginx",pid=1204,fd=6))- t / u
- TCP / UDP sockets
- l
- listening sockets only — the doors, not the conversations
- n
- numeric ports (no name lookups — faster, unambiguous)
- p
- show the owning process — the 'who is this door' column
Every listening TCP socket and the process behind it. Read the Local Address column like a security report: 0.0.0.0:22 and :80 are reachable from ANY network (sshd and nginx — presumably intentional), while 127.0.0.1:631 is cupsd listening only to the machine itself — lower risk, but why is a print service on a server at all? The audit rule: every line gets justified or eliminated. ss -ulnp repeats the exercise for UDP. sudo systemctl disable --now cups avahi-daemon sudo systemctl mask telnet.socketRemoved "/etc/systemd/system/multi-user.target.wants/cups.service". Created symlink /etc/systemd/system/telnet.socket → /dev/null.
- disable --now
- stop now AND remove from boot — the standard off
- mask
- symlink the unit to /dev/null — cannot start at all, even as a dependency
Two strengths of 'off', both from your systemctl toolkit. disable --now is the standard shutdown: stopped now, won't return at boot — right for cups and avahi on a server (print discovery and zero-conf networking have no business there). mask is the never-again switch: the /dev/null symlink in the output means NOTHING can start it — not an admin's habit, not another unit's dependency. Reserve mask for services that are policy violations by existence: telnet (plaintext passwords), rsh. Before disabling anything unfamiliar, identify it — lsof -i :PORT names the process, dnf repoquery --whatprovides tells you what installed it. Check yourself
ss -tlnp shows something listening on 0.0.0.0:6379 owned by a process you don't recognize. What's your triage sequence?
Accounts, passwords, and SSH — the front door
Strengthen who can log in and how, without locking yourself out
Network doors closed, now the locks: authentication. Two account questions matter on every box — whose passwords age, and which accounts can log in at all. Service accounts (
mysql,nginx) exist to own processes and files; they should never have an interactive shell. Departed users' accounts should keep their files but lose their login. Both are shell-field fixes:sudo chage -M 90 -W 14 alice; sudo chage -l alice | head -3 grep -vE 'nologin|false' /etc/passwd | cut -d: -f1Last password change : Jul 11, 2026 Password expires : Oct 09, 2026 Password inactive : never root alice
- -M 90
- (chage) maximum password age in days
- -W 14
- (chage) warning period before expiry
- -l
- (chage) list an account's aging settings — the verify step
chage sets aging per-account (max 90 days, 14-day warning; /etc/login.defs' PASS_MAX_DAYS sets the default for accounts created later). The grep is the who-can-actually-log-in audit: filter out nologin/false shells and what remains is your real login surface — here just root and alice, which is healthy. Any service account appearing in that list gets sudo usermod -s /sbin/nologin. Complexity and lockout live one layer down in PAM — pam_pwquality (minlen=12 dcredit=-1 …) and pam_faillock (deny=5 unlock_time=900) — covered in the Access Control lesson. A third account question is who holds admin. Membership in
sudo(Debian/Ubuntu) orwheel(RHEL) is root on demand, and it creeps: people are added for a project and never removed. Least privilege means reconciling the group against an approved list on a schedule.getent group <name>prints the group's entry from whatever account database the box uses (local files, LDAP, SSSD), and the fourth colon field is the comma-separated member list. Subtract the approved names and what's left is the finding;gpasswd -d <user> <group>removes a member from one group without touching the account or any of its other groups.sudo groupadd demo-admins && sudo useradd -M -G demo-admins demo-ana && sudo useradd -M -G demo-admins demo-raj printf 'demo-ana\n' > approved.txt getent group demo-admins | cut -d: -f1,4 getent group demo-admins | cut -d: -f4 | tr ',' '\n' | sort | comm -23 - approved.txt sudo gpasswd -d demo-raj demo-admins getent group demo-admins | cut -d: -f1,4 sudo userdel demo-ana; sudo userdel demo-raj; sudo groupdel demo-adminsdemo-admins:demo-ana,demo-raj demo-raj Removing user demo-raj from group demo-admins demo-admins:demo-ana
- getent group NAME
- Look a group up through the system's account sources (files, LDAP, SSSD); field 4 lists its supplementary members.
- comm -23
- Compare two sorted lists and print the lines unique to the first: members not on the approved list.
- gpasswd -d USER GROUP
- Remove USER from GROUP only; the account and its other memberships are untouched.
A throwaway admin group with two members, one of them approved. cut -d: -f1,4shows the group name and its members. The reconcile is a set difference: split the member list one per line, sort it, andcomm -23keeps the names that appear only in the membership and not inapproved.txt(which must be sorted too), heredemo-raj.gpasswd -dremoves exactly that member, and the re-read confirms only the approved admin is left. Run the same pipeline againstsudoorwheelon a real box; the last line just deletes the demo accounts.And then SSH — on most servers, the only intentional interactive door, which makes its config file the highest-value twenty lines on the machine. Three settings in
/etc/ssh/sshd_configdo most of the work:sudo grep -E '^#?(PermitRootLogin|PasswordAuthentication|MaxAuthTries)' /etc/ssh/sshd_config#PermitRootLogin prohibit-password #PasswordAuthentication yes #MaxAuthTries 6
Reading before changing, as always — the # prefixes mean these are commented defaults. The hardened trio to set: PermitRootLogin no (root can't log in directly — admins arrive as themselves and sudo up, so every action has a name attached, and the #1 brute-force target username stops existing); PasswordAuthentication no (key-only login — a credential class that can't be guessed, ending password brute-force wholesale); MaxAuthTries 3. AllowUsers alice bob whitelists further. Apply with systemctl restart sshd. Who may log in is half of SSH hardening. The other half is how the connection is encrypted. Three directives choose the algorithms sshd will negotiate:
Ciphers(bulk encryption),MACs(message integrity) andKexAlgorithms(key exchange). Left unset, OpenSSH uses its compiled-in defaults, which are modern but not what a compliance baseline like the DISA STIG demands. Don't read the config file to find out what's in force. Ask sshd:sshd -Tprints the effective configuration after every default, include and override is applied, which is exactly what an auditor's check reads.sudo sshd -T | grep -E '^(ciphers|macs|kexalgorithms) 'ciphers chacha20-poly1305@openssh.com,aes128-ctr,aes192-ctr,aes256-ctr,aes128-gcm@openssh.com,aes256-gcm@openssh.com macs umac-64-etm@openssh.com,umac-128-etm@openssh.com,hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com,hmac-sha1-etm@openssh.com,umac-64@openssh.com,umac-128@openssh.com,hmac-sha2-256,hmac-sha2-512,hmac-sha1 kexalgorithms curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521,sntrup761x25519-sha512@openssh.com,diffie-hellman-group-exchange-sha256,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512,diffie-hellman-group14-sha256
- sshd -T
- print the effective config sshd would run with, after defaults and includes
- sshd -t
- syntax-check the config only; prints nothing when it's valid
These are OpenSSH 8.9's defaults on Ubuntu 22.04, because nothing in sshd_config sets the three directives. They are good crypto. chacha20-poly1305 and curve25519 are excellent. But a FIPS 140-3 baseline such as the Ubuntu 22.04 STIG accepts only NIST-approved algorithms: AES in CTR or GCM mode, HMAC-SHA2 MACs, and NIST-curve ECDH or DH group exchange with SHA-256. chacha20, curve25519, umac and the sntrup hybrid all fail that check, and so does hmac-sha1. -T must run as root, because sshd reads its host keys. Example — not runnable on your lab box
sudo sed -i -E '/^(Ciphers|MACs|KexAlgorithms) /d' /etc/ssh/sshd_config printf 'Ciphers aes256-gcm@openssh.com,aes256-ctr,aes128-gcm@openssh.com,aes128-ctr\nMACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,hmac-sha2-512,hmac-sha2-256\nKexAlgorithms ecdh-sha2-nistp384,ecdh-sha2-nistp256,diffie-hellman-group-exchange-sha256\n' | sudo tee -a /etc/ssh/sshd_config >/dev/null sudo sshd -t && sudo sshd -T | grep -E '^(ciphers|kexalgorithms) ' sudo systemctl restart sshciphers aes256-gcm@openssh.com,aes256-ctr,aes128-gcm@openssh.com,aes128-ctr kexalgorithms ecdh-sha2-nistp384,ecdh-sha2-nistp256,diffie-hellman-group-exchange-sha256
Delete any existing lines first, then write the approved lists. Then the same ritual as any sshd change: sshd -t to syntax-check (a typo here stops sshd from starting), sshd -T to prove what will be enforced, and a restart. Keep your lifeline session open, as in the callout above. A client that only speaks the removed algorithms will now fail with 'no matching cipher found', and that is the point of the control. On RHEL the same lists are usually set system-wide with update-crypto-policies --set FIPS rather than in sshd_config. Check yourself
Why does PermitRootLogin no improve security even though admins can still become root via sudo?
Filesystem and kernel hardening
Run the risky-file audits and set the sysctl knobs that close escalation paths
Suppose an attacker gets some foothold — a compromised web app, a stolen user password. The next layers decide whether that foothold becomes root. Filesystem hardening is about the two things a foothold needs: places to run code and privileged programs to abuse. The classic audit hunts three kinds of risky file:
sudo find / -xdev -perm -4000 -type f 2>/dev/null | head -6 sudo find / -xdev -type f -perm -0002 2>/dev/null sudo find / -xdev \( -nouser -o -nogroup \) 2>/dev/null/usr/bin/passwd /usr/bin/sudo /usr/bin/mount /usr/bin/su /usr/bin/chage /usr/bin/newgrp
- -perm -4000
- SUID bit set — runs as the file's owner (root, usually)
- -perm -0002
- world-writable — anyone may modify
- -nouser / -nogroup
- owner/group no longer exists in passwd/group
- -xdev
- stay on this filesystem — skip /proc, network mounts
Audit one: SUID binaries (-perm -4000) — each runs AS ROOT for whoever invokes it. This list is legitimate (passwd must edit /etc/shadow), but the audit's value is knowing your baseline: a NEW entry — /tmp/.hidden/sh with SUID? — is a planted backdoor. Audit two: world-writable files — configs or scripts anyone can modify are privilege escalation waiting to be scheduled. Audit three: orphaned files (owner deleted) — routine leftovers sometimes, attacker artifacts other times. -xdev keeps find on local filesystems. mkdir -p home/ghost && sudo chown 4242 home/ghost uid=$(stat -c %u home/ghost); getent passwd "$uid" >/dev/null || echo "home/ghost: owner uid $uid has no account"home/ghost: owner uid 4242 has no account
- stat -c %u
- Print the file's owner as a numeric UID (%U would print the name, or UNKNOWN for an orphan).
- getent passwd UID
- Resolve a UID (or name) through the account sources; non-zero exit means no such account.
What -nousermeans, one directory at a time. Files store a numeric UID, not a name; deleting an account leaves its UID on everything it owned.stat -c %ureads that number, andgetent passwd <uid>asks the account database whether anyone holds it: it prints the entry and exits 0 if so, prints nothing and exits non-zero if not. A home directory whose owner resolves to no account is an orphan, a classic offboarding leftover. It matters beyond tidiness: the next account created with that UID silently inherits the orphan's files. Record orphans first, then archive or remove them.echo 'net.ipv4.tcp_syncookies = 1' | sudo tee /etc/sysctl.d/99-hardening.conf sudo sysctl --system | tail -2net.ipv4.tcp_syncookies = 1 * Applying /etc/sysctl.d/99-hardening.conf ... net.ipv4.tcp_syncookies = 1
Kernel behaviour is tunable through sysctl, and a handful of knobs are standard hardening: tcp_syncookies=1 (keeps the box responsive under SYN floods), net.ipv4.conf.all.rp_filter=1 (drops packets with spoofed source addresses), kernel.randomize_va_space=2 (full ASLR — randomized memory layout makes exploit addresses guesses), kernel.dmesg_restrict=1 and kptr_restrict=2 (stop unprivileged users reading kernel addresses that make exploits easier). The persistence pattern matters as much as the knobs: files in /etc/sysctl.d/ apply at every boot; sysctl --system applies them NOW — runtime and persistent in one move, the same two-state discipline as everywhere else. Check yourself
Malware lands in /tmp on a server whose fstab mounts /tmp with noexec,nosuid,nodev. What can the malware still do, and what can't it?
Detect what prevention misses: fail2ban and auditd
Automate response to attacks and keep the forensic record
The fourth move: monitoring. However good the locks, you need to notice the person trying handles — and keep records for the day something works. Two tools split the job: fail2ban is the automated bouncer (watch the logs, ban repeat offenders); auditd is the security camera (record who touched what, tamper-evidently).
Example — not runnable on your lab box
sudo dnf install -y fail2ban && sudo systemctl enable --now fail2ban sudo fail2ban-client status sshdStatus for the jail: sshd |- Filter | |- Currently failed: 2 | `- Total failed: 47 `- Actions |- Currently banned: 1 `- Banned IP list: 203.0.113.42
fail2ban tails auth logs (/var/log/secure on RHEL) for repeated failures and firewall-bans the source IP for a cooldown. The status output is your dashboard: 47 failures against sshd today, one IP currently banned. Configure per-service 'jails' in /etc/fail2ban/jail.local ([sshd] enabled=true, maxretry=5, bantime=1h — never edit jail.conf; updates overwrite it, the same generated-file lesson as resolv.conf). With key-only SSH already set, brute force was futile — fail2ban makes it expensive and visible too. sudo auditctl -w /etc/sudoers -p wa -k sudoers-watch sudo ausearch -k sudoers-watch --start today | tail -4type=SYSCALL ... auid=1000 uid=0 comm="vi" exe="/usr/bin/vi" key="sudoers-watch" type=PATH ... name="/etc/sudoers" ...
- -w PATH -p wa
- watch a file for writes and attribute changes
- -k TAG
- label events for retrieval with ausearch -k
- auid
- the ORIGINAL login identity — survives su/sudo; the who-really-did-it field
auditd answers the question logs can't: WHO changed this file, WHEN, with WHAT? The watch rule flags writes (w) and attribute changes (a) on /etc/sudoers, tagged for retrieval. The ausearch hit decodes to: the user whose original login was uid 1000 (auid — audit identity survives sudo!), running as root, edited sudoers with vi. That auid is the forensic gold — it names the person behind the root session. Watch the short list of files whose modification is always significant: /etc/passwd, /etc/shadow, /etc/sudoers, /etc/ssh/sshd_config. Persist rules in /etc/audit/rules.d/ + augenrules --load; aureport --auth summarizes authentication history. Patching starts with triage: which installed packages are below the version an advisory says is fixed? On Debian and Ubuntu the installed version comes from
dpkg -lorapt list --installed, and the comparison has to follow Debian's version rules, where the numeric parts of a revision compare as numbers. `dpkg --compare-versions A lt B` does exactly that and answers through its exit status, so it slots straight into anifor a&&. (RPM systems answer the same question withrpmdev-vercmp, ordnf updateinfoagainst the vendor's advisories.)dpkg --compare-versions 8.9p1-3ubuntu0.9 lt 8.9p1-3ubuntu0.10 && echo 'installed is older: still vulnerable' printf '%s\n' 8.9p1-3ubuntu0.9 8.9p1-3ubuntu0.10 | sort | head -1installed is older: still vulnerable 8.9p1-3ubuntu0.10
- --compare-versions A lt B
- Exit 0 if version A is strictly lower than B under Debian ordering (also le, eq, ge, gt).
The first line asks dpkg whether the installed …ubuntu0.9is older than the fixed…ubuntu0.10. It is (9 < 10 as numbers), so the test exits 0 and the package is flagged. The second line shows why you never use string sorting for this:sortcompares character by character, sees1before9, and puts…ubuntu0.10first, as though it were the older version. A triage built on that sort would mark the patched host vulnerable and the vulnerable one patched.ltis strict, so a package installed at exactly the fixed version is not flagged.Check yourself
In an audit event, uid=0 but auid=1000. Who made the change?
Keys and certificates: issue a service cert and prove it's right
Mint a key and self-signed certificate with openssl, lock the key down, and verify the pair like an auditor
Any service that speaks TLS needs two files: a private key, which must stay secret, and a certificate, which binds the matching public key to a name and a validity window and is handed to every client. For an internal service a self-signed certificate is often enough (clients are told to trust it directly); public services get theirs signed by a CA, but the files and the checks are the same.
openssl req -x509 -newkey rsa:2048 -nodes -keyout svc.key -out svc.crt -days 365 -subj '/CN=app.internal'mints both in one command:-newkey rsa:2048generates the key,-nodesleaves it unencrypted so the service can start unattended,-subjsets the name without prompts, and-dayssets the validity. The example below does the same job in its two halves so each is visible:openssl genpkeymakes the key, andopenssl req -x509 -keyissues a self-signed certificate for it. Either way, harden the key file like any secret, owner-only (chmod 600), and verify three things before you deploy: the subject is the name clients will use, the cert isn't about to expire, and the cert actually matches the key. A mismatched pair is the classic TLS outage: the service refuses to start, or clients refuse the handshake.openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 > svc.key 2>/dev/null chmod 600 svc.key openssl req -x509 -new -key svc.key -days 365 -subj '/CN=app.internal' > svc.crt openssl x509 -in svc.crt -noout -subject openssl x509 -in svc.crt -noout -checkend 2592000 [ "$(openssl x509 -in svc.crt -noout -pubkey)" = "$(openssl pkey -in svc.key -pubout)" ] && echo 'cert matches key'subject=CN = app.internal Certificate will not expire cert matches key
- genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048
- Generate a 2048-bit RSA private key (unencrypted) on stdout.
- req -x509 -new -key KEY
- Issue a self-signed certificate for an existing key (req -x509 -newkey … -nodes does key and cert in one step).
- -subj '/CN=…'
- Set the certificate subject non-interactively.
- x509 -checkend N
- Exit 0 if the certificate is still valid N seconds from now, 1 if it will have expired.
- pkey -pubout
- Print the public key derived from a private key, for comparison with the certificate's.
The first three lines make the key, lock it to its owner, and issue a one-year certificate for it; the rest is the audit. -noout -subjectprints the certificate's name.-checkend 2592000asks whether it will still be valid 30 days (2,592,000 seconds) from now, and both prints and exits on the answer, so it works in scripts and monitoring. The match test extracts the public key from each file and compares them: the certificate's public key must be exactly the public half of the private key. In production the key lives in/etc/ssl/private/(mode 600) and the certificate in/etc/ssl/certs/, where services expect them.Check yourself
A service won't start after you installed a new certificate, and the log says the key values mismatch. The cert's subject and expiry are fine. What two outputs do you compare to confirm the cause?
Now do it — run a hardening review on the sandbox
Perform the read-only audits that begin every real hardening engagement
Every professional hardening engagement — CIS audit, pentest prep, new-server checklist — starts with the same read-only sweep you're about to run. For each command, don't just run it: deliver the verdict out loud, like you're writing the findings report. That habit is the skill.
ss -tlnp; systemctl list-unit-files --state=enabled | headExposure first: every listening port with its process, and everything set to start at boot. Verdict format: 'N listeners; justified: sshd. Questionable: …' — anything you can't justify goes on the findings list. sudo grep -E '^#?(PermitRootLogin|PasswordAuthentication)' /etc/ssh/sshd_config; ls -ld /tmpThe front door and the launchpad: are the two critical SSH settings hardened or commented defaults? Does /tmp end in rwt (sticky bit present)? Two of the highest-signal checks on any review, ten seconds total. sudo find / -xdev -perm -4000 -type f 2>/dev/null | wc -l; sudo find / -xdev -perm -4000 -type f 2>/dev/null | head -5The SUID census: count it, then eyeball the list. A stock system runs a few dozen, all in /usr/bin and /usr/sbin. The count is your baseline for next month's diff — and anything SUID living under /tmp, /home, or /var is a finding, immediately. sysctl net.ipv4.tcp_syncookies kernel.randomize_va_space 2>/dev/null; sudo chage -l root | head -2Kernel posture (want: syncookies 1, ASLR 2) and root's password aging. Review complete — you've walked exposure, front door, escalation paths, and kernel in four commands. That sweep, plus the verdicts you spoke, is a real (if compact) hardening assessment.
- Think attack surface: every service, port, login path, SUID binary, and writable script is a door. Four moves, by payoff: remove > restrict > strengthen > monitor — layered, because no single control is perfect.
- Audit exposure with ss -tlnp (justify every listener) and enabled units; disable --now the unneeded, mask (symlink to /dev/null) what must never run.
- SSH is the front door: PermitRootLogin no, PasswordAuthentication no (keys only), MaxAuthTries 3 — applied with the lifeline ritual: test keys first, keep a session open, verify fresh login before closing it.
- Close escalation paths: SUID/world-writable/orphan audits (find -perm -4000 …) with a monthly baseline diff; noexec,nosuid,nodev on /tmp; sysctl knobs (syncookies, rp_filter, ASLR) persisted in /etc/sysctl.d/.
- Reconcile who holds admin (getent group sudo against the approved list, gpasswd -d the rest) and resolve file owners with getent passwd: a UID with no account is an orphan the next new account would inherit.
- Keep credentials out of scripts: a secret belongs in a mode-600 root:root config the script sources (
. /etc/x.conf), never hardcoded in a world-readable script; a world-readable dump or backup dir is the same leak — chmod 600 the file, 700 the directory. - Detect and record: fail2ban jails auto-ban repeat offenders (configure in jail.local, not jail.conf); auditd watches sensitive files — auid names the human behind a root session. And patch: dnf update --security outranks everything glamorous; triage Debian versions with dpkg --compare-versions, never a string sort.
- TLS keys and certs: mint with openssl req -x509, chmod 600 the key, and verify subject, -checkend expiry and that the cert's public key matches the key's before you deploy.
| Area | Command / setting |
|---|---|
| Audit listeners | ss -tlnp (justify every line) |
| Disable / forbid service | systemctl disable --now X · mask X |
| SSH front door | PermitRootLogin no · PasswordAuthentication no (+ lifeline ritual) |
| Password aging | chage -M 90 -W 14 user · /etc/login.defs |
| Admin-group drift | getent group sudo · comm -23 vs approved · gpasswd -d user sudo |
| Risky-file audits | find -perm -4000 · -perm -0002 · -nouser · getent passwd UID |
| Scratch-dir lockdown | fstab: noexec,nosuid,nodev on /tmp · sticky bit |
| Kernel knobs | /etc/sysctl.d/*.conf + sysctl --system |
| Auto-ban attackers | fail2ban jail.local · fail2ban-client status sshd |
| Forensic record | auditctl -w FILE -p wa -k tag · ausearch -k (read auid) |
| Patch triage (Debian) | dpkg --compare-versions INSTALLED lt FIXED |
| Service certificate | openssl req -x509 … · x509 -subject / -checkend · pubkey match |
