#!/bin/bash

show_help() {
cat << EOF

  Script:        scfanalyzer.sh
  Description:   Intelligent analyzer and fixer for CSF firewall deny list.

  Purpose:
    This script scans /etc/csf/csf.deny and performs two main functions:

    1. ANALYZE Mode (default):
       - Finds IPv4 /24 and IPv6 /64 or /128 subnets with more than 3 individual IPs.
       - Reports those subnets with the associated comment.
       - Detects and reports redundant IPs already covered by subnet blocks.
       - Detects and reports duplicate subnet entries.
       - Detects and reports entries older then 60 days.
       - Detects and reports entries withoud date stamp.

    2. FIX Mode (-fix):
       - Automatically adds subnet blocks for subnets with >3 IPs.
       - Removes individual IPs already covered by those subnets.
       - Removes duplicate subnet block entries.
       - Adding current date stamp for entries without.
       - Remove entries older then 60 days.
       - Creates timestamped backup of /etc/csf/csf.deny before making changes.
       - Keeps only the 10 most recent backups in /etc/csf/backups.

  Usage:
    ./scfanalyzer.sh           → Run in ANALYZE mode (no changes made)
    ./scfanalyzer.sh -fix      → Run in FIX mode (modifies csf.deny safely)

  Output Example (ANALYZE):
    === ANALYZE mode enabled: scanning /etc/csf/csf.deny ===
    3 subnets found with more than 3 entries
    	Count     Subnet                         Comment
    	4         176.125.228.0/24               # lfd: ...
    Found 1 redundant IP
    	192.168.1.2
    Found 1 duplicated subnet block
    	176.125.228.0/24
	Found 0 entries older than 60 days
	Found 0 entries without date stamp
	Total entries scanned: 1234

  Output Example (FIX):
    === FIX mode enabled: scanning /etc/csf/csf.deny ===
    ...
    === Starting fix process ===
    Backup saved to /etc/csf/backups/csf.deny.bak.YYYY-MM-DD_HH-MM-SS
    Adding 3 subnets found with more than 3 entries
    	x.x.x.0/24
    	x.x.x.0/24
    	x.x.x.0/24
    Removing 1 redundant IP
    	x.x.x.x
    Removing 1 duplicated subnet block
    	x.x.x.0/24

  Recommendations:
    - Run in ANALYZE mode first to preview changes.
    - Use FIX mode during low-traffic hours to minimize firewall reload impact.
    - Add execution permission: chmod +x /root/scfanalyzer.sh
    - Optional: schedule in cron (e.g., weekly cleanup)

  Author:        Emilius
  Date Updated:  2025-06-22
  Tested on:     CWP + CSF (CentOS / AlmaLinux)

EOF
exit 0
}

DENY_FILE="/etc/csf/csf.deny"
BACKUP_DIR="/etc/csf/backups"
TIMESTAMP=$(date "+%Y-%m-%d_%H-%M-%S")
BACKUP_FILE="$BACKUP_DIR/csf.deny.bak.$TIMESTAMP"
THRESHOLD=3
DAYS_OLD=60
NOW_EPOCH=$(date +%s)
CSF_DATE=$(date "+%a %b %d %T %Y")

declare -A ipv4_subnets ipv6_subnets subnet_comments ip_lines all_lines existing_subnets
redundant_ips=()
duplicated_subnets=()
new_subnet_blocks=()
old_entries=()
missing_date_entries=()
total_entries=()

mode="ANALYZE"
[[ "$1" == "-fix" ]] && mode="FIX"
[[ "$1" == "-h" || "$1" == "--help" ]] && show_help
echo "=== $mode mode enabled: scanning $DENY_FILE ==="

while IFS= read -r line || [[ -n "$line" ]]; do
    [[ "$line" =~ ^#.*$ || -z "$line" ]] && continue

    entry="${line%%#*}"; entry="${entry#"${entry%%[![:space:]]*}"}"; entry="${entry%"${entry##*[![:space:]]}"}"
    comment="${line#*#}"; comment="${comment#"${comment%%[![:space:]]*}"}"; comment="${comment%"${comment##*[![:space:]]}"}"

    # Validate entry is an IP or subnet
    if ! [[ "$entry" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ || "$entry" =~ ^([0-9a-fA-F:]+)$ || "$entry" =~ ^([0-9]{1,3}\.){3}0/24$ || "$entry" =~ :/64$ || "$entry" =~ :/128$ ]]; then
        continue
    fi

	((total_entries++))

    # Check if comment contains a date and if it's older than threshold
    if [[ "$comment" =~ ([A-Za-z]{3}\ [A-Za-z]{3}\ [0-9]{1,2}\ [0-9]{2}:[0-9]{2}:[0-9]{2}\ [0-9]{4}) ]]; then
        parsed_date="${BASH_REMATCH[1]}"
        date_epoch=$(date -d "$parsed_date" +%s 2>/dev/null)
        [[ -n "$date_epoch" ]] && (( (NOW_EPOCH - date_epoch) / 86400 > DAYS_OLD )) && old_entries+=("$entry")
    else
        missing_date_entries+=("$entry")
    fi

    if [[ "$entry" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
        ip="$entry"
        subnet="$(echo "$ip" | cut -d. -f1-3).0/24"
        ipv4_subnets["$subnet"]+="$ip "
        [[ -z "${subnet_comments[$subnet]}" ]] && subnet_comments["$subnet"]="$comment"
        ip_lines["$ip"]="$line"
    elif [[ "$entry" =~ ^([0-9a-fA-F:]+)$ ]]; then
        ip="$entry"
        subnet="$(echo "$ip" | awk -F: '{OFS=":"; print $1,$2,$3,$4}' )::/64"
        ipv6_subnets["$subnet"]+="$ip "
        [[ -z "${subnet_comments[$subnet]}" ]] && subnet_comments["$subnet"]="$comment"
        ip_lines["$ip"]="$line"
    elif [[ "$entry" =~ ^([0-9]{1,3}\.){3}0/24$ || "$entry" =~ :/64$ || "$entry" =~ :/128$ ]]; then
        [[ -n "${existing_subnets[$entry]}" ]] && duplicated_subnets+=("$entry")
        existing_subnets["$entry"]="$line"
    fi

    all_lines["$entry"]="$line"
done < "$DENY_FILE"

pluralize() {
    local count=$1
    local singular=$2
    local plural=$3
    (( count == 1 )) && echo "$singular" || echo "$plural"
}

output_lines=()

process_subnets() {
    local -n subnet_map=$1
    for subnet in "${!subnet_map[@]}"; do
        ips=(${subnet_map[$subnet]})
        count=${#ips[@]}
        if (( count > THRESHOLD )); then
            comment="${subnet_comments[$subnet]}"
            output_lines+=("$count|$subnet|$comment")
            new_subnet_blocks+=("$subnet|$comment")
            for ip in "${ips[@]}"; do
                [[ -n "${all_lines[$ip]}" && -z "${existing_subnets[$subnet]}" ]] && redundant_ips+=("$ip")
            done
        fi
    done
}

process_subnets ipv4_subnets
process_subnets ipv6_subnets

# === ANALYZE ===

# Finds IPv4 /24 and IPv6 /64 or /128 subnets with more than 3 individual IPs
if [[ "${#output_lines[@]}" -gt 0 ]]; then
    echo "Found ${#output_lines[@]} subnets with more than $THRESHOLD entries"
    echo "       Count     Subnet                         Comment"
    IFS=$'\n' sorted=($(printf "       %s
" "${output_lines[@]}" | sort -t'|' -k1,1nr))
    for line in "${sorted[@]}"; do
        IFS='|' read -r count subnet comment <<< "$line"
        printf "%-9s %-37s # %s
" "$count" "       $subnet" "$comment"
    done
else
    echo "Found 0 subnets with more than $THRESHOLD entries"
fi

# Detects and reports redundant IPs already covered by subnet blocks
if [[ ${#redundant_ips[@]} -gt 0 ]]; then
    echo "Found ${#redundant_ips[@]} $(pluralize ${#redundant_ips[@]} 'redundant IP' 'redundant IPs')"
    printf "       %s\n" "${redundant_ips[@]}"
else
    echo "Found 0 redundant IPs"
fi

# Detects and reports duplicate subnet entries
if [[ ${#duplicated_subnets[@]} -gt 0 ]]; then
    echo "Found ${#duplicated_subnets[@]} $(pluralize ${#duplicated_subnets[@]} 'duplicated subnet block' 'duplicated subnet blocks')"
    printf "       %s\n" "${duplicated_subnets[@]}"
else
    echo "Found 0 duplicated subnet blocks"
fi

# Detects and reports entries older then 60 days
if [[ ${#old_entries[@]} -gt 0 ]]; then
    echo "Found ${#old_entries[@]} $(pluralize ${#old_entries[@]} 'entry' 'entries') older than $DAYS_OLD days"
    printf "       %s\n" "${old_entries[@]}"
else
    echo "Found 0 entries older than $DAYS_OLD days"
fi

# Detects and reports entries withoud date stamp
if [[ ${#missing_date_entries[@]} -gt 0 ]]; then
    echo "Found ${#missing_date_entries[@]} $(pluralize ${#missing_date_entries[@]} 'entry' 'entries') without date stamp"
    printf "       %s\n" "${missing_date_entries[@]}"
else
    echo "Found 0 entries without date stamp"
fi

# Showing total number of IPs scanned
echo "Total $(pluralize $total_entries 'entry' 'entries') scanned: $total_entries"
                                                      
# Messages when all is clean and there is no need to fix anything
if [[ ${#new_subnet_blocks[@]} -eq 0 && \
      ${#redundant_ips[@]} -eq 0 && \
      ${#duplicated_subnets[@]} -eq 0 && \
      ${#old_entries[@]} -eq 0 && \
      ${#missing_date_entries[@]} -eq 0 ]]; then
		messages=(
		"Everything is locked down tighter than a submarine's mailbox. Nothing to fix!"
		"Your deny list is cleaner than a freshly formatted drive. All clear!"
		"No issues found. The firewall sleeps… but with one eye open."
		"All IPs accounted for. The blacklist is as tidy as a sysadmin’s desk — on a good day."
		"System secure. Even the bots are bored."
		"No redundant entries. It’s like digital feng shui in here."
		"Well optimized. The hackers called — they said it’s too hard now."
		"Subnets are in check. Redundancies eliminated. CSF approved."
		"Congratulations, your deny list has reached nirvana."
		"No duplicates, no outdated blocks. This list is tighter than a firewall’s default DROP."
		"Nothing to fix. Your deny list is living its best life."
		"Clean as a whistle. Bots denied. Peace restored."
		"Everything’s under control. Even the AI is impressed."
		"No clutter, no chaos. Just pure CSF zen."
		"Redundancies? Not on your watch."
		"Firewall status: chef’s kiss."
		"If your deny list were a server, it’d be running 99.999% uptime right now."
		"Clean report. Go ahead and brag to the other sysadmins."
		"Immaculate! Not even a suspicious subnet in sight."
		"It’s so clean in here, even malware turned around."
		"Your deny list is so clean, it’s making other firewalls blush."
		"Not even a single bot slipped through — now that’s security with swagger."
		"This deny list is tighter than a VPN on date night."
		"Your firewall just gave 10/10 ‘don’t talk to me or my packets ever again’ energy."
		"Nothing to fix. It’s almost unfair how secure this is"
		"Deny list status: smooth, sharp, and irresistibly efficient."
		"This list is so locked down, even hackers are swiping left."
		"Well hello there, security guru. You've got those subnets under control."
		"That deny list is looking fine. Are you sure you’re not firewalling professionally?"
		"You’re making ‘DROP all’ look like a lifestyle choice."
		"System so secure, it whispered ‘talk firewall to me.’"
		"If blocking IPs were flirting, you’d be dangerously good at it."
		"Oof, this deny list just rejected more traffic than a nightclub bouncer in Vegas."
		"That cleanup? Chef’s kiss... and maybe a second date."
		"You’ve seduced the chaos into order — one subnet at a time."
		"This deny list is so optimized, it compiles itself."
		"You’ve reached level 99 in deny list wizardry."
		"Firewall so secure, it’s being invited to DEFCON — as a guest of honor."
		"Not even Shodan can see you now."
		"Your deny list is so effective, it should be teaching security classes."
		"Nothing to clean. Even ChatGPT’s impressed."
		"This firewall setup deserves its own GitHub star."
		"Alert: You’ve defeated the entropy boss and claimed the legendary /24 blade."
		"99 problems but a bot ain’t one."
		"System secure — Skynet couldn’t break in."
		"Arrr! No scallywag IPs be found in these waters."
		"Yer deny list be cleaner than a polished cutlass!"
		"Not a single rogue slipped through. Batten down the ports!"
		"Shiver me sectors! Nothing to patch today, Cap’n!"
		"This firewall’s tighter than a Kraken’s grip!"
		"Avast! No duplicates on this voyage."
		"Thar be no bots breaching this hull. Smooth sailing ahead!"
		"Subnets all tied down like good crew. No mutiny here!"
		"Dead men tell no tales... and blocked IPs tell no traffic."
		"Ye've earned the Black Hat Bounty — no leaks in yer ship!"
		"The force is strong with this deny list."
		"No intrusions detected, Master Admin."
		"Firewall status: More secure than the Death Star plans."
		"This list is tighter than a stormtrooper’s aim — and way more accurate."
		"Redundancy has been eliminated. Like Alderaan."
		"Your subnet control is... impressive. Most impressive."
		"May the logs be ever clean."
		"Even a Sith couldn't break through this deny list."
		"Peace has returned to the firewall galaxy."
		"All entries verified. You are the One."
		"Deny list loaded. Deja vu? No — just perfection."
		"Agents found: 0. Chaos contained."
		"Everything that has a beginning... is now denied."
		"Firewall status: green rain of control."
		"This deny list doesn’t dodge bullets — it stops them cold."
		"Nothing to fix. The system is... bulletproof."
		"Neo couldn’t clean this list better."
		"The Matrix has you — and it’s clean."
		)
		random_index=$((RANDOM % ${#messages[@]}))
		echo "${messages[$random_index]}"
	fi

# === FIX MODE ===

# Checking If FIX mode is needed
if [[ "$mode" == "FIX" && ( ${#new_subnet_blocks[@]} -gt 0 || ${#redundant_ips[@]} -gt 0 || ${#duplicated_subnets[@]} -gt 0 || ${#old_entries[@]} -gt 0 || ${#missing_date_entries[@]} -gt 0 ) ]]; then
    echo "=== Starting fix process ==="

	# Making new backup
	mkdir -p "$BACKUP_DIR"
    cp "$DENY_FILE" "$BACKUP_FILE"
    echo "Backup saved to $BACKUP_FILE"

    tmpfile=$(mktemp)
    cp "$DENY_FILE" "$tmpfile"

	# Adding subnet blocks for subnets with >3 IPs
	if (( ${#new_subnet_blocks[@]} > 0 )); then
        echo "Adding ${#new_subnet_blocks[@]} $(pluralize ${#new_subnet_blocks[@]} 'subnet' 'subnets') found with more than $THRESHOLD entries"
        for entry in "${new_subnet_blocks[@]}"; do
            IFS='|' read -r subnet comment <<< "$entry"
            echo "       $subnet"
            echo "$subnet # do not delete - $comment" >> "$tmpfile"
        done
	fi

	# Removes individual IPs already covered by those subnets
	if (( ${#redundant_ips[@]} > 0 )); then
        echo "Removing ${#redundant_ips[@]} $(pluralize ${#redundant_ips[@]} 'redundant IP' 'redundant IPs')"
        for ip in "${redundant_ips[@]}"; do
            echo "       $ip"
            sed -i "/^$ip[[:space:]]/d" "$tmpfile"
        done
	fi

	# Removes duplicate subnet block entries
	if (( ${#duplicated_subnets[@]} > 0 )); then
		echo "Removing ${#duplicated_subnets[@]} $(pluralize ${#duplicated_subnets[@]} 'duplicated subnet block' 'duplicated subnet blocks')"
        for subnet in "${duplicated_subnets[@]}"; do
            escaped=$(printf '%s\n' "$subnet" | sed 's/[][\\/.*^$]/\\&/g')
            mapfile -t matches < <(grep -n "^$escaped[[:space:]]*#" "$tmpfile" | cut -d: -f1)
            if (( ${#matches[@]} > 1 )); then
                for ((i = 1; i < ${#matches[@]}; i++)); do
                    sed -i "${matches[i]}d" "$tmpfile"
                done
            fi
            echo "       $subnet"
        done
	fi

    # Remove entries older than 60 days
    if (( ${#old_entries[@]} > 0 )); then
        echo "Removing ${#old_entries[@]} $(pluralize ${#old_entries[@]} 'entry' 'entries') older than $DAYS_OLD days"
        for ip in "${old_entries[@]}"; do
            echo "       $ip"
            [[ -n "$ip" ]] && sed -i "/^$ip[[:space:]]/d" "$tmpfile"
        done
    fi

	 # Adding current date stamp for entries without
    if (( ${#missing_date_entries[@]} > 0 )); then
        echo "Adding date stamp to ${#missing_date_entries[@]} $(pluralize ${#missing_date_entries[@]} 'entry' 'entries')"
        for ip in "${missing_date_entries[@]}"; do
            [[ -z "$ip" ]] && continue
            escaped_ip=$(printf '%s\n' "$ip" | sed 's/[][\\/.*^$]/\\&/g')
            # Use a more robust match to ensure comment stays inline and date is appended
            sed -i -E "s|^(${escaped_ip}[[:space:]]*#.*[^[:space:]])[[:space:]]*\$|\1 - $CSF_DATE|" "$tmpfile"
            echo "       $ip"
        done
    fi

    mv "$tmpfile" "$DENY_FILE"

    ls -t "$BACKUP_DIR"/csf.deny.bak.* 2>/dev/null | tail -n +11 | xargs -r rm -f
    echo "Reloading CSF firewall..."
    csf -r > /dev/null 2>&1 && echo "[CSF reloaded]"

fi
