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