|
#!/usr/bin/env bash |
|
# ============================================================================= |
|
# linux_audit.sh — Complete Linux Server Security & Health Audit |
|
# Version : 2.0.0 |
|
# Author : Security Audit Framework |
|
# License : MIT |
|
# ============================================================================= |
|
# |
|
# USAGE: |
|
# sudo bash linux_audit.sh [OPTIONS] |
|
# |
|
# OPTIONS: |
|
# -o <file> Write report to a specific file (default: auto-generated) |
|
# -q Quiet mode (no colors in terminal output) |
|
# -h Show this help message |
|
# |
|
# REQUIREMENTS: |
|
# - Bash 4.0+ |
|
# - Root or sudo privileges (strongly recommended for full coverage) |
|
# - Standard Linux utilities (ss, ps, find, awk, etc.) |
|
# |
|
# ============================================================================= |
|
|
|
set -euo pipefail |
|
IFS=$'\n\t' |
|
|
|
# ───────────────────────────────────────────────────────────────────────────── |
|
# SECTION 0 — CLI Arguments & Global Configuration |
|
# ───────────────────────────────────────────────────────────────────────────── |
|
|
|
REPORT_DIR="/tmp" |
|
REPORT_FILE="" |
|
QUIET=false |
|
TIMESTAMP=$(date '+%Y%m%d_%H%M%S') |
|
HOSTNAME_SHORT=$(hostname -s 2>/dev/null || echo "unknown") |
|
|
|
usage() { |
|
grep '^# ' "$0" | sed 's/^# //' |
|
exit 0 |
|
} |
|
|
|
while getopts ":o:qh" opt; do |
|
case $opt in |
|
o) REPORT_FILE="$OPTARG" ;; |
|
q) QUIET=true ;; |
|
h) usage ;; |
|
*) echo "Unknown option: -$OPTARG" >&2; exit 1 ;; |
|
esac |
|
done |
|
|
|
[[ -z "$REPORT_FILE" ]] && REPORT_FILE="${REPORT_DIR}/audit_${HOSTNAME_SHORT}_${TIMESTAMP}.txt" |
|
|
|
# ───────────────────────────────────────────────────────────────────────────── |
|
# SECTION 1 — Color & Formatting Helpers |
|
# ───────────────────────────────────────────────────────────────────────────── |
|
|
|
if [[ "$QUIET" == false ]] && [[ -t 1 ]]; then |
|
RED='\033[0;31m'; YELLOW='\033[1;33m'; GREEN='\033[0;32m' |
|
CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m' |
|
BLUE='\033[0;34m'; MAGENTA='\033[0;35m' |
|
else |
|
RED=''; YELLOW=''; GREEN=''; CYAN=''; BOLD=''; RESET=''; BLUE=''; MAGENTA='' |
|
fi |
|
|
|
# Outputs to both terminal and the report file simultaneously |
|
log() { printf "%b\n" "$*" | tee -a "$REPORT_FILE"; } |
|
warn() { printf "%b\n" "${YELLOW}[WARN]${RESET} $*" | tee -a "$REPORT_FILE"; } |
|
alert() { printf "%b\n" "${RED}[ALERT]${RESET} $*" | tee -a "$REPORT_FILE"; } |
|
ok() { printf "%b\n" "${GREEN}[OK]${RESET} $*" | tee -a "$REPORT_FILE"; } |
|
info() { printf "%b\n" "${CYAN}[INFO]${RESET} $*" | tee -a "$REPORT_FILE"; } |
|
|
|
# Print a clearly visible section header |
|
section() { |
|
local title="$1" |
|
local line="════════════════════════════════════════════════════════════════" |
|
log "" |
|
log "${BOLD}${BLUE}${line}${RESET}" |
|
log "${BOLD}${BLUE} ► $title${RESET}" |
|
log "${BOLD}${BLUE}${line}${RESET}" |
|
} |
|
|
|
# Check if a command exists on the system |
|
cmd_exists() { command -v "$1" &>/dev/null; } |
|
|
|
# ───────────────────────────────────────────────────────────────────────────── |
|
# SECTION 2 — Privilege & Environment Check |
|
# ───────────────────────────────────────────────────────────────────────────── |
|
|
|
preflight_checks() { |
|
section "Pre-flight Checks" |
|
|
|
# Root check — many checks silently fail without root |
|
if [[ $EUID -ne 0 ]]; then |
|
warn "Script is NOT running as root. Some checks may return incomplete results." |
|
warn "Re-run with: sudo bash $0" |
|
else |
|
ok "Running as root — full audit coverage enabled." |
|
fi |
|
|
|
# Ensure the report file is writable |
|
touch "$REPORT_FILE" 2>/dev/null || { |
|
echo "Cannot write to $REPORT_FILE. Check permissions." >&2 |
|
exit 1 |
|
} |
|
|
|
info "Report will be saved to: ${BOLD}${REPORT_FILE}${RESET}" |
|
info "Audit started at: $(date '+%A %d %B %Y — %H:%M:%S %Z')" |
|
} |
|
|
|
# ───────────────────────────────────────────────────────────────────────────── |
|
# SECTION 3 — System Identity & OS Information |
|
# ───────────────────────────────────────────────────────────────────────────── |
|
|
|
audit_system_info() { |
|
section "System Identity & OS Information" |
|
|
|
info "Hostname : $(hostname -f 2>/dev/null || hostname)" |
|
info "Operating System: $(grep PRETTY_NAME /etc/os-release 2>/dev/null | cut -d= -f2 | tr -d '"' || uname -o)" |
|
info "Kernel Version : $(uname -r)" |
|
info "Architecture : $(uname -m)" |
|
info "Uptime : $(uptime -p 2>/dev/null || uptime)" |
|
info "System Date/Time: $(date)" |
|
info "Timezone : $(timedatectl 2>/dev/null | grep 'Time zone' | awk '{print $3}' || cat /etc/timezone 2>/dev/null || echo 'Unknown')" |
|
info "Last Reboot : $(who -b 2>/dev/null | awk '{print $3, $4}' || last reboot | head -1)" |
|
|
|
# Virtualization detection (bare metal vs VM vs container) |
|
if cmd_exists systemd-detect-virt; then |
|
local virt |
|
virt=$(systemd-detect-virt 2>/dev/null || echo "unknown") |
|
info "Virtualization : ${virt}" |
|
[[ "$virt" == "none" ]] && ok "Bare-metal server detected." || warn "Running inside a virtualized environment: ${virt}" |
|
fi |
|
|
|
# Check if running inside a container |
|
if [[ -f /.dockerenv ]]; then |
|
warn "Docker container environment detected." |
|
fi |
|
if grep -qE 'docker|lxc|kubepods' /proc/1/cgroup 2>/dev/null; then |
|
warn "Container cgroup markers found in /proc/1/cgroup." |
|
fi |
|
} |
|
|
|
# ───────────────────────────────────────────────────────────────────────────── |
|
# SECTION 4 — Hardware Resources (CPU / RAM / Disk / Swap) |
|
# ───────────────────────────────────────────────────────────────────────────── |
|
|
|
audit_hardware() { |
|
section "Hardware Resources" |
|
|
|
# CPU |
|
log "${BOLD}── CPU ──────────────────────────────────────────────${RESET}" |
|
local cpu_model cpu_cores cpu_threads |
|
cpu_model=$(grep 'model name' /proc/cpuinfo | head -1 | cut -d: -f2 | xargs) |
|
cpu_cores=$(grep -c '^processor' /proc/cpuinfo) |
|
cpu_threads=$(nproc --all 2>/dev/null || echo "$cpu_cores") |
|
info "Model : ${cpu_model:-Unknown}" |
|
info "Cores : ${cpu_cores}" |
|
info "Threads : ${cpu_threads}" |
|
|
|
# Load average vs core count — flag if load is critically high |
|
local load1 load5 load15 |
|
read -r load1 load5 load15 _ < /proc/loadavg |
|
info "Load Average (1m/5m/15m): ${load1} / ${load5} / ${load15}" |
|
if (( $(echo "$load1 > $cpu_cores" | bc -l 2>/dev/null || echo 0) )); then |
|
alert "1-minute load (${load1}) exceeds CPU core count (${cpu_cores}) — possible overload!" |
|
else |
|
ok "Load average is within acceptable range." |
|
fi |
|
|
|
# RAM |
|
log "" |
|
log "${BOLD}── Memory (RAM) ─────────────────────────────────────${RESET}" |
|
if cmd_exists free; then |
|
free -h | tee -a "$REPORT_FILE" |
|
local mem_used mem_total mem_pct |
|
mem_used=$(free -m | awk '/^Mem:/{print $3}') |
|
mem_total=$(free -m | awk '/^Mem:/{print $2}') |
|
if [[ $mem_total -gt 0 ]]; then |
|
mem_pct=$(( mem_used * 100 / mem_total )) |
|
info "RAM Usage: ${mem_pct}% (${mem_used} MB / ${mem_total} MB)" |
|
[[ $mem_pct -gt 90 ]] && alert "RAM usage is critically high: ${mem_pct}%" |
|
[[ $mem_pct -gt 75 && $mem_pct -le 90 ]] && warn "RAM usage is elevated: ${mem_pct}%" |
|
[[ $mem_pct -le 75 ]] && ok "RAM usage is normal: ${mem_pct}%" |
|
fi |
|
fi |
|
|
|
# Swap |
|
log "" |
|
log "${BOLD}── Swap ──────────────────────────────────────────────${RESET}" |
|
local swap_total |
|
swap_total=$(free -m | awk '/^Swap:/{print $2}') |
|
if [[ "${swap_total:-0}" -eq 0 ]]; then |
|
warn "No swap space configured. This may be intentional (e.g., Kubernetes nodes) or a risk." |
|
else |
|
free -h | awk '/^Swap:/' | tee -a "$REPORT_FILE" |
|
ok "Swap is configured (${swap_total} MB total)." |
|
fi |
|
|
|
# Disk |
|
log "" |
|
log "${BOLD}── Disk Usage ───────────────────────────────────────${RESET}" |
|
df -hT --exclude-type=tmpfs --exclude-type=devtmpfs --exclude-type=squashfs 2>/dev/null | tee -a "$REPORT_FILE" |
|
# Alert on partitions above 85% |
|
df -P --exclude-type=tmpfs --exclude-type=devtmpfs --exclude-type=squashfs 2>/dev/null | awk 'NR>1{ |
|
used=$5+0 |
|
if(used>=90) print "[ALERT] Partition "$6" is "$5" full — CRITICAL!" |
|
else if(used>=80) print "[WARN] Partition "$6" is "$5" full." |
|
else print "[OK] Partition "$6" usage: "$5 |
|
}' | tee -a "$REPORT_FILE" |
|
|
|
# Block devices |
|
log "" |
|
log "${BOLD}── Block Devices ────────────────────────────────────${RESET}" |
|
if cmd_exists lsblk; then |
|
lsblk -o NAME,SIZE,TYPE,MOUNTPOINT,FSTYPE 2>/dev/null | tee -a "$REPORT_FILE" |
|
fi |
|
|
|
# Network interfaces |
|
log "" |
|
log "${BOLD}── Network Interfaces ───────────────────────────────${RESET}" |
|
if cmd_exists ip; then |
|
ip -brief address show 2>/dev/null | tee -a "$REPORT_FILE" |
|
else |
|
ifconfig 2>/dev/null | grep -E 'inet|^[a-z]' | tee -a "$REPORT_FILE" |
|
fi |
|
} |
|
|
|
# ───────────────────────────────────────────────────────────────────────────── |
|
# SECTION 5 — User Account & Privilege Audit |
|
# ───────────────────────────────────────────────────────────────────────────── |
|
|
|
audit_users() { |
|
section "User Accounts & Privilege Audit" |
|
|
|
# Root-equivalent accounts (UID 0) |
|
log "${BOLD}── Accounts with UID 0 (root-equivalent) ────────────${RESET}" |
|
local uid0_accounts |
|
uid0_accounts=$(awk -F: '$3==0{print $1}' /etc/passwd) |
|
if [[ $(echo "$uid0_accounts" | wc -l) -gt 1 ]]; then |
|
alert "Multiple UID 0 accounts detected:" |
|
echo "$uid0_accounts" | tee -a "$REPORT_FILE" |
|
else |
|
ok "Only one UID 0 account: ${uid0_accounts}" |
|
fi |
|
|
|
# Accounts with interactive login shells |
|
log "" |
|
log "${BOLD}── Accounts with Interactive Login Shells ───────────${RESET}" |
|
grep -E '/bin/(bash|sh|zsh|fish|dash|ksh)$' /etc/passwd | tee -a "$REPORT_FILE" |
|
|
|
# Accounts with no password set |
|
log "" |
|
log "${BOLD}── Accounts without a Password ──────────────────────${RESET}" |
|
local no_pass |
|
no_pass=$(awk -F: '($2==""||$2=="!"|$2=="*"){print $1}' /etc/shadow 2>/dev/null || echo "Cannot read /etc/shadow — re-run as root") |
|
if [[ -n "$no_pass" ]]; then |
|
warn "Accounts with no valid password:" |
|
echo "$no_pass" | tee -a "$REPORT_FILE" |
|
else |
|
ok "All accounts have passwords set." |
|
fi |
|
|
|
# Password expiry & aging policy |
|
log "" |
|
log "${BOLD}── Password Aging Policy (/etc/login.defs) ──────────${RESET}" |
|
grep -E '^(PASS_MAX_DAYS|PASS_MIN_DAYS|PASS_WARN_AGE)' /etc/login.defs 2>/dev/null | tee -a "$REPORT_FILE" |
|
|
|
# Sudo privileges — who can run what |
|
log "" |
|
log "${BOLD}── Sudo Configuration ───────────────────────────────${RESET}" |
|
if [[ -f /etc/sudoers ]]; then |
|
grep -v '^#\|^$' /etc/sudoers 2>/dev/null | tee -a "$REPORT_FILE" || warn "Cannot read /etc/sudoers (need root)." |
|
fi |
|
if [[ -d /etc/sudoers.d ]]; then |
|
info "Additional sudoers.d files:" |
|
ls /etc/sudoers.d/ 2>/dev/null | tee -a "$REPORT_FILE" |
|
fi |
|
|
|
# Members of privileged groups |
|
log "" |
|
log "${BOLD}── Members of Privileged Groups ─────────────────────${RESET}" |
|
for grp in root sudo wheel docker admin; do |
|
local members |
|
members=$(getent group "$grp" 2>/dev/null | cut -d: -f4) |
|
if [[ -n "$members" ]]; then |
|
info "Group '${grp}': ${members}" |
|
[[ "$grp" == "docker" ]] && warn "Docker group members can escalate to root via container escape." |
|
fi |
|
done |
|
|
|
# Last login activity |
|
log "" |
|
log "${BOLD}── Last 10 User Logins ───────────────────────────────${RESET}" |
|
last -n 10 2>/dev/null | tee -a "$REPORT_FILE" |
|
|
|
# Currently logged-in users |
|
log "" |
|
log "${BOLD}── Currently Logged-in Users ────────────────────────${RESET}" |
|
who 2>/dev/null | tee -a "$REPORT_FILE" |
|
|
|
# Failed login attempts |
|
log "" |
|
log "${BOLD}── Failed Login Attempts (last 20) ──────────────────${RESET}" |
|
if cmd_exists lastb; then |
|
lastb -n 20 2>/dev/null | tee -a "$REPORT_FILE" || warn "lastb unavailable or insufficient privileges." |
|
else |
|
warn "lastb command not found. Check auth logs manually." |
|
fi |
|
} |
|
|
|
# ───────────────────────────────────────────────────────────────────────────── |
|
# SECTION 6 — SSH Configuration Audit |
|
# ───────────────────────────────────────────────────────────────────────────── |
|
|
|
audit_ssh() { |
|
section "SSH Configuration Audit" |
|
|
|
local sshd_config="/etc/ssh/sshd_config" |
|
|
|
if [[ ! -f "$sshd_config" ]]; then |
|
warn "SSH daemon config not found at $sshd_config — SSH may not be installed." |
|
return |
|
fi |
|
|
|
# Helper: extract value from sshd_config (last uncommented directive wins) |
|
get_ssh_val() { |
|
grep -iE "^\s*$1\s" "$sshd_config" | tail -1 | awk '{print $2}' |
|
} |
|
|
|
local port root_login pw_auth permit_empty pubkey x11 max_auth max_sess banner |
|
port=$(get_ssh_val Port); port=${port:-22} |
|
root_login=$(get_ssh_val PermitRootLogin); root_login=${root_login:-yes} |
|
pw_auth=$(get_ssh_val PasswordAuthentication); pw_auth=${pw_auth:-yes} |
|
permit_empty=$(get_ssh_val PermitEmptyPasswords); permit_empty=${permit_empty:-no} |
|
pubkey=$(get_ssh_val PubkeyAuthentication); pubkey=${pubkey:-yes} |
|
x11=$(get_ssh_val X11Forwarding); x11=${x11:-no} |
|
max_auth=$(get_ssh_val MaxAuthTries); max_auth=${max_auth:-6} |
|
max_sess=$(get_ssh_val MaxSessions); max_sess=${max_sess:-10} |
|
banner=$(get_ssh_val Banner); banner=${banner:-none} |
|
|
|
info "SSH Port : ${port}" |
|
[[ "$port" == "22" ]] && warn "Default SSH port 22 in use. Consider a non-standard port." || ok "Non-default SSH port: ${port}" |
|
|
|
[[ "$root_login" =~ ^(yes|without-password|forced-commands-only)$ ]] && \ |
|
alert "PermitRootLogin is '${root_login}' — direct root SSH login is dangerous!" || \ |
|
ok "PermitRootLogin is '${root_login}'" |
|
|
|
[[ "$pw_auth" =~ ^[Yy]es$ ]] && \ |
|
warn "PasswordAuthentication is enabled. Prefer key-based authentication only." || \ |
|
ok "PasswordAuthentication is disabled — key-based auth only." |
|
|
|
[[ "$permit_empty" =~ ^[Yy]es$ ]] && \ |
|
alert "PermitEmptyPasswords is ENABLED — critical security risk!" || \ |
|
ok "PermitEmptyPasswords is disabled." |
|
|
|
[[ "$pubkey" =~ ^[Yy]es$ ]] && ok "PubkeyAuthentication is enabled." || warn "PubkeyAuthentication is disabled." |
|
|
|
[[ "$x11" =~ ^[Yy]es$ ]] && warn "X11Forwarding is enabled. Disable if not needed." || ok "X11Forwarding is disabled." |
|
|
|
local max_auth_int=${max_auth//[^0-9]/} |
|
[[ -n "$max_auth_int" && "$max_auth_int" -gt 4 ]] && \ |
|
warn "MaxAuthTries is ${max_auth} — consider reducing to 3." || \ |
|
ok "MaxAuthTries: ${max_auth}" |
|
|
|
info "MaxSessions : ${max_sess}" |
|
[[ "$banner" == "none" ]] && warn "No login banner configured (Banner directive missing or 'none')." || ok "Login banner: ${banner}" |
|
|
|
# Check authorized_keys for all users |
|
log "" |
|
log "${BOLD}── Authorized SSH Keys ───────────────────────────────${RESET}" |
|
while IFS=: read -r user _ uid _ _ home _; do |
|
local ak="${home}/.ssh/authorized_keys" |
|
if [[ -f "$ak" ]]; then |
|
local key_count |
|
key_count=$(grep -c '^ssh-' "$ak" 2>/dev/null || echo 0) |
|
info "User '${user}' (UID ${uid}): ${key_count} authorized key(s) in ${ak}" |
|
# Warn about world-readable auth key files |
|
if [[ -r "$ak" ]]; then |
|
local perms |
|
perms=$(stat -c '%a' "$ak" 2>/dev/null) |
|
[[ "$perms" != "600" && "$perms" != "640" ]] && warn " Permissions on ${ak}: ${perms} (should be 600)" |
|
fi |
|
fi |
|
done < /etc/passwd |
|
|
|
# Check SSH host keys |
|
log "" |
|
log "${BOLD}── SSH Host Keys ─────────────────────────────────────${RESET}" |
|
for key in /etc/ssh/ssh_host_*_key; do |
|
[[ -f "$key" ]] && info "Found host key: ${key} ($(stat -c '%a' "$key" 2>/dev/null))" |
|
done |
|
} |
|
|
|
# ───────────────────────────────────────────────────────────────────────────── |
|
# SECTION 7 — Firewall Audit |
|
# ───────────────────────────────────────────────────────────────────────────── |
|
|
|
audit_firewall() { |
|
section "Firewall Configuration" |
|
|
|
local fw_found=false |
|
|
|
# UFW (Uncomplicated Firewall — common on Debian/Ubuntu) |
|
if cmd_exists ufw; then |
|
fw_found=true |
|
log "${BOLD}── UFW Status ────────────────────────────────────────${RESET}" |
|
local ufw_status |
|
ufw_status=$(ufw status verbose 2>/dev/null || echo "Unable to query UFW") |
|
echo "$ufw_status" | tee -a "$REPORT_FILE" |
|
echo "$ufw_status" | grep -qi "inactive" && alert "UFW is installed but INACTIVE — server has no firewall protection!" || ok "UFW is active." |
|
fi |
|
|
|
# firewalld (common on RHEL/CentOS/Fedora) |
|
if cmd_exists firewall-cmd; then |
|
fw_found=true |
|
log "${BOLD}── firewalld Status ──────────────────────────────────${RESET}" |
|
if systemctl is-active firewalld &>/dev/null; then |
|
ok "firewalld is running." |
|
firewall-cmd --list-all 2>/dev/null | tee -a "$REPORT_FILE" |
|
else |
|
alert "firewalld is installed but NOT running." |
|
fi |
|
fi |
|
|
|
# iptables — raw rules dump |
|
if cmd_exists iptables; then |
|
fw_found=true |
|
log "${BOLD}── iptables Rules ────────────────────────────────────${RESET}" |
|
iptables -L -n -v --line-numbers 2>/dev/null | tee -a "$REPORT_FILE" || warn "Cannot read iptables rules." |
|
# Check for a default DROP/REJECT policy on INPUT chain |
|
local input_policy |
|
input_policy=$(iptables -L INPUT 2>/dev/null | head -1 | awk '{print $NF}') |
|
[[ "$input_policy" == "ACCEPT" ]] && alert "iptables INPUT chain default policy is ACCEPT — not restrictive!" || ok "iptables INPUT default policy: ${input_policy}" |
|
fi |
|
|
|
# nftables |
|
if cmd_exists nft; then |
|
fw_found=true |
|
log "${BOLD}── nftables Ruleset ──────────────────────────────────${RESET}" |
|
nft list ruleset 2>/dev/null | tee -a "$REPORT_FILE" || warn "Cannot read nftables ruleset." |
|
fi |
|
|
|
[[ "$fw_found" == false ]] && alert "No firewall detected (UFW, firewalld, iptables, nftables)!" |
|
} |
|
|
|
# ───────────────────────────────────────────────────────────────────────────── |
|
# SECTION 8 — Open Ports & Network Connections |
|
# ───────────────────────────────────────────────────────────────────────────── |
|
|
|
audit_ports() { |
|
section "Open Ports & Network Connections" |
|
|
|
log "${BOLD}── Listening TCP/UDP Ports ──────────────────────────${RESET}" |
|
if cmd_exists ss; then |
|
ss -tulnp 2>/dev/null | tee -a "$REPORT_FILE" |
|
elif cmd_exists netstat; then |
|
netstat -tulnp 2>/dev/null | tee -a "$REPORT_FILE" |
|
else |
|
warn "Neither 'ss' nor 'netstat' found. Install iproute2 or net-tools." |
|
fi |
|
|
|
# Highlight known dangerous / unexpected ports |
|
log "" |
|
log "${BOLD}── Sensitive Port Check ──────────────────────────────${RESET}" |
|
declare -A KNOWN_RISKY=( |
|
[21]="FTP (cleartext — use SFTP instead)" |
|
[23]="Telnet (cleartext — critical risk)" |
|
[25]="SMTP (open relay risk)" |
|
[110]="POP3 (cleartext mail)" |
|
[143]="IMAP (cleartext mail)" |
|
[512]="rexec (remote exec — legacy & dangerous)" |
|
[513]="rlogin (remote login — legacy & dangerous)" |
|
[514]="rsh/syslog (dangerous if exposed)" |
|
[5900]="VNC (remote desktop — ensure tunneled)" |
|
[3389]="RDP (Windows Remote Desktop)" |
|
[6379]="Redis (often unauthenticated)" |
|
[27017]="MongoDB (check auth)" |
|
[5432]="PostgreSQL" |
|
[3306]="MySQL/MariaDB" |
|
[11211]="Memcached (often unauthenticated)" |
|
[9200]="Elasticsearch (REST API — often open)" |
|
[8080]="HTTP alternate" |
|
[8443]="HTTPS alternate" |
|
) |
|
|
|
for port in "${!KNOWN_RISKY[@]}"; do |
|
if ss -tuln 2>/dev/null | grep -qE ":${port}\b"; then |
|
warn "Port ${port} is OPEN — ${KNOWN_RISKY[$port]}" |
|
fi |
|
done |
|
|
|
# Established connections (who is currently talking to us) |
|
log "" |
|
log "${BOLD}── Established Network Connections ──────────────────${RESET}" |
|
ss -tnp state established 2>/dev/null | tee -a "$REPORT_FILE" |
|
} |
|
|
|
# ───────────────────────────────────────────────────────────────────────────── |
|
# SECTION 9 — Running Processes & Active Services |
|
# ───────────────────────────────────────────────────────────────────────────── |
|
|
|
audit_services() { |
|
section "Running Processes & Active Services" |
|
|
|
# Top CPU & memory consumers |
|
log "${BOLD}── Top 15 Processes by CPU ───────────────────────────${RESET}" |
|
ps aux --sort=-%cpu 2>/dev/null | head -16 | tee -a "$REPORT_FILE" |
|
|
|
log "" |
|
log "${BOLD}── Top 15 Processes by Memory ───────────────────────${RESET}" |
|
ps aux --sort=-%mem 2>/dev/null | head -16 | tee -a "$REPORT_FILE" |
|
|
|
# Systemd services |
|
log "" |
|
log "${BOLD}── Active systemd Services ──────────────────────────${RESET}" |
|
if cmd_exists systemctl; then |
|
systemctl list-units --type=service --state=running --no-pager 2>/dev/null | tee -a "$REPORT_FILE" |
|
|
|
log "" |
|
log "${BOLD}── Failed systemd Services ──────────────────────────${RESET}" |
|
local failed |
|
failed=$(systemctl list-units --type=service --state=failed --no-pager 2>/dev/null) |
|
if echo "$failed" | grep -q '\.service'; then |
|
alert "There are FAILED systemd services:" |
|
echo "$failed" | tee -a "$REPORT_FILE" |
|
else |
|
ok "No failed systemd services detected." |
|
fi |
|
else |
|
warn "systemctl not found — system may use SysV init or another init system." |
|
service --status-all 2>/dev/null | tee -a "$REPORT_FILE" || true |
|
fi |
|
} |
|
|
|
# ───────────────────────────────────────────────────────────────────────────── |
|
# SECTION 10 — Dangerous File Permissions |
|
# ───────────────────────────────────────────────────────────────────────────── |
|
|
|
audit_permissions() { |
|
section "Dangerous File Permissions" |
|
|
|
# SUID / SGID binaries — can be exploited for privilege escalation |
|
log "${BOLD}── SUID Binaries ──────────────────────────────────────${RESET}" |
|
info "Searching for SUID binaries (this may take a moment)..." |
|
find / -xdev -perm /4000 -type f 2>/dev/null | sort | tee -a "$REPORT_FILE" |
|
|
|
log "" |
|
log "${BOLD}── SGID Binaries ──────────────────────────────────────${RESET}" |
|
find / -xdev -perm /2000 -type f 2>/dev/null | sort | tee -a "$REPORT_FILE" |
|
|
|
# World-writable files (any user can modify) |
|
log "" |
|
log "${BOLD}── World-Writable Files (excl. /proc, /sys, /dev) ───${RESET}" |
|
info "Searching for world-writable files..." |
|
find / -xdev -not \( -path '/proc/*' -o -path '/sys/*' -o -path '/dev/*' -o -path '/run/*' \) \ |
|
-perm -o+w -type f 2>/dev/null | head -50 | tee -a "$REPORT_FILE" |
|
|
|
# World-writable directories without sticky bit |
|
log "" |
|
log "${BOLD}── World-Writable Directories (no sticky bit) ───────${RESET}" |
|
find / -xdev -not \( -path '/proc/*' -o -path '/sys/*' -o -path '/dev/*' \) \ |
|
-perm -0002 -not -perm -1000 -type d 2>/dev/null | tee -a "$REPORT_FILE" |
|
|
|
# Files with no owner |
|
log "" |
|
log "${BOLD}── Files with No Owner (orphaned) ───────────────────${RESET}" |
|
find / -xdev -not \( -path '/proc/*' -o -path '/sys/*' \) \ |
|
-nouser -o -nogroup 2>/dev/null | head -30 | tee -a "$REPORT_FILE" |
|
|
|
# Critical config files — check permissions |
|
log "" |
|
log "${BOLD}── Critical Config File Permissions ────────────────${RESET}" |
|
for f in /etc/passwd /etc/shadow /etc/group /etc/sudoers /etc/ssh/sshd_config; do |
|
if [[ -f "$f" ]]; then |
|
local perm owner |
|
perm=$(stat -c '%a' "$f" 2>/dev/null) |
|
owner=$(stat -c '%U:%G' "$f" 2>/dev/null) |
|
info "${f}: permissions=${perm}, owner=${owner}" |
|
# shadow should be 000 or 640 |
|
[[ "$f" == "/etc/shadow" && "$perm" != "000" && "$perm" != "640" ]] && \ |
|
alert "/etc/shadow permissions are ${perm} — should be 000 or 640!" |
|
fi |
|
done |
|
|
|
# /tmp and /var/tmp sticky bits |
|
log "" |
|
log "${BOLD}── Sticky Bit on /tmp /var/tmp ──────────────────────${RESET}" |
|
for tmpdir in /tmp /var/tmp; do |
|
if [[ -d "$tmpdir" ]]; then |
|
local sticky |
|
sticky=$(stat -c '%a' "$tmpdir" 2>/dev/null) |
|
[[ "${sticky: -1}" == "1" || "${sticky: -1}" == "7" ]] && \ |
|
ok "${tmpdir} has sticky bit set (${sticky})." || \ |
|
alert "${tmpdir} is MISSING the sticky bit (${sticky}) — privilege escalation risk!" |
|
fi |
|
done |
|
} |
|
|
|
# ───────────────────────────────────────────────────────────────────────────── |
|
# SECTION 11 — Cron Jobs Audit |
|
# ───────────────────────────────────────────────────────────────────────────── |
|
|
|
audit_cron() { |
|
section "Cron Jobs Audit" |
|
|
|
# System-wide crontab |
|
log "${BOLD}── /etc/crontab ─────────────────────────────────────${RESET}" |
|
[[ -f /etc/crontab ]] && cat /etc/crontab | tee -a "$REPORT_FILE" || info "/etc/crontab not found." |
|
|
|
# Cron directories |
|
log "" |
|
log "${BOLD}── /etc/cron.* Directories ──────────────────────────${RESET}" |
|
for crondir in /etc/cron.d /etc/cron.hourly /etc/cron.daily /etc/cron.weekly /etc/cron.monthly; do |
|
if [[ -d "$crondir" ]]; then |
|
info "Contents of ${crondir}:" |
|
ls -la "$crondir" 2>/dev/null | tee -a "$REPORT_FILE" |
|
# Show content of each file |
|
for f in "$crondir"/*; do |
|
[[ -f "$f" ]] && { echo " → ${f}:"; cat "$f" | tee -a "$REPORT_FILE"; } |
|
done |
|
fi |
|
done |
|
|
|
# Per-user crontabs |
|
log "" |
|
log "${BOLD}── Per-User Crontabs ─────────────────────────────────${RESET}" |
|
local cron_spool="/var/spool/cron" |
|
[[ -d "/var/spool/cron/crontabs" ]] && cron_spool="/var/spool/cron/crontabs" |
|
if [[ -d "$cron_spool" ]]; then |
|
for f in "$cron_spool"/*; do |
|
if [[ -f "$f" ]]; then |
|
info "Crontab for user '$(basename "$f")':" |
|
cat "$f" | tee -a "$REPORT_FILE" |
|
fi |
|
done |
|
else |
|
info "No per-user cron spool directory found." |
|
fi |
|
|
|
# at jobs |
|
log "" |
|
log "${BOLD}── at / batch Jobs ──────────────────────────────────${RESET}" |
|
if cmd_exists atq; then |
|
atq 2>/dev/null | tee -a "$REPORT_FILE" || info "No 'at' jobs found or atd not running." |
|
else |
|
info "'at' command not installed." |
|
fi |
|
|
|
# systemd timers (modern replacement for cron) |
|
log "" |
|
log "${BOLD}── systemd Timers ────────────────────────────────────${RESET}" |
|
if cmd_exists systemctl; then |
|
systemctl list-timers --all --no-pager 2>/dev/null | tee -a "$REPORT_FILE" |
|
fi |
|
} |
|
|
|
# ───────────────────────────────────────────────────────────────────────────── |
|
# SECTION 12 — Docker Audit |
|
# ───────────────────────────────────────────────────────────────────────────── |
|
|
|
audit_docker() { |
|
section "Docker Security Audit" |
|
|
|
if ! cmd_exists docker; then |
|
info "Docker is not installed on this system. Skipping." |
|
return |
|
fi |
|
|
|
if ! docker info &>/dev/null; then |
|
warn "Docker is installed but the daemon is not running or access is denied." |
|
return |
|
fi |
|
|
|
# Docker version |
|
log "${BOLD}── Docker Version ─────────────────────────────────────${RESET}" |
|
docker version 2>/dev/null | tee -a "$REPORT_FILE" |
|
|
|
# Docker daemon info (security options, rootless, etc.) |
|
log "" |
|
log "${BOLD}── Docker Daemon Info ────────────────────────────────${RESET}" |
|
docker info 2>/dev/null | grep -E 'Security|Root|User|Debug|Experimental|Cgroup|Insecure' | tee -a "$REPORT_FILE" |
|
|
|
# Check if Docker is rootless |
|
local rootless |
|
rootless=$(docker info 2>/dev/null | grep -i 'rootless' || echo "") |
|
[[ -z "$rootless" ]] && warn "Docker does not appear to be running in rootless mode." || ok "Docker rootless mode detected." |
|
|
|
# Running containers |
|
log "" |
|
log "${BOLD}── Running Containers ───────────────────────────────${RESET}" |
|
docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}" 2>/dev/null | tee -a "$REPORT_FILE" |
|
|
|
# All containers (including stopped) |
|
log "" |
|
log "${BOLD}── All Containers (inc. stopped) ────────────────────${RESET}" |
|
docker ps -a --format "table {{.Names}}\t{{.Image}}\t{{.Status}}" 2>/dev/null | tee -a "$REPORT_FILE" |
|
|
|
# Docker images |
|
log "" |
|
log "${BOLD}── Docker Images ─────────────────────────────────────${RESET}" |
|
docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}" 2>/dev/null | tee -a "$REPORT_FILE" |
|
|
|
# Privileged containers — critical risk! |
|
log "" |
|
log "${BOLD}── Privileged Containers ─────────────────────────────${RESET}" |
|
local privileged_containers |
|
privileged_containers=$(docker ps -q 2>/dev/null | xargs -I{} docker inspect {} --format '{{.Name}}: Privileged={{.HostConfig.Privileged}}' 2>/dev/null | grep 'Privileged=true' || true) |
|
if [[ -n "$privileged_containers" ]]; then |
|
alert "Privileged containers found (full host access — critical risk):" |
|
echo "$privileged_containers" | tee -a "$REPORT_FILE" |
|
else |
|
ok "No privileged containers running." |
|
fi |
|
|
|
# Containers with host network mode |
|
log "" |
|
log "${BOLD}── Containers with Host Network ─────────────────────${RESET}" |
|
docker ps -q 2>/dev/null | xargs -I{} docker inspect {} --format '{{.Name}}: Network={{.HostConfig.NetworkMode}}' 2>/dev/null | grep 'NetworkMode=host' | tee -a "$REPORT_FILE" || ok "No containers using host network mode." |
|
|
|
# Docker volumes |
|
log "" |
|
log "${BOLD}── Docker Volumes ─────────────────────────────────────${RESET}" |
|
docker volume ls 2>/dev/null | tee -a "$REPORT_FILE" |
|
|
|
# Docker networks |
|
log "" |
|
log "${BOLD}── Docker Networks ───────────────────────────────────${RESET}" |
|
docker network ls 2>/dev/null | tee -a "$REPORT_FILE" |
|
|
|
# Docker socket exposure (major privilege escalation vector) |
|
log "" |
|
log "${BOLD}── Docker Socket (/var/run/docker.sock) ─────────────${RESET}" |
|
if [[ -S /var/run/docker.sock ]]; then |
|
local sock_perms |
|
sock_perms=$(stat -c '%a %U:%G' /var/run/docker.sock 2>/dev/null) |
|
warn "Docker socket is present: /var/run/docker.sock (${sock_perms})" |
|
warn "Mounting this socket into a container grants root-equivalent access to the host." |
|
# Check which containers mount the socket |
|
local sock_containers |
|
sock_containers=$(docker ps -q 2>/dev/null | xargs -I{} docker inspect {} --format '{{.Name}}: {{.HostConfig.Binds}}' 2>/dev/null | grep 'docker.sock' || true) |
|
[[ -n "$sock_containers" ]] && alert "Containers with docker.sock mounted: ${sock_containers}" |
|
fi |
|
} |
|
|
|
# ───────────────────────────────────────────────────────────────────────────── |
|
# SECTION 13 — Kubernetes Audit |
|
# ───────────────────────────────────────────────────────────────────────────── |
|
|
|
audit_kubernetes() { |
|
section "Kubernetes Audit" |
|
|
|
local k8s_found=false |
|
|
|
# Check for kubectl |
|
if cmd_exists kubectl; then |
|
k8s_found=true |
|
log "${BOLD}── kubectl Version & Cluster Info ───────────────────${RESET}" |
|
kubectl version --short 2>/dev/null | tee -a "$REPORT_FILE" || true |
|
kubectl cluster-info 2>/dev/null | tee -a "$REPORT_FILE" || warn "Cannot reach Kubernetes cluster." |
|
|
|
log "" |
|
log "${BOLD}── Node Status ───────────────────────────────────────${RESET}" |
|
kubectl get nodes -o wide 2>/dev/null | tee -a "$REPORT_FILE" || true |
|
|
|
log "" |
|
log "${BOLD}── Pods Across All Namespaces ───────────────────────${RESET}" |
|
kubectl get pods --all-namespaces 2>/dev/null | tee -a "$REPORT_FILE" || true |
|
|
|
log "" |
|
log "${BOLD}── Privileged Pods ───────────────────────────────────${RESET}" |
|
kubectl get pods --all-namespaces -o json 2>/dev/null | \ |
|
python3 -c " |
|
import json, sys |
|
data = json.load(sys.stdin) |
|
for item in data.get('items', []): |
|
ns = item['metadata']['namespace'] |
|
name = item['metadata']['name'] |
|
for c in item['spec'].get('containers', []): |
|
sc = c.get('securityContext', {}) |
|
if sc.get('privileged'): |
|
print(f'[ALERT] Privileged pod: {ns}/{name} (container: {c[\"name\"]})') |
|
" 2>/dev/null | tee -a "$REPORT_FILE" || true |
|
|
|
log "" |
|
log "${BOLD}── RBAC — ClusterRoleBindings ───────────────────────${RESET}" |
|
kubectl get clusterrolebindings -o wide 2>/dev/null | head -30 | tee -a "$REPORT_FILE" || true |
|
|
|
log "" |
|
log "${BOLD}── Network Policies ──────────────────────────────────${RESET}" |
|
local np_count |
|
np_count=$(kubectl get networkpolicies --all-namespaces 2>/dev/null | wc -l) |
|
if [[ "$np_count" -lt 2 ]]; then |
|
warn "No NetworkPolicies found. All pod-to-pod traffic is allowed by default." |
|
else |
|
ok "NetworkPolicies are configured." |
|
kubectl get networkpolicies --all-namespaces 2>/dev/null | tee -a "$REPORT_FILE" |
|
fi |
|
|
|
log "" |
|
log "${BOLD}── Secrets Count per Namespace ──────────────────────${RESET}" |
|
kubectl get secrets --all-namespaces 2>/dev/null | tee -a "$REPORT_FILE" || true |
|
|
|
log "" |
|
log "${BOLD}── Services (LoadBalancer / NodePort — public exposure) ──${RESET}" |
|
kubectl get svc --all-namespaces 2>/dev/null | grep -E 'LoadBalancer|NodePort' | tee -a "$REPORT_FILE" || info "No LoadBalancer or NodePort services found." |
|
fi |
|
|
|
# kubelet process |
|
if pgrep -x kubelet &>/dev/null; then |
|
k8s_found=true |
|
ok "kubelet process is running on this node." |
|
fi |
|
|
|
# k3s / k0s / microk8s |
|
for alt in k3s microk8s k0s; do |
|
if cmd_exists "$alt"; then |
|
k8s_found=true |
|
info "Lightweight Kubernetes distribution detected: ${alt}" |
|
fi |
|
done |
|
|
|
[[ "$k8s_found" == false ]] && info "Kubernetes does not appear to be installed or running on this system." |
|
} |
|
|
|
# ───────────────────────────────────────────────────────────────────────────── |
|
# SECTION 14 — Package & Software Audit |
|
# ───────────────────────────────────────────────────────────────────────────── |
|
|
|
audit_packages() { |
|
section "Package & Software Audit" |
|
|
|
# Pending security updates |
|
log "${BOLD}── Pending Security Updates ─────────────────────────${RESET}" |
|
if cmd_exists apt; then |
|
apt-get update -qq 2>/dev/null |
|
local upgradeable |
|
upgradeable=$(apt list --upgradeable 2>/dev/null | grep -c '\[upgradeable' || echo 0) |
|
info "Packages with available updates: ${upgradeable}" |
|
[[ "$upgradeable" -gt 0 ]] && warn "Run 'apt upgrade' to apply updates." || ok "System is fully up-to-date." |
|
apt list --upgradeable 2>/dev/null | head -20 | tee -a "$REPORT_FILE" |
|
elif cmd_exists yum; then |
|
yum check-update --quiet 2>/dev/null | tee -a "$REPORT_FILE" || true |
|
elif cmd_exists dnf; then |
|
dnf check-update --quiet 2>/dev/null | tee -a "$REPORT_FILE" || true |
|
fi |
|
|
|
# Installed package count |
|
log "" |
|
log "${BOLD}── Installed Package Count ──────────────────────────${RESET}" |
|
if cmd_exists dpkg; then |
|
info "dpkg packages installed: $(dpkg -l 2>/dev/null | grep -c '^ii')" |
|
elif cmd_exists rpm; then |
|
info "RPM packages installed: $(rpm -qa 2>/dev/null | wc -l)" |
|
fi |
|
|
|
# Check for known security-relevant tools |
|
log "" |
|
log "${BOLD}── Security Tool Availability ───────────────────────${RESET}" |
|
for tool in fail2ban rkhunter chkrootkit aide auditd lynis apparmor selinux; do |
|
cmd_exists "$tool" && ok "${tool} is installed." || info "${tool} not found." |
|
done |
|
|
|
# AppArmor status |
|
log "" |
|
log "${BOLD}── AppArmor Status ───────────────────────────────────${RESET}" |
|
if cmd_exists aa-status; then |
|
aa-status 2>/dev/null | tee -a "$REPORT_FILE" |
|
elif cmd_exists apparmor_status; then |
|
apparmor_status 2>/dev/null | tee -a "$REPORT_FILE" |
|
else |
|
info "AppArmor not found." |
|
fi |
|
|
|
# SELinux status |
|
log "" |
|
log "${BOLD}── SELinux Status ────────────────────────────────────${RESET}" |
|
if cmd_exists getenforce; then |
|
local selinux_mode |
|
selinux_mode=$(getenforce 2>/dev/null) |
|
info "SELinux mode: ${selinux_mode}" |
|
[[ "$selinux_mode" == "Enforcing" ]] && ok "SELinux is enforcing." || warn "SELinux is ${selinux_mode} — not actively enforcing policies." |
|
elif cmd_exists sestatus; then |
|
sestatus 2>/dev/null | tee -a "$REPORT_FILE" |
|
else |
|
info "SELinux not found on this system." |
|
fi |
|
} |
|
|
|
# ───────────────────────────────────────────────────────────────────────────── |
|
# SECTION 15 — Kernel Parameters & Security Hardening (sysctl) |
|
# ───────────────────────────────────────────────────────────────────────────── |
|
|
|
audit_kernel() { |
|
section "Kernel Parameters & Security Hardening" |
|
|
|
# Helper: read a sysctl value |
|
sysctl_get() { sysctl -n "$1" 2>/dev/null || echo "N/A"; } |
|
|
|
# IP forwarding (required for routers/NAT — risky on regular servers) |
|
local ipfwd |
|
ipfwd=$(sysctl_get net.ipv4.ip_forward) |
|
[[ "$ipfwd" == "1" ]] && warn "IP forwarding is ENABLED (net.ipv4.ip_forward=1) — acceptable for routers/k8s nodes only." || ok "IP forwarding is disabled." |
|
|
|
# ICMP redirects |
|
local icmp_redir |
|
icmp_redir=$(sysctl_get net.ipv4.conf.all.accept_redirects) |
|
[[ "$icmp_redir" == "1" ]] && warn "ICMP redirects accepted (net.ipv4.conf.all.accept_redirects=1) — risk of routing manipulation." || ok "ICMP redirects disabled." |
|
|
|
# Source routing |
|
local src_route |
|
src_route=$(sysctl_get net.ipv4.conf.all.accept_source_route) |
|
[[ "$src_route" == "1" ]] && alert "Source routing ENABLED (net.ipv4.conf.all.accept_source_route=1) — disable immediately!" || ok "Source routing disabled." |
|
|
|
# SYN cookies (DDoS protection) |
|
local syn_cookies |
|
syn_cookies=$(sysctl_get net.ipv4.tcp_syncookies) |
|
[[ "$syn_cookies" == "1" ]] && ok "TCP SYN cookies enabled (DDoS protection)." || warn "TCP SYN cookies disabled — vulnerable to SYN flood attacks." |
|
|
|
# ASLR (Address Space Layout Randomization) |
|
local aslr |
|
aslr=$(sysctl_get kernel.randomize_va_space) |
|
case "$aslr" in |
|
2) ok "ASLR is fully enabled (kernel.randomize_va_space=2)." ;; |
|
1) warn "ASLR is partially enabled (=1). Set to 2 for full protection." ;; |
|
0) alert "ASLR is DISABLED (=0) — serious memory exploitation risk!" ;; |
|
*) info "ASLR status unknown." ;; |
|
esac |
|
|
|
# Core dumps |
|
local core |
|
core=$(sysctl_get kernel.core_pattern) |
|
info "Core dump pattern: ${core}" |
|
local core_limit |
|
core_limit=$(ulimit -c 2>/dev/null || echo "unknown") |
|
[[ "$core_limit" == "unlimited" ]] && warn "Core dumps are unlimited — may expose sensitive memory data." |
|
|
|
# Dmesg restriction |
|
local dmesg_restrict |
|
dmesg_restrict=$(sysctl_get kernel.dmesg_restrict) |
|
[[ "$dmesg_restrict" == "1" ]] && ok "dmesg output is restricted to root (kernel.dmesg_restrict=1)." || warn "dmesg is readable by non-root users (kernel.dmesg_restrict=0)." |
|
|
|
# ptrace scope |
|
local ptrace |
|
ptrace=$(sysctl_get kernel.yama.ptrace_scope) |
|
case "$ptrace" in |
|
0) warn "ptrace scope is 0 — any process can be traced by any other." ;; |
|
1) ok "ptrace scope is 1 — only parent processes can trace children." ;; |
|
2) ok "ptrace scope is 2 — only admin can use ptrace." ;; |
|
3) ok "ptrace is fully disabled (scope=3)." ;; |
|
*) info "ptrace scope: ${ptrace:-not configured}" ;; |
|
esac |
|
|
|
# NX/DEP (Execute Disable — hardware level) |
|
if grep -q ' nx ' /proc/cpuinfo 2>/dev/null; then |
|
ok "NX/XD (No-Execute / Execute Disable) bit is supported by CPU." |
|
else |
|
warn "NX bit not detected in CPU flags." |
|
fi |
|
|
|
# Spectre/Meltdown mitigations |
|
log "" |
|
log "${BOLD}── CPU Vulnerability Mitigations ────────────────────${RESET}" |
|
if [[ -d /sys/devices/system/cpu/vulnerabilities ]]; then |
|
for v in /sys/devices/system/cpu/vulnerabilities/*; do |
|
local vuln_status |
|
vuln_status=$(cat "$v" 2>/dev/null) |
|
local vuln_name |
|
vuln_name=$(basename "$v") |
|
if echo "$vuln_status" | grep -qi 'vulnerable'; then |
|
alert "${vuln_name}: ${vuln_status}" |
|
else |
|
ok "${vuln_name}: ${vuln_status}" |
|
fi |
|
done |
|
else |
|
info "CPU vulnerability information not available at /sys/devices/system/cpu/vulnerabilities" |
|
fi |
|
} |
|
|
|
# ───────────────────────────────────────────────────────────────────────────── |
|
# SECTION 16 — Log & Audit Trail Inspection |
|
# ───────────────────────────────────────────────────────────────────────────── |
|
|
|
audit_logs() { |
|
section "Log & Audit Trail Inspection" |
|
|
|
# Check auditd |
|
log "${BOLD}── Auditd (Linux Audit Daemon) ──────────────────────${RESET}" |
|
if cmd_exists auditctl; then |
|
if systemctl is-active auditd &>/dev/null || service auditd status &>/dev/null 2>&1; then |
|
ok "auditd is running." |
|
auditctl -l 2>/dev/null | tee -a "$REPORT_FILE" |
|
else |
|
warn "auditd is installed but not running." |
|
fi |
|
else |
|
warn "auditd not installed. Security events may not be logged." |
|
fi |
|
|
|
# Syslog / journald |
|
log "" |
|
log "${BOLD}── Logging Daemon ────────────────────────────────────${RESET}" |
|
if cmd_exists journalctl; then |
|
ok "systemd-journald is available." |
|
journalctl --disk-usage 2>/dev/null | tee -a "$REPORT_FILE" |
|
fi |
|
for log_daemon in rsyslog syslog syslog-ng; do |
|
systemctl is-active "$log_daemon" &>/dev/null 2>&1 && ok "${log_daemon} is active." || true |
|
done |
|
|
|
# Recent auth failures (from journal or auth.log) |
|
log "" |
|
log "${BOLD}── Recent Authentication Failures (last 25) ─────────${RESET}" |
|
if cmd_exists journalctl; then |
|
journalctl -u ssh -u sshd --since "24 hours ago" --no-pager 2>/dev/null | \ |
|
grep -i 'failed\|invalid\|error' | tail -25 | tee -a "$REPORT_FILE" || info "No recent SSH failures in journal." |
|
elif [[ -f /var/log/auth.log ]]; then |
|
grep -i 'failed\|invalid' /var/log/auth.log 2>/dev/null | tail -25 | tee -a "$REPORT_FILE" |
|
elif [[ -f /var/log/secure ]]; then |
|
grep -i 'failed\|invalid' /var/log/secure 2>/dev/null | tail -25 | tee -a "$REPORT_FILE" |
|
fi |
|
|
|
# Sudo usage audit |
|
log "" |
|
log "${BOLD}── Recent sudo Usage (last 20) ──────────────────────${RESET}" |
|
if cmd_exists journalctl; then |
|
journalctl -u sudo --no-pager --since "7 days ago" 2>/dev/null | tail -20 | tee -a "$REPORT_FILE" || true |
|
fi |
|
grep 'sudo' /var/log/auth.log 2>/dev/null | tail -20 | tee -a "$REPORT_FILE" || true |
|
|
|
# Log rotation config |
|
log "" |
|
log "${BOLD}── Log Rotation (logrotate) ─────────────────────────${RESET}" |
|
[[ -f /etc/logrotate.conf ]] && ok "logrotate.conf found." || warn "No logrotate.conf found." |
|
if [[ -d /etc/logrotate.d ]]; then |
|
info "logrotate.d entries: $(ls /etc/logrotate.d/ | wc -l)" |
|
fi |
|
} |
|
|
|
# ───────────────────────────────────────────────────────────────────────────── |
|
# SECTION 17 — Network Configuration & Security |
|
# ───────────────────────────────────────────────────────────────────────────── |
|
|
|
audit_network() { |
|
section "Network Configuration & Security" |
|
|
|
# DNS configuration |
|
log "${BOLD}── DNS Configuration ─────────────────────────────────${RESET}" |
|
cat /etc/resolv.conf 2>/dev/null | tee -a "$REPORT_FILE" |
|
|
|
# Hosts file |
|
log "" |
|
log "${BOLD}── /etc/hosts ────────────────────────────────────────${RESET}" |
|
cat /etc/hosts 2>/dev/null | tee -a "$REPORT_FILE" |
|
|
|
# Routing table |
|
log "" |
|
log "${BOLD}── Routing Table ─────────────────────────────────────${RESET}" |
|
if cmd_exists ip; then |
|
ip route show 2>/dev/null | tee -a "$REPORT_FILE" |
|
else |
|
route -n 2>/dev/null | tee -a "$REPORT_FILE" |
|
fi |
|
|
|
# ARP cache |
|
log "" |
|
log "${BOLD}── ARP Cache ─────────────────────────────────────────${RESET}" |
|
if cmd_exists ip; then |
|
ip neigh show 2>/dev/null | tee -a "$REPORT_FILE" |
|
else |
|
arp -n 2>/dev/null | tee -a "$REPORT_FILE" |
|
fi |
|
|
|
# hosts.allow / hosts.deny (TCP Wrappers) |
|
log "" |
|
log "${BOLD}── TCP Wrappers (hosts.allow / hosts.deny) ──────────${RESET}" |
|
for f in /etc/hosts.allow /etc/hosts.deny; do |
|
if [[ -f "$f" ]]; then |
|
info "Contents of ${f}:" |
|
grep -v '^#\|^$' "$f" 2>/dev/null | tee -a "$REPORT_FILE" || true |
|
fi |
|
done |
|
|
|
# Wireshark / tcpdump check (capture tools) |
|
log "" |
|
log "${BOLD}── Packet Capture Tools ──────────────────────────────${RESET}" |
|
for tool in tcpdump wireshark tshark; do |
|
cmd_exists "$tool" && warn "${tool} is installed — ensure it is authorized." || info "${tool}: not installed." |
|
done |
|
} |
|
|
|
# ───────────────────────────────────────────────────────────────────────────── |
|
# SECTION 18 — Sensitive File & Directory Audit |
|
# ───────────────────────────────────────────────────────────────────────────── |
|
|
|
audit_sensitive_files() { |
|
section "Sensitive Files & Directories" |
|
|
|
# Environment files that might contain secrets |
|
log "${BOLD}── Potential Secret Files ────────────────────────────${RESET}" |
|
local secret_patterns=(".env" "*.pem" "*.key" "id_rsa" "id_dsa" "id_ecdsa" "id_ed25519" "*.p12" "*.pfx" "*.kdbx") |
|
for pattern in "${secret_patterns[@]}"; do |
|
find /home /root /etc /var /opt 2>/dev/null -name "$pattern" -not -path '*/proc/*' 2>/dev/null | head -10 | while read -r f; do |
|
warn "Potentially sensitive file found: ${f} ($(stat -c '%a %U:%G' "$f" 2>/dev/null))" |
|
done |
|
done |
|
|
|
# Private SSH keys with bad permissions |
|
log "" |
|
log "${BOLD}── Private SSH Keys Permission Check ────────────────${RESET}" |
|
find /home /root 2>/dev/null -name 'id_*' -not -name '*.pub' 2>/dev/null | while read -r key; do |
|
local perm |
|
perm=$(stat -c '%a' "$key" 2>/dev/null) |
|
[[ "$perm" != "600" && "$perm" != "400" ]] && alert "SSH key ${key} has insecure permissions: ${perm} (should be 600)" || ok "Key ${key}: ${perm}" |
|
done |
|
|
|
# .bash_history with credentials |
|
log "" |
|
log "${BOLD}── Shell History Files ───────────────────────────────${RESET}" |
|
find /home /root 2>/dev/null -name '.*history' 2>/dev/null | while read -r hist; do |
|
info "Found: ${hist} ($(wc -l < "$hist" 2>/dev/null) lines)" |
|
# Hint at potential credential leaks without printing actual content |
|
if grep -qiE '(password|passwd|secret|token|api_key|auth)' "$hist" 2>/dev/null; then |
|
alert "${hist} may contain sensitive keywords (password/token/secret). Review manually." |
|
fi |
|
done |
|
|
|
# World-readable /root |
|
log "" |
|
log "${BOLD}── /root Directory Permissions ──────────────────────${RESET}" |
|
if [[ -d /root ]]; then |
|
local root_perm |
|
root_perm=$(stat -c '%a' /root) |
|
[[ "$root_perm" != "700" && "$root_perm" != "750" ]] && \ |
|
alert "/root directory permissions: ${root_perm} (should be 700)." || \ |
|
ok "/root directory permissions: ${root_perm}" |
|
fi |
|
} |
|
|
|
# ───────────────────────────────────────────────────────────────────────────── |
|
# SECTION 19 — Systemd & Init Security |
|
# ───────────────────────────────────────────────────────────────────────────── |
|
|
|
audit_init() { |
|
section "Init System & Startup Security" |
|
|
|
log "${BOLD}── Init System ───────────────────────────────────────${RESET}" |
|
local init_cmd |
|
init_cmd=$(readlink /proc/1/exe 2>/dev/null || ls -la /proc/1/exe 2>/dev/null) |
|
info "PID 1 (init): ${init_cmd}" |
|
|
|
# Systemd security hardening for service units |
|
log "" |
|
log "${BOLD}── systemd Service Security Hardening (sample) ──────${RESET}" |
|
if cmd_exists systemctl; then |
|
info "Checking for services without security sandboxing (first 15 running services):" |
|
systemctl list-units --type=service --state=running --no-pager 2>/dev/null | awk '/\.service/{print $1}' | head -15 | while read -r svc; do |
|
local priv_users protect_sys |
|
priv_users=$(systemctl show "$svc" -p PrivateUsers --value 2>/dev/null) |
|
protect_sys=$(systemctl show "$svc" -p ProtectSystem --value 2>/dev/null) |
|
if [[ "$priv_users" == "no" && "$protect_sys" == "no" ]]; then |
|
warn "${svc}: No PrivateUsers or ProtectSystem hardening." |
|
fi |
|
done |
|
fi |
|
|
|
# rc.local / legacy init scripts |
|
log "" |
|
log "${BOLD}── Legacy Startup Scripts ────────────────────────────${RESET}" |
|
for rc in /etc/rc.local /etc/rc.d/rc.local; do |
|
if [[ -f "$rc" && -x "$rc" ]]; then |
|
warn "${rc} exists and is executable — review its content:" |
|
cat "$rc" | tee -a "$REPORT_FILE" |
|
fi |
|
done |
|
} |
|
|
|
# ───────────────────────────────────────────────────────────────────────────── |
|
# SECTION 20 — Final Report Summary |
|
# ───────────────────────────────────────────────────────────────────────────── |
|
|
|
audit_summary() { |
|
local line="════════════════════════════════════════════════════════════════" |
|
log "" |
|
log "${BOLD}${MAGENTA}${line}${RESET}" |
|
log "${BOLD}${MAGENTA} AUDIT COMPLETE${RESET}" |
|
log "${BOLD}${MAGENTA}${line}${RESET}" |
|
log "" |
|
log "${BOLD}Host :${RESET} $(hostname -f 2>/dev/null || hostname)" |
|
log "${BOLD}Date :${RESET} $(date '+%A %d %B %Y — %H:%M:%S')" |
|
log "${BOLD}Report :${RESET} ${REPORT_FILE}" |
|
log "" |
|
|
|
# Count findings by severity |
|
local alerts warns oks |
|
alerts=$(grep -c '^\[ALERT\]' "$REPORT_FILE" 2>/dev/null || echo 0) |
|
warns=$(grep -c '^\[WARN\]' "$REPORT_FILE" 2>/dev/null || echo 0) |
|
oks=$(grep -c '^\[OK\]' "$REPORT_FILE" 2>/dev/null || echo 0) |
|
|
|
log "${BOLD}${RED} ALERTS : ${alerts}${RESET}" |
|
log "${BOLD}${YELLOW} WARNINGS: ${warns}${RESET}" |
|
log "${BOLD}${GREEN} OK : ${oks}${RESET}" |
|
log "" |
|
|
|
if [[ "$alerts" -gt 0 ]]; then |
|
log "${BOLD}${RED} ► CRITICAL FINDINGS (review immediately):${RESET}" |
|
grep '^\[ALERT\]' "$REPORT_FILE" | tee /dev/tty |
|
fi |
|
|
|
log "" |
|
log "${BOLD}${MAGENTA}${line}${RESET}" |
|
log "Full report saved to: ${REPORT_FILE}" |
|
log "${BOLD}${MAGENTA}${line}${RESET}" |
|
} |
|
|
|
# ───────────────────────────────────────────────────────────────────────────── |
|
# MAIN — Run all audit modules in order |
|
# ───────────────────────────────────────────────────────────────────────────── |
|
|
|
main() { |
|
# Print the audit banner to terminal |
|
printf "%b\n" "${BOLD}${CYAN}" |
|
printf "%b\n" " ██╗ ██╗███╗ ██╗██╗ ██╗██╗ ██╗ █████╗ ██╗ ██╗██████╗ ██╗████████╗" |
|
printf "%b\n" " ██║ ██║████╗ ██║██║ ██║╚██╗██╔╝ ██╔══██╗██║ ██║██╔══██╗██║╚══██╔══╝" |
|
printf "%b\n" " ██║ ██║██╔██╗ ██║██║ ██║ ╚███╔╝ ███████║██║ ██║██║ ██║██║ ██║ " |
|
printf "%b\n" " ██║ ██║██║╚██╗██║██║ ██║ ██╔██╗ ██╔══██║██║ ██║██║ ██║██║ ██║ " |
|
printf "%b\n" " ███████╗██║██║ ╚████║╚██████╔╝██╔╝ ██╗ ██║ ██║╚██████╔╝██████╔╝██║ ██║ " |
|
printf "%b\n" " ╚══════╝╚═╝╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ " |
|
printf "%b\n" "${RESET}" |
|
printf "%b\n" "${BOLD} Linux Server Audit — v2.0.0${RESET}" |
|
printf "%b\n" " $(date '+%Y-%m-%d %H:%M:%S')" |
|
echo "" |
|
|
|
# Initialize the report file header |
|
{ |
|
echo "===============================================================================" |
|
echo " Linux Server Security & Health Audit Report" |
|
echo " Generated by: linux_audit.sh v2.0.0" |
|
echo " Host: $(hostname -f 2>/dev/null || hostname)" |
|
echo " Date: $(date '+%Y-%m-%d %H:%M:%S %Z')" |
|
echo " OS : $(grep PRETTY_NAME /etc/os-release 2>/dev/null | cut -d= -f2 | tr -d '"')" |
|
echo "===============================================================================" |
|
} > "$REPORT_FILE" |
|
|
|
preflight_checks |
|
audit_system_info |
|
audit_hardware |
|
audit_users |
|
audit_ssh |
|
audit_firewall |
|
audit_ports |
|
audit_services |
|
audit_permissions |
|
audit_cron |
|
audit_docker |
|
audit_kubernetes |
|
audit_packages |
|
audit_kernel |
|
audit_logs |
|
audit_network |
|
audit_sensitive_files |
|
audit_init |
|
audit_summary |
|
} |
|
|
|
main "$@" |