← back to projects

A small SSH brute-force alerter in Python

2026 · security tooling · python, stdlib only

Why build this when fail2ban exists

My server already runs fail2ban, and it does its job: three failed SSH attempts and the source IP is banned for 24 hours. But fail2ban is silent. It quietly handles the attack and I never find out it happened. On a box sitting on a campus network, I wanted the opposite: tell me when someone is hammering the door, who they are, and what usernames they're guessing.

So this script doesn't ban anything. It's purely a visibility tool that runs alongside fail2ban, and it reports to the same place my other alerts (drive health, monitoring) already go: a Discord channel via webhook.

How it works

The whole thing is standard library: read auth.log, match failed login lines, aggregate by source IP, and post an alert if any IP crosses a threshold. No dependencies means nothing to install or break when it runs from cron.

The matching and aggregation:

FAILED = re.compile(
    r'Failed (?:password|none) for (?:invalid user )?(?P<user>\S+) '
    r'from (?P<ip>[0-9a-fA-F.:]+)'
)

def parse(path):
    attempts = Counter()
    users = defaultdict(set)
    with open(path, errors='replace') as log:
        for line in log:
            m = FAILED.search(line)
            if m:
                attempts[m['ip']] += 1
                users[m['ip']].add(m['user'])
    return attempts, users

The interesting detail is invalid user: sshd logs a different line format for usernames that don't exist on the system versus real users with wrong passwords. Bots overwhelmingly guess non-existent users (admin, oracle, test), so capturing both formats and keeping the username set per IP shows at a glance whether something is a dumb scanner or actually targeting a real account.

Alerting is a plain webhook POST, also stdlib:

def alert(ip, count, users):
    body = json.dumps({'content':
        f'{count} failed SSH attempts from {ip} '
        f'(users tried: {", ".join(sorted(users))})'}).encode()
    req = urllib.request.Request(
        WEBHOOK_URL, data=body,
        headers={'Content-Type': 'application/json'})
    urllib.request.urlopen(req, timeout=10)

Sample output

Console output is a per-IP breakdown sorted by volume (IPs below are sanitised to documentation ranges):

$ python3 sshwatch.py /var/log/auth.log
203.0.113.47    312 attempts   users: admin, oracle, root, test
198.51.100.9     41 attempts   users: root
192.0.2.200       3 attempts   users: anders

2 IPs over threshold (10): alerts sent

Limitations and next steps

More write-ups: VLAN discovery and responsible disclosure · Hardened home lab server