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
package/README.md CHANGED
@@ -9,9 +9,9 @@
9
9
  [![secret-scan](https://github.com/arthurr-beep/fraudsim/workflows/secret-scan/badge.svg)](https://github.com/arthurr-beep/fraudsim/actions)
10
10
  [![node](https://img.shields.io/badge/node-%3E%3D20-brightgreen)](https://nodejs.org)
11
11
 
12
- `fraud-sim` is a target-agnostic library for generating realistic fraud attack traffic — credential stuffing, account takeovers, card testing, and LLM-driven adaptive attackers — against any fraud detection system you want to evaluate.
12
+ `fraud-sim` is a target-agnostic library for generating realistic fraud attack traffic — credential stuffing, account takeovers, card testing, and LLM-driven adaptive attackers — against any fraud detection system you want to evaluate. Alongside the attacks it sends ordinary customer traffic, so every run measures both what your defence stops and who it wrongly locks out.
13
13
 
14
- You build the defence. We bring the adversary.
14
+ You build the defence. We bring the adversary — and the customers.
15
15
 
16
16
  ```js
17
17
  import { run, httpAdapter } from 'fraud-sim';
@@ -22,7 +22,7 @@ const target = httpAdapter({
22
22
  });
23
23
 
24
24
  const report = await run('credential-stuffing', {
25
- options: { targetUserId: 'user_001', attackerCount: 50 },
25
+ options: { targetUserIds: ['user_001', 'user_002', 'user_003'], attackerCount: 20 },
26
26
  target,
27
27
  onEvent: (event) => console.log(event.type, event),
28
28
  });
@@ -38,7 +38,9 @@ Every team building fraud detection needs to answer one question: *does it actua
38
38
 
39
39
  The honest ways to find out are limited. You can wait for real attackers (expensive in losses, slow in feedback). You can write throwaway shell scripts (different per engineer, never reused, never reviewed). You can buy proprietary attack libraries from vendors (closed, locked to one product, often years out of date).
40
40
 
41
- `fraud-sim` is the open alternative: a maintained, scenario-extensible library that any team can point at any target. Test your in-house rules engine. Benchmark Stripe Radar. Compare two vendors side by side. Run it in CI to catch regressions when you change a threshold.
41
+ `fraud-sim` is the open alternative: a maintained, scenario-extensible library for teams that run their own scoring API. Regression-test your rules engine in CI, so a threshold change that lets card testing through — or starts locking out customers who bought a new phone — fails the build instead of reaching production.
42
+
43
+ Be clear about what it measures. Payloads are synthetic: fingerprints, IPs and amounts are generated, not captured from real devices or real fraud. That exercises the rules and thresholds behind your API. It does not exercise client-side device intelligence, behavioural biometrics or consortium data, and pointing it at a third-party vendor's production model would tell you little and probably breach their terms.
42
44
 
43
45
  It works against any HTTP API. It uses no real fraud data. It includes an LLM-powered adaptive attacker that learns from your blocks and changes strategy mid-attack — because real attackers do exactly that.
44
46
 
@@ -46,12 +48,13 @@ It works against any HTTP API. It uses no real fraud data. It includes an LLM-po
46
48
 
47
49
  ## What it does
48
50
 
49
- - **9 attack scenarios out of the box** — credential stuffing, account drain, card testing, promo abuse, mule networks, slow drain, session hijack, SIM swap takeover, and the adaptive LLM attacker
51
+ - **10 attack scenarios** — credential stuffing, account drain, card testing, promo abuse, mule networks, slow drain, session hijack, SIM swap takeover, velocity evasion, and the LLM-driven adaptive attacker
52
+ - **Legitimate customer traffic** — ordinary logins, new phones, travel and payments, so a run reports its false-positive rate next to its block rate. A defence that blocks everything scores 100% on attacks and fails here
50
53
  - **Target-agnostic** — works against any REST API via a configurable adapter
51
54
  - **LLM-driven adaptive attacker** — bring your own OpenAI, Anthropic, or local model. A deterministic fallback works without any LLM
52
55
  - **Structured event stream** — every action emits a typed event. Pipe it to a dashboard, log it, or stream it via Server-Sent Events
53
- - **Benchmarking and reporting** — run scenarios as a suite, compare two systems, generate shareable HTML reports, integrate with CI
54
- - **Safe by default** — modest attack rates that won't accidentally DDoS your own staging environment
56
+ - **Reports and CI** — a `fraud-sim` command line, plus HTML, JUnit, JSON and terminal reports with findings ranked by what they cost
57
+ - **Safe by default** — an enforced ceiling of 25 requests/second, not just per-scenario pacing, so a mistyped delay stays a simulation
55
58
 
56
59
  ---
57
60
 
@@ -69,7 +72,14 @@ It works against any HTTP API. It uses no real fraud data. It includes an LLM-po
69
72
  npm install fraud-sim
70
73
  ```
71
74
 
72
- Requires Node.js 20 or higher. Works with ESM and CommonJS.
75
+ Requires Node.js 20 or higher. The package is ESM-only — `require('fraud-sim')`
76
+ throws `ERR_REQUIRE_ESM`. From a CommonJS project, use a dynamic import:
77
+
78
+ ```js
79
+ const { run } = await import('fraud-sim');
80
+ ```
81
+
82
+ TypeScript declarations are bundled; no `@types` package is needed.
73
83
 
74
84
  ---
75
85
 
@@ -95,18 +105,162 @@ const report = await run('credential-stuffing', {
95
105
  console.log(report); // { attempts, blocked, stepUp, allowed, blockRate, ... }
96
106
  ```
97
107
 
98
- **Bundled mock HTTP target — a realistic practice range.** The package ships a
99
- small Express server with an actual rules engine. Install `express` once
100
- (`npm install express`), then:
108
+ **Bundled mock HTTP targets — a practice range with two difficulty levels.**
109
+ The package ships two Express servers with real rules engines. Install
110
+ `express` once (`npm install express`), then:
101
111
 
102
112
  ```bash
103
113
  npm run example:mock-target # terminal 1 → http://localhost:4000
104
114
  npm run example:basic # terminal 2 → attacks it, watch decisions escalate
105
115
  ```
106
116
 
117
+ The simple target has a deliberate cold-start gap: it allows a first login from
118
+ an unrecognised device, so an attacker wins on move one. That is useful for
119
+ seeing what a weak threshold costs, and useless for seeing an attacker adapt.
120
+
121
+ The **hardened target** closes that gap — an unrecognised device is never
122
+ allowed outright, only challenged — plus IP reputation, device recidivism and
123
+ rotation detection:
124
+
125
+ ```bash
126
+ npm run example:hardened-target # terminal 1 → http://localhost:4001
127
+ npm run example:mock-target # terminal 2 → http://localhost:4000
128
+ npm run example:adaptive-hardened # terminal 3 → same attacker, both targets
129
+ ```
130
+
131
+ That prints the adaptation round by round:
132
+
133
+ ```
134
+ round 4 BLOCK risk 0.90 IP_KNOWN_ABUSIVE, NEW_DEVICE
135
+ ↳ Last attempt was blocked on an IP-related reason. Rotating to a fresh IP address.
136
+
137
+ round 5 STEP_UP risk 0.35 NEW_DEVICE
138
+ ↳ Last attempt was blocked on an IP-related reason. Rotating to a fresh IP address.
139
+
140
+ round 7 BLOCK risk 1.00 IP_KNOWN_ABUSIVE, IP_ROTATION_PATTERN, NEW_DEVICE
141
+
142
+ attacker defeated after 8 rounds — blocked 5 · challenged 3 · allowed 0
143
+ ```
144
+
145
+ Rotation buys the attacker its way back from a block to a challenge, but never
146
+ to an allow — and by round seven the rotation is itself a signal.
147
+
148
+ **The gap the hardened target leaves on purpose:** a device that was only
149
+ *challenged* is remembered as seen, not as bad. Replay it from a clean IP and it
150
+ is allowed. The shipped deterministic attacker never finds this, because it
151
+ rotates its fingerprint every round; an attacker reasoning "a step-up is not a
152
+ block" beats the target immediately. Point a real LLM at it and see which one
153
+ you have.
154
+
107
155
  Graduating to the real thing is a one-line change — swap the `baseUrl`. Full
108
156
  walk-through in **[docs/TESTING.md](docs/TESTING.md)**.
109
157
 
158
+ ### Watch the whole thing in 90 seconds
159
+
160
+ From a clone of the repository, `npm run demo` starts both practice engines
161
+ and walks through six acts in the terminal, paced to be followed:
162
+
163
+ 1. A credential-stuffing botnet is contained.
164
+ 2. An adaptive attacker that rotates IP, device and browser is defeated.
165
+ 3. A smarter attacker replays a device that was only challenged, and gets in.
166
+ 4. Ordinary customers get through the hardened engine.
167
+ 5. The full suite, gated like CI: an 85% block rate passes, and customers
168
+ blocked fail the run.
169
+ 6. An unreachable target exits 3 — fix the environment, not the rules.
170
+
171
+ It finishes by opening the HTML report. Each act asserts what it shows, and CI
172
+ runs the same walkthrough flat out (`npm run test:e2e`), so the demo cannot
173
+ drift from what the tool does. `DEMO_PACE=<ms>` sets the pause per beat;
174
+ `DEMO_LLM=anthropic` with `ANTHROPIC_API_KEY` puts a real model behind act 3.
175
+
176
+ ---
177
+
178
+ ## The command line
179
+
180
+ No JavaScript required — point it at a URL and read the result.
181
+
182
+ ```bash
183
+ npx fraud-sim list
184
+
185
+ npx fraud-sim suite --all \
186
+ --target-url https://staging.example.com \
187
+ --header "Authorization: Bearer $API_KEY" \
188
+ --seed 42 \
189
+ --report assessment.html
190
+ ```
191
+
192
+ That writes a shareable HTML report and prints a summary. This one is the
193
+ bundled simple practice target (`npm run example:mock-target`), seed 42:
194
+
195
+ ```
196
+ SCENARIO EVENTS BLOCK CHALL ALLOW RATE OUTCOME
197
+ --------------------------------------------------------------------
198
+ credential-stuffing 500 440 51 9 88.0% Contained
199
+ card-testing 64 50 6 8 78.1% Value extracted
200
+ sim-swap-takeover 3 0 1 2 0.0% Never blocked
201
+ ...
202
+
203
+ TOTAL 675 events · 675 scored · 85.0% blocked · 0 errors
204
+ LOSS $606,000 extracted
205
+
206
+ LEGITIMATE TRAFFIC EVENTS BLOCK CHALL ALLOW FALSE+ OUTCOME
207
+ --------------------------------------------------------------------
208
+ legitimate-users 66 16 17 33 24.2% Customers blocked
209
+
210
+ CUSTOMERS 66 events · 24.2% blocked (false positives) · 50.0% challenged or blocked · 11 of 12 customers refused
211
+
212
+ FINDINGS (8)
213
+ [CRITICAL] card-testing — The attack reached money
214
+ [CRITICAL] promo-abuse — The attack reached money
215
+ [HIGH] legitimate-users — Legitimate customers were blocked
216
+ [HIGH] sim-swap-takeover — Nothing was ever blocked
217
+ ...
218
+ ```
219
+
220
+ The simple target looks respectable on attacks and then refuses one customer
221
+ event in four. Most of those blocks cite velocity: that target counts logins by
222
+ arrival time, and the customers' sessions are days apart on the event clock but
223
+ arrive within seconds. The finding says so rather than presenting the figure as
224
+ fact — see [Legitimate traffic](#legitimate-traffic-and-false-positives).
225
+
226
+ `suite --all` synthesises the account identifiers each scenario needs, giving
227
+ every scenario its own block so they do not contaminate each other's per-user
228
+ counters. Override anything with `--option key=value`, or target one scenario
229
+ with `--option <scenario>.key=value`.
230
+
231
+ ### In CI
232
+
233
+ ```bash
234
+ npx fraud-sim suite --all \
235
+ --target-url "$STAGING_URL" \
236
+ --seed 42 \
237
+ --report junit.xml \
238
+ --fail-under 85 \
239
+ --max-false-positives 2
240
+ ```
241
+
242
+ `--fail-under` is the floor on attacks blocked; `--max-false-positives` is the
243
+ ceiling on legitimate customer events blocked. Gate on both — either one alone
244
+ can be passed by tuning the defence to the extreme.
245
+
246
+ | Exit | Meaning |
247
+ |---|---|
248
+ | 0 | Ran, gate passed |
249
+ | 1 | Ran, gate failed — an attack succeeded, the block rate was below the floor, or the false-positive rate was above the ceiling |
250
+ | 2 | Usage error |
251
+ | 3 | The run failed, or the target was too unreliable to measure |
252
+
253
+ The 1/3 split is the point: a weak defence and an unreachable staging box are
254
+ different problems, and a pipeline that reports them identically sends people to
255
+ debug the wrong one. An attack that reaches its objective fails the run whether
256
+ or not you set a threshold.
257
+
258
+ > **Windows / Git Bash:** MSYS shells rewrite arguments that look like absolute
259
+ > Unix paths, so `--endpoint-login /score/login` becomes a filesystem path and
260
+ > every request 404s. Prefix the command with `MSYS_NO_PATHCONV=1`, or write the
261
+ > path as `//score/login`. fraud-sim detects this and says so rather than
262
+ > reporting your target as broken.
263
+
110
264
  ---
111
265
 
112
266
  ## Quickstart — 5 minutes from zero to your first simulation
@@ -138,7 +292,10 @@ const target = httpAdapter({
138
292
  });
139
293
 
140
294
  const report = await run('credential-stuffing', {
141
- options: { targetUserId: 'test_user_001', attackerCount: 10 },
295
+ options: {
296
+ targetUserIds: Array.from({ length: 12 }, (_, i) => `test_user_${i}`),
297
+ attackerCount: 10,
298
+ },
142
299
  target,
143
300
  onEvent: (event) => {
144
301
  if (event.type === 'response') {
@@ -205,25 +362,86 @@ const llm = deterministic({ seed: 42 });
205
362
 
206
363
  ## The scenarios
207
364
 
208
- | ID | What it simulates |
209
- |---|---|
210
- | `credential-stuffing` | A botnet authenticates using stolen credentials across many IPs and rotating user agents |
211
- | `account-drain` | Attacker logs in from a new device, changes the password, and rapidly drains funds to new payees |
212
- | `adaptive-attacker` | An LLM-driven attacker that observes blocks and reason codes, then adapts its strategy |
213
- | `card-testing` | BIN attacks, micro-authorisation probing, and automated card validation patterns |
214
- | `promo-abuse` | One actor creating many accounts to farm signup bonuses and referral rewards |
215
- | `mule-network` | Coordinated transfers between accounts simulating fund laundering |
216
- | `slow-drain` | Withdrawals individually below the velocity threshold but collectively significant |
217
- | `session-hijack` | Mid-session device or IP changes simulating session takeover |
218
- | `sim-swap-takeover` | Phone number ported, OTP redirected, login from new device, immediate withdrawal |
365
+ Ten attack scenarios and one legitimate one are registered and runnable.
366
+ `listScenarios()` returns the live list at runtime, each entry marked
367
+ `traffic: 'attack'` or `traffic: 'legitimate'`.
368
+
369
+ | ID | Needs | What it simulates |
370
+ |---|---|---|
371
+ | `legitimate-users` | login, withdrawal | **Not an attack.** Ordinary customers: routine logins, a new phone, travel, payments to known and new payees. Every block is a false positive |
372
+ | `credential-stuffing` | login | A botnet replays a stolen credential dump across many IPs and many accounts |
373
+ | `account-drain` | login, withdrawal | Attacker logs in from a new device, changes the password, and rapidly drains funds to new payees |
374
+ | `adaptive-attacker` | login | An LLM-driven attacker that observes blocks and reason codes, then adapts its strategy |
375
+ | `card-testing` | action | BIN attacks and micro-authorisation probing, escalating to real charges on validated cards |
376
+ | `promo-abuse` | action | One actor creating many accounts to farm signup bonuses, reusing devices and subnets |
377
+ | `mule-network` | withdrawal | Fan-out to mule accounts, then fan-in to one destination — laundering by layering |
378
+ | `slow-drain` | withdrawal | Withdrawals individually below every short-window threshold, spread over days |
379
+ | `session-hijack` | login, action, withdrawal | A stolen session replayed from a different device and network |
380
+ | `sim-swap-takeover` | login, action, withdrawal | Number ported, OTP redirected, password reset, login, immediate withdrawal |
381
+ | `velocity-evasion` | login | An attacker pacing attempts to stay just under the rate limits |
382
+
383
+ The **Needs** column is the target methods each scenario calls: `login` is
384
+ `scoreLogin`, `withdrawal` is `scoreWithdrawal`, `action` is `scoreAction`. A
385
+ scenario throws a clear error if the adapter does not implement what it needs,
386
+ so a target that only scores logins and withdrawals can still run seven of the
387
+ ten attacks, and the legitimate traffic.
388
+
389
+ Four scenarios (`slow-drain`, `velocity-evasion`, `mule-network`, and the timing
390
+ stages of `sim-swap-takeover`) advance the payload `timestamp` instead of
391
+ sleeping for hours, and set `simulatedTime: true` on the report. That is
392
+ faithful only if your system derives its velocity windows from the event
393
+ timestamp — if it stamps arrival time server-side, those runs measure a burst
394
+ rather than the attack they describe.
219
395
 
220
396
  Each scenario has its own options and emits structured events you can subscribe to. See [`docs/ATTACK_PATTERNS.md`](docs/ATTACK_PATTERNS.md) for full details on each one.
221
397
 
398
+ ### Legitimate traffic and false positives
399
+
400
+ A block rate on its own is easy to win: block everything and it reads 100%.
401
+ `legitimate-users` is the other half of the measurement. It plays ordinary
402
+ customers, each with one device, one browser, their own home address in a
403
+ domestic city and a couple of payees they already pay. A block here is a real
404
+ customer refused; a step-up is friction.
405
+
406
+ ```js
407
+ const report = await run('legitimate-users', {
408
+ target,
409
+ options: { targetUserIds: customerIds }, // one id per customer
410
+ seed: 42,
411
+ });
412
+
413
+ report.falsePositiveRate; // blocked / scored customer events
414
+ report.frictionRate; // (blocked + challenged) / scored
415
+ report.customersBlocked; // customers refused at least once
416
+ report.valueBlockedMinor; // customers' own payments refused
417
+ report.byStage; // routine-login, new-device-login, travel-login,
418
+ // known-payee-withdrawal, new-payee-withdrawal
419
+ ```
420
+
421
+ How it stays honest:
422
+
423
+ - **Warm-up first.** Each customer logs in `warmupLogins` times before anything
424
+ is measured, because a real customer is not a stranger to your system. Warm-up
425
+ decisions are reported under `warmup` and excluded from the rates.
426
+ - **Own addresses.** Every customer gets a distinct home IP, so a users-per-IP
427
+ rule cannot fire on traffic the simulator crowded onto one address.
428
+ - **Days, not seconds.** Sessions are `sessionGapHours` apart on the event
429
+ clock. That is faithful only if your engine windows on the payload
430
+ `timestamp`. If it counts arrivals, it sees a burst, and the finding flags any
431
+ velocity reason among the blocks as possible compression rather than behaviour.
432
+ - **Never mixed with attacks.** Suite totals, block rates and `--fail-under`
433
+ cover attack scenarios only. Customer traffic is summarised separately under
434
+ `totals.legitimate` in every report format.
435
+
436
+ Tune the mix with `newDeviceRate`, `travelRate`, `withdrawalRate`,
437
+ `newPayeeRate` and `amountMinor`. `suite --all` runs it first, before any attack
438
+ has burned shared state on the target.
439
+
222
440
  List scenarios programmatically:
223
441
 
224
442
  ```js
225
443
  import { listScenarios } from 'fraud-sim';
226
- const scenarios = await listScenarios();
444
+ const scenarios = listScenarios(); // synchronous
227
445
  // → [{ id: 'credential-stuffing', name: '...', description: '...', defaultOptions: {...} }, ...]
228
446
  ```
229
447
 
@@ -255,6 +473,78 @@ npx fraud-sim compare \
255
473
 
256
474
  ---
257
475
 
476
+ ## Calibrating a run
477
+
478
+ A simulation only tells you something if the attack actually crosses the
479
+ thresholds the target is configured with. Two failure modes look identical in a
480
+ report and mean opposite things:
481
+
482
+ - **The attack was caught.** Low allow rate, several distinct reason codes.
483
+ - **The attack was too quiet to catch.** Also a low block rate — but the
484
+ defence never had anything to fire on.
485
+
486
+ Three settings decide which one you get.
487
+
488
+ **`targetUserIds` — how many accounts the dump covers.** Credential stuffing is
489
+ a list of victims, not one victim retried. Engines commonly ship a "one IP,
490
+ many users" rule with a limit near 10 accounts; pass fewer than that and the
491
+ rule cannot fire. The scenario emits a `milestone` warning if you pass a single
492
+ account.
493
+
494
+ **`attemptsPerIp` — how hard each IP pushes.** Per-IP velocity limits commonly
495
+ sit near 20 attempts per 5 minutes. The default of 25 clears that; the previous
496
+ default of 3 did not, so the rule stayed silent on every run.
497
+
498
+ **`ipPersona` — whether geo and reputation rules are reachable at all.** This is
499
+ the subtle one. Most fraud systems resolve IP intelligence *asynchronously* and
500
+ fall back to neutral values on a cache miss. In a short run every IP is seen for
501
+ the first time, so proxy, abuse-score, new-country, and impossible-travel rules
502
+ all score zero — often four to six of the target's rules, silently disabled.
503
+
504
+ ```js
505
+ import { DEMO_IP_POOL } from 'fraud-sim';
506
+
507
+ // Before the run: seed the target's IP-intel cache from the pool.
508
+ for (const entry of DEMO_IP_POOL) {
509
+ await yourSystem.cacheIpIntel(entry.ip, entry);
510
+ }
511
+
512
+ const report = await run('credential-stuffing', {
513
+ target,
514
+ options: { targetUserIds, ipPersona: 'mixed' },
515
+ });
516
+ ```
517
+
518
+ `DEMO_IP_POOL` is a fixed set of addresses with declared geolocation and
519
+ reputation, drawn from the RFC 5737 documentation ranges so a simulation never
520
+ attributes attack traffic to a real network operator. Because it is fixed and
521
+ its metadata is published, the system under test can pre-warm from it — no live
522
+ lookups, no third-party rate limits mid-demo, and reproducible results.
523
+
524
+ | `ipPersona` | Draws from | Use when |
525
+ |---|---|---|
526
+ | `random` (default) | Generated public IPv4, no metadata | Pointing at a system that does its own live IP lookups |
527
+ | `mixed` | Hosting, proxy, foreign and domestic pool entries | Demos and CI — the realistic botnet composition |
528
+ | `domestic` | In-country residential | Isolating velocity rules from geo rules |
529
+ | `hosting` / `proxy` / `foreign` | That persona only | Probing one rule family at a time |
530
+
531
+ Note that `random` deliberately excludes private, loopback, CGNAT, link-local
532
+ and documentation ranges. Addresses in those blocks resolve at no IP-intel
533
+ provider, which produces the silent-neutral-fallback problem above.
534
+
535
+ Scenario events carry the intel alongside each attempt when a pool persona is
536
+ used, so a live dashboard can show *why* an IP is suspicious:
537
+
538
+ ```js
539
+ onEvent: (e) => {
540
+ if (e.type === 'attempt' && e.ipIntel) {
541
+ console.log(e.payload.ip_address, e.ipIntel.countryCode, e.ipIntel.abuseScore);
542
+ }
543
+ }
544
+ ```
545
+
546
+ ---
547
+
258
548
  ## The adaptive attacker — how it works
259
549
 
260
550
  The `adaptive-attacker` scenario is `fraud-sim`'s headline feature, and the part that's worth understanding before using it.
@@ -275,62 +565,301 @@ Round 3: LLM reasons: "IP is flagged as a proxy. Switching to a residential
275
565
  Attacker tries with a residential-looking IP.
276
566
  Target responds: BLOCK — reasons: ["LOGIN_VELOCITY_USER"]
277
567
 
278
- Round 4: LLM reasons: "Velocity threshold hit. I'll wait and reduce my pace."
279
- Attacker waits 60 seconds, tries again with cleaner signals.
568
+ Round 4: LLM reasons: "Velocity threshold hit. I'll rotate the IP and keep
569
+ the device that was only challenged."
280
570
  Target responds: BLOCK — reasons: ["IP_MANY_USERS"]
281
571
 
282
572
  ...
283
573
  ```
284
574
 
285
- **What the LLM sees:** the decision (`ALLOW`/`STEP_UP`/`BLOCK`), the reason codes (`NEW_DEVICE`, `IP_PROXY_VPN`, etc.), and the round number. Nothing else. It cannot see the target's rule weights, thresholds, or internal state.
575
+ **What the LLM sees:** for every previous round, the exact `ip`, `userAgent` and `deviceFingerprint` that were sent, the decision (`ALLOW`/`STEP_UP`/`BLOCK`) and the reason codes (`NEW_DEVICE`, `IP_PROXY_VPN`, etc.). Nothing else. It cannot see the target's rule weights, thresholds, or internal state.
576
+
577
+ **What it controls:** those three parameters, plus `delayMs`. A parameter it omits keeps its previous value, so reusing a device is a choice it can make. `delayMs` is recorded in the report but not enacted — the run is paced by the scenario's own `delayMs` option, so a ten-round attack does not take minutes.
286
578
 
287
579
  **Why this matters:** real attackers operate this way. They probe, learn, and adapt. A fraud system that catches a one-shot attack but fails against an adaptive adversary is not actually well-defended. The adaptive attacker exposes the difference.
288
580
 
289
581
  **Defensive systems that defeat the adaptive attacker do so because rules stack.** Defeating one signal exposes the next. A system with only one rule (say, IP reputation) can be evaded by changing IPs. A system with nine independent signals — velocity, device, geo, behaviour, payee, session continuity — cannot be evaded by changing any one of them.
290
582
 
291
- The library ships with three LLM providers (OpenAI, Anthropic, and a deterministic fallback) and a clean interface for plugging in others. See [`docs/LLM_PROVIDERS.md`](docs/LLM_PROVIDERS.md).
583
+ The library ships with three LLM providers (OpenAI, Anthropic, and a deterministic fallback) and a clean interface for plugging in others — see `src/llm/`.
292
584
 
293
585
  ---
294
586
 
295
587
  ## Benchmarking and reporting
296
588
 
297
- Run a suite of scenarios and generate a shareable report:
589
+ Run several scenarios in one pass and get a per-scenario breakdown plus an
590
+ aggregate across all of them:
298
591
 
299
592
  ```js
300
- import { runSuite, generateReport } from 'fraud-sim';
301
- import fs from 'node:fs';
593
+ import { runSuite } from 'fraud-sim';
302
594
 
303
595
  const result = await runSuite({
304
596
  name: 'monthly-benchmark',
305
597
  scenarios: [
306
- { id: 'credential-stuffing', options: { attackerCount: 100 } },
307
- { id: 'account-drain', options: { withdrawalCount: 10 } },
308
- { id: 'card-testing', options: { cardCount: 50 } },
309
- { id: 'adaptive-attacker', options: { maxRounds: 15 }, llm },
598
+ { id: 'legitimate-users', options: { targetUserIds: customerIds } },
599
+ { id: 'credential-stuffing', options: { targetUserIds, attackerCount: 20 } },
600
+ { id: 'account-drain', options: { targetUserId: 'victim_001', withdrawalCount: 10 } },
601
+ { id: 'adaptive-attacker', options: { targetUserId: 'victim_001', maxRounds: 15 } },
310
602
  ],
311
603
  target,
312
604
  });
313
605
 
314
- const html = await generateReport(result, { format: 'html' });
315
- fs.writeFileSync('benchmark-report.html', html);
606
+ console.log(result.aggregate);
607
+ // → { attempts, blocked, stepUp, allowed, blockRate, stepUpRate,
608
+ // legitimate: { falsePositiveRate, frictionRate, customersBlocked, ... } }
609
+ // Attack figures cover attack scenarios only; `legitimate` is null when the
610
+ // suite included no legitimate traffic.
611
+
612
+ for (const r of result.scenarioReports) {
613
+ console.log(r.scenarioId, r.blockRate);
614
+ }
316
615
  ```
317
616
 
318
- The HTML report includes:
617
+ Because the aggregate is a plain object, gating CI on it needs no extra
618
+ tooling — assert on the block rate and fail the build when a threshold change
619
+ makes the defence worse. Check `errorRate` first, or a target that was simply
620
+ unreachable will sail through the gate:
319
621
 
320
- - Block rate per scenario with confidence intervals
321
- - Latency p50, p95, p99 across all attempts
322
- - Reason code frequency (what's catching the most attacks)
323
- - Side-by-side comparison when two targets are tested
324
- - A timeline view of the adaptive attacker's reasoning
622
+ ```js
623
+ const { blockRate, errorRate, scoredAttempts } = result.aggregate;
325
624
 
326
- For CI integration, generate JUnit XML and feed it to GitHub Actions, GitLab CI, or Jenkins:
625
+ if (errorRate > 0.05) {
626
+ throw new Error(
627
+ `${(errorRate * 100).toFixed(1)}% of attempts never got a decision — ` +
628
+ 'the target was unhealthy, so this run says nothing about the defence.'
629
+ );
630
+ }
631
+
632
+ if (blockRate < 0.85) {
633
+ throw new Error(`Block rate regressed to ${blockRate} over ${scoredAttempts} scored attempts`);
634
+ }
635
+
636
+ if (result.aggregate.legitimate?.falsePositiveRate > 0.02) {
637
+ throw new Error('More than 2% of ordinary customer activity is being blocked');
638
+ }
639
+ ```
640
+
641
+ ### Reports
327
642
 
328
643
  ```js
329
- const xml = await generateReport(result, { format: 'junit' });
330
- fs.writeFileSync('fraud-sim-results.xml', xml);
644
+ import { generateReport } from 'fraud-sim/reporting';
645
+ import { writeFileSync } from 'node:fs';
646
+
647
+ writeFileSync('report.html', generateReport(result, { format: 'html' }));
648
+ writeFileSync('junit.xml', generateReport(result, { format: 'junit' }));
331
649
  ```
332
650
 
333
- A worked GitHub Actions example lives in [`examples/ci-integration-github-actions/`](examples/ci-integration-github-actions/).
651
+ Four formats: `html` (a standalone, printable document), `junit` (for CI),
652
+ `json` (the structured summary), and `text` (the terminal table).
653
+
654
+ Every report carries latency percentiles, reason-code frequency, and findings
655
+ ranked by what they cost. Two mappings are worth knowing:
656
+
657
+ - **JUnit is scored from the defender's side.** A blocked attack is a *pass*. A
658
+ failure means the attack succeeded or nothing was blocked. An unreliable
659
+ target is an `error`, not a failure. A timing-dependent result is `skipped`,
660
+ never a pass — a green pipeline should not be achievable by breaking the
661
+ target.
662
+ - **Reports never invent causes.** Findings state what the data supports and
663
+ stop there.
664
+
665
+ ### Markets
666
+
667
+ A fraud engine is fed by data that looks like its market. Account numbers of the
668
+ wrong length, banks that do not exist and mis-formatted phone numbers get
669
+ rejected as malformed *before* they are ever scored — and a run full of
670
+ validation errors measures nothing at all.
671
+
672
+ So the market is a parameter:
673
+
674
+ ```js
675
+ await run('account-drain', { target, locale: 'GB', options });
676
+ ```
677
+
678
+ ```bash
679
+ npx fraud-sim suite --all --target-url "$URL" --locale GB
680
+ ```
681
+
682
+ | | `NG` | `US` | `GB` | `KE` |
683
+ |---|---|---|---|---|
684
+ | Currency | NGN | USD | GBP | KES |
685
+ | Account number | 10 digits | 8–12 digits | 8 digits | 12 digits |
686
+ | Phone | `0803…` | `+1…` | `+447…` | `+2547…` |
687
+ | Banks | GTBank, UBA… | Chase, Wells Fargo… | Barclays, Monzo… | Equity, KCB… |
688
+
689
+ Defaults to `US`. An unknown code throws rather than falling back, because
690
+ silently generating US-shaped data for a run that asked for `DE` produces a
691
+ result that looks valid and measures the wrong market.
692
+
693
+ The market also decides which country counts as **domestic** in `DEMO_IP_POOL`.
694
+ An address in Lagos is the quiet baseline for a Nigerian bank and a new-country
695
+ signal for a British one, so a fixed pool would mean opposite things depending
696
+ on who you pointed it at. Use `demoIpPool()` to get the pool for the market in
697
+ scope.
698
+
699
+ Adding a market is a pull request against `src/locales/index.js` — same fields,
700
+ synthetic data, real formats. The bundled packs are a small plausible sample
701
+ chosen so payloads pass format validation; they are not a directory, and no real
702
+ customer, account or number is represented.
703
+
704
+ ### Currency
705
+
706
+ Every amount in `fraud-sim` is an integer in the currency's **minor unit** —
707
+ kobo, cents, the same thing structurally. So a report renders in any currency
708
+ without touching a number:
709
+
710
+ ```js
711
+ generateReport(result, { format: 'html', currency: 'USD' });
712
+ ```
713
+
714
+ ```bash
715
+ npx fraud-sim suite --all --target-url "$URL" --currency USD
716
+ ```
717
+
718
+ Defaults to `NGN`. **No conversion is applied** — the integers are unchanged and
719
+ only the symbol and grouping differ. That is deliberate: a report that silently
720
+ applied an FX rate would be quietly wrong the day the rate moved, and the tool
721
+ has no business holding an opinion about exchange rates. If you need a converted
722
+ figure, convert it before you generate the report.
723
+
724
+ The minor-unit exponent comes from `Intl`, so zero-decimal currencies are not
725
+ divided by a hundred: the same integer that reads `₦606,000` reads `¥60,600,000`.
726
+
727
+ Reports default to the market's own currency, so `--locale GB` reports in GBP
728
+ without being told twice.
729
+
730
+ ### Running scenarios in parallel
731
+
732
+ ```js
733
+ const result = await runSuite({ name: 'nightly', target, scenarios, concurrency: 4 });
734
+ ```
735
+
736
+ The request ceiling is shared across the whole suite, so concurrency speeds a
737
+ run up without multiplying the load. Note that scenarios sharing a target
738
+ interleave their traffic, and most engines keep per-user and per-IP state — a
739
+ parallel suite measures a *combined* attack rather than each attack
740
+ independently. Give each scenario distinct users if you want both speed and
741
+ isolation.
742
+
743
+ ---
744
+
745
+ ## Reproducible runs
746
+
747
+ Pass a `seed` and the run replays choice for choice — same IPs, same devices,
748
+ same amounts, same ordering:
749
+
750
+ ```js
751
+ const report = await run('credential-stuffing', {
752
+ target,
753
+ options: { targetUserIds },
754
+ seed: 42,
755
+ });
756
+
757
+ console.log(report.seed); // 42
758
+ ```
759
+
760
+ Two runs with the same seed produce identical payloads. Different seeds produce
761
+ different ones. An unseeded run has no `seed` field on its report, so a report
762
+ never implies reproducibility it does not have.
763
+
764
+ This matters in two places. In CI, an unseeded block rate drifts between builds
765
+ for reasons unrelated to your rules, so a threshold gate is either too loose to
766
+ catch regressions or flaky enough to be ignored. And when an attack does get
767
+ through, a seed is the difference between "something got past us" and a run you
768
+ can hand someone and replay exactly.
769
+
770
+ Seeding controls generated values, **not wall-clock time** — payload timestamps
771
+ still come from the real clock (or a scenario's simulated one), so two seeded
772
+ runs are identical in shape rather than byte-identical.
773
+
774
+ `runSuite` takes a seed too, deriving a distinct one per scenario so the suite
775
+ is reproducible as a whole without every scenario replaying the same values.
776
+
777
+ Custom scenarios get this for free as long as they draw randomness from
778
+ `fraud-sim/utils` rather than calling `Math.random()` directly.
779
+
780
+ ---
781
+
782
+ ## Rate limiting
783
+
784
+ Every run is capped at **25 outbound requests per second** by default, enforced
785
+ by wrapping the target adapter — so the ceiling holds for custom scenarios too,
786
+ not just the built-in ones. The shipped scenarios pace themselves well under it;
787
+ the cap exists so that a `delayMs: 0` typo against a real host stays a
788
+ simulation.
789
+
790
+ ```js
791
+ await run('credential-stuffing', {
792
+ target,
793
+ options: { targetUserIds },
794
+ maxRequestsPerSecond: 50, // raise it deliberately
795
+ // maxRequestsPerSecond: Infinity, // or disable it entirely
796
+ });
797
+ ```
798
+
799
+ If the cap actually held a run back, it emits a `warning` event saying by how
800
+ much — worth noticing before reading anything into that run's latency numbers.
801
+
802
+ ---
803
+
804
+ ## When the target misbehaves
805
+
806
+ A simulation is only a measurement if the target actually answered. `fraud-sim`
807
+ separates the two cases.
808
+
809
+ **Non-2xx responses are errors, not blocks.** If a `429` or a `503` were counted
810
+ as a `BLOCK`, then rate-limiting the simulator — or the target falling over
811
+ under load — would *raise* its measured block rate. A benchmark that rewards a
812
+ target for breaking is measuring the opposite of what it claims. So HTTP
813
+ failures come back as `decision: 'ERROR'` with `infraError: true` and a reason
814
+ code naming the cause (`RATE_LIMITED`, `SERVER_ERROR`, `AUTH_ERROR`,
815
+ `ENDPOINT_NOT_FOUND`, `NETWORK_ERROR`).
816
+
817
+ If your API genuinely signals a block with an HTTP status, say so explicitly:
818
+
819
+ ```js
820
+ const target = httpAdapter({
821
+ baseUrl: 'https://api.example.com',
822
+ blockOnStatus: [403], // this API returns 403 for a hard block
823
+ });
824
+ ```
825
+
826
+ **Rates are computed over attempts that were actually scored.** Every report
827
+ carries:
828
+
829
+ | Field | Meaning |
830
+ |---|---|
831
+ | `attempts` | Everything the scenario tried |
832
+ | `scoredAttempts` | Attempts that came back with a real decision (`attempts - errors`) |
833
+ | `errors` | Attempts that failed before being scored |
834
+ | `blockRate` | `blocked / scoredAttempts` |
835
+ | `stepUpRate` | `stepUp / scoredAttempts` |
836
+ | `errorRate` | `errors / attempts` |
837
+
838
+ A run with a non-zero `errorRate` also emits a `warning` event saying how much
839
+ of it failed. Treat a high `errorRate` as an invalid run, not a bad score.
840
+
841
+ ---
842
+
843
+ ## Unknown options are rejected
844
+
845
+ Each scenario's option surface is exactly its `defaultOptions`. Passing a key it
846
+ does not declare throws:
847
+
848
+ ```js
849
+ await run('credential-stuffing', {
850
+ target,
851
+ options: { targetUserIds, attemptsPerIP: 25 }, // note the capital P
852
+ });
853
+ // Error: Unknown option for scenario "credential-stuffing": "attemptsPerIP"
854
+ // (did you mean "attemptsPerIp"?). Valid options: attackerCount,
855
+ // attemptsPerIp, delayMs, ipPersona, maxTotalAttempts, targetUserId,
856
+ // targetUserIds.
857
+ ```
858
+
859
+ Silently falling back to the default would produce a run that completes, looks
860
+ healthy, and answers a different question than the one you asked — the same
861
+ failure mode as an under-calibrated attack, just self-inflicted. Custom
862
+ scenarios registered without `defaultOptions` skip this check.
334
863
 
335
864
  ---
336
865
 
@@ -340,49 +869,76 @@ A scenario is a single file. Here's a minimal one:
340
869
 
341
870
  ```js
342
871
  // my-custom-scenario.js
343
- import { faker } from '@faker-js/faker';
872
+ import {
873
+ sleep,
874
+ newResults,
875
+ scoreAndCount,
876
+ buildLoginPayload,
877
+ randomIp,
878
+ alphanumeric,
879
+ } from 'fraud-sim/utils';
344
880
 
345
881
  export default {
346
882
  id: 'my-custom-attack',
347
883
  name: 'Account enumeration',
348
- description: 'Tries to enumerate valid user accounts by varying the email',
884
+ description: 'Tries to enumerate valid accounts by varying the user, not the attacker',
349
885
 
886
+ // The option surface. Anything not listed here is rejected at run time, so
887
+ // a typo fails loudly instead of silently falling back to a default.
350
888
  defaultOptions: {
351
889
  attemptCount: 100,
352
890
  delayMs: 100,
353
891
  },
354
892
 
355
- async run({ options, target, emit, signal }) {
356
- const opts = { ...this.defaultOptions, ...options };
357
- const results = { attempts: 0, blocked: 0, stepUp: 0, allowed: 0 };
358
-
359
- for (let i = 0; i < opts.attemptCount && !signal.aborted; i++) {
360
- const payload = {
361
- user_id: faker.internet.email(), // varying the user, not the attacker
362
- ip_address: '192.168.1.100', // same IP — this is the key signal
363
- device_fingerprint: 'enum-bot-001',
364
- timestamp: new Date().toISOString(),
365
- metadata: { _simulated: true },
366
- };
367
-
368
- emit({ type: 'attempt', round: i, payload, timestamp: Date.now() });
369
- const t0 = Date.now();
370
- const response = await target.scoreLogin(payload);
371
- emit({ type: 'response', round: i, response, latencyMs: Date.now() - t0, timestamp: Date.now() });
372
-
373
- results.attempts++;
374
- if (response.decision === 'BLOCK') results.blocked++;
375
- if (response.decision === 'STEP_UP') results.stepUp++;
376
- if (response.decision === 'ALLOW') results.allowed++;
377
-
378
- await new Promise(r => setTimeout(r, opts.delayMs));
893
+ async run(ctx) {
894
+ const { options, emit, signal } = ctx;
895
+ const results = newResults({ usersProbed: 0 });
896
+
897
+ // One attacker, one device — the constants are the signal here.
898
+ const attackerIp = randomIp();
899
+ const device = `enum-bot-${alphanumeric(8)}`;
900
+
901
+ emit({
902
+ type: 'milestone',
903
+ message: `Enumerating ${options.attemptCount} accounts from ${attackerIp}`,
904
+ timestamp: Date.now(),
905
+ });
906
+
907
+ for (let i = 0; i < options.attemptCount && !signal.aborted; i++) {
908
+ // scoreAndCount emits the attempt/response pair, updates the tally, and
909
+ // reports a missing adapter method clearly.
910
+ await scoreAndCount({
911
+ ctx,
912
+ method: 'scoreLogin',
913
+ payload: buildLoginPayload(`probe_${alphanumeric(10)}@example.invalid`, {
914
+ ip_address: attackerIp,
915
+ device_fingerprint: device,
916
+ }),
917
+ results,
918
+ });
919
+
920
+ results.usersProbed++;
921
+
922
+ try {
923
+ await sleep(options.delayMs, signal);
924
+ } catch {
925
+ break; // aborted
926
+ }
379
927
  }
380
928
 
381
- return { scenarioId: this.id, ...results, blockRate: results.blocked / results.attempts };
929
+ // The runner adds scenarioId, blockRate, errorRate and timings.
930
+ return results;
382
931
  },
383
932
  };
384
933
  ```
385
934
 
935
+ Two things the helpers buy you. `randomIp()` avoids private, loopback and
936
+ documentation ranges — a hardcoded `192.168.1.100` resolves at no IP-intel
937
+ provider, so the target falls back to neutral values and its geo and reputation
938
+ rules never fire, making your attack look harmless for the wrong reason. And
939
+ because every generator draws from the run's random source, your scenario
940
+ replays under `seed` like the built-in ones do.
941
+
386
942
  Register it and run it:
387
943
 
388
944
  ```js
@@ -393,7 +949,7 @@ registerScenario(myScenario);
393
949
  await run('my-custom-attack', { options: { attemptCount: 200 }, target });
394
950
  ```
395
951
 
396
- Full guide: [`docs/EXTENDING.md`](docs/EXTENDING.md).
952
+ The built-in scenarios in `src/scenarios/` are the reference — `slow-drain` is the simplest one that uses a simulated clock, and `mule-network` the simplest multi-stage one.
397
953
 
398
954
  ---
399
955
 
@@ -420,7 +976,7 @@ This library generates traffic that looks like fraud, against systems that detec
420
976
  - Do not use it as part of an actual attack on any system. The library is explicitly designed for testing your own defences and for vendors testing their own products.
421
977
  - Do not use it to evade or attack real fintech production systems, including ones you have customer accounts with. Customer terms of service prohibit this.
422
978
 
423
- Default rate limits in the library (modest attack counts, 200ms delays between actions) are deliberate. They are not aggressive enough to support real attacks at scale, and aggressive configurations require explicit setup. This is a feature, not a limitation.
979
+ The library caps outbound traffic at 25 requests per second by default, enforced in the runner rather than left to each scenario's pacing. That is not aggressive enough to support real attacks at scale, and raising it requires passing `maxRequestsPerSecond` explicitly. This is a feature, not a limitation.
424
980
 
425
981
  If you find a way to use `fraud-sim` that the maintainers should consider out of scope, please open an issue.
426
982
 
@@ -432,13 +988,15 @@ If you find a way to use `fraud-sim` that the maintainers should consider out of
432
988
 
433
989
  | Version | Focus | Status |
434
990
  |---|---|---|
435
- | v0.1 | Core abstractions, 2 scenarios, HTTP adapter | 🚧 In development |
436
- | v0.2 | LLM-driven adaptive attacker, SSE streaming | Planned |
437
- | v0.3 | 7 additional scenarios, CLI runner | Planned |
438
- | v0.4 | Test suites, reporting, benchmarking, CI integration | Planned |
991
+ | v0.1 | Core abstractions, 2 scenarios, HTTP adapter | ✅ Released |
992
+ | v0.2 | LLM-driven adaptive attacker, SSE streaming | ✅ Released |
993
+ | v0.3 | 7 additional scenarios (library complete), seeded runs, rate limiting, TypeScript declarations | Shipped in v0.5 |
994
+ | v0.4 | CLI runner, reporting (HTML/JUnit/JSON), latency percentiles, suite concurrency | Shipped in v0.5 |
995
+ | v0.5 | Legitimate customer traffic and false-positive reporting, markets, hardened practice target | ✅ Current |
996
+ | v0.6 | Config-file request mapping, `init` and `demo` commands, before/after comparison | Planned |
439
997
  | v1.0 | Community scenario library, governance, stable API | Planned |
440
998
 
441
- Full phase plan in [`docs/ROADMAP.md`](docs/ROADMAP.md).
999
+ See [`CHANGELOG.md`](CHANGELOG.md) for what landed in each release.
442
1000
 
443
1001
  ---
444
1002