#!/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:-}" 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:-}" 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:-}" 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:-}" 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:-}" 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:-}" 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:-}" 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:-}" 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 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') detail=$(echo "$detail" | sed 's/&/\&/g; s//\>/g; s/"/\"/g') case "$status" in PASS) echo " " >> "$JUNIT_FILE" [[ -n "$detail" ]] && echo " ${detail}" >> "$JUNIT_FILE" echo " " >> "$JUNIT_FILE" ;; FAIL) echo " " >> "$JUNIT_FILE" echo " FAILED: ${name} — ${detail}" >> "$JUNIT_FILE" echo " " >> "$JUNIT_FILE" ;; SKIP) echo " " >> "$JUNIT_FILE" echo " " >> "$JUNIT_FILE" echo " " >> "$JUNIT_FILE" ;; esac done echo " " >> "$JUNIT_FILE" echo "" >> "$JUNIT_FILE" log "JUnit report written to ${JUNIT_FILE}" } # ══════════════════════════════════════════════════════════════════════ # MAIN # ══════════════════════════════════════════════════════════════════════ usage() { cat </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 "$@"