Lesson 12 / 15
Blue, Red, and Purple Teams
How three teams differ over the same set of paths: in a 30-path access graph, the 11 paths the defense knows, the 15 the attacking side finds, the 8 items where the two do not overlap, and the 13 paths neither sees; how purple work drives the information gap from 6 down to 0, how the closed paths climb from 17 to 22 while the legitimate requests they stop fall from 2,764 to 2,037, and how 8 out-of-scope paths remain.
Published Updated
In this lesson
- Counts, separately, the paths the defense knows, the paths the attacking side finds, and the paths neither sees within the same set of paths
- Defines the information gap and measures how many paths purple work closes
- Computes the legitimate-work cost of closing a path and shows why closing a path that is not understood is expensive
Contents
The previous topic measured six defense principles over the same set of paths and wrote, side by side, how many paths each one closed and how much legitimate work it stopped. Every one of those measurements carried a quiet assumption: the set of paths itself is known. If a closed path could be counted, that count was already written down somewhere beforehand. This lesson lifts that assumption and asks a single question: how many items separate the set of paths an organization knows from the set of paths that actually exists, and who finds that gap.
There are three working modes. The blue team runs the defense: it keeps the inventory, builds the controls, reads the log. The red team looks for paths to the same asset within written authorization and a defined scope; every path it finds may be an item that is not on the defense’s list. Purple work is not a separate roster — it is the two sides working together over the same set of paths. This lesson separates all three into one number: who sees how many paths within the same set of paths.
Three Teams, One Set of Paths
The example is the course’s fictional regional measurement network. The graph below is a model; there is no real host, real address, or real service endpoint.
- FR1. The access graph is 12 nodes and 20 steps: 3 entry points, 2 protected assets. A path is a walk from an entry node to an asset with no repeated node.
- FR2. The defense knows a path only if every node on it is written in the asset inventory. A single node missing from the inventory makes every path through that node invisible.
- FR3. The attacking side finds a path only if every node is within the written scope and the step count does not exceed the step budget. In separate work the budget is 4 steps.
- FR4. In purple work the inventory is shared: every node the red side touches enters the inventory, and because no step is spent on discovery, the budget rises to 6.
- FR5. Each step carries a fixed number of legitimate requests per day; the numbers are fictional.
- FR6. A path closes when one step on it is cut. On a path it understands, the defense cuts the step carrying the fewest legitimate requests.
- FR7. On a path learned only from a report, whose intermediate nodes are not in the inventory, the defense cuts the one step it can name — the entry step.
- FR8. The cost to legitimate work is the total legitimate requests carried by the distinct set of cut steps; if the same step closes more than one path, it is counted once.
// team/network.mjs — the access graph of a fictional regional measurement network (MODEL). There is // no real host, real address, or real service endpoint; every number is fictional. // node: inventory = is it listed in the defense's asset list; // scope = is it within the bounds of written authorization (a third-party endpoint is outside). export const NODE = ` field-terminal entry yes yes subscriber-portal entry yes yes supplier-endpoint entry no no branch-switch mid no yes collector mid yes yes validator mid yes yes identity-directory mid yes yes management-network mid yes yes work-order mid yes yes backup-endpoint mid no yes reading-store asset yes yes billing asset yes yes` .trim().split("\n").map((r) => { const [name, type, inv, scp] = r.trim().split(/\s+/); return { name, type, inventory: inv === "yes", scope: scp === "yes" }; }); // edge: a step, and how many legitimate requests pass through it per day (fictional). export const EDGE = ` field-terminal collector 420 field-terminal branch-switch 35 subscriber-portal validator 900 subscriber-portal identity-directory 760 supplier-endpoint management-network 18 supplier-endpoint backup-endpoint 6 branch-switch management-network 24 branch-switch collector 110 collector validator 380 collector reading-store 540 validator reading-store 610 validator billing 290 identity-directory management-network 44 identity-directory work-order 150 management-network collector 60 management-network backup-endpoint 12 management-network identity-directory 38 backup-endpoint reading-store 9 work-order validator 130 work-order billing 95` .trim().split("\n").map((r) => { const [a, b, m] = r.trim().split(/\s+/); return { a, b, legit: Number(m) }; }); const find = (name) => NODE.find((d) => d.name === name); export const ENTRY = NODE.filter((d) => d.type === "entry").map((d) => d.name); export const ASSET = NODE.filter((d) => d.type === "asset").map((d) => d.name); // All simple paths: from an entry node to an asset, with no repeated node. export function paths() { const out = {}; for (const e of EDGE) (out[e.a] ??= []).push(e.b); const all = []; const walk = (d, trail) => { if (ASSET.includes(d)) { all.push([...trail]); return; } for (const s of out[d] ?? []) if (!trail.includes(s)) walk(s, [...trail, s]); }; for (const g of ENTRY) walk(g, [g]); return all.map((trail) => ({ trail, step: trail.length - 1, edges: trail.slice(0, -1).map((_, i) => `${trail[i]}>${trail[i + 1]}`), })); } export const edgeLegitimate = (e) => { const [a, b] = e.split(">"); return EDGE.find((k) => k.a === a && k.b === b).legit; }; // The defense knows a path only if every node in it is listed in its inventory. export const blueKnows = (y) => y.trail.every((d) => find(d).inventory); // The attacking side finds a path only if every node is in scope and the step count does not // exceed its budget; touching an out-of-scope node steps outside the authorization. export const redFinds = (y, budget) => y.trail.every((d) => find(d).scope) && y.step <= budget;
Who Knows What
// team/measure.mjs — within the same set of paths, the path the defense knows, the path the // attacking side finds, and the path neither sees; then the legitimate-work cost of closing them. import { paths, NODE, EDGE, edgeLegitimate, blueKnows, redFinds } from "./network.mjs"; const P = paths(); const write = (g, ...s) => console.log(s.map((v, i) => (g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i]))).join("")); const INV0 = new Set(NODE.filter((d) => d.inventory).map((d) => d.name)); const knows = (y, inv) => y.trail.every((d) => inv.has(d)); const BUDGET = 4, PURPLE_BUDGET = 6; // fictional: purple work spends no step on discovery // Purple work: every node the red side touches enters the inventory, and red reads the inventory. const TOUCHED = new Set(); for (const y of P) if (redFinds(y, BUDGET)) y.trail.forEach((d) => TOUCHED.add(d)); const INV1 = new Set([...INV0, ...TOUCHED]); const status = (inv, b) => { const m = P.filter((y) => knows(y, inv)), k = P.filter((y) => redFinds(y, b)); const cut = m.filter((y) => k.includes(y)); return { blue: m.length, red: k.length, common: cut.length, blueOnly: m.length - cut.length, redOnly: k.length - cut.length, neither: P.length - (m.length + k.length - cut.length) }; }; const SOLO = status(INV0, BUDGET), PURPLE = status(INV1, PURPLE_BUDGET); console.log(`access graph MODEL: ${NODE.length} nodes, ${EDGE.length} steps, ` + `${P.length} simple paths; step budget is ${BUDGET} working separately, ${PURPLE_BUDGET} in purple work.`); const W1 = [-38, 10, 8]; console.log("\n1. who knows what in the same set of paths"); write(W1, "measure", "separate", "purple"); for (const [ad, f] of [["path the defense knows", (r) => r.blue], ["path the attacking side finds", (r) => r.red], [" known by both", (r) => r.common], [" known by defense only", (r) => r.blueOnly], [" known by attack only (gap)", (r) => r.redOnly], ["path neither sees", (r) => r.neither]]) write(W1, ad, f(SOLO), f(PURPLE)); // Closing: the step carrying the fewest legitimate requests is cut on a known path; on a path // learned only from the report, the defense cuts the one step it can name — the entry step. const cheapest = (y) => y.edges.reduce((a, e) => (edgeLegitimate(e) < edgeLegitimate(a) ? e : a)); const cost = (cut) => ({ step: cut.size, legit: [...cut].reduce((a, e) => a + edgeLegitimate(e), 0) }); const knownSolo = P.filter((y) => knows(y, INV0)); const knownPaired = P.filter((y) => knows(y, INV0) || redFinds(y, BUDGET)); const knownPurple = P.filter((y) => knows(y, INV1) || redFinds(y, PURPLE_BUDGET)); const CUT1 = new Set(knownSolo.map(cheapest)); const CUT2 = new Set([...knownSolo.map(cheapest), ...knownPaired.filter((y) => !knows(y, INV0)).map((y) => y.edges[0])]); const CUT3 = new Set(knownPurple.map(cheapest)); const W2 = [-30, 10, 8, 10, 16]; console.log("\n2. legitimate-work cost of closing (legitimate requests: per day, fictional)"); write(W2, "work mode", "closed", "open", "cut", "legit stopped"); for (const [ad, y, k] of [["defense only", knownSolo, CUT1], ["defense + separate testing", knownPaired, CUT2], ["purple work", knownPurple, CUT3]]) { const b = cost(k); write(W2, ad, y.length, P.length - y.length, b.step, b.legit); } const costliest = [...CUT2].filter((e) => !CUT3.has(e)) .reduce((a, e) => (edgeLegitimate(e) > edgeLegitimate(a) ? e : a)); const thatPath = knownPaired.find((y) => y.edges[0] === costliest && !knows(y, INV0)); console.log(`\nmost expensive cut in separate testing: ${costliest} (${edgeLegitimate(costliest)} legitimate requests),`); console.log(` path it closes: ${thatPath.trail.join(" > ")}`); console.log(` in purple work the same path closes by cutting ${cheapest(thatPath)}: ` + `${edgeLegitimate(cheapest(thatPath))} legitimate requests.`); const outOfScope = P.filter((y) => !y.trail.every((d) => NODE.find((x) => x.name === d).scope)); console.log(`of the ${PURPLE.neither} paths still remaining after purple work, ${outOfScope.length} ` + `pass through an out-of-scope node.`);
access graph MODEL: 12 nodes, 20 steps, 30 simple paths; step budget is 4 working separately, 6 in purple work. 1. who knows what in the same set of paths measure separate purple path the defense knows 11 22 path the attacking side finds 15 22 known by both 9 22 known by defense only 2 0 known by attack only (gap) 6 0 path neither sees 13 8 2. legitimate-work cost of closing (legitimate requests: per day, fictional) work mode closed open cut legit stopped defense only 11 19 7 1969 defense + separate testing 17 13 9 2764 purple work 22 8 10 2037 most expensive cut in separate testing: subscriber-portal>identity-directory (760 legitimate requests), path it closes: subscriber-portal > identity-directory > management-network > backup-endpoint > reading-store in purple work the same path closes by cutting backup-endpoint>reading-store: 9 legitimate requests. of the 8 paths still remaining after purple work, 8 pass through an out-of-scope node.
The separate column of the first table reads the setup where the three teams work
independently. The graph has 30 paths. The defense knows 11 of them; the attacking side
finds 15. The two numbers look close, but they do not point at the same paths: the
intersection is only 9 items.
Two non-overlapping items are not the same kind of gap. The 2 paths known only to the defense are harmless: they are 5 steps long, and the attacking side’s budget is 4 steps. The defense knows them and can close them; they do not stop existing just because testing did not find them. The 6 paths found only by the attacking side, though, are the information gap: they pass through two nodes — the branch switch and the backup endpoint — that are not in the inventory, so the defense’s list never had them. The size of what an organization does not know about its own network can only be written down once an outside test measures that number.
The last row gives what neither team sees: 13 paths. This is nearly half the total, and it has two separate causes. Part of it runs through nodes the defense’s inventory does not have and is too long for the attacking side’s budget to reach; the rest starts from the third-party endpoint, which is outside the scope. Testing a system without written authorization and a defined scope is both illegal and unprofessional; the reason no one touches the out-of-scope node is not a technical barrier but this boundary.
What Purple Work Closes
The purple column is the result of two changes, and both are an exchange of information. The
defense adds the nodes testing touches to its inventory; the branch switch and the backup
endpoint are now on the list. Because the testing side reads the inventory, it spends no step on
discovery, and its budget rises from 4 to 6. No new control was built, no new tool was bought, not
a single step in the graph changed.
The result changes across four rows at once. The path the defense knows goes from 11 to 22, the path the attacking side finds goes from 15 to 22, and both are the same 22 paths: the non-overlapping item drops to zero. The information gap falls from 6 to 0. The path neither sees drops from 13 to 8.
This is the measured contribution of purple work: 11 paths the defense could not reach alone enter the shared list. None of these paths are new — all 30 were in the graph from the start. What closes is not a vulnerability; it is an information gap.
The Cost of Closing
The second table reads the same three setups in terms of the defense’s actual job: how many paths close, and how many legitimate requests are stopped in exchange.
When the defense works alone, 11 paths close, 19 stay open, and cutting 7 steps stops 1,969 legitimate requests a day. Once testing is added, closed paths rise to 17, but stopped legitimate requests jump to 2,764: closing 6 more paths costs 795 more requests, or 132 per path. In purple work, closed paths are 22 and stopped legitimate requests are 2,037: compared to the paired setup, 5 more paths close while 727 fewer legitimate requests are stopped.
This reversal is explained in the last three rows. In a setup working separately, the defense has to close a path it reads from a report, but that path’s intermediate nodes are not in its inventory; the only step it can name is the entry step. It cuts the step from the subscriber portal to the identity directory, and 760 legitimate requests are stopped a day. The same path closes in purple work by cutting the step from the backup endpoint to the reading store: 9 legitimate requests. The path that closes is identical; the cost differs by a factor of 84.
The rule follows from this: a path closed without being understood closes, but closes expensively. A finding list says which path is open; it does not say which step should be cut. Choosing that step requires knowing what job every node on the path does, and that knowledge sits with the defense. What purple work closes is a two-way gap: the defense learns that the path exists, and the testing side learns the legitimate work running over it.
Where Testing Is Not Enough
Even after purple work, 8 paths are not on anyone’s list, and the output’s last line writes that all 8 of these pass through an out-of-scope node. The third-party endpoint is neither in the defense’s inventory nor within the area testing is authorized for.
This remainder does not close with better testing. Raising the budget from 6 to 10 finds no new path, because the obstacle is not the step count. Widening the inventory does not help either, because that endpoint belongs to another organization, and authority to test it is granted only by a separate written agreement. Part of the set of paths stays unmeasured not from a technical gap but from the boundary of authorization — and an unmeasured path is an unclosed path.
Summary
- In the 30-path graph, the defense sees 11 paths and the attacking side sees 15; the intersection is 9. Of the 8 non-overlapping items, 2 are the long path known to the defense, and 6 are the information gap — paths the defense never knew about because they pass through two nodes missing from the inventory.
- Working separately, 13 paths sit outside both teams’ view; after purple work that number falls to 8, and the information gap drops from 6 to 0. Not one step in the graph changes — only information is shared.
- Purple work raises closed paths from 17 to 22 while lowering stopped legitimate requests from 2,764 to 2,037: more paths, less cost.
- A path that is not understood closes expensively. On a path learned from a report, the defense cuts the entry step (760 legitimate requests); the same path in purple work closes with a step carrying 9 legitimate requests.
- All of the remaining 8 paths pass through an out-of-scope node. This remainder can only be measured by changing the boundary of authorization, not by growing the budget.
Next Step
This lesson separated three working modes and tied the difference between them to a single number: who sees how many paths within the same set of paths. The measurement showed one thing — the defense is not a roster, it is the sum of complementary jobs. The job that keeps the inventory and the job that looks for the path sit in separate hands, and when the two do not talk, 11 paths never enter the shared list.
Still, “blue” and “red” are team names, not job names. Who keeps the inventory, who chooses which step to cut, who reads the record left after a cut, who makes the architectural decision? The next lesson divides the same set of paths this time by role, and looks for two numbers: how many paths sit on no role at all, and how many sit on two roles at once.
To keep your progress and take notes, Log in
My notes
Log in to take notes.