Signal9Signal9 — home
online
all lessons

System Hardening Essentials

Intermediate45 min7 steps
What you'll learn
  • 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
  1. 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. /tmp will 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?

  2. 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 -tlnp
    State   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.socket
    Removed "/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?

  3. 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: -f1
    Last 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) or wheel (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-admins
    demo-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,4 shows the group name and its members. The reconcile is a set difference: split the member list one per line, sort it, and comm -23 keeps the names that appear only in the membership and not in approved.txt (which must be sorted too), here demo-raj. gpasswd -d removes exactly that member, and the re-read confirms only the approved admin is left. Run the same pipeline against sudo or wheel on 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_config do 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) and KexAlgorithms (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 -T prints 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 ssh
    ciphers 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?

  4. 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 -nouser means, 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 %u reads that number, and getent 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 -2
    net.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?

  5. 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 sshd
    Status 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 -4
    type=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 -l or apt 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 an if or a &&. (RPM systems answer the same question with rpmdev-vercmp, or dnf updateinfo against 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 -1
    installed 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.9 is 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: sort compares character by character, sees 1 before 9, and puts …ubuntu0.10 first, as though it were the older version. A triage built on that sort would mark the patched host vulnerable and the vulnerable one patched. lt is 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?

  6. 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:2048 generates the key, -nodes leaves it unencrypted so the service can start unattended, -subj sets the name without prompts, and -days sets the validity. The example below does the same job in its two halves so each is visible: openssl genpkey makes the key, and openssl req -x509 -key issues 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 -subject prints the certificate's name. -checkend 2592000 asks 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?

  7. 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 | head
    Exposure 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 /tmp
    The 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 -5
    The 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 -2
    Kernel 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.
Recap
  • 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 → key action
AreaCommand / setting
Audit listenersss -tlnp (justify every line)
Disable / forbid servicesystemctl disable --now X · mask X
SSH front doorPermitRootLogin no · PasswordAuthentication no (+ lifeline ritual)
Password agingchage -M 90 -W 14 user · /etc/login.defs
Admin-group driftgetent group sudo · comm -23 vs approved · gpasswd -d user sudo
Risky-file auditsfind -perm -4000 · -perm -0002 · -nouser · getent passwd UID
Scratch-dir lockdownfstab: noexec,nosuid,nodev on /tmp · sticky bit
Kernel knobs/etc/sysctl.d/*.conf + sysctl --system
Auto-ban attackersfail2ban jail.local · fail2ban-client status sshd
Forensic recordauditctl -w FILE -p wa -k tag · ausearch -k (read auid)
Patch triage (Debian)dpkg --compare-versions INSTALLED lt FIXED
Service certificateopenssl req -x509 … · x509 -subject / -checkend · pubkey match