Server Security Audit Script for Debian/Ubuntu
Table of Contents ๐
Changelog
| Date | Change |
|---|---|
| 2026-05-04 | Initial Version: Full audit script with comprehensive explanations. |
Introduction to the Server Security Audit Script
When you provision a new server, especially in a cloud environment using tools like cloud-init, itโs crucial to verify that all intended security measures have been correctly applied. Manual checks can be time-consuming and error-prone. This Bash script automates a significant portion of this auditing process, providing a quick overview of your serverโs security posture on Debian or Ubuntu-based systems.
This script is designed to be run with root privileges and will:
- Check System Fundamentals: Hostname, timezone, kernel, pending updates, and reboot status.
- Audit User & Authentication: Verify the existence and
sudoprivileges of a dedicated admin user, check root password status, and identify accounts with UID 0 or empty passwords. - Harden SSH: Scrutinize your SSH daemonโs configuration for best practices like disabled root login, password authentication, X11 forwarding, and the use of strong key algorithms. It also checks for the presence of SSH keys for your admin user.
- Firewall Configuration (UFW): Confirm UFWโs active status, default policies, specific rule sets (e.g., rate-limiting for SSH), and log redirection.
- Intrusion Prevention (Fail2Ban): Validate that Fail2Ban is running, its SSH jail is active, and itโs monitoring the correct SSH port.
- Automated Updates: Ensure
unattended-upgradesis installed and configured to keep your system patched. - Docker Security: If Docker is installed, it checks daemon status, Docker Compose presence, user group membership, and critically, Dockerโs log rotation settings and network configuration.
- Log Rotation: Verify persistent journaling and
logrotateconfigurations for Traefik and UFW logs. - Network & Open Ports: List actively listening ports and check for known insecure legacy services.
- Filesystem Security: Scan for world-writable files in critical directories and report disk usage.
At the end, it provides a summary of PASS, WARN, and FAIL counts, giving you an immediate understanding of areas needing attention.
Why is this important? Even with cloud-init or automated provisioning, misconfigurations can occur. This script acts as your second line of defense, ensuring that your server adheres to a baseline of security best practices before it goes into production.
How to Use the Script
- Save the script: Copy the entire script content into a file, for example,
server-audit.sh. - Make it executable:
chmod +x server-audit.sh - Run with
sudo: The script requires root privileges to access system configurations and logs.sudo ./server-audit.sh - Review the output: Pay close attention to
[FAIL]and[WARN]messages, which indicate potential security vulnerabilities or areas for improvement.
The Server Security Audit Script
Here is the complete script. You can customize the ADMIN_USER variable at the beginning to match your dedicated administrative username.
#!/bin/bash
# =============================================================================
# Server Security Audit Script
# =============================================================================
# Checks if the cloud-init setup has been correctly applied and if current
# security standards are met.
#
# Usage: sudo bash server-audit.sh
# =============================================================================
set -u
# --- Colors & Symbols --------------------------------------------------------
GREEN="\033[0;32m"
RED="\033[0;31m"
YELLOW="\033[0;33m"
CYAN="\033[0;36m"
BOLD="\033[1m"
NC="\033[0m"
PASS="${GREEN}PASS${NC}"
FAIL="${RED}FAIL${NC}"
WARN="${YELLOW}WARN${NC}"
INFO="${CYAN}INFO${NC}"
pass_count=0
fail_count=0
warn_count=0
log_pass() { echo -e " [${PASS}] $1"; ((pass_count++)); }
log_fail() { echo -e " [${FAIL}] $1"; ((fail_count++)); }
log_warn() { echo -e " [${WARN}] $1"; ((warn_count++)); }
log_info() { echo -e " [${INFO}] $1"; }
section() { echo -e "\n${BOLD}=== $1 ===${NC}"; }
# --- Root Check --------------------------------------------------------------
if [[ $EUID -ne 0 ]]; then
echo -e "${RED}This script must be run with sudo.${NC}"
exit 1
fi
ADMIN_USER="delightfuldude" # <--- CUSTOMIZE THIS TO YOUR ADMIN USERNAME
echo -e "${BOLD}"
echo "============================================================"
echo " Server Security Audit - $(date '+%Y-%m-%d %H:%M:%S')"
echo "============================================================"
echo -e "${NC}"
# =============================================================================
# 1. SYSTEM FUNDAMENTALS
# =============================================================================
section "1. System Fundamentals"
# Hostname
CURRENT_HOSTNAME=$(hostnamectl --static 2>/dev/null || hostname)
if [[ "$CURRENT_HOSTNAME" != "localhost" && -n "$CURRENT_HOSTNAME" ]]; then
log_pass "Hostname set: ${CURRENT_HOSTNAME}"
else
log_warn "Hostname is still on default"
fi
# Timezone
CURRENT_TZ=$(timedatectl show -p Timezone --value 2>/dev/null || cat /etc/timezone 2>/dev/null || echo "unknown")
if [[ "$CURRENT_TZ" == "Europe/Berlin" ]]; then
log_pass "Timezone: ${CURRENT_TZ}"
else
log_warn "Timezone is ${CURRENT_TZ} (expected: Europe/Berlin)"
fi
# Kernel & OS
log_info "OS: $(grep PRETTY_NAME /etc/os-release 2>/dev/null | cut -d= -f2 | tr -d '"')"
log_info "Kernel: $(uname -r)"
# Uptime
log_info "Uptime: $(uptime -p)"
# Pending Updates
UPDATES=$(apt list --upgradable 2>/dev/null | grep -c upgradable || true)
if [[ "$UPDATES" -eq 0 ]]; then
log_pass "No pending package updates"
else
log_warn "${UPDATES} package update(s) available"
fi
# Reboot required?
if [[ -f /var/run/reboot-required ]]; then
log_warn "System reboot required"
else
log_pass "No reboot required"
fi
# =============================================================================
# 2. USER & AUTHENTICATION
# =============================================================================
section "2. User & Authentication"
# Admin user exists
if id "$ADMIN_USER" &>/dev/null; then
log_pass "User '${ADMIN_USER}' exists"
else
log_fail "User '${ADMIN_USER}' does NOT exist"
fi
# sudo group
if groups "$ADMIN_USER" 2>/dev/null | grep -qw sudo; then
log_pass "'${ADMIN_USER}' is in the sudo group"
else
log_fail "'${ADMIN_USER}' is NOT in the sudo group"
fi
# NOPASSWD in sudoers (accepted for cloud-init without password)
if grep -r "NOPASSWD" /etc/sudoers /etc/sudoers.d/ 2>/dev/null | grep -v "^#" | grep -q "$ADMIN_USER"; then
log_info "'${ADMIN_USER}' has NOPASSWD (intended, as cloud-init doesn't set a password)"
else
log_pass "No NOPASSWD for '${ADMIN_USER}' - sudo requires password"
fi
# Root password (should be locked or random, not empty)
ROOT_PW_STATUS=$(passwd -S root 2>/dev/null | awk '{print $2}')
case "$ROOT_PW_STATUS" in
L) log_pass "Root account is locked" ;;
P) log_info "Root has a password set (randomized by cloud-init)" ;;
NP) log_fail "Root has NO password!" ;;
*) log_info "Root password status: ${ROOT_PW_STATUS}" ;;
esac
# Accounts with empty password
EMPTY_PW=$(awk -F: '($2 == "" ) { print $1 }' /etc/shadow 2>/dev/null || true)
if [[ -z "$EMPTY_PW" ]]; then
log_pass "No accounts with empty password"
else
log_fail "Accounts with empty password: ${EMPTY_PW}"
fi
# UID-0 accounts (only root should have UID 0)
ROOT_ACCOUNTS=$(awk -F: '($3 == 0) { print $1 }' /etc/passwd)
if [[ "$ROOT_ACCOUNTS" == "root" ]]; then
log_pass "Only 'root' has UID 0"
else
log_fail "Multiple accounts with UID 0: ${ROOT_ACCOUNTS}"
fi
# =============================================================================
# 3. SSH HARDENING
# =============================================================================
section "3. SSH Hardening"
# Read effective SSH configuration
SSHD_CONFIG=$(sshd -T 2>/dev/null || true)
if [[ -z "$SSHD_CONFIG" ]]; then
log_fail "Cannot read SSH configuration (sshd -T failed)"
else
# Port
SSH_PORT=$(echo "$SSHD_CONFIG" | grep "^port " | awk '{print $2}')
if [[ "$SSH_PORT" != "22" ]]; then
log_pass "SSH port changed: ${SSH_PORT}"
else
log_warn "SSH is running on default port 22"
fi
# Root Login
ROOT_LOGIN=$(echo "$SSHD_CONFIG" | grep "^permitrootlogin " | awk '{print $2}')
if [[ "$ROOT_LOGIN" == "no" ]]; then
log_pass "Root login disabled"
else
log_fail "Root login is '${ROOT_LOGIN}' (should be 'no')"
fi
# Password Auth
PW_AUTH=$(echo "$SSHD_CONFIG" | grep "^passwordauthentication " | awk '{print $2}')
if [[ "$PW_AUTH" == "no" ]]; then
log_pass "Password authentication disabled"
else
log_fail "Password authentication is enabled!"
fi
# Pubkey Auth
PUBKEY_AUTH=$(echo "$SSHD_CONFIG" | grep "^pubkeyauthentication " | awk '{print $2}')
if [[ "$PUBKEY_AUTH" == "yes" ]]; then
log_pass "Public-key authentication enabled"
else
log_fail "Public-key authentication is disabled!"
fi
# MaxAuthTries
MAX_AUTH=$(echo "$SSHD_CONFIG" | grep "^maxauthtries " | awk '{print $2}')
if [[ "$MAX_AUTH" -le 3 ]]; then
log_pass "MaxAuthTries: ${MAX_AUTH}"
else
log_warn "MaxAuthTries is ${MAX_AUTH} (recommended: <=3)"
fi
# LoginGraceTime
GRACE_TIME=$(echo "$SSHD_CONFIG" | grep "^logingracetime " | awk '{print $2}')
if [[ "$GRACE_TIME" -le 60 ]]; then
log_pass "LoginGraceTime: ${GRACE_TIME}s"
else
log_warn "LoginGraceTime is ${GRACE_TIME}s (recommended: <=60)"
fi
# X11Forwarding
X11=$(echo "$SSHD_CONFIG" | grep "^x11forwarding " | awk '{print $2}')
if [[ "$X11" == "no" ]]; then
log_pass "X11Forwarding disabled"
else
log_warn "X11Forwarding is enabled"
fi
# TCP Forwarding
TCP_FWD=$(echo "$SSHD_CONFIG" | grep "^allowtcpforwarding " | awk '{print $2}')
if [[ "$TCP_FWD" == "no" ]]; then
log_pass "TCP-Forwarding disabled"
else
log_warn "TCP-Forwarding is enabled"
fi
# Agent Forwarding
AGENT_FWD=$(echo "$SSHD_CONFIG" | grep "^allowagentforwarding " | awk '{print $2}')
if [[ "$AGENT_FWD" == "no" ]]; then
log_pass "Agent-Forwarding disabled"
else
log_warn "Agent-Forwarding is enabled"
fi
# AllowUsers set
ALLOW_USERS=$(echo "$SSHD_CONFIG" | grep "^allowusers " | awk '{$1=""; print $0}' | xargs)
if [[ -n "$ALLOW_USERS" ]]; then
log_pass "AllowUsers restricted to: ${ALLOW_USERS}"
else
log_warn "AllowUsers is not set (all users can use SSH)"
fi
# Check if insecure Key Algorithms are accepted
ACCEPTED_ALGOS=$(echo "$SSHD_CONFIG" | grep "^pubkeyacceptedalgorithms " | awk '{$1=""; print $0}' | xargs)
if [[ -n "$ACCEPTED_ALGOS" ]]; then
if echo "$ACCEPTED_ALGOS" | grep -qw "ssh-rsa"; then
log_fail "ssh-rsa (SHA-1) is still allowed - use rsa-sha2-* only!"
else
log_pass "Only modern Key Algorithms allowed"
fi
log_info "Allowed Algorithms: ${ACCEPTED_ALGOS}"
else
log_info "PubkeyAcceptedAlgorithms: System Default (SHA-1 blocked since OpenSSH 8.8)"
fi
fi
# SSH Key present for Admin User
ADMIN_HOME=$(eval echo ~"$ADMIN_USER")
if [[ -f "${ADMIN_HOME}/.ssh/authorized_keys" ]]; then
KEY_COUNT=$(grep -c "^ssh-" "${ADMIN_HOME}/.ssh/authorized_keys" 2>/dev/null || true)
if [[ "$KEY_COUNT" -gt 0 ]]; then
log_pass "${KEY_COUNT} SSH key(s) stored for '${ADMIN_USER}'"
while IFS= read -r line; do
KEY_TYPE=$(echo "$line" | awk '{print $1}')
KEY_COMMENT=$(echo "$line" | awk '{print $3}')
KEY_BITS=$(ssh-keygen -l -f /dev/stdin <<< "$line" 2>/dev/null | awk '{print $1}' || true)
log_info " Key: ${KEY_TYPE} ${KEY_BITS:-?} bit (${KEY_COMMENT:-no comment})"
done < <(grep "^ssh-" "${ADMIN_HOME}/.ssh/authorized_keys")
else
log_fail "No SSH keys in authorized_keys!"
fi
else
log_fail "No authorized_keys file for '${ADMIN_USER}'!"
fi
SSH_VERSION=$(ssh -V 2>&1)
log_info "SSH Version: ${SSH_VERSION}"
# =============================================================================
# 4. FIREWALL (UFW)
# =============================================================================
section "4. Firewall (UFW)"
# SSH_PORT for later use (if sshd -T failed)
SSH_PORT=${SSH_PORT:-8496} # Fallback to a common custom port
if command -v ufw &>/dev/null; then
UFW_STATUS=$(ufw status 2>/dev/null || true)
if echo "$UFW_STATUS" | grep -q "Status: active"; then
log_pass "UFW is active"
# Default Policies
UFW_VERBOSE=$(ufw status verbose 2>/dev/null || true)
if echo "$UFW_VERBOSE" | grep -q "deny (incoming)"; then
log_pass "Default policy incoming: deny"
else
log_fail "Default policy incoming is NOT deny!"
fi
if echo "$UFW_VERBOSE" | grep -q "allow (outgoing)"; then
log_pass "Default policy outgoing: allow"
else
log_info "Default policy outgoing is not 'allow'"
fi
# List open ports
echo ""
log_info "Active rules:"
ufw status numbered 2>/dev/null | grep -E "^\[" | while IFS= read -r rule; do
echo -e " ${rule}"
done
# SSH port with rate-limit?
if echo "$UFW_STATUS" | grep -q "${SSH_PORT}.*LIMIT"; then
log_pass "SSH port ${SSH_PORT} has Rate-Limiting"
elif echo "$UFW_STATUS" | grep -q "${SSH_PORT}"; then
log_warn "SSH port ${SSH_PORT} is open, but WITHOUT Rate-Limiting"
else
log_warn "SSH port ${SSH_PORT} is not explicitly in UFW"
fi
else
log_fail "UFW is NOT active"
fi
# UFW Logging Redirect (VNC Console fix)
if [[ -f /etc/rsyslog.d/20-ufw.conf ]]; then
log_pass "UFW logs are redirected to /var/log/ufw.log (console remains clean)"
else
log_warn "UFW logs not redirected - VNC console will be flooded with [UFW BLOCK]"
fi
else
log_fail "UFW is not installed"
fi
# =============================================================================
# 5. FAIL2BAN
# =============================================================================
section "5. Fail2Ban"
if command -v fail2ban-client &>/dev/null; then
if systemctl is-active --quiet fail2ban; then
log_pass "Fail2Ban is running"
# SSH Jail active?
if fail2ban-client status sshd &>/dev/null; then
log_pass "SSH Jail is active"
F2B_STATUS=$(fail2ban-client status sshd 2>/dev/null || true)
BANNED=$(echo "$F2B_STATUS" | grep "Currently banned" | awk '{print $NF}')
TOTAL_BANNED=$(echo "$F2B_STATUS" | grep "Total banned" | awk '{print $NF}')
log_info "Currently banned: ${BANNED:-0}, Total banned: ${TOTAL_BANNED:-0}"
else
log_fail "SSH Jail is NOT active"
fi
# Check configuration
F2B_PORT=$(grep -r "^port" /etc/fail2ban/jail.local 2>/dev/null | head -1 | awk '{print $NF}' || true)
if [[ "$F2B_PORT" == "$SSH_PORT" ]]; then
log_pass "Fail2Ban monitors correct SSH port (${F2B_PORT})"
elif [[ -n "$F2B_PORT" ]]; then
log_fail "Fail2Ban monitors port ${F2B_PORT}, SSH runs on ${SSH_PORT}!"
fi
else
log_fail "Fail2Ban is installed, but NOT active"
fi
else
log_fail "Fail2Ban is not installed"
fi
# =============================================================================
# 6. AUTOMATIC UPDATES
# =============================================================================
section "6. Automatic Updates"
if dpkg -l unattended-upgrades &>/dev/null; then
log_pass "unattended-upgrades is installed"
if [[ -f /etc/apt/apt.conf.d/20auto-upgrades ]]; then
if grep -q 'Unattended-Upgrade "1"' /etc/apt/apt.conf.d/20auto-upgrades; then
log_pass "Automatic upgrades are enabled"
else
log_warn "20auto-upgrades exists, but upgrades appear disabled"
fi
else
log_warn "20auto-upgrades configuration is missing"
fi
else
log_fail "unattended-upgrades is not installed"
fi
# =============================================================================
# 7. DOCKER
# =============================================================================
section "7. Docker"
if command -v docker &>/dev/null; then
log_pass "Docker is installed: $(docker --version 2>/dev/null | head -1)"
if systemctl is-active --quiet docker; then
log_pass "Docker daemon is running"
else
log_warn "Docker daemon is not active"
fi
# Docker Compose
if docker compose version &>/dev/null; then
log_pass "Docker Compose: $(docker compose version 2>/dev/null | head -1)"
else
log_warn "Docker Compose Plugin not found"
fi
# User in docker group
if groups "$ADMIN_USER" 2>/dev/null | grep -qw docker; then
log_pass "'${ADMIN_USER}' is in the docker group"
else
log_warn "'${ADMIN_USER}' is NOT in the docker group"
fi
# Log rotation configured?
if [[ -f /etc/docker/daemon.json ]]; then
if grep -q "max-size" /etc/docker/daemon.json; then
log_pass "Docker Log rotation configured"
MAX_SIZE=$(grep "max-size" /etc/docker/daemon.json | tr -d ' ",' | cut -d: -f2)
MAX_FILE=$(grep "max-file" /etc/docker/daemon.json | tr -d ' ",' | cut -d: -f2)
log_info "max-size: ${MAX_SIZE}, max-file: ${MAX_FILE}"
else
log_warn "daemon.json exists, but no Log rotation configured"
fi
else
log_warn "No daemon.json - Docker logs can grow indefinitely!"
fi
# Proxy network
if docker network ls 2>/dev/null | grep -q "proxy"; then
log_pass "Docker network 'proxy' exists"
else
log_warn "Docker network 'proxy' is missing"
fi
else
log_fail "Docker is not installed"
fi
# =============================================================================
# 8. TRAEFIK PREPARATION
# =============================================================================
section "8. Traefik Stack Preparation"
TRAEFIK_DIR="/opt/containers/traefik-stack"
if [[ -d "$TRAEFIK_DIR" ]]; then
log_pass "Directory ${TRAEFIK_DIR} exists"
# Subdirectories
for dir in traefik/dynamic traefik/logs traefik/certs crowdsec/config crowdsec/data; do
if [[ -d "${TRAEFIK_DIR}/${dir}" ]]; then
log_pass " ${dir}/ is present"
else
log_fail " ${dir}/ is missing"
fi
done
# acme.json Permissions
if [[ -f "${TRAEFIK_DIR}/traefik/certs/acme.json" ]]; then
ACME_PERMS=$(stat -c "%a" "${TRAEFIK_DIR}/traefik/certs/acme.json")
if [[ "$ACME_PERMS" == "600" ]]; then
log_pass "acme.json has correct permissions (600)"
else
log_fail "acme.json has permissions ${ACME_PERMS} (should be 600)"
fi
else
log_warn "acme.json does not exist yet"
fi
else
log_fail "Directory ${TRAEFIK_DIR} is missing"
fi
# =============================================================================
# 9. LOG ROTATION
# =============================================================================
section "9. Log Rotation"
# Persistent Journal
if [[ -d /var/log/journal ]]; then
log_pass "Persistent Journal activated"
JOURNAL_SIZE=$(journalctl --disk-usage 2>/dev/null | grep -oP '[\d.]+\w+' | head -1 || true)
log_info "Journal size: ${JOURNAL_SIZE:-unknown}"
else
log_warn "Journal is NOT persistent (logs are lost on reboot)"
fi
# Logrotate: Traefik
if [[ -f /etc/logrotate.d/traefik ]]; then
log_pass "Logrotate for Traefik configured"
else
log_warn "Logrotate for Traefik is missing"
fi
# Logrotate: UFW
if [[ -f /etc/logrotate.d/ufw-custom ]]; then
log_pass "Logrotate for UFW logs configured"
elif [[ -f /etc/logrotate.d/ufw ]]; then
log_pass "Logrotate for UFW logs configured (system default)"
else
log_warn "Logrotate for UFW logs is missing - /var/log/ufw.log can grow indefinitely!"
fi
# Docker Log Rotation (Summary)
if [[ -f /etc/docker/daemon.json ]] && grep -q "max-size" /etc/docker/daemon.json 2>/dev/null; then
log_pass "Docker Container logs rotated"
else
log_warn "Docker Container logs are not rotated"
fi
# =============================================================================
# 10. NETWORK & OPEN PORTS
# =============================================================================
section "10. Network & Open Ports"
log_info "Listening ports (externally accessible):"
ss -tlnp 2>/dev/null | grep -v "127.0.0" | grep "LISTEN" | while IFS= read -r line; do
PORT=$(echo "$line" | awk '{print $4}' | rev | cut -d: -f1 | rev)
PROC=$(echo "$line" | grep -oP 'users:\(\("\K[^"]+' || echo "unknown")
echo -e " Port ${PORT} (${PROC})"
done
# Check for known insecure services
INSECURE_FOUND=0
for svc in telnetd rshd rlogind vsftpd proftpd; do
if systemctl is-active --quiet "$svc" 2>/dev/null; then
log_fail "Insecure service running: ${svc}"
INSECURE_FOUND=1
fi
done
if [[ "$INSECURE_FOUND" -eq 0 ]]; then
log_pass "No insecure legacy services active"
fi
# =============================================================================
# 11. FILESYSTEM SECURITY
# =============================================================================
section "11. Filesystem"
# World-writable files in critical directories
WW_COUNT=$(find /etc /usr -xdev -type f -perm -o+w 2>/dev/null | wc -l)
if [[ "$WW_COUNT" -eq 0 ]]; then
log_pass "No world-writable files in /etc and /usr"
else
log_warn "${WW_COUNT} world-writable file(s) in /etc or /usr"
fi
# SUID Binaries (informative)
SUID_COUNT=$(find / -xdev -type f -perm -4000 2>/dev/null | wc -l)
log_info "SUID binaries found: ${SUID_COUNT} (manual review if needed)"
# Disk Usage
DISK_USAGE=$(df -h / | awk 'NR==2 {print $5}' | tr -d '%')
if [[ "$DISK_USAGE" -lt 80 ]]; then
log_pass "Disk usage: ${DISK_USAGE}%"
elif [[ "$DISK_USAGE" -lt 90 ]]; then
log_warn "Disk usage: ${DISK_USAGE}% (getting low)"
else
log_fail "Disk usage: ${DISK_USAGE}% (critical!)"
fi
# =============================================================================
# SUMMARY
# =============================================================================
echo ""
echo -e "${BOLD}============================================================${NC}"
echo -e "${BOLD} SUMMARY${NC}"
echo -e "${BOLD}============================================================${NC}"
echo -e " ${GREEN}Passed:${NC} ${pass_count}"
echo -e " ${YELLOW}Warnings:${NC} ${warn_count}"
echo -e " ${RED}Failed:${NC} ${fail_count}"
echo -e "${BOLD}============================================================${NC}"
if [[ "$fail_count" -eq 0 && "$warn_count" -eq 0 ]]; then
echo -e "\n${GREEN}${BOLD}Excellent! All checks passed.${NC}"
elif [[ "$fail_count" -eq 0 ]]; then
echo -e "\n${YELLOW}${BOLD}Good - no critical failures, but review warnings.${NC}"
else
echo -e "\n${RED}${BOLD}Attention - there are critical failures that should be resolved!${NC}"
fi
echo ""
exit "$fail_count"





