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/bin/fraud-sim.js
ADDED
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* fraud-sim command line interface.
|
|
4
|
+
*
|
|
5
|
+
* Exists so a team can gate CI on their fraud defence without writing any
|
|
6
|
+
* JavaScript, and so an assessment is one command rather than a script someone
|
|
7
|
+
* has to maintain.
|
|
8
|
+
*
|
|
9
|
+
* Exit codes are the contract with CI:
|
|
10
|
+
* 0 ran, and the gate passed (or no gate was set)
|
|
11
|
+
* 1 ran, and the gate failed — an attack succeeded, a block rate was
|
|
12
|
+
* below --fail-under, or too many legitimate customers were blocked
|
|
13
|
+
* 2 usage error — bad flags, unknown scenario
|
|
14
|
+
* 3 the run itself failed, or the target was too unreliable to measure
|
|
15
|
+
*
|
|
16
|
+
* The 1/3 split matters: a weak defence and an unreachable target are different
|
|
17
|
+
* problems, and a pipeline should not report them the same way.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { writeFileSync } from 'node:fs';
|
|
21
|
+
import { resolve, extname } from 'node:path';
|
|
22
|
+
|
|
23
|
+
import { run, runSuite, listScenarios, resolveLocale, localeIds } from '../src/index.js';
|
|
24
|
+
import { httpAdapter } from '../src/adapters/http.js';
|
|
25
|
+
import { generateReport, summarise } from '../src/reporting/index.js';
|
|
26
|
+
|
|
27
|
+
const VERSION = '0.5.0';
|
|
28
|
+
|
|
29
|
+
const FORMAT_BY_EXT = {
|
|
30
|
+
'.html': 'html',
|
|
31
|
+
'.htm': 'html',
|
|
32
|
+
'.xml': 'junit',
|
|
33
|
+
'.json': 'json',
|
|
34
|
+
'.txt': 'text',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
async function main() {
|
|
38
|
+
const argv = process.argv.slice(2);
|
|
39
|
+
|
|
40
|
+
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h' || argv[0] === 'help') {
|
|
41
|
+
process.stdout.write(HELP);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (argv[0] === '--version' || argv[0] === '-v') {
|
|
46
|
+
process.stdout.write(`${VERSION}\n`);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const command = argv[0];
|
|
51
|
+
const flags = parseFlags(argv.slice(1));
|
|
52
|
+
|
|
53
|
+
if (command === 'list') return cmdList(flags);
|
|
54
|
+
if (command === 'run') return cmdRun(flags);
|
|
55
|
+
if (command === 'suite') return cmdSuite(flags);
|
|
56
|
+
|
|
57
|
+
throw usage(`Unknown command "${command}". Expected: list, run, suite.`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── commands ────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
function cmdList(flags) {
|
|
63
|
+
const scenarios = listScenarios();
|
|
64
|
+
|
|
65
|
+
if (flags.json) {
|
|
66
|
+
process.stdout.write(JSON.stringify(scenarios, null, 2) + '\n');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const width = Math.max(...scenarios.map((s) => s.id.length));
|
|
71
|
+
process.stdout.write(`\n ${scenarios.length} scenarios\n\n`);
|
|
72
|
+
for (const s of scenarios) {
|
|
73
|
+
process.stdout.write(` ${s.id.padEnd(width)} ${s.description}\n`);
|
|
74
|
+
}
|
|
75
|
+
process.stdout.write(
|
|
76
|
+
`\n Options for a scenario: fraud-sim list --json\n` +
|
|
77
|
+
` Run one: fraud-sim run <id> --target-url <url>\n\n`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function cmdRun(flags) {
|
|
82
|
+
const scenarioId = flags._[0];
|
|
83
|
+
if (!scenarioId) throw usage('run needs a scenario id. See: fraud-sim list');
|
|
84
|
+
|
|
85
|
+
const known = listScenarios().map((s) => s.id);
|
|
86
|
+
if (!known.includes(scenarioId)) {
|
|
87
|
+
throw usage(`Unknown scenario "${scenarioId}". Available: ${known.join(', ')}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
resolveMarket(flags); // fail on a bad country code before sending anything
|
|
91
|
+
assertFalsePositiveGateMeasurable([scenarioId], flags);
|
|
92
|
+
const target = buildTarget(flags);
|
|
93
|
+
const report = await run(scenarioId, {
|
|
94
|
+
target,
|
|
95
|
+
options: optionsFor(scenarioId, flags),
|
|
96
|
+
...runConfig(flags),
|
|
97
|
+
onEvent: progressReporter(flags),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return finish([report], flags, scenarioId);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function cmdSuite(flags) {
|
|
104
|
+
const known = listScenarios().map((s) => s.id);
|
|
105
|
+
|
|
106
|
+
let ids;
|
|
107
|
+
if (flags.all) {
|
|
108
|
+
ids = known;
|
|
109
|
+
} else if (flags.scenarios) {
|
|
110
|
+
ids = String(flags.scenarios)
|
|
111
|
+
.split(',')
|
|
112
|
+
.map((s) => s.trim())
|
|
113
|
+
.filter(Boolean);
|
|
114
|
+
const unknown = ids.filter((id) => !known.includes(id));
|
|
115
|
+
if (unknown.length) throw usage(`Unknown scenario(s): ${unknown.join(', ')}`);
|
|
116
|
+
} else {
|
|
117
|
+
throw usage('suite needs --all or --scenarios a,b,c');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
resolveMarket(flags); // fail on a bad country code before sending anything
|
|
121
|
+
assertFalsePositiveGateMeasurable(ids, flags);
|
|
122
|
+
const target = buildTarget(flags);
|
|
123
|
+
const result = await runSuite({
|
|
124
|
+
name: flags.name ?? 'fraud-sim suite',
|
|
125
|
+
target,
|
|
126
|
+
scenarios: ids.map((id) => ({ id, options: optionsFor(id, flags) })),
|
|
127
|
+
concurrency: flags.concurrency ?? 1,
|
|
128
|
+
...runConfig(flags),
|
|
129
|
+
onEvent: progressReporter(flags),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
return finish(result.scenarioReports, flags, result.suiteName);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── shared ──────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Identifiers a scenario requires but cannot invent for itself. Declared as
|
|
139
|
+
* `null` in a scenario's defaultOptions, meaning "you must supply this".
|
|
140
|
+
*
|
|
141
|
+
* The CLI fills them so `suite --all` works without the caller hand-writing an
|
|
142
|
+
* option set per scenario. Each scenario gets its own slice of the account
|
|
143
|
+
* pool, because scenarios sharing accounts contaminate each other's velocity
|
|
144
|
+
* counters — the same isolation problem as running them concurrently.
|
|
145
|
+
*/
|
|
146
|
+
const IDENTITY_OPTIONS = {
|
|
147
|
+
targetUserIds: (users) => users,
|
|
148
|
+
targetUserId: (users) => users[0],
|
|
149
|
+
sourceUserId: (users) => users[0],
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Build the option set for one scenario.
|
|
154
|
+
*
|
|
155
|
+
* Precedence, lowest first:
|
|
156
|
+
* 1. synthesised identifiers
|
|
157
|
+
* 2. global `--option key=value`, applied only where the scenario declares
|
|
158
|
+
* that key — otherwise a global `--option delayMs=5` would be rejected by
|
|
159
|
+
* every scenario that has no delayMs, which is the opposite of useful
|
|
160
|
+
* 3. scoped `--option <scenario>.<key>=<value>`, which always wins
|
|
161
|
+
*/
|
|
162
|
+
function optionsFor(scenarioId, flags) {
|
|
163
|
+
const meta = listScenarios().find((s) => s.id === scenarioId);
|
|
164
|
+
const declared = meta?.defaultOptions ?? {};
|
|
165
|
+
const options = {};
|
|
166
|
+
|
|
167
|
+
const users = usersFor(scenarioId, flags);
|
|
168
|
+
for (const [key, build] of Object.entries(IDENTITY_OPTIONS)) {
|
|
169
|
+
if (key in declared && declared[key] === null) options[key] = build(users);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// `targetUserIds` supersedes `targetUserId` where a scenario accepts both.
|
|
173
|
+
if ('targetUserIds' in options && 'targetUserId' in options) delete options.targetUserId;
|
|
174
|
+
|
|
175
|
+
for (const [key, value] of Object.entries(flags.option)) {
|
|
176
|
+
if (key in declared) options[key] = value;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
for (const [key, value] of Object.entries(flags.scopedOption[scenarioId] ?? {})) {
|
|
180
|
+
options[key] = value;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return options;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** A distinct block of synthetic accounts per scenario. */
|
|
187
|
+
function usersFor(scenarioId, flags) {
|
|
188
|
+
const count = flags.users ?? 12;
|
|
189
|
+
const prefix = flags['user-prefix'] ?? 'sim_user';
|
|
190
|
+
// A stable per-scenario offset keeps runs reproducible while keeping the
|
|
191
|
+
// account ranges disjoint.
|
|
192
|
+
const offset = [...scenarioId].reduce((a, c) => a + c.charCodeAt(0), 0) % 900;
|
|
193
|
+
return Array.from({ length: count }, (_, i) => `${prefix}_${1000 + offset * 100 + i}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
function runConfig(flags) {
|
|
198
|
+
const config = {};
|
|
199
|
+
if (flags.seed != null) config.seed = flags.seed;
|
|
200
|
+
if (flags.rps != null) config.maxRequestsPerSecond = flags.rps === 0 ? Infinity : flags.rps;
|
|
201
|
+
if (flags.locale != null) config.locale = flags.locale;
|
|
202
|
+
return config;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The market to simulate, validated before anything is sent.
|
|
207
|
+
*
|
|
208
|
+
* Checking up front matters: an unknown country code would otherwise surface as
|
|
209
|
+
* a wall of validation errors from the target, which reads as "your fraud engine
|
|
210
|
+
* is broken" rather than "you typed the wrong code".
|
|
211
|
+
*/
|
|
212
|
+
function resolveMarket(flags) {
|
|
213
|
+
try {
|
|
214
|
+
return resolveLocale(flags.locale);
|
|
215
|
+
} catch (err) {
|
|
216
|
+
throw usage(err.message);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* A false-positive gate over a run with no legitimate traffic would pass by
|
|
222
|
+
* default — zero customers, zero refused. Refuse it before anything is sent.
|
|
223
|
+
*/
|
|
224
|
+
function assertFalsePositiveGateMeasurable(ids, flags) {
|
|
225
|
+
if (!Number.isFinite(flags['max-false-positives'])) return;
|
|
226
|
+
const legitimate = listScenarios()
|
|
227
|
+
.filter((s) => s.traffic === 'legitimate')
|
|
228
|
+
.map((s) => s.id);
|
|
229
|
+
if (!ids.some((id) => legitimate.includes(id))) {
|
|
230
|
+
throw usage(
|
|
231
|
+
'--max-false-positives needs legitimate traffic in the run. ' +
|
|
232
|
+
`Use suite --all, or include ${legitimate.join(', ')} in --scenarios.`
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function buildTarget(flags) {
|
|
238
|
+
if (!flags['target-url']) {
|
|
239
|
+
throw usage('--target-url is required (e.g. --target-url https://api.example.com)');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const endpoints = {};
|
|
243
|
+
if (flags['endpoint-login']) endpoints.scoreLogin = endpointPath(flags['endpoint-login']);
|
|
244
|
+
if (flags['endpoint-withdrawal']) endpoints.scoreWithdrawal = endpointPath(flags['endpoint-withdrawal']);
|
|
245
|
+
if (flags['endpoint-action']) endpoints.scoreAction = endpointPath(flags['endpoint-action']);
|
|
246
|
+
|
|
247
|
+
return httpAdapter({
|
|
248
|
+
baseUrl: flags['target-url'],
|
|
249
|
+
headers: flags.header,
|
|
250
|
+
endpoints: Object.keys(endpoints).length ? endpoints : undefined,
|
|
251
|
+
timeoutMs: flags.timeout ?? 5000,
|
|
252
|
+
blockOnStatus: flags['block-on-status'] ?? [],
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Validate an endpoint path, catching one specific way it arrives wrong.
|
|
258
|
+
*
|
|
259
|
+
* Git Bash and other MSYS shells on Windows rewrite arguments that look like
|
|
260
|
+
* absolute Unix paths into Windows paths, so `--endpoint-login /score/login`
|
|
261
|
+
* silently becomes `C:/Program Files/Git/score/login`. Every request then 404s
|
|
262
|
+
* and the run reports that the *target* is unreliable — sending the user to
|
|
263
|
+
* debug a system that was never contacted. Fail loudly with the actual fix
|
|
264
|
+
* instead.
|
|
265
|
+
*/
|
|
266
|
+
function endpointPath(value) {
|
|
267
|
+
if (/^[A-Za-z]:[\\/]/.test(value)) {
|
|
268
|
+
throw usage(
|
|
269
|
+
`--endpoint path "${value}" was rewritten into a filesystem path by your shell.\n` +
|
|
270
|
+
' This is MSYS/Git Bash path conversion on Windows. Either prefix the command:\n' +
|
|
271
|
+
' MSYS_NO_PATHCONV=1 fraud-sim ...\n' +
|
|
272
|
+
' or pass the path with a leading double slash: --endpoint-login //score/login'
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Accept `score/login` as well as `/score/login`; the difference never matters
|
|
277
|
+
// to the caller and getting it wrong costs a confusing 404.
|
|
278
|
+
const path = value.startsWith('//') ? value.slice(1) : value;
|
|
279
|
+
return path.startsWith('/') ? path : `/${path}`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Live progress on a TTY; silent in a pipeline unless something is wrong. */
|
|
283
|
+
function progressReporter(flags) {
|
|
284
|
+
if (flags.quiet) return undefined;
|
|
285
|
+
|
|
286
|
+
const interactive = process.stdout.isTTY;
|
|
287
|
+
let scored = 0;
|
|
288
|
+
const seenErrors = new Set();
|
|
289
|
+
|
|
290
|
+
return (event) => {
|
|
291
|
+
if (event.type === 'response') {
|
|
292
|
+
scored++;
|
|
293
|
+
if (interactive && scored % 10 === 0) {
|
|
294
|
+
process.stdout.write(`\r scoring… ${scored} events`);
|
|
295
|
+
}
|
|
296
|
+
} else if (event.type === 'warning') {
|
|
297
|
+
if (interactive) process.stdout.write('\r');
|
|
298
|
+
process.stderr.write(` ! ${event.message}\n`);
|
|
299
|
+
} else if (event.type === 'error') {
|
|
300
|
+
// Report *why* attempts are failing, not just how many. Deduplicated:
|
|
301
|
+
// a broken target produces the same message hundreds of times, and the
|
|
302
|
+
// useful information is the set of distinct causes.
|
|
303
|
+
const message = String(event.message ?? 'unknown error');
|
|
304
|
+
if (!seenErrors.has(message) && seenErrors.size < 5) {
|
|
305
|
+
seenErrors.add(message);
|
|
306
|
+
if (interactive) process.stdout.write('\r');
|
|
307
|
+
process.stderr.write(` x ${message}\n`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function finish(reports, flags, name) {
|
|
314
|
+
const market = resolveMarket(flags);
|
|
315
|
+
|
|
316
|
+
if (process.stdout.isTTY && !flags.quiet) process.stdout.write('\r[K');
|
|
317
|
+
|
|
318
|
+
const reportOptions = {
|
|
319
|
+
title: flags.title ?? name,
|
|
320
|
+
targetName: flags['target-name'] ?? flags['target-url'],
|
|
321
|
+
version: VERSION,
|
|
322
|
+
// Denominated in the market's own currency unless overridden — simulating
|
|
323
|
+
// a UK bank and reporting in naira would be a strange default.
|
|
324
|
+
currency: flags.currency ?? market.currency,
|
|
325
|
+
market: market.id,
|
|
326
|
+
failUnder: flags['fail-under-each'] ?? flags['fail-under'],
|
|
327
|
+
failUnderScope: flags['fail-under-each'] != null ? 'scenario' : 'overall',
|
|
328
|
+
maxFalsePositiveRate: flags['max-false-positives'],
|
|
329
|
+
suiteName: name,
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
// Always print the terminal summary, so a run is legible without opening a file.
|
|
333
|
+
if (!flags.quiet) {
|
|
334
|
+
process.stdout.write(generateReport(reports, { ...reportOptions, format: 'text' }));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (flags.report) {
|
|
338
|
+
const path = resolve(flags.report);
|
|
339
|
+
const format =
|
|
340
|
+
flags.format ?? FORMAT_BY_EXT[extname(path).toLowerCase()] ?? 'html';
|
|
341
|
+
writeFileSync(path, generateReport(reports, { ...reportOptions, format }));
|
|
342
|
+
if (!flags.quiet) process.stdout.write(` Report written to ${path} (${format})\n\n`);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const { totals } = summarise(reports, reportOptions);
|
|
346
|
+
const legit = totals.legitimate;
|
|
347
|
+
|
|
348
|
+
// An unmeasurable run must not be reported as either a pass or a failure of
|
|
349
|
+
// the defence — it is an environment problem.
|
|
350
|
+
const unreliable = Math.max(totals.errorRate, legit?.errorRate ?? 0);
|
|
351
|
+
if (unreliable > 0.05) {
|
|
352
|
+
process.stderr.write(
|
|
353
|
+
` ${(unreliable * 100).toFixed(1)}% of attempts never received a decision. ` +
|
|
354
|
+
'The target was unreliable, so this run does not measure the defence.\n\n'
|
|
355
|
+
);
|
|
356
|
+
process.exit(3);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const failures = [];
|
|
360
|
+
if (totals.attacksSucceeded > 0) {
|
|
361
|
+
failures.push(`${totals.attacksSucceeded} attack(s) succeeded`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// --fail-under gates the overall block rate. Gating every scenario
|
|
365
|
+
// individually is a different, stricter question: some scenarios legitimately
|
|
366
|
+
// run at a low block rate because their event stream includes the victim's
|
|
367
|
+
// own traffic, so a per-scenario floor produces false failures unless the
|
|
368
|
+
// caller has chosen it deliberately. That is --fail-under-each.
|
|
369
|
+
if (Number.isFinite(flags['fail-under']) && totals.blockRate < flags['fail-under']) {
|
|
370
|
+
failures.push(
|
|
371
|
+
`overall block rate ${(totals.blockRate * 100).toFixed(1)}% is below the ` +
|
|
372
|
+
`${(flags['fail-under'] * 100).toFixed(0)}% floor`
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Blocking good customers is a failure of the defence too — the half of the
|
|
377
|
+
// measurement a block rate cannot see. Gated only when asked, because every
|
|
378
|
+
// real engine refuses some customers and the acceptable share is a policy.
|
|
379
|
+
if (
|
|
380
|
+
Number.isFinite(flags['max-false-positives']) &&
|
|
381
|
+
legit &&
|
|
382
|
+
legit.falsePositiveRate > flags['max-false-positives']
|
|
383
|
+
) {
|
|
384
|
+
failures.push(
|
|
385
|
+
`false-positive rate ${(legit.falsePositiveRate * 100).toFixed(1)}% is above the ` +
|
|
386
|
+
`${(flags['max-false-positives'] * 100).toFixed(1)}% ceiling ` +
|
|
387
|
+
`(${legit.customersBlocked} of ${legit.customers} customers refused)`
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (Number.isFinite(flags['fail-under-each'])) {
|
|
392
|
+
// Attack scenarios only: a block-rate floor on legitimate traffic would
|
|
393
|
+
// demand that good customers be refused.
|
|
394
|
+
const below = reports.filter(
|
|
395
|
+
(r) => r.traffic !== 'legitimate' && r.blockRate < flags['fail-under-each']
|
|
396
|
+
);
|
|
397
|
+
if (below.length) {
|
|
398
|
+
failures.push(
|
|
399
|
+
`${below.length} scenario(s) below the per-scenario ${(flags['fail-under-each'] * 100).toFixed(0)}% floor: ` +
|
|
400
|
+
below.map((r) => r.scenarioId).join(', ')
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (failures.length) {
|
|
406
|
+
process.stderr.write(` FAILED: ${failures.join('; ')}\n\n`);
|
|
407
|
+
process.exit(1);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ── flag parsing ────────────────────────────────────────────────────────
|
|
412
|
+
|
|
413
|
+
const NUMERIC = new Set([
|
|
414
|
+
'seed',
|
|
415
|
+
'rps',
|
|
416
|
+
'concurrency',
|
|
417
|
+
'timeout',
|
|
418
|
+
'fail-under',
|
|
419
|
+
'fail-under-each',
|
|
420
|
+
'max-false-positives',
|
|
421
|
+
'users',
|
|
422
|
+
]);
|
|
423
|
+
const REPEATABLE = new Set(['header', 'option', 'block-on-status']);
|
|
424
|
+
const BOOLEAN = new Set(['all', 'json', 'quiet']);
|
|
425
|
+
|
|
426
|
+
function parseFlags(argv) {
|
|
427
|
+
const flags = { _: [], header: {}, option: {}, scopedOption: {} };
|
|
428
|
+
|
|
429
|
+
for (let i = 0; i < argv.length; i++) {
|
|
430
|
+
const arg = argv[i];
|
|
431
|
+
|
|
432
|
+
if (!arg.startsWith('--')) {
|
|
433
|
+
flags._.push(arg);
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
let key = arg.slice(2);
|
|
438
|
+
let value;
|
|
439
|
+
|
|
440
|
+
const eq = key.indexOf('=');
|
|
441
|
+
if (eq !== -1) {
|
|
442
|
+
value = key.slice(eq + 1);
|
|
443
|
+
key = key.slice(0, eq);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (BOOLEAN.has(key)) {
|
|
447
|
+
flags[key] = value === undefined ? true : value !== 'false';
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (value === undefined) {
|
|
452
|
+
value = argv[++i];
|
|
453
|
+
if (value === undefined) throw usage(`--${key} needs a value`);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (REPEATABLE.has(key)) {
|
|
457
|
+
applyRepeatable(flags, key, value);
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (NUMERIC.has(key)) {
|
|
462
|
+
const n = Number(value);
|
|
463
|
+
if (!Number.isFinite(n)) throw usage(`--${key} must be a number, received "${value}"`);
|
|
464
|
+
flags[key] = n;
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
flags[key] = value;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// A percentage is friendlier to type than a fraction, so accept both.
|
|
472
|
+
for (const key of ['fail-under', 'fail-under-each']) {
|
|
473
|
+
if (flags[key] > 1) flags[key] = flags[key] / 100;
|
|
474
|
+
}
|
|
475
|
+
// A 100% false-positive ceiling gates nothing, so 1 here means 1%, not 1.0.
|
|
476
|
+
if (flags['max-false-positives'] >= 1) {
|
|
477
|
+
flags['max-false-positives'] = flags['max-false-positives'] / 100;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return flags;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function applyRepeatable(flags, key, value) {
|
|
484
|
+
if (key === 'header') {
|
|
485
|
+
const idx = value.indexOf(':');
|
|
486
|
+
if (idx === -1) throw usage(`--header expects "Name: value", received "${value}"`);
|
|
487
|
+
flags.header[value.slice(0, idx).trim()] = value.slice(idx + 1).trim();
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (key === 'block-on-status') {
|
|
492
|
+
flags['block-on-status'] ??= [];
|
|
493
|
+
for (const part of value.split(',')) {
|
|
494
|
+
const n = Number(part.trim());
|
|
495
|
+
if (!Number.isInteger(n)) throw usage(`--block-on-status expects status codes`);
|
|
496
|
+
flags['block-on-status'].push(n);
|
|
497
|
+
}
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// --option key=value, with the value coerced so numeric and boolean scenario
|
|
502
|
+
// options do not arrive as strings and silently fail their validation.
|
|
503
|
+
const idx = value.indexOf('=');
|
|
504
|
+
if (idx === -1) throw usage(`--option expects key=value, received "${value}"`);
|
|
505
|
+
const name = value.slice(0, idx);
|
|
506
|
+
const parsed = coerce(value.slice(idx + 1));
|
|
507
|
+
|
|
508
|
+
// `--option scenario.key=value` targets one scenario; a bare key applies to
|
|
509
|
+
// every scenario that declares it.
|
|
510
|
+
const dot = name.indexOf('.');
|
|
511
|
+
if (dot !== -1) {
|
|
512
|
+
const scenario = name.slice(0, dot);
|
|
513
|
+
flags.scopedOption[scenario] ??= {};
|
|
514
|
+
flags.scopedOption[scenario][name.slice(dot + 1)] = parsed;
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
flags.option[name] = parsed;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function coerce(raw) {
|
|
522
|
+
if (raw === 'true') return true;
|
|
523
|
+
if (raw === 'false') return false;
|
|
524
|
+
if (raw === 'null') return null;
|
|
525
|
+
if (raw !== '' && Number.isFinite(Number(raw))) return Number(raw);
|
|
526
|
+
if (raw.includes(',')) return raw.split(',').map((s) => coerce(s.trim()));
|
|
527
|
+
return raw;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function usage(message) {
|
|
531
|
+
const err = new Error(`${message}\n\nRun \`fraud-sim --help\` for usage.`);
|
|
532
|
+
err.exitCode = 2;
|
|
533
|
+
return err;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const HELP = `
|
|
537
|
+
fraud-sim ${VERSION} — synthetic attack traffic for testing fraud detection
|
|
538
|
+
|
|
539
|
+
USAGE
|
|
540
|
+
fraud-sim list [--json]
|
|
541
|
+
fraud-sim run <scenario> --target-url <url> [options]
|
|
542
|
+
fraud-sim suite (--all | --scenarios a,b,c) --target-url <url> [options]
|
|
543
|
+
|
|
544
|
+
TARGET
|
|
545
|
+
--target-url <url> Base URL of the system under test (required)
|
|
546
|
+
--header "Name: value" Request header; repeat for several
|
|
547
|
+
--endpoint-login <path> Override the login scoring path
|
|
548
|
+
--endpoint-withdrawal <p> Override the withdrawal scoring path
|
|
549
|
+
--endpoint-action <path> Override the action scoring path
|
|
550
|
+
--block-on-status 403 HTTP statuses that mean a genuine block.
|
|
551
|
+
By default no status does — a 429 or 5xx is an
|
|
552
|
+
error, never a block, so a failing target can
|
|
553
|
+
never look like a strong one.
|
|
554
|
+
--timeout <ms> Per-request timeout (default 5000)
|
|
555
|
+
|
|
556
|
+
RUN
|
|
557
|
+
--option key=value Scenario option, applied to every scenario that
|
|
558
|
+
declares it; repeat for several
|
|
559
|
+
--option <id>.key=value Scenario option for one scenario only
|
|
560
|
+
--users <n> Synthetic accounts per scenario (default 12).
|
|
561
|
+
Scenarios that require account ids get their own
|
|
562
|
+
disjoint block, so they do not contaminate each
|
|
563
|
+
other's per-user counters.
|
|
564
|
+
--user-prefix <text> Prefix for those accounts (default sim_user)
|
|
565
|
+
--seed <n> Make the run reproducible
|
|
566
|
+
--rps <n> Request ceiling per second (default 25, 0 = off)
|
|
567
|
+
--concurrency <n> Scenarios in parallel (suite only, default 1)
|
|
568
|
+
--name <text> Suite name for the report
|
|
569
|
+
--locale <code> Market to simulate: account-number and phone
|
|
570
|
+
formats, bank names, payload currency, and which
|
|
571
|
+
country counts as domestic for geo rules.
|
|
572
|
+
Available: ${localeIds().join(', ')} (default US)
|
|
573
|
+
|
|
574
|
+
OUTPUT
|
|
575
|
+
--report <file> Write a report; format inferred from extension
|
|
576
|
+
(.html, .xml = JUnit, .json, .txt)
|
|
577
|
+
--format <fmt> Force html | junit | json | text
|
|
578
|
+
--title <text> Report title
|
|
579
|
+
--currency <code> Display currency. Defaults to the market's own.
|
|
580
|
+
Amounts are minor-unit integers, so this changes
|
|
581
|
+
presentation only — nothing is converted.
|
|
582
|
+
--target-name <text> How the target is named in the report
|
|
583
|
+
--quiet Suppress the terminal summary
|
|
584
|
+
|
|
585
|
+
CI
|
|
586
|
+
--fail-under <rate> Exit 1 if the OVERALL block rate is below this
|
|
587
|
+
(accepts 0.85 or 85)
|
|
588
|
+
--fail-under-each <rate> Exit 1 if ANY single scenario is below this.
|
|
589
|
+
Stricter, and needs care: some scenarios include
|
|
590
|
+
the victim's own legitimate traffic, so a low
|
|
591
|
+
block rate there is correct rather than a failure.
|
|
592
|
+
|
|
593
|
+
--max-false-positives <r> Exit 1 if more than this share of LEGITIMATE
|
|
594
|
+
customer events were blocked (accepts 0.02 or 2).
|
|
595
|
+
Needs legitimate-users in the run; suite --all
|
|
596
|
+
includes it.
|
|
597
|
+
|
|
598
|
+
An attack that reaches its objective always fails the run, with or without
|
|
599
|
+
these flags.
|
|
600
|
+
|
|
601
|
+
EXIT CODES
|
|
602
|
+
0 gate passed 2 usage error
|
|
603
|
+
1 gate failed 3 run failed, or target too unreliable to measure
|
|
604
|
+
|
|
605
|
+
EXAMPLES
|
|
606
|
+
fraud-sim list
|
|
607
|
+
|
|
608
|
+
fraud-sim run credential-stuffing \\
|
|
609
|
+
--target-url https://staging.example.com \\
|
|
610
|
+
--header "Authorization: Bearer $API_KEY" \\
|
|
611
|
+
--option targetUserIds=u1,u2,u3 --seed 42
|
|
612
|
+
|
|
613
|
+
fraud-sim suite --all \\
|
|
614
|
+
--target-url https://staging.example.com \\
|
|
615
|
+
--seed 42 --report assessment.html --fail-under 85 --max-false-positives 2
|
|
616
|
+
|
|
617
|
+
`;
|
|
618
|
+
// Invoked last on purpose: the flag tables above are module-level `const`,
|
|
619
|
+
// so calling main() before they initialise fails with a temporal-dead-zone
|
|
620
|
+
// error at runtime rather than anything the linter would catch.
|
|
621
|
+
main().catch((err) => {
|
|
622
|
+
process.stderr.write(`\nfraud-sim: ${err.message}\n`);
|
|
623
|
+
process.exit(err.exitCode ?? 3);
|
|
624
|
+
});
|
|
625
|
+
|
|
@@ -26,7 +26,7 @@ const report = await run('account-drain', {
|
|
|
26
26
|
options: {
|
|
27
27
|
targetUserId: 'victim_user_002',
|
|
28
28
|
withdrawalCount: 5,
|
|
29
|
-
|
|
29
|
+
amountMinor: 12000000, // NGN 120,000 — clears the common new-payee/new-device gates
|
|
30
30
|
delayBetweenWithdrawalsMs: 500,
|
|
31
31
|
postLoginPauseMs: 1000,
|
|
32
32
|
},
|