DRAFT — internal staging preview. Not for distribution until owner-approved.
Zero to SOC starting…

Zero to SOC · Free open lesson series · Lesson 5 of 7

Lesson 5: The Drip That Landed

FreeNo signupRuns in your browserNo install

Lesson 5 of 7 · Detection arc

Time: ~60–75 minutes, one sitting.

You will be able to:

Prerequisites: Lessons 1–4. You paste back, unchanged: the parser and time helpers (parse, when, hour_of), Lesson 2's detect_compromise, and Lesson 4's detect_low_and_slow — the chaining rule calls detect_low_and_slow directly. Python used: re, collections.defaultdict, datetime.strptime, list/set/dict comprehensions, sorted/min with key=, json.dumps, appending to a file.

The free fifth lesson in Zero to SOC. It aims to be the clearest explanation of why

a rule that only reads raw events has a ceiling — and the one move that breaks

through it — that exists anywhere. If it isn't, it isn't done.

Start here: this lesson continues directly from Lesson 1,

Lesson 2, Lesson 3, and

Lesson 4. You'll reuse the parser and two of the

detectors you already wrote. If you haven't done those, do them first — each takes one

sitting.


Where Lessons 1–4 left off

You've built a real detection panel. Four rules, each watching the same log through a different lens:

first-seen (user, IP) logins (the stolen credential).

Every one of those rules has the same shape: it reads the raw log and emits an alert. Events in, alert out. That shape got you a long way. It also has a ceiling, and this lesson is about hitting it — then climbing over it.

Because here is the attack that walks straight through all four rules at once.

Lessons 1–6 are the detection arc; Lesson 7 opens the investigation arc.

LessonThe skill it adds
1. Build a SIEM from scratchingest → parse → detect → alert
2. Catch the breachcorrelate events; baseline what's normal
3. Defeat the counterpivot the group-by key
4. The patient attackerstretch the horizon; count occasions
5. The drip that landedchain detectors — rules that read alerts← you are here
6. The login that looked fineweigh weak signals; sum the risk
7. Follow the threadinvestigate: pivot, timeline, blast radius

The attack: grind quietly, then log in from somewhere else

The patient attacker from Lesson 4 has been dripping guesses at root — one an hour, from a single quiet IP, for five days. Your Lesson 4 rule catches the grinding. Good.

But this time the grind works. On day six the attacker finally guesses the password. And now they do the smart thing: they don't log in from the noisy scanning box that's been failing for a week. They log in once, cleanly, from a completely different IP — a fresh machine, a residential proxy, wherever. One Accepted. No failures attached to it.

Look at what each of your rules sees:

it only ever looks at failures. It has no idea the attacker got in. It cries about the knocking and never sees the door open.

break-in. But you built it to group by IP — it needs five failures and a success from the same address. The failures came from the scanning box; the success came from the proxy. Different IPs. The one rule designed to catch "they failed, then they're in" is blind, because the attacker split the two halves across two addresses.

So the failure-side rule fires without knowing it's a breach, and the breach-side rule stays silent because it was looking at the wrong key. Each rule is doing its job. The attack lives in the gap between them.

Here's the trap in one line: **a rule that reads only raw events can't see a pattern that only exists across two of your alerts.** Lesson 5 is about writing a rule whose input isn't the log — it's your other rules.


The data

Same format as always. A week of a small server: a Jun 15 baseline of normal logins, alice and bob living their lives across the week (alice fumbles a password once, like a human), a low-and-slow grind against root from 203.0.113.77ten failures over five days, never two in the same hour (the exact drip from Lesson 4) — and then, at the very end, the payoff: root logs in successfully from 203.0.113.90, a brand-new IP, with no failures next to it.

Make an auth.log with this content:

auth.log — edit freely, then create it

Twenty-one lines. Find the last one — root, Accepted, 203.0.113.90, Jun 23. On its own that line is almost invisible: it's a single successful login, and successful logins are the normal thing in a log. The whole breach hides in the fact that nothing next to it looks wrong. (All IPs are from ranges reserved for documentation, so they're safe as examples.)


Recap: your parser and time helpers

Unchanged from earlier lessons. Paste them (or keep your file open) so we have events, when(), and hour_of() to work with.

Python — editable, runs in your browser

Run it: parsed 21 events.


First, watch the two rules that should catch this — and don't

You already own the two rules that, between them, describe this whole attack. Paste them back in exactly as you wrote them: Lesson 2's per-IP correlation and Lesson 4's low-and-slow.

Python — editable, runs in your browser

Output:

Expected output
L2 per-IP correlation: []
{"user": "root", "distinct_hours": 10, "failures": 10, "source_ips": 1, "first_seen": "Jun 18 23:41:55", "last_seen": "Jun 22 20:14:27", "rule": "low_and_slow_bruteforce", "severity": "high"}

Read that carefully, because both halves matter.

Lesson 2's rule returns nothing. The rule you wrote specifically to catch "failed a bunch, then got in" is empty. Why? It groups by IP and needs the failures and the success on the same address. The failures are all on 203.0.113.77; the success is on 203.0.113.90. No single IP has both. The attacker beat this rule by changing IPs for the login — one line of behavior, and your break-in detector goes dark.

Lesson 4's rule fires — but look at what it says, and what it can't. It correctly flags root: ten failures across ten distinct hours, a five-day grind. High severity. But every field in that alert is about failuresdistinct_hours, failures, first_seen, last_seen. The word "Accepted" appears nowhere, because the rule never looks at successes. It is shouting "someone is grinding root" while a successful root login sits four lines below it in the same file, and the rule has no way to connect them. It sees the knocking. It cannot see the door open.

You could stare at these two outputs all day. One rule says "empty." The other says "still grinding." Neither says the true thing: they got in. That sentence isn't in either rule's output because it isn't in either rule's events — it only exists in the relationship between the two alerts.


The new idea: write a rule over your alerts, not over the log

Every detector so far has been a function of raw events:

events  →  detector  →  alerts

The move in this lesson is to add a second layer that reads the first layer's output:

events  →  detectors  →  alerts  →  chaining rule  →  a better alert

The chaining rule doesn't re-examine the log line by line. It asks a question about your alerts: is there an account that my low-and-slow rule flagged for grinding, that then had a successful login? If yes, the drip landed — and that is the single highest-severity thing in this entire series: not a knock, not a guess, but a patient attacker who is now inside.

Notice what the chain is keyed on. Lesson 2's rule keyed on IP and the attacker escaped by switching IPs. The chain keys on the account — and the account is the one thing constant across both halves of the attack. The grind is against root. The login is as root. The attacker can change machines all they like; they cannot change who they're trying to become. Same lesson as Lesson 3, one level up: pick the key the attacker doesn't control.


Detection: chain low-and-slow into success

The rule takes the whole event stream, runs Lesson 4's detector to find dripped-on accounts, and then — for each of those accounts — checks whether a successful login happened at or after the grind began. If it did, it stitches the two stories into one critical alert that carries both: the grind that softened the door, and the login that walked through it.

Python — editable, runs in your browser

Emit it — critical, and this time the word means it: this isn't weather, isn't a knock, isn't a maybe. It's a break-in with a name:

Python — editable, runs in your browser

Run it:

Expected output
{"user": "root", "grind_failures": 10, "grind_hours": 10, "grind_source_ips": 1, "grind_first_seen": "Jun 18 23:41:55", "success_ts": "Jun 23 02:07:33", "success_ip": "203.0.113.90", "rule": "breach_via_slow_grind", "severity": "critical"}

There it is — the sentence neither rule could say alone. The alert tells the whole story on one line: root took a ten-failure grind across ten separate hours from one quiet IP, and then, on Jun 23, logged in successfully from a different IP203.0.113.90 — that had never failed once. The grind and the login were two quiet events in two different rules' blind spots. Put next to each other, they're an incident, and now they're one alert an analyst can act on: the patient attacker who was grinding root all week is in — here's when, here's the address they came from.

And it stays silent about everyone normal. alice fumbled a password and logged in; she was never flagged by the low-and-slow rule, so she never enters the chain. The chain can only escalate accounts that were already suspicious — that's what keeps it from crying breach every time someone logs in.


Now generate the attack yourself

Same loop as every lesson — be the attacker, watch your detector fire. This time you run the full play: grind a new account patiently, then land it from a fresh IP, exactly the shape that split L2 and L4 apart. Drip six guesses at deploy from one quiet IP across six hours, then log in as deploy from a different address the next day.

Python — editable, runs in your browser

Now it catches both landed grinds:

Expected output
{"user": "root", "grind_failures": 10, "grind_hours": 10, "grind_source_ips": 1, "grind_first_seen": "Jun 18 23:41:55", "success_ts": "Jun 23 02:07:33", "success_ip": "203.0.113.90", "rule": "breach_via_slow_grind", "severity": "critical"}
{"user": "deploy", "grind_failures": 6, "grind_hours": 6, "grind_source_ips": 1, "grind_first_seen": "Jun 23 01:00:05", "success_ts": "Jun 24 03:11:00", "success_ip": "203.0.113.95", "rule": "breach_via_slow_grind", "severity": "critical"}

Your deploy attack never put two failures in the same hour and logged in from an IP that never failed — the exact two moves that make L1, L2, L3, and L4 each look away. The chain caught it anyway, because it isn't watching for a burst, or a spread, or a per-IP correlation. It's watching for one of your own alerts followed by a success on the same account. Run Lesson 2's per-IP rule over this same log if you want the contrast: still [].


Make it real: where this rule breaks

A fifth working detection — and this one is different in kind, so the caveats are too. Here's what an analyst pokes at next.

Legitimate logins after real failures exist. A sysadmin does sometimes fat-finger a root password across a stressful afternoon and then log in. The chain as written would call that a breach. But notice it's already far more precise than a raw "root logged in" alert, because it only fires on accounts the low-and-slow rule flagged — five-plus distinct hours of failures. A human doesn't fail in ten separate hours across five days; a script does. The precision comes from what feeds the chain, which is the real lesson: a chaining rule is exactly as good as the alerts underneath it.

This is really risk scoring in disguise. We chained exactly two signals — grind, then success. Real SOCs generalize this: give every account a running risk score, add points for each weak signal (a first-seen IP, a login at 3 a.m., a grind, a burst, a hit on threat intel), and raise an incident when the score crosses a line. That's the same idea — a detection whose inputs are other detections — with a dial instead of an on/off. What you built is the two-signal version; the score is the N-signal version. The industry name is risk-based alerting, and you now understand what's under it.

Order and timing deserve more care than we gave them. We accepted any success at or after the grind started. Tighter versions ask: did the success come after the grind stopped (the failures ending is itself the tell that they finally guessed right)? Within how long? From an IP that's new for this account specifically? Each of those turns a good signal into a better one — and each is another rep of the pattern you've now done five times.

The panel is the point, not any one rule. Step back and look at what you've built. Five detectors, and the fifth one's job is to listen to the others. That's not a bigger rule; it's a different altitude. The first four ask "what happened in the log?" The fifth asks "what do my own alarms, taken together, mean?" Mature detection is layers of that question, and you just wrote the first layer of it by hand.

That move — stop reading the log, start reading your alerts, and fire on the pattern that only exists between them — is detection engineering at the next level up. You've now built a panel that reasons about itself.


Check yourself

Before moving on — answer from your head, then verify against your code:

  1. On the original 21-line log — before you append the deploy attack — how many alerts does detect_chained_breach return, and what are the success_ts and success_ip fields?
  2. The chain correlates the grind and the login by account. What exactly goes wrong, on this data, if you correlate by IP instead?
  3. An attacker grinds a password in only four distinct hours, then logs in from a fresh IP. What does the chain say, and why?
Answers
  1. One alert, for root: success_ts is Jun 23 02:07:33 and success_ip is 203.0.113.90. The grind fields come straight from Lesson 4's alert (grind_failures: 10, grind_hours: 10, grind_first_seen: Jun 18 23:41:55); the success fields come from the first Accepted for root at or after the grind began.
  2. You get Lesson 2's rule back — and its blindness. The ten failures live on 203.0.113.77 and the success lives on 203.0.113.90; no single IP holds both halves, so an IP-keyed correlation returns []. The account (root) is the one value present in both halves, because the attacker can change machines but not who they're trying to become.
  3. Nothing. detect_low_and_slow needs five distinct hours; four hours of failures never produces a grind alert, so the account never enters the chain and the success is ignored — verified: a four-hour grind plus a landing returns no alert. A chaining rule is exactly as good as the alerts underneath it; anything below the first layer's threshold is invisible to the second layer too.

What you just did, and what's next

You learned the idea that separates a pile of rules from a detection system: a detector doesn't have to read the log — it can read your other detectors, and catch the attack that lives in the gap between them. You watched the one rule built to catch break-ins go silent because the attacker switched IPs, watched the grind rule fire without ever knowing the attacker got in, and then wrote a rule that put those two half-truths together into the one sentence that matters — they're inside — and fired it as a single critical alert. Then you ran the whole attack yourself and watched it fire.

This is the Zero to SOC flagship on the same path it started: ingest, parse, detect, correlate, baseline, pivot the key, stretch the horizon — now reason across your own detections. From here the course keeps going the same way:

threshold, taming the false positives a chaining rule can create.

you do in the next ten minutes?*

scenarios — fast, distributed, patient, chained — at your lab so no two students get the same exercise.

generated — a portfolio artifact, not a multiple-choice score.

If these five lessons were clear, that's the whole course: hard things explained plainly, built by your own hands, no magic.


Free to use and share. This series is open-source on purpose — it's the clearest way we know to show what the flagship is. If it helped, that's the pitch.

← Previous lesson Next lesson →