#!/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 <&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/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