Back to writing

Turning my auth-log reader into a tiny SIEM

A standard library Python tool that parses an auth.log once and runs four detection rules over it. The bug that nearly made it blind, and what happened when I finally pointed it at a real 2000-line log off the internet.

Tooling & AutomationDetection & Response

A while ago I wrote a small script that counted failed SSH logins per IP. It was useful, but it only ever answered one question. The more I read about SOC work, the more I realised that an analyst is not really asking “which IP failed the most”, they are asking a handful of different questions at the same time, over the same logs. So I wanted to build the next step up: something that reads the log once and then runs several checks over it, the way a real detection pipeline would.

What it does

You point it at a Linux auth.log and it parses every SSH login line into a structured event, then runs four detection rules over those events and prints a report. The counts at the top are the part I care about most. That is the bit that tells you where to look first.

Building it up one rule at a time

I did not write it all at once, and I did not test it on anything scary at first. I started with the boring part on a tiny hand-made sample log: reading the file and pulling the login lines out into events. Before any detection existed, all it did was count what it had parsed.

VS Code showing the read_log function, with a terminal printing "Parsed 24 login events."

Once the events existed, each rule was just a function that looped over them. The suspicious-IP rule was the simplest one: check every event’s IP against a watchlist and flag the matches.

VS Code showing the detect_suspicious_ips function checking each event's IP against a WATCHLIST, with a terminal tagging each matching line by its IP address

The off-hours rule is where I added a small choice. By default it only flags successful logins between midnight and 6am, because that is the scary one, someone actually getting in. On the sample log that is a single alert:

terminal showing "Off-hours alerts: 1" with one accepted root login at 02:15

But I added a --night-all flag that widens it to every attempt in that window, and the same log jumps to ten:

terminal showing "Off-hours alerts: 10" listing every failed and accepted login in the 02:00 hour

Same data, different question, and I liked that a single flag could change what the tool decides is worth an alert.

The bug that taught me the most

The keyword rule is where I got something wrong, and it is the mistake I am most glad I made, because the lesson underneath it is the whole point of the project.

Keyword detection is meant to catch lines with words like sudo, root, invalid user, and error in them. My first version looped over events:

VS Code showing detect_keywords defined to take events and loop "for e in events", with the terminal output only ever tagging login lines

That looks fine until you remember what events actually holds. The parser only keeps login lines, so the lines it dropped were exactly the sudo, CRON, kernel, and error: lines. My keyword rule was scanning a bucket those lines had already been thrown out of. It completely missed sudo and error, and it undercounted root, because it could only ever see a keyword where it happened to sit inside a login line.

If this were a real SIEM, that is you going blind to privilege escalation and error events, which are some of the first things you actually want to see.

The fix is one word. detect_keywords should take the raw lines, not the parsed events. Login parsing and keyword scanning are two different jobs with two different inputs: parsing wants only the login lines, keyword scanning wants every line. Feeding the parser’s output into the keyword detector was the mistake.

Pointing it at a real log

The sample log did its job, but it was 36 lines I wrote myself, so of course everything lit up the way I designed it to. The real test was to feed it something I had not built to order. I used the OpenSSH log from loghub, a public collection of real system logs kept for research: 2000 lines off a box that had been sitting on the internet getting scanned, the kind of auth.log you would actually be handed.

Parsed 520 login events from 2000 log lines.

======================================================================
MINI SIEM REPORT - 1051 alert(s)
======================================================================
  brute_force     9
  suspicious_ip   0
  off_hours       0
  keyword         1042
----------------------------------------------------------------------

[brute_force]
  183.62.140.253 - 286 failed logins (5 within 8s)
  187.141.143.180 - 80 failed logins (5 within 22s)
  103.99.0.122 - 46 failed logins (5 within 13s)
  112.95.230.3 - 26 failed logins (5 within 11s)
  ...

The block above is trimmed; the full report is on GitHub if you want to scroll all 1051 lines. Two thousand log lines, one accepted login in the whole file, and 1051 alerts. That number is the story. Almost none of it is signal, and figuring out which part was is where I learned the most.

Brute force earned its keep

This is the rule I trusted least on the sample log, because a burst of five was easy to fake. On the real log it was the one that clearly worked. Nine IPs tripped it, and the worst was 183.62.140.253 with 286 failed logins, five of them inside eight seconds. That is not a person mistyping a password. That is a script, and the rule caught it for the right reason: it does not care that the total was 286, it cares that five landed in eight seconds. A slow trickle over a day would have stayed quiet. This is the shape of thing the rule was built to see, and the real log was full of them.

Suspicious IP fell flat

Zero hits. Not because the log was clean, but because my watchlist is two addresses I made up while testing, and neither of them shows up in real traffic. The 27 attacking IPs in this log were never going to be on a list I wrote by hand last week. It is the honest version of the problem every static blocklist has: it only knows what you already told it, and attackers do not check your list first. A real deployment would wire this to a live threat-intel feed. Mine is a stub, and the real log made that obvious in a way the sample never could.

Off-hours had nothing to find

Also zero, and this one is not a flaw. The log only covers about four hours, from 06:55 to 11:04 on a single morning. My night window is midnight to 6am, so there was simply no traffic inside it to flag. The rule did exactly what it should: it stayed silent because nothing matched, not because it broke. Worth remembering that a zero can mean “working and quiet” as easily as “not working”.

Keyword drowned everything

1042 of the 1051 alerts came from the keyword rule. Once I fixed the bug and let it scan every raw line, it caught everything I asked it to, and that turned out to be the problem. On a log where nearly every line is root failing to log in, matching on the word root fires 743 times on its own. Add invalid user and error and you get 1042. Every one is technically a correct match. Together they are noise, and they bury the nine brute-force alerts that actually matter under a thousand lines of the same thing.

That is the lesson the sample log could never have taught me. A rule can be correct on every single line and still be useless, because detection is not about matching, it is about signal. My keyword rule needs to count and group, not print a line per hit, so that “root failed 743 times from 27 IPs” reads as one finding instead of 743. The bug earlier made it blind. Scale made it loud. Both are ways of being wrong, and I only saw the second one by running it on something real.

Limits

It only understands SSH login lines and the keywords I hard coded, so plenty of real auth events still slide past it. The watchlist is a fixed set in the file rather than anything live, which the real log made painfully clear. It reads a file once and exits, so it is not watching anything in real time, and the keyword rule needs to summarise its hits instead of listing them before it is any use at scale.

None of that bothers me much, because the goal was never to replace a real SIEM. It was to build the smallest possible thing that still thinks the way one does, parse once, detect many times, and rank the results so the loudest thing is at the top. Running it on a real log did not make it look finished. It showed me exactly which rule to fix next, which is more than the sample log ever did.

Source on GitHub