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
@@ -22,6 +22,7 @@
22
22
  import { sleep } from '../utils/sleep.js';
23
23
  import { buildLoginPayload } from '../utils/faker-helpers.js';
24
24
  import { deterministic } from '../llm/deterministic.js';
25
+ import { randomInt } from '../utils/random.js';
25
26
 
26
27
  export default {
27
28
  id: 'adaptive-attacker',
@@ -45,8 +46,13 @@ export default {
45
46
  throw new Error('adaptive-attacker requires options.targetUserId');
46
47
  }
47
48
 
48
- // Fall back to a seeded deterministic provider when no LLM is supplied.
49
- const llm = ctx.llm ?? deterministic({ seed: 42 });
49
+ // Fall back to the deterministic provider when no LLM is supplied. Its seed
50
+ // is drawn from the run's own random source rather than being hardcoded:
51
+ // under a seeded run that makes the strategy sequence reproducible, and it
52
+ // makes two different run seeds actually produce different attacks. A fixed
53
+ // provider seed would have made every seeded run of this scenario identical
54
+ // no matter what seed the caller passed.
55
+ const llm = ctx.llm ?? deterministic({ seed: randomInt(1, 2 ** 31 - 1) });
50
56
 
51
57
  const results = {
52
58
  attempts: 0,
@@ -68,10 +74,13 @@ export default {
68
74
  timestamp: Date.now(),
69
75
  });
70
76
 
77
+ // Declared outside the loop: each round's strategy is merged over the
78
+ // parameters actually sent the round before.
79
+ let parameters;
80
+
71
81
  for (let round = 1; round <= options.maxRounds; round++) {
72
82
  if (signal.aborted) break;
73
83
 
74
- let parameters;
75
84
  let reasoning;
76
85
 
77
86
  if (round === 1) {
@@ -109,6 +118,27 @@ export default {
109
118
  reasoning = strategy?.reasoning ?? '';
110
119
  }
111
120
 
121
+ const payload = buildLoginPayload(options.targetUserId, {
122
+ ip_address: parameters.ip,
123
+ user_agent: parameters.userAgent,
124
+ ...(parameters.deviceFingerprint
125
+ ? { device_fingerprint: parameters.deviceFingerprint }
126
+ : {}),
127
+ });
128
+
129
+ // Record what was actually sent, not what was asked for. A parameter the
130
+ // strategy left unset is filled in by the payload builder, and the
131
+ // attacker has to see that value: round 1 never names a fingerprint, so
132
+ // without this the history said `null` and the device the defender
133
+ // challenged could never be replayed. It also makes an omitted key mean
134
+ // "keep it" rather than silently rotating it every round.
135
+ parameters = {
136
+ ...parameters,
137
+ ip: payload.ip_address,
138
+ userAgent: payload.user_agent,
139
+ deviceFingerprint: payload.device_fingerprint,
140
+ };
141
+
112
142
  // Emit the adaptation BEFORE sending — this is the demo moment.
113
143
  emit({
114
144
  type: 'attacker_adapts',
@@ -119,14 +149,6 @@ export default {
119
149
  });
120
150
  results.strategyHistory.push({ round, reasoning, parameters });
121
151
 
122
- const payload = buildLoginPayload(options.targetUserId, {
123
- ip_address: parameters.ip,
124
- user_agent: parameters.userAgent,
125
- ...(parameters.deviceFingerprint
126
- ? { device_fingerprint: parameters.deviceFingerprint }
127
- : {}),
128
- });
129
-
130
152
  const t0 = Date.now();
131
153
  emit({ type: 'attempt', round, payload, timestamp: t0 });
132
154
 
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Scenario: card-testing
3
+ *
4
+ * An attacker holding a list of stolen card numbers needs to know which ones
5
+ * are still live. They probe each card with a tiny authorisation — small enough
6
+ * that a cardholder may not notice and a fraud system may not care — and then
7
+ * escalate to a real charge on whichever cards come back approved.
8
+ *
9
+ * The tell is not any single transaction. It is the shape of the traffic: many
10
+ * distinct cards, drawn from a handful of BIN ranges, all card-not-present, all
11
+ * for near-identical trivial amounts, in a short window.
12
+ *
13
+ * Defensive systems should catch this via:
14
+ * - Micro-authorisation velocity (many tiny auths in a short window)
15
+ * - BIN range velocity (many cards sharing few BIN prefixes)
16
+ * - CNP transaction patterns (no cardholder present, no address match)
17
+ * - Decline-rate monitoring (a high decline ratio is itself the signal)
18
+ *
19
+ * Note on card data: `fraud-sim` never generates Luhn-valid numbers and never
20
+ * emits a full PAN. Payloads carry an opaque token, a published test BIN, and
21
+ * four synthetic digits — which is what a risk API receives in practice.
22
+ *
23
+ * Realistic block rates against a well-tuned system: 80-95%, and most of the
24
+ * value is in *how early* the block comes.
25
+ */
26
+
27
+ import { sleep } from '../utils/sleep.js';
28
+ import { buildCardAuthPayload } from '../utils/faker-helpers.js';
29
+ import { newResults, scoreAndCount } from '../utils/results.js';
30
+ import { cardBins, randomLast4, newCardToken, randomIp, pickDemoIp } from '../utils/random.js';
31
+
32
+ export default {
33
+ id: 'card-testing',
34
+ name: 'Card testing / BIN attack',
35
+ description:
36
+ 'An attacker validates a list of stolen cards with micro-authorisations, then escalates to real charges on the cards that come back approved.',
37
+
38
+ defaultOptions: {
39
+ attackerUserId: 'card_tester_001', // the merchant-side account doing the probing
40
+ cardCount: 60, // distinct cards in the stolen list
41
+ binCount: 3, // how many BIN ranges the list is drawn from — few is the tell
42
+ microAmountMinor: 5000, // NGN 50 probe
43
+ largeAmountMinor: 15000000, // NGN 150,000 once a card is known good
44
+ escalateAfter: 1, // approvals on a card before attempting the real charge
45
+ delayMs: 80,
46
+ maxTotalAttempts: 300,
47
+ ipPersona: 'random',
48
+ reuseIp: true, // card testers usually run from one host until it stops working
49
+ },
50
+
51
+ async run(ctx) {
52
+ const { options, emit, signal } = ctx;
53
+
54
+ if (options.cardCount < 1) {
55
+ throw new Error('card-testing requires options.cardCount >= 1');
56
+ }
57
+ if (options.binCount < 1) {
58
+ throw new Error('card-testing requires options.binCount >= 1');
59
+ }
60
+
61
+ const results = newResults({
62
+ cardsTested: 0,
63
+ cardsValidated: 0, // probes that came back ALLOW
64
+ escalations: 0, // large charges attempted
65
+ escalationsAllowed: 0, // large charges that got through — actual loss
66
+ });
67
+
68
+ const bins = cardBins().slice(0, options.binCount);
69
+ const attacker = resolveIp(options);
70
+
71
+ emit({
72
+ type: 'milestone',
73
+ message:
74
+ `Probing ${options.cardCount} cards across ${bins.length} BIN range(s) ` +
75
+ `with NGN ${(options.microAmountMinor / 100).toLocaleString()} authorisations`,
76
+ timestamp: Date.now(),
77
+ });
78
+
79
+ for (let i = 0; i < options.cardCount; i++) {
80
+ if (signal.aborted) break;
81
+ if (results.attempts >= options.maxTotalAttempts) break;
82
+
83
+ const card = {
84
+ token: newCardToken(),
85
+ bin: bins[i % bins.length],
86
+ last4: randomLast4(),
87
+ };
88
+
89
+ // A fresh host per card would defeat the point — the attacker is trying
90
+ // to stay cheap, and reusing infrastructure is what makes them catchable.
91
+ const ip = options.reuseIp ? attacker : resolveIp(options);
92
+
93
+ const probe = await scoreAndCount({
94
+ ctx,
95
+ method: 'scoreAction',
96
+ payload: buildCardAuthPayload(options.attackerUserId, card, options.microAmountMinor, {
97
+ ip_address: ip.ip,
98
+ details: { probe: true },
99
+ }),
100
+ results,
101
+ stage: 'probe',
102
+ ipIntel: ip.intel,
103
+ });
104
+
105
+ results.cardsTested++;
106
+
107
+ if (probe?.decision === 'ALLOW') {
108
+ results.cardsValidated++;
109
+
110
+ if (results.cardsValidated % options.escalateAfter === 0) {
111
+ if (results.attempts >= options.maxTotalAttempts) break;
112
+
113
+ emit({
114
+ type: 'milestone',
115
+ message: `Card ${card.bin}••${card.last4} approved — escalating to a real charge`,
116
+ timestamp: Date.now(),
117
+ });
118
+
119
+ const charge = await scoreAndCount({
120
+ ctx,
121
+ method: 'scoreAction',
122
+ payload: buildCardAuthPayload(
123
+ options.attackerUserId,
124
+ card,
125
+ options.largeAmountMinor,
126
+ { ip_address: ip.ip, details: { probe: false } }
127
+ ),
128
+ results,
129
+ stage: 'escalation',
130
+ ipIntel: ip.intel,
131
+ });
132
+
133
+ results.escalations++;
134
+ if (charge?.decision === 'ALLOW') results.escalationsAllowed++;
135
+ }
136
+ }
137
+
138
+ try {
139
+ await sleep(options.delayMs, signal);
140
+ } catch {
141
+ break; // aborted
142
+ }
143
+ }
144
+
145
+ const lossMinor = results.escalationsAllowed * options.largeAmountMinor;
146
+
147
+ emit({
148
+ type: 'milestone',
149
+ message:
150
+ `${results.cardsValidated} of ${results.cardsTested} cards validated; ` +
151
+ `${results.escalationsAllowed} of ${results.escalations} charges succeeded ` +
152
+ `(NGN ${(lossMinor / 100).toLocaleString()} at risk)`,
153
+ timestamp: Date.now(),
154
+ });
155
+
156
+ return {
157
+ ...results,
158
+ // The headline number for this scenario is not the block rate — it is
159
+ // whether any escalated charge got through at all.
160
+ estimatedLossKobo: lossMinor,
161
+ attackerSucceeded: results.escalationsAllowed > 0,
162
+ binsUsed: bins,
163
+ };
164
+ },
165
+ };
166
+
167
+ function resolveIp(options) {
168
+ if (!options.ipPersona || options.ipPersona === 'random') {
169
+ return { ip: randomIp(), intel: undefined };
170
+ }
171
+ const persona = options.ipPersona === 'mixed' ? 'hosting' : options.ipPersona;
172
+ const { ip, ...intel } = pickDemoIp(persona);
173
+ return { ip, intel };
174
+ }
@@ -1,45 +1,78 @@
1
1
  /**
2
2
  * Scenario: credential-stuffing
3
3
  *
4
- * A botnet attempts to authenticate using stolen credentials. Each attempt
5
- * comes from a different IP, rotates user agents, and paces attempts to
6
- * evade naive rate limits. This is the most common automated attack against
7
- * fintech login endpoints.
4
+ * A botnet replays a stolen credential dump against a login endpoint. Attempts
5
+ * come from many IPs, rotate user agents, and — crucially — spray across many
6
+ * *different* user accounts, because a credential dump is a list of victims,
7
+ * not one victim tried repeatedly. This is the most common automated attack
8
+ * against fintech login endpoints.
8
9
  *
9
10
  * Defensive systems should catch this via:
10
11
  * - LOGIN_VELOCITY_USER (many attempts on one user)
11
12
  * - LOGIN_VELOCITY_IP (many attempts from one IP)
12
- * - IP_PROXY_VPN (datacenter / VPN IPs)
13
13
  * - IP_MANY_USERS (one IP trying many user accounts)
14
+ * - IP_PROXY_VPN (datacenter / VPN IPs)
14
15
  * - NEW_DEVICE (every attempt is a new device fingerprint)
15
16
  *
16
17
  * Realistic block rates against a well-tuned system: 85-95%.
18
+ *
19
+ * ── Calibration note ──────────────────────────────────────────────────
20
+ * The defaults below are deliberately tuned to *cross* the thresholds that
21
+ * typical rule engines ship with, rather than to look gentle. Two settings
22
+ * matter most:
23
+ *
24
+ * - `attemptsPerIp` must exceed the target's per-IP velocity limit within
25
+ * its window, or that rule never fires. Common defaults sit near 20 per
26
+ * 5 minutes; the default here is 25.
27
+ * - `targetUserIds` must contain more accounts than the target's
28
+ * "one IP, many users" limit (commonly 10), or that rule never fires.
29
+ *
30
+ * A run that stays under both limits will report a low block rate that says
31
+ * nothing about the defence — it only means the attack was too quiet to be
32
+ * worth catching. Turn these *down* to probe where a system's floor is; leave
33
+ * them at the defaults to measure whether it catches an ordinary attack.
17
34
  */
18
35
 
19
36
  import { sleep } from '../utils/sleep.js';
20
37
  import { buildLoginPayload } from '../utils/faker-helpers.js';
21
- import { randomIp, randomUserAgent } from '../utils/random.js';
38
+ import {
39
+ randomIp,
40
+ randomUserAgent,
41
+ randomFloat,
42
+ pickDemoIp,
43
+ DEMO_IP_POOL,
44
+ } from '../utils/random.js';
45
+
46
+ /**
47
+ * Personas drawn from DEMO_IP_POOL when `ipPersona: 'mixed'`.
48
+ *
49
+ * Includes `domestic` on purpose: a real botnet rents residential proxies in
50
+ * the victim's own country precisely to avoid the geo signals that the other
51
+ * personas trip. An all-foreign mix makes every attempt look identical to the
52
+ * defence, which flatters the block rate and produces a monotonous event feed.
53
+ */
54
+ const MIXED_PERSONAS = ['hosting', 'proxy', 'foreign', 'domestic'];
22
55
 
23
56
  export default {
24
57
  id: 'credential-stuffing',
25
58
  name: 'Credential stuffing attack',
26
59
  description:
27
- 'A botnet attempts to authenticate using stolen credentials. Distinct IPs, rotating user agents, paced to evade simple rate limits.',
60
+ 'A botnet replays a stolen credential dump. Distinct IPs, rotating user agents, sprayed across many accounts to evade per-user rate limits.',
28
61
 
29
62
  defaultOptions: {
30
- targetUserId: null, // required
31
- attackerCount: 50, // distinct IPs
32
- attemptsPerIp: 3, // attempts before rotating IP
33
- delayMs: 200, // ms between attempts
34
- maxTotalAttempts: 200, // safety ceiling — prevents runaway sims
63
+ targetUserIds: null, // required — array of accounts from the "dump"
64
+ targetUserId: null, // back-compat: single account, equivalent to [targetUserId]
65
+ attackerCount: 20, // distinct IPs
66
+ attemptsPerIp: 25, // attempts before rotating IP — must beat per-IP velocity limits
67
+ delayMs: 100, // ms between attempts
68
+ maxTotalAttempts: 500, // safety ceiling — prevents runaway sims
69
+ ipPersona: 'random', // 'random' | 'mixed' | 'domestic' | 'hosting' | 'proxy' | 'foreign'
35
70
  },
36
71
 
37
72
  async run(ctx) {
38
73
  const { options, target, emit, signal } = ctx;
39
74
 
40
- if (!options.targetUserId) {
41
- throw new Error('credential-stuffing requires options.targetUserId');
42
- }
75
+ const userIds = resolveUserIds(options);
43
76
 
44
77
  const results = {
45
78
  attempts: 0,
@@ -49,31 +82,64 @@ export default {
49
82
  errors: 0,
50
83
  };
51
84
 
85
+ const usersSeen = new Set();
86
+
52
87
  emit({
53
88
  type: 'milestone',
54
- message: `Starting credential stuffing: ${options.attackerCount} IPs × ${options.attemptsPerIp} attempts (max ${options.maxTotalAttempts} total)`,
89
+ message:
90
+ `Starting credential stuffing: ${options.attackerCount} IPs × ${options.attemptsPerIp} attempts ` +
91
+ `across ${userIds.length} account${userIds.length === 1 ? '' : 's'} ` +
92
+ `(max ${options.maxTotalAttempts} total)`,
55
93
  timestamp: Date.now(),
56
94
  });
57
95
 
96
+ if (userIds.length === 1) {
97
+ emit({
98
+ type: 'milestone',
99
+ message:
100
+ 'Only one target account supplied — "one IP, many users" detection cannot fire. ' +
101
+ 'Pass options.targetUserIds with several accounts to exercise it.',
102
+ timestamp: Date.now(),
103
+ });
104
+ }
105
+
106
+ // Draw pool IPs without replacement so each attacker is distinct until the
107
+ // pool is exhausted, then cycle — a real botnet reuses its exits too.
108
+ const ipSource = makeIpSource(options.ipPersona);
109
+
58
110
  outer: for (let i = 0; i < options.attackerCount; i++) {
59
111
  if (signal.aborted) break;
60
112
 
61
- // Each "attacker IP" is a different IP + user agent combination
62
- const attackerIp = randomIp();
113
+ // Each "attacker" is a different IP + user agent combination
114
+ const attacker = ipSource(i);
115
+ const attackerIp = attacker.ip;
63
116
  const userAgent = randomUserAgent();
64
117
 
65
118
  for (let j = 0; j < options.attemptsPerIp; j++) {
66
119
  if (signal.aborted) break outer;
67
120
  if (results.attempts >= options.maxTotalAttempts) break outer;
68
121
 
69
- const payload = buildLoginPayload(options.targetUserId, {
122
+ // Spray across the dump: this IP walks the account list rather than
123
+ // hammering one user, which is what makes it credential stuffing.
124
+ const userId = userIds[results.attempts % userIds.length];
125
+ usersSeen.add(userId);
126
+
127
+ const payload = buildLoginPayload(userId, {
70
128
  ip_address: attackerIp,
71
129
  user_agent: userAgent,
72
130
  // Crucially: new device fingerprint per attempt (botnet behaviour)
73
131
  });
74
132
 
75
133
  const t0 = Date.now();
76
- emit({ type: 'attempt', round: results.attempts, payload, timestamp: t0 });
134
+ emit({
135
+ type: 'attempt',
136
+ round: results.attempts,
137
+ payload,
138
+ // Present only for pool-backed IPs — lets a target pre-warm its
139
+ // IP-intel cache instead of doing live lookups mid-run.
140
+ ipIntel: attacker.intel,
141
+ timestamp: t0,
142
+ });
77
143
 
78
144
  let response;
79
145
  try {
@@ -119,6 +185,66 @@ export default {
119
185
  stepUp: results.stepUp,
120
186
  allowed: results.allowed,
121
187
  errors: results.errors,
188
+ accountsTargeted: usersSeen.size,
122
189
  };
123
190
  },
124
191
  };
192
+
193
+ // ── internals ───────────────────────────────────────────────────────────
194
+
195
+ /**
196
+ * Accept either `targetUserIds` (preferred) or the legacy single
197
+ * `targetUserId`, and validate.
198
+ */
199
+ function resolveUserIds(options) {
200
+ const { targetUserIds, targetUserId } = options;
201
+
202
+ if (targetUserIds != null) {
203
+ if (!Array.isArray(targetUserIds) || targetUserIds.length === 0) {
204
+ throw new Error('credential-stuffing: options.targetUserIds must be a non-empty array');
205
+ }
206
+ return targetUserIds;
207
+ }
208
+
209
+ if (targetUserId) return [targetUserId];
210
+
211
+ throw new Error(
212
+ 'credential-stuffing requires options.targetUserIds (an array of accounts), ' +
213
+ 'or options.targetUserId for a single account'
214
+ );
215
+ }
216
+
217
+ /**
218
+ * Build a function that returns the i-th attacker's IP and, where known, its
219
+ * declared intel. Pool draws are without replacement until exhausted.
220
+ */
221
+ function makeIpSource(persona) {
222
+ if (persona === 'random' || persona == null) {
223
+ return () => ({ ip: randomIp(), intel: undefined });
224
+ }
225
+
226
+ if (persona === 'mixed') {
227
+ const shuffled = shuffle(DEMO_IP_POOL.filter((e) => MIXED_PERSONAS.includes(e.persona)));
228
+ return (i) => toAttacker(shuffled[i % shuffled.length]);
229
+ }
230
+
231
+ // A single named persona — validate eagerly so a typo fails on the first
232
+ // attacker rather than silently behaving like 'random'.
233
+ pickDemoIp(persona);
234
+ const shuffled = shuffle(DEMO_IP_POOL.filter((e) => e.persona === persona));
235
+ return (i) => toAttacker(shuffled[i % shuffled.length]);
236
+ }
237
+
238
+ function toAttacker(entry) {
239
+ const { ip, ...intel } = entry;
240
+ return { ip, intel };
241
+ }
242
+
243
+ function shuffle(arr) {
244
+ const out = [...arr];
245
+ for (let i = out.length - 1; i > 0; i--) {
246
+ const j = Math.floor(randomFloat() * (i + 1));
247
+ [out[i], out[j]] = [out[j], out[i]];
248
+ }
249
+ return out;
250
+ }
@@ -1,25 +1,56 @@
1
1
  /**
2
2
  * Scenario registry — all built-in scenarios.
3
3
  *
4
- * v0.1 shipped with two scenarios:
4
+ * v0.1 shipped:
5
5
  * - credential-stuffing
6
6
  * - account-drain
7
7
  *
8
- * v0.2 adds:
8
+ * v0.2 added:
9
9
  * - adaptive-attacker (LLM-driven)
10
10
  *
11
- * v0.3 will add:
12
- * - promo-abuse
11
+ * v0.3 adds seven, completing the planned library:
13
12
  * - card-testing
14
- * - sim-swap-takeover
13
+ * - promo-abuse
15
14
  * - mule-network
16
15
  * - slow-drain
17
16
  * - session-hijack
17
+ * - sim-swap-takeover
18
18
  * - velocity-evasion
19
+ *
20
+ * v0.5 adds the other half of the measurement:
21
+ * - legitimate-users — ordinary customers, not an attack. Every block it
22
+ * receives is a false positive. Listed first so a suite measures customers
23
+ * before its own attacks have burned any shared state on the target.
24
+ *
25
+ * Endpoint requirements differ by scenario. `credential-stuffing`,
26
+ * `account-drain`, `adaptive-attacker`, `slow-drain` and `velocity-evasion`
27
+ * need only `scoreLogin` / `scoreWithdrawal`. `card-testing`, `promo-abuse` and
28
+ * `session-hijack` also need `scoreAction`; a scenario throws with a clear
29
+ * message if the adapter does not implement what it needs.
19
30
  */
20
31
 
32
+ import legitimateUsers from './legitimate-users.js';
21
33
  import credentialStuffing from './credential-stuffing.js';
22
34
  import accountDrain from './account-drain.js';
23
35
  import adaptiveAttacker from './adaptive-attacker.js';
36
+ import cardTesting from './card-testing.js';
37
+ import promoAbuse from './promo-abuse.js';
38
+ import muleNetwork from './mule-network.js';
39
+ import slowDrain from './slow-drain.js';
40
+ import sessionHijack from './session-hijack.js';
41
+ import simSwapTakeover from './sim-swap-takeover.js';
42
+ import velocityEvasion from './velocity-evasion.js';
24
43
 
25
- export const scenarios = [credentialStuffing, accountDrain, adaptiveAttacker];
44
+ export const scenarios = [
45
+ legitimateUsers,
46
+ credentialStuffing,
47
+ accountDrain,
48
+ adaptiveAttacker,
49
+ cardTesting,
50
+ promoAbuse,
51
+ muleNetwork,
52
+ slowDrain,
53
+ sessionHijack,
54
+ simSwapTakeover,
55
+ velocityEvasion,
56
+ ];