#!/bin/bash ################################################################################ # Script Name: add-fail2ban-nginx-hardening.sh # Version: 1.0 # Description: Adds custom Fail2ban jails to block vulnerability scanners, # script probes, and path enumeration attacks on nginx servers. # Installs filters + jails and reloads Fail2ban. # # Author: Phil Connor # Contact: contact@mylinux.work # Website: https://mylinux.work # License: MIT # # Usage: # sudo ./add-fail2ban-nginx-hardening.sh # sudo ./add-fail2ban-nginx-hardening.sh --logpath /var/log/nginx/access.log # sudo ./add-fail2ban-nginx-hardening.sh --bantime 604800 # sudo ./add-fail2ban-nginx-hardening.sh --dry-run # ################################################################################ set -euo pipefail # ============================================================================ # DEFAULTS # ============================================================================ readonly VERSION="1.0" readonly SCRIPT_NAME="${0##*/}" ACCESS_LOGPATH="auto" ERROR_LOGPATH="auto" BANTIME="86400" DRY_RUN=false SKIP_JAILS="" ALLOW_EXTENSIONS="" ALLOW_PATHS="" # Colors RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' CYAN='\033[0;36m' NC='\033[0m' # ============================================================================ # HELPER FUNCTIONS # ============================================================================ log_info() { echo -e "${GREEN}[INFO]${NC} $*"; } log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } log_step() { echo -e "${CYAN}[STEP]${NC} $*"; } show_usage() { cat </dev/null; then log_error "Fail2ban is not installed" log_error "Install it first: https://mylinux.work/guides/fail2ban-setup/" exit 1 fi if ! systemctl is-active --quiet fail2ban; then log_error "Fail2ban is not running" exit 1 fi log_info "Fail2ban is installed and running" } detect_logpaths() { # Access log if [[ "$ACCESS_LOGPATH" == "auto" ]]; then if [[ -f /var/log/nginx/access.log ]]; then ACCESS_LOGPATH="/var/log/nginx/access.log" else log_error "Could not find nginx access log. Use --logpath to specify." exit 1 fi elif [[ ! -f "$ACCESS_LOGPATH" ]]; then log_error "Access log not found: $ACCESS_LOGPATH" exit 1 fi log_info "Access log: $ACCESS_LOGPATH" # Error log if [[ "$ERROR_LOGPATH" == "auto" ]]; then if [[ -f /var/log/nginx/error.log ]]; then ERROR_LOGPATH="/var/log/nginx/error.log" else ERROR_LOGPATH="$ACCESS_LOGPATH" log_warn "Error log not found — using access log for all jails" fi fi log_info "Error log: $ERROR_LOGPATH" } should_skip() { local jail="$1" [[ ",$SKIP_JAILS," == *",$jail,"* ]] } # Build extension regex excluding allowed extensions build_extension_regex() { local all_exts="php|asp|aspx|jsp|cgi|exe|pl" if [[ -n "$ALLOW_EXTENSIONS" ]]; then local result="" IFS='|' read -ra EXT_ARRAY <<< "${all_exts}" for ext in "${EXT_ARRAY[@]}"; do if [[ ",$ALLOW_EXTENSIONS," != *",$ext,"* ]]; then [[ -n "$result" ]] && result="${result}|" result="${result}${ext}" fi done if [[ -z "$result" ]]; then log_warn "All extensions whitelisted — skipping nginx-noscript" return 1 fi echo "$result" else echo "$all_exts" fi return 0 } # Build path ignore regex from allowed paths build_path_ignoreregex() { if [[ -z "$ALLOW_PATHS" ]]; then echo "" return fi local ignore_parts="" IFS=',' read -ra PATH_ARRAY <<< "$ALLOW_PATHS" for p in "${PATH_ARRAY[@]}"; do p=$(echo "$p" | xargs | sed 's|^/||') [[ -n "$ignore_parts" ]] && ignore_parts="${ignore_parts}|" ignore_parts="${ignore_parts}/${p}" done echo "ignoreregex = ^ \\S+ \\S+ \\[.*\\] \"\\S+ [^\"]*($ignore_parts)[^\"]*HTTP" } # ============================================================================ # HELPER — write file with backup # ============================================================================ write_config() { local file="$1" local label="$2" if $DRY_RUN; then log_info "[DRY RUN] Would create $file" echo "" cat echo "" return fi if [[ -f "$file" ]]; then log_warn "$label already exists — backing up to ${file}.bak" cp "$file" "${file}.bak" fi cat > "$file" log_info "$label installed: $file" } # ============================================================================ # JAIL 1: nginx-noscript # ============================================================================ install_noscript() { if should_skip "noscript"; then log_info "Skipping nginx-noscript (--skip)" return fi local ext_regex ext_regex=$(build_extension_regex) || return 0 log_step "Installing nginx-noscript filter and jail..." if [[ -n "$ALLOW_EXTENSIONS" ]]; then log_info "Whitelisted extensions: $ALLOW_EXTENSIONS" fi log_info "Blocking extensions: $ext_regex" # Filter generate_noscript_filter "$ext_regex" | write_config \ /etc/fail2ban/filter.d/nginx-noscript.conf \ "nginx-noscript filter" # Jail generate_noscript_jail | write_config \ /etc/fail2ban/jail.d/nginx-noscript.conf \ "nginx-noscript jail" } generate_noscript_filter() { local ext_regex="$1" cat < \S+ \S+ \[.*\] "(GET|POST|HEAD|PUT|DELETE|OPTIONS) [^"]*\.($ext_regex)(\?[^\"]*)? HTTP[^"]*" (400|403|404|444) ignoreregex = # Author: Phil Connor — https://mylinux.work EOF } generate_noscript_jail() { cat < \S+ \S+ \[.*\] "(GET|POST|HEAD) [^"]*(/\.env|/\.git|/\.svn|/\.hg|/\.htaccess|/\.htpasswd|/\.aws|/\.docker|/\.ssh|/\.kube|/\.config|/wp-admin|/wp-login|/wp-config|/wp-content/uploads|/wp-includes|/xmlrpc\.php|/administrator|/admin/config|/phpmyadmin|/pma|/myadmin|/dbadmin|/mysql|/phpinfo|/info\.php|/server-status|/server-info|/cgi-bin|/shell|/cmd|/command|/console|/config\.json|/config\.yml|/config\.yaml|/config\.xml|/database\.yml|/backup|/dump|/db\.sql|/\.sql|/api/v1/debug|/debug|/trace|/actuator|/swagger|/graphql|/solr|/elasticsearch|/_cat|/_cluster)[^"]*HTTP[^"]*" (400|403|404|444) $ignore_line # Author: Phil Connor — https://mylinux.work EOF } generate_pathscan_jail() { cat < \S+ \S+ \[.*\] "\S+ \S+ \S+" (400|401|403|404|405|444) ignoreregex = # Author: Phil Connor — https://mylinux.work EOF } generate_4xx_jail() { cat </dev/null; then log_info " ✓ $full_jail is active" else log_error " ✗ $full_jail failed to start" all_ok=false fi done echo "" if ! $all_ok; then log_error "Some jails failed — debug with:" log_error " fail2ban-client status" log_error " fail2ban-regex $ACCESS_LOGPATH /etc/fail2ban/filter.d/.conf" exit 1 fi } # ============================================================================ # MAIN # ============================================================================ main() { parse_args "$@" echo "" echo "============================================" echo " Fail2ban Nginx Hardening v${VERSION}" echo " https://mylinux.work" echo "============================================" echo "" check_root check_fail2ban detect_logpaths install_noscript install_pathscan install_4xx_flood reload_fail2ban verify_jails local installed=0 should_skip "noscript" || installed=$((installed + 1)) should_skip "pathscan" || installed=$((installed + 1)) should_skip "4xx-flood" || installed=$((installed + 1)) echo "============================================" echo " Setup Complete — ${installed} Jail(s) Installed" echo "============================================" echo "" echo " Jails:" should_skip "noscript" || echo " nginx-noscript Ban after 2 script requests (.php, .asp, etc.)" should_skip "pathscan" || echo " nginx-pathscan Ban on first sensitive path probe (.env, .git, wp-admin)" should_skip "4xx-flood" || echo " nginx-4xx-flood Ban after 20 errors in 5 minutes" echo "" echo " Log: $ACCESS_LOGPATH" echo " Ban time: ${BANTIME}s ($(( BANTIME / 3600 ))h)" echo "" echo " Useful commands:" echo " fail2ban-client status nginx-noscript" echo " fail2ban-client status nginx-pathscan" echo " fail2ban-client status nginx-4xx-flood" echo " fail2ban-client set unbanip " echo " fail2ban-regex $ACCESS_LOGPATH /etc/fail2ban/filter.d/nginx-pathscan.conf" echo "" } main "$@"