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/CHANGELOG.md CHANGED
@@ -5,6 +5,486 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.5.1] — Cleaner customer-only output, end-to-end walkthrough — 2026-10-16
11
+
12
+ ### Added
13
+
14
+ - **End-to-end walkthrough** (`test/e2e/walkthrough.e2e.js`). Starts both
15
+ practice engines as real HTTP servers and drives the real CLI and library
16
+ against them in six acts: a contained botnet, a defeated adaptive attacker, a
17
+ smarter attacker finding the hardened engine's gap, customers getting
18
+ through, the full suite gated on block rate and false positives, and an
19
+ unreachable target exiting 3. `npm run demo` paces it for a screen recording
20
+ and opens the HTML report; `npm run test:e2e` runs it flat out and is part of
21
+ CI. Every act asserts what it shows.
22
+
23
+ ### Fixed
24
+
25
+ - The terminal report no longer prints an empty attack table and a
26
+ "TOTAL 0 events" line for a run that contained only legitimate traffic.
27
+
28
+ ## [0.5.0] — False positives, markets, a harder target — 2026-10-16
29
+
30
+ The first npm release since 0.2.0. Versions 0.3.0 and 0.4.0 below were tagged
31
+ in this changelog but never published, so everything from them ships here.
32
+
33
+ ### Added — the other half of the measurement
34
+
35
+ - **`legitimate-users` scenario.** Ordinary customers, not an attack: routine
36
+ logins, a new phone, travel, payments to known payees and the occasional new
37
+ one. Every block it receives is a false positive, every step-up is friction.
38
+ Until now a defence that blocked everything scored a perfect run.
39
+
40
+ Built so the simulator does not manufacture the false positives it reports:
41
+ a warm-up gives each customer history before anything is measured (reported
42
+ separately, excluded from the rates); each customer has their own home
43
+ address, so users-per-IP rules cannot fire on crowding the simulator caused;
44
+ sessions are spread across days on the event clock; customers only use
45
+ browser user agents.
46
+ - **Reports keep the two halves apart.** Attack totals, block rates and
47
+ `--fail-under` cover attack scenarios only. Legitimate traffic is summarised
48
+ under `totals.legitimate` (`falsePositiveRate`, `frictionRate`,
49
+ `customersBlocked`, `valueBlockedMinor`), in every format: its own table in
50
+ the terminal, a customer-impact panel in HTML with the bar colours inverted,
51
+ JUnit failures of type `FalsePositives`, and a high-severity finding that
52
+ names the stages and reason codes doing the blocking. When those reasons are
53
+ velocity rules on time-compressed traffic, the finding says the block may be
54
+ the simulator's clock rather than the customer.
55
+ - **`--max-false-positives <rate>`** on the CLI: exit 1 when more than that
56
+ share of customer events was blocked. Refused up front if the run contains no
57
+ legitimate traffic, because the gate would otherwise pass on zero customers.
58
+ - Every report and `listScenarios()` entry now carries
59
+ `traffic: 'attack' | 'legitimate'`. Custom scenarios may declare `traffic`;
60
+ it defaults to `'attack'`.
61
+
62
+ ### Fixed — the adaptive attacker could not replay a device
63
+
64
+ - The history given to the LLM recorded the parameters a strategy *asked for*,
65
+ not the payload actually sent. Round 1 never names a fingerprint, so the model
66
+ saw `null` and could never replay the device the defender had challenged —
67
+ the exact gap the hardened practice target leaves for a smarter attacker.
68
+ History and `strategyHistory` now record the IP, user agent and fingerprint
69
+ that were sent.
70
+ - `parameters` was declared inside the round loop, so a key the strategy
71
+ omitted was silently re-randomised every round instead of kept. Omitting a
72
+ key now means "keep it".
73
+ - The OpenAI and Anthropic prompts now list the parameters the attacker
74
+ controls and say that history shows exactly what was sent.
75
+
76
+ ### Changed
77
+
78
+ - `suite --all` runs `legitimate-users` first, before any attack has burned
79
+ shared state (blocked IPs, blocked devices) on the target.
80
+ - README no longer claims the library benchmarks third-party vendors such as
81
+ Stripe Radar. It exercises the rules behind your own scoring API; synthetic
82
+ payloads say little about a vendor's ML, device SDK or consortium data.
83
+
84
+ ### Added — a target that makes the attacker work
85
+
86
+ - **Hardened example target** (`examples/express-hardened-target`, port 4001)
87
+ and a contrast demo (`npm run example:adaptive-hardened`) that runs the same
88
+ scenario, at the same seed, against both practice targets.
89
+
90
+ The reason it exists: against the simple target the adaptive attacker wins on
91
+ move one and never adapts, so the artifact that is supposed to demonstrate
92
+ adaptation demonstrates nothing. The honest fix was a target worth attacking,
93
+ not a scenario rewritten to look busy — the scenario already adapts correctly
94
+ when something pushes back.
95
+
96
+ Its central rule is that an unrecognised device is never allowed outright,
97
+ only challenged, which is what defeats an attacker whose entire strategy is
98
+ rotation: rotating *creates* the unrecognised device. It also leaves one
99
+ deliberate gap — a device that was merely challenged is remembered as seen,
100
+ not as bad — so a smarter attacker beats it and the shipped deterministic one
101
+ does not. That asymmetry is the point.
102
+
103
+ ### Added — markets (breaking)
104
+
105
+ fraud-sim is a global tool now, not a Nigerian one with global reporting.
106
+
107
+ - **Locale packs** (`src/locales/`) supplying account-number and phone formats,
108
+ bank names, personal names, payload currency, and the cities that count as
109
+ domestic for geo rules. `NG`, `US`, `GB` and `KE` ship; adding one is a pull
110
+ request against a single file.
111
+ - **`locale` option** on `run()` and `runSuite()`, and `--locale` on the CLI.
112
+ Scoped with `AsyncLocalStorage` like the random source, so no generator needed
113
+ a new argument and concurrent runs in different markets stay independent.
114
+ Reports default to the market's own currency.
115
+ - Format is not cosmetic: a target that validates account numbers rejects a
116
+ wrongly-shaped one before scoring it, so a mis-marketed run measures nothing.
117
+ An unknown market code therefore throws instead of falling back.
118
+ - `demoIpPool()` returns the IP pool for the market in scope. "Domestic" is
119
+ relative — Lagos is the quiet baseline for a Nigerian bank and a new-country
120
+ signal for a British one — so a fixed pool meant opposite things per target.
121
+ `DEMO_IP_POOL` remains exported as the default market's snapshot.
122
+
123
+ ### Changed — breaking
124
+
125
+ - **The default market is `US`.** Previously every payload was Nigerian with no
126
+ way to say otherwise. Runs that want the old behaviour should pass
127
+ `locale: 'NG'`.
128
+ - **Amount options renamed** from `*Kobo` to `*Minor`: `amountMinor`,
129
+ `amountJitterMinor`, `amountPerMuleMinor`, `microAmountMinor`,
130
+ `largeAmountMinor`, `bonusMinor`. The values are unchanged — they were always
131
+ minor-unit integers, and kobo and cents are structurally identical. The old
132
+ names still work and emit a deprecation warning, because the option validator
133
+ rejects undeclared keys and a bare rename would have been a hard break.
134
+ - Report fields follow the same rename: `drainedMinor`, `totalMovedMinor`,
135
+ `reachedDestinationMinor`. `estimatedLossKobo` is still accepted and still
136
+ emitted alongside `estimatedLossMinor`.
137
+
138
+ ### Added — report currency
139
+
140
+ - **`currency` and `locale` options** on `generateReport()` and `--currency` /
141
+ `--locale` on the CLI. Amounts were always minor-unit integers — kobo and
142
+ cents are structurally identical — so only presentation was ever bound to
143
+ naira. Nothing is converted: the integers are untouched and only the symbol
144
+ and grouping change, because a report that silently applied an FX rate would
145
+ be wrong the moment the rate moved.
146
+ - The minor-unit exponent is read from `Intl` rather than assumed to be 2, so
147
+ zero-decimal currencies are not divided by a hundred. The same integer that
148
+ reads `₦606,000` correctly reads `¥60,600,000`.
149
+ - `estimatedLossMinor` is now the canonical report field; `estimatedLossKobo`
150
+ keeps working for callers already written against it.
151
+
152
+ ### Fixed
153
+
154
+ - **Seeded runs of `adaptive-attacker` ignored the seed.** The fallback provider
155
+ was constructed with a hardcoded `seed: 42`, so every seeded run produced an
156
+ identical strategy sequence no matter what the caller passed — in the one
157
+ scenario whose value is that the attack varies. The provider seed now derives
158
+ from the run's random source: reproducible under a seed, different across
159
+ seeds.
160
+ - **The deterministic attacker described the wrong signal.** A rotating attacker
161
+ sees `NEW_DEVICE` on every response because rotating is what creates it, and
162
+ device signals were classified ahead of IP ones — so the attacker announced it
163
+ was changing fingerprint while the address was what blocked it. IP is now
164
+ ranked above device, being the part it can actually change for the better.
165
+ - **A step-up was reported as a block** in the attacker's reasoning. That text is
166
+ read by a human in a report, and an attacker that mis-describes what just
167
+ happened does not read as reasoning.
168
+
169
+ ## [0.4.0] — CLI, reporting, concurrency — 2026-08-29
170
+
171
+ ### Added — command line
172
+
173
+ - **`fraud-sim` CLI** (`npx fraud-sim`) with `list`, `run` and `suite`.
174
+ Gates CI without writing JavaScript. Exit codes separate a weak defence (1)
175
+ from an unmeasurable run (3), because a pipeline that reports an unreachable
176
+ staging box as a security failure sends people to debug the wrong system.
177
+ - `suite --all` synthesises the account identifiers each scenario requires,
178
+ giving every scenario a disjoint block so they do not contaminate each other's
179
+ per-user counters. `--option key=value` applies to every scenario that
180
+ declares that key; `--option <scenario>.key=value` targets one.
181
+ - `--fail-under` gates the **overall** block rate; `--fail-under-each` gates
182
+ every scenario individually. The split matters: some scenarios include the
183
+ victim's own legitimate traffic, so a low block rate there is correct, and a
184
+ naive per-scenario floor produces false failures.
185
+ - Distinct error causes are surfaced during a run, deduplicated. Previously a
186
+ broken target reported "100% failed" without saying why.
187
+ - **MSYS path-mangling guard.** Git Bash rewrites `--endpoint-login /score/login`
188
+ into `C:/Program Files/Git/score/login`, so every request 404s and the run
189
+ blames the target. Detected during development of this release; the CLI now
190
+ names the cause and the fix instead.
191
+
192
+ ### Added — reporting
193
+
194
+ - **`generateReport(result, { format })`** in `fraud-sim/reporting`, with
195
+ `html`, `junit`, `json` and `text` formats, plus `summarise()` for asserting
196
+ on a run without parsing a document.
197
+ - The HTML report is a standalone, printable document: findings ranked by cost,
198
+ per-scenario decision bars, reason-code frequency, latency, and a limits
199
+ section stating what the run cannot tell you. Decision colours are fixed
200
+ status hues with the count always beside the bar, so colour is never the sole
201
+ carrier.
202
+ - **JUnit is scored from the defender's side.** A blocked attack passes; a
203
+ failure means the attack succeeded or nothing was blocked; an unreliable
204
+ target is an `error`; a timing-dependent result is `skipped`, never a pass.
205
+ - Every report reads its numbers from one aggregation module, so no two formats
206
+ can disagree. Totals are recomputed from counts rather than trusting a stored
207
+ rate.
208
+
209
+ ### Added — concurrency
210
+
211
+ - **`runSuite({ concurrency })`** runs scenarios in parallel, preserving input
212
+ order in the results. The rate ceiling is shared across the whole suite: a
213
+ limiter per run would have let the effective ceiling scale with the
214
+ concurrency, which is the opposite of what a ceiling is for. Seeding stays
215
+ correct under parallelism because the random source is scoped with
216
+ `AsyncLocalStorage` rather than a module global.
217
+ - Sequential remains the default, and the docs say why: scenarios sharing a
218
+ target interleave, so a parallel suite measures a combined attack rather than
219
+ each attack independently.
220
+
221
+ ### Added — metrics
222
+
223
+ - Every report now carries **latency percentiles** (nearest-rank, so each figure
224
+ is a latency that actually occurred) and **reason-code frequency**, collected
225
+ by the runner off the event stream so no scenario can forget to report them.
226
+
227
+ ### Changed
228
+
229
+ - A scenario where nothing was blocked is now classified as "never blocked" even
230
+ when it also carries a timing caveat — filing that as a methodology footnote
231
+ buried the headline.
232
+
233
+ ### Tests
234
+
235
+ - 31 new (180 total): outcome ranking, aggregation invariants, HTML and XML
236
+ escaping of untrusted scenario ids and reason codes, the JUnit state mapping,
237
+ gate scoping, concurrency overlap, order preservation, seeded reproducibility
238
+ under concurrency, and a check that the shared ceiling holds at 1× and 4×
239
+ concurrency.
240
+
241
+ ## [0.3.0] — Complete scenario library, reproducible runs — 2026-08-29
242
+
243
+ Ten scenarios, seeded replay, an enforced rate ceiling, bundled TypeScript
244
+ declarations, and two fixes to how results are measured. **Read the breaking
245
+ notes before upgrading:** HTTP failures no longer count as blocks, unknown
246
+ scenario options are now rejected, and several attack defaults were retuned to
247
+ actually cross common rule thresholds.
248
+
249
+ ### Added — TypeScript declarations
250
+
251
+ - **Types ship with the package.** Declarations are generated from the existing
252
+ JSDoc by `npm run build:types` and wired into every export subpath, so
253
+ `fraud-sim`, `/adapters`, `/llm`, `/streaming` and `/utils` all resolve with
254
+ full autocomplete. No `@types` package needed.
255
+ - **`src/types.js`** defines the public shapes (`ScenarioReport`,
256
+ `TargetAdapter`, `ScenarioEvent`, `Scenario`, `SuiteReport` and friends) as
257
+ JSDoc typedefs. These names were already referenced throughout the codebase
258
+ but never defined, so the generated declarations previously failed to compile
259
+ with four `TS2304: Cannot find name` errors.
260
+ - **`prepublishOnly`** rebuilds declarations and runs lint plus the full test
261
+ suite, so a release cannot ship stale types or a failing suite.
262
+
263
+ ### Fixed
264
+
265
+ - **The README claimed CommonJS support the package does not have.** It is
266
+ ESM-only; `require('fraud-sim')` throws `ERR_REQUIRE_ESM`. The install
267
+ section now says so and documents the dynamic-import workaround.
268
+
269
+
270
+ ### Added — reproducible runs
271
+
272
+ - **`seed` option on `run()` and `runSuite()`.** A seeded run replays choice
273
+ for choice: same IPs, devices, amounts and ordering. Previously `rng.js`
274
+ existed but was wired only into the deterministic LLM provider, while every
275
+ payload value came from `Math.random()` — so the "reproducible runs" this
276
+ changelog already claimed were not achievable. All randomness now flows
277
+ through three primitives (`randomInt`, `pickOne`, `alphanumeric`) drawing on
278
+ a run-scoped source.
279
+
280
+ Seeding controls generated values, not wall-clock time; payload timestamps
281
+ still come from the real clock, so seeded runs are identical in shape rather
282
+ than byte-identical. Reports carry `seed` only when one was given, so a
283
+ report never implies reproducibility it does not have. `runSuite` derives a
284
+ distinct seed per scenario so a suite is reproducible as a whole.
285
+
286
+ The source is scoped with `AsyncLocalStorage` rather than a module-level
287
+ variable: a global would work for one run and silently corrupt two, since a
288
+ second run starting mid-await would swap the generator underneath the first
289
+ with no error to show for it. Concurrent seeded runs stay independent, which
290
+ also keeps the door open for parallel execution.
291
+
292
+ ### Added — enforced rate limiting
293
+
294
+ - **`maxRequestsPerSecond`, default 25.** Applied by wrapping the target
295
+ adapter, so the ceiling holds for user-written scenarios too rather than
296
+ depending on each scenario pacing itself. Sliding window, so a run cannot
297
+ spend its whole budget in the first 50ms of each second. `Infinity` disables
298
+ it. A run that was actually held back emits a `warning` saying by how much —
299
+ relevant before reading anything into its latency numbers.
300
+
301
+ This replaces a documentation claim with a mechanism: the README previously
302
+ said safety came from "modest attack counts, 200ms delays", which had also
303
+ gone stale (defaults are 100ms since the calibration work).
304
+
305
+ ### Added — `fraud-sim/utils` subpath
306
+
307
+ - The scenario-authoring toolkit is now importable: `simulatedClock`,
308
+ `newResults`, `scoreAndCount`, every payload builder, and the synthetic-data
309
+ helpers. Previously the README documented writing custom scenarios while the
310
+ helpers every built-in uses were unreachable, so authors had to hand-roll
311
+ equivalents — and would drop out of seeded reproducibility by reaching for
312
+ `Math.random()`.
313
+ - The README's custom-scenario example is rewritten on top of it. The old one
314
+ used `@faker-js/faker` (not a dependency — the library deliberately avoids
315
+ it) and hardcoded `192.168.1.100`, an address no IP-intel provider resolves,
316
+ which silently disables the target's geo and reputation rules.
317
+
318
+ ### Fixed
319
+
320
+ - Dead documentation links: `docs/EXTENDING.md` (never existed).
321
+ - `rng.js` header described `random.js` as using `Math.random()`, no longer true.
322
+
323
+ ### Tests
324
+
325
+ - 26 new tests (149 total): seed replay and isolation across `await` and under
326
+ concurrency, seeded suites, non-numeric seed rejection, sliding-window rate
327
+ limiting, adapter surface preservation through the throttle wrapper, clock
328
+ arithmetic and validation, `scoreAndCount` error paths and stage tagging, and
329
+ the `fraud-sim/utils` surface.
330
+
331
+
332
+ ### Fixed — measurement correctness (breaking)
333
+
334
+ - **HTTP failures were counted as blocks.** `httpAdapter` mapped every non-2xx
335
+ response to `decision: 'BLOCK'` with `riskScore: 1`. A target that rate-limited
336
+ the simulator, returned 500s, or was simply unreachable therefore scored as a
337
+ *better* defence the more it failed — and the CI block-rate gate documented in
338
+ the README would pass on a staging box that was down. Non-2xx responses now
339
+ return `decision: 'ERROR'` with `infraError: true` and a reason code naming the
340
+ cause (`RATE_LIMITED`, `SERVER_ERROR`, `AUTH_ERROR`, `ENDPOINT_NOT_FOUND`).
341
+ A `Retry-After` header, when present, is surfaced as `retryAfter`.
342
+
343
+ APIs that genuinely signal a block by status can opt in per status:
344
+ `httpAdapter({ baseUrl, blockOnStatus: [403] })`.
345
+
346
+ - **Rates now exclude unscored attempts.** Reports gain `scoredAttempts`
347
+ (`attempts - errors`) and `errorRate`. `blockRate` and `stepUpRate` are
348
+ computed over `scoredAttempts` rather than all attempts, so an attempt that
349
+ never received a decision is neither credited to the defence nor held against
350
+ it. Runs with any errors emit a `warning` event stating how much of the run
351
+ failed. For runs with no errors, every rate is unchanged.
352
+
353
+ Derived rates are also computed *after* merging a scenario's own report
354
+ fields, so a scenario can no longer shadow them.
355
+
356
+ ### Added
357
+
358
+ - **Unknown scenario options are rejected.** A scenario's option surface is its
359
+ `defaultOptions`; anything else throws, with a "did you mean" suggestion from
360
+ an edit-distance match. Previously `attemptsPerIP` (capital P) silently fell
361
+ back to the default of `attemptsPerIp` and the run completed looking healthy
362
+ while measuring something else. Scenarios registered without `defaultOptions`
363
+ skip the check.
364
+ - 6 tests covering HTTP status classification, `blockOnStatus`, `Retry-After`,
365
+ option rejection with suggestions, and error-aware rate arithmetic
366
+ (122 total, all passing).
367
+
368
+
369
+ ### Added — the remaining seven scenarios (library complete)
370
+
371
+ The scenario library planned in `docs/ATTACK_PATTERNS.md` now ships in full.
372
+ Ten scenarios are registered; `listScenarios()` is the runtime source of truth.
373
+
374
+ - **`card-testing`** — probes a list of stolen cards with micro-authorisations,
375
+ then escalates to a real charge on each card that comes back approved.
376
+ Reports `cardsValidated`, `escalationsAllowed` and `estimatedLossKobo`; the
377
+ headline result is whether any escalated charge got through, not the block
378
+ rate. Never generates Luhn-valid numbers and never emits a full PAN — payloads
379
+ carry an opaque token, a published sandbox BIN, and four synthetic digits.
380
+ - **`promo-abuse`** — one actor farming signup bonuses across many accounts,
381
+ with configurable device and /24 reuse and a shared payout destination.
382
+ Reports `firstBlockedAtAccount`, because a system that catches account 40 has
383
+ already paid 39 bonuses.
384
+ - **`mule-network`** — fan-out from a source to mule accounts, then fan-in to a
385
+ common destination with a configurable retention cut. Reports
386
+ `passThroughRatio`; a system scoring transactions in isolation passes cleanly.
387
+ - **`slow-drain`** — withdrawals below every short-window threshold, spread over
388
+ simulated days. Reports how much left before anything fired.
389
+ - **`session-hijack`** — a legitimate login whose session is then replayed from
390
+ a different device and network. Tests whether the *session* is scored or only
391
+ the login. Treats a block on the victim's own opening login as a false
392
+ positive and stops.
393
+ - **`sim-swap-takeover`** — number ported, OTP redirected, then password reset →
394
+ login → withdrawal, every stage confirmed by an OTP the attacker receives.
395
+ Reports `stages` and `blockedAtStage`; `null` means the whole chain succeeded.
396
+ - **`velocity-evasion`** — an attacker pacing attempts under a rate limit they
397
+ believe they have inferred. The assumed limit is a parameter, so running it
398
+ with a deliberately wrong belief shows how much margin your thresholds have.
399
+
400
+ ### Added — supporting utilities
401
+
402
+ - **`src/utils/clock.js`** — `simulatedClock()` advances the payload
403
+ `timestamp` instead of sleeping, so a four-day slow drain runs in seconds.
404
+ Scenarios using it set `simulatedTime: true` and `simulatedSpan` on the
405
+ report and emit a milestone explaining the caveat: the result is only
406
+ faithful if the target derives its windows from the event timestamp rather
407
+ than server-side arrival time.
408
+ - **`src/utils/results.js`** — shared decision tallying and a `scoreAndCount()`
409
+ helper that emits the attempt/response pair, updates the tally, and throws a
410
+ clear error naming the missing method when an adapter does not implement the
411
+ endpoint a scenario needs.
412
+ - **Payload builders** for the new event types: `buildActionPayload`,
413
+ `buildSignupPayload`, `buildCardAuthPayload`, `buildTransferPayload`.
414
+ - **Card, subnet and phone helpers** in `random.js`: `randomCardBin`,
415
+ `cardBins`, `randomLast4`, `newCardToken`, `ipInSameSubnet`,
416
+ `randomPhoneNumber`.
417
+ - 29 new tests (116 total, all passing), covering PAN-leak prevention, BIN
418
+ concentration, device/subnet reuse bounds, mule graph shape, drain timestamp
419
+ spacing, session-id continuity, SIM-swap stage ordering, evasion pacing
420
+ arithmetic, and a cross-cutting check that every scenario's decision tally
421
+ balances against its attempt count.
422
+
423
+ ### Fixed
424
+
425
+ - **`buildActionPayload` dropped sibling detail fields.** Spreading `overrides`
426
+ wholesale replaced the entire `details` object, so a caller adding one field
427
+ silently discarded the rest — a card payload could lose its BIN and last4 and
428
+ still look well-formed. `details` is now merged; top-level keys still
429
+ override.
430
+
431
+
432
+ ### Changed — attack calibration (behavioural, review before upgrading)
433
+
434
+ The v0.2 defaults sat *below* the thresholds that rule engines commonly ship
435
+ with, so scenarios ran without tripping the rules they were written to trip.
436
+ Against a correctly configured target this reported a near-zero block rate that
437
+ said nothing about the defence — the attack was simply too quiet to catch.
438
+ These defaults now cross those thresholds.
439
+
440
+ - **`credential-stuffing` accepts `targetUserIds`** (an array) and sprays across
441
+ the whole list, one account per attempt. A credential dump is a list of
442
+ victims, not one victim retried — and "one IP, many users" rules (limit
443
+ commonly 10 accounts) could never fire against the old single-user shape.
444
+ `targetUserId` still works and is treated as a one-element list; the scenario
445
+ emits a `milestone` warning when only one account is supplied.
446
+ - **`credential-stuffing` defaults retuned**: `attemptsPerIp` 3 → 25 (per-IP
447
+ velocity limits commonly sit at 20 per 5 min, so 3 never fired),
448
+ `attackerCount` 50 → 20, `delayMs` 200 → 100, `maxTotalAttempts` 200 → 500.
449
+ - **`account-drain` `amountKobo` 4,500,000 → 12,000,000** (NGN 45,000 → 120,000).
450
+ The old default cleared neither the "new payee, high amount" gate (~NGN
451
+ 50,000) nor "large amount, new device" (~NGN 100,000), the latter often a hard
452
+ block — so the drain ran to completion against a system that would have
453
+ stopped it.
454
+ - **Default withdrawal amounts in `buildWithdrawalPayload`** raised to NGN
455
+ 50,000–250,000. The old ceiling landed exactly on the common threshold, and
456
+ those rules compare with `>`.
457
+ - **`credential-stuffing` reports `accountsTargeted`**.
458
+
459
+ ### Fixed
460
+
461
+ - **`randomIp()` emitted unroutable addresses.** It generated across the whole
462
+ IPv4 space, including private, loopback, CGNAT, link-local, multicast and
463
+ documentation ranges. No IP-intelligence provider resolves those, so targets
464
+ fell back to neutral intel and their proxy, abuse-score, new-country and
465
+ impossible-travel rules silently scored zero — often four to six rules
466
+ disabled without any error. Now restricted to publicly routable space.
467
+
468
+ ### Added
469
+
470
+ - **`DEMO_IP_POOL` and `pickDemoIp()`** (exported from the package root) — a
471
+ fixed pool of attacker IPs with declared geolocation, proxy/hosting flags and
472
+ abuse scores, covering `domestic`, `hosting`, `proxy` and `foreign` personas.
473
+ Most fraud systems resolve IP intel asynchronously and fall back to neutral
474
+ values on a cache miss; in a short run every IP is new, so geo and reputation
475
+ rules never fire. Because this pool is fixed and its metadata published, a
476
+ target can pre-warm its cache from it — no live lookups, no third-party rate
477
+ limits mid-demo, reproducible results. Addresses come from the RFC 5737
478
+ documentation ranges, so a simulation never attributes attack traffic to a
479
+ real network operator.
480
+ - **`ipPersona` option on `credential-stuffing`** — `random` (default,
481
+ generated routable IPs), `mixed` (realistic botnet composition drawn from the
482
+ pool), or a single persona to probe one rule family at a time.
483
+ - **`ipIntel` on `attempt` events** when a pool persona is used, so a dashboard
484
+ can show why an IP is suspicious and a target can seed intel inline.
485
+ - 17 tests covering reserved-range exclusion, pool integrity, account spraying,
486
+ persona filtering and back-compat (87 total, all passing).
487
+
8
488
  ## [0.2.0] — LLM Adaptive Attacker
9
489
 
10
490
  ### Added