---
title: 'Security Operations Center'
source: 'https://academia.sh/en/courses/security-operations/security-operations-center'
course: 'Security Operations and Incident Response'
language: en
updated: '2026-09-26T03:02:56+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - "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'
---

# 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.

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

```js
// 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.

```js
// 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.
