Lesson 14 / 16
Authentication Logs
A one-day, 1,168-line fictional authentication log set is parsed and six failure clusters are measured against threshold rules: the per-account threshold misses 206 of 216 campaign events, produces 5 false positives, and blocks 9 legitimate logins; opening the window to 24 hours catches both campaigns but locks out 70 accounts and cuts off 49 legitimate logins. Because the spray never makes more than 2 attempts per account, it is invisible at any threshold; a rule that counts along the zone axis separates it out, because the largest legitimate bulk failure stays at 62 accounts.
Published Updated
In this lesson
- Parses a text-format authentication log with a regular expression and counts failure clusters
- Measures a threshold choice in two directions: missed campaign events versus false positives and locked-out legitimate accounts
- Shows why a rule that counts per account never sees a spray-shaped attempt, and compares it with a rule that counts along the zone axis
Contents
In the previous lesson’s coverage table, the authentication log did not write a single one of the twelve foothold point types by name. That source’s job is not tracking items: a foothold point has to have a session opened before it can be set up, and the authentication log sees exactly that step. The number of points it closes is zero; what it makes visible is the step before the points.
This lesson takes that log on its own. The question is not how to write an alert rule, but what a chosen threshold misses and how much legitimate work it stops in exchange. The log set below is a model and the regional measurement network is fictional; account names, machine names, and zone names are made up. Attempting passwords against a set of accounts without written authorization and a defined scope is both illegal and outside professional bounds; what is measured here is not the attempt itself, but the record it leaves behind.
- SL6. The log set covers one day: 96 personnel accounts, 14 service identities, five network zones. The lines are text; fields are extracted with a regular expression.
- SL7. The cluster label the model knows is not written to the log line. The parser cannot see it; the rules only have the fields present in the line.
- SL8. There are six clusters. Four are legitimate: a typo, a forgotten password, a service account with a stale credential, and a server-side authentication setting change. Two are campaigns: a targeted attempt and a spray.
- SL9. Rule 1 counts failures per account, rule 2 counts distinct accounts per zone. Both use a sliding window; for a given key, only the first crossing produces an alert.
- SL10. The threshold is tied to lockout: an account that crosses the threshold cannot log in for the rest of the day. The blocked-login count follows from this assumption.
Parsing the Log
// login.mjs — a fictional authentication log is generated and parsed. THIS IS A MODEL. // There is no real organization, real account, or real address; zone names are fictional. // The generator is written by hand; the seed is visible below. export const SEED = 20260314; const generator = (t) => { let s = t >>> 0; return () => (s = (s * 1664525 + 1013904223) >>> 0) / 4294967296; }; const r = generator(SEED); const int = (n) => Math.floor(r() * n); const clock = (s) => [s / 3600, (s / 60) % 60, s % 60] .map((v) => String(Math.floor(v)).padStart(2, "0")).join(":"); export const ACCOUNT = Array.from({ length: 96 }, (_, i) => `sh-${101 + i}`); export const SERVICE = Array.from({ length: 14 }, (_, i) => `sv-${201 + i}`); export const ZONE = ["branch-net", "field-vpn", "collector-net", "management-net", "external-access"]; // Raw record: [seconds, machine, result, account, zone, label]. The label is the ground truth // the model knows and is NOT WRITTEN to the log line; parsing cannot see it. const raw = []; const add = (s, mac, result, account, zone, label) => raw.push([s, mac, result, account, zone, label]); // 1) Normal login at shift start; a fraction has a typo. for (const [i, h] of ACCOUNT.entries()) { const z = ZONE[i % 3], mac = `ep-${String(1 + (i % 268)).padStart(3, "0")}`; let t = 25200 + int(7200); // between 07:00 and 09:00 if (r() < 0.22) for (let k = 0, n = 1 + int(2); k < n; k++) add(t += 20 + int(40), mac, "failed", h, z, "typo"); add(t + 15, mac, "succeeded", h, z, "typo"); for (let k = 0, n = int(3); k < n; k++) add(36000 + int(28800), mac, "succeeded", h, z, "typo"); } // 2) Three accounts that forgot their password: heavy failures, then a reset and login. for (const h of [ACCOUNT[7], ACCOUNT[41], ACCOUNT[70]]) { let t = 27000 + int(9000); const n = 9 + int(8); for (let k = 0; k < n; k++) add(t += 30 + int(90), "ep-114", "failed", h, "field-vpn", "forgotten-password"); add(t + 2400, "ep-114", "succeeded", h, "field-vpn", "forgotten-password"); } // 3) Two service accounts with stale credentials: steady retries all day. for (const [j, h] of [SERVICE[3], SERVICE[9]].entries()) for (let t = 600 + j * 130; t < 86400; t += 300) add(t, "ep-207", "failed", h, "collector-net", "stale-service-credential"); // 4) A targeted attempt against one account: the same account for three hours. for (let k = 0, t = 68400; k < 40; k++) add(t += 240 + int(60), "ep-018", "failed", ACCOUNT[92], "external-access", "targeted-attempt"); // 5) Spray: two attempts against each of 88 accounts, a fifty-minute window. for (const [j, h] of ACCOUNT.slice(0, 88).entries()) { add(48000 + j * 17, "ep-018", "failed", h, "external-access", "spray"); add(49500 + j * 17, "ep-018", "failed", h, "external-access", "spray"); } add(51100, "ep-018", "succeeded", ACCOUNT[63], "external-access", "spray"); // 6) Server-side authentication setting change: 62 branch accounts fail once, then log in. for (const [j, h] of ACCOUNT.slice(20, 82).entries()) { add(36000 + j * 19, "ep-061", "failed", h, "branch-net", "config-change"); add(36300 + j * 19, "ep-061", "succeeded", h, "branch-net", "config-change"); } raw.sort((a, b) => a[0] - b[0]); export const LINE = raw.map(([s, mac, result, account, zone]) => `2026-03-14T${clock(s)} ${mac} authentication result=${result} account=${account} ` + `zone=${zone} method=password`); export const LABEL = raw.map((k) => k[5]); // Parsing: the log line is text, its fields are extracted with a regular expression. const PATTERN = /^(\S+)T(\S+) (\S+) authentication result=(\w+) account=(\S+) zone=(\S+) /; export const parse = (line) => { const m = PATTERN.exec(line); if (!m) return null; const [, , ti, machine, result, account, zone] = m; const [h, d, s] = ti.split(":").map(Number); return { t: h * 3600 + d * 60 + s, machine, result, account, zone }; }; export const EVENT = LINE.map(parse); if (import.meta.url === `file://${process.argv[1]}`) { console.log(`model (seed ${SEED}): ${LINE.length} log lines, ` + `${ACCOUNT.length} personnel and ${SERVICE.length} service accounts (fictional)`); for (const i of [0, 1]) console.log(LINE[i]); const broken = EVENT.filter((o) => o === null).length; const fail = EVENT.filter((o) => o.result === "failed"); console.log(`\nunparsable lines ${broken}; failed ${fail.length}, ` + `succeeded ${EVENT.length - fail.length}`); const row = (B) => (h) => h.map((v, j) => (j ? String(v).padStart(B[j]) : String(v).padEnd(B[0]))).join(""); const line1 = row([26, 12, 10, 10, 17]); console.log(line1(["cluster (ground truth)", "failed", "accounts", "zones", "max per account"])); for (const e of [...new Set(LABEL)]) { const l = LINE.map((_, i) => i).filter((i) => LABEL[i] === e && EVENT[i].result === "failed"); const h = {}; for (const i of l) h[EVENT[i].account] = (h[EVENT[i].account] || 0) + 1; console.log(line1([e, l.length, Object.keys(h).length, new Set(l.map((i) => EVENT[i].zone)).size, Math.max(0, ...Object.values(h))])); } }
model (seed 20260314): 1168 log lines, 96 personnel and 14 service accounts (fictional) 2026-03-14T00:10:00 ep-207 authentication result=failed account=sv-204 zone=collector-net method=password 2026-03-14T00:12:10 ep-207 authentication result=failed account=sv-210 zone=collector-net method=password unparsable lines 0; failed 916, succeeded 252 cluster (ground truth) failed accounts zones max per account stale-service-credential 572 2 1 286 typo 29 19 3 2 forgotten-password 37 3 1 14 config-change 62 62 1 1 spray 176 88 1 2 targeted-attempt 40 1 1 40
The cluster table puts the whole tension of the lesson on a single screen. 572 of the day’s 916 failed logins come from a single problem: two service accounts with stale credentials retry every five minutes and produce 286 failures per account. That number is seven times the 40 failures the deepest campaign leaves on a single account, and it is entirely legitimate.
The shapes of the two campaigns in the bottom rows are opposites. The targeted attempt leaves 40 failures on a single account — deep and narrow. The spray spreads across 88 accounts but makes only two attempts per account — shallow and wide. The fourth row shows why the second shape is hard to separate: a server-side setting change produced one failure each on 62 accounts, and its shape resembles a spray.
Threshold Per Account
// threshold.mjs — threshold choice: missed events, needless alerts, and legitimate work lockout stops. import { EVENT, LABEL, LINE } from "./login.mjs"; const CAMPAIGN = ["targeted-attempt", "spray"]; const failures = []; EVENT.forEach((o, i) => { if (o.result === "failed") failures.push({ ...o, label: LABEL[i], id: failures.length }); }); const succeeded = EVENT.map((o, i) => ({ ...o, label: LABEL[i] })) .filter((o) => o.result === "succeeded"); const campaignEvents = failures.filter((o) => CAMPAIGN.includes(o.label)); // For one key (account or zone) in a sliding window: the moment it first crosses the threshold, and the events in that window. function crossing(key, measure, threshold, window) { const first = new Map(); for (const [i, o] of failures.entries()) { const k = key(o); if (first.has(k)) continue; const w = failures.slice(0, i + 1).filter((x) => key(x) === k && x.t > o.t - window); if (measure(w) >= threshold) first.set(k, { t: o.t, w }); } return first; } const count = (w) => w.length; const distinctAccounts = (w) => new Set(w.map((x) => x.account)).size; // Evaluation looks at the alert's WINDOW: does it contain a campaign event. function evaluate(alerts) { const covered = new Set(); let empty = 0; for (const [, v] of alerts) { const c = v.w.filter((o) => CAMPAIGN.includes(o.label)); if (c.length === 0) empty++; for (const o of c) covered.add(o.id); } const caught = new Set(campaignEvents.filter((o) => covered.has(o.id)) .map((o) => o.label)); return { caught: caught.size, missed: campaignEvents.length - covered.size, empty }; } const row = (B) => (h) => h.map((v, j) => (j ? String(v).padStart(B[j]) : String(v).padEnd(B[0]))).join(""); const line1 = row([10, 11, 12, 12, 15, 12, 19]); const line2 = row([11, 8, 12, 15, 12, 19]); console.log(`${LINE.length} lines, ${failures.length} failed logins; two campaigns ` + `(${CAMPAIGN.join(", ")}) produced ${campaignEvents.length} events in total`); console.log("\nrule 1 — failure count per account"); console.log(line1(["window", "threshold", "locked out", "caught", "missed events", "false pos.", "blocked logins"])); for (const [name, window] of [["1 hour", 3600], ["24 hours", 86400]]) for (const threshold of [3, 5, 10, 20]) { const alerts = crossing((o) => o.account, count, threshold, window); const d = evaluate(alerts); const blocked = [...alerts].reduce((a, [k, v]) => a + succeeded .filter((b) => b.account === k && b.t >= v.t && b.label !== "spray").length, 0); console.log(line1([name, threshold, alerts.size, `${d.caught}/2`, d.missed, d.empty, blocked])); } console.log("\nrule 2 — count of DISTINCT accounts per zone (1-hour window)"); console.log(line2(["threshold", "alerts", "caught", "missed events", "false pos.", "blocked accounts"])); for (const threshold of [10, 20, 40, 70, 90]) { const alerts = crossing((o) => o.zone, distinctAccounts, threshold, 3600); const d = evaluate(alerts); const blockedAccounts = new Set([...alerts].filter(([, v]) => !v.w.some((o) => CAMPAIGN.includes(o.label))).flatMap(([, v]) => v.w.map((o) => o.account))).size; console.log(line2([threshold, alerts.size, `${d.caught}/2`, d.missed, d.empty, blockedAccounts])); } const biggestLegit = Math.max(...failures.map((o, i) => new Set(failures.slice(0, i + 1) .filter((x) => x.zone === o.zone && x.t > o.t - 3600 && !CAMPAIGN.includes(x.label)) .map((x) => x.account)).size)); const spray = failures.filter((o) => o.label === "spray"); const perAccount = [...new Set(spray.map((o) => o.account))] .map((h) => spray.filter((o) => o.account === h).length); console.log(`\nspray: ${new Set(spray.map((o) => o.account)).size} accounts, at most ` + `${Math.max(...perAccount)} attempts per account, 1 successful login`); console.log(`largest LEGITIMATE bulk failure: ${biggestLegit} distinct accounts in one hour`);
1168 lines, 916 failed logins; two campaigns (targeted-attempt, spray) produced 216 events in total rule 1 — failure count per account window threshold locked out caught missed events false pos. blocked logins 1 hour 3 6 1/2 213 5 9 1 hour 5 6 1/2 211 5 9 1 hour 10 6 1/2 206 5 9 1 hour 20 0 0/2 216 0 0 24 hours 3 70 2/2 104 11 49 24 hours 5 12 2/2 199 5 16 24 hours 10 6 1/2 206 5 9 24 hours 20 3 1/2 196 2 0 rule 2 — count of DISTINCT accounts per zone (1-hour window) threshold alerts caught missed events false pos. blocked accounts 10 2 1/2 206 1 10 20 2 1/2 196 1 20 40 2 1/2 176 1 40 70 1 1/2 146 0 0 90 0 0/2 216 0 0 spray: 88 accounts, at most 2 attempts per account, 1 successful login largest LEGITIMATE bulk failure: 62 distinct accounts in one hour
The first table’s first three rows try three different thresholds and all three give the same result: six accounts get locked out, one of the two campaigns is caught, and between 206 and 213 of the 216 campaign events get missed. There is no measurable detection difference among thresholds set at three, five, and ten; all three catch the same five legitimate accounts, because the only things that cross ten failures in a one-hour window are stale service credentials, users who forgot their password, and the targeted attempt.
The table’s false pos. column counts alerts whose window contains no campaign event — that is,
false positives: at every threshold, five alerts come from a legitimate cause, and if lockout
is on, nine legitimate logins are stopped. Two are service accounts; what stops when they get
locked out is not a user, it is a meter-collection job. Pulling the threshold up to twenty
zeroes out detection along with the false positives: zero campaigns caught, 216 events missed.
Opening the window to twenty-four hours changes the table but does not solve the problem. At a threshold of three, both campaigns get caught and missed events drop to 104; the cost is 70 locked-out accounts, 11 false positives, and 49 blocked legitimate logins. That means everyone who mistypes their password once during the day cannot log back in until evening. That is the bill for buying sensitivity from the window.
How the catch happens matters too: the spray becomes visible only because its two attempts per account add up with a legitimate failure elsewhere in the day. The rule does not see it as a spray; it sees an account that has accumulated three failures, and on accounts with no legitimate failure at all, it still does not see it.
Counting the Same Event Along the Zone Axis
A rule that counts per account can never see the spray on its own at any threshold, because it is measuring the wrong quantity: the campaign stays at two attempts per account, and two is below the level of a legitimate typo. Lowering the threshold does not help here — it only turns typos into alerts. What needs to change is not the threshold, it is the key being counted.
The second table reads the same log by counting distinct accounts per zone. At thresholds of ten, twenty, and forty, two alerts come out, and one is a false positive: the server-side setting change produced one failure each on 62 branch accounts, and this rule sees it as a campaign. In a rule tied to zone blocking, the cost reads directly — at a threshold of forty, 40 legitimate accounts get blocked.
At a threshold of seventy, only one alert remains, the false positive drops to zero, and the spray is separated out. This row looks promising, but the last output line writes the limit: the largest legitimate bulk failure is 62 accounts in one hour. The rule can only separate out attempts that spread wider than that without producing a false positive. If the same campaign had spread across 50 accounts, it could not have been separated at any threshold; and at a threshold of ninety, even this campaign is missed and all 216 events become invisible.
Translated into this course’s measure, the table says: the authentication log closes no foothold point at all; it only makes visible the step before a point is set up, and that visibility depends on the threshold. The one successful login the spray produced is sitting right there in the record too, but a failure threshold never looks at successful logins at all.
Summary
- 572 of the day’s 916 failed logins come from a single legitimate problem: two service accounts with stale credentials produce 286 failures per account, seven times the 40 failures the deepest campaign leaves on a single account.
- A rule that counts per account locks out the same six accounts in a one-hour window at thresholds of three, five, and ten — none at a threshold of twenty; at a threshold of ten, 206 of 216 campaign events get missed, 5 alerts are false positives, and 9 legitimate logins are blocked.
- Opening the window to 24 hours catches both campaigns and brings missed events down to 104; the cost is 70 locked-out accounts, 11 false positives, and 49 blocked legitimate logins.
- Because the spray makes at most 2 attempts per account, it is invisible at any account-axis threshold; the number two is below the level of a legitimate typo, and lowering the threshold only turns typos into alerts.
- A rule that counts distinct accounts per zone separates the spray out without a false positive at a threshold of 70; the floor for that is the size of the largest legitimate bulk failure — 62 accounts in one hour.
Next Step
Both rules looked at a single field: whether the login failed, on which account, in which zone. What happens after a session opens is not in this log; the one successful login the spray produced cannot be told apart from the other 251 successful logins. The next lesson looks inside that session and opens the source that wrote eight types by name on its own in the first lesson’s table: the process creation log. The measure there is parent-process context — how many items look suspicious when the same command list is read as a flat sequence, how many get separated out when it is read as a tree, and what the record’s volume is.
To keep your progress and take notes, Log in
My notes
Log in to take notes.