fraud-sim 0.2.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +462 -0
  2. package/README.md +621 -81
  3. package/bin/fraud-sim.js +625 -0
  4. package/examples/account-drain-demo/index.js +1 -1
  5. package/examples/adaptive-hardened-demo/index.js +106 -0
  6. package/examples/basic-credential-stuffing/index.js +4 -2
  7. package/examples/express-hardened-target/server.js +230 -0
  8. package/examples/express-mock-target/server.js +153 -2
  9. package/package.json +40 -8
  10. package/src/adapters/http.js +49 -7
  11. package/src/index.js +126 -13
  12. package/src/llm/anthropic.js +7 -0
  13. package/src/llm/deterministic.js +21 -7
  14. package/src/llm/openai.js +7 -0
  15. package/src/locales/index.js +193 -0
  16. package/src/reporting/aggregate.js +338 -0
  17. package/src/reporting/html.js +424 -0
  18. package/src/reporting/index.js +241 -0
  19. package/src/reporting/junit.js +171 -0
  20. package/src/reporting/money.js +105 -0
  21. package/src/runner.js +295 -12
  22. package/src/scenarios/account-drain.js +10 -2
  23. package/src/scenarios/adaptive-attacker.js +33 -11
  24. package/src/scenarios/card-testing.js +174 -0
  25. package/src/scenarios/credential-stuffing.js +146 -20
  26. package/src/scenarios/index.js +37 -6
  27. package/src/scenarios/legitimate-users.js +290 -0
  28. package/src/scenarios/mule-network.js +201 -0
  29. package/src/scenarios/promo-abuse.js +168 -0
  30. package/src/scenarios/session-hijack.js +234 -0
  31. package/src/scenarios/sim-swap-takeover.js +253 -0
  32. package/src/scenarios/slow-drain.js +171 -0
  33. package/src/scenarios/velocity-evasion.js +179 -0
  34. package/src/types.js +160 -0
  35. package/src/utils/clock.js +77 -0
  36. package/src/utils/faker-helpers.js +87 -2
  37. package/src/utils/index.js +69 -0
  38. package/src/utils/random-source.js +104 -0
  39. package/src/utils/random.js +257 -44
  40. package/src/utils/results.js +84 -0
  41. package/src/utils/rng.js +3 -4
  42. package/src/utils/throttle.js +127 -0
  43. package/types/adapters/http.d.ts +6 -0
  44. package/types/adapters/index.d.ts +3 -0
  45. package/types/adapters/logging.d.ts +19 -0
  46. package/types/adapters/mock.d.ts +32 -0
  47. package/types/index.d.ts +79 -0
  48. package/types/llm/anthropic.d.ts +17 -0
  49. package/types/llm/deterministic.d.ts +11 -0
  50. package/types/llm/index.d.ts +3 -0
  51. package/types/llm/openai.d.ts +17 -0
  52. package/types/locales/index.d.ts +146 -0
  53. package/types/reporting/aggregate.d.ts +126 -0
  54. package/types/reporting/html.d.ts +1 -0
  55. package/types/reporting/index.d.ts +99 -0
  56. package/types/reporting/junit.d.ts +13 -0
  57. package/types/reporting/money.d.ts +20 -0
  58. package/types/runner.d.ts +10 -0
  59. package/types/scenarios/account-drain.d.ts +25 -0
  60. package/types/scenarios/adaptive-attacker.d.ts +22 -0
  61. package/types/scenarios/card-testing.d.ts +28 -0
  62. package/types/scenarios/credential-stuffing.d.ts +23 -0
  63. package/types/scenarios/index.d.ts +261 -0
  64. package/types/scenarios/legitimate-users.d.ts +49 -0
  65. package/types/scenarios/mule-network.d.ts +17 -0
  66. package/types/scenarios/promo-abuse.d.ts +28 -0
  67. package/types/scenarios/session-hijack.d.ts +15 -0
  68. package/types/scenarios/sim-swap-takeover.d.ts +16 -0
  69. package/types/scenarios/slow-drain.d.ts +28 -0
  70. package/types/scenarios/velocity-evasion.d.ts +31 -0
  71. package/types/streaming/index.d.ts +1 -0
  72. package/types/streaming/sse.d.ts +34 -0
  73. package/types/types.d.ts +191 -0
  74. package/types/utils/clock.d.ts +39 -0
  75. package/types/utils/faker-helpers.d.ts +114 -0
  76. package/types/utils/index.d.ts +8 -0
  77. package/types/utils/random-source.d.ts +39 -0
  78. package/types/utils/random.d.ts +89 -0
  79. package/types/utils/results.d.ts +46 -0
  80. package/types/utils/rng.d.ts +36 -0
  81. package/types/utils/sleep.d.ts +7 -0
  82. package/types/utils/throttle.d.ts +33 -0
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Configurable for any target's response shape via the `responseMap` function.
5
5
  * This is the key flexibility point that keeps fraud-sim target-agnostic:
6
- * a user testing against Stripe Radar writes a different responseMap than
7
- * one testing against an in-house system.
6
+ * a target that answers `{ action: 'deny' }` writes a different responseMap
7
+ * than one that answers `{ decision: 'BLOCK' }`.
8
8
  *
9
9
  * @example
10
10
  * const target = httpAdapter({
@@ -20,7 +20,17 @@
20
20
  * reasons: raw.reasons,
21
21
  * raw,
22
22
  * }),
23
+ * // Only if your API signals a genuine block with an HTTP status:
24
+ * blockOnStatus: [403],
23
25
  * });
26
+ *
27
+ * ── On HTTP failures ──────────────────────────────────────────────────
28
+ * A non-2xx response is treated as an infrastructure error, NOT as a block,
29
+ * unless its status is listed in `blockOnStatus`. This matters: if a 429 or a
30
+ * 503 counted as a BLOCK, then rate-limiting the simulator or crashing the
31
+ * target under load would *raise* its measured block rate. A benchmark that
32
+ * rewards a target for falling over is measuring the opposite of what it
33
+ * claims, so errors are reported as errors and excluded from the block rate.
24
34
  */
25
35
 
26
36
  const DEFAULT_ENDPOINTS = {
@@ -52,6 +62,10 @@ export function httpAdapter(config) {
52
62
  const endpoints = { ...DEFAULT_ENDPOINTS, ...(config.endpoints ?? {}) };
53
63
  const responseMap = config.responseMap ?? DEFAULT_RESPONSE_MAP;
54
64
  const timeoutMs = config.timeoutMs ?? 5000;
65
+ // Statuses this specific API uses to signal a real block. Empty by default:
66
+ // no standard HTTP status means "the fraud engine decided to block", and
67
+ // guessing is how an outage gets scored as a successful defence.
68
+ const blockOnStatus = new Set(config.blockOnStatus ?? []);
55
69
  const fetchImpl = config.fetch ?? globalThis.fetch;
56
70
 
57
71
  if (!fetchImpl) {
@@ -76,14 +90,29 @@ export function httpAdapter(config) {
76
90
  const raw = await res.json().catch(() => ({}));
77
91
 
78
92
  if (!res.ok) {
79
- // Non-2xx response — fraud detection systems often return 4xx/5xx
80
- // for hard blocks. We surface this in the response, not as an error.
93
+ // Only statuses the caller has explicitly declared as blocks count as
94
+ // blocks. Everything else is infrastructure trouble and is reported as
95
+ // such — see the note at the top of this file.
96
+ if (blockOnStatus.has(res.status)) {
97
+ return {
98
+ decision: raw.decision ?? 'BLOCK',
99
+ riskScore: raw.risk_score ?? 1.0,
100
+ reasons: raw.reasons ?? [{ code: 'HTTP_BLOCK', label: `HTTP ${res.status}` }],
101
+ raw,
102
+ httpStatus: res.status,
103
+ };
104
+ }
105
+
81
106
  return {
82
- decision: raw.decision ?? 'BLOCK',
83
- riskScore: raw.risk_score ?? 1.0,
84
- reasons: raw.reasons ?? [{ code: 'HTTP_ERROR', label: `HTTP ${res.status}` }],
107
+ decision: 'ERROR',
108
+ riskScore: null,
109
+ reasons: [{ code: errorCodeFor(res.status), label: `HTTP ${res.status}` }],
85
110
  raw,
86
111
  httpStatus: res.status,
112
+ // Scenarios and the runner key off this to keep infrastructure
113
+ // failures out of the block rate.
114
+ infraError: true,
115
+ retryAfter: res.headers?.get?.('retry-after') ?? null,
87
116
  };
88
117
  }
89
118
 
@@ -97,6 +126,7 @@ export function httpAdapter(config) {
97
126
  riskScore: null,
98
127
  reasons: [{ code: 'NETWORK_ERROR', label: err.message }],
99
128
  raw: { error: err.message },
129
+ infraError: true,
100
130
  };
101
131
  } finally {
102
132
  clearTimeout(timer);
@@ -130,3 +160,15 @@ export function httpAdapter(config) {
130
160
  },
131
161
  };
132
162
  }
163
+
164
+ /**
165
+ * Name the failure so a report says why a run is unreliable, not just that it
166
+ * is. Rate limiting and server errors have very different remedies.
167
+ */
168
+ function errorCodeFor(status) {
169
+ if (status === 429) return 'RATE_LIMITED';
170
+ if (status >= 500) return 'SERVER_ERROR';
171
+ if (status === 401 || status === 403) return 'AUTH_ERROR';
172
+ if (status === 404) return 'ENDPOINT_NOT_FOUND';
173
+ return 'HTTP_ERROR';
174
+ }
package/src/index.js CHANGED
@@ -10,8 +10,33 @@
10
10
  * const report = await run('credential-stuffing', { target, options: {...} });
11
11
  */
12
12
 
13
- import { Runner } from './runner.js';
13
+ /**
14
+ * @typedef {import('./types.js').Scenario} Scenario
15
+ * @typedef {import('./types.js').ScenarioMetadata} ScenarioMetadata
16
+ * @typedef {import('./types.js').ScenarioReport} ScenarioReport
17
+ * @typedef {import('./types.js').SuiteReport} SuiteReport
18
+ * @typedef {import('./types.js').TargetAdapter} TargetAdapter
19
+ */
20
+
21
+ import { Runner, DEFAULT_MAX_REQUESTS_PER_SECOND } from './runner.js';
14
22
  import { scenarios as builtInScenarios } from './scenarios/index.js';
23
+ import { rateLimiter } from './utils/throttle.js';
24
+ import { legitimateTotalsFor } from './reporting/aggregate.js';
25
+
26
+ /**
27
+ * The fixed attacker IP pool, with declared geolocation and reputation.
28
+ *
29
+ * Re-exported here so a system under test can pre-warm its IP-intelligence
30
+ * cache before a run. Most fraud engines resolve IP intel asynchronously and
31
+ * fall back to neutral values on a cache miss — in a short simulation every IP
32
+ * is new, so proxy, abuse-score, and geo rules never fire and the run
33
+ * understates what the defence catches. Seeding from this pool removes that
34
+ * blind spot, along with any dependency on a third-party lookup during a run.
35
+ *
36
+ * Used by scenarios when `options.ipPersona` is anything other than 'random'.
37
+ */
38
+ export { DEMO_IP_POOL, demoIpPool, pickDemoIp } from './utils/random.js';
39
+ export { LOCALES, localeIds, resolveLocale, DEFAULT_LOCALE } from './locales/index.js';
15
40
 
16
41
  // Module-level registry — extensible via registerScenario()
17
42
  const registry = new Map();
@@ -24,10 +49,15 @@ for (const scenario of builtInScenarios) {
24
49
  *
25
50
  * @param {string} scenarioId - The ID of a registered scenario
26
51
  * @param {object} config
27
- * @param {object} config.target - A TargetAdapter
52
+ * @param {TargetAdapter} config.target - A TargetAdapter
28
53
  * @param {object} [config.options] - Scenario-specific options (merged with defaults)
29
54
  * @param {function} [config.onEvent] - Event callback (event) => void
30
55
  * @param {AbortSignal} [config.signal] - For cancellation
56
+ * @param {number} [config.seed] - Seed the run's randomness so it replays
57
+ * identically. Controls every generated value (IPs, devices, amounts,
58
+ * ordering) but not wall-clock timestamps.
59
+ * @param {number} [config.maxRequestsPerSecond=25] - Ceiling on outbound
60
+ * requests. `Infinity` disables it.
31
61
  * @returns {Promise<ScenarioReport>}
32
62
  */
33
63
  export async function run(scenarioId, config = {}) {
@@ -48,13 +78,19 @@ export async function run(scenarioId, config = {}) {
48
78
  /**
49
79
  * Run a suite of scenarios as a benchmark.
50
80
  *
51
- * v0.1 — runs sequentially. v0.4 will add parallel execution and full reporting.
52
- *
53
81
  * @param {object} config
54
82
  * @param {string} config.name - Human-readable suite name
55
83
  * @param {Array<{id: string, options?: object}>} config.scenarios
56
- * @param {object} config.target
84
+ * @param {TargetAdapter} config.target
57
85
  * @param {function} [config.onEvent]
86
+ * @param {AbortSignal} [config.signal]
87
+ * @param {number} [config.seed] - Seeds the suite. Each scenario gets a
88
+ * distinct derived seed, so the suite replays as a whole without every
89
+ * scenario drawing the same values.
90
+ * @param {number} [config.maxRequestsPerSecond=25] - Shared across the whole
91
+ * suite, including when scenarios run concurrently.
92
+ * @param {number} [config.concurrency=1] - How many scenarios run at once.
93
+ * See the note below before raising it.
58
94
  * @returns {Promise<SuiteReport>}
59
95
  */
60
96
  export async function runSuite(config = {}) {
@@ -62,27 +98,85 @@ export async function runSuite(config = {}) {
62
98
  throw new Error('A suite requires at least one scenario.');
63
99
  }
64
100
 
101
+ const concurrency = config.concurrency ?? 1;
102
+ if (!Number.isInteger(concurrency) || concurrency < 1) {
103
+ throw new Error(`concurrency must be a positive integer, received: ${config.concurrency}`);
104
+ }
105
+
65
106
  const startedAt = new Date().toISOString();
66
- const scenarioReports = [];
107
+ const maxRequestsPerSecond =
108
+ config.maxRequestsPerSecond ?? DEFAULT_MAX_REQUESTS_PER_SECOND;
109
+
110
+ // One limiter for the whole suite. A limiter per run would let the effective
111
+ // request ceiling scale with the concurrency — the opposite of a ceiling.
112
+ const limiter = Number.isFinite(maxRequestsPerSecond)
113
+ ? rateLimiter(maxRequestsPerSecond, { signal: config.signal })
114
+ : null;
67
115
 
68
- for (const entry of config.scenarios) {
69
- const report = await run(entry.id, {
116
+ const runOne = (entry, index) =>
117
+ run(entry.id, {
70
118
  target: config.target,
71
119
  options: entry.options ?? {},
72
120
  onEvent: config.onEvent,
121
+ signal: config.signal,
122
+ llm: entry.llm ?? config.llm,
123
+ ...(config.seed != null ? { seed: config.seed + index } : {}),
124
+ ...(config.locale != null ? { locale: config.locale } : {}),
125
+ maxRequestsPerSecond,
126
+ ...(limiter ? { limiter } : {}),
73
127
  });
74
- scenarioReports.push(report);
75
- }
128
+
129
+ const scenarioReports =
130
+ concurrency === 1
131
+ ? await runSequentially(config.scenarios, runOne)
132
+ : await runPooled(config.scenarios, runOne, concurrency);
76
133
 
77
134
  return {
78
135
  suiteName: config.name ?? 'unnamed-suite',
79
136
  startedAt,
80
137
  completedAt: new Date().toISOString(),
138
+ concurrency,
81
139
  scenarioReports,
82
140
  aggregate: aggregateReports(scenarioReports),
83
141
  };
84
142
  }
85
143
 
144
+ async function runSequentially(entries, runOne) {
145
+ const out = [];
146
+ for (const [index, entry] of entries.entries()) {
147
+ out.push(await runOne(entry, index));
148
+ }
149
+ return out;
150
+ }
151
+
152
+ /**
153
+ * Run up to `limit` scenarios at once, preserving input order in the results.
154
+ *
155
+ * ── Before raising concurrency ────────────────────────────────────────
156
+ * Scenarios sharing a target interleave their traffic, and most fraud engines
157
+ * keep per-user and per-IP state. Two scenarios touching the same account at
158
+ * once will influence each other's decisions, so a parallel suite measures a
159
+ * *combined* attack rather than each attack independently. That is realistic —
160
+ * real systems face concurrent attackers — but it is a different question from
161
+ * "does this scenario get caught", and results will not match a sequential run.
162
+ * Use distinct users per scenario if you want both speed and isolation.
163
+ */
164
+ async function runPooled(entries, runOne, limit) {
165
+ const results = new Array(entries.length);
166
+ let cursor = 0;
167
+
168
+ const workers = Array.from({ length: Math.min(limit, entries.length) }, async () => {
169
+ for (;;) {
170
+ const index = cursor++;
171
+ if (index >= entries.length) return;
172
+ results[index] = await runOne(entries[index], index);
173
+ }
174
+ });
175
+
176
+ await Promise.all(workers);
177
+ return results;
178
+ }
179
+
86
180
  /**
87
181
  * Returns metadata for all registered scenarios.
88
182
  * @returns {Array<ScenarioMetadata>}
@@ -92,6 +186,7 @@ export function listScenarios() {
92
186
  id: s.id,
93
187
  name: s.name,
94
188
  description: s.description,
189
+ traffic: s.traffic ?? 'attack',
95
190
  defaultOptions: s.defaultOptions,
96
191
  }));
97
192
  }
@@ -132,24 +227,42 @@ function validateScenario(scenario) {
132
227
  if (scenario.defaultOptions && typeof scenario.defaultOptions !== 'object') {
133
228
  throw new Error('Scenario.defaultOptions must be an object.');
134
229
  }
230
+ if (scenario.traffic != null && !['attack', 'legitimate'].includes(scenario.traffic)) {
231
+ throw new Error(`Scenario.traffic must be "attack" or "legitimate", received: "${scenario.traffic}".`);
232
+ }
135
233
  }
136
234
 
137
235
  function aggregateReports(reports) {
236
+ // Attack and legitimate traffic answer opposite questions — a block is a
237
+ // catch on one and a false positive on the other — so they are never summed.
238
+ const attacks = reports.filter((r) => r.traffic !== 'legitimate');
239
+
138
240
  const totals = {
139
241
  attempts: 0,
140
242
  blocked: 0,
141
243
  stepUp: 0,
142
244
  allowed: 0,
245
+ errors: 0,
143
246
  };
144
- for (const r of reports) {
247
+ for (const r of attacks) {
145
248
  totals.attempts += r.attempts ?? 0;
146
249
  totals.blocked += r.blocked ?? 0;
147
250
  totals.stepUp += r.stepUp ?? 0;
148
251
  totals.allowed += r.allowed ?? 0;
252
+ totals.errors += r.errors ?? 0;
149
253
  }
254
+
255
+ // Same rule as a single run: attempts that never received a decision are not
256
+ // evidence about the defence, so they are excluded from the rates rather than
257
+ // being credited to it. See Runner.run.
258
+ const scoredAttempts = Math.max(0, totals.attempts - totals.errors);
259
+
150
260
  return {
151
261
  ...totals,
152
- blockRate: totals.attempts > 0 ? totals.blocked / totals.attempts : 0,
153
- stepUpRate: totals.attempts > 0 ? totals.stepUp / totals.attempts : 0,
262
+ scoredAttempts,
263
+ blockRate: scoredAttempts > 0 ? totals.blocked / scoredAttempts : 0,
264
+ stepUpRate: scoredAttempts > 0 ? totals.stepUp / scoredAttempts : 0,
265
+ errorRate: totals.attempts > 0 ? totals.errors / totals.attempts : 0,
266
+ legitimate: legitimateTotalsFor(reports),
154
267
  };
155
268
  }
@@ -22,6 +22,13 @@ const DEFAULT_MAX_TOKENS = 1024;
22
22
  const SYSTEM_PROMPT = `You are simulating a fraud actor probing a fintech security system.
23
23
  You can only observe API responses — you cannot see the rules.
24
24
  Your goal: based on the attack history, decide what to change for the next attempt.
25
+ Each history entry shows the exact parameters sent that round and the decision returned.
26
+ You control these parameters:
27
+ "ip" - source IPv4 address
28
+ "userAgent" - browser user agent string
29
+ "deviceFingerprint" - device fingerprint; reuse one from history to replay that device
30
+ "delayMs" - how long you would wait before the next attempt
31
+ Omit a parameter to keep its previous value.
25
32
  Respond ONLY with valid JSON: { "reasoning": "...", "parameters": {...} }`;
26
33
 
27
34
  /**
@@ -66,6 +66,11 @@ export function deterministic(config = {}) {
66
66
  const reasonCodes = extractReasonCodes(lastAttempt);
67
67
  const category = classify(reasonCodes);
68
68
 
69
+ // A step-up is not a block, and saying so matters: the reasoning is read
70
+ // by a human in a report, and an attacker that mis-describes what just
71
+ // happened to it does not look like it is reasoning at all.
72
+ const outcome = lastAttempt?.decision === 'STEP_UP' ? 'challenged' : 'blocked';
73
+
69
74
  const parameters = {
70
75
  ip: rng.ip(),
71
76
  deviceFingerprint: rng.alphanumeric(32),
@@ -76,15 +81,15 @@ export function deterministic(config = {}) {
76
81
  let reasoning;
77
82
  switch (category) {
78
83
  case 'ip':
79
- reasoning = `Last attempt was blocked on an IP-related reason (${reasonCodes.join(', ')}). Rotating to a fresh IP address.`;
84
+ reasoning = `Last attempt was ${outcome} on an IP-related reason (${reasonCodes.join(', ')}). Rotating to a fresh IP address.`;
80
85
  break;
81
86
  case 'device':
82
- reasoning = `Last attempt was blocked on a device-related reason (${reasonCodes.join(', ')}). Rotating the device fingerprint and user agent.`;
87
+ reasoning = `Last attempt was ${outcome} on a device-related reason (${reasonCodes.join(', ')}). Rotating the device fingerprint and user agent.`;
83
88
  break;
84
89
  case 'velocity':
85
90
  // Velocity blocks: slow down AND change IP to dodge rate limits.
86
91
  parameters.delayMs = rng.int(5000, 15000);
87
- reasoning = `Last attempt was blocked on a velocity-related reason (${reasonCodes.join(', ')}). Waiting longer between attempts and changing IP.`;
92
+ reasoning = `Last attempt was ${outcome} on a velocity-related reason (${reasonCodes.join(', ')}). Waiting longer between attempts and changing IP.`;
88
93
  break;
89
94
  case 'none':
90
95
  reasoning =
@@ -93,7 +98,7 @@ export function deterministic(config = {}) {
93
98
  : 'No clear block pattern to react to. Randomising all attributes to probe for gaps.';
94
99
  break;
95
100
  default:
96
- reasoning = `Blocked for an unrecognised reason (${reasonCodes.join(', ')}). Randomising all attributes.`;
101
+ reasoning = `${outcome === 'challenged' ? 'Challenged' : 'Blocked'} for an unrecognised reason (${reasonCodes.join(', ')}). Randomising all attributes.`;
97
102
  }
98
103
 
99
104
  return { reasoning, parameters };
@@ -116,10 +121,19 @@ function classify(reasonCodes) {
116
121
  return signals.some((sig) => upper.includes(sig));
117
122
  });
118
123
 
119
- // Velocity and device are checked before IP because velocity codes can also
120
- // mention IP (e.g. IP_VELOCITY) and the velocity response is the stronger one.
124
+ // Order is by how *actionable* the signal is, not how severe it looks.
125
+ //
126
+ // Velocity first: those codes often also mention IP (IP_VELOCITY), and the
127
+ // velocity response — slow down — is the stronger move.
128
+ //
129
+ // Then IP before device. A defence that reports NEW_DEVICE alongside an IP
130
+ // reason is not telling the attacker anything it can act on: the device is
131
+ // unrecognised *because* the attacker rotated it, and it will be unrecognised
132
+ // again next round no matter what. The IP is the part that can actually be
133
+ // changed for the better. Checking device first made the attacker announce it
134
+ // was rotating a fingerprint while the thing blocking it was the address.
121
135
  if (matches(VELOCITY_SIGNALS)) return 'velocity';
122
- if (matches(DEVICE_SIGNALS)) return 'device';
123
136
  if (matches(IP_SIGNALS)) return 'ip';
137
+ if (matches(DEVICE_SIGNALS)) return 'device';
124
138
  return 'unknown';
125
139
  }
package/src/llm/openai.js CHANGED
@@ -21,6 +21,13 @@ const TIMEOUT_MS = 10000;
21
21
  const SYSTEM_PROMPT = `You are simulating a fraud actor probing a fintech security system.
22
22
  You can only observe API responses — you cannot see the rules.
23
23
  Your goal: based on the attack history, decide what to change for the next attempt.
24
+ Each history entry shows the exact parameters sent that round and the decision returned.
25
+ You control these parameters:
26
+ "ip" - source IPv4 address
27
+ "userAgent" - browser user agent string
28
+ "deviceFingerprint" - device fingerprint; reuse one from history to replay that device
29
+ "delayMs" - how long you would wait before the next attempt
30
+ Omit a parameter to keep its previous value.
24
31
  Respond ONLY with valid JSON: { "reasoning": "...", "parameters": {...} }`;
25
32
 
26
33
  /**
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Locale packs — the market a simulation pretends to be in.
3
+ *
4
+ * A fraud system is fed by data that looks like its market: account numbers of
5
+ * the right length, banks that exist, phone numbers in the right format, amounts
6
+ * in the right currency. Sending Nigerian-shaped payloads at a US processor
7
+ * produces validation errors, not fraud decisions — and a run full of 400s
8
+ * measures nothing.
9
+ *
10
+ * So the market is a parameter. `run()` and `runSuite()` take a `locale`, and
11
+ * every generator draws from it — the same way `seed` works, and scoped the same
12
+ * way, so no builder needs an extra argument threaded through it.
13
+ *
14
+ * ── What a pack is not ────────────────────────────────────────────────
15
+ * It is not a claim about a country. The banks and names here are a small,
16
+ * plausible sample chosen so generated payloads pass format validation; they are
17
+ * not a directory, and no real customer, account or number is represented. Every
18
+ * value is synthetic.
19
+ *
20
+ * @example
21
+ * await run('account-drain', { target, locale: 'US', options: { ... } });
22
+ *
23
+ * Adding a market is a pull request against this file: fill the same fields,
24
+ * keeping the data synthetic and the formats real.
25
+ */
26
+
27
+ /**
28
+ * @typedef {object} Locale
29
+ * @property {string} id - ISO 3166-1 alpha-2 country code.
30
+ * @property {string} name
31
+ * @property {string} currency - ISO 4217 code.
32
+ * @property {number} minorUnits - Minor units per major unit (100 for most).
33
+ * @property {string[]} banks
34
+ * @property {string[]} firstNames
35
+ * @property {string[]} lastNames
36
+ * @property {() => string} accountNumber - Market-shaped account identifier.
37
+ * @property {() => string} phoneNumber - Market-shaped subscriber number.
38
+ * @property {Array<{city: string, lat: number, lon: number}>} cities - Domestic
39
+ * locations for the IP pool's "this looks like home" entries.
40
+ */
41
+
42
+ /** Digits helper shared by the packs; takes the run's RNG so output stays seeded. */
43
+ const digits = (rand, n) => {
44
+ let out = '';
45
+ for (let i = 0; i < n; i++) out += Math.floor(rand() * 10).toString();
46
+ return out;
47
+ };
48
+
49
+ const pick = (rand, arr) => arr[Math.floor(rand() * arr.length)];
50
+
51
+ export const LOCALES = {
52
+ NG: {
53
+ id: 'NG',
54
+ name: 'Nigeria',
55
+ currency: 'NGN',
56
+ minorUnits: 100,
57
+ banks: [
58
+ 'GTBank', 'UBA', 'Access', 'Zenith', 'First Bank',
59
+ 'FCMB', 'Sterling', 'Stanbic IBTC', 'Fidelity', 'Wema',
60
+ ],
61
+ firstNames: [
62
+ 'Adaeze', 'Chinedu', 'Folake', 'Tunde', 'Aisha', 'Emeka', 'Nkechi',
63
+ 'Bayo', 'Ngozi', 'Olumide', 'Funmi', 'Kayode', 'Hauwa', 'Ifeanyi',
64
+ ],
65
+ lastNames: [
66
+ 'Adeyemi', 'Okonkwo', 'Ibrahim', 'Nwosu', 'Bello', 'Eze', 'Yusuf',
67
+ 'Okoye', 'Lawal', 'Obi', 'Musa', 'Achebe', 'Adamu', 'Chukwu',
68
+ ],
69
+ // NUBAN: 10 digits.
70
+ accountNumber: (rand) => digits(rand, 10),
71
+ phoneNumber: (rand) =>
72
+ pick(rand, ['0803', '0806', '0703', '0705', '0813', '0816', '0906', '0913']) +
73
+ digits(rand, 7),
74
+ cities: [
75
+ { city: 'Lagos', lat: 6.5244, lon: 3.3792 },
76
+ { city: 'Abuja', lat: 9.0765, lon: 7.3986 },
77
+ { city: 'Port Harcourt', lat: 4.8156, lon: 7.0498 },
78
+ ],
79
+ },
80
+
81
+ US: {
82
+ id: 'US',
83
+ name: 'United States',
84
+ currency: 'USD',
85
+ minorUnits: 100,
86
+ banks: [
87
+ 'Chase', 'Bank of America', 'Wells Fargo', 'Citibank', 'US Bank',
88
+ 'PNC', 'Truist', 'Capital One', 'TD Bank', 'Fifth Third',
89
+ ],
90
+ firstNames: [
91
+ 'James', 'Mary', 'Robert', 'Patricia', 'Michael', 'Jennifer', 'David',
92
+ 'Linda', 'William', 'Elizabeth', 'Maria', 'Jose', 'Aisha', 'Wei',
93
+ ],
94
+ lastNames: [
95
+ 'Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller',
96
+ 'Davis', 'Rodriguez', 'Martinez', 'Nguyen', 'Kim', 'Patel', 'Cohen',
97
+ ],
98
+ // ACH account numbers vary; 8-12 digits is the common range.
99
+ accountNumber: (rand) => digits(rand, 8 + Math.floor(rand() * 5)),
100
+ // NANP, avoiding reserved 555 exchange codes being the only option.
101
+ phoneNumber: (rand) =>
102
+ `+1${pick(rand, ['212', '312', '415', '469', '512', '646', '702', '917'])}${digits(rand, 7)}`,
103
+ cities: [
104
+ { city: 'New York', lat: 40.7128, lon: -74.006 },
105
+ { city: 'Chicago', lat: 41.8781, lon: -87.6298 },
106
+ { city: 'Austin', lat: 30.2672, lon: -97.7431 },
107
+ ],
108
+ },
109
+
110
+ GB: {
111
+ id: 'GB',
112
+ name: 'United Kingdom',
113
+ currency: 'GBP',
114
+ minorUnits: 100,
115
+ banks: [
116
+ 'Barclays', 'HSBC', 'Lloyds', 'NatWest', 'Santander UK',
117
+ 'Halifax', 'Nationwide', 'Monzo', 'Starling', 'TSB',
118
+ ],
119
+ firstNames: [
120
+ 'Oliver', 'Amelia', 'Harry', 'Isla', 'George', 'Ava', 'Noah',
121
+ 'Freya', 'Priya', 'Mohammed', 'Sophie', 'Jack', 'Chloe', 'Adam',
122
+ ],
123
+ lastNames: [
124
+ 'Smith', 'Jones', 'Taylor', 'Brown', 'Williams', 'Wilson', 'Davies',
125
+ 'Patel', 'Evans', 'Thomas', 'Khan', 'Roberts', 'Walker', 'Wright',
126
+ ],
127
+ // UK account numbers are 8 digits (sort code is separate).
128
+ accountNumber: (rand) => digits(rand, 8),
129
+ phoneNumber: (rand) => `+447${digits(rand, 9)}`,
130
+ cities: [
131
+ { city: 'London', lat: 51.5072, lon: -0.1276 },
132
+ { city: 'Manchester', lat: 53.4808, lon: -2.2426 },
133
+ { city: 'Edinburgh', lat: 55.9533, lon: -3.1883 },
134
+ ],
135
+ },
136
+
137
+ KE: {
138
+ id: 'KE',
139
+ name: 'Kenya',
140
+ currency: 'KES',
141
+ minorUnits: 100,
142
+ banks: [
143
+ 'Equity Bank', 'KCB', 'Co-operative Bank', 'Absa Kenya', 'Standard Chartered',
144
+ 'NCBA', 'DTB', 'Stanbic Kenya', 'I&M Bank', 'Family Bank',
145
+ ],
146
+ firstNames: [
147
+ 'Wanjiku', 'Otieno', 'Achieng', 'Kamau', 'Njeri', 'Mwangi', 'Akinyi',
148
+ 'Kipchoge', 'Wafula', 'Chebet', 'Omondi', 'Nyambura', 'Barasa', 'Amina',
149
+ ],
150
+ lastNames: [
151
+ 'Kariuki', 'Ochieng', 'Mutiso', 'Wanjala', 'Kimani', 'Odhiambo',
152
+ 'Njoroge', 'Mutua', 'Cheruiyot', 'Maina', 'Owino', 'Githinji',
153
+ ],
154
+ accountNumber: (rand) => digits(rand, 12),
155
+ phoneNumber: (rand) => `+2547${digits(rand, 8)}`,
156
+ cities: [
157
+ { city: 'Nairobi', lat: -1.2864, lon: 36.8172 },
158
+ { city: 'Mombasa', lat: -4.0435, lon: 39.6682 },
159
+ { city: 'Kisumu', lat: -0.0917, lon: 34.768 },
160
+ ],
161
+ },
162
+ };
163
+
164
+ /** The market used when a run does not name one. */
165
+ export const DEFAULT_LOCALE = 'US';
166
+
167
+ /**
168
+ * Resolve a locale id to its pack.
169
+ *
170
+ * Unknown ids throw rather than falling back: silently generating US-shaped
171
+ * data for a run that asked for `DE` would produce a result that looks valid
172
+ * and measures the wrong market.
173
+ *
174
+ * @param {string|Locale} [id]
175
+ * @returns {Locale}
176
+ */
177
+ export function resolveLocale(id = DEFAULT_LOCALE) {
178
+ if (id && typeof id === 'object') return id;
179
+
180
+ const pack = LOCALES[String(id).toUpperCase()];
181
+ if (!pack) {
182
+ throw new Error(
183
+ `Unknown locale: "${id}". Available: ${Object.keys(LOCALES).join(', ')}. ` +
184
+ 'Pass a locale object to use a market that is not bundled.'
185
+ );
186
+ }
187
+ return pack;
188
+ }
189
+
190
+ /** Ids of the bundled packs. */
191
+ export function localeIds() {
192
+ return Object.keys(LOCALES);
193
+ }