fraud-sim 0.2.0 → 0.5.1

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 +480 -0
  2. package/README.md +639 -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 +43 -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 +246 -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,338 @@
1
+ import { formatMoney } from './money.js';
2
+
3
+ /**
4
+ * Turning raw run data into the things a report needs to say.
5
+ *
6
+ * Kept separate from rendering so every output format — HTML, JUnit, JSON —
7
+ * agrees on the numbers. A format that computed its own totals would eventually
8
+ * disagree with another one, and a report that contradicts itself is worse than
9
+ * no report.
10
+ */
11
+
12
+ /** Accepts a single ScenarioReport, an array of them, or a SuiteReport. */
13
+ export function normaliseInput(result) {
14
+ if (Array.isArray(result)) return { name: 'fraud-sim run', reports: result };
15
+ if (result?.scenarioReports) {
16
+ return { name: result.suiteName ?? 'fraud-sim suite', reports: result.scenarioReports };
17
+ }
18
+ if (result?.scenarioId) return { name: result.scenarioId, reports: [result] };
19
+ throw new Error(
20
+ 'generateReport expects a scenario report, an array of them, or the result of runSuite().'
21
+ );
22
+ }
23
+
24
+ /** True for traffic a defence should let through — see legitimate-users. */
25
+ export function isLegitimate(report) {
26
+ return report?.traffic === 'legitimate';
27
+ }
28
+
29
+ /**
30
+ * Totals across every attack scenario, using the same denominator rule as a
31
+ * single run: rates are over attempts that actually received a decision.
32
+ *
33
+ * Legitimate traffic is kept out of these figures and summarised under
34
+ * `legitimate` instead. A blocked customer added to the block rate would make
35
+ * a defence look better the more good customers it refused.
36
+ */
37
+ export function totalsFor(reports) {
38
+ const attacks = reports.filter((r) => !isLegitimate(r));
39
+ const t = { attempts: 0, blocked: 0, stepUp: 0, allowed: 0, errors: 0 };
40
+ for (const r of attacks) {
41
+ t.attempts += r.attempts ?? 0;
42
+ t.blocked += r.blocked ?? 0;
43
+ t.stepUp += r.stepUp ?? 0;
44
+ t.allowed += r.allowed ?? 0;
45
+ t.errors += r.errors ?? 0;
46
+ }
47
+
48
+ const scoredAttempts = Math.max(0, t.attempts - t.errors);
49
+
50
+ return {
51
+ ...t,
52
+ scoredAttempts,
53
+ blockRate: scoredAttempts > 0 ? t.blocked / scoredAttempts : 0,
54
+ stepUpRate: scoredAttempts > 0 ? t.stepUp / scoredAttempts : 0,
55
+ allowRate: scoredAttempts > 0 ? t.allowed / scoredAttempts : 0,
56
+ errorRate: t.attempts > 0 ? t.errors / t.attempts : 0,
57
+ scenarios: attacks.length,
58
+ attacksSucceeded: attacks.filter((r) => r.attackerSucceeded === true).length,
59
+ estimatedLossMinor: attacks.reduce((a, r) => a + lossOf(r), 0),
60
+ // Retained for callers written against the naira-specific name.
61
+ estimatedLossKobo: attacks.reduce((a, r) => a + lossOf(r), 0),
62
+ legitimate: legitimateTotalsFor(reports),
63
+ };
64
+ }
65
+
66
+ /**
67
+ * What the defence cost ordinary customers, or `null` when the run included
68
+ * no legitimate traffic — absent is different from zero, and a report should
69
+ * never claim "no false positives" for a run that could not have seen any.
70
+ */
71
+ export function legitimateTotalsFor(reports) {
72
+ const legit = reports.filter(isLegitimate);
73
+ if (legit.length === 0) return null;
74
+
75
+ const t = { attempts: 0, blocked: 0, stepUp: 0, allowed: 0, errors: 0 };
76
+ let customers = 0;
77
+ let customersBlocked = 0;
78
+ let valueBlockedMinor = 0;
79
+ for (const r of legit) {
80
+ t.attempts += r.attempts ?? 0;
81
+ t.blocked += r.blocked ?? 0;
82
+ t.stepUp += r.stepUp ?? 0;
83
+ t.allowed += r.allowed ?? 0;
84
+ t.errors += r.errors ?? 0;
85
+ customers += r.customers ?? 0;
86
+ customersBlocked += r.customersBlocked ?? 0;
87
+ valueBlockedMinor += r.valueBlockedMinor ?? 0;
88
+ }
89
+
90
+ const scoredAttempts = Math.max(0, t.attempts - t.errors);
91
+
92
+ return {
93
+ ...t,
94
+ scoredAttempts,
95
+ falsePositiveRate: scoredAttempts > 0 ? t.blocked / scoredAttempts : 0,
96
+ frictionRate: scoredAttempts > 0 ? (t.blocked + t.stepUp) / scoredAttempts : 0,
97
+ errorRate: t.attempts > 0 ? t.errors / t.attempts : 0,
98
+ customers,
99
+ customersBlocked,
100
+ valueBlockedMinor,
101
+ };
102
+ }
103
+
104
+ /** Reason codes across the whole run, most frequent first. */
105
+ export function reasonFrequency(reports) {
106
+ const counts = new Map();
107
+ for (const r of reports) {
108
+ for (const [code, n] of Object.entries(r.reasonCounts ?? {})) {
109
+ counts.set(code, (counts.get(code) ?? 0) + n);
110
+ }
111
+ }
112
+ return [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
113
+ }
114
+
115
+ /** Latency across the whole run — merged samples, not an average of averages. */
116
+ export function latencyFor(reports) {
117
+ const present = reports.map((r) => r.latency).filter((l) => l && l.count > 0);
118
+ if (present.length === 0) return { count: 0, p50: null, p95: null, p99: null, max: null };
119
+
120
+ // Per-scenario percentiles cannot be re-averaged into a suite percentile, so
121
+ // report the range honestly: worst observed p95, and the true max.
122
+ return {
123
+ count: present.reduce((a, l) => a + l.count, 0),
124
+ p50: Math.max(...present.map((l) => l.p50)),
125
+ p95: Math.max(...present.map((l) => l.p95)),
126
+ p99: Math.max(...present.map((l) => l.p99)),
127
+ max: Math.max(...present.map((l) => l.max)),
128
+ approximate: present.length > 1,
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Classify what a scenario's run actually means, which is not the same as its
134
+ * block rate. Order matters: reaching money outranks everything, and a result
135
+ * we cannot trust must never be reported as a pass.
136
+ */
137
+ /**
138
+ * A scenario's extracted value, in the currency's minor unit.
139
+ *
140
+ * Amounts are minor-unit integers throughout — kobo, cents, the same thing
141
+ * structurally — so nothing here is currency-specific. `estimatedLossKobo`
142
+ * is the original naira-flavoured name, kept working.
143
+ */
144
+ export function lossOf(report) {
145
+ return report?.estimatedLossMinor ?? report?.estimatedLossKobo ?? 0;
146
+ }
147
+
148
+ export function outcomeFor(report) {
149
+ // Legitimate traffic reads the other way round: a block is the failure.
150
+ if (isLegitimate(report)) {
151
+ if (report.errorRate > 0.05) {
152
+ return { kind: 'unreliable', label: 'Run unreliable', severity: 'unknown' };
153
+ }
154
+ if (report.blocked > 0) {
155
+ return { kind: 'false-positives', label: 'Customers blocked', severity: 'high' };
156
+ }
157
+ if (report.stepUp > 0) {
158
+ return { kind: 'friction', label: 'Challenged, none blocked', severity: 'ok' };
159
+ }
160
+ return { kind: 'clean', label: 'No friction', severity: 'ok' };
161
+ }
162
+
163
+ if (lossOf(report) > 0) {
164
+ return { kind: 'loss', label: 'Value extracted', severity: 'critical' };
165
+ }
166
+ if (report.attackerSucceeded === true) {
167
+ return { kind: 'succeeded', label: 'Attack succeeded', severity: 'critical' };
168
+ }
169
+ if (report.errorRate > 0.05) {
170
+ return { kind: 'unreliable', label: 'Run unreliable', severity: 'unknown' };
171
+ }
172
+ // A scenario where nothing was refused is a finding whatever else is true of
173
+ // it — reporting that as merely "timing-dependent" buries the headline.
174
+ if (report.blocked === 0 && report.attempts > 0) {
175
+ return { kind: 'through', label: 'Never blocked', severity: 'high' };
176
+ }
177
+ if (report.simulatedTime) {
178
+ return { kind: 'caveat', label: 'Timing-dependent', severity: 'caveat' };
179
+ }
180
+ return { kind: 'held', label: 'Contained', severity: 'ok' };
181
+ }
182
+
183
+ /**
184
+ * The findings list, worst first.
185
+ *
186
+ * Deliberately conservative: it reports what the data supports and does not
187
+ * guess at causes. A generated finding that speculates is a finding a reader
188
+ * will eventually catch being wrong.
189
+ */
190
+ export function findingsFor(reports, options = {}) {
191
+ const money = (minor) => formatMoney(minor, options);
192
+ const findings = [];
193
+
194
+ for (const r of reports) {
195
+ const outcome = outcomeFor(r);
196
+
197
+ if (outcome.kind === 'false-positives') {
198
+ const stats = [
199
+ ['customer events', r.scoredAttempts],
200
+ ['blocked', r.blocked],
201
+ ['false-positive rate', pct(r.falsePositiveRate ?? r.blockRate)],
202
+ ['customers refused', `${r.customersBlocked ?? '—'} of ${r.customers ?? '—'}`],
203
+ ];
204
+ if (r.valueBlockedMinor > 0) stats.push(['payments refused', money(r.valueBlockedMinor)]);
205
+ for (const [stage, row] of Object.entries(r.byStage ?? {})) {
206
+ if (row.blocked > 0) stats.push([`blocked on ${stage}`, `${row.blocked} of ${row.events}`]);
207
+ }
208
+ const reasons = Object.keys(r.falsePositiveReasons ?? {}).slice(0, 3);
209
+ if (reasons.length) stats.push(['top reasons', reasons.join(', ')]);
210
+
211
+ // Customer sessions are days apart on the event clock but arrive within
212
+ // seconds. A windowed rule that counts arrivals sees a burst, so a
213
+ // velocity block here may be the compression, not the customer.
214
+ const windowed = Object.keys(r.falsePositiveReasons ?? {}).filter((code) =>
215
+ /VELOCITY|RATE|FREQUENCY|BURST/i.test(code)
216
+ );
217
+ const caveat =
218
+ r.simulatedTime && windowed.length
219
+ ? ` Caveat: ${windowed.join(', ')} count events in a time window, and this traffic ` +
220
+ `spans ${r.simulatedSpan ?? 'days'} of event time delivered in seconds. If the target ` +
221
+ 'windows on arrival time rather than the event timestamp, those blocks are the ' +
222
+ "simulator's compression, not customer behaviour."
223
+ : '';
224
+ if (caveat) stats.push(['simulated span', r.simulatedSpan ?? '—']);
225
+
226
+ findings.push({
227
+ severity: 'high',
228
+ scenario: r.scenarioId,
229
+ title: 'Legitimate customers were blocked',
230
+ detail:
231
+ `${r.blocked} of ${r.scoredAttempts} ordinary customer events were refused ` +
232
+ `(${pct(r.falsePositiveRate ?? r.blockRate)}). Every one is a real customer locked ` +
233
+ 'out of their own account — the cost a block rate never shows.' +
234
+ caveat,
235
+ stats,
236
+ });
237
+ continue;
238
+ }
239
+
240
+ if (outcome.kind === 'loss') {
241
+ findings.push({
242
+ severity: 'critical',
243
+ scenario: r.scenarioId,
244
+ title: 'The attack reached money',
245
+ detail:
246
+ `${money(lossOf(r))} was extracted despite a ` +
247
+ `${pct(r.blockRate)} block rate. A block rate averages over every event ` +
248
+ 'an attacker generates, and attackers generate many cheap events on the ' +
249
+ 'way to a few expensive ones.',
250
+ stats: lossStats(r, money),
251
+ });
252
+ } else if (outcome.kind === 'succeeded') {
253
+ findings.push({
254
+ severity: 'critical',
255
+ scenario: r.scenarioId,
256
+ title: 'The attack completed',
257
+ detail: 'The scenario reached its objective without being stopped.',
258
+ stats: lossStats(r, money),
259
+ });
260
+ } else if (outcome.kind === 'through') {
261
+ findings.push({
262
+ severity: 'high',
263
+ scenario: r.scenarioId,
264
+ title: 'Nothing was ever blocked',
265
+ detail:
266
+ `${r.attempts} event${r.attempts === 1 ? '' : 's'} scored, none refused` +
267
+ (r.stepUp > 0 ? ` — ${r.stepUp} challenged, the rest allowed.` : '.'),
268
+ stats: [
269
+ ['events', r.attempts],
270
+ ['blocked', 0],
271
+ ['challenged', r.stepUp],
272
+ ['allowed', r.allowed],
273
+ ],
274
+ });
275
+ } else if (outcome.kind === 'unreliable') {
276
+ findings.push({
277
+ severity: 'high',
278
+ scenario: r.scenarioId,
279
+ title: 'This run did not measure the defence',
280
+ detail:
281
+ `${pct(r.errorRate)} of attempts never received a decision. A target that ` +
282
+ 'is rate limiting or failing is not a target that is defending; treat this ' +
283
+ 'scenario as not run.',
284
+ stats: [
285
+ ['attempts', r.attempts],
286
+ ['scored', r.scoredAttempts],
287
+ ['errors', r.errors],
288
+ ],
289
+ });
290
+ } else if (outcome.kind === 'caveat') {
291
+ findings.push({
292
+ severity: 'medium',
293
+ scenario: r.scenarioId,
294
+ title: 'Result depends on which clock the target uses',
295
+ detail:
296
+ 'This scenario spaces its events by advancing the event timestamp rather ' +
297
+ 'than sleeping. If the target derives its velocity windows from server ' +
298
+ 'arrival time, it saw a burst and this figure says nothing about the ' +
299
+ 'attack the scenario describes.',
300
+ stats: [
301
+ ['simulated span', r.simulatedSpan ?? '—'],
302
+ ['block rate', pct(r.blockRate)],
303
+ ],
304
+ });
305
+ }
306
+ }
307
+
308
+ const rank = { critical: 0, high: 1, medium: 2 };
309
+ return findings.sort((a, b) => rank[a.severity] - rank[b.severity]);
310
+ }
311
+
312
+ function lossStats(r, money) {
313
+ const stats = [
314
+ ['events', r.attempts],
315
+ ['block rate', pct(r.blockRate)],
316
+ ];
317
+ if (lossOf(r)) stats.push(['extracted', money(lossOf(r))]);
318
+ if (r.cardsValidated != null) stats.push(['cards validated', r.cardsValidated]);
319
+ if (r.escalationsAllowed != null) stats.push(['charges allowed', r.escalationsAllowed]);
320
+ if (r.bonusesClaimed != null) stats.push(['bonuses paid', r.bonusesClaimed]);
321
+ if (r.firstBlockedAtAccount != null) stats.push(['first block at account', r.firstBlockedAtAccount]);
322
+ if (r.blockedAtStage != null) stats.push(['first pushback', r.blockedAtStage]);
323
+ return stats;
324
+ }
325
+
326
+ export function pct(n) {
327
+ if (!Number.isFinite(n)) return '—';
328
+ return `${(n * 100).toFixed(1)}%`;
329
+ }
330
+
331
+ /** @deprecated Use `formatMoney` from ./money.js — kept for compatibility. */
332
+ export function fmtNaira(kobo) {
333
+ return formatMoney(kobo, { currency: 'NGN' });
334
+ }
335
+
336
+ export function fmtMs(ms) {
337
+ return Number.isFinite(ms) ? `${ms}ms` : '—';
338
+ }