#!/usr/bin/env bash ######################################################################################### #### apache-security-auditor.sh — Audit Apache httpd configuration for security issues#### #### Checks server info, TLS, headers, directories, modules, and file permissions #### #### Requires: bash 4+, root access #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version 1.01 #### #### #### #### Usage: #### #### sudo ./apache-security-auditor.sh --full #### #### #### #### See --help for all options. #### ######################################################################################### set -euo pipefail # ── Colors (pre-initialized) ───────────────────────────────────────── RED="" GREEN="" YELLOW="" BLUE="" CYAN="" BOLD="" DIM="" RESET="" setup_colors() { if [[ "${COLOR:-auto}" == "never" ]]; then return fi if [[ "${COLOR:-auto}" == "always" ]] || [[ -t 1 ]]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' BOLD='\033[1m' DIM='\033[2m' RESET='\033[0m' 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 "${DIM}[DEBUG]${RESET} $*"; fi; } die() { err "$*"; exit 1; } # ── Severity counters ──────────────────────────────────────────────── TOTAL_CRIT=0 TOTAL_WARN=0 TOTAL_INFO=0 TOTAL_OK=0 flag_crit() { ((TOTAL_CRIT++)) || true; } flag_warn() { ((TOTAL_WARN++)) || true; } flag_info() { ((TOTAL_INFO++)) || true; } flag_ok() { ((TOTAL_OK++)) || true; } # ── Defaults ────────────────────────────────────────────────────────── RUN_MODE="" VERBOSE="${VERBOSE:-false}" COLOR="${COLOR:-auto}" APACHE_CONF="" APACHECTL="" APACHE_CONF_DIR="" APACHE_RUN_USER="" PLATFORM="" # ── State ───────────────────────────────────────────────────────────── SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_NAME START_TIME="" # ── Platform detection ─────────────────────────────────────────────── detect_platform() { if [[ -n "$APACHE_CONF" ]]; then if command -v apache2ctl &>/dev/null; then APACHECTL="apache2ctl" APACHE_CONF_DIR="$(dirname "$APACHE_CONF")" APACHE_RUN_USER="www-data" PLATFORM="Debian/Ubuntu (apache2)" elif command -v httpd &>/dev/null; then APACHECTL="httpd" APACHE_CONF_DIR="$(dirname "$APACHE_CONF")" APACHE_RUN_USER="apache" PLATFORM="RHEL/CentOS (httpd)" else die "Cannot find apache2ctl or httpd" fi return fi if command -v apache2ctl &>/dev/null && [[ -f /etc/apache2/apache2.conf ]]; then APACHECTL="apache2ctl" APACHE_CONF="/etc/apache2/apache2.conf" APACHE_CONF_DIR="/etc/apache2" APACHE_RUN_USER="www-data" PLATFORM="Debian/Ubuntu (apache2)" elif command -v httpd &>/dev/null && [[ -f /etc/httpd/conf/httpd.conf ]]; then APACHECTL="httpd" APACHE_CONF="/etc/httpd/conf/httpd.conf" APACHE_CONF_DIR="/etc/httpd" APACHE_RUN_USER="apache" PLATFORM="RHEL/CentOS (httpd)" else die "Cannot detect Apache installation — use --config to specify config path" fi verbose "Platform: ${PLATFORM}" verbose "Config: ${APACHE_CONF}" verbose "Config dir: ${APACHE_CONF_DIR}" } # ── Get all config files ───────────────────────────────────────────── get_config_files() { local files=() if [[ -f "$APACHE_CONF" ]]; then files+=("$APACHE_CONF") fi local included included=$($APACHECTL -t -D DUMP_INCLUDES 2>/dev/null | grep -oP '\(\*\) \K.*|^ *\K/.*' || true) if [[ -n "$included" ]]; then while IFS= read -r f; do [[ -f "$f" ]] && files+=("$f") done <<< "$included" fi for d in "${APACHE_CONF_DIR}/sites-enabled" "${APACHE_CONF_DIR}/conf-enabled" \ "${APACHE_CONF_DIR}/conf.d" "${APACHE_CONF_DIR}/conf.modules.d"; do if [[ -d "$d" ]]; then while IFS= read -r f; do files+=("$f") done < <(find "$d" -name '*.conf' -type f 2>/dev/null) fi done printf '%s\n' "${files[@]}" | sort -u } # ── Search across all config files ─────────────────────────────────── search_config() { local pattern="$1" local config_files config_files=$(get_config_files) while IFS= read -r f; do [[ -z "$f" ]] && continue grep -iP "$pattern" "$f" 2>/dev/null || true done <<< "$config_files" } # ── Table header ───────────────────────────────────────────────────── print_table_header() { printf " %-32s %-14s %s\n" "CHECK" "STATUS" "SEVERITY" printf " %s\n" "$(printf '%.0s─' {1..65})" } # ── Table row ──────────────────────────────────────────────────────── print_row() { local check="$1" status="$2" severity="$3" local color="" case "$severity" in CRITICAL) color="$RED"; flag_crit ;; WARN) color="$YELLOW"; flag_warn ;; INFO) color="$CYAN"; flag_info ;; OK) color="$GREEN"; flag_ok ;; esac printf " %-32s %-14s %b%s%b\n" "$check" "$status" "$color" "$severity" "$RESET" } # ══════════════════════════════════════════════════════════════════════ # SERVER INFO AUDIT # ══════════════════════════════════════════════════════════════════════ audit_server_info() { log "Auditing server information exposure..." echo "" print_table_header # ServerTokens local tokens tokens=$(search_config '^\s*ServerTokens' | tail -1 | awk '{print $2}') if [[ -z "$tokens" ]]; then print_row "ServerTokens" "Full (default)" "CRITICAL" elif [[ "${tokens,,}" == "prod" || "${tokens,,}" == "productonly" ]]; then print_row "ServerTokens" "Prod" "OK" elif [[ "${tokens,,}" == "major" ]]; then print_row "ServerTokens" "Major" "WARN" else print_row "ServerTokens" "$tokens" "CRITICAL" fi # ServerSignature local sig sig=$(search_config '^\s*ServerSignature' | tail -1 | awk '{print $2}') if [[ -z "$sig" ]]; then print_row "ServerSignature" "On (default)" "CRITICAL" elif [[ "${sig,,}" == "off" ]]; then print_row "ServerSignature" "Off" "OK" else print_row "ServerSignature" "$sig" "CRITICAL" fi # TraceEnable local trace trace=$(search_config '^\s*TraceEnable' | tail -1 | awk '{print $2}') if [[ -z "$trace" ]]; then print_row "TraceEnable" "On (default)" "WARN" elif [[ "${trace,,}" == "off" ]]; then print_row "TraceEnable" "Off" "OK" else print_row "TraceEnable" "$trace" "WARN" fi echo "" } # ══════════════════════════════════════════════════════════════════════ # TLS AUDIT # ══════════════════════════════════════════════════════════════════════ audit_tls() { log "Auditing TLS configuration..." echo "" print_table_header # Check if mod_ssl is loaded if ! $APACHECTL -M 2>/dev/null | grep -q ssl_module; then print_row "mod_ssl" "not loaded" "INFO" echo "" return fi print_row "mod_ssl" "loaded" "OK" # SSLProtocol local proto proto=$(search_config '^\s*SSLProtocol' | tail -1) if [[ -z "$proto" ]]; then print_row "SSLProtocol" "not set (default)" "WARN" else if echo "$proto" | grep -iqP '(\+SSLv3|\+TLSv1\.0|\+TLSv1[^.]|[^-]TLSv1[^.23])'; then print_row "SSLProtocol (legacy)" "enabled" "CRITICAL" elif echo "$proto" | grep -iqP '(\+TLSv1\.1|[^-]TLSv1\.1)'; then print_row "SSLProtocol (TLSv1.1)" "enabled" "CRITICAL" else print_row "SSLProtocol" "modern only" "OK" fi fi # SSLCipherSuite local ciphers ciphers=$(search_config '^\s*SSLCipherSuite' | tail -1) if [[ -z "$ciphers" ]]; then print_row "SSLCipherSuite" "not set" "WARN" else print_row "SSLCipherSuite" "configured" "OK" fi # SSLHonorCipherOrder local honor honor=$(search_config '^\s*SSLHonorCipherOrder' | tail -1 | awk '{print $2}') if [[ -z "$honor" ]]; then print_row "SSLHonorCipherOrder" "not set" "WARN" elif [[ "${honor,,}" == "on" ]]; then print_row "SSLHonorCipherOrder" "on" "OK" else print_row "SSLHonorCipherOrder" "$honor" "WARN" fi # HSTS local hsts hsts=$(search_config 'Strict-Transport-Security') if [[ -z "$hsts" ]]; then print_row "HSTS Header" "missing" "WARN" else print_row "HSTS Header" "set" "OK" fi # OCSP Stapling local ocsp ocsp=$(search_config '^\s*SSLUseStapling' | tail -1 | awk '{print $2}') if [[ -z "$ocsp" ]]; then print_row "OCSP Stapling" "not configured" "WARN" elif [[ "${ocsp,,}" == "on" ]]; then print_row "OCSP Stapling" "on" "OK" else print_row "OCSP Stapling" "$ocsp" "WARN" fi echo "" } # ══════════════════════════════════════════════════════════════════════ # SECURITY HEADERS AUDIT # ══════════════════════════════════════════════════════════════════════ audit_headers() { log "Auditing security headers..." echo "" print_table_header # Check if mod_headers is loaded if ! $APACHECTL -M 2>/dev/null | grep -q headers_module; then print_row "mod_headers" "not loaded" "WARN" echo "" return fi local headers=( "X-Content-Type-Options" "X-Frame-Options" "Content-Security-Policy" "Referrer-Policy" "Permissions-Policy" ) for header in "${headers[@]}"; do local found found=$(search_config "Header.*set.*${header}") if [[ -n "$found" ]]; then print_row "$header" "set" "OK" else print_row "$header" "missing" "WARN" fi done echo "" } # ══════════════════════════════════════════════════════════════════════ # DIRECTORY AUDIT # ══════════════════════════════════════════════════════════════════════ audit_directories() { log "Auditing directory and file restrictions..." echo "" print_table_header # Options Indexes (enabled = bad) local indexes_on indexes_on=$(search_config '^\s*Options\b.*\bIndexes\b' | grep -v '\-Indexes' || true) if [[ -n "$indexes_on" ]]; then print_row "Options Indexes" "enabled" "WARN" else print_row "Options Indexes" "disabled" "OK" fi # AllowOverride All local override_all override_all=$(search_config '^\s*AllowOverride\s+All' || true) if [[ -n "$override_all" ]]; then print_row "AllowOverride" "All (permissive)" "WARN" else print_row "AllowOverride" "restricted" "OK" fi # Sensitive file protection (.git, .env, .htpasswd) local sensitive_protection sensitive_protection=$(search_config '(FilesMatch|Files|Directory).*(\\.git|\\.env|\\.htpasswd)' || true) if [[ -n "$sensitive_protection" ]]; then print_row "Sensitive file blocking" "configured" "OK" else print_row "Sensitive file blocking" "not configured" "CRITICAL" fi # Check for root directory restriction local root_deny root_deny=$(search_config '^\s*Require\s+all\s+denied' || true) if [[ -n "$root_deny" ]]; then print_row "Root directory denied" "yes" "OK" else print_row "Root directory denied" "not found" "WARN" fi echo "" } # ══════════════════════════════════════════════════════════════════════ # MODULES AUDIT # ══════════════════════════════════════════════════════════════════════ audit_modules() { log "Auditing modules..." echo "" print_table_header local loaded_modules loaded_modules=$($APACHECTL -M 2>/dev/null || true) # mod_security if echo "$loaded_modules" | grep -q "security2_module"; then print_row "mod_security" "loaded" "OK" else print_row "mod_security" "not loaded" "INFO" fi # mod_status if echo "$loaded_modules" | grep -q "status_module"; then local status_restricted status_restricted=$(search_config '(/dev/null) if [[ "$conf_perms" -le 644 ]]; then print_row "Config ($APACHE_CONF)" "$conf_perms" "OK" else print_row "Config ($APACHE_CONF)" "$conf_perms" "WARN" fi fi # .htpasswd files local htpasswd_files htpasswd_files=$(find "$APACHE_CONF_DIR" /var/www -name '.htpasswd' -type f 2>/dev/null || true) if [[ -n "$htpasswd_files" ]]; then while IFS= read -r f; do local perms perms=$(stat -c '%a' "$f" 2>/dev/null) if [[ "$perms" -le 640 ]]; then print_row ".htpasswd ($f)" "$perms" "OK" else print_row ".htpasswd ($f)" "$perms" "WARN" fi done <<< "$htpasswd_files" else verbose "No .htpasswd files found" fi # Document root world-writable check local docroots docroots=$(search_config '^\s*DocumentRoot' | awk '{print $2}' | tr -d '"' | sort -u) if [[ -n "$docroots" ]]; then while IFS= read -r dr; do [[ -z "$dr" || ! -d "$dr" ]] && continue local dr_perms dr_perms=$(stat -c '%a' "$dr" 2>/dev/null) if [[ "${dr_perms: -1}" -ge 6 ]]; then print_row "Docroot ($dr)" "${dr_perms} (world-writable)" "CRITICAL" else print_row "Docroot ($dr)" "$dr_perms" "OK" fi done <<< "$docroots" fi echo "" } # ══════════════════════════════════════════════════════════════════════ # SUMMARY # ══════════════════════════════════════════════════════════════════════ print_summary() { local elapsed elapsed=$(( $(date +%s) - START_TIME )) echo "" echo " ══════════════════════════════════════════" echo " Apache Security Audit Summary" echo " ══════════════════════════════════════════" printf " %-20s %b%d%b\n" "CRITICAL:" "$RED" "$TOTAL_CRIT" "$RESET" printf " %-20s %b%d%b\n" "WARN:" "$YELLOW" "$TOTAL_WARN" "$RESET" printf " %-20s %b%d%b\n" "INFO:" "$CYAN" "$TOTAL_INFO" "$RESET" printf " %-20s %b%d%b\n" "OK:" "$GREEN" "$TOTAL_OK" "$RESET" echo " ──────────────────────────────────────────" printf " Completed in %ds\n" "$elapsed" echo "" if [[ "$TOTAL_CRIT" -gt 0 ]]; then echo -e " ${RED}${BOLD}Action required:${RESET} ${TOTAL_CRIT} critical finding(s)" echo "" echo " Top recommendations:" echo " • Set ServerTokens Prod and ServerSignature Off" echo " • Disable SSLv3, TLSv1, and TLSv1.1" echo " • Restrict mod_status to localhost with Require ip 127.0.0.1" echo " • Block access to .git, .env, and .htpasswd files" echo " • Fix world-writable document root permissions" echo "" elif [[ "$TOTAL_WARN" -gt 0 ]]; then echo -e " ${YELLOW}Review recommended:${RESET} ${TOTAL_WARN} warning(s)" echo "" echo " Suggestions:" echo " • Add security headers (CSP, X-Frame-Options, HSTS)" echo " • Enable OCSP stapling for TLS" echo " • Disable mod_info and mod_autoindex in production" echo " • Set TraceEnable Off" echo "" else echo -e " ${GREEN}All checks passed${RESET}" echo "" fi } # ══════════════════════════════════════════════════════════════════════ # USAGE # ══════════════════════════════════════════════════════════════════════ show_help() { cat <&2 exit 1 fi RUN_MODE="${modes[*]}" } # ══════════════════════════════════════════════════════════════════════ # MAIN # ══════════════════════════════════════════════════════════════════════ main() { parse_args "$@" setup_colors detect_platform START_TIME=$(date +%s) echo "" echo -e "${BOLD}Apache Security Auditor${RESET}" echo -e "Host: $(hostname)" echo -e "Config: ${APACHE_CONF}" echo -e "Platform: ${PLATFORM}" echo -e "Mode: ${RUN_MODE}" echo -e "Time: $(date -u +%Y-%m-%dT%H:%M:%SZ)" echo "" for mode in $RUN_MODE; do case "$mode" in server-info) audit_server_info ;; tls) audit_tls ;; headers) audit_headers ;; directories) audit_directories ;; modules) audit_modules ;; permissions) audit_permissions ;; esac done print_summary if [[ "$TOTAL_CRIT" -gt 0 ]]; then exit 2 elif [[ "$TOTAL_WARN" -gt 0 ]]; then exit 1 fi exit 0 } main "$@"