#!/usr/bin/env bash
#
# TechSteps Linux hardening audit, version 1.0 (19 October 2026)
#
# Checks a Debian, Ubuntu, Rocky Linux, AlmaLinux or RHEL server against
#   https://techsteps.ae/insights/linux-server-hardening-checklist/
# and prints PASS, WARN or INFO for each item.
#
# READ-ONLY. It installs nothing, changes no setting and sends nothing
# anywhere. Read it before you run it, as you should with any script.
#
#   curl -fsSLO https://techsteps.ae/downloads/linux-hardening-audit.sh
#   less linux-hardening-audit.sh
#   sudo bash linux-hardening-audit.sh
#
# A WARN is a question to answer, not an order. Some warnings are deliberate
# on a given server; write those down with the reason.

set -u
export LC_ALL=C

PASSES=0
WARNINGS=0
pass() { printf '  PASS  %s\n' "$1"; PASSES=$((PASSES + 1)); }
warn() { printf '  WARN  %s\n' "$1"; WARNINGS=$((WARNINGS + 1)); }
info() { printf '  INFO  %s\n' "$1"; }
section() { printf '\n%s\n' "$1"; }
have() { command -v "$1" >/dev/null 2>&1; }
active() { have systemctl && systemctl is-active --quiet "$1" 2>/dev/null; }
enabled() { have systemctl && systemctl is-enabled --quiet "$1" 2>/dev/null; }

ROOT=no
[ "$(id -u)" -eq 0 ] && ROOT=yes

ID="" ID_LIKE="" PRETTY_NAME=""
# shellcheck disable=SC1091
[ -r /etc/os-release ] && . /etc/os-release
case " ${ID} ${ID_LIKE} " in
  *" debian "* | *" ubuntu "*) FAMILY=debian ;;
  *" rhel "* | *" fedora "* | *" centos "* | *" rocky "* | *" almalinux "*) FAMILY=rhel ;;
  *) FAMILY=other ;;
esac

echo "TechSteps Linux hardening audit 1.0 (read-only)"
echo "Host: $(hostname 2>/dev/null)   OS: ${PRETTY_NAME:-unknown}   $(date -u '+%Y-%m-%d %H:%M UTC')"
[ "$ROOT" = no ] && echo "Not running as root: SSH, password and some file checks are skipped. Use sudo for a full report."

# ---------------------------------------------------------------- 1. SSH
section "1. SSH"
if [ "$ROOT" = yes ] && have sshd; then
  SSHD_CONFIG=$(sshd -T 2>/dev/null)
  if [ -n "$SSHD_CONFIG" ]; then
    setting() { printf '%s\n' "$SSHD_CONFIG" | awk -v k="$1" '$1 == k { print $2; exit }'; }

    v=$(setting passwordauthentication)
    if [ "$v" = no ]; then pass "Password authentication is off"; else warn "Password authentication is '${v:-unknown}' (expected 'no')"; fi

    v=$(setting permitrootlogin)
    case "$v" in
      no | prohibit-password | without-password) pass "Root login is '$v'" ;;
      *) warn "Root login is '${v:-unknown}' (expected 'no' or 'prohibit-password')" ;;
    esac

    v=$(setting kbdinteractiveauthentication)
    [ -z "$v" ] && v=$(setting challengeresponseauthentication)
    if [ "$v" = no ]; then pass "Keyboard-interactive authentication is off"; else warn "Keyboard-interactive authentication is '${v:-unknown}'"; fi

    v=$(setting permitemptypasswords)
    if [ "$v" = no ]; then pass "Empty passwords are refused"; else warn "PermitEmptyPasswords is '${v:-unknown}'"; fi

    v=$(setting maxauthtries)
    if [ -n "$v" ] && [ "$v" -le 4 ] 2>/dev/null; then pass "MaxAuthTries is $v"; else warn "MaxAuthTries is ${v:-unknown} (4 or fewer suggested)"; fi
  else
    warn "sshd -T produced no output. Test the configuration with: sshd -t"
  fi
elif ! have sshd; then
  info "No sshd found on this machine"
else
  info "SSH checks need root"
fi

# --------------------------------------------------- 2. Accounts and keys
section "2. Accounts and keys"
v=$(awk -F: '$3 == 0 && $1 != "root" { print $1 }' /etc/passwd | tr '\n' ' ')
if [ -z "$v" ]; then pass "Only root has UID 0"; else warn "Other accounts with UID 0: $v"; fi

if [ -r /etc/shadow ]; then
  v=$(awk -F: '$2 == "" { print $1 }' /etc/shadow | tr '\n' ' ')
  if [ -z "$v" ]; then pass "No account has an empty password"; else warn "Accounts with an empty password: $v"; fi
fi

v=$(awk -F: '$7 !~ /(nologin|false|sync|shutdown|halt)$/ { print $1 }' /etc/passwd | tr '\n' ' ' | sed 's/ $//')
info "Accounts with a login shell: ${v:-none}"

while IFS=: read -r user _ _ _ _ home _; do
  f="$home/.ssh/authorized_keys"
  if [ -r "$f" ]; then
    n=$(grep -cE '^[^#[:space:]]' "$f" 2>/dev/null)
    info "$user has $n authorized key(s) in $f. Can you name the owner of each?"
  fi
done </etc/passwd

if [ "$ROOT" = yes ]; then
  v=$(grep -rhsE '^[^#]*NOPASSWD' /etc/sudoers /etc/sudoers.d/ 2>/dev/null | wc -l | tr -d ' ')
  if [ "$v" -eq 0 ]; then pass "No passwordless sudo rules"; else warn "$v passwordless sudo rule(s) in /etc/sudoers or /etc/sudoers.d. Review each."; fi
fi

# ---------------------------------------------------- 3. Network exposure
section "3. Network exposure"
if have ss; then
  LISTENING=$(ss -tulnH 2>/dev/null | awk '{ print $1, $5 }' | grep -vE ' (127\.[0-9.]+|\[::1\]|::1)(%[a-z0-9]+)?:[0-9]+$' | sort -u)
  if [ -n "$LISTENING" ]; then
    info "Listening on non-loopback addresses (justify each one):"
    printf '%s\n' "$LISTENING" | sed 's/^/          /'
  else
    pass "Nothing listens on a non-loopback address"
  fi
  for port in 3306 5432 6379 11211 27017 9200 5984; do
    if printf '%s\n' "$LISTENING" | grep -qE ":${port}$"; then
      warn "Port $port (a database or cache) listens on a non-loopback address. Bind it to 127.0.0.1 unless another host needs it."
    fi
  done
else
  info "ss not found; listening-socket check skipped"
fi

FIREWALL=""
if have ufw && ufw status 2>/dev/null | grep -q 'Status: active'; then
  FIREWALL=ufw
  if ufw status verbose 2>/dev/null | grep -qE 'Default: (deny|reject) \(incoming\)'; then pass "ufw is active with default-deny for incoming traffic"; else warn "ufw is active but incoming traffic is not denied by default"; fi
elif have firewall-cmd && firewall-cmd --state 2>/dev/null | grep -q running; then
  FIREWALL=firewalld
  pass "firewalld is running (zone: $(firewall-cmd --get-default-zone 2>/dev/null))"
elif [ "$ROOT" = yes ] && have nft && nft list ruleset 2>/dev/null | grep -q 'hook input'; then
  FIREWALL=nftables
  pass "nftables has an input ruleset"
elif [ "$ROOT" = yes ] && have iptables && iptables -S INPUT 2>/dev/null | grep -qE '^-P INPUT (DROP|REJECT)|^-A INPUT'; then
  FIREWALL=iptables
  pass "iptables has INPUT rules"
fi
if [ -z "$FIREWALL" ]; then
  if [ "$ROOT" = yes ]; then warn "No active host firewall found (ufw, firewalld, nftables or iptables)"; else info "Firewall check is incomplete without root"; fi
fi

# ------------------------------------------------- 4. Installed software
section "4. Installed software"
case "$FAMILY" in
  debian) have dpkg-query && info "$(dpkg-query -f '.\n' -W 2>/dev/null | wc -l | tr -d ' ') packages installed. Remove what nothing needs." ;;
  rhel) have rpm && info "$(rpm -qa 2>/dev/null | wc -l | tr -d ' ') packages installed. 'dnf autoremove --assumeno' lists what nothing needs." ;;
esac
for tool in gcc cc make; do
  if have "$tool"; then warn "Build tool '$tool' is installed. Convenient for you, equally convenient for an intruder."; break; fi
done
if have systemctl; then
  for unit in avahi-daemon cups rpcbind nfs-server bluetooth; do
    if enabled "$unit"; then warn "Service '$unit' is enabled. Rarely needed on a server."; fi
  done
fi

# ------------------------------------------ 5. Permissions and secrets
section "5. Permissions and secrets"
for dir in /var/www /srv /home; do
  [ -d "$dir" ] || continue
  v=$(find "$dir" -xdev -maxdepth 6 -type f \( -name 'wp-config.php' -o -name '.env' -o -name '*.env' \) -perm -o=r 2>/dev/null | head -5)
  if [ -n "$v" ]; then
    warn "World-readable secrets under $dir (first five):"
    printf '%s\n' "$v" | sed 's/^/          /'
  fi
done
v=$(find /etc -xdev -type f -perm -o=w 2>/dev/null | head -5)
if [ -z "$v" ]; then pass "No world-writable files in /etc"; else warn "World-writable files in /etc: $(printf '%s\n' "$v" | tr '\n' ' ')"; fi

if [ "$ROOT" = yes ]; then
  v=$(find / -xdev -perm -4000 -type f 2>/dev/null | wc -l | tr -d ' ')
  info "$v setuid binaries on the root filesystem. List them with: find / -xdev -perm -4000 -type f"
fi

if have getenforce; then
  v=$(getenforce 2>/dev/null)
  if [ "$v" = Enforcing ]; then pass "SELinux is enforcing"; else warn "SELinux is '${v:-unknown}' (keep it enforcing)"; fi
elif have aa-status; then
  if aa-status --enabled 2>/dev/null; then pass "AppArmor is enabled"; else warn "AppArmor is installed but not enabled"; fi
else
  info "Neither SELinux nor AppArmor tools found"
fi

# --------------------------------------------- 6. Logging and visibility
section "6. Logging and visibility"
if active auditd; then
  if [ "$ROOT" = yes ] && have auditctl; then
    v=$(auditctl -l 2>/dev/null | grep -vc '^No rules')
    info "auditd is running with $v rule(s)"
  fi
  pass "auditd is running"
else
  warn "auditd is not running, so account and privilege changes are not recorded"
fi

if active fail2ban || active crowdsec; then
  pass "Brute-force protection is running ($(active fail2ban && echo fail2ban)$(active crowdsec && echo ' crowdsec'))"
else
  warn "Neither fail2ban nor CrowdSec is running"
fi

if grep -rhsE '^[^#]*[^@]@@?\[?[A-Za-z0-9.:-]+\]?(:[0-9]+)?[[:space:]]*$|^[^#]*action\(.*type="omfwd"' /etc/rsyslog.conf /etc/rsyslog.d/ >/dev/null 2>&1; then
  pass "rsyslog forwards logs to another host"
elif active systemd-journal-upload; then
  pass "systemd-journal-upload ships the journal to another host"
else
  info "No log forwarding found in rsyslog or systemd-journal-upload. Logs that stay on the host can be edited by an intruder. (Other shippers are not detected.)"
fi

if have timedatectl; then
  v=$(timedatectl show -p NTPSynchronized --value 2>/dev/null)
  case "$v" in
    yes) pass "System clock is synchronised" ;;
    no) warn "System clock is not synchronised (log times will not line up)" ;;
    *) info "Could not read the clock synchronisation state" ;;
  esac
fi

# ----------------------------------------------- 7. Patching and recovery
section "7. Patching and recovery"
case "$FAMILY" in
  debian)
    if dpkg-query -W -f '${Status}' unattended-upgrades 2>/dev/null | grep -q 'ok installed' &&
       grep -qsE 'Unattended-Upgrade[^0-9]*"1"' /etc/apt/apt.conf.d/*; then
      pass "Unattended security upgrades are enabled"
    else
      warn "Unattended security upgrades are not enabled"
    fi
    if have apt-get; then
      v=$(apt-get -s dist-upgrade 2>/dev/null | grep -c '^Inst.*security')
      if [ "$v" -eq 0 ]; then pass "No pending security updates (as of the last apt update)"; else warn "$v security update(s) pending (as of the last apt update)"; fi
    fi
    if [ -f /var/run/reboot-required ]; then warn "A reboot is required to activate installed updates"; else pass "No reboot pending"; fi
    ;;
  rhel)
    if enabled dnf-automatic.timer || enabled dnf-automatic-install.timer; then pass "dnf-automatic is enabled"; else warn "dnf-automatic is not enabled"; fi
    if have dnf; then
      v=$(dnf -q --cacheonly updateinfo list --security 2>/dev/null | grep -c .)
      if [ "$v" -eq 0 ]; then pass "No pending security updates (from cached metadata)"; else warn "$v security update(s) pending (from cached metadata)"; fi
      if dnf needs-restarting --help >/dev/null 2>&1; then
        if dnf needs-restarting -r >/dev/null 2>&1; then pass "No reboot pending"; else warn "A reboot is required to activate installed updates"; fi
      fi
    fi
    ;;
  *) info "Unknown distribution family; patch checks skipped" ;;
esac
info "Backups cannot be checked from here. When did you last restore one and confirm it worked?"

# ---------------------------------------------------------------- Summary
printf '\n%d passed, %d warnings. Nothing on this server was changed.\n' "$PASSES" "$WARNINGS"
echo "What each item means and how to fix it: https://techsteps.ae/insights/linux-server-hardening-checklist/"
exit 0
