SSH Brute Force Attempts With a Python Script
Every Linux server exposed to the internet collects a steady stream of failed SSH authentication attempts. Bots from compromised hosts in Brazil, Vietnam, and occasionally Australian IP ranges themselves, hammer port 22 looking for weak passwords. For sysadmins in Brisbane and Melbourne managing a handful of VPS instances, sorting through /var/log/auth.log by hand quickly becomes tedious. A small Python script that parses these entries and groups them by source address turns a wall of noise into something you can actually act on.
This article walks through building such a script from scratch. We will look at how the OpenSSH log format is structured, how to design a regex that picks out the right lines, and how to use Python's standard library to tally repeated offenders. Along the way I will point to a companion piece on building a debugger extension that highlights regex matches in log files, which makes iterating on patterns far less painful.
Why Failed SSH Logins Matter
A single failed login is not interesting. A thousand failed logins from the same IP within an hour is a different story. The Australian Cyber Security Centre regularly warns that brute force and credential stuffing remain among the top initial access techniques observed against local organisations. The Notifiable Data Breaches scheme under the Privacy Act 1988 means a successful intrusion can trigger reporting obligations, so catching these attempts early is more than a technical nicety.
Beyond compliance, there is a practical angle. If you can see which addresses are hammering your server, you can decide whether to block them at the firewall, add them to a deny list, or feed them into a SIEM. DenyHosts, one of the older Python-based SSH attack blockers, took exactly this approach years ago. The script we build here is a lighter, more transparent alternative that you can read end to end.
What the Auth Log Actually Contains
On Debian and Ubuntu, OpenSSH writes failed password attempts to /var/log/auth.log in a predictable shape. A typical line looks like:
Sep 12 03:14:22 hostname sshd[21453]: Failed password for invalid user admin from 203.0.113.45 port 51234 ssh2
The fields we care about are the timestamp, the process ID, the keyword Failed password (or error: maximum authentication attempts exceeded), and the source IP near the end of the message. Some distros wrap very long lines across two physical lines using a backslash continuation, which can catch you out if you parse line by line without accounting for it.
If you are running a hardened distribution such as those used by ASD-compliant agencies in Canberra, you may also see preauth and disconnected entries that look similar but lack the IP. Filtering on the word Failed first, then anchoring on the trailing IP, is a reliable starting strategy.
Building the Regex
A regex that matches the canonical failed password line can be surprisingly compact. The following pattern captures the essentials:
^(\w+\s+\d+\s+\d+:\d+:\d+)\s+\S+\s+sshd\[\d+\]:\s+Failed password for (?:invalid user )?(\S+)\s+from\s+(\d{1,3}(?:\.\d{1,3}){3})- The three capture groups correspond to timestamp, attempted username, and source IP.
- Compiling the pattern once with
re.compileand reusing it keeps the parser fast even on multi-gigabyte logs. - Anchoring with
^and rejectingConnection closed bylines avoids false positives from disconnects.
If you find yourself iterating on the pattern often, having a visual debugger helps. I wrote a Python debugger plugin that highlights regex matches directly in log files, which speeds up the trial-and-error cycle considerably when you are chasing subtle format variations across distros.
Grouping Attempts by IP
Once the regex is producing clean tuples, the rest is bookkeeping. A collections.defaultdict(int) keyed on the IP string gives you a running tally in a few lines. After iterating through the file, sort the dictionary by value descending and you have a ranked list of repeat offenders, ready to print or write out as CSV.
For a small server in Adelaide handling a few dozen logins a day, this runs in under a second. For a busy web host in Sydney fronting thousands of connections, you may want to stream the file rather than slurp it whole, and consider using mmap if memory pressure becomes an issue. Either way, the core logic stays the same: read, match, count, sort.
From Script to Operational Tool
A parser that prints results to stdout is useful for ad hoc investigation, but the real value comes from plugging it into your routine. Common extensions include:
- Emailing a daily digest to an on-call address, filtered to IPs that attempted more than a threshold count.
- Posting JSON to a local API that feeds into Grafana, a stack many Australian DevOps teams have standardised on.
- Writing to a deny list that is reloaded into iptables or nftables automatically.
Be careful about feed loops if your script runs on the same host it is monitoring. Logging the script's own output to a separate file avoids recursive noise. Also, when you start blocking addresses, remember that cloud providers recycle IP blocks. An address attacking you today from a Sydney data centre may belong to a legitimate customer tomorrow.
Handling Edge Cases and Rotated Logs
Production systems rotate logs through logrotate, which compresses older files with gzip or xz. Your parser should accept a filename and detect compression from the extension, opening the file with gzip.open or lzma.open accordingly. Walking a directory of rotated archives turns a one-shot tool into something that can rebuild a full picture of attack history.
Time zones add another wrinkle. auth.log is normally written in the system's local time, which on a VPS in Sydney is AEDT, but logs from containers or remote syslog receivers may arrive in UTC. Storing timestamps alongside a normalised timezone offset in your output makes downstream correlation much easier, particularly when sharing indicators of compromise with peers through ACSC's voluntary program.
The finished script is roughly fifty lines of Python. It depends only on the standard library, runs on any Linux box, and gives you a clear picture of who is knocking on your door.
