Practical infra coding

Flag IPs with repeated failed SSH logins

easyLog parsing and aggregation Must-do

Problem statement

Read an auth.log from a bastion host. Count Failed password events per source IP, including attempts against invalid users. Print every IP with 3 or more failures, most failures first; break ties by which IP appeared first in the log. Also show the usernames each IP tried, sorted.

Ignore other lines: successful logins, Invalid user notices (sshd logs these in addition to the Failed password line for the same attempt), and other daemons. IPv6 sources must work.

auth.log

TEXT
Sep 20 10:00:01 bastion sshd[2101]: Failed password for root from 203.0.113.50 port 51122 ssh2
Sep 20 10:00:03 bastion sshd[2101]: Failed password for root from 203.0.113.50 port 51122 ssh2
Sep 20 10:00:05 bastion sshd[2104]: Failed password for invalid user admin from 198.51.100.23 port 40210 ssh2
Sep 20 10:00:06 bastion sshd[2101]: Failed password for root from 203.0.113.50 port 51122 ssh2
Sep 20 10:00:09 bastion sshd[2110]: Accepted publickey for deploy from 192.0.2.10 port 50022 ssh2: ED25519 SHA256:abc
Sep 20 10:00:12 bastion sshd[2104]: Failed password for invalid user admin from 198.51.100.23 port 40210 ssh2
Sep 20 10:00:15 bastion sshd[2120]: Invalid user oracle from 198.51.100.23 port 40290
Sep 20 10:00:18 bastion sshd[2120]: Failed password for invalid user oracle from 198.51.100.23 port 40290 ssh2
Sep 20 10:00:20 bastion CRON[2130]: pam_unix(cron:session): session opened for user root
Sep 20 10:00:31 bastion sshd[2140]: Failed password for deploy from 192.0.2.10 port 50100 ssh2
Sep 20 10:00:40 bastion sshd[2150]: Failed password for invalid user test from 2001:db8::7 port 51000 ssh2

Examples

Example 1

Input: python solution.py auth.log

Output: 203.0.113.50 3 attempts users=root 198.51.100.23 3 attempts users=admin,oracle

Explanation: 198.51.100.23 has three Failed password lines (admin, admin, oracle). Its Invalid user oracle line is not counted separately. 192.0.2.10 and 2001:db8::7 have one failure each.

Hints

Approach

Optimal

Match only the line shape you care about, and anchor it so attacker-controlled text cannot shift the fields.

  1. The regex matches sshd[pid]: Failed password for [invalid user ]USER from IP port N ssh2 at the end of the line. USER is greedy (.*), so if the username contains from 10.9.9.9 port 1, the IP is still taken from the real tail of the message.
  2. The IP group accepts hex digits, dots and colons, which covers IPv4 and IPv6.
  3. Count attempts, collect usernames in a set, and remember the first line number for tie-breaking.
  4. Filter by the threshold and sort by (-attempts, first_seen).

f is the number of distinct failing IPs.

ComplexityTime O(n + f log f)Space O(f)
Python
import re
import sys
from collections import Counter, defaultdict
# Anchored at the end: the username is attacker-controlled and may itself
# contain " from ", so read the IP from the fixed tail of the message.
FAILED = re.compile(
r"sshd\[\d+\]: Failed password for (?:invalid user )?(?P<user>.*) "
r"from (?P<ip>[0-9A-Fa-f:.]+) port \d+ ssh2\s*$"
)
def failed_logins(lines):
attempts = Counter()
users = defaultdict(set)
first_seen = {}
for lineno, line in enumerate(lines, 1):
m = FAILED.search(line)
if not m:
continue # accepted logins, cron, other daemons
ip = m["ip"]
attempts[ip] += 1
users[ip].add(m["user"])
first_seen.setdefault(ip, lineno)
return attempts, users, first_seen
def main(path, threshold=3):
with open(path, encoding="utf-8", errors="replace") as f:
attempts, users, first_seen = failed_logins(f)
flagged = [ip for ip, n in attempts.items() if n >= threshold]
flagged.sort(key=lambda ip: (-attempts[ip], first_seen[ip]))
for ip in flagged:
print(f"{ip} {attempts[ip]} attempts users={','.join(sorted(users[ip]))}")
if __name__ == "__main__":
main(sys.argv[1] if len(sys.argv) > 1 else "auth.log")

Follow-up questions

  • Only flag IPs with 3 failures within any 60 seconds, not in the whole file. (Parse the syslog timestamp; note it has no year.)
  • Generate iptables or security-group rules for the flagged IPs, but never block an allow-listed range.
  • Also detect an IP that fails several times and then succeeds: a possible successful brute force.

Frequently asked questions

sshd writes the username exactly as the client sent it, so an attacker can send x from 10.9.9.9 port 1 as a username. A pattern that stops at the first from would then blame 10.9.9.9, and an automatic blocking script would block an address the attacker chose. Reading the IP from the fixed end of the message avoids that. Log injection like this is a real class of bug in security tooling.

It is a small version of what fail2ban does, and every team with a public bastion has looked at this log. It checks regex skills, counting, and whether you think about hostile input. Interviewers often move on to "now block them", which leads to rate windows and allow-lists.