Skip to content
academia.sh

Lesson 01 / 22

Security Operations Center

273 alerts falling on a fictional line are measured against a three-person shift: the day's work is 1,506 minutes, capacity is 1,440 minutes; the plan that splits twenty-four hours evenly leaves 64 alerts never reviewed and pushes the median wait to 222 minutes, while the plan that leans on density leaves eight hours uncovered and brings that down to 13 alerts and 83 minutes. 19 of 26 cases cross the handover point; with an empty handover note, 19 cases never merge and the median case decision is 987 minutes, dropping to zero unmerged cases at a six-item note with the median falling to 273 minutes, while at twelve items the handover eats 72 minutes and pushes never-reviewed alerts to 26.

Published Updated

In this lesson

  • Runs a day's alert flow against shift capacity and measures time spent in queue
  • Separates coverage from capacity: counts uncovered hours and never-reviewed alerts together
  • Counts cases left unmerged at the handover point and calculates what extending the handover note costs in review capacity
Contents

The previous course spent eighteen lessons counting how many log entries each defensive step left behind. That count carried a silent assumption: that the log entry gets read. This course removes that assumption and measures the structure that reads the log entry. The unit of measure changes too — what is counted is no longer a count of log entries, but time. The time between an event occurring and it being seen as an alert is called time to detect; the time from being seen until it is stopped is called time to contain. Every measurement in this course ties back to one of these two.

A security operations center is not a room; it is a capacity and a handover arrangement. This lesson derives two numbers from that arrangement: how many alerts land on whom within how many hours, and how many items get lost at shift change. On-call policy — who gets called, at what threshold it escalates — was measured in the Observability and Operations course; what is measured here is not the call, but the capacity of a continuous line.

  • DI1. The regional measurement network is fictional: meter collectors, the subscriber portal, and branch endpoints send their logs to a central line. The line produces 273 alerts a day. The numbers and durations are data, not measurement.
  • DI2. The center has three people, each on an eight-hour shift. Daily capacity is 3 × 8 × 60 = 1,440 analyst-minutes.
  • DI3. Alerts come in five types. Each type has two numbers: average review minutes and the minutes between the event occurring and the log entry landing on the line.
  • DI4. An analyst reviews only one alert at a time and does not take on work they cannot finish before their shift ends. If no one is on duty, the alert waits.
  • DI5. A case is 2-5 alerts belonging to the same fictional actor, spread across 30-240 minutes. This link is not written in the log; it lives in the analyst’s memory.
  • DI6. A handover note takes two minutes per item and comes out of the receiving analyst’s review capacity.

One Day’s Flow and Two Shift Plans

// shift.mjs — fictional alert flow and the three-person shift roster. IS A MODEL.
// The regional measurement network is fictional; no real organization, address or event name.
// The generator is self-written; the seed appears below.
export const SEED = 20260401;
const generator = (t) => { let s = t >>> 0;
  return () => (s = (s * 1664525 + 1013904223) >>> 0) / 4294967296; };
const r = generator(SEED);
const randInt = (n) => Math.floor(r() * n);

// Alerts per hour (fictional data, not a measurement): busy during business hours.
const DENSITY = [4, 3, 3, 2, 2, 3, 6, 11, 18, 22, 24, 21,
                 19, 23, 25, 22, 17, 12, 9, 7, 6, 5, 5, 4];
// [type, percentage share, average review minutes, minutes between event and log arrival]
const TYPES = [["authentication", 34, 3, 1], ["network-flow", 26, 4, 5],
               ["endpoint", 22, 8, 2], ["data-access", 12, 11, 12],
               ["control-plane", 6, 6, 9]];

export const ALERT = [];
for (const [h, n] of DENSITY.entries())
  for (let k = 0; k < n; k++) {
    let p = randInt(100), t = TYPES[0];
    for (const row of TYPES) { if (p < row[1]) { t = row; break; } p -= row[1]; }
    ALERT.push({ id: ALERT.length, arrival: h * 60 + randInt(60), type: t[0],
      review: Math.max(1, t[2] + randInt(5) - 2), lag: t[3] });
  }
ALERT.sort((a, b) => a.arrival - b.arrival);

// Shift plan: [name, start hour, end hour]. Three people, eight hours each.
export const PLAN = {
  "even split": [["N1", 0, 8], ["N2", 8, 16], ["N3", 16, 24]],
  "density-weighted": [["N1", 6, 14], ["N2", 10, 18], ["N3", 14, 22]],
};

// Queue simulation: an alert is only reviewed once an on-duty analyst frees up.
export function runQueue(shifts, handoverPrep = []) {
  const free = shifts.map(([, b], i) => b * 60 + (handoverPrep[i] || 0));
  const end = shifts.map(([, , s]) => s * 60);
  const result = [];
  for (const u of ALERT) {
    let pick = -1;
    for (const [i, v] of free.entries()) {
      const start = Math.max(v, u.arrival);
      if (start + u.review > end[i]) continue;      // no new work once the shift is ending
      if (pick < 0 || start < Math.max(free[pick], u.arrival)) pick = i;
    }
    if (pick < 0) { result.push({ ...u, analyst: null, wait: null }); continue; }
    const start = Math.max(free[pick], u.arrival);
    free[pick] = start + u.review;
    result.push({ ...u, analyst: shifts[pick][0], wait: start - u.arrival, close: free[pick] });
  }
  return result;
}

const median = (v) => v.slice().sort((a, b) => a - b)[Math.floor(v.length / 2)];
const fmt = (B) => (h) => h.map((v, j) => (j ? String(v).padStart(B[j])
  : String(v).padEnd(B[0]))).join("");

if (import.meta.url === `file://${process.argv[1]}`) {
  const wide = fmt([18, 12, 16, 13, 14, 13]), narrow = fmt([18, 8, 12, 14]);
  console.log(`model (seed ${SEED}): ${ALERT.length} alerts, total ` +
    `${ALERT.reduce((a, u) => a + u.review, 0)} minutes of review work; ` +
    `three analysts' capacity 3 x 8 x 60 = 1440 minutes`);
  console.log(narrow(["type", "alerts", "log lag", "review min"]));
  for (const [name, , , lag] of TYPES) {
    const l = ALERT.filter((u) => u.type === name);
    console.log(narrow([name, l.length, `${lag} min`, l.reduce((a, u) => a + u.review, 0)]));
  }
  console.log();
  console.log(wide(["plan", "uncovered", "never reviewed", "median wait", "longest wait",
    "over 30 min"]));
  for (const [name, v] of Object.entries(PLAN)) {
    const s = runQueue(v), reviewed = s.filter((u) => u.analyst !== null);
    const covered = new Set(v.flatMap(([, b, e]) =>
      Array.from({ length: e - b }, (_, i) => b + i)));
    console.log(wide([name, `${24 - covered.size} hours`, s.length - reviewed.length,
      median(reviewed.map((u) => u.wait)), Math.max(...reviewed.map((u) => u.wait)),
      reviewed.filter((u) => u.wait > 30).length]));
  }
  console.log("\nper-analyst breakdown in the density-weighted plan (fictional day):");
  const s2 = runQueue(PLAN["density-weighted"]), narrow2 = fmt([8, 8, 11, 12, 14]);
  console.log(narrow2(["analyst", "alerts", "review min", "idle min", "longest wait"]));
  for (const [name, b, e] of PLAN["density-weighted"]) {
    const l = s2.filter((u) => u.analyst === name), rv = l.reduce((a, u) => a + u.review, 0);
    console.log(narrow2([name, l.length, rv, (e - b) * 60 - rv, Math.max(...l.map((u) => u.wait))]));
  }
}
model (seed 20260401): 273 alerts, total 1506 minutes of review work; three analysts' capacity 3 x 8 x 60 = 1440 minutes
type                alerts     log lag    review min
authentication         104       1 min           308
network-flow            68       5 min           271
endpoint                53       2 min           439
data-access             39      12 min           430
control-plane            9       9 min            58

plan                 uncovered  never reviewed  median wait  longest wait  over 30 min
even split             0 hours              64          222           477          161
density-weighted       8 hours              13           83           363          260

per-analyst breakdown in the density-weighted plan (fictional day):
analyst   alerts review min    idle min  longest wait
N1            85        480           0           363
N2            86        480           0           101
N3            89        479           1           127

The first line gives the lesson’s boundary: the day’s work is 1,506 minutes, capacity is 1,440 minutes. Utilization exceeds one hundred percent, and this is a shortfall no shift arrangement can rescue. But where the shortfall lands depends on the arrangement, and the two plans show this in opposite directions.

The type table gives the first column: how many minutes after the event the log entry lands. An authentication log entry arrives in one minute, a data-access log entry in twelve. This is a delay the line never knows about — by the time the log entry lands on the line, the event already happened twelve minutes ago, and the queue only starts after that.

The plan that splits twenty-four hours evenly delivers full coverage: zero uncovered hours. In exchange, 64 alerts are never reviewed, and the median wait is 222 minutes. The reason is plain: the night shift takes three alerts an hour and sits idle, while the day shift meets twenty-five alerts an hour and grows the queue. Coverage is not a measure of capacity. Having someone on duty does not mean that person can handle that hour’s load.

The plan that leans on density leaves eight hours completely uncovered — no one is there between 22:00 and 06:00. In exchange, never-reviewed alerts drop from 64 to 13, and the median wait drops from 222 to 83 minutes. Two columns move in opposite directions: alerts exceeding thirty minutes rise from 161 to 260. The reason lies in how the number is defined — in the even-split plan, the 64 never-reviewed alerts do not enter any wait column at all. A lost item improves the average.

The last table says all three analysts are fully occupied: idle minutes of 0, 0, and 1. The load looks evenly distributed, but the longest-wait column writes out what that costs. An alert N1 picked up waited 363 minutes in the queue; a log entry that lands at night sits in front of no one until six in the morning. Time to detect breaks into three parts in this model, and all three can be read separately in the table: how many minutes after the event the log entry lands, how many minutes it waits in the queue, and how many minutes the review takes.

The Case That Fails to Merge at the Handover Point

Queue time alone is an incomplete measure, because it assumes the decision is made per alert. The real decision is usually made by reading multiple alerts together, and in this model what forms that link is the analyst’s memory. Shift change is exactly the moment that memory resets.

// handover.mjs — the cost of the case left unmerged at shift handover and of the handover note. IS A MODEL.
import { ALERT, PLAN, runQueue } from "./shift.mjs";

const generator = (t) => { let s = t >>> 0;
  return () => (s = (s * 1664525 + 1013904223) >>> 0) / 4294967296; };
const r2 = generator(760413);

// Case: 2-5 alerts belonging to the same fictional actor, spread across 30-240 minutes. The
// analyst forms the link; it is not written in the log. Seed alerts are chosen by the seed.
const taken = new Set();
export const CASE = [];
for (const u of ALERT) {
  if (taken.has(u.id) || r2() > 0.16 || CASE.length === 26) continue;
  const group = [u.id]; taken.add(u.id);
  for (const v of ALERT)
    if (!taken.has(v.id) && group.length < 2 + Math.floor(r2() * 4) &&
        v.arrival > u.arrival + 30 && v.arrival < u.arrival + 240) { group.push(v.id); taken.add(v.id); }
  CASE.push(group);
}

const V = PLAN["density-weighted"];
const END = V.map(([, , e]) => e * 60);

function day(limit) {
  const s = runQueue(V, [2 * limit, 2 * limit, 2 * limit]);   // two minutes of handover per item
  const d = new Map(s.map((u) => [u.id, u]));
  const solo = [], handover = [];
  for (const g of CASE) {
    const items = g.map((i) => d.get(i));
    const seen = items.filter((u) => u.analyst !== null);
    const who = [...new Set(seen.map((u) => u.analyst))];
    if (who.length === 1 && seen.length === items.length)
      solo.push({ g, decision: Math.max(...items.map((u) => u.close)) });
    else handover.push({ g, boundaryIndex: who.length ? V.findIndex(([a]) => a === who[0]) : 2,
      first: Math.min(...items.map((u) => u.arrival)),
      lastClose: Math.max(...seen.map((u) => u.close), 0) });
  }
  let carried = 0, unmerged = 0, strayAlerts = 0;
  for (const i of [0, 1, 2]) {
    const list = handover.filter((v) => v.boundaryIndex === i).sort((a, b) => a.first - b.first);
    for (const [j, v] of list.entries())
      if (j < limit) { carried++; v.decision = Math.max(v.lastClose, END[i]) + 3; }
      else { unmerged++; strayAlerts += v.g.length; v.decision = 1800; }   // 06:00 the next day
  }
  const span = [...solo, ...handover]
    .map((v) => v.decision - Math.min(...v.g.map((i) => d.get(i).arrival - d.get(i).lag)))
    .sort((a, b) => a - b);
  return { solo: solo.length, handover: handover.length, carried, unmerged, strayAlerts,
    handoverMin: 6 * limit, neverReviewed: s.filter((u) => u.analyst === null).length,
    median: span[Math.floor(span.length / 2)] };
}

const fmt = (B) => (h) => h.map((v, j) => (j ? String(v).padStart(B[j])
  : String(v).padEnd(B[0]))).join("");
const yz = fmt([12, 12, 10, 12, 13, 13, 16, 12]);
console.log(`${CASE.length} cases exist, containing ${CASE.reduce((a, g) => a + g.length, 0)} ` +
  `alerts; what forms the link is the analyst's memory, not written in the log`);
console.log(`cases closed within a single shift: ${day(0).solo}, ` +
  `cases crossing the handover point: ${day(0).handover}`);
console.log();
console.log(yz(["note limit", "spans shift", "carried", "unmerged", "stray alerts",
  "handover min", "never reviewed", "median case"]));
for (const limit of [0, 2, 4, 6, 12]) {
  const g = day(limit);
  console.log(yz([`${limit} cases`, g.handover, g.carried, g.unmerged, g.strayAlerts, g.handoverMin,
    g.neverReviewed, `${g.median} min`]));
}
26 cases exist, containing 128 alerts; what forms the link is the analyst's memory, not written in the log
cases closed within a single shift: 7, cases crossing the handover point: 19

note limit   spans shift   carried    unmerged stray alerts handover min  never reviewed median case
0 cases               19         0          19           94            0              13     987 min
2 cases               18         6          12           59           12              15     528 min
4 cases               18        12           6           29           24              18     436 min
6 cases               18        18           0            0           36              18     273 min
12 cases              20        20           0            0           72              26     285 min

Only seven of twenty-six cases close within a single shift; nineteen cross the handover point. This ratio is not a consequence of the model but of the arithmetic of durations: a case spans at most four hours, a shift is eight hours, and once queue waiting is added, it becomes routine for a case to fall to two analysts.

With an empty handover note, 19 cases never merge. This does not mean those cases’ alerts go unreviewed — all 94 alerts get opened and closed one by one. What is lost is not the alert, but the link between alerts, and without that link, each alert looks unimportant on its own. The median case decision rises to 987 minutes, or sixteen hours.

Raising the note limit to six items zeroes out unmerged cases and brings the median down to 273 minutes. The cost is written in the table: 36 analyst-minutes go to the handover meeting, and never-reviewed alerts rise from 13 to 18. At twelve items, though, the curve turns back — unmerged cases are already at zero, the handover eats 72 minutes, never-reviewed alerts jump to 26, and the median climbs back up from 273 to 285 minutes. Capacity spent shortening the decision time lengthens it again past a certain point.

The two-way cost reads together in this table. The cost of a late decision is the case staying open for sixteen hours; how many more items get reached during that time is not written into this model, and will be counted in later lessons. The cost of an early decision is capacity: every minute set aside for handover means one alert that never gets opened at all.

Summary

  • The fictional line’s daily work is 1,506 minutes; the three-person shift’s capacity is 1,440 minutes. Once utilization exceeds one hundred percent, no arrangement closes the gap — it only decides where it lands.
  • The plan that splits twenty-four hours evenly zeroes out uncovered hours but never gets 64 alerts reviewed and pushes the median wait to 222 minutes; coverage is not a measure of capacity.
  • The plan that leans on density leaves eight hours uncovered, bringing never-reviewed alerts down to 13 and the median wait down to 83 minutes; alerts exceeding thirty minutes rising from 161 to 260 is because the lost item improves the average.
  • 19 of 26 cases cross the handover point; with an empty handover note, none of them merge, all 94 alerts close one by one, and the median case decision stays at 987 minutes.
  • A six-item handover note zeroes out unmerged cases and brings the median down to 273 minutes; at twelve items, the handover eats 72 minutes, never-reviewed alerts rise to 26, and the median climbs back up to 285 minutes.

Next Step

What the handover note did was put into writing a link that lived in an analyst’s memory. Building that link into the log from the start — that is, letting the machine see that two alerts belong to the same actor — was assumed in this lesson’s model. In reality, it cannot be assumed: the meter collector’s log and the subscriber portal’s log write the same person with different field names, different time formats, and different levels of detail. The next lesson fits three separately formatted fictional logs into a single schema and establishes the course’s unit of measure: how many fields map, how many fields fit no counterpart at all, and what question an unmappable field leaves unanswered.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close