fraud-sim 0.2.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/src/runner.js ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Runner — orchestrates the lifecycle of a single scenario execution.
3
+ *
4
+ * Responsibilities:
5
+ * - Merge user options with scenario defaults
6
+ * - Provide the ScenarioContext (target, emit, signal)
7
+ * - Forward events to the user's onEvent callback
8
+ * - Wrap scenario errors with useful context
9
+ * - Emit a final 'summary' event when the scenario completes
10
+ *
11
+ * Non-responsibilities:
12
+ * - The runner does NOT know about HTTP, LLMs, faker, or specific scenarios.
13
+ * Every concrete behaviour lives in a scenario or an adapter.
14
+ */
15
+
16
+ export class Runner {
17
+ async run(scenario, config) {
18
+ const startedAt = Date.now();
19
+
20
+ const options = {
21
+ ...(scenario.defaultOptions ?? {}),
22
+ ...(config.options ?? {}),
23
+ };
24
+
25
+ const onEvent = config.onEvent ?? (() => {});
26
+ const signal = config.signal ?? new AbortController().signal;
27
+
28
+ // Wrap onEvent so we never let a buggy subscriber crash the runner.
29
+ const emit = (event) => {
30
+ try {
31
+ onEvent(event);
32
+ } catch (err) {
33
+ // Subscribers should not break simulations.
34
+ // We log to stderr but continue.
35
+ console.error('[fraud-sim] subscriber threw:', err);
36
+ }
37
+ };
38
+
39
+ const context = {
40
+ options,
41
+ target: config.target,
42
+ llm: config.llm, // optional, used only by adaptive scenarios (v0.2+)
43
+ emit,
44
+ signal,
45
+ };
46
+
47
+ let report;
48
+ try {
49
+ report = await scenario.run(context);
50
+ } catch (err) {
51
+ emit({
52
+ type: 'error',
53
+ message: `Scenario "${scenario.id}" failed: ${err.message}`,
54
+ details: { stack: err.stack },
55
+ timestamp: Date.now(),
56
+ });
57
+ throw err;
58
+ }
59
+
60
+ // Normalise the report. Scenarios may return loose shapes; the runner
61
+ // ensures the canonical fields exist so downstream tooling is reliable.
62
+ const normalised = {
63
+ scenarioId: scenario.id,
64
+ scenarioName: scenario.name,
65
+ startedAt,
66
+ completedAt: Date.now(),
67
+ durationMs: Date.now() - startedAt,
68
+ attempts: report.attempts ?? 0,
69
+ blocked: report.blocked ?? 0,
70
+ stepUp: report.stepUp ?? 0,
71
+ allowed: report.allowed ?? 0,
72
+ blockRate:
73
+ (report.attempts ?? 0) > 0 ? (report.blocked ?? 0) / report.attempts : 0,
74
+ stepUpRate:
75
+ (report.attempts ?? 0) > 0 ? (report.stepUp ?? 0) / report.attempts : 0,
76
+ ...report, // scenarios can add custom fields
77
+ };
78
+
79
+ emit({
80
+ type: 'summary',
81
+ report: normalised,
82
+ timestamp: Date.now(),
83
+ });
84
+
85
+ return normalised;
86
+ }
87
+ }
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Scenario: account-drain
3
+ *
4
+ * An attacker who has obtained valid credentials logs in from a new device,
5
+ * changes the password to lock out the legitimate user, and rapidly drains
6
+ * the account through withdrawals to new payees.
7
+ *
8
+ * Defensive systems should catch this via:
9
+ * - NEW_DEVICE (login from unknown device)
10
+ * - WITHDRAWAL_AFTER_PWD_CHANGE (withdrawal soon after password reset)
11
+ * - DEVICE_SESSION_MISMATCH (device changed mid-session)
12
+ * - NEW_PAYEE_HIGH_AMOUNT (first transaction to new payee, high amount)
13
+ * - DRAIN_PATTERN (multiple new payees in short window)
14
+ *
15
+ * Realistic outcomes:
16
+ * - Login itself may STEP_UP (NEW_DEVICE)
17
+ * - First withdrawal: STEP_UP or BLOCK depending on amount
18
+ * - Drain pattern detection should BLOCK by the 3rd withdrawal
19
+ */
20
+
21
+ import { sleep } from '../utils/sleep.js';
22
+ import {
23
+ buildLoginPayload,
24
+ buildWithdrawalPayload,
25
+ newSessionId,
26
+ newDeviceFingerprint,
27
+ } from '../utils/faker-helpers.js';
28
+ import { randomIp } from '../utils/random.js';
29
+
30
+ export default {
31
+ id: 'account-drain',
32
+ name: 'Account takeover and drain',
33
+ description:
34
+ 'Attacker logs in from a new device, changes the password, and rapidly drains funds to new payees.',
35
+
36
+ defaultOptions: {
37
+ targetUserId: null, // required
38
+ attackerIp: null, // optional — random if not provided
39
+ withdrawalCount: 5,
40
+ amountKobo: 4500000, // NGN 45,000 per withdrawal
41
+ delayBetweenWithdrawalsMs: 15000, // 15 seconds
42
+ postLoginPauseMs: 30000, // 30 seconds — attackers move fast after password change
43
+ skipPasswordChange: false,
44
+ },
45
+
46
+ async run(ctx) {
47
+ const { options, target, emit, signal } = ctx;
48
+
49
+ if (!options.targetUserId) {
50
+ throw new Error('account-drain requires options.targetUserId');
51
+ }
52
+
53
+ const results = {
54
+ attempts: 0,
55
+ blocked: 0,
56
+ stepUp: 0,
57
+ allowed: 0,
58
+ errors: 0,
59
+ loginDecision: null,
60
+ withdrawalDecisions: [],
61
+ attackerSucceeded: false,
62
+ };
63
+
64
+ const attackerIp = options.attackerIp ?? randomIp();
65
+ const attackerDevice = newDeviceFingerprint();
66
+ const sessionId = newSessionId('drain');
67
+
68
+ // ── Step 1: login from new device ─────────────────────────────────
69
+ emit({
70
+ type: 'milestone',
71
+ message: `Attacker attempts login from new device (IP: ${attackerIp})`,
72
+ timestamp: Date.now(),
73
+ });
74
+
75
+ const loginPayload = buildLoginPayload(options.targetUserId, {
76
+ session_id: sessionId,
77
+ ip_address: attackerIp,
78
+ device_fingerprint: attackerDevice,
79
+ user_agent: 'Mozilla/5.0 (Linux; Android 13; SM-A546B)',
80
+ });
81
+
82
+ const t0 = Date.now();
83
+ emit({ type: 'attempt', round: 0, payload: loginPayload, timestamp: t0 });
84
+
85
+ let loginResponse;
86
+ try {
87
+ loginResponse = await target.scoreLogin(loginPayload);
88
+ } catch (err) {
89
+ results.errors++;
90
+ emit({
91
+ type: 'error',
92
+ message: `Login attempt failed: ${err.message}`,
93
+ timestamp: Date.now(),
94
+ });
95
+ return finalise(results);
96
+ }
97
+
98
+ emit({
99
+ type: 'response',
100
+ round: 0,
101
+ response: loginResponse,
102
+ latencyMs: Date.now() - t0,
103
+ timestamp: Date.now(),
104
+ });
105
+
106
+ results.attempts++;
107
+ results.loginDecision = loginResponse.decision;
108
+ incrementCount(results, loginResponse.decision);
109
+
110
+ if (loginResponse.decision === 'BLOCK') {
111
+ emit({
112
+ type: 'milestone',
113
+ message: 'Login blocked — attacker cannot proceed',
114
+ timestamp: Date.now(),
115
+ });
116
+ return finalise(results);
117
+ }
118
+
119
+ // ── Step 2: change password (simulated — reported to target) ──────
120
+ if (!options.skipPasswordChange) {
121
+ emit({
122
+ type: 'milestone',
123
+ message: 'Attacker changes password to lock out the legitimate user',
124
+ timestamp: Date.now(),
125
+ });
126
+
127
+ try {
128
+ await target.reportEvent('password-changed', {
129
+ user_id: options.targetUserId,
130
+ changed_at: new Date().toISOString(),
131
+ metadata: { _simulated: true },
132
+ });
133
+ } catch {
134
+ // Non-critical — some targets won't support this endpoint
135
+ }
136
+ }
137
+
138
+ // Realistic attackers move fast after a password change
139
+ try {
140
+ await sleep(options.postLoginPauseMs, signal);
141
+ } catch {
142
+ return finalise(results);
143
+ }
144
+
145
+ // ── Step 3: rapid withdrawals to new payees ───────────────────────
146
+ emit({
147
+ type: 'milestone',
148
+ message: `Attacker attempts ${options.withdrawalCount} rapid withdrawals to new payees`,
149
+ timestamp: Date.now(),
150
+ });
151
+
152
+ for (let i = 0; i < options.withdrawalCount; i++) {
153
+ if (signal.aborted) break;
154
+
155
+ const wdPayload = buildWithdrawalPayload(
156
+ options.targetUserId,
157
+ {
158
+ amount: options.amountKobo,
159
+ is_new_payee: true,
160
+ },
161
+ {
162
+ session_id: sessionId,
163
+ ip_address: attackerIp,
164
+ device_fingerprint: attackerDevice,
165
+ }
166
+ );
167
+
168
+ const wt0 = Date.now();
169
+ emit({
170
+ type: 'attempt',
171
+ round: results.attempts,
172
+ payload: wdPayload,
173
+ timestamp: wt0,
174
+ });
175
+
176
+ let wdResponse;
177
+ try {
178
+ wdResponse = await target.scoreWithdrawal(wdPayload);
179
+ } catch (err) {
180
+ results.errors++;
181
+ emit({
182
+ type: 'error',
183
+ message: `Withdrawal ${i + 1} failed: ${err.message}`,
184
+ timestamp: Date.now(),
185
+ });
186
+ continue;
187
+ }
188
+
189
+ emit({
190
+ type: 'response',
191
+ round: results.attempts,
192
+ response: wdResponse,
193
+ latencyMs: Date.now() - wt0,
194
+ timestamp: Date.now(),
195
+ });
196
+
197
+ results.attempts++;
198
+ results.withdrawalDecisions.push({
199
+ index: i + 1,
200
+ decision: wdResponse.decision,
201
+ reasons: wdResponse.reasons,
202
+ });
203
+ incrementCount(results, wdResponse.decision);
204
+
205
+ // Stop draining once any withdrawal is blocked — a real attacker would
206
+ // realise the system has noticed and pivot or abandon
207
+ if (wdResponse.decision === 'BLOCK') {
208
+ emit({
209
+ type: 'milestone',
210
+ message: `Withdrawal ${i + 1} blocked — attacker gives up`,
211
+ timestamp: Date.now(),
212
+ });
213
+ break;
214
+ }
215
+
216
+ // If all withdrawals succeed, the attacker has fully drained the account
217
+ if (i === options.withdrawalCount - 1 && wdResponse.decision === 'ALLOW') {
218
+ results.attackerSucceeded = true;
219
+ emit({
220
+ type: 'milestone',
221
+ message: 'All withdrawals succeeded — account fully drained',
222
+ timestamp: Date.now(),
223
+ });
224
+ }
225
+
226
+ try {
227
+ await sleep(options.delayBetweenWithdrawalsMs, signal);
228
+ } catch {
229
+ break;
230
+ }
231
+ }
232
+
233
+ return finalise(results);
234
+ },
235
+ };
236
+
237
+ function incrementCount(results, decision) {
238
+ if (decision === 'BLOCK') results.blocked++;
239
+ else if (decision === 'STEP_UP') results.stepUp++;
240
+ else if (decision === 'ALLOW') results.allowed++;
241
+ else results.errors++;
242
+ }
243
+
244
+ function finalise(results) {
245
+ return {
246
+ attempts: results.attempts,
247
+ blocked: results.blocked,
248
+ stepUp: results.stepUp,
249
+ allowed: results.allowed,
250
+ errors: results.errors,
251
+ loginDecision: results.loginDecision,
252
+ withdrawalDecisions: results.withdrawalDecisions,
253
+ attackerSucceeded: results.attackerSucceeded,
254
+ };
255
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Scenario: adaptive-attacker
3
+ *
4
+ * The headline scenario. An attacker probes a login endpoint and, after each
5
+ * block, asks an LLM (via ctx.llm) what to change for the next attempt. Round
6
+ * by round it rotates IPs, device fingerprints, user agents, and timing based
7
+ * on the reasons the defender returned — the same loop a real adaptive actor
8
+ * runs, made visible.
9
+ *
10
+ * The demo moment is the `attacker_adapts` event: it carries the LLM's
11
+ * reasoning and is emitted BEFORE each adapted attempt is sent, so a dashboard
12
+ * can show *why* the attacker is about to do what it does.
13
+ *
14
+ * Defensive systems should catch this via the same signals as credential
15
+ * stuffing (IP reputation, device mismatch, velocity), but the point of the
16
+ * scenario is to see whether a defender holds up as the attacker learns.
17
+ *
18
+ * Works with no LLM: if `ctx.llm` is undefined it falls back to the seeded
19
+ * deterministic provider, so the scenario is fully reproducible in CI.
20
+ */
21
+
22
+ import { sleep } from '../utils/sleep.js';
23
+ import { buildLoginPayload } from '../utils/faker-helpers.js';
24
+ import { deterministic } from '../llm/deterministic.js';
25
+
26
+ export default {
27
+ id: 'adaptive-attacker',
28
+ name: 'LLM adaptive attacker',
29
+ description:
30
+ 'An attacker that uses an LLM to adapt its login strategy based on which attempts get blocked.',
31
+
32
+ defaultOptions: {
33
+ targetUserId: null, // required
34
+ maxRounds: 10,
35
+ baselineIp: '197.211.62.14', // realistic residential starting point
36
+ delayMs: 1000, // wall-clock pacing between rounds (the attacker's own
37
+ // chosen delay is recorded, not enacted — see run())
38
+ // The LLM is passed via ctx.llm, not options.
39
+ },
40
+
41
+ async run(ctx) {
42
+ const { options, target, emit, signal } = ctx;
43
+
44
+ if (!options.targetUserId) {
45
+ throw new Error('adaptive-attacker requires options.targetUserId');
46
+ }
47
+
48
+ // Fall back to a seeded deterministic provider when no LLM is supplied.
49
+ const llm = ctx.llm ?? deterministic({ seed: 42 });
50
+
51
+ const results = {
52
+ attempts: 0,
53
+ blocked: 0,
54
+ stepUp: 0,
55
+ allowed: 0,
56
+ errors: 0,
57
+ rounds: 0,
58
+ outcome: 'attacker_defeated',
59
+ strategyHistory: [],
60
+ };
61
+
62
+ // The history the LLM reasons over. One entry per completed round.
63
+ const history = [];
64
+
65
+ emit({
66
+ type: 'milestone',
67
+ message: `Adaptive attacker engaging user ${options.targetUserId} (up to ${options.maxRounds} rounds, provider: ${llm.name ?? 'unknown'})`,
68
+ timestamp: Date.now(),
69
+ });
70
+
71
+ for (let round = 1; round <= options.maxRounds; round++) {
72
+ if (signal.aborted) break;
73
+
74
+ let parameters;
75
+ let reasoning;
76
+
77
+ if (round === 1) {
78
+ // Hardcoded baseline: residential-looking IP, common browser UA.
79
+ parameters = {
80
+ ip: options.baselineIp,
81
+ deviceFingerprint: null, // let the payload builder generate one
82
+ userAgent:
83
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
84
+ delayMs: options.delayMs,
85
+ };
86
+ reasoning = 'Baseline attempt: residential-looking IP with a common browser user agent.';
87
+ } else {
88
+ // Ask the LLM what to change based on everything so far.
89
+ let strategy;
90
+ try {
91
+ strategy = await llm.generateStrategy({
92
+ scenarioId: 'adaptive-attacker',
93
+ history,
94
+ round,
95
+ maxRound: options.maxRounds,
96
+ });
97
+ } catch (err) {
98
+ // Providers shouldn't throw, but guard anyway — never crash the run.
99
+ results.errors++;
100
+ emit({
101
+ type: 'error',
102
+ message: `LLM provider threw on round ${round}: ${err.message}`,
103
+ timestamp: Date.now(),
104
+ });
105
+ strategy = { reasoning: 'Provider error — reusing previous parameters.', parameters };
106
+ }
107
+
108
+ parameters = { ...parameters, ...(strategy?.parameters ?? {}) };
109
+ reasoning = strategy?.reasoning ?? '';
110
+ }
111
+
112
+ // Emit the adaptation BEFORE sending — this is the demo moment.
113
+ emit({
114
+ type: 'attacker_adapts',
115
+ round,
116
+ reasoning,
117
+ parameters,
118
+ timestamp: Date.now(),
119
+ });
120
+ results.strategyHistory.push({ round, reasoning, parameters });
121
+
122
+ const payload = buildLoginPayload(options.targetUserId, {
123
+ ip_address: parameters.ip,
124
+ user_agent: parameters.userAgent,
125
+ ...(parameters.deviceFingerprint
126
+ ? { device_fingerprint: parameters.deviceFingerprint }
127
+ : {}),
128
+ });
129
+
130
+ const t0 = Date.now();
131
+ emit({ type: 'attempt', round, payload, timestamp: t0 });
132
+
133
+ let response;
134
+ try {
135
+ response = await target.scoreLogin(payload);
136
+ } catch (err) {
137
+ results.errors++;
138
+ emit({
139
+ type: 'error',
140
+ message: `Round ${round} attempt failed: ${err.message}`,
141
+ timestamp: Date.now(),
142
+ });
143
+ results.rounds = round;
144
+ // Record the failure in history so the LLM can react to it.
145
+ history.push({ round, parameters, decision: 'ERROR', reasons: [] });
146
+ continue;
147
+ }
148
+
149
+ const latencyMs = Date.now() - t0;
150
+ emit({ type: 'response', round, response, latencyMs, timestamp: Date.now() });
151
+
152
+ results.attempts++;
153
+ results.rounds = round;
154
+ const decision = response?.decision ?? 'ERROR';
155
+ const reasons = Array.isArray(response?.reasons) ? response.reasons : [];
156
+
157
+ if (decision === 'BLOCK') results.blocked++;
158
+ else if (decision === 'STEP_UP') results.stepUp++;
159
+ else if (decision === 'ALLOW') results.allowed++;
160
+ else results.errors++;
161
+
162
+ history.push({ round, parameters, decision, reasons });
163
+
164
+ if (decision === 'ALLOW') {
165
+ results.outcome = 'attacker_succeeded';
166
+ emit({
167
+ type: 'milestone',
168
+ message: `Round ${round}: ALLOW — attacker found a gap and succeeded`,
169
+ timestamp: Date.now(),
170
+ });
171
+ break;
172
+ }
173
+
174
+ // Pace the next round by the sim's own knob (options.delayMs), not the
175
+ // strategy's delayMs. The attacker's chosen delay is a real adaptation —
176
+ // it's recorded in the attacker_adapts event and strategyHistory — but
177
+ // enacting a 5–15s velocity-evasion wait per round would make a run take
178
+ // minutes for no benefit. This is a simulator; the decision is what's
179
+ // interesting, not the wall-clock wait.
180
+ try {
181
+ await sleep(options.delayMs, signal);
182
+ } catch {
183
+ break; // aborted
184
+ }
185
+ }
186
+
187
+ if (results.outcome !== 'attacker_succeeded') {
188
+ emit({
189
+ type: 'milestone',
190
+ message: `Attacker defeated after ${results.rounds} round(s) — no ALLOW reached`,
191
+ timestamp: Date.now(),
192
+ });
193
+ }
194
+
195
+ return {
196
+ attempts: results.attempts,
197
+ blocked: results.blocked,
198
+ stepUp: results.stepUp,
199
+ allowed: results.allowed,
200
+ errors: results.errors,
201
+ rounds: results.rounds,
202
+ outcome: results.outcome,
203
+ strategyHistory: results.strategyHistory,
204
+ };
205
+ },
206
+ };
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Scenario: credential-stuffing
3
+ *
4
+ * A botnet attempts to authenticate using stolen credentials. Each attempt
5
+ * comes from a different IP, rotates user agents, and paces attempts to
6
+ * evade naive rate limits. This is the most common automated attack against
7
+ * fintech login endpoints.
8
+ *
9
+ * Defensive systems should catch this via:
10
+ * - LOGIN_VELOCITY_USER (many attempts on one user)
11
+ * - LOGIN_VELOCITY_IP (many attempts from one IP)
12
+ * - IP_PROXY_VPN (datacenter / VPN IPs)
13
+ * - IP_MANY_USERS (one IP trying many user accounts)
14
+ * - NEW_DEVICE (every attempt is a new device fingerprint)
15
+ *
16
+ * Realistic block rates against a well-tuned system: 85-95%.
17
+ */
18
+
19
+ import { sleep } from '../utils/sleep.js';
20
+ import { buildLoginPayload } from '../utils/faker-helpers.js';
21
+ import { randomIp, randomUserAgent } from '../utils/random.js';
22
+
23
+ export default {
24
+ id: 'credential-stuffing',
25
+ name: 'Credential stuffing attack',
26
+ description:
27
+ 'A botnet attempts to authenticate using stolen credentials. Distinct IPs, rotating user agents, paced to evade simple rate limits.',
28
+
29
+ defaultOptions: {
30
+ targetUserId: null, // required
31
+ attackerCount: 50, // distinct IPs
32
+ attemptsPerIp: 3, // attempts before rotating IP
33
+ delayMs: 200, // ms between attempts
34
+ maxTotalAttempts: 200, // safety ceiling — prevents runaway sims
35
+ },
36
+
37
+ async run(ctx) {
38
+ const { options, target, emit, signal } = ctx;
39
+
40
+ if (!options.targetUserId) {
41
+ throw new Error('credential-stuffing requires options.targetUserId');
42
+ }
43
+
44
+ const results = {
45
+ attempts: 0,
46
+ blocked: 0,
47
+ stepUp: 0,
48
+ allowed: 0,
49
+ errors: 0,
50
+ };
51
+
52
+ emit({
53
+ type: 'milestone',
54
+ message: `Starting credential stuffing: ${options.attackerCount} IPs × ${options.attemptsPerIp} attempts (max ${options.maxTotalAttempts} total)`,
55
+ timestamp: Date.now(),
56
+ });
57
+
58
+ outer: for (let i = 0; i < options.attackerCount; i++) {
59
+ if (signal.aborted) break;
60
+
61
+ // Each "attacker IP" is a different IP + user agent combination
62
+ const attackerIp = randomIp();
63
+ const userAgent = randomUserAgent();
64
+
65
+ for (let j = 0; j < options.attemptsPerIp; j++) {
66
+ if (signal.aborted) break outer;
67
+ if (results.attempts >= options.maxTotalAttempts) break outer;
68
+
69
+ const payload = buildLoginPayload(options.targetUserId, {
70
+ ip_address: attackerIp,
71
+ user_agent: userAgent,
72
+ // Crucially: new device fingerprint per attempt (botnet behaviour)
73
+ });
74
+
75
+ const t0 = Date.now();
76
+ emit({ type: 'attempt', round: results.attempts, payload, timestamp: t0 });
77
+
78
+ let response;
79
+ try {
80
+ response = await target.scoreLogin(payload);
81
+ } catch (err) {
82
+ results.errors++;
83
+ emit({
84
+ type: 'error',
85
+ message: `Attempt ${results.attempts} failed: ${err.message}`,
86
+ timestamp: Date.now(),
87
+ });
88
+ continue;
89
+ }
90
+
91
+ const latencyMs = Date.now() - t0;
92
+
93
+ emit({
94
+ type: 'response',
95
+ round: results.attempts,
96
+ response,
97
+ latencyMs,
98
+ timestamp: Date.now(),
99
+ });
100
+
101
+ results.attempts++;
102
+ const decision = response?.decision ?? 'ERROR';
103
+ if (decision === 'BLOCK') results.blocked++;
104
+ else if (decision === 'STEP_UP') results.stepUp++;
105
+ else if (decision === 'ALLOW') results.allowed++;
106
+ else results.errors++;
107
+
108
+ try {
109
+ await sleep(options.delayMs, signal);
110
+ } catch {
111
+ break outer; // aborted
112
+ }
113
+ }
114
+ }
115
+
116
+ return {
117
+ attempts: results.attempts,
118
+ blocked: results.blocked,
119
+ stepUp: results.stepUp,
120
+ allowed: results.allowed,
121
+ errors: results.errors,
122
+ };
123
+ },
124
+ };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Scenario registry — all built-in scenarios.
3
+ *
4
+ * v0.1 shipped with two scenarios:
5
+ * - credential-stuffing
6
+ * - account-drain
7
+ *
8
+ * v0.2 adds:
9
+ * - adaptive-attacker (LLM-driven)
10
+ *
11
+ * v0.3 will add:
12
+ * - promo-abuse
13
+ * - card-testing
14
+ * - sim-swap-takeover
15
+ * - mule-network
16
+ * - slow-drain
17
+ * - session-hijack
18
+ * - velocity-evasion
19
+ */
20
+
21
+ import credentialStuffing from './credential-stuffing.js';
22
+ import accountDrain from './account-drain.js';
23
+ import adaptiveAttacker from './adaptive-attacker.js';
24
+
25
+ export const scenarios = [credentialStuffing, accountDrain, adaptiveAttacker];
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Streaming helpers — push simulation events to a live consumer (a browser
3
+ * dashboard, a log tail, etc.) as they happen rather than only at the end.
4
+ */
5
+
6
+ export { sseStream } from './sse.js';