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/src/adapters/http.js
CHANGED
|
@@ -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
|
|
7
|
-
* one
|
|
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
|
-
//
|
|
80
|
-
//
|
|
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:
|
|
83
|
-
riskScore:
|
|
84
|
-
reasons:
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
153
|
-
|
|
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
|
}
|
package/src/llm/anthropic.js
CHANGED
|
@@ -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
|
/**
|
package/src/llm/deterministic.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
//
|
|
120
|
-
//
|
|
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
|
+
}
|