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,625 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * fraud-sim command line interface.
4
+ *
5
+ * Exists so a team can gate CI on their fraud defence without writing any
6
+ * JavaScript, and so an assessment is one command rather than a script someone
7
+ * has to maintain.
8
+ *
9
+ * Exit codes are the contract with CI:
10
+ * 0 ran, and the gate passed (or no gate was set)
11
+ * 1 ran, and the gate failed — an attack succeeded, a block rate was
12
+ * below --fail-under, or too many legitimate customers were blocked
13
+ * 2 usage error — bad flags, unknown scenario
14
+ * 3 the run itself failed, or the target was too unreliable to measure
15
+ *
16
+ * The 1/3 split matters: a weak defence and an unreachable target are different
17
+ * problems, and a pipeline should not report them the same way.
18
+ */
19
+
20
+ import { writeFileSync } from 'node:fs';
21
+ import { resolve, extname } from 'node:path';
22
+
23
+ import { run, runSuite, listScenarios, resolveLocale, localeIds } from '../src/index.js';
24
+ import { httpAdapter } from '../src/adapters/http.js';
25
+ import { generateReport, summarise } from '../src/reporting/index.js';
26
+
27
+ const VERSION = '0.5.0';
28
+
29
+ const FORMAT_BY_EXT = {
30
+ '.html': 'html',
31
+ '.htm': 'html',
32
+ '.xml': 'junit',
33
+ '.json': 'json',
34
+ '.txt': 'text',
35
+ };
36
+
37
+ async function main() {
38
+ const argv = process.argv.slice(2);
39
+
40
+ if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h' || argv[0] === 'help') {
41
+ process.stdout.write(HELP);
42
+ return;
43
+ }
44
+
45
+ if (argv[0] === '--version' || argv[0] === '-v') {
46
+ process.stdout.write(`${VERSION}\n`);
47
+ return;
48
+ }
49
+
50
+ const command = argv[0];
51
+ const flags = parseFlags(argv.slice(1));
52
+
53
+ if (command === 'list') return cmdList(flags);
54
+ if (command === 'run') return cmdRun(flags);
55
+ if (command === 'suite') return cmdSuite(flags);
56
+
57
+ throw usage(`Unknown command "${command}". Expected: list, run, suite.`);
58
+ }
59
+
60
+ // ── commands ────────────────────────────────────────────────────────────
61
+
62
+ function cmdList(flags) {
63
+ const scenarios = listScenarios();
64
+
65
+ if (flags.json) {
66
+ process.stdout.write(JSON.stringify(scenarios, null, 2) + '\n');
67
+ return;
68
+ }
69
+
70
+ const width = Math.max(...scenarios.map((s) => s.id.length));
71
+ process.stdout.write(`\n ${scenarios.length} scenarios\n\n`);
72
+ for (const s of scenarios) {
73
+ process.stdout.write(` ${s.id.padEnd(width)} ${s.description}\n`);
74
+ }
75
+ process.stdout.write(
76
+ `\n Options for a scenario: fraud-sim list --json\n` +
77
+ ` Run one: fraud-sim run <id> --target-url <url>\n\n`
78
+ );
79
+ }
80
+
81
+ async function cmdRun(flags) {
82
+ const scenarioId = flags._[0];
83
+ if (!scenarioId) throw usage('run needs a scenario id. See: fraud-sim list');
84
+
85
+ const known = listScenarios().map((s) => s.id);
86
+ if (!known.includes(scenarioId)) {
87
+ throw usage(`Unknown scenario "${scenarioId}". Available: ${known.join(', ')}`);
88
+ }
89
+
90
+ resolveMarket(flags); // fail on a bad country code before sending anything
91
+ assertFalsePositiveGateMeasurable([scenarioId], flags);
92
+ const target = buildTarget(flags);
93
+ const report = await run(scenarioId, {
94
+ target,
95
+ options: optionsFor(scenarioId, flags),
96
+ ...runConfig(flags),
97
+ onEvent: progressReporter(flags),
98
+ });
99
+
100
+ return finish([report], flags, scenarioId);
101
+ }
102
+
103
+ async function cmdSuite(flags) {
104
+ const known = listScenarios().map((s) => s.id);
105
+
106
+ let ids;
107
+ if (flags.all) {
108
+ ids = known;
109
+ } else if (flags.scenarios) {
110
+ ids = String(flags.scenarios)
111
+ .split(',')
112
+ .map((s) => s.trim())
113
+ .filter(Boolean);
114
+ const unknown = ids.filter((id) => !known.includes(id));
115
+ if (unknown.length) throw usage(`Unknown scenario(s): ${unknown.join(', ')}`);
116
+ } else {
117
+ throw usage('suite needs --all or --scenarios a,b,c');
118
+ }
119
+
120
+ resolveMarket(flags); // fail on a bad country code before sending anything
121
+ assertFalsePositiveGateMeasurable(ids, flags);
122
+ const target = buildTarget(flags);
123
+ const result = await runSuite({
124
+ name: flags.name ?? 'fraud-sim suite',
125
+ target,
126
+ scenarios: ids.map((id) => ({ id, options: optionsFor(id, flags) })),
127
+ concurrency: flags.concurrency ?? 1,
128
+ ...runConfig(flags),
129
+ onEvent: progressReporter(flags),
130
+ });
131
+
132
+ return finish(result.scenarioReports, flags, result.suiteName);
133
+ }
134
+
135
+ // ── shared ──────────────────────────────────────────────────────────────
136
+
137
+ /**
138
+ * Identifiers a scenario requires but cannot invent for itself. Declared as
139
+ * `null` in a scenario's defaultOptions, meaning "you must supply this".
140
+ *
141
+ * The CLI fills them so `suite --all` works without the caller hand-writing an
142
+ * option set per scenario. Each scenario gets its own slice of the account
143
+ * pool, because scenarios sharing accounts contaminate each other's velocity
144
+ * counters — the same isolation problem as running them concurrently.
145
+ */
146
+ const IDENTITY_OPTIONS = {
147
+ targetUserIds: (users) => users,
148
+ targetUserId: (users) => users[0],
149
+ sourceUserId: (users) => users[0],
150
+ };
151
+
152
+ /**
153
+ * Build the option set for one scenario.
154
+ *
155
+ * Precedence, lowest first:
156
+ * 1. synthesised identifiers
157
+ * 2. global `--option key=value`, applied only where the scenario declares
158
+ * that key — otherwise a global `--option delayMs=5` would be rejected by
159
+ * every scenario that has no delayMs, which is the opposite of useful
160
+ * 3. scoped `--option <scenario>.<key>=<value>`, which always wins
161
+ */
162
+ function optionsFor(scenarioId, flags) {
163
+ const meta = listScenarios().find((s) => s.id === scenarioId);
164
+ const declared = meta?.defaultOptions ?? {};
165
+ const options = {};
166
+
167
+ const users = usersFor(scenarioId, flags);
168
+ for (const [key, build] of Object.entries(IDENTITY_OPTIONS)) {
169
+ if (key in declared && declared[key] === null) options[key] = build(users);
170
+ }
171
+
172
+ // `targetUserIds` supersedes `targetUserId` where a scenario accepts both.
173
+ if ('targetUserIds' in options && 'targetUserId' in options) delete options.targetUserId;
174
+
175
+ for (const [key, value] of Object.entries(flags.option)) {
176
+ if (key in declared) options[key] = value;
177
+ }
178
+
179
+ for (const [key, value] of Object.entries(flags.scopedOption[scenarioId] ?? {})) {
180
+ options[key] = value;
181
+ }
182
+
183
+ return options;
184
+ }
185
+
186
+ /** A distinct block of synthetic accounts per scenario. */
187
+ function usersFor(scenarioId, flags) {
188
+ const count = flags.users ?? 12;
189
+ const prefix = flags['user-prefix'] ?? 'sim_user';
190
+ // A stable per-scenario offset keeps runs reproducible while keeping the
191
+ // account ranges disjoint.
192
+ const offset = [...scenarioId].reduce((a, c) => a + c.charCodeAt(0), 0) % 900;
193
+ return Array.from({ length: count }, (_, i) => `${prefix}_${1000 + offset * 100 + i}`);
194
+ }
195
+
196
+
197
+ function runConfig(flags) {
198
+ const config = {};
199
+ if (flags.seed != null) config.seed = flags.seed;
200
+ if (flags.rps != null) config.maxRequestsPerSecond = flags.rps === 0 ? Infinity : flags.rps;
201
+ if (flags.locale != null) config.locale = flags.locale;
202
+ return config;
203
+ }
204
+
205
+ /**
206
+ * The market to simulate, validated before anything is sent.
207
+ *
208
+ * Checking up front matters: an unknown country code would otherwise surface as
209
+ * a wall of validation errors from the target, which reads as "your fraud engine
210
+ * is broken" rather than "you typed the wrong code".
211
+ */
212
+ function resolveMarket(flags) {
213
+ try {
214
+ return resolveLocale(flags.locale);
215
+ } catch (err) {
216
+ throw usage(err.message);
217
+ }
218
+ }
219
+
220
+ /**
221
+ * A false-positive gate over a run with no legitimate traffic would pass by
222
+ * default — zero customers, zero refused. Refuse it before anything is sent.
223
+ */
224
+ function assertFalsePositiveGateMeasurable(ids, flags) {
225
+ if (!Number.isFinite(flags['max-false-positives'])) return;
226
+ const legitimate = listScenarios()
227
+ .filter((s) => s.traffic === 'legitimate')
228
+ .map((s) => s.id);
229
+ if (!ids.some((id) => legitimate.includes(id))) {
230
+ throw usage(
231
+ '--max-false-positives needs legitimate traffic in the run. ' +
232
+ `Use suite --all, or include ${legitimate.join(', ')} in --scenarios.`
233
+ );
234
+ }
235
+ }
236
+
237
+ function buildTarget(flags) {
238
+ if (!flags['target-url']) {
239
+ throw usage('--target-url is required (e.g. --target-url https://api.example.com)');
240
+ }
241
+
242
+ const endpoints = {};
243
+ if (flags['endpoint-login']) endpoints.scoreLogin = endpointPath(flags['endpoint-login']);
244
+ if (flags['endpoint-withdrawal']) endpoints.scoreWithdrawal = endpointPath(flags['endpoint-withdrawal']);
245
+ if (flags['endpoint-action']) endpoints.scoreAction = endpointPath(flags['endpoint-action']);
246
+
247
+ return httpAdapter({
248
+ baseUrl: flags['target-url'],
249
+ headers: flags.header,
250
+ endpoints: Object.keys(endpoints).length ? endpoints : undefined,
251
+ timeoutMs: flags.timeout ?? 5000,
252
+ blockOnStatus: flags['block-on-status'] ?? [],
253
+ });
254
+ }
255
+
256
+ /**
257
+ * Validate an endpoint path, catching one specific way it arrives wrong.
258
+ *
259
+ * Git Bash and other MSYS shells on Windows rewrite arguments that look like
260
+ * absolute Unix paths into Windows paths, so `--endpoint-login /score/login`
261
+ * silently becomes `C:/Program Files/Git/score/login`. Every request then 404s
262
+ * and the run reports that the *target* is unreliable — sending the user to
263
+ * debug a system that was never contacted. Fail loudly with the actual fix
264
+ * instead.
265
+ */
266
+ function endpointPath(value) {
267
+ if (/^[A-Za-z]:[\\/]/.test(value)) {
268
+ throw usage(
269
+ `--endpoint path "${value}" was rewritten into a filesystem path by your shell.\n` +
270
+ ' This is MSYS/Git Bash path conversion on Windows. Either prefix the command:\n' +
271
+ ' MSYS_NO_PATHCONV=1 fraud-sim ...\n' +
272
+ ' or pass the path with a leading double slash: --endpoint-login //score/login'
273
+ );
274
+ }
275
+
276
+ // Accept `score/login` as well as `/score/login`; the difference never matters
277
+ // to the caller and getting it wrong costs a confusing 404.
278
+ const path = value.startsWith('//') ? value.slice(1) : value;
279
+ return path.startsWith('/') ? path : `/${path}`;
280
+ }
281
+
282
+ /** Live progress on a TTY; silent in a pipeline unless something is wrong. */
283
+ function progressReporter(flags) {
284
+ if (flags.quiet) return undefined;
285
+
286
+ const interactive = process.stdout.isTTY;
287
+ let scored = 0;
288
+ const seenErrors = new Set();
289
+
290
+ return (event) => {
291
+ if (event.type === 'response') {
292
+ scored++;
293
+ if (interactive && scored % 10 === 0) {
294
+ process.stdout.write(`\r scoring… ${scored} events`);
295
+ }
296
+ } else if (event.type === 'warning') {
297
+ if (interactive) process.stdout.write('\r');
298
+ process.stderr.write(` ! ${event.message}\n`);
299
+ } else if (event.type === 'error') {
300
+ // Report *why* attempts are failing, not just how many. Deduplicated:
301
+ // a broken target produces the same message hundreds of times, and the
302
+ // useful information is the set of distinct causes.
303
+ const message = String(event.message ?? 'unknown error');
304
+ if (!seenErrors.has(message) && seenErrors.size < 5) {
305
+ seenErrors.add(message);
306
+ if (interactive) process.stdout.write('\r');
307
+ process.stderr.write(` x ${message}\n`);
308
+ }
309
+ }
310
+ };
311
+ }
312
+
313
+ function finish(reports, flags, name) {
314
+ const market = resolveMarket(flags);
315
+
316
+ if (process.stdout.isTTY && !flags.quiet) process.stdout.write('\r');
317
+
318
+ const reportOptions = {
319
+ title: flags.title ?? name,
320
+ targetName: flags['target-name'] ?? flags['target-url'],
321
+ version: VERSION,
322
+ // Denominated in the market's own currency unless overridden — simulating
323
+ // a UK bank and reporting in naira would be a strange default.
324
+ currency: flags.currency ?? market.currency,
325
+ market: market.id,
326
+ failUnder: flags['fail-under-each'] ?? flags['fail-under'],
327
+ failUnderScope: flags['fail-under-each'] != null ? 'scenario' : 'overall',
328
+ maxFalsePositiveRate: flags['max-false-positives'],
329
+ suiteName: name,
330
+ };
331
+
332
+ // Always print the terminal summary, so a run is legible without opening a file.
333
+ if (!flags.quiet) {
334
+ process.stdout.write(generateReport(reports, { ...reportOptions, format: 'text' }));
335
+ }
336
+
337
+ if (flags.report) {
338
+ const path = resolve(flags.report);
339
+ const format =
340
+ flags.format ?? FORMAT_BY_EXT[extname(path).toLowerCase()] ?? 'html';
341
+ writeFileSync(path, generateReport(reports, { ...reportOptions, format }));
342
+ if (!flags.quiet) process.stdout.write(` Report written to ${path} (${format})\n\n`);
343
+ }
344
+
345
+ const { totals } = summarise(reports, reportOptions);
346
+ const legit = totals.legitimate;
347
+
348
+ // An unmeasurable run must not be reported as either a pass or a failure of
349
+ // the defence — it is an environment problem.
350
+ const unreliable = Math.max(totals.errorRate, legit?.errorRate ?? 0);
351
+ if (unreliable > 0.05) {
352
+ process.stderr.write(
353
+ ` ${(unreliable * 100).toFixed(1)}% of attempts never received a decision. ` +
354
+ 'The target was unreliable, so this run does not measure the defence.\n\n'
355
+ );
356
+ process.exit(3);
357
+ }
358
+
359
+ const failures = [];
360
+ if (totals.attacksSucceeded > 0) {
361
+ failures.push(`${totals.attacksSucceeded} attack(s) succeeded`);
362
+ }
363
+
364
+ // --fail-under gates the overall block rate. Gating every scenario
365
+ // individually is a different, stricter question: some scenarios legitimately
366
+ // run at a low block rate because their event stream includes the victim's
367
+ // own traffic, so a per-scenario floor produces false failures unless the
368
+ // caller has chosen it deliberately. That is --fail-under-each.
369
+ if (Number.isFinite(flags['fail-under']) && totals.blockRate < flags['fail-under']) {
370
+ failures.push(
371
+ `overall block rate ${(totals.blockRate * 100).toFixed(1)}% is below the ` +
372
+ `${(flags['fail-under'] * 100).toFixed(0)}% floor`
373
+ );
374
+ }
375
+
376
+ // Blocking good customers is a failure of the defence too — the half of the
377
+ // measurement a block rate cannot see. Gated only when asked, because every
378
+ // real engine refuses some customers and the acceptable share is a policy.
379
+ if (
380
+ Number.isFinite(flags['max-false-positives']) &&
381
+ legit &&
382
+ legit.falsePositiveRate > flags['max-false-positives']
383
+ ) {
384
+ failures.push(
385
+ `false-positive rate ${(legit.falsePositiveRate * 100).toFixed(1)}% is above the ` +
386
+ `${(flags['max-false-positives'] * 100).toFixed(1)}% ceiling ` +
387
+ `(${legit.customersBlocked} of ${legit.customers} customers refused)`
388
+ );
389
+ }
390
+
391
+ if (Number.isFinite(flags['fail-under-each'])) {
392
+ // Attack scenarios only: a block-rate floor on legitimate traffic would
393
+ // demand that good customers be refused.
394
+ const below = reports.filter(
395
+ (r) => r.traffic !== 'legitimate' && r.blockRate < flags['fail-under-each']
396
+ );
397
+ if (below.length) {
398
+ failures.push(
399
+ `${below.length} scenario(s) below the per-scenario ${(flags['fail-under-each'] * 100).toFixed(0)}% floor: ` +
400
+ below.map((r) => r.scenarioId).join(', ')
401
+ );
402
+ }
403
+ }
404
+
405
+ if (failures.length) {
406
+ process.stderr.write(` FAILED: ${failures.join('; ')}\n\n`);
407
+ process.exit(1);
408
+ }
409
+ }
410
+
411
+ // ── flag parsing ────────────────────────────────────────────────────────
412
+
413
+ const NUMERIC = new Set([
414
+ 'seed',
415
+ 'rps',
416
+ 'concurrency',
417
+ 'timeout',
418
+ 'fail-under',
419
+ 'fail-under-each',
420
+ 'max-false-positives',
421
+ 'users',
422
+ ]);
423
+ const REPEATABLE = new Set(['header', 'option', 'block-on-status']);
424
+ const BOOLEAN = new Set(['all', 'json', 'quiet']);
425
+
426
+ function parseFlags(argv) {
427
+ const flags = { _: [], header: {}, option: {}, scopedOption: {} };
428
+
429
+ for (let i = 0; i < argv.length; i++) {
430
+ const arg = argv[i];
431
+
432
+ if (!arg.startsWith('--')) {
433
+ flags._.push(arg);
434
+ continue;
435
+ }
436
+
437
+ let key = arg.slice(2);
438
+ let value;
439
+
440
+ const eq = key.indexOf('=');
441
+ if (eq !== -1) {
442
+ value = key.slice(eq + 1);
443
+ key = key.slice(0, eq);
444
+ }
445
+
446
+ if (BOOLEAN.has(key)) {
447
+ flags[key] = value === undefined ? true : value !== 'false';
448
+ continue;
449
+ }
450
+
451
+ if (value === undefined) {
452
+ value = argv[++i];
453
+ if (value === undefined) throw usage(`--${key} needs a value`);
454
+ }
455
+
456
+ if (REPEATABLE.has(key)) {
457
+ applyRepeatable(flags, key, value);
458
+ continue;
459
+ }
460
+
461
+ if (NUMERIC.has(key)) {
462
+ const n = Number(value);
463
+ if (!Number.isFinite(n)) throw usage(`--${key} must be a number, received "${value}"`);
464
+ flags[key] = n;
465
+ continue;
466
+ }
467
+
468
+ flags[key] = value;
469
+ }
470
+
471
+ // A percentage is friendlier to type than a fraction, so accept both.
472
+ for (const key of ['fail-under', 'fail-under-each']) {
473
+ if (flags[key] > 1) flags[key] = flags[key] / 100;
474
+ }
475
+ // A 100% false-positive ceiling gates nothing, so 1 here means 1%, not 1.0.
476
+ if (flags['max-false-positives'] >= 1) {
477
+ flags['max-false-positives'] = flags['max-false-positives'] / 100;
478
+ }
479
+
480
+ return flags;
481
+ }
482
+
483
+ function applyRepeatable(flags, key, value) {
484
+ if (key === 'header') {
485
+ const idx = value.indexOf(':');
486
+ if (idx === -1) throw usage(`--header expects "Name: value", received "${value}"`);
487
+ flags.header[value.slice(0, idx).trim()] = value.slice(idx + 1).trim();
488
+ return;
489
+ }
490
+
491
+ if (key === 'block-on-status') {
492
+ flags['block-on-status'] ??= [];
493
+ for (const part of value.split(',')) {
494
+ const n = Number(part.trim());
495
+ if (!Number.isInteger(n)) throw usage(`--block-on-status expects status codes`);
496
+ flags['block-on-status'].push(n);
497
+ }
498
+ return;
499
+ }
500
+
501
+ // --option key=value, with the value coerced so numeric and boolean scenario
502
+ // options do not arrive as strings and silently fail their validation.
503
+ const idx = value.indexOf('=');
504
+ if (idx === -1) throw usage(`--option expects key=value, received "${value}"`);
505
+ const name = value.slice(0, idx);
506
+ const parsed = coerce(value.slice(idx + 1));
507
+
508
+ // `--option scenario.key=value` targets one scenario; a bare key applies to
509
+ // every scenario that declares it.
510
+ const dot = name.indexOf('.');
511
+ if (dot !== -1) {
512
+ const scenario = name.slice(0, dot);
513
+ flags.scopedOption[scenario] ??= {};
514
+ flags.scopedOption[scenario][name.slice(dot + 1)] = parsed;
515
+ return;
516
+ }
517
+
518
+ flags.option[name] = parsed;
519
+ }
520
+
521
+ function coerce(raw) {
522
+ if (raw === 'true') return true;
523
+ if (raw === 'false') return false;
524
+ if (raw === 'null') return null;
525
+ if (raw !== '' && Number.isFinite(Number(raw))) return Number(raw);
526
+ if (raw.includes(',')) return raw.split(',').map((s) => coerce(s.trim()));
527
+ return raw;
528
+ }
529
+
530
+ function usage(message) {
531
+ const err = new Error(`${message}\n\nRun \`fraud-sim --help\` for usage.`);
532
+ err.exitCode = 2;
533
+ return err;
534
+ }
535
+
536
+ const HELP = `
537
+ fraud-sim ${VERSION} — synthetic attack traffic for testing fraud detection
538
+
539
+ USAGE
540
+ fraud-sim list [--json]
541
+ fraud-sim run <scenario> --target-url <url> [options]
542
+ fraud-sim suite (--all | --scenarios a,b,c) --target-url <url> [options]
543
+
544
+ TARGET
545
+ --target-url <url> Base URL of the system under test (required)
546
+ --header "Name: value" Request header; repeat for several
547
+ --endpoint-login <path> Override the login scoring path
548
+ --endpoint-withdrawal <p> Override the withdrawal scoring path
549
+ --endpoint-action <path> Override the action scoring path
550
+ --block-on-status 403 HTTP statuses that mean a genuine block.
551
+ By default no status does — a 429 or 5xx is an
552
+ error, never a block, so a failing target can
553
+ never look like a strong one.
554
+ --timeout <ms> Per-request timeout (default 5000)
555
+
556
+ RUN
557
+ --option key=value Scenario option, applied to every scenario that
558
+ declares it; repeat for several
559
+ --option <id>.key=value Scenario option for one scenario only
560
+ --users <n> Synthetic accounts per scenario (default 12).
561
+ Scenarios that require account ids get their own
562
+ disjoint block, so they do not contaminate each
563
+ other's per-user counters.
564
+ --user-prefix <text> Prefix for those accounts (default sim_user)
565
+ --seed <n> Make the run reproducible
566
+ --rps <n> Request ceiling per second (default 25, 0 = off)
567
+ --concurrency <n> Scenarios in parallel (suite only, default 1)
568
+ --name <text> Suite name for the report
569
+ --locale <code> Market to simulate: account-number and phone
570
+ formats, bank names, payload currency, and which
571
+ country counts as domestic for geo rules.
572
+ Available: ${localeIds().join(', ')} (default US)
573
+
574
+ OUTPUT
575
+ --report <file> Write a report; format inferred from extension
576
+ (.html, .xml = JUnit, .json, .txt)
577
+ --format <fmt> Force html | junit | json | text
578
+ --title <text> Report title
579
+ --currency <code> Display currency. Defaults to the market's own.
580
+ Amounts are minor-unit integers, so this changes
581
+ presentation only — nothing is converted.
582
+ --target-name <text> How the target is named in the report
583
+ --quiet Suppress the terminal summary
584
+
585
+ CI
586
+ --fail-under <rate> Exit 1 if the OVERALL block rate is below this
587
+ (accepts 0.85 or 85)
588
+ --fail-under-each <rate> Exit 1 if ANY single scenario is below this.
589
+ Stricter, and needs care: some scenarios include
590
+ the victim's own legitimate traffic, so a low
591
+ block rate there is correct rather than a failure.
592
+
593
+ --max-false-positives <r> Exit 1 if more than this share of LEGITIMATE
594
+ customer events were blocked (accepts 0.02 or 2).
595
+ Needs legitimate-users in the run; suite --all
596
+ includes it.
597
+
598
+ An attack that reaches its objective always fails the run, with or without
599
+ these flags.
600
+
601
+ EXIT CODES
602
+ 0 gate passed 2 usage error
603
+ 1 gate failed 3 run failed, or target too unreliable to measure
604
+
605
+ EXAMPLES
606
+ fraud-sim list
607
+
608
+ fraud-sim run credential-stuffing \\
609
+ --target-url https://staging.example.com \\
610
+ --header "Authorization: Bearer $API_KEY" \\
611
+ --option targetUserIds=u1,u2,u3 --seed 42
612
+
613
+ fraud-sim suite --all \\
614
+ --target-url https://staging.example.com \\
615
+ --seed 42 --report assessment.html --fail-under 85 --max-false-positives 2
616
+
617
+ `;
618
+ // Invoked last on purpose: the flag tables above are module-level `const`,
619
+ // so calling main() before they initialise fails with a temporal-dead-zone
620
+ // error at runtime rather than anything the linter would catch.
621
+ main().catch((err) => {
622
+ process.stderr.write(`\nfraud-sim: ${err.message}\n`);
623
+ process.exit(err.exitCode ?? 3);
624
+ });
625
+
@@ -26,7 +26,7 @@ const report = await run('account-drain', {
26
26
  options: {
27
27
  targetUserId: 'victim_user_002',
28
28
  withdrawalCount: 5,
29
- amountKobo: 4500000,
29
+ amountMinor: 12000000, // NGN 120,000 — clears the common new-payee/new-device gates
30
30
  delayBetweenWithdrawalsMs: 500,
31
31
  postLoginPauseMs: 1000,
32
32
  },