VPS Security Audit: Turning Checklist Findings Into Automated Remediation

A security audit that ends with a PDF of findings is worthless if nobody patches anything. The useful audit is one where every finding maps to a command you can run and verify. This walkthrough runs against a Debian 12 server and produces machine-checkable output at every step, so you finish with a host whose state you can re-verify on demand rather than a list of regrets.

Run this on a freshly provisioned VPS before it serves traffic, and re-run the verification block after any major change. The same discipline applies to the hardening workflow described on the main site.

Step 1: Establish the baseline

Before changing anything, capture what is actually running. Unmanaged boxes accumulate surprises — a leftover service, an old package with a known CVE, a world-writable file in /etc.

apt-get update && apt list --upgradable 2>/dev/null | tail -n +2
systemctl list-units --type=service --state=running --no-pager
ss -tulpn
find /etc -xdev -type f -perm -o+w 2>/dev/null
lastb | head -20

The upgradable list is your CVE exposure, the running-services list is your attack surface, the listening-sockets list is what is reachable, and the world-writable find is your local privilege-escalation surface.

Step 2: Verify SSH is not the weak link

sshd -T | grep -Ei 'permitrootlogin|passwordauthentication|pubkeyauth|x11forward|maxauthtries|allowtcpforwarding'

Expected hardened values:

PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
X11Forwarding no
MaxAuthTries 3
AllowTcpForwarding no

sshd -T reports the effective config after all includes are merged, which is what actually matters — editing sshd_config.d/*.conf and reading the file back proves nothing. If PasswordAuthentication yes appears, fix it in a drop-in and reload, keeping your existing session open so you do not lock yourself out.

Step 3: Close the firewall to a default-deny posture

cat <<'EOF' > /etc/nftables.conf
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
  chain input {
    type filter hook input priority 0; policy drop;
    ct state established,related accept
    ct state invalid drop
    iif lo accept
    ip protocol icmp accept
    tcp dport { 22, 80, 443 } accept
    tcp flags syn limit rate 20/second accept
    counter drop
  }
  chain forward { type filter hook forward priority 0; policy drop; }
  chain output { type filter hook output priority 0; policy accept; }
}
EOF
nft -f /etc/nftables.conf && systemctl enable --now nftables

Then prove it:

nft list ruleset
nmap -Pn -p- <your-public-ip>   # run from an external host

A default-drop policy with an explicit allow list is the only firewall posture worth having. The rate limit on new SYNs blunts SYN floods without a separate tool. The nmap run from outside is the ground truth — a local firewall listing means nothing until a remote scan confirms ports are unfiltered or closed.

Step 4: Attack a brute-force problem with data, not kneejerk blocks

Before adding fail2ban, quantify the threat. If lastb shows thousands of failed attempts, the fix is key-only auth (already done above) plus a jail for defence in depth, not endless IP blocking.

[sshd]
enabled = true
port = 22
maxretry = 4
findtime = 600
bantime = 3600
bantime.increment = true
bantime.factor = 2
bantime.maxtime = 1w

The bantime.increment settings make repeat offenders get progressively longer bans — a single global 1-hour ban is trivially waited out by a botnet. Verify with fail2ban-client status sshd and check the jail is actually reading logs, since a wrong logpath produces a silent no-op jail that gives false comfort.

Step 5: Bound what every service can touch

A compromise that escapes the service process is far worse than one contained inside it. systemd gives you sandboxing for free. A hardened web-app unit looks like this:

[Service]
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
ReadWritePaths=/var/lib/myapp /var/log/myapp

ProtectSystem=strict makes the entire filesystem read-only except explicitly whitelisted paths — this one directive defeats a large class of web-shell persistence attempts. SystemCallFilter=~@privileged blocks calls a web app never legitimately needs. Test with systemd-analyze security myapp.service, which scores the unit and itemises every remaining exposure.

Step 6: Kernel-level hardening

kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1
kernel.yama.ptrace_scope = 2
kernel.unprivileged_bpf_disabled = 1
net.core.bpf_jit_harden = 2
fs.protected_hardlinks = 1
fs.protected_symlinks = 1
fs.suid_dumpable = 0
kernel.randomize_va_space = 2
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.all.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0

Each of these removes an information leak or a local escalation primitive with negligible performance cost. kernel.dmesg_restrict=1 stops an unprivileged user reading kernel addresses from the ring buffer; kptr_restrict=2 hides them from everyone but root. Reboot-dependent settings should be applied through /etc/sysctl.d/ so they survive.

Step 7: Automate the audit itself

A one-time audit decays. Wrap the verification commands in a script that exits non-zero when a check regresses, then run it from a systemd timer or CI. Keep it dependency-light so it never fails because a package is missing:

#!/bin/bash
set -u
fail=0
chk(){ if eval "$2"; then echo "PASS $1"; else echo "FAIL $1"; fail=1; fi; }
chk "root-ssh-off"      "[ \"\$(sshd -T | grep -c 'permitrootlogin no')\" -eq 1 ]"
chk "password-auth-off" "[ \"\$(sshd -T | grep -c 'passwordauthentication no')\" -eq 1 ]"
chk "firewall-default-drop" "nft list ruleset | grep -q 'policy drop'"
chk "no-world-writable-etc" "[ -z \"\$(find /etc -xdev -type f -perm -o+w 2>/dev/null)\" ]"
chk "autoupdates-on"    "systemctl is-enabled --quiet unattended-upgrades"
exit $fail

Hook it into a timer that mails or webhooks on failure. Drift detection turns the audit from a project into a guarantee.

Prioritising what you found

Not every finding deserves equal urgency. Rank by reachability first: an exposed service with a known CVE outranks a local hardening gap every time. Fix in this order — patch exposed software, disable unused services, enforce key-only auth, default-deny the firewall, then layer kernel and systemd hardening. Re-run the verification script after each change so you always know your current posture instead of assuming it.

Leave a Reply