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
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,20 +105,146 @@ 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
 
110
158
  ---
111
159
 
160
+ ## The command line
161
+
162
+ No JavaScript required — point it at a URL and read the result.
163
+
164
+ ```bash
165
+ npx fraud-sim list
166
+
167
+ npx fraud-sim suite --all \
168
+ --target-url https://staging.example.com \
169
+ --header "Authorization: Bearer $API_KEY" \
170
+ --seed 42 \
171
+ --report assessment.html
172
+ ```
173
+
174
+ That writes a shareable HTML report and prints a summary. This one is the
175
+ bundled simple practice target (`npm run example:mock-target`), seed 42:
176
+
177
+ ```
178
+ SCENARIO EVENTS BLOCK CHALL ALLOW RATE OUTCOME
179
+ --------------------------------------------------------------------
180
+ credential-stuffing 500 440 51 9 88.0% Contained
181
+ card-testing 64 50 6 8 78.1% Value extracted
182
+ sim-swap-takeover 3 0 1 2 0.0% Never blocked
183
+ ...
184
+
185
+ TOTAL 675 events · 675 scored · 85.0% blocked · 0 errors
186
+ LOSS $606,000 extracted
187
+
188
+ LEGITIMATE TRAFFIC EVENTS BLOCK CHALL ALLOW FALSE+ OUTCOME
189
+ --------------------------------------------------------------------
190
+ legitimate-users 66 16 17 33 24.2% Customers blocked
191
+
192
+ CUSTOMERS 66 events · 24.2% blocked (false positives) · 50.0% challenged or blocked · 11 of 12 customers refused
193
+
194
+ FINDINGS (8)
195
+ [CRITICAL] card-testing — The attack reached money
196
+ [CRITICAL] promo-abuse — The attack reached money
197
+ [HIGH] legitimate-users — Legitimate customers were blocked
198
+ [HIGH] sim-swap-takeover — Nothing was ever blocked
199
+ ...
200
+ ```
201
+
202
+ The simple target looks respectable on attacks and then refuses one customer
203
+ event in four. Most of those blocks cite velocity: that target counts logins by
204
+ arrival time, and the customers' sessions are days apart on the event clock but
205
+ arrive within seconds. The finding says so rather than presenting the figure as
206
+ fact — see [Legitimate traffic](#legitimate-traffic-and-false-positives).
207
+
208
+ `suite --all` synthesises the account identifiers each scenario needs, giving
209
+ every scenario its own block so they do not contaminate each other's per-user
210
+ counters. Override anything with `--option key=value`, or target one scenario
211
+ with `--option <scenario>.key=value`.
212
+
213
+ ### In CI
214
+
215
+ ```bash
216
+ npx fraud-sim suite --all \
217
+ --target-url "$STAGING_URL" \
218
+ --seed 42 \
219
+ --report junit.xml \
220
+ --fail-under 85 \
221
+ --max-false-positives 2
222
+ ```
223
+
224
+ `--fail-under` is the floor on attacks blocked; `--max-false-positives` is the
225
+ ceiling on legitimate customer events blocked. Gate on both — either one alone
226
+ can be passed by tuning the defence to the extreme.
227
+
228
+ | Exit | Meaning |
229
+ |---|---|
230
+ | 0 | Ran, gate passed |
231
+ | 1 | Ran, gate failed — an attack succeeded, the block rate was below the floor, or the false-positive rate was above the ceiling |
232
+ | 2 | Usage error |
233
+ | 3 | The run failed, or the target was too unreliable to measure |
234
+
235
+ The 1/3 split is the point: a weak defence and an unreachable staging box are
236
+ different problems, and a pipeline that reports them identically sends people to
237
+ debug the wrong one. An attack that reaches its objective fails the run whether
238
+ or not you set a threshold.
239
+
240
+ > **Windows / Git Bash:** MSYS shells rewrite arguments that look like absolute
241
+ > Unix paths, so `--endpoint-login /score/login` becomes a filesystem path and
242
+ > every request 404s. Prefix the command with `MSYS_NO_PATHCONV=1`, or write the
243
+ > path as `//score/login`. fraud-sim detects this and says so rather than
244
+ > reporting your target as broken.
245
+
246
+ ---
247
+
112
248
  ## Quickstart — 5 minutes from zero to your first simulation
113
249
 
114
250
  ### 1. Set up a target
@@ -138,7 +274,10 @@ const target = httpAdapter({
138
274
  });
139
275
 
140
276
  const report = await run('credential-stuffing', {
141
- options: { targetUserId: 'test_user_001', attackerCount: 10 },
277
+ options: {
278
+ targetUserIds: Array.from({ length: 12 }, (_, i) => `test_user_${i}`),
279
+ attackerCount: 10,
280
+ },
142
281
  target,
143
282
  onEvent: (event) => {
144
283
  if (event.type === 'response') {
@@ -205,25 +344,86 @@ const llm = deterministic({ seed: 42 });
205
344
 
206
345
  ## The scenarios
207
346
 
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 |
347
+ Ten attack scenarios and one legitimate one are registered and runnable.
348
+ `listScenarios()` returns the live list at runtime, each entry marked
349
+ `traffic: 'attack'` or `traffic: 'legitimate'`.
350
+
351
+ | ID | Needs | What it simulates |
352
+ |---|---|---|
353
+ | `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 |
354
+ | `credential-stuffing` | login | A botnet replays a stolen credential dump across many IPs and many accounts |
355
+ | `account-drain` | login, withdrawal | Attacker logs in from a new device, changes the password, and rapidly drains funds to new payees |
356
+ | `adaptive-attacker` | login | An LLM-driven attacker that observes blocks and reason codes, then adapts its strategy |
357
+ | `card-testing` | action | BIN attacks and micro-authorisation probing, escalating to real charges on validated cards |
358
+ | `promo-abuse` | action | One actor creating many accounts to farm signup bonuses, reusing devices and subnets |
359
+ | `mule-network` | withdrawal | Fan-out to mule accounts, then fan-in to one destination — laundering by layering |
360
+ | `slow-drain` | withdrawal | Withdrawals individually below every short-window threshold, spread over days |
361
+ | `session-hijack` | login, action, withdrawal | A stolen session replayed from a different device and network |
362
+ | `sim-swap-takeover` | login, action, withdrawal | Number ported, OTP redirected, password reset, login, immediate withdrawal |
363
+ | `velocity-evasion` | login | An attacker pacing attempts to stay just under the rate limits |
364
+
365
+ The **Needs** column is the target methods each scenario calls: `login` is
366
+ `scoreLogin`, `withdrawal` is `scoreWithdrawal`, `action` is `scoreAction`. A
367
+ scenario throws a clear error if the adapter does not implement what it needs,
368
+ so a target that only scores logins and withdrawals can still run seven of the
369
+ ten attacks, and the legitimate traffic.
370
+
371
+ Four scenarios (`slow-drain`, `velocity-evasion`, `mule-network`, and the timing
372
+ stages of `sim-swap-takeover`) advance the payload `timestamp` instead of
373
+ sleeping for hours, and set `simulatedTime: true` on the report. That is
374
+ faithful only if your system derives its velocity windows from the event
375
+ timestamp — if it stamps arrival time server-side, those runs measure a burst
376
+ rather than the attack they describe.
219
377
 
220
378
  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
379
 
380
+ ### Legitimate traffic and false positives
381
+
382
+ A block rate on its own is easy to win: block everything and it reads 100%.
383
+ `legitimate-users` is the other half of the measurement. It plays ordinary
384
+ customers, each with one device, one browser, their own home address in a
385
+ domestic city and a couple of payees they already pay. A block here is a real
386
+ customer refused; a step-up is friction.
387
+
388
+ ```js
389
+ const report = await run('legitimate-users', {
390
+ target,
391
+ options: { targetUserIds: customerIds }, // one id per customer
392
+ seed: 42,
393
+ });
394
+
395
+ report.falsePositiveRate; // blocked / scored customer events
396
+ report.frictionRate; // (blocked + challenged) / scored
397
+ report.customersBlocked; // customers refused at least once
398
+ report.valueBlockedMinor; // customers' own payments refused
399
+ report.byStage; // routine-login, new-device-login, travel-login,
400
+ // known-payee-withdrawal, new-payee-withdrawal
401
+ ```
402
+
403
+ How it stays honest:
404
+
405
+ - **Warm-up first.** Each customer logs in `warmupLogins` times before anything
406
+ is measured, because a real customer is not a stranger to your system. Warm-up
407
+ decisions are reported under `warmup` and excluded from the rates.
408
+ - **Own addresses.** Every customer gets a distinct home IP, so a users-per-IP
409
+ rule cannot fire on traffic the simulator crowded onto one address.
410
+ - **Days, not seconds.** Sessions are `sessionGapHours` apart on the event
411
+ clock. That is faithful only if your engine windows on the payload
412
+ `timestamp`. If it counts arrivals, it sees a burst, and the finding flags any
413
+ velocity reason among the blocks as possible compression rather than behaviour.
414
+ - **Never mixed with attacks.** Suite totals, block rates and `--fail-under`
415
+ cover attack scenarios only. Customer traffic is summarised separately under
416
+ `totals.legitimate` in every report format.
417
+
418
+ Tune the mix with `newDeviceRate`, `travelRate`, `withdrawalRate`,
419
+ `newPayeeRate` and `amountMinor`. `suite --all` runs it first, before any attack
420
+ has burned shared state on the target.
421
+
222
422
  List scenarios programmatically:
223
423
 
224
424
  ```js
225
425
  import { listScenarios } from 'fraud-sim';
226
- const scenarios = await listScenarios();
426
+ const scenarios = listScenarios(); // synchronous
227
427
  // → [{ id: 'credential-stuffing', name: '...', description: '...', defaultOptions: {...} }, ...]
228
428
  ```
229
429
 
@@ -255,6 +455,78 @@ npx fraud-sim compare \
255
455
 
256
456
  ---
257
457
 
458
+ ## Calibrating a run
459
+
460
+ A simulation only tells you something if the attack actually crosses the
461
+ thresholds the target is configured with. Two failure modes look identical in a
462
+ report and mean opposite things:
463
+
464
+ - **The attack was caught.** Low allow rate, several distinct reason codes.
465
+ - **The attack was too quiet to catch.** Also a low block rate — but the
466
+ defence never had anything to fire on.
467
+
468
+ Three settings decide which one you get.
469
+
470
+ **`targetUserIds` — how many accounts the dump covers.** Credential stuffing is
471
+ a list of victims, not one victim retried. Engines commonly ship a "one IP,
472
+ many users" rule with a limit near 10 accounts; pass fewer than that and the
473
+ rule cannot fire. The scenario emits a `milestone` warning if you pass a single
474
+ account.
475
+
476
+ **`attemptsPerIp` — how hard each IP pushes.** Per-IP velocity limits commonly
477
+ sit near 20 attempts per 5 minutes. The default of 25 clears that; the previous
478
+ default of 3 did not, so the rule stayed silent on every run.
479
+
480
+ **`ipPersona` — whether geo and reputation rules are reachable at all.** This is
481
+ the subtle one. Most fraud systems resolve IP intelligence *asynchronously* and
482
+ fall back to neutral values on a cache miss. In a short run every IP is seen for
483
+ the first time, so proxy, abuse-score, new-country, and impossible-travel rules
484
+ all score zero — often four to six of the target's rules, silently disabled.
485
+
486
+ ```js
487
+ import { DEMO_IP_POOL } from 'fraud-sim';
488
+
489
+ // Before the run: seed the target's IP-intel cache from the pool.
490
+ for (const entry of DEMO_IP_POOL) {
491
+ await yourSystem.cacheIpIntel(entry.ip, entry);
492
+ }
493
+
494
+ const report = await run('credential-stuffing', {
495
+ target,
496
+ options: { targetUserIds, ipPersona: 'mixed' },
497
+ });
498
+ ```
499
+
500
+ `DEMO_IP_POOL` is a fixed set of addresses with declared geolocation and
501
+ reputation, drawn from the RFC 5737 documentation ranges so a simulation never
502
+ attributes attack traffic to a real network operator. Because it is fixed and
503
+ its metadata is published, the system under test can pre-warm from it — no live
504
+ lookups, no third-party rate limits mid-demo, and reproducible results.
505
+
506
+ | `ipPersona` | Draws from | Use when |
507
+ |---|---|---|
508
+ | `random` (default) | Generated public IPv4, no metadata | Pointing at a system that does its own live IP lookups |
509
+ | `mixed` | Hosting, proxy, foreign and domestic pool entries | Demos and CI — the realistic botnet composition |
510
+ | `domestic` | In-country residential | Isolating velocity rules from geo rules |
511
+ | `hosting` / `proxy` / `foreign` | That persona only | Probing one rule family at a time |
512
+
513
+ Note that `random` deliberately excludes private, loopback, CGNAT, link-local
514
+ and documentation ranges. Addresses in those blocks resolve at no IP-intel
515
+ provider, which produces the silent-neutral-fallback problem above.
516
+
517
+ Scenario events carry the intel alongside each attempt when a pool persona is
518
+ used, so a live dashboard can show *why* an IP is suspicious:
519
+
520
+ ```js
521
+ onEvent: (e) => {
522
+ if (e.type === 'attempt' && e.ipIntel) {
523
+ console.log(e.payload.ip_address, e.ipIntel.countryCode, e.ipIntel.abuseScore);
524
+ }
525
+ }
526
+ ```
527
+
528
+ ---
529
+
258
530
  ## The adaptive attacker — how it works
259
531
 
260
532
  The `adaptive-attacker` scenario is `fraud-sim`'s headline feature, and the part that's worth understanding before using it.
@@ -275,62 +547,301 @@ Round 3: LLM reasons: "IP is flagged as a proxy. Switching to a residential
275
547
  Attacker tries with a residential-looking IP.
276
548
  Target responds: BLOCK — reasons: ["LOGIN_VELOCITY_USER"]
277
549
 
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.
550
+ Round 4: LLM reasons: "Velocity threshold hit. I'll rotate the IP and keep
551
+ the device that was only challenged."
280
552
  Target responds: BLOCK — reasons: ["IP_MANY_USERS"]
281
553
 
282
554
  ...
283
555
  ```
284
556
 
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.
557
+ **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.
558
+
559
+ **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
560
 
287
561
  **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
562
 
289
563
  **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
564
 
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).
565
+ 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
566
 
293
567
  ---
294
568
 
295
569
  ## Benchmarking and reporting
296
570
 
297
- Run a suite of scenarios and generate a shareable report:
571
+ Run several scenarios in one pass and get a per-scenario breakdown plus an
572
+ aggregate across all of them:
298
573
 
299
574
  ```js
300
- import { runSuite, generateReport } from 'fraud-sim';
301
- import fs from 'node:fs';
575
+ import { runSuite } from 'fraud-sim';
302
576
 
303
577
  const result = await runSuite({
304
578
  name: 'monthly-benchmark',
305
579
  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 },
580
+ { id: 'legitimate-users', options: { targetUserIds: customerIds } },
581
+ { id: 'credential-stuffing', options: { targetUserIds, attackerCount: 20 } },
582
+ { id: 'account-drain', options: { targetUserId: 'victim_001', withdrawalCount: 10 } },
583
+ { id: 'adaptive-attacker', options: { targetUserId: 'victim_001', maxRounds: 15 } },
310
584
  ],
311
585
  target,
312
586
  });
313
587
 
314
- const html = await generateReport(result, { format: 'html' });
315
- fs.writeFileSync('benchmark-report.html', html);
588
+ console.log(result.aggregate);
589
+ // → { attempts, blocked, stepUp, allowed, blockRate, stepUpRate,
590
+ // legitimate: { falsePositiveRate, frictionRate, customersBlocked, ... } }
591
+ // Attack figures cover attack scenarios only; `legitimate` is null when the
592
+ // suite included no legitimate traffic.
593
+
594
+ for (const r of result.scenarioReports) {
595
+ console.log(r.scenarioId, r.blockRate);
596
+ }
316
597
  ```
317
598
 
318
- The HTML report includes:
599
+ Because the aggregate is a plain object, gating CI on it needs no extra
600
+ tooling — assert on the block rate and fail the build when a threshold change
601
+ makes the defence worse. Check `errorRate` first, or a target that was simply
602
+ unreachable will sail through the gate:
319
603
 
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
604
+ ```js
605
+ const { blockRate, errorRate, scoredAttempts } = result.aggregate;
325
606
 
326
- For CI integration, generate JUnit XML and feed it to GitHub Actions, GitLab CI, or Jenkins:
607
+ if (errorRate > 0.05) {
608
+ throw new Error(
609
+ `${(errorRate * 100).toFixed(1)}% of attempts never got a decision — ` +
610
+ 'the target was unhealthy, so this run says nothing about the defence.'
611
+ );
612
+ }
613
+
614
+ if (blockRate < 0.85) {
615
+ throw new Error(`Block rate regressed to ${blockRate} over ${scoredAttempts} scored attempts`);
616
+ }
617
+
618
+ if (result.aggregate.legitimate?.falsePositiveRate > 0.02) {
619
+ throw new Error('More than 2% of ordinary customer activity is being blocked');
620
+ }
621
+ ```
622
+
623
+ ### Reports
624
+
625
+ ```js
626
+ import { generateReport } from 'fraud-sim/reporting';
627
+ import { writeFileSync } from 'node:fs';
628
+
629
+ writeFileSync('report.html', generateReport(result, { format: 'html' }));
630
+ writeFileSync('junit.xml', generateReport(result, { format: 'junit' }));
631
+ ```
632
+
633
+ Four formats: `html` (a standalone, printable document), `junit` (for CI),
634
+ `json` (the structured summary), and `text` (the terminal table).
635
+
636
+ Every report carries latency percentiles, reason-code frequency, and findings
637
+ ranked by what they cost. Two mappings are worth knowing:
638
+
639
+ - **JUnit is scored from the defender's side.** A blocked attack is a *pass*. A
640
+ failure means the attack succeeded or nothing was blocked. An unreliable
641
+ target is an `error`, not a failure. A timing-dependent result is `skipped`,
642
+ never a pass — a green pipeline should not be achievable by breaking the
643
+ target.
644
+ - **Reports never invent causes.** Findings state what the data supports and
645
+ stop there.
646
+
647
+ ### Markets
648
+
649
+ A fraud engine is fed by data that looks like its market. Account numbers of the
650
+ wrong length, banks that do not exist and mis-formatted phone numbers get
651
+ rejected as malformed *before* they are ever scored — and a run full of
652
+ validation errors measures nothing at all.
653
+
654
+ So the market is a parameter:
655
+
656
+ ```js
657
+ await run('account-drain', { target, locale: 'GB', options });
658
+ ```
659
+
660
+ ```bash
661
+ npx fraud-sim suite --all --target-url "$URL" --locale GB
662
+ ```
663
+
664
+ | | `NG` | `US` | `GB` | `KE` |
665
+ |---|---|---|---|---|
666
+ | Currency | NGN | USD | GBP | KES |
667
+ | Account number | 10 digits | 8–12 digits | 8 digits | 12 digits |
668
+ | Phone | `0803…` | `+1…` | `+447…` | `+2547…` |
669
+ | Banks | GTBank, UBA… | Chase, Wells Fargo… | Barclays, Monzo… | Equity, KCB… |
670
+
671
+ Defaults to `US`. An unknown code throws rather than falling back, because
672
+ silently generating US-shaped data for a run that asked for `DE` produces a
673
+ result that looks valid and measures the wrong market.
674
+
675
+ The market also decides which country counts as **domestic** in `DEMO_IP_POOL`.
676
+ An address in Lagos is the quiet baseline for a Nigerian bank and a new-country
677
+ signal for a British one, so a fixed pool would mean opposite things depending
678
+ on who you pointed it at. Use `demoIpPool()` to get the pool for the market in
679
+ scope.
680
+
681
+ Adding a market is a pull request against `src/locales/index.js` — same fields,
682
+ synthetic data, real formats. The bundled packs are a small plausible sample
683
+ chosen so payloads pass format validation; they are not a directory, and no real
684
+ customer, account or number is represented.
685
+
686
+ ### Currency
687
+
688
+ Every amount in `fraud-sim` is an integer in the currency's **minor unit** —
689
+ kobo, cents, the same thing structurally. So a report renders in any currency
690
+ without touching a number:
691
+
692
+ ```js
693
+ generateReport(result, { format: 'html', currency: 'USD' });
694
+ ```
695
+
696
+ ```bash
697
+ npx fraud-sim suite --all --target-url "$URL" --currency USD
698
+ ```
699
+
700
+ Defaults to `NGN`. **No conversion is applied** — the integers are unchanged and
701
+ only the symbol and grouping differ. That is deliberate: a report that silently
702
+ applied an FX rate would be quietly wrong the day the rate moved, and the tool
703
+ has no business holding an opinion about exchange rates. If you need a converted
704
+ figure, convert it before you generate the report.
705
+
706
+ The minor-unit exponent comes from `Intl`, so zero-decimal currencies are not
707
+ divided by a hundred: the same integer that reads `₦606,000` reads `¥60,600,000`.
708
+
709
+ Reports default to the market's own currency, so `--locale GB` reports in GBP
710
+ without being told twice.
711
+
712
+ ### Running scenarios in parallel
327
713
 
328
714
  ```js
329
- const xml = await generateReport(result, { format: 'junit' });
330
- fs.writeFileSync('fraud-sim-results.xml', xml);
715
+ const result = await runSuite({ name: 'nightly', target, scenarios, concurrency: 4 });
331
716
  ```
332
717
 
333
- A worked GitHub Actions example lives in [`examples/ci-integration-github-actions/`](examples/ci-integration-github-actions/).
718
+ The request ceiling is shared across the whole suite, so concurrency speeds a
719
+ run up without multiplying the load. Note that scenarios sharing a target
720
+ interleave their traffic, and most engines keep per-user and per-IP state — a
721
+ parallel suite measures a *combined* attack rather than each attack
722
+ independently. Give each scenario distinct users if you want both speed and
723
+ isolation.
724
+
725
+ ---
726
+
727
+ ## Reproducible runs
728
+
729
+ Pass a `seed` and the run replays choice for choice — same IPs, same devices,
730
+ same amounts, same ordering:
731
+
732
+ ```js
733
+ const report = await run('credential-stuffing', {
734
+ target,
735
+ options: { targetUserIds },
736
+ seed: 42,
737
+ });
738
+
739
+ console.log(report.seed); // 42
740
+ ```
741
+
742
+ Two runs with the same seed produce identical payloads. Different seeds produce
743
+ different ones. An unseeded run has no `seed` field on its report, so a report
744
+ never implies reproducibility it does not have.
745
+
746
+ This matters in two places. In CI, an unseeded block rate drifts between builds
747
+ for reasons unrelated to your rules, so a threshold gate is either too loose to
748
+ catch regressions or flaky enough to be ignored. And when an attack does get
749
+ through, a seed is the difference between "something got past us" and a run you
750
+ can hand someone and replay exactly.
751
+
752
+ Seeding controls generated values, **not wall-clock time** — payload timestamps
753
+ still come from the real clock (or a scenario's simulated one), so two seeded
754
+ runs are identical in shape rather than byte-identical.
755
+
756
+ `runSuite` takes a seed too, deriving a distinct one per scenario so the suite
757
+ is reproducible as a whole without every scenario replaying the same values.
758
+
759
+ Custom scenarios get this for free as long as they draw randomness from
760
+ `fraud-sim/utils` rather than calling `Math.random()` directly.
761
+
762
+ ---
763
+
764
+ ## Rate limiting
765
+
766
+ Every run is capped at **25 outbound requests per second** by default, enforced
767
+ by wrapping the target adapter — so the ceiling holds for custom scenarios too,
768
+ not just the built-in ones. The shipped scenarios pace themselves well under it;
769
+ the cap exists so that a `delayMs: 0` typo against a real host stays a
770
+ simulation.
771
+
772
+ ```js
773
+ await run('credential-stuffing', {
774
+ target,
775
+ options: { targetUserIds },
776
+ maxRequestsPerSecond: 50, // raise it deliberately
777
+ // maxRequestsPerSecond: Infinity, // or disable it entirely
778
+ });
779
+ ```
780
+
781
+ If the cap actually held a run back, it emits a `warning` event saying by how
782
+ much — worth noticing before reading anything into that run's latency numbers.
783
+
784
+ ---
785
+
786
+ ## When the target misbehaves
787
+
788
+ A simulation is only a measurement if the target actually answered. `fraud-sim`
789
+ separates the two cases.
790
+
791
+ **Non-2xx responses are errors, not blocks.** If a `429` or a `503` were counted
792
+ as a `BLOCK`, then rate-limiting the simulator — or the target falling over
793
+ under load — would *raise* its measured block rate. A benchmark that rewards a
794
+ target for breaking is measuring the opposite of what it claims. So HTTP
795
+ failures come back as `decision: 'ERROR'` with `infraError: true` and a reason
796
+ code naming the cause (`RATE_LIMITED`, `SERVER_ERROR`, `AUTH_ERROR`,
797
+ `ENDPOINT_NOT_FOUND`, `NETWORK_ERROR`).
798
+
799
+ If your API genuinely signals a block with an HTTP status, say so explicitly:
800
+
801
+ ```js
802
+ const target = httpAdapter({
803
+ baseUrl: 'https://api.example.com',
804
+ blockOnStatus: [403], // this API returns 403 for a hard block
805
+ });
806
+ ```
807
+
808
+ **Rates are computed over attempts that were actually scored.** Every report
809
+ carries:
810
+
811
+ | Field | Meaning |
812
+ |---|---|
813
+ | `attempts` | Everything the scenario tried |
814
+ | `scoredAttempts` | Attempts that came back with a real decision (`attempts - errors`) |
815
+ | `errors` | Attempts that failed before being scored |
816
+ | `blockRate` | `blocked / scoredAttempts` |
817
+ | `stepUpRate` | `stepUp / scoredAttempts` |
818
+ | `errorRate` | `errors / attempts` |
819
+
820
+ A run with a non-zero `errorRate` also emits a `warning` event saying how much
821
+ of it failed. Treat a high `errorRate` as an invalid run, not a bad score.
822
+
823
+ ---
824
+
825
+ ## Unknown options are rejected
826
+
827
+ Each scenario's option surface is exactly its `defaultOptions`. Passing a key it
828
+ does not declare throws:
829
+
830
+ ```js
831
+ await run('credential-stuffing', {
832
+ target,
833
+ options: { targetUserIds, attemptsPerIP: 25 }, // note the capital P
834
+ });
835
+ // Error: Unknown option for scenario "credential-stuffing": "attemptsPerIP"
836
+ // (did you mean "attemptsPerIp"?). Valid options: attackerCount,
837
+ // attemptsPerIp, delayMs, ipPersona, maxTotalAttempts, targetUserId,
838
+ // targetUserIds.
839
+ ```
840
+
841
+ Silently falling back to the default would produce a run that completes, looks
842
+ healthy, and answers a different question than the one you asked — the same
843
+ failure mode as an under-calibrated attack, just self-inflicted. Custom
844
+ scenarios registered without `defaultOptions` skip this check.
334
845
 
335
846
  ---
336
847
 
@@ -340,49 +851,76 @@ A scenario is a single file. Here's a minimal one:
340
851
 
341
852
  ```js
342
853
  // my-custom-scenario.js
343
- import { faker } from '@faker-js/faker';
854
+ import {
855
+ sleep,
856
+ newResults,
857
+ scoreAndCount,
858
+ buildLoginPayload,
859
+ randomIp,
860
+ alphanumeric,
861
+ } from 'fraud-sim/utils';
344
862
 
345
863
  export default {
346
864
  id: 'my-custom-attack',
347
865
  name: 'Account enumeration',
348
- description: 'Tries to enumerate valid user accounts by varying the email',
866
+ description: 'Tries to enumerate valid accounts by varying the user, not the attacker',
349
867
 
868
+ // The option surface. Anything not listed here is rejected at run time, so
869
+ // a typo fails loudly instead of silently falling back to a default.
350
870
  defaultOptions: {
351
871
  attemptCount: 100,
352
872
  delayMs: 100,
353
873
  },
354
874
 
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));
875
+ async run(ctx) {
876
+ const { options, emit, signal } = ctx;
877
+ const results = newResults({ usersProbed: 0 });
878
+
879
+ // One attacker, one device — the constants are the signal here.
880
+ const attackerIp = randomIp();
881
+ const device = `enum-bot-${alphanumeric(8)}`;
882
+
883
+ emit({
884
+ type: 'milestone',
885
+ message: `Enumerating ${options.attemptCount} accounts from ${attackerIp}`,
886
+ timestamp: Date.now(),
887
+ });
888
+
889
+ for (let i = 0; i < options.attemptCount && !signal.aborted; i++) {
890
+ // scoreAndCount emits the attempt/response pair, updates the tally, and
891
+ // reports a missing adapter method clearly.
892
+ await scoreAndCount({
893
+ ctx,
894
+ method: 'scoreLogin',
895
+ payload: buildLoginPayload(`probe_${alphanumeric(10)}@example.invalid`, {
896
+ ip_address: attackerIp,
897
+ device_fingerprint: device,
898
+ }),
899
+ results,
900
+ });
901
+
902
+ results.usersProbed++;
903
+
904
+ try {
905
+ await sleep(options.delayMs, signal);
906
+ } catch {
907
+ break; // aborted
908
+ }
379
909
  }
380
910
 
381
- return { scenarioId: this.id, ...results, blockRate: results.blocked / results.attempts };
911
+ // The runner adds scenarioId, blockRate, errorRate and timings.
912
+ return results;
382
913
  },
383
914
  };
384
915
  ```
385
916
 
917
+ Two things the helpers buy you. `randomIp()` avoids private, loopback and
918
+ documentation ranges — a hardcoded `192.168.1.100` resolves at no IP-intel
919
+ provider, so the target falls back to neutral values and its geo and reputation
920
+ rules never fire, making your attack look harmless for the wrong reason. And
921
+ because every generator draws from the run's random source, your scenario
922
+ replays under `seed` like the built-in ones do.
923
+
386
924
  Register it and run it:
387
925
 
388
926
  ```js
@@ -393,7 +931,7 @@ registerScenario(myScenario);
393
931
  await run('my-custom-attack', { options: { attemptCount: 200 }, target });
394
932
  ```
395
933
 
396
- Full guide: [`docs/EXTENDING.md`](docs/EXTENDING.md).
934
+ 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
935
 
398
936
  ---
399
937
 
@@ -420,7 +958,7 @@ This library generates traffic that looks like fraud, against systems that detec
420
958
  - 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
959
  - 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
960
 
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.
961
+ 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
962
 
425
963
  If you find a way to use `fraud-sim` that the maintainers should consider out of scope, please open an issue.
426
964
 
@@ -432,13 +970,15 @@ If you find a way to use `fraud-sim` that the maintainers should consider out of
432
970
 
433
971
  | Version | Focus | Status |
434
972
  |---|---|---|
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 |
973
+ | v0.1 | Core abstractions, 2 scenarios, HTTP adapter | ✅ Released |
974
+ | v0.2 | LLM-driven adaptive attacker, SSE streaming | ✅ Released |
975
+ | v0.3 | 7 additional scenarios (library complete), seeded runs, rate limiting, TypeScript declarations | Shipped in v0.5 |
976
+ | v0.4 | CLI runner, reporting (HTML/JUnit/JSON), latency percentiles, suite concurrency | Shipped in v0.5 |
977
+ | v0.5 | Legitimate customer traffic and false-positive reporting, markets, hardened practice target | ✅ Current |
978
+ | v0.6 | Config-file request mapping, `init` and `demo` commands, before/after comparison | Planned |
439
979
  | v1.0 | Community scenario library, governance, stable API | Planned |
440
980
 
441
- Full phase plan in [`docs/ROADMAP.md`](docs/ROADMAP.md).
981
+ See [`CHANGELOG.md`](CHANGELOG.md) for what landed in each release.
442
982
 
443
983
  ---
444
984