Free security template

Log triage

Parse auth log lines, group failures by account and address, and flag brute force and spraying.

It opens with this template loaded and runs in your browser. You don't need an account to use it; a free account saves your work and exports it.

The job it does

When an alert fires or a customer reports a strange login: find out quickly what happened from the logs. It comes with its own checks, so after you change it you can see at a glance that it still does the job.

How to use it with your own data

Paste your own log lines into logs.js (one event per line: time, user, ip, result).

What's inside

This is the template exactly as the Security Lab opens it, read-only. Open it to run it, change it and export it.

  • 4 files
  • Runs from main.js
  • JavaScript
The brief: Log triage: sign-in attacks

An alert fired, or a customer reports a strange login. Read the sign-in log and flag what an analyst checks first: brute force on one account, password spraying from one address, and a success right after a burst (a possible takeover). Lines it cannot read are listed, not silently dropped.

Your own data: paste your log lines into logs.js (time user=… ip=… result=success|fail), or change parseLine to your log format. Tune the thresholds in RULES.

Done when
  • All my tests pass
  • Output contains “3 findings”
  • Output contains “could not read line 14”
  • No uncaught errors
main.js
import { LOGS } from './logs';
import { triage } from './triage';

const r = triage(LOGS);
console.log('Read ' + r.events + ' events' + (r.unreadable.length ? ', could not read line ' + r.unreadable.join(', ') : ''));
for (const f of r.findings) console.log('- ' + f.kind + ': ' + (f.user ? 'account ' + f.user + ' from ' : '') + f.ip + (f.users ? ' (' + f.users + ' accounts)' : ''));
console.log(r.findings.length + ' findings');
logs.js
// Paste your own sign-in log lines here: time, user, ip and result on each line.
export const LOGS = [
  '2026-03-02T21:00:01Z user=nadia ip=203.0.113.7 result=fail',
  '2026-03-02T21:00:09Z user=nadia ip=203.0.113.7 result=fail',
  '2026-03-02T21:00:17Z user=nadia ip=203.0.113.7 result=fail',
  '2026-03-02T21:00:25Z user=nadia ip=203.0.113.7 result=fail',
  '2026-03-02T21:00:33Z user=nadia ip=203.0.113.7 result=fail',
  '2026-03-02T21:00:41Z user=nadia ip=203.0.113.7 result=success',
  '2026-03-02T21:05:00Z user=imran ip=198.51.100.23 result=fail',
  '2026-03-02T21:05:02Z user=leila ip=198.51.100.23 result=fail',
  '2026-03-02T21:05:04Z user=ravi ip=198.51.100.23 result=fail',
  '2026-03-02T21:05:06Z user=sara ip=198.51.100.23 result=fail',
  '2026-03-02T21:05:08Z user=kofi ip=198.51.100.23 result=fail',
  '2026-03-02T21:20:00Z user=mei ip=192.0.2.44 result=fail',
  '2026-03-02T21:20:30Z user=mei ip=192.0.2.44 result=success',
  'garbled line from a broken agent',
].join('\n');
triage.js
// Log triage: parse sign-in events, then flag the patterns an analyst checks first.
//   brute force:   one account, 5+ failures within 10 minutes
//   spraying:      one address failing on 4+ different accounts within 10 minutes
//   possible takeover: a success right after a brute-force run on the same account
export const RULES = { burst: 5, sprayUsers: 4, windowMs: 10 * 60 * 1000 };

export function parseLine(line) {
  const m = /^(\S+) user=(\S+) ip=(\S+) result=(success|fail)$/.exec(line.trim());
  if (!m) return null;
  const at = Date.parse(m[1]);
  return Number.isNaN(at) ? null : { at, user: m[2], ip: m[3], ok: m[4] === 'success' };
}

export function triage(text, rules = RULES) {
  const events = [], unreadable = [];
  text.split('\n').forEach((l, i) => { const e = parseLine(l); if (e) events.push(e); else if (l.trim()) unreadable.push(i + 1); });
  events.sort((a, b) => a.at - b.at);
  const findings = [];
  const fails = events.filter((e) => !e.ok);
  const users = [...new Set(fails.map((e) => e.user))];
  for (const user of users) {
    const f = fails.filter((e) => e.user === user);
    for (let i = 0; i + rules.burst - 1 < f.length; i++) {
      if (f[i + rules.burst - 1].at - f[i].at > rules.windowMs) continue;
      findings.push({ kind: 'brute force', user, ip: f[i].ip, count: f.length });
      const after = events.find((e) => e.user === user && e.ok && e.at > f[i + rules.burst - 1].at && e.at - f[i + rules.burst - 1].at <= rules.windowMs);
      if (after) findings.push({ kind: 'possible takeover', user, ip: after.ip });
      break;
    }
  }
  for (const ip of [...new Set(fails.map((e) => e.ip))]) {
    const f = fails.filter((e) => e.ip === ip);
    const accounts = new Set(f.filter((e) => e.at - f[0].at <= rules.windowMs).map((e) => e.user));
    if (accounts.size >= rules.sprayUsers) findings.push({ kind: 'spraying', ip, users: accounts.size });
  }
  return { events: events.length, unreadable, findings };
}
triage.test.js
import { parseLine, triage } from './triage';

const line = (s, user, ip, result) => '2026-03-02T21:' + s + 'Z user=' + user + ' ip=' + ip + ' result=' + result;

test('parseLine reads a good line and refuses a broken one', () => {
  expect(parseLine(line('00:01', 'a', '1.1.1.1', 'fail'))).toEqual({ at: Date.parse('2026-03-02T21:00:01Z'), user: 'a', ip: '1.1.1.1', ok: false });
  expect(parseLine('nonsense')).toBe(null);
});
test('5 failures on one account in 10 minutes is brute force', () => {
  const logs = ['00:01', '00:02', '00:03', '00:04', '00:05'].map((s) => line(s, 'nadia', '9.9.9.9', 'fail')).join('\n');
  expect(triage(logs).findings.map((f) => f.kind)).toEqual(['brute force']);
});
test('4 failures spread over an hour is not', () => {
  const logs = ['00:01', '20:00', '40:00', '59:00'].map((s) => line(s, 'nadia', '9.9.9.9', 'fail')).join('\n');
  expect(triage(logs).findings).toEqual([]);
});
test('a success right after the burst is a possible takeover', () => {
  const logs = ['00:01', '00:02', '00:03', '00:04', '00:05'].map((s) => line(s, 'nadia', '9.9.9.9', 'fail')).concat(line('00:30', 'nadia', '9.9.9.9', 'success')).join('\n');
  expect(triage(logs).findings.map((f) => f.kind)).toEqual(['brute force', 'possible takeover']);
});
test('one address failing on 4 accounts is spraying', () => {
  const logs = ['a', 'b', 'c', 'd'].map((u, i) => line('0' + i + ':00', u, '5.5.5.5', 'fail')).join('\n');
  expect(triage(logs).findings).toEqual([{ kind: 'spraying', ip: '5.5.5.5', users: 4 }]);
});

The lesson behind it

Learn why it works, with a podcast and a quiz for each lesson.

  • Threat model checklist

    STRIDE questions for one feature as data, with tests that every threat has an owner and a mitigation.

  • Password policy checker

    Length, breached-list and repeat checks in one function, with tests for the policy you agree.

All security templates · Every template