Files
linux-scripts/server-attack-report.sh
T
chiefgeek a1a17e81a1 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.
2026-05-25 03:31:08 +02:00

570 lines
24 KiB
Bash
Executable File

#!/usr/bin/env bash
#########################################################################################
#### server-attack-report.sh — Analyze server logs for attack patterns ####
#### Scans nginx access logs and SSH auth logs, produces a formatted report. ####
#### Requires: bash, awk, grep, sort, uniq ####
#### ####
#### Author: Phil Connor ####
#### Contact: contact@mylinux.work ####
#### License: MIT ####
#### Version 1.00 ####
#### ####
#### Usage: ####
#### ./server-attack-report.sh ####
#### ./server-attack-report.sh --access /var/log/nginx/access.log ####
#### ./server-attack-report.sh --auth /var/log/auth.log ####
#### ./server-attack-report.sh --top 30 --geo ####
#### ./server-attack-report.sh --markdown --output report.md ####
#### ####
#### See --help for all options. ####
#########################################################################################
set -euo pipefail
# ── Colors ────────────────────────────────────────────────────────────
if [[ -t 1 ]]; then
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
DIM='\033[2m'
RESET='\033[0m'
else
RED="" GREEN="" YELLOW="" CYAN="" BOLD="" DIM="" RESET=""
fi
# ── Defaults ──────────────────────────────────────────────────────────
ACCESS_LOG=""
AUTH_LOG=""
TOP_N=20
GEO_LOOKUP=false
MARKDOWN=false
OUTPUT_FILE=""
THRESHOLD=50
# ── Auto-detect log paths ────────────────────────────────────────────
detect_access_log() {
local candidates=(
/var/log/nginx/access.log
/var/log/apache2/access.log
/var/log/httpd/access_log
)
for f in "${candidates[@]}"; do
if [[ -r "$f" && -s "$f" ]]; then
echo "$f"
return
fi
done
echo ""
}
detect_auth_log() {
local candidates=(
/var/log/auth.log
/var/log/secure
)
for f in "${candidates[@]}"; do
if [[ -r "$f" && -s "$f" ]]; then
echo "$f"
return
fi
done
echo ""
}
# ── Usage ─────────────────────────────────────────────────────────────
usage() {
cat <<EOF
Usage: $(basename "$0") [OPTIONS]
Analyze server logs for attack patterns and produce a security report.
Options:
--access PATH Path to nginx/Apache access log
(auto-detects if not specified)
--auth PATH Path to SSH auth log
(auto-detects if not specified)
--top N Number of results per section (default: 20)
--threshold N Flag IPs with more than N requests (default: 50)
--geo Enable geographic IP lookups (requires curl + internet)
--markdown Output in markdown format
--output FILE Write report to file instead of stdout
--no-color Disable colored output
-h, --help Show this help
Examples:
$(basename "$0")
$(basename "$0") --access /var/log/nginx/access.log --top 30
$(basename "$0") --geo --markdown --output report.md
$(basename "$0") --auth /var/log/secure --threshold 100
EOF
exit 0
}
# ── Parse arguments ───────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
--access) ACCESS_LOG="${2:?--access requires a path}"; shift 2 ;;
--auth) AUTH_LOG="${2:?--auth requires a path}"; shift 2 ;;
--top) TOP_N="${2:?--top requires a number}"; shift 2 ;;
--threshold) THRESHOLD="${2:?--threshold requires a number}"; shift 2 ;;
--geo) GEO_LOOKUP=true; shift ;;
--markdown) MARKDOWN=true; shift ;;
--output) OUTPUT_FILE="${2:?--output requires a filename}"; shift 2 ;;
--no-color) RED="" GREEN="" YELLOW="" CYAN="" BOLD="" DIM="" RESET=""; shift ;;
-h|--help) usage ;;
*) echo "Unknown option: $1" >&2; usage ;;
esac
done
# ── Output helpers ────────────────────────────────────────────────────
OUT_FD=1
if [[ -n "$OUTPUT_FILE" ]]; then
exec 3>"$OUTPUT_FILE"
OUT_FD=3
# disable colors for file output
RED="" GREEN="" YELLOW="" CYAN="" BOLD="" DIM="" RESET=""
fi
out() { echo -e "$*" >&$OUT_FD; }
heading() {
if $MARKDOWN; then
out ""
out "## $1"
out ""
else
out ""
out "${BOLD}${CYAN}═══ $1 ═══${RESET}"
out ""
fi
}
subheading() {
if $MARKDOWN; then
out "### $1"
out ""
else
out "${BOLD}$1${RESET}"
fi
}
separator() {
if $MARKDOWN; then
out "---"
out ""
else
out "${DIM}$(printf '─%.0s' {1..70})${RESET}"
fi
}
# ── Auto-detect logs ─────────────────────────────────────────────────
if [[ -z "$ACCESS_LOG" ]]; then
ACCESS_LOG=$(detect_access_log)
fi
if [[ -z "$AUTH_LOG" ]]; then
AUTH_LOG=$(detect_auth_log)
fi
HAS_ACCESS=false
HAS_AUTH=false
if [[ -n "$ACCESS_LOG" && -r "$ACCESS_LOG" && -s "$ACCESS_LOG" ]]; then
HAS_ACCESS=true
fi
if [[ -n "$AUTH_LOG" && -r "$AUTH_LOG" && -s "$AUTH_LOG" ]]; then
HAS_AUTH=true
fi
if ! $HAS_ACCESS && ! $HAS_AUTH; then
echo "ERROR: No readable log files found." >&2
echo "Specify paths with --access and/or --auth" >&2
exit 1
fi
# ── Report header ─────────────────────────────────────────────────────
REPORT_TIME=$(date -u +"%Y-%m-%d %H:%M:%S UTC")
HOSTNAME=$(hostname -f 2>/dev/null || hostname)
if $MARKDOWN; then
out "# Server Attack Report"
out ""
out "- **Host:** ${HOSTNAME}"
out "- **Generated:** ${REPORT_TIME}"
[[ $HAS_ACCESS == true ]] && out "- **Access log:** ${ACCESS_LOG}"
[[ $HAS_AUTH == true ]] && out "- **Auth log:** ${AUTH_LOG}"
out ""
else
out "${BOLD}Server Attack Report${RESET}"
out "Host: ${HOSTNAME}"
out "Generated: ${REPORT_TIME}"
[[ $HAS_ACCESS == true ]] && out "Access log: ${ACCESS_LOG}"
[[ $HAS_AUTH == true ]] && out "Auth log: ${AUTH_LOG}"
fi
# ── Helper: format count table ────────────────────────────────────────
# Reads lines of "count value" from stdin and formats them
format_table() {
local label_col="${1:-Item}"
local count_col="${2:-Count}"
if $MARKDOWN; then
out "| ${count_col} | ${label_col} |"
out "|------|------|"
while IFS= read -r line; do
local count value
count=$(echo "$line" | awk '{print $1}')
value=$(echo "$line" | awk '{$1=""; print $0}' | sed 's/^ //')
out "| ${count} | ${value} |"
done
out ""
else
printf "${BOLD}%8s %-s${RESET}\n" "$count_col" "$label_col" >&$OUT_FD
while IFS= read -r line; do
local count value
count=$(echo "$line" | awk '{print $1}')
value=$(echo "$line" | awk '{$1=""; print $0}' | sed 's/^ //')
printf "%8s %s\n" "$count" "$value" >&$OUT_FD
done
fi
}
# ── Helper: format multi-column table ─────────────────────────────────
format_table_multi() {
local header="$1"
if $MARKDOWN; then
out "$header"
# auto-generate separator from header pipe count
local cols
cols=$(echo "$header" | awk -F'|' '{print NF-2}')
local sep="|"
for ((i=0; i<cols; i++)); do sep+="------|"; done
out "$sep"
while IFS= read -r line; do
out "$line"
done
out ""
else
out "$header"
while IFS= read -r line; do
out "$line"
done
fi
}
# ══════════════════════════════════════════════════════════════════════
# ACCESS LOG ANALYSIS
# ══════════════════════════════════════════════════════════════════════
if $HAS_ACCESS; then
TOTAL_REQUESTS=$(wc -l < "$ACCESS_LOG" | tr -dc '0-9')
TOTAL_IPS=$(awk '{print $1}' "$ACCESS_LOG" | sort -u | wc -l | tr -dc '0-9')
TOTAL_404=$(awk '$9 == 404' "$ACCESS_LOG" | wc -l | tr -dc '0-9')
TOTAL_POST=$({ grep -c '"POST ' "$ACCESS_LOG" 2>/dev/null || echo 0; } | tail -1 | tr -dc '0-9')
: "${TOTAL_REQUESTS:=0}" "${TOTAL_IPS:=0}" "${TOTAL_404:=0}" "${TOTAL_POST:=0}"
heading "Summary"
if $MARKDOWN; then
out "| Metric | Value |"
out "|--------|-------|"
out "| Total requests | ${TOTAL_REQUESTS} |"
out "| Unique IPs | ${TOTAL_IPS} |"
out "| 404 responses | ${TOTAL_404} |"
out "| POST requests | ${TOTAL_POST} |"
out ""
else
out "Total requests: ${BOLD}${TOTAL_REQUESTS}${RESET}"
out "Unique IPs: ${BOLD}${TOTAL_IPS}${RESET}"
out "404 responses: ${BOLD}${TOTAL_404}${RESET}"
out "POST requests: ${BOLD}${TOTAL_POST}${RESET}"
fi
# ── Top IPs ───────────────────────────────────────────────────────
heading "Top IPs by Request Count"
awk '{print $1}' "$ACCESS_LOG" \
| sort | uniq -c | sort -rn | head -"$TOP_N" \
| format_table "IP Address" "Requests"
# ── High-volume IPs ───────────────────────────────────────────────
HIGH_VOLUME=$(awk '{print $1}' "$ACCESS_LOG" \
| sort | uniq -c | awk -v t="$THRESHOLD" '$1 > t' | sort -rn | wc -l)
if [[ "$HIGH_VOLUME" -gt 0 ]]; then
heading "High-Volume IPs (>${THRESHOLD} requests)"
if $GEO_LOOKUP; then
subheading "With geographic lookup"
awk '{print $1}' "$ACCESS_LOG" \
| sort | uniq -c | awk -v t="$THRESHOLD" '$1 > t' | sort -rn \
| head -"$TOP_N" | while read -r count ip; do
country=$(curl -s --max-time 3 "https://ipinfo.io/${ip}/country" 2>/dev/null || echo "??")
org=$(curl -s --max-time 3 "https://ipinfo.io/${ip}/org" 2>/dev/null || echo "unknown")
if $MARKDOWN; then
out "| ${count} | ${ip} | ${country} | ${org} |"
else
printf "%8s %-16s %-4s %s\n" "$count" "$ip" "$country" "$org" >&$OUT_FD
fi
done
out ""
else
awk '{print $1}' "$ACCESS_LOG" \
| sort | uniq -c | awk -v t="$THRESHOLD" '$1 > t' | sort -rn \
| head -"$TOP_N" \
| format_table "IP Address" "Requests"
fi
fi
# ── POST Requests ─────────────────────────────────────────────────
if [[ "$TOTAL_POST" -gt 0 ]]; then
heading "POST Requests"
subheading "POST requests by IP and path"
grep '"POST ' "$ACCESS_LOG" \
| awk '{print $1, $7, $9}' | sort | uniq -c | sort -rn \
| head -"$TOP_N" \
| format_table "IP / Path / Status" "Count"
fi
# ── 404 Paths ─────────────────────────────────────────────────────
if [[ "$TOTAL_404" -gt 0 ]]; then
heading "Top 404 Paths (Scanner Targets)"
awk '$9 == 404 {print $7}' "$ACCESS_LOG" \
| sort | uniq -c | sort -rn | head -"$TOP_N" \
| format_table "Path" "Hits"
fi
# ── Suspicious Paths ──────────────────────────────────────────────
SUSPICIOUS=$({ grep -ciE '\.(env|git|php|asp|jsp|bak|sql|tar\.gz|zip)|wp-login|wp-admin|xmlrpc|phpmyadmin|pma|cgi-bin' "$ACCESS_LOG" 2>/dev/null || echo 0; } | tail -1 | tr -dc '0-9')
: "${SUSPICIOUS:=0}"
if [[ "$SUSPICIOUS" -gt 0 ]]; then
heading "Suspicious Path Probes"
subheading "Secret/config file hunting"
grep -iE '\.(env|git|aws|ssh)' "$ACCESS_LOG" 2>/dev/null \
| awk '{print $1, $7}' | sort | uniq -c | sort -rn \
| head -"$TOP_N" \
| format_table "IP / Path" "Hits"
separator
subheading "WordPress / PHP scanning"
grep -iE 'wp-login|wp-admin|xmlrpc|\.php|phpmyadmin|pma|cgi-bin' "$ACCESS_LOG" 2>/dev/null \
| awk '{print $1, $7}' | sort | uniq -c | sort -rn \
| head -"$TOP_N" \
| format_table "IP / Path" "Hits"
fi
# ── API Enumeration ───────────────────────────────────────────────
API_PROBES=$({ grep -ciE '/api/|/v[0-9]+/|/swagger|/graphql|/actuator|/openapi|/api-docs|/debug/' "$ACCESS_LOG" 2>/dev/null || echo 0; } | tail -1 | tr -dc '0-9')
: "${API_PROBES:=0}"
if [[ "$API_PROBES" -gt 0 ]]; then
heading "API Enumeration Probes"
grep -iE '/api/|/v[0-9]+/|/swagger|/graphql|/actuator|/openapi|/api-docs|/debug/' "$ACCESS_LOG" \
| awk '{print $1, $7}' | sort | uniq -c | sort -rn \
| head -"$TOP_N" \
| format_table "IP / Path" "Hits"
fi
# ── User Agents ───────────────────────────────────────────────────
heading "Top User Agents"
awk -F'"' '{print $6}' "$ACCESS_LOG" \
| sort | uniq -c | sort -rn | head -"$TOP_N" \
| format_table "User Agent" "Requests"
# ── Known Bot User Agents ─────────────────────────────────────────
BOT_HITS=$({ grep -ciE 'GPTBot|ClaudeBot|CCBot|Bytespider|PerplexityBot|meta-external|meta-webindexer|SemrushBot|AhrefsBot|MJ12bot|DotBot|Scrapy|python-requests|Go-http-client|curl/' "$ACCESS_LOG" 2>/dev/null || echo 0; } | tail -1 | tr -dc '0-9')
: "${BOT_HITS:=0}"
if [[ "$BOT_HITS" -gt 0 ]]; then
heading "Known Bot / Scraper Traffic"
grep -iE 'GPTBot|ClaudeBot|CCBot|Bytespider|PerplexityBot|meta-external|meta-webindexer|SemrushBot|AhrefsBot|MJ12bot|DotBot|Scrapy|python-requests|Go-http-client|curl/' "$ACCESS_LOG" \
| awk -F'"' '{print $6}' | sort | uniq -c | sort -rn \
| head -"$TOP_N" \
| format_table "Bot User Agent" "Requests"
fi
# ── Response Code Distribution ────────────────────────────────────
heading "Response Code Distribution"
awk '$9 ~ /^[0-9]{3}$/ {print $9}' "$ACCESS_LOG" \
| sort | uniq -c | sort -rn \
| format_table "Status Code" "Count"
# ── Requests Per Hour ─────────────────────────────────────────────
heading "Requests Per Hour (Recent 24)"
awk '{gsub(/\[/,"",$4); print $4}' "$ACCESS_LOG" \
| cut -d: -f1-2 | sort | uniq -c \
| tail -24 \
| format_table "Date/Hour" "Requests"
# ── Bandwidth by IP ───────────────────────────────────────────────
heading "Top IPs by Bandwidth"
awk '$10 ~ /^[0-9]+$/ {sum[$1] += $10} END {for (ip in sum) printf "%d %s\n", sum[ip], ip}' "$ACCESS_LOG" \
| sort -rn | head -"$TOP_N" \
| while read -r bytes ip; do
bytes="${bytes//[^0-9]/}"
[[ -z "$bytes" ]] && bytes=0
if [[ "$bytes" -ge 1073741824 ]]; then
human=$(awk "BEGIN {printf \"%.1f GB\", $bytes/1073741824}")
elif [[ "$bytes" -ge 1048576 ]]; then
human=$(awk "BEGIN {printf \"%.1f MB\", $bytes/1048576}")
elif [[ "$bytes" -ge 1024 ]]; then
human=$(awk "BEGIN {printf \"%.1f KB\", $bytes/1024}")
else
human="${bytes} B"
fi
echo "${human} ${ip}"
done \
| format_table "IP Address" "Bandwidth"
fi
# ══════════════════════════════════════════════════════════════════════
# SSH AUTH LOG ANALYSIS
# ══════════════════════════════════════════════════════════════════════
if $HAS_AUTH; then
TOTAL_FAILED=$({ grep -c "Failed password" "$AUTH_LOG" 2>/dev/null || echo 0; } | tail -1 | tr -dc '0-9')
TOTAL_INVALID=$({ grep -c "Invalid user" "$AUTH_LOG" 2>/dev/null || echo 0; } | tail -1 | tr -dc '0-9')
TOTAL_ACCEPTED=$({ grep -c "Accepted" "$AUTH_LOG" 2>/dev/null || echo 0; } | tail -1 | tr -dc '0-9')
: "${TOTAL_FAILED:=0}" "${TOTAL_INVALID:=0}" "${TOTAL_ACCEPTED:=0}"
heading "SSH Authentication Summary"
if $MARKDOWN; then
out "| Metric | Value |"
out "|--------|-------|"
out "| Failed password attempts | ${TOTAL_FAILED} |"
out "| Invalid user attempts | ${TOTAL_INVALID} |"
out "| Successful logins | ${TOTAL_ACCEPTED} |"
out ""
else
out "Failed password attempts: ${BOLD}${RED}${TOTAL_FAILED}${RESET}"
out "Invalid user attempts: ${BOLD}${RED}${TOTAL_INVALID}${RESET}"
out "Successful logins: ${BOLD}${GREEN}${TOTAL_ACCEPTED}${RESET}"
fi
# ── SSH Brute Force IPs ───────────────────────────────────────────
if [[ "$TOTAL_FAILED" -gt 0 ]]; then
heading "Top SSH Brute Force IPs"
grep "Failed password" "$AUTH_LOG" \
| sed -n 's/.*from \([0-9.]*\) port.*/\1/p' | sort | uniq -c | sort -rn \
| head -"$TOP_N" \
| if $GEO_LOOKUP; then
while read -r count ip; do
country=$(curl -s --max-time 3 "https://ipinfo.io/${ip}/country" 2>/dev/null || echo "??")
if $MARKDOWN; then
echo "| ${count} | ${ip} | ${country} |"
else
printf "%8s %-16s %s\n" "$count" "$ip" "$country"
fi
done
else
format_table "IP Address" "Attempts"
fi
fi
# ── Targeted Usernames ────────────────────────────────────────────
if [[ "$TOTAL_FAILED" -gt 0 ]]; then
heading "Most Targeted SSH Usernames"
grep "Failed password" "$AUTH_LOG" \
| sed -n 's/.*Failed password for invalid user \([^ ]*\) from.*/\1/p; s/.*Failed password for \([^ ]*\) from.*/\1/p' \
| sort | uniq -c | sort -rn | head -"$TOP_N" \
| format_table "Username" "Attempts"
fi
# ── Invalid Users ─────────────────────────────────────────────────
if [[ "$TOTAL_INVALID" -gt 0 ]]; then
heading "Invalid Usernames Attempted"
grep "Invalid user" "$AUTH_LOG" \
| sed -n 's/.*Invalid user \([^ ]*\) from.*/\1/p' \
| sort | uniq -c | sort -rn \
| head -"$TOP_N" \
| format_table "Username" "Attempts"
fi
# ── Successful Logins ─────────────────────────────────────────────
if [[ "$TOTAL_ACCEPTED" -gt 0 ]]; then
heading "Successful SSH Logins (Verify These)"
if $MARKDOWN; then
out "| Date | User | IP | Method |"
out "|------|------|----|--------|"
fi
grep "Accepted" "$AUTH_LOG" | tail -"$TOP_N" \
| while IFS= read -r line; do
local_date=$(echo "$line" | awk '{print $1, $2, $3}')
user=$(echo "$line" | awk '{print $9}')
ip=$(echo "$line" | awk '{print $11}')
method=$(echo "$line" | awk '{print $7}')
if $MARKDOWN; then
out "| ${local_date} | ${user} | ${ip} | ${method} |"
else
printf " %-15s %-12s %-16s %s\n" "$local_date" "$user" "$ip" "$method" >&$OUT_FD
fi
done
out ""
fi
# ── SSH Failures Per Hour ─────────────────────────────────────────
if [[ "$TOTAL_FAILED" -gt 0 ]]; then
heading "SSH Failures Per Hour (Recent)"
grep "Failed password" "$AUTH_LOG" \
| awk '{
if ($1 ~ /^[0-9]{4}-/) {
# ISO 8601: 2026-04-25T21:11:13.346843+02:00
split($1, a, "T")
split(a[2], b, ":")
print a[1] " " b[1]":00"
} else {
# BSD syslog: Apr 24 14:30:01
print $1, $2, substr($3,1,2)":00"
}
}' | sort | uniq -c \
| tail -24 \
| format_table "Time" "Failures"
fi
fi
# ══════════════════════════════════════════════════════════════════════
# REPORT FOOTER
# ══════════════════════════════════════════════════════════════════════
separator
if $MARKDOWN; then
out ""
out "*Report generated by [server-attack-report.sh](https://mylinux.work/projects/server-attack-report/) on ${REPORT_TIME}*"
else
out "${DIM}Report generated by server-attack-report.sh on ${REPORT_TIME}${RESET}"
fi
if [[ -n "$OUTPUT_FILE" ]]; then
exec 3>&-
echo "Report written to: ${OUTPUT_FILE}"
fi