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
@@ -8,34 +8,18 @@
8
8
  * If you need richer fake data in your own scenarios (real-looking names,
9
9
  * locale-specific data, etc.), import @faker-js/faker in your scenario file —
10
10
  * it is not bundled with fraud-sim, but you can use it freely alongside it.
11
+ *
12
+ * Every helper here is built on exactly three primitives — `randomInt`,
13
+ * `pickOne` and `alphanumeric` — and all three draw from the run-scoped source
14
+ * in `random-source.js`. Seed a run and every value below replays identically.
15
+ * A helper that reaches for `Math.random()` directly would silently opt itself
16
+ * out of that, so none of them do.
11
17
  */
12
18
 
13
- const ALPHANUM = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
14
-
15
- const NIGERIAN_BANKS = [
16
- 'GTBank',
17
- 'UBA',
18
- 'Access',
19
- 'Zenith',
20
- 'First Bank',
21
- 'FCMB',
22
- 'Sterling',
23
- 'Stanbic IBTC',
24
- 'Fidelity',
25
- 'Wema',
26
- ];
27
-
28
- const FIRST_NAMES = [
29
- 'Adaeze', 'Chinedu', 'Folake', 'Tunde', 'Aisha', 'Emeka', 'Nkechi', 'Bayo',
30
- 'Ngozi', 'Olumide', 'Funmi', 'Kayode', 'Hauwa', 'Ifeanyi', 'Toyin', 'Segun',
31
- 'James', 'Mary', 'David', 'Sarah', 'Michael', 'Grace', 'John', 'Esther',
32
- ];
19
+ import { randomFloat, currentLocale } from './random-source.js';
20
+ import { resolveLocale } from '../locales/index.js';
33
21
 
34
- const LAST_NAMES = [
35
- 'Adeyemi', 'Okonkwo', 'Ibrahim', 'Nwosu', 'Bello', 'Eze', 'Yusuf', 'Okoye',
36
- 'Lawal', 'Obi', 'Musa', 'Achebe', 'Akin', 'Adamu', 'Ojo', 'Chukwu',
37
- 'Smith', 'Brown', 'Williams', 'Johnson',
38
- ];
22
+ const ALPHANUM = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
39
23
 
40
24
  const USER_AGENTS = [
41
25
  'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
@@ -52,14 +36,14 @@ const USER_AGENTS = [
52
36
  * Random integer in [min, max] inclusive.
53
37
  */
54
38
  export function randomInt(min, max) {
55
- return Math.floor(Math.random() * (max - min + 1)) + min;
39
+ return Math.floor(randomFloat() * (max - min + 1)) + min;
56
40
  }
57
41
 
58
42
  /**
59
43
  * Pick one element from an array.
60
44
  */
61
45
  export function pickOne(arr) {
62
- return arr[Math.floor(Math.random() * arr.length)];
46
+ return arr[Math.floor(randomFloat() * arr.length)];
63
47
  }
64
48
 
65
49
  /**
@@ -68,47 +52,276 @@ export function pickOne(arr) {
68
52
  export function alphanumeric(length) {
69
53
  let out = '';
70
54
  for (let i = 0; i < length; i++) {
71
- out += ALPHANUM.charAt(Math.floor(Math.random() * ALPHANUM.length));
55
+ out += ALPHANUM.charAt(Math.floor(randomFloat() * ALPHANUM.length));
72
56
  }
73
57
  return out;
74
58
  }
75
59
 
76
60
  /**
77
- * Generate a synthetic IPv4 address.
78
- * Not from any real range — purely synthetic for testing.
61
+ * IPv4 ranges that are reserved, private, or otherwise never routed on the
62
+ * public internet. A generator that emits these produces addresses that no
63
+ * IP-intelligence provider can resolve — which silently disables every
64
+ * geo/reputation rule on the target being tested. We skip them.
65
+ *
66
+ * @param {number} a - first octet
67
+ * @param {number} b - second octet
68
+ * @returns {boolean}
69
+ */
70
+ function isReservedIpv4(a, b) {
71
+ if (a === 0 || a === 10 || a === 127) return true; // this-network, private, loopback
72
+ if (a >= 224) return true; // multicast + reserved + broadcast
73
+ if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT 100.64.0.0/10
74
+ if (a === 169 && b === 254) return true; // link-local
75
+ if (a === 172 && b >= 16 && b <= 31) return true; // private 172.16.0.0/12
76
+ if (a === 192 && b === 168) return true; // private
77
+ if (a === 192 && b === 0) return true; // 192.0.0.0/24 + TEST-NET-1
78
+ if (a === 192 && b === 88) return true; // 6to4 relay anycast
79
+ if (a === 198 && (b === 18 || b === 19)) return true; // benchmarking
80
+ if (a === 198 && b === 51) return true; // TEST-NET-2
81
+ if (a === 203 && b === 0) return true; // TEST-NET-3
82
+ return false;
83
+ }
84
+
85
+ /**
86
+ * Generate a synthetic but *publicly routable* IPv4 address.
87
+ *
88
+ * Deliberately excludes private, loopback, CGNAT, link-local, multicast and
89
+ * documentation ranges. Addresses from those blocks cannot be resolved by any
90
+ * geo/reputation provider, so a target that scores them will fall back to
91
+ * neutral intel and never fire its proxy, abuse-score, or geo rules — making a
92
+ * defence look weaker than it is.
93
+ *
94
+ * These addresses are used only as payload field values. `fraud-sim` never
95
+ * connects to them.
96
+ *
97
+ * For a stable, pre-seedable set with known geolocation, use
98
+ * {@link DEMO_IP_POOL} / {@link pickDemoIp} instead.
79
99
  */
80
100
  export function randomIp() {
81
- return `${randomInt(1, 254)}.${randomInt(0, 255)}.${randomInt(0, 255)}.${randomInt(1, 254)}`;
101
+ let a, b;
102
+ do {
103
+ a = randomInt(1, 223);
104
+ b = randomInt(0, 255);
105
+ } while (isReservedIpv4(a, b));
106
+ return `${a}.${b}.${randomInt(0, 255)}.${randomInt(1, 254)}`;
82
107
  }
83
108
 
84
109
  /**
85
- * Pick a realistic user agent string.
110
+ * A fixed pool of attacker IPs with known, declared geolocation and
111
+ * reputation metadata.
112
+ *
113
+ * Why this exists: most fraud systems resolve IP intelligence *asynchronously*
114
+ * and fall back to neutral values on a cache miss. In a short simulation every
115
+ * IP is seen for the first time, so geo and reputation rules never fire and the
116
+ * run under-reports what the defence can actually catch. Because this pool is
117
+ * fixed and its metadata is declared here, the system under test can pre-warm
118
+ * its IP-intel cache from `DEMO_IP_POOL` before a run — no live lookups, no
119
+ * third-party rate limits, and reproducible results.
120
+ *
121
+ * Addresses are drawn from the RFC 5737 documentation ranges (192.0.2.0/24,
122
+ * 198.51.100.0/24, 203.0.113.0/24). They are reserved for documentation, are
123
+ * never routed, and belong to no real network — so attack traffic in a
124
+ * simulation is never attributed to a real operator. The geolocation and
125
+ * reputation fields are synthetic labels chosen to exercise distinct rule
126
+ * paths, not claims about these addresses.
127
+ *
128
+ * Personas covered:
129
+ * - `domestic` residential → the victim's home country; the quiet baseline
130
+ * - `hosting` → datacenter ranges; should fire hosting rules
131
+ * - `proxy` → VPN/proxy exits; should fire proxy rules
132
+ * - `foreign` residential → new-country and impossible-travel paths
133
+ *
134
+ * @type {ReadonlyArray<{ip: string, countryCode: string, country: string,
135
+ * lat: number, lon: number, proxy: boolean, hosting: boolean,
136
+ * abuseScore: number, isp: string, persona: string}>}
86
137
  */
87
- export function randomUserAgent() {
88
- return pickOne(USER_AGENTS);
138
+ const FOREIGN_POOL = Object.freeze([
139
+ // ── Datacenter / hosting — bot traffic rarely comes from a living room
140
+ f('198.51.100.20', 'NL', 'Netherlands', 52.3676, 4.9041, false, true, 78, 'Simulated Cloud Hosting (Amsterdam)', 'hosting'),
141
+ f('198.51.100.21', 'DE', 'Germany', 50.1109, 8.6821, false, true, 84, 'Simulated Cloud Hosting (Frankfurt)', 'hosting'),
142
+ f('198.51.100.22', 'US', 'United States', 39.0438, -77.4874, false, true, 91, 'Simulated Cloud Hosting (Ashburn)', 'hosting'),
143
+ f('198.51.100.23', 'SG', 'Singapore', 1.3521, 103.8198, false, true, 66, 'Simulated Cloud Hosting (Singapore)', 'hosting'),
144
+
145
+ // ── Proxy / VPN exits — the usual answer to a blocked datacenter range
146
+ f('203.0.113.30', 'RU', 'Russia', 55.7558, 37.6173, true, false, 72, 'Simulated VPN Exit (Moscow)', 'proxy'),
147
+ f('203.0.113.31', 'UA', 'Ukraine', 50.4501, 30.5234, true, false, 58, 'Simulated VPN Exit (Kyiv)', 'proxy'),
148
+ f('203.0.113.32', 'CN', 'China', 22.5431, 114.0579, true, false, 69, 'Simulated Proxy Exit (Shenzhen)', 'proxy'),
149
+
150
+ // ── Foreign residential — new country + impossible travel, low reputation signal
151
+ f('203.0.113.40', 'BR', 'Brazil', -23.5505, -46.6333, false, false, 12, 'Simulated Residential ISP (Sao Paulo)', 'foreign'),
152
+ f('203.0.113.41', 'IN', 'India', 19.076, 72.8777, false, false, 8, 'Simulated Residential ISP (Mumbai)', 'foreign'),
153
+ ]);
154
+
155
+ /**
156
+ * Domestic entries for the active market.
157
+ *
158
+ * "Domestic" only means anything relative to a market: an address in Lagos is
159
+ * the quiet baseline for a Nigerian bank and a new-country signal for a British
160
+ * one. These are generated from the locale's cities so the pool means the same
161
+ * thing wherever the tool is pointed.
162
+ */
163
+ function domesticEntries(locale) {
164
+ return locale.cities.map((place, i) =>
165
+ f(
166
+ `192.0.2.${10 + i}`,
167
+ locale.id,
168
+ locale.name,
169
+ place.lat,
170
+ place.lon,
171
+ false,
172
+ false,
173
+ 0,
174
+ `Simulated Residential ISP (${place.city})`,
175
+ 'domestic'
176
+ )
177
+ );
89
178
  }
90
179
 
91
180
  /**
92
- * Generate a synthetic Nigerian-style account number (10 digits).
181
+ * The attacker IP pool for the run's market: domestic entries from the active
182
+ * locale, plus the fixed set of hosting, proxy and foreign exits.
183
+ *
184
+ * Exposed as a getter so it tracks the locale in scope without callers having
185
+ * to pass one. `demoIpPool()` is the explicit form.
93
186
  */
94
- export function randomAccountNumber() {
95
- let out = '';
96
- for (let i = 0; i < 10; i++) {
97
- out += randomInt(0, 9).toString();
187
+ export function demoIpPool(locale) {
188
+ return Object.freeze([
189
+ ...domesticEntries(locale ?? currentLocale()),
190
+ ...FOREIGN_POOL,
191
+ ]);
192
+ }
193
+
194
+ /** Compact constructor for DEMO_IP_POOL rows — keeps the table above readable. */
195
+ function f(ip, countryCode, country, lat, lon, proxy, hosting, abuseScore, isp, persona) {
196
+ return Object.freeze({ ip, countryCode, country, lat, lon, proxy, hosting, abuseScore, isp, persona });
197
+ }
198
+
199
+ /**
200
+ * Pick an entry from {@link DEMO_IP_POOL}, optionally filtered by persona.
201
+ *
202
+ * @param {'any'|'domestic'|'hosting'|'proxy'|'foreign'} [persona='any']
203
+ * @returns {(typeof DEMO_IP_POOL)[number]}
204
+ */
205
+ export function pickDemoIp(persona = 'any') {
206
+ const pool = demoIpPool();
207
+ if (persona === 'any') return pickOne(pool);
208
+ const matches = pool.filter((entry) => entry.persona === persona);
209
+ if (matches.length === 0) {
210
+ throw new Error(
211
+ `Unknown IP persona: "${persona}". Expected one of: any, domestic, hosting, proxy, foreign.`
212
+ );
98
213
  }
214
+ return pickOne(matches);
215
+ }
216
+
217
+ /**
218
+ * Publicly documented test BIN prefixes.
219
+ *
220
+ * These are the card prefixes every payment processor publishes for sandbox
221
+ * use. `fraud-sim` deliberately does NOT generate Luhn-valid card numbers and
222
+ * never emits a full PAN — a fraud API receives a token, a BIN, and the last
223
+ * four digits, so that is all a scenario produces. Anything more would make the
224
+ * library useful for something other than testing your own defences.
225
+ */
226
+ const TEST_CARD_BINS = [
227
+ '411111', // Visa
228
+ '400000', // Visa
229
+ '555555', // Mastercard
230
+ '510510', // Mastercard
231
+ '601111', // Discover
232
+ '371449', // Amex
233
+ ];
234
+
235
+ /** Pick one of the published test BINs. */
236
+ export function randomCardBin() {
237
+ return pickOne(TEST_CARD_BINS);
238
+ }
239
+
240
+ /** All test BINs, for scenarios that need to walk a BIN range deliberately. */
241
+ export function cardBins() {
242
+ return [...TEST_CARD_BINS];
243
+ }
244
+
245
+ /** Four synthetic trailing digits. Not derived from any real card. */
246
+ export function randomLast4() {
247
+ let out = '';
248
+ for (let i = 0; i < 4; i++) out += randomInt(0, 9).toString();
99
249
  return out;
100
250
  }
101
251
 
252
+ /** An opaque card token, the way a processor would hand one to a risk API. */
253
+ export function newCardToken() {
254
+ return `tok_sim_${alphanumeric(24)}`;
255
+ }
256
+
102
257
  /**
103
- * Pick a Nigerian bank name.
258
+ * Generate an IP inside the same /24 as `ip`.
259
+ *
260
+ * Promo-abuse and other single-actor attacks look like many users, but the
261
+ * addresses cluster. Subnet clustering is usually the signal that catches them.
104
262
  */
105
- export function randomBank() {
106
- return pickOne(NIGERIAN_BANKS);
263
+ export function ipInSameSubnet(ip) {
264
+ const octets = String(ip).split('.');
265
+ if (octets.length !== 4) {
266
+ throw new Error(`ipInSameSubnet: "${ip}" is not a dotted-quad IPv4 address`);
267
+ }
268
+ return `${octets[0]}.${octets[1]}.${octets[2]}.${randomInt(1, 254)}`;
269
+ }
270
+
271
+ /** A subscriber number in the active market's format. */
272
+ export function randomPhoneNumber() {
273
+ return currentLocale().phoneNumber(randomFloat);
107
274
  }
108
275
 
109
276
  /**
110
- * Generate a random full name.
277
+ * Pick a realistic user agent string.
111
278
  */
279
+ export function randomUserAgent() {
280
+ return pickOne(USER_AGENTS);
281
+ }
282
+
283
+ /**
284
+ * A real browser's user agent — never a tool. For legitimate traffic, where a
285
+ * `curl` agent would be a false positive the simulator manufactured.
286
+ */
287
+ export function randomBrowserUserAgent() {
288
+ return pickOne(USER_AGENTS.filter((ua) => ua.startsWith('Mozilla/')));
289
+ }
290
+
291
+ /**
292
+ * An account identifier shaped like the active market's — 10 digits in Nigeria,
293
+ * 8 in the UK, a variable length in the US. Format matters: a target that
294
+ * validates account numbers rejects a wrongly-shaped one before it ever scores
295
+ * it, and a run full of validation errors measures nothing.
296
+ */
297
+ export function randomAccountNumber() {
298
+ return currentLocale().accountNumber(randomFloat);
299
+ }
300
+
301
+ /** A bank from the active market. */
302
+ export function randomBank() {
303
+ return pickOne(currentLocale().banks);
304
+ }
305
+
306
+ /** A plausible name for the active market. Synthetic; nobody real. */
112
307
  export function randomFullName() {
113
- return `${pickOne(FIRST_NAMES)} ${pickOne(LAST_NAMES)}`;
308
+ const locale = currentLocale();
309
+ return `${pickOne(locale.firstNames)} ${pickOne(locale.lastNames)}`;
114
310
  }
311
+
312
+ /**
313
+ * A float in [0, 1) from the run's random source.
314
+ *
315
+ * Custom scenarios should use this rather than `Math.random()` — anything that
316
+ * bypasses it will not replay when the run is seeded.
317
+ */
318
+ export { randomFloat } from './random-source.js';
319
+
320
+ /**
321
+ * The pool for the market currently in scope.
322
+ *
323
+ * Retained as a named export because callers pre-date `demoIpPool()`; it is a
324
+ * snapshot of the default market rather than a live view, so a target
325
+ * pre-seeding intel for a non-default locale should call `demoIpPool()`.
326
+ */
327
+ export const DEMO_IP_POOL = demoIpPool(resolveLocale());
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Shared decision tallying.
3
+ *
4
+ * Every scenario counts the same four outcomes and emits the same
5
+ * attempt/response event pair around each call to the target. Centralising it
6
+ * keeps the counts consistent and means a scenario file contains only the
7
+ * attack logic.
8
+ */
9
+
10
+ /** A fresh tally. */
11
+ export function newResults(extra = {}) {
12
+ return { attempts: 0, blocked: 0, stepUp: 0, allowed: 0, errors: 0, ...extra };
13
+ }
14
+
15
+ /**
16
+ * Record one decision against a tally. Anything that isn't a recognised
17
+ * decision counts as an error rather than being silently dropped.
18
+ */
19
+ export function countDecision(results, decision) {
20
+ if (decision === 'BLOCK') results.blocked++;
21
+ else if (decision === 'STEP_UP') results.stepUp++;
22
+ else if (decision === 'ALLOW') results.allowed++;
23
+ else results.errors++;
24
+ return decision;
25
+ }
26
+
27
+ /**
28
+ * Call one of the target's scoring methods, emitting the attempt/response
29
+ * events and updating the tally.
30
+ *
31
+ * Returns the target's response, or `null` if the call threw — callers decide
32
+ * whether a failed call ends the scenario or is skipped. A throw is counted as
33
+ * an error but does NOT increment `attempts`, because nothing was scored.
34
+ *
35
+ * @param {object} args
36
+ * @param {object} args.ctx - The scenario context ({ target, emit }).
37
+ * @param {string} args.method - 'scoreLogin' | 'scoreWithdrawal' | 'scoreAction'
38
+ * @param {object} args.payload - The payload to send.
39
+ * @param {object} args.results - Tally from `newResults()`.
40
+ * @param {string} [args.stage] - Optional label carried on both events.
41
+ * @param {object} [args.ipIntel] - Declared intel for the payload's IP, if known.
42
+ * @returns {Promise<object|null>}
43
+ */
44
+ export async function scoreAndCount({ ctx, method, payload, results, stage, ipIntel }) {
45
+ const { target, emit } = ctx;
46
+
47
+ if (typeof target?.[method] !== 'function') {
48
+ throw new Error(
49
+ `This scenario needs target.${method}(), which the adapter does not implement. ` +
50
+ 'httpAdapter supports it — map it to your endpoint via the `endpoints` option.'
51
+ );
52
+ }
53
+
54
+ const round = results.attempts;
55
+ const t0 = Date.now();
56
+ emit({ type: 'attempt', round, payload, stage, ipIntel, timestamp: t0 });
57
+
58
+ let response;
59
+ try {
60
+ response = await target[method](payload);
61
+ } catch (err) {
62
+ results.errors++;
63
+ emit({
64
+ type: 'error',
65
+ message: `${stage ?? method} failed: ${err.message}`,
66
+ round,
67
+ timestamp: Date.now(),
68
+ });
69
+ return null;
70
+ }
71
+
72
+ emit({
73
+ type: 'response',
74
+ round,
75
+ response,
76
+ stage,
77
+ latencyMs: Date.now() - t0,
78
+ timestamp: Date.now(),
79
+ });
80
+
81
+ results.attempts++;
82
+ countDecision(results, response?.decision ?? 'ERROR');
83
+ return response;
84
+ }
package/src/utils/rng.js CHANGED
@@ -1,10 +1,9 @@
1
1
  /**
2
2
  * Seeded pseudo-random number generator.
3
3
  *
4
- * `random.js` uses `Math.random()`, which is fine for generating attack traffic
5
- * but useless when a run has to be reproducible. The deterministic LLM provider
6
- * needs the same seed to produce the same strategy for the same history, so it
7
- * uses this instead.
4
+ * Backs both seeded runs (installed as the run's random source by the runner —
5
+ * see `random-source.js`) and the deterministic LLM provider, which needs the
6
+ * same seed to produce the same strategy for the same history.
8
7
  *
9
8
  * Algorithm is mulberry32 — small, fast, and good enough for synthetic data.
10
9
  * It is NOT cryptographically secure and must never be used for anything that
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Outbound request rate limiting.
3
+ *
4
+ * `fraud-sim` generates attack traffic, so the difference between a useful
5
+ * simulation and a denial-of-service against your own staging environment is
6
+ * one mistyped `delayMs`. Per-scenario pacing is a convention; this is the
7
+ * guard rail that holds when the convention is ignored.
8
+ *
9
+ * The limiter wraps the *target adapter* rather than living inside scenarios,
10
+ * so every call from every scenario passes through it — including scenarios
11
+ * written by users, which cannot be expected to police themselves.
12
+ *
13
+ * It is a sliding window, not a fixed one: a run cannot spend its whole budget
14
+ * in the first 50ms of each second and call the average acceptable.
15
+ */
16
+
17
+ const SCORING_METHODS = ['scoreLogin', 'scoreWithdrawal', 'scoreAction', 'reportEvent'];
18
+
19
+ /**
20
+ * Wrap a target adapter so its outbound calls never exceed `maxPerSecond`.
21
+ *
22
+ * Passing `Infinity` (or a non-finite value) returns the target untouched —
23
+ * useful in tests, and for users who have their own pacing and mean it.
24
+ *
25
+ * @param {object} target - Any TargetAdapter.
26
+ * @param {number} maxPerSecond
27
+ * @param {{ onThrottle?: (waitMs: number) => void, signal?: AbortSignal }} [hooks]
28
+ * @returns {object} A adapter with the same shape as `target`.
29
+ */
30
+ export function throttleTarget(target, maxPerSecond, hooks = {}) {
31
+ if (!Number.isFinite(maxPerSecond)) return target;
32
+ if (maxPerSecond <= 0) {
33
+ throw new Error(`maxRequestsPerSecond must be greater than 0, received: ${maxPerSecond}`);
34
+ }
35
+
36
+ return applyLimiter(target, rateLimiter(maxPerSecond, hooks));
37
+ }
38
+
39
+ /**
40
+ * Wrap a target with an already-constructed limiter.
41
+ *
42
+ * Separate from `throttleTarget` because several concurrent runs need to share
43
+ * a single limiter instance: one limiter per run would let the effective rate
44
+ * scale with the concurrency, which is the opposite of what a ceiling is for.
45
+ *
46
+ * @param {object} target
47
+ * @param {() => Promise<void>} limiter - From `rateLimiter()`.
48
+ * @param {(waitMs: number) => void} [onThrottle] - Per-run accounting.
49
+ * @returns {object}
50
+ */
51
+ export function applyLimiter(target, limiter, onThrottle) {
52
+ const wrapped = Object.create(Object.getPrototypeOf(target) ?? Object.prototype);
53
+
54
+ // Copy anything the adapter exposes that we are not wrapping — test helpers
55
+ // like mockAdapter's `_calls` have to survive the wrapping.
56
+ for (const key of allKeys(target)) {
57
+ const value = target[key];
58
+ wrapped[key] = typeof value === 'function' ? value.bind(target) : value;
59
+ }
60
+
61
+ for (const method of SCORING_METHODS) {
62
+ if (typeof target[method] !== 'function') continue;
63
+ wrapped[method] = async (...args) => {
64
+ const t0 = Date.now();
65
+ await limiter();
66
+ const waited = Date.now() - t0;
67
+ if (waited > 0) onThrottle?.(waited);
68
+ return target[method](...args);
69
+ };
70
+ }
71
+
72
+ return wrapped;
73
+ }
74
+
75
+ /**
76
+ * A sliding-window limiter. Resolves immediately while under the limit;
77
+ * otherwise waits until the oldest request in the window ages out.
78
+ */
79
+ export function rateLimiter(maxPerSecond, hooks = {}) {
80
+ const windowMs = 1000;
81
+ /** @type {number[]} timestamps of recent requests, oldest first */
82
+ const recent = [];
83
+
84
+ return async function acquire() {
85
+ for (;;) {
86
+ const now = Date.now();
87
+
88
+ // Drop anything that has aged out of the window.
89
+ while (recent.length > 0 && now - recent[0] >= windowMs) recent.shift();
90
+
91
+ if (recent.length < maxPerSecond) {
92
+ recent.push(now);
93
+ return;
94
+ }
95
+
96
+ const waitMs = windowMs - (now - recent[0]);
97
+ hooks.onThrottle?.(waitMs);
98
+ await delay(waitMs, hooks.signal);
99
+ }
100
+ };
101
+ }
102
+
103
+ function delay(ms, signal) {
104
+ if (ms <= 0) return Promise.resolve();
105
+ return new Promise((resolve) => {
106
+ const timer = setTimeout(finish, ms);
107
+ function finish() {
108
+ clearTimeout(timer);
109
+ signal?.removeEventListener?.('abort', finish);
110
+ resolve();
111
+ }
112
+ // An aborted run should stop waiting on a rate limit immediately; the
113
+ // scenario's own abort check is what ends the loop.
114
+ signal?.addEventListener?.('abort', finish, { once: true });
115
+ });
116
+ }
117
+
118
+ /** Own and inherited enumerable keys, so adapters built either way survive. */
119
+ function allKeys(target) {
120
+ const keys = new Set();
121
+ for (let obj = target; obj && obj !== Object.prototype; obj = Object.getPrototypeOf(obj)) {
122
+ for (const key of Object.getOwnPropertyNames(obj)) {
123
+ if (key !== 'constructor') keys.add(key);
124
+ }
125
+ }
126
+ return keys;
127
+ }
@@ -0,0 +1,6 @@
1
+ export function httpAdapter(config: any): {
2
+ scoreLogin(payload: any): Promise<any>;
3
+ scoreWithdrawal(payload: any): Promise<any>;
4
+ scoreAction(payload: any): Promise<any>;
5
+ reportEvent(eventType: any, data: any): Promise<void>;
6
+ };
@@ -0,0 +1,3 @@
1
+ export { httpAdapter } from "./http.js";
2
+ export { mockAdapter } from "./mock.js";
3
+ export { loggingAdapter } from "./logging.js";
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Logging adapter — logs payloads to a logger without sending anywhere.
3
+ *
4
+ * Used to inspect what a scenario produces before pointing at a real target.
5
+ * Returns ALLOW for every call by default.
6
+ *
7
+ * @example
8
+ * import { loggingAdapter } from 'fraud-sim/adapters';
9
+ *
10
+ * const target = loggingAdapter();
11
+ * await run('credential-stuffing', { target, options: { ... } });
12
+ * // → All payloads are printed to stdout; nothing is sent to the network
13
+ */
14
+ export function loggingAdapter(config?: {}): {
15
+ scoreLogin(payload: any): Promise<any>;
16
+ scoreWithdrawal(payload: any): Promise<any>;
17
+ scoreAction(payload: any): Promise<any>;
18
+ reportEvent(eventType: any, data: any): Promise<void>;
19
+ };
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Mock adapter — returns predetermined responses in sequence.
3
+ *
4
+ * Primary use case: unit testing scenarios. Lets a test verify that a
5
+ * scenario behaves correctly given a known sequence of target responses.
6
+ *
7
+ * @example
8
+ * const target = mockAdapter({
9
+ * scoreLogin: [
10
+ * { decision: 'ALLOW' },
11
+ * { decision: 'STEP_UP', riskScore: 0.5 },
12
+ * { decision: 'BLOCK', riskScore: 0.9, reasons: [{ code: 'NEW_DEVICE' }] },
13
+ * ],
14
+ * });
15
+ *
16
+ * After the queue is exhausted, the adapter returns the last response repeatedly,
17
+ * unless `defaultResponse` is provided.
18
+ */
19
+ export function mockAdapter(config?: {}): {
20
+ scoreLogin(payload: any): Promise<any>;
21
+ scoreWithdrawal(payload: any): Promise<any>;
22
+ scoreAction(payload: any): Promise<any>;
23
+ reportEvent(eventType: any, data: any): Promise<void>;
24
+ /**
25
+ * Test helper: return all payloads received by a given endpoint.
26
+ */
27
+ _calls(name: any): any;
28
+ /**
29
+ * Test helper: return all reported events.
30
+ */
31
+ _reportedEvents(): any[];
32
+ };