fraud-sim 0.2.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +462 -0
  2. package/README.md +621 -81
  3. package/bin/fraud-sim.js +625 -0
  4. package/examples/account-drain-demo/index.js +1 -1
  5. package/examples/adaptive-hardened-demo/index.js +106 -0
  6. package/examples/basic-credential-stuffing/index.js +4 -2
  7. package/examples/express-hardened-target/server.js +230 -0
  8. package/examples/express-mock-target/server.js +153 -2
  9. package/package.json +40 -8
  10. package/src/adapters/http.js +49 -7
  11. package/src/index.js +126 -13
  12. package/src/llm/anthropic.js +7 -0
  13. package/src/llm/deterministic.js +21 -7
  14. package/src/llm/openai.js +7 -0
  15. package/src/locales/index.js +193 -0
  16. package/src/reporting/aggregate.js +338 -0
  17. package/src/reporting/html.js +424 -0
  18. package/src/reporting/index.js +241 -0
  19. package/src/reporting/junit.js +171 -0
  20. package/src/reporting/money.js +105 -0
  21. package/src/runner.js +295 -12
  22. package/src/scenarios/account-drain.js +10 -2
  23. package/src/scenarios/adaptive-attacker.js +33 -11
  24. package/src/scenarios/card-testing.js +174 -0
  25. package/src/scenarios/credential-stuffing.js +146 -20
  26. package/src/scenarios/index.js +37 -6
  27. package/src/scenarios/legitimate-users.js +290 -0
  28. package/src/scenarios/mule-network.js +201 -0
  29. package/src/scenarios/promo-abuse.js +168 -0
  30. package/src/scenarios/session-hijack.js +234 -0
  31. package/src/scenarios/sim-swap-takeover.js +253 -0
  32. package/src/scenarios/slow-drain.js +171 -0
  33. package/src/scenarios/velocity-evasion.js +179 -0
  34. package/src/types.js +160 -0
  35. package/src/utils/clock.js +77 -0
  36. package/src/utils/faker-helpers.js +87 -2
  37. package/src/utils/index.js +69 -0
  38. package/src/utils/random-source.js +104 -0
  39. package/src/utils/random.js +257 -44
  40. package/src/utils/results.js +84 -0
  41. package/src/utils/rng.js +3 -4
  42. package/src/utils/throttle.js +127 -0
  43. package/types/adapters/http.d.ts +6 -0
  44. package/types/adapters/index.d.ts +3 -0
  45. package/types/adapters/logging.d.ts +19 -0
  46. package/types/adapters/mock.d.ts +32 -0
  47. package/types/index.d.ts +79 -0
  48. package/types/llm/anthropic.d.ts +17 -0
  49. package/types/llm/deterministic.d.ts +11 -0
  50. package/types/llm/index.d.ts +3 -0
  51. package/types/llm/openai.d.ts +17 -0
  52. package/types/locales/index.d.ts +146 -0
  53. package/types/reporting/aggregate.d.ts +126 -0
  54. package/types/reporting/html.d.ts +1 -0
  55. package/types/reporting/index.d.ts +99 -0
  56. package/types/reporting/junit.d.ts +13 -0
  57. package/types/reporting/money.d.ts +20 -0
  58. package/types/runner.d.ts +10 -0
  59. package/types/scenarios/account-drain.d.ts +25 -0
  60. package/types/scenarios/adaptive-attacker.d.ts +22 -0
  61. package/types/scenarios/card-testing.d.ts +28 -0
  62. package/types/scenarios/credential-stuffing.d.ts +23 -0
  63. package/types/scenarios/index.d.ts +261 -0
  64. package/types/scenarios/legitimate-users.d.ts +49 -0
  65. package/types/scenarios/mule-network.d.ts +17 -0
  66. package/types/scenarios/promo-abuse.d.ts +28 -0
  67. package/types/scenarios/session-hijack.d.ts +15 -0
  68. package/types/scenarios/sim-swap-takeover.d.ts +16 -0
  69. package/types/scenarios/slow-drain.d.ts +28 -0
  70. package/types/scenarios/velocity-evasion.d.ts +31 -0
  71. package/types/streaming/index.d.ts +1 -0
  72. package/types/streaming/sse.d.ts +34 -0
  73. package/types/types.d.ts +191 -0
  74. package/types/utils/clock.d.ts +39 -0
  75. package/types/utils/faker-helpers.d.ts +114 -0
  76. package/types/utils/index.d.ts +8 -0
  77. package/types/utils/random-source.d.ts +39 -0
  78. package/types/utils/random.d.ts +89 -0
  79. package/types/utils/results.d.ts +46 -0
  80. package/types/utils/rng.d.ts +36 -0
  81. package/types/utils/sleep.d.ts +7 -0
  82. package/types/utils/throttle.d.ts +33 -0
@@ -0,0 +1,424 @@
1
+ /**
2
+ * HTML report renderer.
3
+ *
4
+ * Produces a single self-contained file — no build step, no external assets
5
+ * beyond an optional web font, and it prints correctly. The output is meant to
6
+ * be sent to someone: a risk lead, a CTO evaluating the system, an auditor.
7
+ *
8
+ * Two deliberate choices worth knowing about:
9
+ *
10
+ * - **Decision colour is status colour, not series colour.** Blocked,
11
+ * challenged and allowed mean good, partial and bad from the defender's
12
+ * side, so they use fixed status hues rather than a categorical palette.
13
+ * - **Every bar has its number beside it.** The status yellow is below 3:1
14
+ * against a light surface, so colour alone is never the carrier — the table
15
+ * is the data, the bar is the glance.
16
+ */
17
+
18
+ import {
19
+ normaliseInput,
20
+ totalsFor,
21
+ reasonFrequency,
22
+ latencyFor,
23
+ outcomeFor,
24
+ findingsFor,
25
+ isLegitimate,
26
+ pct,
27
+ fmtMs,
28
+ } from './aggregate.js';
29
+ import { formatMoney } from './money.js';
30
+
31
+ const ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
32
+
33
+ /** Escape anything that reaches the document — scenario ids and reason codes
34
+ * come from user-written scenarios and target responses, not from us. */
35
+ function esc(value) {
36
+ return String(value ?? '').replace(/[&<>"']/g, (c) => ESCAPES[c]);
37
+ }
38
+
39
+ export function renderHtml(result, options = {}) {
40
+ const { reports } = normaliseInput(result);
41
+ const title = options.title ?? 'Adversarial Assessment';
42
+ const targetName = options.targetName ?? 'Target system';
43
+ const generatedAt = options.now ?? new Date();
44
+
45
+ const money = (minor) => formatMoney(minor, options);
46
+ const totals = totalsFor(reports);
47
+ const legit = totals.legitimate;
48
+ const customerIds = reports.filter(isLegitimate).map((r) => r.scenarioId);
49
+ const findings = findingsFor(reports, options);
50
+ const reasons = reasonFrequency(reports);
51
+ const latency = latencyFor(reports);
52
+ const seeds = [...new Set(reports.map((r) => r.seed).filter((s) => s != null))];
53
+
54
+ const rows = [...reports].sort((a, b) => (b.attempts ?? 0) - (a.attempts ?? 0));
55
+
56
+ return `<!doctype html>
57
+ <html lang="en">
58
+ <head>
59
+ <meta charset="utf-8">
60
+ <meta name="viewport" content="width=device-width, initial-scale=1">
61
+ <meta name="color-scheme" content="light dark">
62
+ <title>${esc(title)}</title>
63
+ <link rel="preconnect" href="https://fonts.googleapis.com">
64
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
65
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Serif:wght@500;600&display=swap">
66
+ <style>${STYLES}</style>
67
+ </head>
68
+ <body>
69
+ <div class="wrap">
70
+
71
+ <header class="masthead">
72
+ <span class="eyebrow">Adversarial assessment</span>
73
+ <h1>${esc(title)}</h1>
74
+ <p class="standfirst">
75
+ ${reports.length} scenario${reports.length === 1 ? '' : 's'},
76
+ ${totals.attempts} scored event${totals.attempts === 1 ? '' : 's'},
77
+ run against ${esc(targetName)}.
78
+ ${totals.estimatedLossMinor > 0
79
+ ? `It blocked ${pct(totals.blockRate)} of what was thrown at it — and still let ${esc(money(totals.estimatedLossMinor))} through.`
80
+ : `It blocked ${pct(totals.blockRate)} of what was thrown at it.`}
81
+ ${legit
82
+ ? legit.blocked > 0
83
+ ? `It also refused ${pct(legit.falsePositiveRate)} of ordinary customer activity — ${legit.customersBlocked} of ${legit.customers} customers locked out at least once.`
84
+ : `It let every ordinary customer through${legit.stepUp > 0 ? `, challenging ${pct(legit.stepUp / Math.max(1, legit.scoredAttempts))} of their activity` : ''}.`
85
+ : ''}
86
+ </p>
87
+
88
+ <dl class="meta">
89
+ <div><dt>Target</dt><dd>${esc(targetName)}</dd></div>
90
+ <div><dt>Date</dt><dd>${esc(generatedAt.toISOString().slice(0, 10))}</dd></div>
91
+ <div><dt>Scenarios</dt><dd class="num">${reports.length}</dd></div>
92
+ <div><dt>Scored events</dt><dd class="num">${totals.scoredAttempts}</dd></div>
93
+ <div><dt>Seed</dt><dd class="mono num">${seeds.length ? esc(seeds.join(', ')) : 'unseeded'}</dd></div>
94
+ <div><dt>Failed requests</dt><dd class="num">${totals.errors}</dd></div>
95
+ </dl>
96
+ </header>
97
+
98
+ ${totals.errorRate > 0.05 ? unreliableBanner(totals) : ''}
99
+ ${seeds.length === 0 ? unseededBanner() : ''}
100
+
101
+ <section>
102
+ <div class="sec-head"><span class="eyebrow">01 — Summary</span>
103
+ <h2>${totals.estimatedLossMinor > 0
104
+ ? 'A healthy block rate is not the same as no losses'
105
+ : 'What the defence stopped'}</h2>
106
+ </div>
107
+
108
+ <div class="tiles">
109
+ ${tile('Block rate', pct(totals.blockRate), `${totals.blocked} of ${totals.scoredAttempts} scored events blocked outright.`)}
110
+ ${tile('Challenged', pct(totals.stepUpRate), `${totals.stepUp} sent to step-up rather than refused.`)}
111
+ ${tile('Attacks that succeeded', String(totals.attacksSucceeded), 'Scenarios that reached their objective.', totals.attacksSucceeded > 0)}
112
+ ${tile('Value extracted', totals.estimatedLossMinor > 0 ? esc(money(totals.estimatedLossMinor)) : '—',
113
+ totals.estimatedLossMinor > 0 ? 'Summed across every scenario that reached money.' : 'No scenario reached money.',
114
+ totals.estimatedLossMinor > 0)}
115
+ </div>
116
+ ${legit ? `
117
+ <h3 class="sub">What it cost ordinary customers</h3>
118
+ <div class="tiles">
119
+ ${tile('False-positive rate', pct(legit.falsePositiveRate), `${legit.blocked} of ${legit.scoredAttempts} legitimate events blocked.`, legit.blocked > 0)}
120
+ ${tile('Friction', pct(legit.frictionRate), `${legit.stepUp + legit.blocked} legitimate events challenged or blocked.`)}
121
+ ${tile('Customers refused', `${legit.customersBlocked} of ${legit.customers}`, 'Customers blocked at least once.', legit.customersBlocked > 0)}
122
+ ${tile('Payments refused', legit.valueBlockedMinor > 0 ? esc(money(legit.valueBlockedMinor)) : '—',
123
+ legit.valueBlockedMinor > 0 ? "Customers' own payments to people they pay." : 'No legitimate payment was refused.',
124
+ legit.valueBlockedMinor > 0)}
125
+ </div>` : ''}
126
+ </section>
127
+
128
+ ${findings.length ? `
129
+ <section>
130
+ <div class="sec-head"><span class="eyebrow">02 — Findings</span>
131
+ <h2>${findings.length} finding${findings.length === 1 ? '' : 's'}, ranked by what they cost</h2>
132
+ </div>
133
+ <div class="findings">
134
+ ${findings.map(renderFinding).join('\n')}
135
+ </div>
136
+ </section>` : `
137
+ <section>
138
+ <div class="sec-head"><span class="eyebrow">02 — Findings</span><h2>Nothing got through</h2></div>
139
+ <p class="prose col">Every scenario was contained, no run reached money, and no result
140
+ carries a reliability caveat. That is a good outcome — read the per-scenario table
141
+ below to see how much margin each one had.</p>
142
+ </section>`}
143
+
144
+ <section>
145
+ <div class="sec-head"><span class="eyebrow">03 — Full results</span><h2>Every scenario, every decision</h2></div>
146
+
147
+ <div class="legend">
148
+ <span class="l-blocked">Blocked</span>
149
+ <span class="l-challenged">Challenged (step-up)</span>
150
+ <span class="l-allowed">Allowed</span>
151
+ </div>
152
+
153
+ <div class="table-scroll">
154
+ <table>
155
+ <thead><tr>
156
+ <th scope="col">Scenario</th>
157
+ <th scope="col" class="r">Events</th>
158
+ <th scope="col" style="min-width:150px">Decisions</th>
159
+ <th scope="col" class="r">Blocked</th>
160
+ <th scope="col" class="r">Chall.</th>
161
+ <th scope="col" class="r">Allowed</th>
162
+ <th scope="col" class="r">p95</th>
163
+ <th scope="col">Outcome</th>
164
+ </tr></thead>
165
+ <tbody>${rows.map(renderRow).join('\n')}</tbody>
166
+ </table>
167
+ </div>
168
+ <p class="footnote">Counts are events, not attacks. Some scenarios include the victim's own
169
+ legitimate activity, so an allowed event is not automatically a miss — the outcome column
170
+ reads the attack rather than the arithmetic.${legit ? ` On legitimate traffic
171
+ (<span class="mono">${customerIds.map(esc).join('</span>, <span class="mono">')}</span>) the
172
+ colours invert: a blocked customer is shown red and an allowed one green.` : ''}</p>
173
+ </section>
174
+
175
+ ${reasons.length ? `
176
+ <section>
177
+ <div class="sec-head"><span class="eyebrow">04 — Signals</span><h2>Which rules did the work</h2></div>
178
+ <div class="reasons">
179
+ ${reasons.map(([code, n]) => {
180
+ const share = n / Math.max(1, reasons[0][1]);
181
+ return `<div class="reason">
182
+ <span class="mono code">${esc(code)}</span>
183
+ <span class="track"><span class="fill" style="width:${(share * 100).toFixed(1)}%"></span></span>
184
+ <span class="mono num count">${n}</span>
185
+ </div>`;
186
+ }).join('\n')}
187
+ </div>
188
+ <p class="footnote">Times each reason code appeared across the run. A rule that never
189
+ appears is either misconfigured or is not being exercised by these scenarios.</p>
190
+ </section>` : ''}
191
+
192
+ <section>
193
+ <div class="sec-head"><span class="eyebrow">05 — Limits</span><h2>What this run does not tell you</h2></div>
194
+ <div class="limits">
195
+ ${limitsFor(reports, totals, latency).map((l) => `<div class="limit"><span class="mark">${l.n}</span><p>${l.html}</p></div>`).join('\n')}
196
+ </div>
197
+ </section>
198
+
199
+ <footer>
200
+ <span>Generated by <span class="mono">fraud-sim</span>${options.version ? ` v${esc(options.version)}` : ''}</span>
201
+ <span class="mono">${totals.attempts} events · ${totals.errors} errors${seeds.length ? ` · seed ${esc(seeds.join(', '))}` : ''}</span>
202
+ </footer>
203
+ </div>
204
+ </body>
205
+ </html>
206
+ `;
207
+ }
208
+
209
+ // ── pieces ──────────────────────────────────────────────────────────────
210
+
211
+ function tile(label, value, note, alarm = false) {
212
+ return `<div class="tile${alarm ? ' is-alarm' : ''}">
213
+ <span class="label">${esc(label)}</span>
214
+ <span class="value">${value}</span>
215
+ <span class="note">${esc(note)}</span>
216
+ </div>`;
217
+ }
218
+
219
+ function renderFinding(f) {
220
+ return `<article class="finding" data-sev="${esc(f.severity)}">
221
+ <div class="finding-top">
222
+ <span class="chip" data-sev="${esc(f.severity)}">${esc(f.severity)}</span>
223
+ <span class="chip scenario mono">${esc(f.scenario)}</span>
224
+ </div>
225
+ <h3>${esc(f.title)}</h3>
226
+ <p>${esc(f.detail)}</p>
227
+ <div class="evidence">${f.stats
228
+ .map(([k, v]) => `<span class="k">${esc(k)}</span> ${esc(v)}`)
229
+ .join('<br>')}</div>
230
+ </article>`;
231
+ }
232
+
233
+ function renderRow(r) {
234
+ const outcome = outcomeFor(r);
235
+ const total = Math.max(1, r.blocked + r.stepUp + r.allowed);
236
+ const seg = (n, cls) =>
237
+ n > 0 ? `<span class="${cls}" style="flex:${n}" title="${n} of ${total}"></span>` : '';
238
+ // Decision colour is status colour: for a real customer a block is the bad
239
+ // outcome, so the hues swap rather than reading a refusal as a win.
240
+ const legitimate = isLegitimate(r);
241
+ const blockedCls = legitimate ? 'seg-allowed' : 'seg-blocked';
242
+ const allowedCls = legitimate ? 'seg-blocked' : 'seg-allowed';
243
+
244
+ return `<tr>
245
+ <td class="scenario-name"><span class="mono">${esc(r.scenarioId)}</span></td>
246
+ <td class="n">${r.attempts}</td>
247
+ <td><div class="bar">${seg(r.blocked, blockedCls)}${seg(r.stepUp, 'seg-challenged')}${seg(r.allowed, allowedCls)}</div></td>
248
+ <td class="n">${r.blocked}</td>
249
+ <td class="n">${r.stepUp}</td>
250
+ <td class="n">${r.allowed}</td>
251
+ <td class="n">${esc(fmtMs(r.latency?.p95))}</td>
252
+ <td><span class="outcome" data-o="${esc(outcome.severity)}">${esc(outcome.label)}</span></td>
253
+ </tr>`;
254
+ }
255
+
256
+ function unreliableBanner(totals) {
257
+ return `<p class="banner is-warn"><span class="mono">!</span><span><strong>This run is not a
258
+ measurement.</strong> ${pct(totals.errorRate)} of attempts never received a decision — the
259
+ target was rate limiting, erroring, or unreachable. Every rate below is computed over the
260
+ ${totals.scoredAttempts} events that were actually scored, but the sample is compromised.
261
+ Fix the target and run again before drawing conclusions.</span></p>`;
262
+ }
263
+
264
+ function unseededBanner() {
265
+ return `<p class="banner"><span class="mono">&#9656;</span><span><strong>This run was not
266
+ seeded.</strong> Its results cannot be replayed exactly. Pass a seed to make anything that
267
+ got through reproducible.</span></p>`;
268
+ }
269
+
270
+ function limitsFor(reports, totals, latency) {
271
+ const out = [];
272
+ const timed = reports.filter((r) => r.simulatedTime).map((r) => r.scenarioId);
273
+
274
+ if (timed.length) {
275
+ out.push({
276
+ html: `<strong>${timed.length} scenario${timed.length === 1 ? '' : 's'} depend on which clock the
277
+ target uses.</strong> <span class="mono">${timed.map(esc).join('</span>, <span class="mono">')}</span>
278
+ space events by advancing the event timestamp rather than sleeping. If the target derives
279
+ its velocity windows from server arrival time, it saw a burst and those rows measure
280
+ something other than the attack described.`,
281
+ });
282
+ }
283
+
284
+ out.push({
285
+ html: `<strong>Latency here is ${latency.count ? `p95 ${esc(fmtMs(latency.p95))}, max ${esc(fmtMs(latency.max))}` : 'not measured'}.</strong>
286
+ ${latency.approximate ? 'Suite-level percentiles are the worst per-scenario value, not a merged distribution. ' : ''}
287
+ Figures include network time to the target and are only meaningful against a
288
+ deployment that resembles production.`,
289
+ });
290
+
291
+ if (totals.errors === 0) {
292
+ out.push({
293
+ html: `<strong>No errors, so nothing is diluted.</strong> All ${totals.attempts} events
294
+ returned a decision. Had the target failed, those events would be excluded from the rates
295
+ above and flagged — a failing target is not a defending one.`,
296
+ });
297
+ }
298
+
299
+ out.push({
300
+ html: `<strong>This is a point-in-time result.</strong> It reflects the rules and thresholds
301
+ in place during the run. Wire the same suite into CI if you want it to stay true.`,
302
+ });
303
+
304
+ return out.map((l, i) => ({ ...l, n: String(i + 1).padStart(2, '0') }));
305
+ }
306
+
307
+ // ── styles ──────────────────────────────────────────────────────────────
308
+
309
+ const STYLES = `
310
+ :root{color-scheme:light;--ground:#f7f8f8;--surface:#fff;--surface-sunk:#eef1f1;--rule:#dfe4e4;--rule-strong:#c6cfcf;--ink:#101a1d;--ink-2:#3d4d52;--ink-3:#5c6b70;--accent:#0f5f66;--accent-soft:#e4eeee;--blocked:#0ca30c;--challenged:#fab219;--allowed:#d03b3b;--sev-critical:#d03b3b;--sev-high:#ec835a;--sev-medium:#fab219;--sev-unknown:#5c6b70;--shadow:0 1px 2px rgba(16,26,29,.06),0 8px 24px -16px rgba(16,26,29,.3)}
311
+ @media (prefers-color-scheme:dark){:root:not([data-theme="light"]){color-scheme:dark;--ground:#12191c;--surface:#182226;--surface-sunk:#101619;--rule:#2a373b;--rule-strong:#3c4b50;--ink:#e9eeee;--ink-2:#b3c1c4;--ink-3:#8b9b9f;--accent:#5cc2ca;--accent-soft:#173235;--sev-unknown:#8b9b9f;--shadow:0 1px 2px rgba(0,0,0,.4),0 8px 24px -16px rgba(0,0,0,.8)}}
312
+ :root[data-theme="dark"]{color-scheme:dark;--ground:#12191c;--surface:#182226;--surface-sunk:#101619;--rule:#2a373b;--rule-strong:#3c4b50;--ink:#e9eeee;--ink-2:#b3c1c4;--ink-3:#8b9b9f;--accent:#5cc2ca;--accent-soft:#173235;--sev-unknown:#8b9b9f;--shadow:0 1px 2px rgba(0,0,0,.4),0 8px 24px -16px rgba(0,0,0,.8)}
313
+ *,*::before,*::after{box-sizing:border-box}
314
+ body{margin:0;background:var(--ground);color:var(--ink);font-family:"IBM Plex Sans",ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;font-size:16.5px;line-height:1.62;-webkit-font-smoothing:antialiased;padding:0 24px 96px}
315
+ .wrap{max-width:1120px;margin:0 auto}
316
+ .col{max-width:68ch}
317
+ h1,h2,h3{font-family:"IBM Plex Serif",ui-serif,Georgia,serif;font-weight:600;line-height:1.18;text-wrap:balance;margin:0}
318
+ p{margin:0}
319
+ .eyebrow{font-family:"IBM Plex Mono",ui-monospace,monospace;font-size:11.5px;font-weight:500;letter-spacing:.13em;text-transform:uppercase;color:var(--accent)}
320
+ .mono{font-family:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,monospace}
321
+ .num{font-variant-numeric:tabular-nums}
322
+ header.masthead{padding:72px 0 0;display:flex;flex-direction:column;gap:20px}
323
+ h1{font-size:clamp(38px,6.5vw,60px);letter-spacing:-.02em}
324
+ .standfirst{max-width:62ch;font-size:19px;line-height:1.5;color:var(--ink-2)}
325
+ .meta{margin-top:12px;display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:1px;background:var(--rule);border:1px solid var(--rule);border-radius:8px;overflow:hidden}
326
+ .meta div{background:var(--surface);padding:12px 16px;display:flex;flex-direction:column;gap:3px}
327
+ .meta dt{font-family:"IBM Plex Mono",monospace;font-size:10.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--ink-3)}
328
+ .meta dd{margin:0;font-size:14px;font-weight:500}
329
+ .banner{margin:36px 0 0;display:flex;gap:14px;align-items:flex-start;padding:16px 20px;background:var(--accent-soft);border:1px solid var(--rule);border-left:3px solid var(--accent);border-radius:6px;font-size:15px;color:var(--ink-2);max-width:92ch}
330
+ .banner.is-warn{border-left-color:var(--allowed)}
331
+ .banner strong{color:var(--ink);font-weight:600}
332
+ section{margin-top:68px}
333
+ .sec-head{display:flex;flex-direction:column;gap:10px;padding-bottom:18px;border-bottom:1px solid var(--rule-strong);margin-bottom:30px}
334
+ h2{font-size:clamp(24px,3.4vw,31px);letter-spacing:-.012em}
335
+ h3{font-size:18px}
336
+ .prose{color:var(--ink-2)}
337
+ .tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(205px,1fr));gap:16px}
338
+ h3.sub{margin:32px 0 16px;color:var(--ink-2)}
339
+ .tile{background:var(--surface);border:1px solid var(--rule);border-radius:10px;padding:20px 22px 22px;display:flex;flex-direction:column;gap:6px;box-shadow:var(--shadow)}
340
+ .tile .label{font-family:"IBM Plex Mono",monospace;font-size:10.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--ink-3)}
341
+ .tile .value{font-family:"IBM Plex Mono",monospace;font-size:38px;font-weight:500;line-height:1.05;letter-spacing:-.02em;font-variant-numeric:tabular-nums}
342
+ .tile .note{font-size:13.5px;color:var(--ink-3);line-height:1.45}
343
+ .tile.is-alarm .value{color:var(--allowed)}
344
+ .findings{display:flex;flex-direction:column;gap:20px}
345
+ .finding{background:var(--surface);border:1px solid var(--rule);border-radius:10px;padding:24px 26px 26px;display:flex;flex-direction:column;gap:13px;position:relative;overflow:hidden;box-shadow:var(--shadow)}
346
+ .finding::before{content:"";position:absolute;inset:0 auto 0 0;width:3px;background:var(--stripe,var(--rule-strong))}
347
+ .finding[data-sev="critical"]{--stripe:var(--sev-critical)}
348
+ .finding[data-sev="high"]{--stripe:var(--sev-high)}
349
+ .finding[data-sev="medium"]{--stripe:var(--sev-medium)}
350
+ .finding-top{display:flex;flex-wrap:wrap;align-items:center;gap:10px}
351
+ .chip{display:inline-flex;align-items:center;gap:6px;font-family:"IBM Plex Mono",monospace;font-size:10.5px;font-weight:600;letter-spacing:.09em;text-transform:uppercase;padding:4px 9px;border-radius:4px;border:1px solid var(--rule-strong);color:var(--ink-2);white-space:nowrap}
352
+ .chip::before{content:"";width:7px;height:7px;border-radius:50%;background:var(--dot,var(--ink-3))}
353
+ .chip[data-sev="critical"]{--dot:var(--sev-critical)}
354
+ .chip[data-sev="high"]{--dot:var(--sev-high)}
355
+ .chip[data-sev="medium"]{--dot:var(--sev-medium)}
356
+ .chip.scenario{color:var(--ink-3)}
357
+ .chip.scenario::before{display:none}
358
+ .finding p{color:var(--ink-2)}
359
+ .evidence{background:var(--surface-sunk);border:1px solid var(--rule);border-radius:7px;padding:13px 15px;font-family:"IBM Plex Mono",monospace;font-size:13px;line-height:1.75;color:var(--ink-2);overflow-x:auto}
360
+ .evidence .k{color:var(--ink-3)}
361
+ .legend{display:flex;flex-wrap:wrap;gap:18px;margin-bottom:16px}
362
+ .legend span{display:inline-flex;align-items:center;gap:7px;font-size:13.5px;color:var(--ink-2)}
363
+ .legend span::before{content:"";width:11px;height:11px;border-radius:3px;background:var(--sw)}
364
+ .legend .l-blocked::before{--sw:var(--blocked)}
365
+ .legend .l-challenged::before{--sw:var(--challenged)}
366
+ .legend .l-allowed::before{--sw:var(--allowed)}
367
+ .table-scroll{overflow-x:auto;border:1px solid var(--rule);border-radius:10px;background:var(--surface)}
368
+ table{width:100%;border-collapse:collapse;min-width:740px}
369
+ thead th{text-align:left;font-family:"IBM Plex Mono",monospace;font-size:10.5px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--ink-3);padding:14px 16px;border-bottom:1px solid var(--rule-strong);white-space:nowrap}
370
+ thead th.r{text-align:right}
371
+ tbody td{padding:13px 16px;border-bottom:1px solid var(--rule);vertical-align:middle}
372
+ tbody tr:last-child td{border-bottom:none}
373
+ td.scenario-name{font-weight:500;white-space:nowrap}
374
+ td.n{font-family:"IBM Plex Mono",monospace;font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap}
375
+ .bar{display:flex;gap:2px;height:16px;min-width:130px}
376
+ .bar span{display:block;border-radius:1px}
377
+ .bar span:first-child{border-radius:4px 1px 1px 4px}
378
+ .bar span:last-child{border-radius:1px 4px 4px 1px}
379
+ .bar span:only-child{border-radius:4px}
380
+ .seg-blocked{background:var(--blocked)}
381
+ .seg-challenged{background:var(--challenged)}
382
+ .seg-allowed{background:var(--allowed)}
383
+ .outcome{display:inline-flex;align-items:center;gap:7px;font-size:13.5px;white-space:nowrap;color:var(--ink-2)}
384
+ .outcome::before{content:"";width:8px;height:8px;border-radius:50%;background:var(--dot);flex:none}
385
+ .outcome[data-o="critical"]{--dot:var(--allowed);color:var(--ink);font-weight:500}
386
+ .outcome[data-o="high"]{--dot:var(--sev-high);color:var(--ink);font-weight:500}
387
+ .outcome[data-o="caveat"]{--dot:var(--challenged)}
388
+ .outcome[data-o="unknown"]{--dot:var(--sev-unknown)}
389
+ .outcome[data-o="ok"]{--dot:var(--blocked)}
390
+ .footnote{margin-top:14px;font-size:13.5px;color:var(--ink-3);max-width:80ch}
391
+ .reasons{display:flex;flex-direction:column;gap:8px}
392
+ .reason{display:grid;grid-template-columns:minmax(180px,240px) 1fr 52px;gap:14px;align-items:center}
393
+ .reason .code{font-size:13px;color:var(--ink-2);overflow:hidden;text-overflow:ellipsis}
394
+ .reason .track{height:9px;background:var(--surface-sunk);border-radius:5px;overflow:hidden}
395
+ .reason .fill{display:block;height:100%;background:var(--accent);border-radius:5px}
396
+ .reason .count{font-size:13px;text-align:right;color:var(--ink-3)}
397
+ .limits{display:flex;flex-direction:column;gap:15px;max-width:84ch}
398
+ .limit{display:grid;grid-template-columns:22px 1fr;gap:14px;align-items:start;font-size:15px;color:var(--ink-2);line-height:1.55}
399
+ .limit .mark{font-family:"IBM Plex Mono",monospace;font-size:12px;color:var(--ink-3);padding-top:3px}
400
+ .limit strong{color:var(--ink);font-weight:600}
401
+ footer{margin-top:56px;padding-top:24px;border-top:1px solid var(--rule);font-size:13.5px;color:var(--ink-3);display:flex;flex-wrap:wrap;gap:8px 24px}
402
+ @page{size:A4;margin:14mm 12mm 16mm}
403
+ @media print{
404
+ :root{--ground:#fff;--surface:#fff;--surface-sunk:#f4f6f6;--rule:#d5dcdc;--rule-strong:#b3bebe;--ink:#0d1518;--ink-2:#35454a;--ink-3:#5b6a6f;--accent:#0d5057;--accent-soft:#eef4f4;--shadow:none}
405
+ body{background:#fff;font-size:10.5pt;line-height:1.5;padding:0}
406
+ .wrap{max-width:none}
407
+ .bar span,.tile.is-alarm .value,.chip::before,.outcome::before,.legend span::before,.finding::before,.banner,.evidence,.meta,.meta div,.tile,.finding,.closing,.table-scroll,thead th,.reason .track,.reason .fill{-webkit-print-color-adjust:exact;print-color-adjust:exact}
408
+ .finding,.tile,.limit,.banner,.evidence,tr,.reason{break-inside:avoid;page-break-inside:avoid}
409
+ h1,h2,h3,.sec-head{break-after:avoid;page-break-after:avoid}
410
+ header.masthead{padding-top:0}
411
+ h1{font-size:30pt}h2{font-size:16pt}h3{font-size:12pt}
412
+ .standfirst{font-size:11.5pt}
413
+ section{margin-top:24pt}
414
+ .tile .value{font-size:24pt}
415
+ .table-scroll{overflow:visible}
416
+ table{min-width:0}
417
+ thead{display:table-header-group}
418
+ tbody td,thead th{padding:6pt 8pt}
419
+ .bar{height:11px;min-width:80px}
420
+ a{text-decoration:none}
421
+ }
422
+ @media (prefers-reduced-motion:reduce){*{transition-duration:.01ms!important;animation-duration:.01ms!important}}
423
+ @media (max-width:640px){body{padding:0 16px 64px}header.masthead{padding-top:44px}.reason{grid-template-columns:1fr 44px}.reason .track{display:none}}
424
+ `;
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Report generation — `fraud-sim/reporting`.
3
+ *
4
+ * @example
5
+ * import { runSuite } from 'fraud-sim';
6
+ * import { generateReport } from 'fraud-sim/reporting';
7
+ * import { writeFileSync } from 'node:fs';
8
+ *
9
+ * const result = await runSuite({ name: 'nightly', target, scenarios, seed: 42 });
10
+ * writeFileSync('report.html', generateReport(result, { format: 'html' }));
11
+ */
12
+
13
+ import { formatMoney } from './money.js';
14
+ import { renderHtml } from './html.js';
15
+ import { renderJUnit } from './junit.js';
16
+ import {
17
+ normaliseInput,
18
+ totalsFor,
19
+ reasonFrequency,
20
+ latencyFor,
21
+ findingsFor,
22
+ outcomeFor,
23
+ isLegitimate,
24
+ pct,
25
+ } from './aggregate.js';
26
+
27
+ const FORMATS = ['html', 'junit', 'json', 'text'];
28
+
29
+ /**
30
+ * Render a run as a shareable report.
31
+ *
32
+ * Synchronous and pure — it returns a string and touches no filesystem, so the
33
+ * caller decides where output goes and nothing surprising happens on import.
34
+ *
35
+ * @param {object} result - A ScenarioReport, an array of them, or a SuiteReport.
36
+ * @param {object} [options]
37
+ * @param {'html'|'junit'|'json'|'text'} [options.format='html']
38
+ * @param {string} [options.title]
39
+ * @param {string} [options.targetName]
40
+ * @param {string} [options.version]
41
+ * @param {number} [options.failUnder] - Block rate below which a scenario is a
42
+ * CI failure. Used by the junit and text formats.
43
+ * @param {number} [options.maxFalsePositiveRate] - Share of legitimate
44
+ * customer events that may be blocked before the run fails. Used by the
45
+ * junit and text formats.
46
+ * @param {string} [options.currency='NGN'] - Display currency. Amounts are
47
+ * minor-unit integers, so this changes presentation only.
48
+ * @param {string} [options.locale] - Defaults to a locale for the currency.
49
+ * @returns {string}
50
+ */
51
+ export function generateReport(result, options = {}) {
52
+ const format = options.format ?? 'html';
53
+
54
+ if (!FORMATS.includes(format)) {
55
+ throw new Error(`Unknown report format: "${format}". Expected one of: ${FORMATS.join(', ')}.`);
56
+ }
57
+
58
+ if (format === 'html') return renderHtml(result, options);
59
+ if (format === 'junit') return renderJUnit(result, options);
60
+ if (format === 'json') return JSON.stringify(summarise(result, options), null, 2);
61
+ return renderText(result, options);
62
+ }
63
+
64
+ /**
65
+ * The structured summary behind every format. Useful on its own when you want
66
+ * to assert on a run in CI without parsing a rendered document.
67
+ */
68
+ export function summarise(result, options = {}) {
69
+ const { name, reports } = normaliseInput(result);
70
+
71
+ return {
72
+ name: options.title ?? name,
73
+ generatedAt: (options.now ?? new Date()).toISOString(),
74
+ totals: totalsFor(reports),
75
+ latency: latencyFor(reports),
76
+ findings: findingsFor(reports, options),
77
+ reasonCounts: Object.fromEntries(reasonFrequency(reports)),
78
+ scenarios: reports.map((r) => ({
79
+ scenarioId: r.scenarioId,
80
+ traffic: r.traffic ?? 'attack',
81
+ attempts: r.attempts,
82
+ scoredAttempts: r.scoredAttempts,
83
+ blocked: r.blocked,
84
+ stepUp: r.stepUp,
85
+ allowed: r.allowed,
86
+ errors: r.errors,
87
+ blockRate: r.blockRate,
88
+ errorRate: r.errorRate,
89
+ seed: r.seed ?? null,
90
+ latency: r.latency ?? null,
91
+ simulatedTime: r.simulatedTime ?? false,
92
+ attackerSucceeded: r.attackerSucceeded ?? null,
93
+ estimatedLossKobo: r.estimatedLossKobo ?? null,
94
+ ...(isLegitimate(r)
95
+ ? {
96
+ falsePositiveRate: r.falsePositiveRate ?? r.blockRate,
97
+ frictionRate: r.frictionRate ?? null,
98
+ customers: r.customers ?? null,
99
+ customersBlocked: r.customersBlocked ?? null,
100
+ valueBlockedMinor: r.valueBlockedMinor ?? 0,
101
+ byStage: r.byStage ?? null,
102
+ }
103
+ : {}),
104
+ outcome: outcomeFor(r),
105
+ })),
106
+ };
107
+ }
108
+
109
+ /** A terminal summary — what the CLI prints when no report file is asked for. */
110
+ function renderText(result, options = {}) {
111
+ const { reports: all } = normaliseInput(result);
112
+ const reports = all.filter((r) => !isLegitimate(r));
113
+ const customers = all.filter(isLegitimate);
114
+ const totals = totalsFor(all);
115
+ const findings = findingsFor(all, options);
116
+
117
+ const pad = (s, n) => String(s).padEnd(n);
118
+ const rpad = (s, n) => String(s).padStart(n);
119
+ const width = Math.max(20, ...all.map((r) => String(r.scenarioId).length));
120
+
121
+ const lines = [];
122
+ lines.push('');
123
+ lines.push(
124
+ ` ${pad('SCENARIO', width)} ${rpad('EVENTS', 6)} ${rpad('BLOCK', 6)} ${rpad('CHALL', 6)} ${rpad('ALLOW', 6)} ${rpad('RATE', 7)} OUTCOME`
125
+ );
126
+ lines.push(` ${'-'.repeat(width + 48)}`);
127
+
128
+ for (const r of reports) {
129
+ const o = outcomeFor(r);
130
+ lines.push(
131
+ ` ${pad(r.scenarioId, width)} ${rpad(r.attempts, 6)} ${rpad(r.blocked, 6)} ` +
132
+ `${rpad(r.stepUp, 6)} ${rpad(r.allowed, 6)} ${rpad((r.blockRate * 100).toFixed(1) + '%', 7)} ${o.label}`
133
+ );
134
+ }
135
+
136
+ lines.push('');
137
+ lines.push(
138
+ ` TOTAL ${totals.attempts} events · ${totals.scoredAttempts} scored · ` +
139
+ `${(totals.blockRate * 100).toFixed(1)}% blocked · ${totals.errors} errors`
140
+ );
141
+
142
+ if (totals.estimatedLossMinor > 0) {
143
+ lines.push(` LOSS ${formatMoney(totals.estimatedLossMinor, options)} extracted`);
144
+ }
145
+
146
+ // The other half of the measurement, in its own table: here a block is the
147
+ // bad outcome, so it must not sit in a column read as "higher is better".
148
+ if (customers.length) {
149
+ const legit = totals.legitimate;
150
+ lines.push('');
151
+ lines.push(
152
+ ` ${pad('LEGITIMATE TRAFFIC', width)} ${rpad('EVENTS', 6)} ${rpad('BLOCK', 6)} ${rpad('CHALL', 6)} ${rpad('ALLOW', 6)} ${rpad('FALSE+', 7)} OUTCOME`
153
+ );
154
+ lines.push(` ${'-'.repeat(width + 48)}`);
155
+ for (const r of customers) {
156
+ lines.push(
157
+ ` ${pad(r.scenarioId, width)} ${rpad(r.attempts, 6)} ${rpad(r.blocked, 6)} ` +
158
+ `${rpad(r.stepUp, 6)} ${rpad(r.allowed, 6)} ${rpad(pct(r.falsePositiveRate ?? r.blockRate), 7)} ${outcomeFor(r).label}`
159
+ );
160
+ }
161
+ lines.push('');
162
+ lines.push(
163
+ ` CUSTOMERS ${legit.scoredAttempts} events · ${pct(legit.falsePositiveRate)} blocked (false positives) · ` +
164
+ `${pct(legit.frictionRate)} challenged or blocked · ${legit.customersBlocked} of ${legit.customers} customers refused`
165
+ );
166
+ if (legit.valueBlockedMinor > 0) {
167
+ lines.push(` REFUSED ${formatMoney(legit.valueBlockedMinor, options)} of customers' own payments`);
168
+ }
169
+ }
170
+
171
+ if (findings.length) {
172
+ lines.push('');
173
+ lines.push(` FINDINGS (${findings.length})`);
174
+ for (const f of findings) {
175
+ lines.push(` [${f.severity.toUpperCase()}] ${f.scenario} — ${f.title}`);
176
+ }
177
+ }
178
+
179
+ if (Number.isFinite(options.maxFalsePositiveRate)) {
180
+ lines.push('');
181
+ const legit = totals.legitimate;
182
+ const ceiling = pct(options.maxFalsePositiveRate);
183
+ if (!legit) {
184
+ lines.push(' GATE FP n/a — no legitimate traffic in this run (add legitimate-users)');
185
+ } else if (legit.errorRate > 0.05) {
186
+ lines.push(' GATE FP n/a — too many customer events went unscored to judge');
187
+ } else {
188
+ lines.push(
189
+ legit.falsePositiveRate > options.maxFalsePositiveRate
190
+ ? ` GATE FAIL — false-positive rate ${pct(legit.falsePositiveRate)} is above the ${ceiling} ceiling`
191
+ : ` GATE PASS — false-positive rate ${pct(legit.falsePositiveRate)} is within the ${ceiling} ceiling`
192
+ );
193
+ }
194
+ }
195
+
196
+ if (Number.isFinite(options.failUnder)) {
197
+ lines.push('');
198
+ // A gate verdict on an unmeasurable run is noise at best and misleading at
199
+ // worst — the block rate is low because nothing was scored, not because the
200
+ // defence is weak.
201
+ if (totals.errorRate > 0.05) {
202
+ lines.push(' GATE n/a — too many attempts went unscored to judge the defence');
203
+ lines.push('');
204
+ return lines.join('\n');
205
+ }
206
+
207
+ const floor = `${(options.failUnder * 100).toFixed(0)}%`;
208
+
209
+ if (options.failUnderScope === 'scenario') {
210
+ const failing = reports.filter((r) => r.blockRate < options.failUnder);
211
+ lines.push(
212
+ failing.length
213
+ ? ` GATE FAIL — ${failing.length} scenario(s) below ${floor}: ${failing.map((r) => r.scenarioId).join(', ')}`
214
+ : ` GATE PASS — every scenario at or above ${floor}`
215
+ );
216
+ } else {
217
+ const rate = `${(totals.blockRate * 100).toFixed(1)}%`;
218
+ lines.push(
219
+ totals.blockRate < options.failUnder
220
+ ? ` GATE FAIL — overall block rate ${rate} is below ${floor}`
221
+ : ` GATE PASS — overall block rate ${rate} meets the ${floor} floor`
222
+ );
223
+ }
224
+ }
225
+
226
+ lines.push('');
227
+ return lines.join('\n');
228
+ }
229
+
230
+ export { renderHtml, renderJUnit };
231
+ export { formatMoney, currencySymbol } from './money.js';
232
+ export {
233
+ normaliseInput,
234
+ totalsFor,
235
+ legitimateTotalsFor,
236
+ isLegitimate,
237
+ reasonFrequency,
238
+ latencyFor,
239
+ findingsFor,
240
+ outcomeFor,
241
+ } from './aggregate.js';