cheat sheet

Security Fundamentals

OS-level security primitives every operator should know: users and groups, file permissions, Linux capabilities, SUID/SGID, mandatory access control (SELinux, AppArmor), sandboxing concepts, least privilege, and encryption at rest vs in transit.

Security Fundamentals — Users, Permissions, Capabilities, MAC, Sandboxing

What it is

Operating system security is the set of OS-enforced primitives that decide what a process can do — which files it can read, which syscalls it can invoke, which network it can touch, whose identity it acts under. The classic Unix model (users + groups + read/write/execute bits) is the foundation; every modern OS layers more on top: Linux capabilities slice up root's power, mandatory access control (SELinux, AppArmor) overlays a separate policy, namespaces and seccomp drive containers, and secure boot + full-disk encryption extend the trust boundary down to the firmware. Reach for this article when you need the canonical taxonomy of these mechanisms — what each one defends against, where they overlap, and how they compose in a real production deployment.

The threat model

Every security decision has a threat model — the attacker's assumed capabilities — even if it's unstated. Useful security work always starts with naming the threats; controls without threats become cargo-culted ritual.

ThreatExamples
Curious local userSnooping at other users' files, reading process environment
Privilege escalationLocal user wants root; container wants host
Remote unauthenticated attackerInternet-facing service exploit
Remote authenticated attackerCompromised account; insider
Physical accessStolen laptop, datacentre theft
Supply-chainMalicious dependency, compromised build
Side channelsSpectre, timing, EM, power analysis

For each, ask: what does the OS provide? A stolen-laptop threat is met by full-disk encryption (FDE), not file permissions; a remote exploit threat by sandboxing + capabilities, not just by chmod 600; a side-channel threat by microcode + kernel mitigations, not by anything you can chmod.

Principle of least privilege

The single most important security maxim: every component runs with the minimum privileges it needs. A web server doesn't need to write /etc; a backup script doesn't need root to read your home directory if it owns the files; a process that accepts internet traffic shouldn't be able to load kernel modules. Least privilege is implemented at every layer below — that's what the rest of this article is about.

bash
# Quick audit: services running as root
ps -eo user,pid,comm --sort=user | awk '$1=="root"' | head

Output:

text
root     1     systemd
root    18     ksoftirqd/0
root   423     systemd-journal
root   611     rsyslogd
root   800     sshd
root  1234     nginx: master process

The nginx: master shouldn't surprise you (it forks workers as www-data), but rsyslogd and other daemons running as root are worth a hard look — switching them to a service-specific user is usually a one-liner in the systemd unit.

Users and groups

Every file, process, and socket carries a user ID (UID) and at least one group ID (GID). The kernel uses them as the basis for almost every access decision. A user can belong to many groups simultaneously (the supplementary groups); their primary group is the one new files inherit.

bash
# Show your IDs
id

Output:

text
uid=1000(alice) gid=1000(alice) groups=1000(alice),27(sudo),998(docker)
FileWhat it stores
/etc/passwdUsername → UID, GID, home, login shell
/etc/shadowUsername → password hash (root-only)
/etc/groupGroup name → GID, member list
/etc/gshadowGroup password (rarely used)
bash
# Create a service account with no login and no home
sudo useradd --system --no-create-home --shell /usr/sbin/nologin myapp
getent passwd myapp

Output:

text
myapp:x:998:998::/nonexistent:/usr/sbin/nologin

Reserve UIDs below 1000 (or below 500 on RHEL) for system accounts; humans get 1000+. Use --system with useradd for service accounts so they're allocated from the low range and don't appear in getent passwd | awk -F: '$3>=1000'.

sudo and its cousins

sudo lets a user run specific commands as another user (usually root) after authenticating. The policy is in /etc/sudoers and /etc/sudoers.d/* — never edit directly, always with visudo so syntax errors don't lock you out.

bash
# Allow alice to restart nginx without a password — narrow, explicit
sudo visudo -f /etc/sudoers.d/nginx-alice

Output: (none — opens editor, validates syntax on save)

text
alice ALL=(root) NOPASSWD: /bin/systemctl restart nginx.service, /bin/systemctl reload nginx.service
bash
# Audit who can sudo to what
sudo -l -U alice

Output:

text
User alice may run the following commands on this host:
    (root) NOPASSWD: /bin/systemctl restart nginx.service, /bin/systemctl reload nginx.service
    (ALL : ALL) ALL

[!WARN] ALL=(ALL:ALL) ALL is the equivalent of "give this user a permanent root key". For day-to-day operators, restrict to specific commands. For root-required automation, use doas (OpenBSD-style, simpler) or short-lived credentials (HashiCorp Vault, AWS STS) — not a wildcard sudo entry.

su, doas, polkit

su switches identities outright; doas (from OpenBSD, packaged on most distros) is a smaller, simpler sudo alternative; polkit (PolicyKit) is the desktop-style framework for fine-grained "may this caller perform this action?" decisions used by systemd, NetworkManager, and udisks.

bash
# Reload a service via polkit
busctl call org.freedesktop.systemd1 /org/freedesktop/systemd1 \
  org.freedesktop.systemd1.Manager ReloadUnit ss "nginx.service" "replace"

Output:

text
o "/org/freedesktop/systemd1/job/1842"

File permissions

Linux file permissions are a 12-bit mode: 9 permission bits (read/write/execute for owner/group/other) plus 3 special bits (setuid, setgid, sticky). The basics are covered in depth in the permissions cheatsheet; here we focus on the security-relevant patterns.

bash
ls -l ~/.ssh

Output:

text
drwx------ 2 alice alice 4096 May 25 09:14 .
-rw------- 1 alice alice 3389 May 24 10:00 id_ed25519
-rw-r--r-- 1 alice alice  747 May 24 10:00 id_ed25519.pub
-rw-r--r-- 1 alice alice 2204 May 25 09:14 known_hosts

The SSH client refuses to use a private key with looser permissions than 0600; the directory must be 0700. These are not arbitrary numbers — sshd's hard refusal is a deliberate guard.

SUID and SGID

The setuid bit on a binary makes it execute as the file's owner, not the invoker. The setgid bit does the same for the group. Setgid on a directory causes new files to inherit the directory's group — useful for shared workspaces.

bash
# Common setuid binaries on a Linux system
sudo find / -xdev -perm -4000 -type f -printf "%M %u %g %p\n" 2>/dev/null | head

Output:

text
-rwsr-xr-x root root /usr/bin/passwd
-rwsr-xr-x root root /usr/bin/sudo
-rwsr-xr-x root root /usr/bin/chsh
-rwsr-xr-x root root /usr/bin/su
-rwsr-xr-x root root /usr/bin/mount
-rwsr-xr-x root root /usr/bin/newgrp

Every setuid-root binary is a privilege-escalation surface. The kernel does not honour setuid on shell scripts (security choice from the 1980s); only on compiled binaries.

[!WARN] Audit setuid binaries quarterly. A new one appearing in your inventory is a red flag — it might be a deliberately installed tool, or it might be malware. Watch /etc/cron.daily and /etc/cron.weekly for scripts that re-create them.

Linux capabilities

Capabilities split root's monolithic power into ~40 discrete units. Each one (CAP_NET_BIND_SERVICE, CAP_DAC_OVERRIDE, CAP_SYS_ADMIN, etc.) grants a single ability; processes can be granted some without being granted all. Capabilities replace setuid-root for almost every modern daemon — give the binary the one thing it needs and nothing else.

bash
# Inspect a process's capabilities
cat /proc/self/status | grep ^Cap

Output:

text
CapInh: 0000000000000000
CapPrm: 0000000000000000
CapEff: 0000000000000000
CapBnd: 000001ffffffffff
CapAmb: 0000000000000000
SetMeaning
CapInhInheritable across exec
CapPrmPermitted — capabilities you have available
CapEffEffective — capabilities currently in force
CapBndBounding — upper bound for the process tree
CapAmbAmbient — inherited by a child after exec
bash
# Decode the bitmap
capsh --decode=000001ffffffffff | head

Output:

text
0x000001ffffffffff=cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,
cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_linux_immutable,
cap_net_bind_service,cap_net_broadcast,cap_net_admin,...

Granting a capability to a binary

The classic case: let nginx bind to port 80 without running as root.

bash
# Drop the setuid bit; grant exactly one capability
sudo setcap 'cap_net_bind_service=+ep' /usr/sbin/nginx
getcap /usr/sbin/nginx

Output:

text
/usr/sbin/nginx cap_net_bind_service=ep

=ep means "permitted + effective" — the capability is available and active when the binary executes.

bash
# Drop a capability
sudo setcap -r /usr/sbin/nginx

Output: (none — exits 0 on success)

Capability-aware syscall reference

The most-used capabilities and what they unlock:

CapabilityLets you
CAP_NET_BIND_SERVICEBind to ports < 1024
CAP_NET_ADMINModify routing tables, interfaces, firewall
CAP_NET_RAWOpen raw sockets (ping, tcpdump)
CAP_SYS_ADMINMount filesystems, set hostname — extremely broad ("the new root")
CAP_SYS_PTRACEAttach to other processes' memory
CAP_SYS_NICEChange scheduling priority
CAP_DAC_OVERRIDEBypass file read/write permission checks
CAP_DAC_READ_SEARCHBypass file read permission checks
CAP_CHOWNChange file ownership
CAP_KILLSend signals to any process
CAP_SETUID / CAP_SETGIDsetuid() / setgid() to any ID
CAP_AUDIT_WRITEWrite to kernel audit log

CAP_SYS_ADMIN is the new root. Granting it to a container or daemon effectively undoes the rest of the sandbox. If a container needs it, the container is probably miscofigured — break the workload into a privileged helper and a least-privilege main.

Mandatory access control (MAC)

The classic Unix model is discretionary — the file's owner controls its permissions, and a process running as that owner can do whatever it wants. Mandatory access control overlays a policy that even the owner cannot override: a process is labelled, files and sockets are labelled, and an external policy decides which labels may interact. SELinux (RHEL, Fedora, Android) and AppArmor (Debian, Ubuntu, SUSE) are the two major Linux implementations.

SELinux

SELinux uses type enforcement: every process has a type (e.g. httpd_t), every file has a type (httpd_sys_content_t), and the policy says which types may access which other types in which ways. Policy is compiled and ships in the distro; you tune it with booleans and file contexts.

bash
# Show your current SELinux mode (enforcing / permissive / disabled)
getenforce
sestatus

Output:

text
Enforcing

SELinux status:                 enabled
SELinuxfs mount:                /sys/fs/selinux
SELinux root directory:         /etc/selinux
Loaded policy name:             targeted
Current mode:                   enforcing
bash
# Inspect labels on files and processes
ls -Z /var/www/html
ps -eZ | head

Output:

text
unconfined_u:object_r:httpd_sys_content_t:s0  index.html
unconfined_u:object_r:httpd_sys_content_t:s0  about.html

LABEL                           PID TTY      TIME CMD
system_u:system_r:init_t:s0       1 ?    00:00:01 systemd
system_u:system_r:syslogd_t:s0  611 ?    00:00:00 rsyslogd
system_u:system_r:httpd_t:s0   1234 ?    00:00:01 nginx
bash
# Allow a non-default content directory for nginx
sudo semanage fcontext -a -t httpd_sys_content_t '/opt/myapp/static(/.*)?'
sudo restorecon -Rv /opt/myapp/static

# Toggle a boolean (read user home dirs)
sudo setsebool -P httpd_read_user_content on

# Investigate denials
sudo ausearch -m AVC -ts recent | head
sudo journalctl --grep='AVC'

Output:

text
Relabeled /opt/myapp/static from unconfined_u:object_r:default_t:s0 to system_u:object_r:httpd_sys_content_t:s0
Relabeled /opt/myapp/static/index.html from unconfined_u:object_r:default_t:s0 to system_u:object_r:httpd_sys_content_t:s0
type=AVC msg=audit(1716638400.234:1521): avc:  denied  { read } for  pid=1234 comm="nginx" name="report.txt" dev="dm-0" ino=98231 scontext=system_u:system_r:httpd_t:s0 tcontext=unconfined_u:object_r:user_home_t:s0 tclass=file permissive=0

[!WARN] When SELinux blocks a service, don't run setenforce 0 and walk away — that disables SELinux globally until reboot. Use audit2why < /var/log/audit/audit.log | head to understand the denial, then either semanage fcontext to fix the label or audit2allow to generate a targeted policy module. Disabling SELinux permanently is rarely the right answer on RHEL.

AppArmor

AppArmor uses path-based profiles instead of type labels. A profile lists which paths a process may read, write, or execute, plus which capabilities and network operations it may perform.

bash
# Show loaded profiles
sudo aa-status

Output:

text
apparmor module is loaded.
22 profiles are loaded.
22 profiles are in enforce mode.
   /usr/sbin/cups-browsed
   /usr/sbin/cupsd
   /usr/sbin/dhclient
   /usr/sbin/nginx
   ...
3 processes are unconfined but have a profile defined.
bash
# Inspect a profile
sudo cat /etc/apparmor.d/usr.sbin.nginx | head -20

Output:

text
#include <tunables/global>
profile nginx /usr/sbin/nginx {
  #include <abstractions/base>
  #include <abstractions/nis>
  capability net_bind_service,
  capability setgid,
  capability setuid,
  /etc/nginx/** r,
  /var/log/nginx/* w,
  /var/www/** r,
  ...
}
bash
# Put a profile in complain mode to log denials without blocking
sudo aa-complain /usr/sbin/nginx
# Re-enable enforcement
sudo aa-enforce /usr/sbin/nginx

Output:

text
Setting /usr/sbin/nginx to complain mode.
Setting /usr/sbin/nginx to enforce mode.
PropertySELinuxAppArmor
StyleType enforcement (labels)Path-based
Policy ships in distroYes (RHEL, Fedora)Yes (Debian, Ubuntu, SUSE)
Toolingsemanage, audit2allow, setseboolaa-status, aa-complain, aa-enforce
Learning curveSteepGentler
GranularityVery finePractical

Sandboxing

Sandboxing is the general technique of running code in a constrained environment that can't reach what it shouldn't. Linux has a stack of sandboxing primitives that compose:

MechanismConstrains
chrootFilesystem root
Namespaces (mount, pid, net, ipc, uts, user, cgroup, time)Per-process views of kernel resources
cgroups v2CPU, memory, I/O, PID counts
seccomp-bpfAllowed syscalls
CapabilitiesDiscrete root powers
MAC (SELinux/AppArmor)Mandatory policy on label/path
pivot_rootAtomic switch to a new filesystem root
User namespacesMap UIDs so root inside is not root outside

Containers (Docker, Podman, Kubernetes) combine all of these; systemd uses many of them too via service hardening directives.

chroot

The oldest sandbox: change the apparent root of the filesystem for a process. Modern code uses namespaces instead, but chroot is still useful for distro-installer chroots and emergency recovery.

bash
# Enter a debian-rootfs directory
sudo chroot /mnt/debian /bin/bash

Output:

text
root@myhost:/#

[!WARN] Plain chroot is not a security boundary — a process with CAP_SYS_CHROOT can escape. Use it for environment isolation (build chroots, debootstrap), not for adversarial sandboxing. For real isolation, use a container runtime (Podman, Docker, systemd-nspawn) which combines namespaces + capabilities drop + seccomp.

Namespaces

Each namespace gives the process its own view of one kernel resource. A container is typically all of these at once.

NamespaceIsolates
mountMount points (filesystem tree)
pidProcess IDs (PID 1 inside, real PID outside)
netInterfaces, routing tables, firewall, sockets
ipcSysV IPC, POSIX message queues
utsHostname, domain name
userUID/GID mappings — root inside ≠ root outside
cgroupCgroup root view
timeSystem clock offsets (since Linux 5.6)
bash
# Inspect this shell's namespaces
ls -l /proc/self/ns/

Output:

text
lrwxrwxrwx 1 alice alice 0 May 25 09:14 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 alice alice 0 May 25 09:14 ipc    -> 'ipc:[4026531839]'
lrwxrwxrwx 1 alice alice 0 May 25 09:14 mnt    -> 'mnt:[4026531840]'
lrwxrwxrwx 1 alice alice 0 May 25 09:14 net    -> 'net:[4026531992]'
lrwxrwxrwx 1 alice alice 0 May 25 09:14 pid    -> 'pid:[4026531836]'
lrwxrwxrwx 1 alice alice 0 May 25 09:14 user   -> 'user:[4026531837]'
lrwxrwxrwx 1 alice alice 0 May 25 09:14 uts    -> 'uts:[4026531838]'
bash
# Quick container-style sandbox using unshare
sudo unshare --pid --fork --mount-proc --uts --ipc --net /bin/bash
hostname container-test          # only changes inside
ip addr                          # only loopback exists
exit

Output:

text
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

seccomp

Seccomp (Secure Computing) filters which syscalls a process may invoke. Modern seccomp-bpf accepts a small BPF program that inspects the syscall number and arguments and returns ALLOW, KILL, or RETURN-ERRNO. Docker and Kubernetes ship default seccomp profiles that block ~40 of the ~330 Linux syscalls — including the obscure ones used in container escapes.

bash
# Run a command with a seccomp filter via firejail
firejail --seccomp curl https://example.com

Output:

text
Reading profile /etc/firejail/curl.profile
Parent pid 8312, child pid 8313
Child process initialized in 28.43 ms
Parent is shutting down, bye...

In systemd unit files:

ini
[Service]
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM

@system-service is a systemd-defined set: everything a normal service needs, nothing dangerous (no mount, pivot_root, kexec_load, …). See systemd unit files for the full hardening stack.

systemd sandboxing directives

A modern Linux service should layer most of these on top of running as a non-root user:

ini
[Service]
User=myapp
Group=myapp
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallFilter=@system-service
CapabilityBoundingSet=
ReadWritePaths=/var/lib/myapp /var/log/myapp
bash
# Score the hardening (0=worst, 10=best)
sudo systemd-analyze security myapp.service

Output:

text
Overall exposure level for myapp.service: 1.4 OK

Anything above 5 is "needs work"; well-hardened services score below 2.

Authentication primitives

The OS sits underneath the application's authentication, but it provides several primitives applications rely on.

PrimitiveUsed for
PAM (Pluggable Authentication Modules)Pluggable auth stack — pam_unix, pam_ldap, pam_sss, pam_u2f, pam_oath
NSS (Name Service Switch)Where to look up users/groups (files, LDAP, SSSD)
KerberosSingle-sign-on tickets, strong cryptographic auth
PKI / TLSServer (and optionally client) certificates
SSH keysAsymmetric key pairs used by sshd
TPM / Secure EnclaveHardware-backed key storage
FIDO2 / WebAuthn / U2FHardware authenticators (YubiKey)
PASETO / JWTApplication-layer tokens
bash
# Inspect the PAM stack for ssh logins
sudo cat /etc/pam.d/sshd | head

Output:

text
#%PAM-1.0
auth       required    pam_sepermit.so
auth       substack    password-auth
auth       include     postlogin
account    required    pam_nologin.so
account    include     password-auth
password   include     password-auth
session    required    pam_selinux.so close
session    required    pam_loginuid.so

Encryption at rest vs in transit

Two parallel concerns; the protections, threats, and tooling are different.

At rest

Encryption at rest protects data on disks against physical theft and decommissioned-disk recovery. The blast radius is the disk; if an attacker has root on a running system, encryption at rest doesn't help.

LayerLinuxmacOSWindows
Full-diskLUKS / dm-cryptFileVault (APFS)BitLocker
Filesystemfscrypt (ext4, F2FS)APFS nativeEFS
Per-filegocryptfs, age, gpg
Cloud volumeEBS encryption, GCE persistent diskAzure Disk Encryption
Applicationclient-side at the app layer
bash
# Inspect a LUKS volume
sudo cryptsetup luksDump /dev/sda3 | head

Output:

text
LUKS header information
Version:        2
Epoch:          5
Metadata area:  16384 [bytes]
UUID:           1a2b3c4d-...
Label:          (no label)
Subsystem:      (no subsystem)
Flags:          (no flags)

Keyslots:
  0: luks2
        Key:        512 bits
        Priority:   normal
        Cipher:     aes-xts-plain64

Full-disk encryption is necessary but not sufficient. A stolen, powered-off laptop is protected; a stolen, suspended-to-RAM laptop with a guessable account password is not. Configure suspend → hibernate (zero memory) for high-value devices, or use TPM + PIN with anti-hammering.

In transit

Encryption in transit protects data while it crosses a network. The big mechanisms:

MechanismProtects
TLS 1.2 / 1.3HTTPS, SMTP, IMAP, LDAP, gRPC, almost everything over TCP
SSHInteractive shells, file transfer, port forwarding
WireGuard / IPsecVPN tunnels (layer 3)
DNS over TLS / HTTPSDNS queries
mTLSMutual auth — both ends present a certificate
Noise protocolCustom protocols (Wireguard, Lightning)
bash
# What TLS version did this connection negotiate?
openssl s_client -connect example.com:443 -servername example.com < /dev/null 2>&1 | grep -E 'Protocol|Cipher'

Output:

text
Protocol  : TLSv1.3
Cipher    : TLS_AES_256_GCM_SHA384

[!WARN] Don't roll your own crypto. Use libraries (OpenSSL, BoringSSL, libsodium, age) and battle-tested protocols (TLS, Noise, SSH). The chance of getting it right from scratch is microscopic; the chance of getting it wrong is essentially 100%.

Audit and logging

Logging is your forensic record. The Linux audit subsystem records syscall-level events; the systemd journal records what services say; last/lastlog records logins. Centralise to a log host you do not also expose — an attacker with root locally will rewrite logs.

bash
# Audit a specific binary for any execution
sudo auditctl -w /usr/bin/sudo -p x -k sudo-exec
# Then read events
sudo ausearch -k sudo-exec | head

# systemd journal
journalctl --since "1 hour ago" -p err
journalctl _COMM=sshd --since today

# Login history
last -20
lastb -10                   # failed logins

Output:

text
time->Sun May 25 10:42:18 2026
type=SYSCALL msg=audit(1716638538.211:842): arch=c000003e syscall=59 success=yes exit=0 a0=55c4d2 a1=55c4e8 a2=55c500 items=2 ppid=8420 pid=8421 auid=1000 uid=0 gid=0 euid=0 comm="sudo" exe="/usr/bin/sudo" key="sudo-exec"
May 25 10:41:02 myhost sshd[8401]: Accepted publickey for alice from 10.0.0.5 port 51420 ssh2
alice   pts/0    10.0.0.5         Sun May 25 10:41   still logged in
alice   pts/0    10.0.0.5         Sun May 25 09:02 - 09:48  (00:46)

Common pitfalls

  1. chmod 777 "fix" — opens the door for thirty seconds and a security incident an hour later. Use namei -l to walk the path and find the actual permission gap.
  2. Setuid script — silently ignored by the Linux kernel. Use sudo with an explicit rule or a small C wrapper.
  3. sudo ALL=(ALL) NOPASSWD: ALL — turns sudo into a transparent door. Always restrict to specific commands.
  4. Disabling SELinux to "fix" a service — usually the wrong answer. audit2why and semanage fcontext solve it for real.
  5. Container running as root — defeats most of the container's security. Use USER in the Dockerfile, --user at run time, or rootless containers (Podman).
  6. Adding CAP_SYS_ADMIN to a container — equivalent to root. Identify the specific need (mount? namespaces? bpf?) and add that capability instead.
  7. Storing secrets in environment variables that systemd logsEnvironmentFile= is OK; Environment= plus journalctl exposure is not. Use LoadCredential= (systemd 250+) or systemd-creds for proper handling.
  8. TLS terminated at a load balancer with plain HTTP inside the VPC — fine until an attacker is inside the VPC. Mutual TLS or service-mesh sidecars solve it.
  9. SSH password auth still on for root — disable both: PermitRootLogin no and PasswordAuthentication no. Use keys, ideally hardware-backed.
  10. Full-disk encryption with no recovery key escrow — locks you out as effectively as an attacker. Escrow to a secure location (sealed envelope in a safe, secret manager).
  11. Auditing locally only — the first thing an attacker with root does is edit /var/log/. Ship logs off-box in real time.
  12. Assuming "behind a firewall" is enough — defence in depth. Every internal service should still authenticate, encrypt, and run least-privilege.

Real-world recipes

Lock down a new VPS in five minutes

A bare-minimum hardening pass for an internet-facing host.

bash
# 1. Update everything
sudo apt update && sudo apt upgrade -y

# 2. Make a user; disable root SSH
sudo useradd -m -s /bin/bash -G sudo alice
sudo passwd alice                            # strong password
sudo mkdir -p /home/alice/.ssh
sudo chown alice:alice /home/alice/.ssh
sudo chmod 700 /home/alice/.ssh

# Paste your public key
echo "ssh-ed25519 AAAA... alice@laptop" | sudo tee /home/alice/.ssh/authorized_keys
sudo chmod 600 /home/alice/.ssh/authorized_keys
sudo chown alice:alice /home/alice/.ssh/authorized_keys

# 3. Tighten sshd
sudo sed -i 's/^#*PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
sudo sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
sudo systemctl restart ssh

# 4. Firewall
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable

# 5. Unattended-upgrades for security patches
sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades

Output: (none — exits 0 on success)

Audit setuid binaries

Quarterly inventory check.

bash
sudo find / -xdev -perm -4000 -type f -printf "%M %u %g %p\n" 2>/dev/null | tee /tmp/suid-$(date +%Y%m%d).txt
diff /tmp/suid-baseline.txt /tmp/suid-$(date +%Y%m%d).txt

Output (if nothing changed): (none — exits 0 on success)

If diff produces output, a new SUID binary has appeared — investigate before doing anything else.

Strip capabilities from a binary

For "this service needs root only because of one syscall" cases.

bash
# Old way: setuid root
sudo chown root:root /usr/local/bin/myserver
sudo chmod 4755 /usr/local/bin/myserver

# New way: capability + non-root user
sudo chown root:root /usr/local/bin/myserver
sudo chmod 755 /usr/local/bin/myserver
sudo setcap 'cap_net_bind_service=+ep' /usr/local/bin/myserver
getcap /usr/local/bin/myserver

Output:

text
/usr/local/bin/myserver cap_net_bind_service=ep

Sandbox a script that scrapes the web

Drop network restrictions on a tool you don't fully trust.

bash
sudo systemd-run --user --scope \
  -p ProtectSystem=strict \
  -p ProtectHome=yes \
  -p PrivateTmp=yes \
  -p NoNewPrivileges=yes \
  -p RestrictAddressFamilies=AF_INET:AF_INET6 \
  -p SystemCallFilter=@system-service \
  -p CapabilityBoundingSet= \
  ./scrape.py

Output: (none — exits 0 on success)

Investigate a denied AVC

When a service fails on a SELinux system and you suspect policy.

bash
sudo ausearch -m AVC -ts recent | head
sudo ausearch -m AVC -ts recent | audit2why | head

Output (audit2why):

text
type=AVC msg=audit(1716618842.012:42): avc: denied { read } for pid=1234 comm="nginx" name="config.toml" dev="nvme0n1p2"
Was caused by:
The boolean httpd_read_user_content was set incorrectly.
Allow access by executing:
# setsebool -P httpd_read_user_content 1

Generate and verify a checksum chain for distribution

Defends against tampering for files downloaded out-of-band.

bash
# Generator side
sha256sum *.tar.gz > SHA256SUMS
gpg --detach-sign --armor SHA256SUMS

# Verifier side
gpg --verify SHA256SUMS.asc SHA256SUMS
sha256sum -c SHA256SUMS

Output:

text
gpg: Good signature from "Alice Dev <alice@example.com>"
release-1.0.tar.gz: OK

Tips

Run systemd-analyze security against every service on a box, weekly. The scores reveal which units are still running as root with no sandboxing — those are exactly where you'll spend the next afternoon if anything goes wrong.

lynis audit system is a one-shot, full-system hardening audit. It's not the answer to every question, but the first run on a fresh host will surface dozens of low-hanging fruit (default passwords, missing AIDE, unsigned packages).

[!WARN] Security advice has an expiry date. The mitigations that worked in 2020 are not all the right ones today. Re-read your distro's security wiki and CVE feed every year, and update playbooks accordingly.