a1a17e81a1
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.
559 lines
16 KiB
Bash
Executable File
559 lines
16 KiB
Bash
Executable File
#!/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 <<EOF
|
|
Usage: sudo $SCRIPT_NAME [OPTIONS]
|
|
|
|
Adds nginx hardening jails to an existing Fail2ban installation.
|
|
Blocks vulnerability scanners, script probes, and path enumeration.
|
|
|
|
OPTIONS:
|
|
--logpath PATH Nginx access log path (default: auto-detect)
|
|
--error-logpath P Nginx error log path (default: auto-detect)
|
|
--bantime SECS Ban duration in seconds (default: 86400 / 24 hours)
|
|
--skip JAILS Comma-separated jails to skip
|
|
(noscript, pathscan, 4xx-flood)
|
|
--allow-ext LIST Comma-separated extensions to whitelist in noscript
|
|
(e.g., php,py for PHP/Python sites)
|
|
--allow-paths LIST Comma-separated paths to whitelist in pathscan
|
|
(e.g., wp-admin,phpmyadmin)
|
|
--dry-run Show what would be done without making changes
|
|
-h, --help Show this help message
|
|
|
|
JAILS INSTALLED:
|
|
nginx-noscript Blocks requests for .php, .asp, .jsp, .cgi, .exe
|
|
on servers that don't serve dynamic content.
|
|
*** SKIP this on PHP/ASP/Python sites or use
|
|
--allow-ext to whitelist your extensions ***
|
|
nginx-pathscan Blocks requests for sensitive paths like .env,
|
|
.git/, wp-admin, phpmyadmin, .aws/credentials.
|
|
*** Use --allow-paths to whitelist paths you
|
|
actually use (e.g., wp-admin on WordPress) ***
|
|
nginx-4xx-flood Bans IPs generating excessive 404/403 errors
|
|
(automated path enumeration / directory brute force).
|
|
Safe for all server types.
|
|
|
|
EXAMPLES:
|
|
# Static site — install all three jails
|
|
sudo $SCRIPT_NAME
|
|
|
|
# WordPress / PHP site — skip noscript, whitelist wp-admin
|
|
sudo $SCRIPT_NAME --skip noscript --allow-paths wp-admin,wp-login,xmlrpc
|
|
|
|
# PHP site with phpMyAdmin
|
|
sudo $SCRIPT_NAME --skip noscript --allow-paths phpmyadmin,pma
|
|
|
|
# Python/Django site
|
|
sudo $SCRIPT_NAME --allow-ext py --allow-paths admin
|
|
|
|
# Laravel site — only 4xx-flood and pathscan
|
|
sudo $SCRIPT_NAME --skip noscript --allow-paths api
|
|
|
|
# Ban for 7 days
|
|
sudo $SCRIPT_NAME --bantime 604800
|
|
|
|
# Preview without changes
|
|
sudo $SCRIPT_NAME --dry-run
|
|
|
|
EOF
|
|
exit 0
|
|
}
|
|
|
|
parse_args() {
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--logpath) ACCESS_LOGPATH="$2"; shift 2 ;;
|
|
--error-logpath) ERROR_LOGPATH="$2"; shift 2 ;;
|
|
--bantime) BANTIME="$2"; shift 2 ;;
|
|
--skip) SKIP_JAILS="$2"; shift 2 ;;
|
|
--allow-ext) ALLOW_EXTENSIONS="$2"; shift 2 ;;
|
|
--allow-paths) ALLOW_PATHS="$2"; shift 2 ;;
|
|
--dry-run) DRY_RUN=true; shift ;;
|
|
-h|--help) show_usage ;;
|
|
*) log_error "Unknown option: $1"; show_usage ;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
check_root() {
|
|
if [[ $EUID -ne 0 ]]; then
|
|
log_error "This script must be run as root (sudo)"
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
# ============================================================================
|
|
# CHECKS
|
|
# ============================================================================
|
|
|
|
check_fail2ban() {
|
|
if ! command -v fail2ban-client &>/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 = ^<HOST> \\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 <<EOF
|
|
# Fail2ban filter — nginx-noscript
|
|
# Blocks requests for dynamic script extensions that should never exist
|
|
# on a static site or any server not running those technologies.
|
|
# https://mylinux.work
|
|
#
|
|
# Blocked extensions: $ext_regex
|
|
|
|
[Definition]
|
|
|
|
# Match requests for script extensions in nginx access logs
|
|
failregex = ^<HOST> \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 <<EOF
|
|
# Fail2ban jail — nginx-noscript
|
|
# Ban IPs requesting dynamic script files
|
|
# https://mylinux.work
|
|
|
|
[nginx-noscript]
|
|
enabled = true
|
|
port = http,https
|
|
filter = nginx-noscript
|
|
logpath = $ACCESS_LOGPATH
|
|
maxretry = 2
|
|
bantime = $BANTIME
|
|
findtime = 3600
|
|
|
|
# Author: Phil Connor — https://mylinux.work
|
|
EOF
|
|
}
|
|
|
|
# ============================================================================
|
|
# JAIL 2: nginx-pathscan
|
|
# ============================================================================
|
|
|
|
install_pathscan() {
|
|
if should_skip "pathscan"; then
|
|
log_info "Skipping nginx-pathscan (--skip)"
|
|
return
|
|
fi
|
|
|
|
log_step "Installing nginx-pathscan filter and jail..."
|
|
if [[ -n "$ALLOW_PATHS" ]]; then
|
|
log_info "Whitelisted paths: $ALLOW_PATHS"
|
|
fi
|
|
|
|
# Filter
|
|
local ignore_line
|
|
ignore_line=$(build_path_ignoreregex)
|
|
generate_pathscan_filter "$ignore_line" | write_config \
|
|
/etc/fail2ban/filter.d/nginx-pathscan.conf \
|
|
"nginx-pathscan filter"
|
|
|
|
# Jail
|
|
generate_pathscan_jail | write_config \
|
|
/etc/fail2ban/jail.d/nginx-pathscan.conf \
|
|
"nginx-pathscan jail"
|
|
}
|
|
|
|
generate_pathscan_filter() {
|
|
local ignore_line="$1"
|
|
[[ -z "$ignore_line" ]] && ignore_line="ignoreregex ="
|
|
|
|
cat <<EOF
|
|
# Fail2ban filter — nginx-pathscan
|
|
# Blocks requests for sensitive paths commonly targeted by vulnerability
|
|
# scanners, script kiddies, and automated exploit tools.
|
|
# https://mylinux.work
|
|
|
|
[Definition]
|
|
|
|
# Match requests for known sensitive/exploit paths
|
|
# Covers: environment files, version control, CMS admin panels,
|
|
# database tools, config files, cloud credentials
|
|
failregex = ^<HOST> \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 <<EOF
|
|
# Fail2ban jail — nginx-pathscan
|
|
# Ban IPs probing for sensitive paths
|
|
# https://mylinux.work
|
|
|
|
[nginx-pathscan]
|
|
enabled = true
|
|
port = http,https
|
|
filter = nginx-pathscan
|
|
logpath = $ACCESS_LOGPATH
|
|
maxretry = 1
|
|
bantime = $BANTIME
|
|
findtime = 3600
|
|
|
|
# Author: Phil Connor — https://mylinux.work
|
|
EOF
|
|
}
|
|
|
|
# ============================================================================
|
|
# JAIL 3: nginx-4xx-flood
|
|
# ============================================================================
|
|
|
|
install_4xx_flood() {
|
|
if should_skip "4xx-flood"; then
|
|
log_info "Skipping nginx-4xx-flood (--skip)"
|
|
return
|
|
fi
|
|
|
|
log_step "Installing nginx-4xx-flood filter and jail..."
|
|
|
|
# Filter
|
|
generate_4xx_filter | write_config \
|
|
/etc/fail2ban/filter.d/nginx-4xx-flood.conf \
|
|
"nginx-4xx-flood filter"
|
|
|
|
# Jail
|
|
generate_4xx_jail | write_config \
|
|
/etc/fail2ban/jail.d/nginx-4xx-flood.conf \
|
|
"nginx-4xx-flood jail"
|
|
}
|
|
|
|
generate_4xx_filter() {
|
|
cat <<'EOF'
|
|
# Fail2ban filter — nginx-4xx-flood
|
|
# Bans IPs generating excessive 4xx errors in a short period.
|
|
# Catches automated directory brute force and path enumeration.
|
|
# https://mylinux.work
|
|
|
|
[Definition]
|
|
|
|
# Match 400, 401, 403, 404, 405, 444 responses
|
|
failregex = ^<HOST> \S+ \S+ \[.*\] "\S+ \S+ \S+" (400|401|403|404|405|444)
|
|
|
|
ignoreregex =
|
|
|
|
# Author: Phil Connor — https://mylinux.work
|
|
EOF
|
|
}
|
|
|
|
generate_4xx_jail() {
|
|
cat <<EOF
|
|
# Fail2ban jail — nginx-4xx-flood
|
|
# Ban IPs with excessive 4xx errors (directory brute force)
|
|
# https://mylinux.work
|
|
|
|
[nginx-4xx-flood]
|
|
enabled = true
|
|
port = http,https
|
|
filter = nginx-4xx-flood
|
|
logpath = $ACCESS_LOGPATH
|
|
maxretry = 20
|
|
bantime = $BANTIME
|
|
findtime = 300
|
|
|
|
# Author: Phil Connor — https://mylinux.work
|
|
EOF
|
|
}
|
|
|
|
# ============================================================================
|
|
# RELOAD AND VERIFY
|
|
# ============================================================================
|
|
|
|
reload_fail2ban() {
|
|
log_step "Reloading Fail2ban..."
|
|
|
|
if $DRY_RUN; then
|
|
log_info "[DRY RUN] Would reload fail2ban"
|
|
return
|
|
fi
|
|
|
|
fail2ban-client reload
|
|
sleep 2
|
|
|
|
if systemctl is-active --quiet fail2ban; then
|
|
log_info "Fail2ban reloaded successfully"
|
|
else
|
|
log_error "Fail2ban failed to restart — check: journalctl -u fail2ban"
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
verify_jails() {
|
|
log_step "Verifying jails..."
|
|
|
|
if $DRY_RUN; then
|
|
log_info "[DRY RUN] Would verify jail status"
|
|
return
|
|
fi
|
|
|
|
local all_ok=true
|
|
echo ""
|
|
for jail in noscript pathscan 4xx-flood; do
|
|
if should_skip "$jail"; then
|
|
continue
|
|
fi
|
|
local full_jail="nginx-${jail}"
|
|
if fail2ban-client status "$full_jail" &>/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/<filter>.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 <jail> unbanip <IP>"
|
|
echo " fail2ban-regex $ACCESS_LOGPATH /etc/fail2ban/filter.d/nginx-pathscan.conf"
|
|
echo ""
|
|
}
|
|
|
|
main "$@"
|