Sync all scripts from website downloads — 352 scripts total
Includes updated JS challenge scripts with Claude-User whitelist, same-site referer bypass, Blackbox-Exporter allowed bot, and all new exporters, cheat sheets, and automation scripts.
This commit is contained in:
@@ -0,0 +1,924 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
#########################################################################################
|
||||
#### linux-baseline-checks.sh — Post-provision baseline security validation ####
|
||||
#### Zero external dependencies. Validates freshly provisioned or hardened servers. ####
|
||||
#### Requires: bash 4+, curl (optional), openssl (optional), ss ####
|
||||
#### ####
|
||||
#### Author: Phil Connor ####
|
||||
#### Contact: contact@mylinux.work ####
|
||||
#### License: MIT ####
|
||||
#### Version 1.00 ####
|
||||
#### ####
|
||||
#### Usage: ####
|
||||
#### ./linux-baseline-checks.sh ####
|
||||
#### ./linux-baseline-checks.sh --skip-updates --format tap ####
|
||||
#### ####
|
||||
#### See --help for all options. ####
|
||||
#########################################################################################
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Defaults ──────────────────────────────────────────────────────────
|
||||
SKIP_SSH="${SKIP_SSH:-false}"
|
||||
SKIP_FIREWALL="${SKIP_FIREWALL:-false}"
|
||||
SKIP_UPDATES="${SKIP_UPDATES:-false}"
|
||||
EXPECTED_SERVICES="${EXPECTED_SERVICES:-}"
|
||||
CURL_TIMEOUT="${CURL_TIMEOUT:-10}"
|
||||
OUTPUT_FORMAT="${OUTPUT_FORMAT:-text}" # text, tap, junit
|
||||
JUNIT_FILE="${JUNIT_FILE:-baseline-results.xml}"
|
||||
VERBOSE="${VERBOSE:-false}"
|
||||
COLOR="${COLOR:-auto}"
|
||||
|
||||
# ── State ─────────────────────────────────────────────────────────────
|
||||
PASS=0
|
||||
FAIL=0
|
||||
SKIP=0
|
||||
TOTAL=0
|
||||
RESULTS=()
|
||||
START_TIME=""
|
||||
|
||||
# ── Colors ────────────────────────────────────────────────────────────
|
||||
setup_colors() {
|
||||
if [[ "$COLOR" == "never" ]]; then
|
||||
RED="" GREEN="" YELLOW="" BLUE="" BOLD="" RESET=""
|
||||
return
|
||||
fi
|
||||
if [[ "$COLOR" == "always" ]] || [[ -t 1 ]]; then
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[0;33m'
|
||||
BLUE='\033[0;34m'
|
||||
BOLD='\033[1m'
|
||||
RESET='\033[0m'
|
||||
else
|
||||
RED="" GREEN="" YELLOW="" BLUE="" BOLD="" RESET=""
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Logging ───────────────────────────────────────────────────────────
|
||||
log() { echo -e "${BLUE}[INFO]${RESET} $*"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; }
|
||||
err() { echo -e "${RED}[ERROR]${RESET} $*" >&2; }
|
||||
verbose() { if [[ "$VERBOSE" == "true" ]]; then echo -e "${BLUE}[DEBUG]${RESET} $*"; fi; }
|
||||
|
||||
# ── Test Result Recording ─────────────────────────────────────────────
|
||||
record_pass() {
|
||||
local name="$1"
|
||||
local detail="${2:-}"
|
||||
((PASS++)) || true
|
||||
((TOTAL++)) || true
|
||||
RESULTS+=("PASS|${name}|${detail}")
|
||||
if [[ "$OUTPUT_FORMAT" == "tap" ]]; then
|
||||
echo "ok ${TOTAL} - ${name}"
|
||||
else
|
||||
echo -e " ${GREEN}✓${RESET} ${name}${detail:+ — ${detail}}"
|
||||
fi
|
||||
}
|
||||
|
||||
record_fail() {
|
||||
local name="$1"
|
||||
local detail="${2:-}"
|
||||
((FAIL++)) || true
|
||||
((TOTAL++)) || true
|
||||
RESULTS+=("FAIL|${name}|${detail}")
|
||||
if [[ "$OUTPUT_FORMAT" == "tap" ]]; then
|
||||
echo "not ok ${TOTAL} - ${name}"
|
||||
[[ -n "$detail" ]] && echo " # ${detail}"
|
||||
else
|
||||
echo -e " ${RED}✗${RESET} ${name}${detail:+ — ${detail}}"
|
||||
fi
|
||||
}
|
||||
|
||||
record_skip() {
|
||||
local name="$1"
|
||||
local reason="${2:-}"
|
||||
((SKIP++)) || true
|
||||
((TOTAL++)) || true
|
||||
RESULTS+=("SKIP|${name}|${reason}")
|
||||
if [[ "$OUTPUT_FORMAT" == "tap" ]]; then
|
||||
echo "ok ${TOTAL} - ${name} # SKIP ${reason}"
|
||||
else
|
||||
echo -e " ${YELLOW}⊘${RESET} ${name}${reason:+ — ${reason}}"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Helper: read sshd config value ───────────────────────────────────
|
||||
sshd_config_value() {
|
||||
local key="$1"
|
||||
local value=""
|
||||
# Check drop-in configs first, then main config
|
||||
if [[ -d /etc/ssh/sshd_config.d ]]; then
|
||||
value=$(grep -rhi "^${key}" /etc/ssh/sshd_config.d/ 2>/dev/null | tail -1 | awk '{print $2}') || true
|
||||
fi
|
||||
if [[ -z "$value" ]]; then
|
||||
value=$(grep -hi "^${key}" /etc/ssh/sshd_config 2>/dev/null | tail -1 | awk '{print $2}') || true
|
||||
fi
|
||||
echo "$value"
|
||||
}
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
# TEST SUITES
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
# ── 1. SSH Hardening ─────────────────────────────────────────────────
|
||||
test_ssh() {
|
||||
if [[ "$SKIP_SSH" == "true" ]]; then
|
||||
echo ""
|
||||
echo -e "${BOLD}SSH Hardening${RESET}"
|
||||
record_skip "SSH hardening" "SKIP_SSH=true"
|
||||
return
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}SSH Hardening${RESET}"
|
||||
|
||||
if [[ ! -f /etc/ssh/sshd_config ]]; then
|
||||
record_skip "SSH hardening" "sshd_config not found"
|
||||
return
|
||||
fi
|
||||
|
||||
# PermitRootLogin
|
||||
local root_login
|
||||
root_login=$(sshd_config_value "PermitRootLogin")
|
||||
verbose "PermitRootLogin = ${root_login:-<unset>}"
|
||||
|
||||
if [[ "$root_login" == "no" || "$root_login" == "prohibit-password" ]]; then
|
||||
record_pass "PermitRootLogin disabled" "${root_login}"
|
||||
elif [[ -z "$root_login" ]]; then
|
||||
record_fail "PermitRootLogin disabled" "not explicitly set (default may allow root)"
|
||||
else
|
||||
record_fail "PermitRootLogin disabled" "set to ${root_login}"
|
||||
fi
|
||||
|
||||
# PasswordAuthentication
|
||||
local pass_auth
|
||||
pass_auth=$(sshd_config_value "PasswordAuthentication")
|
||||
verbose "PasswordAuthentication = ${pass_auth:-<unset>}"
|
||||
|
||||
if [[ "$pass_auth" == "no" ]]; then
|
||||
record_pass "PasswordAuthentication disabled" "key-only auth enforced"
|
||||
elif [[ -z "$pass_auth" ]]; then
|
||||
record_fail "PasswordAuthentication disabled" "not explicitly set (default is yes)"
|
||||
else
|
||||
record_fail "PasswordAuthentication disabled" "set to ${pass_auth}"
|
||||
fi
|
||||
|
||||
# Protocol 2 (older systems may have Protocol directive)
|
||||
local protocol
|
||||
protocol=$(sshd_config_value "Protocol")
|
||||
verbose "Protocol = ${protocol:-<unset>}"
|
||||
|
||||
if [[ -z "$protocol" || "$protocol" == "2" ]]; then
|
||||
record_pass "SSH Protocol 2" "${protocol:-default (2)}"
|
||||
else
|
||||
record_fail "SSH Protocol 2" "set to ${protocol}"
|
||||
fi
|
||||
|
||||
# Non-standard port detection
|
||||
local ssh_port
|
||||
ssh_port=$(sshd_config_value "Port")
|
||||
if [[ -z "$ssh_port" ]]; then
|
||||
ssh_port="22"
|
||||
fi
|
||||
verbose "SSH Port = ${ssh_port}"
|
||||
|
||||
if [[ "$ssh_port" != "22" ]]; then
|
||||
record_pass "SSH non-standard port" "port ${ssh_port}"
|
||||
else
|
||||
record_fail "SSH non-standard port" "still using default port 22"
|
||||
fi
|
||||
|
||||
# PubkeyAuthentication
|
||||
local pubkey_auth
|
||||
pubkey_auth=$(sshd_config_value "PubkeyAuthentication")
|
||||
verbose "PubkeyAuthentication = ${pubkey_auth:-<unset>}"
|
||||
|
||||
if [[ -z "$pubkey_auth" || "$pubkey_auth" == "yes" ]]; then
|
||||
record_pass "PubkeyAuthentication enabled" "${pubkey_auth:-default (yes)}"
|
||||
else
|
||||
record_fail "PubkeyAuthentication enabled" "set to ${pubkey_auth}"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── 2. Firewall ──────────────────────────────────────────────────────
|
||||
test_firewall() {
|
||||
if [[ "$SKIP_FIREWALL" == "true" ]]; then
|
||||
echo ""
|
||||
echo -e "${BOLD}Firewall${RESET}"
|
||||
record_skip "Firewall" "SKIP_FIREWALL=true"
|
||||
return
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}Firewall${RESET}"
|
||||
|
||||
local fw_found=false
|
||||
|
||||
# ufw
|
||||
if command -v ufw &>/dev/null; then
|
||||
local ufw_status
|
||||
ufw_status=$(ufw status 2>/dev/null | head -1) || ufw_status=""
|
||||
verbose "ufw status: ${ufw_status}"
|
||||
|
||||
if [[ "$ufw_status" == *"active"* ]]; then
|
||||
record_pass "ufw active" "firewall enabled"
|
||||
fw_found=true
|
||||
|
||||
# Default deny
|
||||
local ufw_default
|
||||
ufw_default=$(ufw status verbose 2>/dev/null | grep "Default:" | head -1) || ufw_default=""
|
||||
if [[ "$ufw_default" == *"deny"* || "$ufw_default" == *"reject"* ]]; then
|
||||
record_pass "ufw default deny policy" "${ufw_default}"
|
||||
else
|
||||
record_fail "ufw default deny policy" "${ufw_default:-unknown}"
|
||||
fi
|
||||
else
|
||||
record_fail "ufw active" "${ufw_status:-not running}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# firewalld
|
||||
if command -v firewall-cmd &>/dev/null; then
|
||||
local fwd_state
|
||||
fwd_state=$(firewall-cmd --state 2>/dev/null) || fwd_state=""
|
||||
verbose "firewalld state: ${fwd_state}"
|
||||
|
||||
if [[ "$fwd_state" == "running" ]]; then
|
||||
record_pass "firewalld active" "firewall running"
|
||||
fw_found=true
|
||||
|
||||
# Default zone
|
||||
local default_zone
|
||||
default_zone=$(firewall-cmd --get-default-zone 2>/dev/null) || default_zone=""
|
||||
if [[ -n "$default_zone" ]]; then
|
||||
record_pass "firewalld default zone" "${default_zone}"
|
||||
fi
|
||||
elif [[ -z "$fwd_state" ]]; then
|
||||
verbose "firewalld not running"
|
||||
else
|
||||
record_fail "firewalld active" "${fwd_state}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# iptables (fallback)
|
||||
if [[ "$fw_found" == "false" ]] && command -v iptables &>/dev/null; then
|
||||
local ipt_rules
|
||||
ipt_rules=$(iptables -L INPUT -n 2>/dev/null | wc -l) || ipt_rules=0
|
||||
verbose "iptables INPUT rules: ${ipt_rules}"
|
||||
|
||||
if [[ "$ipt_rules" -gt 2 ]]; then
|
||||
record_pass "iptables rules present" "$((ipt_rules - 2)) rules in INPUT chain"
|
||||
fw_found=true
|
||||
|
||||
# Check default policy
|
||||
local ipt_policy
|
||||
ipt_policy=$(iptables -L INPUT -n 2>/dev/null | head -1 | grep -oP '\(policy \K[^)]+') || ipt_policy=""
|
||||
if [[ "$ipt_policy" == "DROP" || "$ipt_policy" == "REJECT" ]]; then
|
||||
record_pass "iptables default deny policy" "INPUT policy ${ipt_policy}"
|
||||
else
|
||||
record_fail "iptables default deny policy" "INPUT policy ${ipt_policy:-ACCEPT}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$fw_found" == "false" ]]; then
|
||||
record_fail "Firewall active" "no firewall detected (ufw/firewalld/iptables)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── 3. Time Sync ─────────────────────────────────────────────────────
|
||||
test_timesync() {
|
||||
echo ""
|
||||
echo -e "${BOLD}Time Sync${RESET}"
|
||||
|
||||
local sync_found=false
|
||||
|
||||
# chrony
|
||||
if command -v chronyc &>/dev/null; then
|
||||
local chrony_tracking
|
||||
chrony_tracking=$(chronyc tracking 2>/dev/null) || chrony_tracking=""
|
||||
|
||||
if [[ -n "$chrony_tracking" ]]; then
|
||||
record_pass "chronyd running" "chrony active"
|
||||
sync_found=true
|
||||
|
||||
# Check sources
|
||||
local chrony_sources
|
||||
chrony_sources=$(chronyc sources 2>/dev/null | grep -c '^\^' 2>/dev/null) || chrony_sources=0
|
||||
if [[ "$chrony_sources" -gt 0 ]]; then
|
||||
record_pass "NTP sources reachable" "${chrony_sources} source(s) configured"
|
||||
else
|
||||
record_fail "NTP sources reachable" "no sources responding"
|
||||
fi
|
||||
|
||||
# Drift check (system time offset)
|
||||
local offset
|
||||
offset=$(echo "$chrony_tracking" | grep -oP 'System time\s*:\s*\K[0-9.]+') || offset=""
|
||||
if [[ -n "$offset" ]]; then
|
||||
# Compare as string — anything under 1 second is fine
|
||||
local int_offset
|
||||
int_offset=$(echo "$offset" | cut -d. -f1)
|
||||
if [[ "${int_offset:-0}" -eq 0 ]]; then
|
||||
record_pass "Time drift acceptable" "${offset} seconds offset"
|
||||
else
|
||||
record_fail "Time drift acceptable" "${offset} seconds offset (>1s)"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# systemd-timesyncd
|
||||
if [[ "$sync_found" == "false" ]] && command -v timedatectl &>/dev/null; then
|
||||
local timesync_status
|
||||
timesync_status=$(timedatectl show --property=NTPSynchronized --value 2>/dev/null) || timesync_status=""
|
||||
verbose "NTPSynchronized = ${timesync_status}"
|
||||
|
||||
if [[ "$timesync_status" == "yes" ]]; then
|
||||
record_pass "systemd-timesyncd synchronized" "NTP active"
|
||||
sync_found=true
|
||||
elif systemctl is-active systemd-timesyncd &>/dev/null; then
|
||||
record_pass "systemd-timesyncd running" "service active"
|
||||
sync_found=true
|
||||
record_fail "NTP synchronized" "timesyncd running but not synchronized"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$sync_found" == "false" ]]; then
|
||||
record_fail "Time sync service" "no NTP service detected (chrony/systemd-timesyncd)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── 4. Services ──────────────────────────────────────────────────────
|
||||
test_services() {
|
||||
echo ""
|
||||
echo -e "${BOLD}Services${RESET}"
|
||||
|
||||
# sshd running
|
||||
if systemctl is-active sshd &>/dev/null || systemctl is-active ssh &>/dev/null; then
|
||||
record_pass "sshd running" "SSH daemon active"
|
||||
else
|
||||
record_fail "sshd running" "SSH daemon not active"
|
||||
fi
|
||||
|
||||
# cron / systemd-timers
|
||||
if systemctl is-active cron &>/dev/null || systemctl is-active crond &>/dev/null; then
|
||||
record_pass "cron active" "cron daemon running"
|
||||
elif systemctl list-timers --no-pager 2>/dev/null | grep -q "\.timer"; then
|
||||
record_pass "systemd timers active" "timers configured"
|
||||
else
|
||||
record_fail "cron/timers active" "no cron or systemd timers found"
|
||||
fi
|
||||
|
||||
# Unnecessary services check
|
||||
local unnecessary_services=("cups" "cups-browsed" "avahi-daemon" "telnet" "rsh" "rlogin" "tftp" "xinetd")
|
||||
local bad_found=false
|
||||
local bad_list=""
|
||||
|
||||
for svc in "${unnecessary_services[@]}"; do
|
||||
if systemctl is-active "$svc" &>/dev/null; then
|
||||
bad_found=true
|
||||
bad_list="${bad_list:+${bad_list}, }${svc}"
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$bad_found" == "true" ]]; then
|
||||
record_fail "No unnecessary services" "running: ${bad_list}"
|
||||
else
|
||||
record_pass "No unnecessary services" "cups/avahi/telnet/rsh/tftp not running"
|
||||
fi
|
||||
|
||||
# Expected services (user-defined)
|
||||
if [[ -n "$EXPECTED_SERVICES" ]]; then
|
||||
IFS=',' read -ra exp_svcs <<< "$EXPECTED_SERVICES"
|
||||
for svc in "${exp_svcs[@]}"; do
|
||||
svc=$(echo "$svc" | xargs) # trim whitespace
|
||||
if systemctl is-active "$svc" &>/dev/null; then
|
||||
record_pass "Expected service: ${svc}" "running"
|
||||
else
|
||||
record_fail "Expected service: ${svc}" "not running"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
# ── 5. Users ─────────────────────────────────────────────────────────
|
||||
test_users() {
|
||||
echo ""
|
||||
echo -e "${BOLD}Users${RESET}"
|
||||
|
||||
# UID 0 accounts (should only be root)
|
||||
local uid0_accounts
|
||||
uid0_accounts=$(awk -F: '$3 == 0 { print $1 }' /etc/passwd 2>/dev/null) || uid0_accounts=""
|
||||
local uid0_count
|
||||
uid0_count=$(echo "$uid0_accounts" | grep -c . 2>/dev/null) || uid0_count=0
|
||||
|
||||
if [[ "$uid0_count" -eq 1 && "$uid0_accounts" == "root" ]]; then
|
||||
record_pass "No extra UID 0 accounts" "only root has UID 0"
|
||||
elif [[ "$uid0_count" -gt 1 ]]; then
|
||||
record_fail "No extra UID 0 accounts" "UID 0: ${uid0_accounts//$'\n'/, }"
|
||||
else
|
||||
record_pass "No extra UID 0 accounts" "only root has UID 0"
|
||||
fi
|
||||
|
||||
# Empty passwords
|
||||
if [[ -r /etc/shadow ]]; then
|
||||
local empty_real
|
||||
empty_real=$(awk -F: '$2 == "" { print $1 }' /etc/shadow 2>/dev/null) || empty_real=""
|
||||
|
||||
if [[ -z "$empty_real" ]]; then
|
||||
record_pass "No empty passwords" "all accounts have passwords or are locked"
|
||||
else
|
||||
record_fail "No empty passwords" "empty password: ${empty_real//$'\n'/, }"
|
||||
fi
|
||||
else
|
||||
record_skip "No empty passwords" "/etc/shadow not readable (run as root)"
|
||||
fi
|
||||
|
||||
# Password aging (PASS_MAX_DAYS in login.defs)
|
||||
if [[ -f /etc/login.defs ]]; then
|
||||
local max_days
|
||||
max_days=$(grep -E "^PASS_MAX_DAYS" /etc/login.defs 2>/dev/null | awk '{print $2}') || max_days=""
|
||||
verbose "PASS_MAX_DAYS = ${max_days:-<unset>}"
|
||||
|
||||
if [[ -n "$max_days" && "$max_days" -le 365 && "$max_days" -gt 0 ]]; then
|
||||
record_pass "Password aging configured" "PASS_MAX_DAYS=${max_days}"
|
||||
elif [[ -n "$max_days" && "$max_days" -gt 365 ]]; then
|
||||
record_fail "Password aging configured" "PASS_MAX_DAYS=${max_days} (>365)"
|
||||
else
|
||||
record_fail "Password aging configured" "PASS_MAX_DAYS not set or invalid"
|
||||
fi
|
||||
else
|
||||
record_skip "Password aging configured" "/etc/login.defs not found"
|
||||
fi
|
||||
|
||||
# /etc/shadow permissions
|
||||
if [[ -f /etc/shadow ]]; then
|
||||
local shadow_perms
|
||||
shadow_perms=$(stat -c '%a' /etc/shadow 2>/dev/null) || shadow_perms=""
|
||||
verbose "/etc/shadow permissions: ${shadow_perms}"
|
||||
|
||||
if [[ "$shadow_perms" == "640" || "$shadow_perms" == "600" || "$shadow_perms" == "000" ]]; then
|
||||
record_pass "/etc/shadow permissions" "${shadow_perms}"
|
||||
else
|
||||
record_fail "/etc/shadow permissions" "${shadow_perms} (expected 640 or stricter)"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ── 6. Filesystem ────────────────────────────────────────────────────
|
||||
test_filesystem() {
|
||||
echo ""
|
||||
echo -e "${BOLD}Filesystem${RESET}"
|
||||
|
||||
# /tmp noexec/nosuid
|
||||
local tmp_opts
|
||||
tmp_opts=$(findmnt -n -o OPTIONS /tmp 2>/dev/null) || tmp_opts=""
|
||||
verbose "/tmp mount options: ${tmp_opts:-<not mounted separately>}"
|
||||
|
||||
if [[ -n "$tmp_opts" ]]; then
|
||||
local tmp_pass=true
|
||||
local tmp_detail=""
|
||||
|
||||
if [[ "$tmp_opts" == *"noexec"* ]]; then
|
||||
tmp_detail="noexec"
|
||||
else
|
||||
tmp_pass=false
|
||||
fi
|
||||
|
||||
if [[ "$tmp_opts" == *"nosuid"* ]]; then
|
||||
tmp_detail="${tmp_detail:+${tmp_detail},}nosuid"
|
||||
else
|
||||
tmp_pass=false
|
||||
fi
|
||||
|
||||
if [[ "$tmp_pass" == "true" ]]; then
|
||||
record_pass "/tmp noexec,nosuid" "${tmp_detail}"
|
||||
else
|
||||
record_fail "/tmp noexec,nosuid" "missing options (current: ${tmp_opts})"
|
||||
fi
|
||||
else
|
||||
record_fail "/tmp noexec,nosuid" "/tmp not mounted as separate partition"
|
||||
fi
|
||||
|
||||
# /var separate partition
|
||||
local var_mount
|
||||
var_mount=$(findmnt -n -o SOURCE /var 2>/dev/null) || var_mount=""
|
||||
local root_mount
|
||||
root_mount=$(findmnt -n -o SOURCE / 2>/dev/null) || root_mount=""
|
||||
verbose "/var source: ${var_mount:-<same as root>}"
|
||||
|
||||
if [[ -n "$var_mount" && "$var_mount" != "$root_mount" ]]; then
|
||||
record_pass "/var separate partition" "${var_mount}"
|
||||
else
|
||||
record_fail "/var separate partition" "not on separate partition"
|
||||
fi
|
||||
|
||||
# /home separate partition
|
||||
local home_mount
|
||||
home_mount=$(findmnt -n -o SOURCE /home 2>/dev/null) || home_mount=""
|
||||
verbose "/home source: ${home_mount:-<same as root>}"
|
||||
|
||||
if [[ -n "$home_mount" && "$home_mount" != "$root_mount" ]]; then
|
||||
record_pass "/home separate partition" "${home_mount}"
|
||||
else
|
||||
record_fail "/home separate partition" "not on separate partition"
|
||||
fi
|
||||
|
||||
# Sticky bit on /tmp
|
||||
local sticky
|
||||
sticky=$(stat -c '%a' /tmp 2>/dev/null) || sticky=""
|
||||
verbose "/tmp mode: ${sticky}"
|
||||
|
||||
if [[ "${sticky:0:1}" == "1" || "$sticky" == "1777" ]]; then
|
||||
record_pass "Sticky bit on /tmp" "mode ${sticky}"
|
||||
else
|
||||
record_fail "Sticky bit on /tmp" "mode ${sticky:-unknown} (expected 1777)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── 7. Kernel ─────────────────────────────────────────────────────────
|
||||
test_kernel() {
|
||||
echo ""
|
||||
echo -e "${BOLD}Kernel${RESET}"
|
||||
|
||||
# ASLR
|
||||
local aslr
|
||||
aslr=$(cat /proc/sys/kernel/randomize_va_space 2>/dev/null) || aslr=""
|
||||
verbose "ASLR (randomize_va_space) = ${aslr}"
|
||||
|
||||
if [[ "$aslr" == "2" ]]; then
|
||||
record_pass "ASLR enabled" "randomize_va_space=2 (full)"
|
||||
elif [[ "$aslr" == "1" ]]; then
|
||||
record_pass "ASLR enabled" "randomize_va_space=1 (partial)"
|
||||
else
|
||||
record_fail "ASLR enabled" "randomize_va_space=${aslr:-not readable}"
|
||||
fi
|
||||
|
||||
# SYN cookies
|
||||
local syncookies
|
||||
syncookies=$(cat /proc/sys/net/ipv4/tcp_syncookies 2>/dev/null) || syncookies=""
|
||||
verbose "tcp_syncookies = ${syncookies}"
|
||||
|
||||
if [[ "$syncookies" == "1" ]]; then
|
||||
record_pass "SYN cookies enabled" "tcp_syncookies=1"
|
||||
else
|
||||
record_fail "SYN cookies enabled" "tcp_syncookies=${syncookies:-not readable}"
|
||||
fi
|
||||
|
||||
# IP forwarding (should be disabled unless router)
|
||||
local ip_forward
|
||||
ip_forward=$(cat /proc/sys/net/ipv4/ip_forward 2>/dev/null) || ip_forward=""
|
||||
verbose "ip_forward = ${ip_forward}"
|
||||
|
||||
if [[ "$ip_forward" == "0" ]]; then
|
||||
record_pass "IP forwarding disabled" "ip_forward=0"
|
||||
else
|
||||
record_fail "IP forwarding disabled" "ip_forward=${ip_forward:-not readable} (enabled — expected unless router)"
|
||||
fi
|
||||
|
||||
# Core dumps restricted
|
||||
local core_pattern
|
||||
core_pattern=$(cat /proc/sys/kernel/core_pattern 2>/dev/null) || core_pattern=""
|
||||
local core_limit
|
||||
core_limit=$(ulimit -c 2>/dev/null) || core_limit=""
|
||||
verbose "core_pattern = ${core_pattern}"
|
||||
verbose "ulimit -c = ${core_limit}"
|
||||
|
||||
if [[ "$core_limit" == "0" ]]; then
|
||||
record_pass "Core dumps restricted" "ulimit -c = 0"
|
||||
elif [[ "$core_pattern" == *"systemd-coredump"* || "$core_pattern" == "|"* ]]; then
|
||||
record_pass "Core dumps restricted" "handled by ${core_pattern%% *}"
|
||||
else
|
||||
record_fail "Core dumps restricted" "core dumps enabled (ulimit -c = ${core_limit})"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── 8. Updates ────────────────────────────────────────────────────────
|
||||
test_updates() {
|
||||
if [[ "$SKIP_UPDATES" == "true" ]]; then
|
||||
echo ""
|
||||
echo -e "${BOLD}Updates${RESET}"
|
||||
record_skip "Pending updates" "SKIP_UPDATES=true"
|
||||
return
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}Updates${RESET}"
|
||||
|
||||
# Detect package manager and check for pending security updates
|
||||
if command -v apt-get &>/dev/null; then
|
||||
verbose "Package manager: apt"
|
||||
local updates
|
||||
updates=$(apt-get -s upgrade 2>/dev/null | grep -c "^Inst" 2>/dev/null) || updates=0
|
||||
local security
|
||||
security=$(apt-get -s upgrade 2>/dev/null | grep -c "security" 2>/dev/null) || security=0
|
||||
|
||||
if [[ "$security" -gt 0 ]]; then
|
||||
record_fail "Security updates pending" "${security} security, ${updates} total"
|
||||
elif [[ "$updates" -gt 0 ]]; then
|
||||
record_pass "No security updates pending" "${updates} non-security updates available"
|
||||
else
|
||||
record_pass "System up to date" "no pending updates"
|
||||
fi
|
||||
|
||||
elif command -v dnf &>/dev/null; then
|
||||
verbose "Package manager: dnf"
|
||||
local sec_updates
|
||||
sec_updates=$(dnf updateinfo list --security 2>/dev/null | grep -c "security" 2>/dev/null) || sec_updates=0
|
||||
|
||||
if [[ "$sec_updates" -gt 0 ]]; then
|
||||
record_fail "Security updates pending" "${sec_updates} security updates"
|
||||
else
|
||||
record_pass "No security updates pending" "dnf reports no security updates"
|
||||
fi
|
||||
|
||||
elif command -v yum &>/dev/null; then
|
||||
verbose "Package manager: yum"
|
||||
local yum_sec
|
||||
yum_sec=$(yum updateinfo list security 2>/dev/null | grep -c "security" 2>/dev/null) || yum_sec=0
|
||||
|
||||
if [[ "$yum_sec" -gt 0 ]]; then
|
||||
record_fail "Security updates pending" "${yum_sec} security updates"
|
||||
else
|
||||
record_pass "No security updates pending" "yum reports no security updates"
|
||||
fi
|
||||
|
||||
else
|
||||
record_skip "Pending updates" "no supported package manager found (apt/dnf/yum)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── 9. Permissions ───────────────────────────────────────────────────
|
||||
test_permissions() {
|
||||
echo ""
|
||||
echo -e "${BOLD}Permissions${RESET}"
|
||||
|
||||
# /etc/passwd (should be 644)
|
||||
local passwd_perms
|
||||
passwd_perms=$(stat -c '%a' /etc/passwd 2>/dev/null) || passwd_perms=""
|
||||
verbose "/etc/passwd permissions: ${passwd_perms}"
|
||||
|
||||
if [[ "$passwd_perms" == "644" ]]; then
|
||||
record_pass "/etc/passwd permissions" "${passwd_perms}"
|
||||
else
|
||||
record_fail "/etc/passwd permissions" "${passwd_perms} (expected 644)"
|
||||
fi
|
||||
|
||||
# /etc/shadow (should be 640 or stricter)
|
||||
if [[ -f /etc/shadow ]]; then
|
||||
local shadow_perms
|
||||
shadow_perms=$(stat -c '%a' /etc/shadow 2>/dev/null) || shadow_perms=""
|
||||
|
||||
if [[ "$shadow_perms" == "640" || "$shadow_perms" == "600" || "$shadow_perms" == "000" ]]; then
|
||||
record_pass "/etc/shadow permissions" "${shadow_perms}"
|
||||
else
|
||||
record_fail "/etc/shadow permissions" "${shadow_perms} (expected 640 or stricter)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# /etc/gshadow (should be 640 or stricter)
|
||||
if [[ -f /etc/gshadow ]]; then
|
||||
local gshadow_perms
|
||||
gshadow_perms=$(stat -c '%a' /etc/gshadow 2>/dev/null) || gshadow_perms=""
|
||||
verbose "/etc/gshadow permissions: ${gshadow_perms}"
|
||||
|
||||
if [[ "$gshadow_perms" == "640" || "$gshadow_perms" == "600" || "$gshadow_perms" == "000" ]]; then
|
||||
record_pass "/etc/gshadow permissions" "${gshadow_perms}"
|
||||
else
|
||||
record_fail "/etc/gshadow permissions" "${gshadow_perms} (expected 640 or stricter)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# World-writable files in /etc
|
||||
local ww_files
|
||||
ww_files=$(find /etc -maxdepth 2 -type f -perm -0002 2>/dev/null | head -10) || ww_files=""
|
||||
local ww_count
|
||||
ww_count=$(echo "$ww_files" | grep -c . 2>/dev/null) || ww_count=0
|
||||
|
||||
if [[ -z "$ww_files" ]]; then
|
||||
record_pass "No world-writable files in /etc" "clean"
|
||||
else
|
||||
record_fail "No world-writable files in /etc" "${ww_count} found"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── 10. Logging ──────────────────────────────────────────────────────
|
||||
test_logging() {
|
||||
echo ""
|
||||
echo -e "${BOLD}Logging${RESET}"
|
||||
|
||||
# rsyslog or journald
|
||||
local logging_found=false
|
||||
|
||||
if systemctl is-active rsyslog &>/dev/null; then
|
||||
record_pass "rsyslog running" "syslog daemon active"
|
||||
logging_found=true
|
||||
fi
|
||||
|
||||
if systemctl is-active systemd-journald &>/dev/null; then
|
||||
record_pass "journald running" "systemd journal active"
|
||||
logging_found=true
|
||||
fi
|
||||
|
||||
if [[ "$logging_found" == "false" ]]; then
|
||||
record_fail "Logging service" "neither rsyslog nor journald running"
|
||||
fi
|
||||
|
||||
# /var/log permissions
|
||||
local varlog_perms
|
||||
varlog_perms=$(stat -c '%a' /var/log 2>/dev/null) || varlog_perms=""
|
||||
verbose "/var/log permissions: ${varlog_perms}"
|
||||
|
||||
if [[ "$varlog_perms" == "755" || "$varlog_perms" == "750" || "$varlog_perms" == "700" ]]; then
|
||||
record_pass "/var/log permissions" "${varlog_perms}"
|
||||
else
|
||||
record_fail "/var/log permissions" "${varlog_perms} (expected 755 or stricter)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
# OUTPUT
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
print_summary() {
|
||||
local end_time
|
||||
end_time=$(date +%s)
|
||||
local duration=$(( end_time - START_TIME ))
|
||||
local hostname
|
||||
hostname=$(hostname 2>/dev/null || echo "unknown")
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}────────────────────────────────────────${RESET}"
|
||||
echo -e "${BOLD}Summary${RESET} ${hostname}"
|
||||
echo -e " ${GREEN}${PASS} passed${RESET} ${RED}${FAIL} failed${RESET} ${YELLOW}${SKIP} skipped${RESET} (${duration}s)"
|
||||
echo -e "${BOLD}────────────────────────────────────────${RESET}"
|
||||
|
||||
if [[ $FAIL -eq 0 ]]; then
|
||||
echo -e "${GREEN}${BOLD}All checks passed.${RESET}"
|
||||
else
|
||||
echo -e "${RED}${BOLD}${FAIL} check(s) failed.${RESET}"
|
||||
fi
|
||||
}
|
||||
|
||||
print_tap_header() {
|
||||
echo "TAP version 13"
|
||||
}
|
||||
|
||||
print_tap_footer() {
|
||||
echo "1..${TOTAL}"
|
||||
echo "# pass ${PASS}"
|
||||
echo "# fail ${FAIL}"
|
||||
echo "# skip ${SKIP}"
|
||||
}
|
||||
|
||||
write_junit() {
|
||||
local end_time
|
||||
end_time=$(date +%s)
|
||||
local duration=$(( end_time - START_TIME ))
|
||||
|
||||
cat > "$JUNIT_FILE" <<JUNIT_EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<testsuites tests="${TOTAL}" failures="${FAIL}" skipped="${SKIP}" time="${duration}">
|
||||
<testsuite name="linux-baseline-checks" tests="${TOTAL}" failures="${FAIL}" skipped="${SKIP}" time="${duration}">
|
||||
JUNIT_EOF
|
||||
|
||||
for result in "${RESULTS[@]}"; do
|
||||
local status name detail
|
||||
status=$(echo "$result" | cut -d'|' -f1)
|
||||
name=$(echo "$result" | cut -d'|' -f2)
|
||||
detail=$(echo "$result" | cut -d'|' -f3)
|
||||
|
||||
# XML-escape the values
|
||||
name=$(echo "$name" | sed 's/&/\&/g; s/</\</g; s/>/\>/g; s/"/\"/g')
|
||||
detail=$(echo "$detail" | sed 's/&/\&/g; s/</\</g; s/>/\>/g; s/"/\"/g')
|
||||
|
||||
case "$status" in
|
||||
PASS)
|
||||
echo " <testcase name=\"${name}\" classname=\"baseline\">" >> "$JUNIT_FILE"
|
||||
[[ -n "$detail" ]] && echo " <system-out>${detail}</system-out>" >> "$JUNIT_FILE"
|
||||
echo " </testcase>" >> "$JUNIT_FILE"
|
||||
;;
|
||||
FAIL)
|
||||
echo " <testcase name=\"${name}\" classname=\"baseline\">" >> "$JUNIT_FILE"
|
||||
echo " <failure message=\"${detail}\">FAILED: ${name} — ${detail}</failure>" >> "$JUNIT_FILE"
|
||||
echo " </testcase>" >> "$JUNIT_FILE"
|
||||
;;
|
||||
SKIP)
|
||||
echo " <testcase name=\"${name}\" classname=\"baseline\">" >> "$JUNIT_FILE"
|
||||
echo " <skipped message=\"${detail}\"/>" >> "$JUNIT_FILE"
|
||||
echo " </testcase>" >> "$JUNIT_FILE"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo " </testsuite>" >> "$JUNIT_FILE"
|
||||
echo "</testsuites>" >> "$JUNIT_FILE"
|
||||
|
||||
log "JUnit report written to ${JUNIT_FILE}"
|
||||
}
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
# MAIN
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [OPTIONS]
|
||||
|
||||
Post-provision baseline security validation for Linux servers.
|
||||
Zero external dependencies — bash, coreutils, ss only. curl/openssl optional.
|
||||
|
||||
Options:
|
||||
--skip-ssh Skip SSH hardening checks
|
||||
--skip-firewall Skip firewall checks
|
||||
--skip-updates Skip pending updates check
|
||||
--format FORMAT Output: text (default), tap, junit
|
||||
--junit-file FILE JUnit output path (default: baseline-results.xml)
|
||||
--verbose Show debug output
|
||||
--no-color Disable colored output
|
||||
--help Show this help
|
||||
|
||||
Environment variables:
|
||||
SKIP_SSH Skip SSH checks (same as --skip-ssh)
|
||||
SKIP_FIREWALL Skip firewall checks (same as --skip-firewall)
|
||||
SKIP_UPDATES Skip updates check (same as --skip-updates)
|
||||
EXPECTED_SERVICES Comma-separated list of services to verify running
|
||||
CURL_TIMEOUT HTTP timeout in seconds (default: 10)
|
||||
OUTPUT_FORMAT Output format (same as --format)
|
||||
JUNIT_FILE JUnit output path (same as --junit-file)
|
||||
|
||||
Examples:
|
||||
# Run all checks
|
||||
sudo ./$(basename "$0")
|
||||
|
||||
# Skip SSH and updates, JUnit output
|
||||
sudo ./$(basename "$0") --skip-ssh --skip-updates --format junit
|
||||
|
||||
# TAP output for CI
|
||||
sudo ./$(basename "$0") --format tap
|
||||
|
||||
# Check specific expected services
|
||||
EXPECTED_SERVICES="nginx,prometheus-node-exporter" sudo ./$(basename "$0")
|
||||
EOF
|
||||
}
|
||||
|
||||
main() {
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--skip-ssh) SKIP_SSH=true ;;
|
||||
--skip-firewall) SKIP_FIREWALL=true ;;
|
||||
--skip-updates) SKIP_UPDATES=true ;;
|
||||
--format) OUTPUT_FORMAT="$2"; shift ;;
|
||||
--junit-file) JUNIT_FILE="$2"; shift ;;
|
||||
--verbose) VERBOSE=true ;;
|
||||
--no-color) COLOR=never ;;
|
||||
--help|-h) usage; exit 0 ;;
|
||||
*) err "Unknown option: $1"; usage; exit 1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
setup_colors
|
||||
|
||||
START_TIME=$(date +%s)
|
||||
|
||||
if [[ "$OUTPUT_FORMAT" == "tap" ]]; then
|
||||
print_tap_header
|
||||
else
|
||||
echo ""
|
||||
echo -e "${BOLD}Linux Baseline Checks${RESET}"
|
||||
echo -e "Host: $(hostname 2>/dev/null || echo 'unknown')"
|
||||
echo -e "Time: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
echo -e "Kernel: $(uname -r)"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Run test suites
|
||||
test_ssh
|
||||
test_firewall
|
||||
test_timesync
|
||||
test_services
|
||||
test_users
|
||||
test_filesystem
|
||||
test_kernel
|
||||
test_updates
|
||||
test_permissions
|
||||
test_logging
|
||||
|
||||
# Output
|
||||
if [[ "$OUTPUT_FORMAT" == "tap" ]]; then
|
||||
print_tap_footer
|
||||
elif [[ "$OUTPUT_FORMAT" == "junit" ]]; then
|
||||
print_summary
|
||||
write_junit
|
||||
else
|
||||
print_summary
|
||||
fi
|
||||
|
||||
# Exit code
|
||||
[[ $FAIL -eq 0 ]] && exit 0 || exit 1
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user