Zero to SOC · Free open lesson series · Lesson 5 of 7
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 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.
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.
| Lesson | The skill it adds | |
|---|---|---|
| 1. Build a SIEM from scratch | ingest → parse → detect → alert | |
| 2. Catch the breach | correlate events; baseline what's normal | |
| 3. Defeat the counter | pivot the group-by key | |
| 4. The patient attacker | stretch the horizon; count occasions | |
| 5. The drip that landed | chain detectors — rules that read alerts | ← you are here |
| 6. The login that looked fine | weigh weak signals; sum the risk | |
| 7. Follow the thread | investigate: pivot, timeline, blast radius |
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:
root." Butit 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.
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.77 — ten 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:
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.)
Unchanged from earlier lessons. Paste them (or keep your file open) so we have events, when(), and hour_of() to work with.
Run it: parsed 21 events.
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.
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 failures — distinct_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.
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.
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.
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:
Run it:
{"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 IP — 203.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.
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.
Now it catches both landed grinds:
{"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 [].
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.
Before moving on — answer from your head, then verify against your code:
deploy attack — how many alerts does detect_chained_breach return, and what are the success_ts and success_ip fields?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.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.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.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.