Summarize fail2ban Bans With a Log Report Script

– min read

I SSH into a server that has been running for a few months and the first thing I check is always /var/log/fail2ban.log. Not CPU load or free memory — who is knocking on the gate. fail2ban logs have a straightforward format but there is no built-in “show me the summary.” This script parses those lines and gives me a punch list I can run before touching anything else.


Get the script: fail2ban (the project itself) and the full script below — no external dependencies beyond Python 3.6+. Save it as f2b-report.py and point it at your log.


The Script


#!/usr/bin/env python3
"""f2b-report.py - summarise fail2ban bans from fail2ban.log.

Parses fail2ban log lines and prints per-jail ban stats, top attacker
IPs, and currently-banned IPs. Exit code 2 when total bans exceed 200.
"""
import argparse, re, sys
from collections import defaultdict
from datetime import datetime, timedelta

def parse_line(line):
    """Return (ts, jail, action, ip) or None."""
    m = re.match(
        r'^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),\d+\s+'
        r'\S+\s+\[\d+\]:\s+'
        r'\S+\s+'
        r'\[(\S+)\]\s+'
        r'(Found|Ban|Unban)\s+(\S+)',
        line
    )
    if not m:
        return None
    ts = datetime.strptime(m.group(1), '%Y-%m-%d %H:%M:%S')
    return (ts, m.group(2), m.group(3), m.group(4))

def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument('-l', '--log', default='/var/log/fail2ban.log')
    ap.add_argument('-d', '--days', type=int, default=7)
    args = ap.parse_args()

    cutoff = datetime.now() - timedelta(days=args.days)
    jails = set()
    ban_cnt = defaultdict(int)
    unban_cnt = defaultdict(int)
    found_cnt = defaultdict(int)
    ip_found = defaultdict(int)
    ip_ban = defaultdict(set)
    ip_first = {}
    ip_last = {}
    last_action = {}

    with open(args.log) as f:
        for line in f:
            rec = parse_line(line)
            if not rec:
                continue
            ts, jail, action, ip = rec
            if ts < cutoff:
                continue
            jails.add(jail)
            if action == 'Found':
                found_cnt[jail] += 1
                ip_found[ip] += 1
                if ip not in ip_first:
                    ip_first[ip] = ts
                ip_last[ip] = ts
            elif action == 'Ban':
                ban_cnt[jail] += 1
                ip_ban[ip].add(jail)
                last_action[(ip, jail)] = 'Ban'
                ip_last[ip] = ts
            elif action == 'Unban':
                unban_cnt[jail] += 1
                last_action[(ip, jail)] = 'Unban'

    t_f = sum(found_cnt.values())
    t_b = sum(ban_cnt.values())
    t_u = sum(unban_cnt.values())
    print(f'\n=== fail2ban Summary (last {args.days} days) ===\n')
    print(f'{"Jail":<25} {"Found":>8} {"Banned":>8} {"Unbanned":>8}')
    print('\u2500' * 50)
    for j in sorted(jails, key=lambda j: -ban_cnt.get(j, 0)):
        print(f'{j:<25} {found_cnt.get(j,0):>8} {ban_cnt.get(j,0):>8} {unban_cnt.get(j,0):>8}')
    print('\u2500' * 50)
    print(f'{"TOTAL":<25} {t_f:>8} {t_b:>8} {t_u:>8}\n')

    print('=== Top 10 Attacker IPs ===\n')
    print(f'{"IP":<20} {"Detects":>8} {"Bans":>8}  {"First Seen":<19}')
    print('\u2500' * 60)
    for i, (ip, cnt) in enumerate(
        sorted(ip_found.items(), key=lambda x: -x[1])
    ):
        if i >= 10:
            break
        b = len(ip_ban.get(ip, set()))
        fs = ip_first[ip].strftime('%d-%b %H:%M')
        print(f'{ip:<20} {cnt:>8} {b:>8}  {fs:<19}')

    print(f'\n=== Currently Banned (approx) ===\n')
    active = []
    for (ip, jail), act in last_action.items():
        if act == 'Ban':
            active.append((ip, jail, ip_last.get(ip)))
    if active:
        print(f'{"IP":<20} {"Jail":<20} {"Last Seen":<19}')
        print('\u2500' * 60)
        for ip, jail, ts in sorted(active, key=lambda x: x[2] or datetime.min):
            ls = ts.strftime('%d-%b %H:%M') if ts else '?'
            print(f'{ip:<20} {jail:<20} {ls:<19}')
    else:
        print('(none)')
    print()

    if t_b > 200:
        sys.exit(2)

if __name__ == '__main__':
    main()


The regex handles all three fail2ban event types — Found, Ban, and Unban — and works across standard log rotations. It only looks at lines within the --days window, so running it with -d 30 gives a monthly perspective.


How It Works


The script walks through the log line by line, building counters per jail (sshd, nginx-botsearch, recidive, dovecot — whatever your jails are) and per source IP. It tracks the last action seen for each IP-jail pair, so it can tell you which IPs are currently Banned (their most recent action was a Ban with no matching Unban since). The exit code turns into a cron-friendly threshold — 0 means all clear, 2 means total bans exceeded the default 200 mark.


Using the Script


The --help output explains the two options:


usage: f2b-report.py [-h] [-l LOG] [-d DAYS]

f2b-report.py - summarise fail2ban bans from fail2ban.log.

Parses fail2ban log lines and prints per-jail ban stats, top attacker
IPs, and currently-banned IPs. Exit code 2 when total bans exceed 200.

options:
  -h, --help         show this help message and exit
  -l LOG, --log LOG  fail2ban log path (default: /var/log/fail2ban.log)
  -d DAYS, --days DAYS  lookback window in days (default: 7)


Drop it on any server with fail2ban installed:


wget https://your-server/f2b-report.py
chmod +x f2b-report.py
sudo ./f2b-report.py         # reads /var/log/fail2ban.log, last 7 days
sudo ./f2b-report.py -d 30   # last 30 days
sudo ./f2b-report.py -l /var/log/fail2ban.log.1  # rotated log


Demo: Synthetic Data


Since real fail2ban logs contain real attacker IPs I do not want to publish, I generated a synthetic log using RFC 5737 test-net ranges (203.0.113.x, 198.51.100.x, 192.0.2.x). The distribution is weighted — a handful of repeat offenders account for most of the traffic (sshd gets ~82 % of hits, a realistic ratio on any public SSH server).


The full 8-day synthetic log has 927 lines, 261 Ban lines and 444 Found lines. Below is the report output:


fail2ban log report script output showing per-jail ban summary and top 10 attacker IPs
fail2ban log report output: 4 jails, 444 detections, 261 bans, top 10 attacker IPs by detection count.


The per-jail table shows sshd dominating with 226 bans followed by nginx-botsearch at 16 — makes sense if you run a web server. The “Top 10 Attacker IPs” section surfaces the worst offenders; in this synthetic data 203.0.113.10 racked up 149 detections across 4 bans. The “Currently Banned” list shows IPs that have a Ban without a subsequent Unban in the window — 5 IPs in this sample range.


The “Currently Banned” section is approximate — it only knows what is in the log window you give it. An IP banned before the window opened and never unbanned will not show up. But for routine checks it catches stragglers:


fail2ban report script help output and usage example
Script help output and a closer look at the usage options.


Adding to Cron


I run this weekly on my edge servers via a cron job. The exit code 2 on high ban counts means you can pipe it into a monitoring check:


# /etc/cron.weekly/f2b-report
#!/bin/sh
/usr/local/bin/f2b-report.py || [ $? -eq 2 ] && \
  echo "fail2ban threshold exceeded" | mail -s "fail2ban Alert" [email protected]


If you already run SSH hardening audits or cron hygiene checks on your fleet, this script fits the same pattern — a single Python file, no pip install, no config. I also have a Docker disk reclamation script that lives in the same /usr/local/bin directory.


One thing I learned the hard way: the fail2ban log format uses a trailing timestamp on Found lines (the IP is followed by - 2026-11-02 03:11:04). My first regex swallowed that and returned garbage IPs like 203.0.113.10 - 2026-11.... The gsub(/[\-\s].*/,"",ip) awk version fixed it in the original Bash attempt, but switching to Python’s structured regex was cleaner in the end. The script above handles it correctly.


The demo data above is entirely synthetic — random IPs from RFC 5737 ranges, with a skewed distribution to simulate real traffic patterns. Your own log will look different (and your IPs will be real). Run it and see who has been knocking.

Mohamad ZulfahmyCybersecurity · OffSec · Entrepreneur
About me ↗
ON THIS PAGE
On this page