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.
@@ -0,0 +1,61 @@
1
+ # Adaptive attacker demo
2
+
3
+ Runs the `adaptive-attacker` scenario against a small local target that blocks
4
+ the attacker's opening move, so you can watch it adapt and break through.
5
+
6
+ ## Run it
7
+
8
+ Two terminals.
9
+
10
+ **Terminal 1 — start the demo target:**
11
+
12
+ ```bash
13
+ npm run example:adaptive-target
14
+ ```
15
+
16
+ It listens on `http://localhost:4100`.
17
+
18
+ **Terminal 2 — run the attacker:**
19
+
20
+ ```bash
21
+ npm run example:adaptive
22
+ ```
23
+
24
+ By default this uses the **deterministic** (no-API) provider, so it needs no
25
+ API key and produces the same result every run. To drive it with a real LLM,
26
+ set an API key first:
27
+
28
+ ```bash
29
+ OPENAI_API_KEY=sk-... npm run example:adaptive
30
+ # or
31
+ ANTHROPIC_API_KEY=sk-ant-... npm run example:adaptive
32
+ ```
33
+
34
+ ## What you'll see
35
+
36
+ ```
37
+ Round 1 Baseline attempt: residential-looking IP with a common browser user agent.
38
+ ip=197.211.62.14 device=(generated)…
39
+ → BLOCK score 0.90 IP_REPUTATION, NEW_DEVICE
40
+
41
+ Round 2 Last attempt was blocked … Rotating the device fingerprint and user agent.
42
+ ip=213.40.21.204 device=NEGxLVra8ZZa…
43
+ → ALLOW score 0.20 NEW_DEVICE
44
+
45
+ Outcome: attacker_succeeded (rounds: 2)
46
+ ```
47
+
48
+ Round 1's baseline IP is on the target's reputation blocklist, so it's blocked.
49
+ The attacker sees the block reasons, rotates everything, and round 2 gets
50
+ through — because the target scores each attempt in isolation and can't tell a
51
+ freshly-rotated login from a legitimate one. That weakness is the point: it's
52
+ what a per-request defense misses and what the scenario is built to surface.
53
+
54
+ ## Notes
55
+
56
+ - The target needs a moment to start listening. If terminal 2 shows
57
+ `NETWORK_ERROR` on the first rounds, the target wasn't up yet — start it
58
+ first and give it a second.
59
+ - The demo target (`demo-target.js`) is intentionally naive. Make its rules
60
+ stricter (track IP /24 reuse, correlate device + IP, add step-up friction)
61
+ and re-run to see the attacker take more rounds — or get defeated.
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Demo target for the adaptive-attacker scenario.
3
+ *
4
+ * The stock mock target (examples/express-mock-target) is too permissive to
5
+ * show adaptation — a single rotated login passes it on the first try, so the
6
+ * attacker "wins" round 1 and never has to adapt. This target adds an
7
+ * IP-reputation rule that blocks the scenario's baseline IP, so the attacker
8
+ * gets blocked, sees why, rotates, and probes again — which is the whole point
9
+ * of the scenario.
10
+ *
11
+ * It is still a naive per-request defense with a real weakness: it scores each
12
+ * attempt in isolation, so an attacker who rotates IP and device on every
13
+ * attempt eventually presents a "clean" request it can't distinguish from a
14
+ * legitimate login. That weakness is what the adaptive attacker exploits.
15
+ *
16
+ * Run: node examples/adaptive-attacker-demo/demo-target.js
17
+ * Then: node examples/adaptive-attacker-demo/index.js (in another terminal)
18
+ */
19
+
20
+ import express from 'express';
21
+
22
+ const app = express();
23
+ app.use(express.json());
24
+
25
+ // IPs (and their /24s) flagged as abusive. The scenario's default baselineIp
26
+ // lives here so round 1 is always blocked on reputation.
27
+ const KNOWN_BAD_IPS = new Set(['197.211.62.14']);
28
+ const badSubnets = new Set([subnet24('197.211.62.14')]);
29
+
30
+ // Devices this target has ever seen, per user.
31
+ const devicesByUser = new Map();
32
+ // Recent login count per user (decays).
33
+ const loginsByUser = new Map();
34
+
35
+ setInterval(() => {
36
+ for (const [user, count] of loginsByUser) {
37
+ const next = Math.floor(count * 0.5);
38
+ if (next === 0) loginsByUser.delete(user);
39
+ else loginsByUser.set(user, next);
40
+ }
41
+ }, 60000);
42
+
43
+ function subnet24(ip) {
44
+ return ip.split('.').slice(0, 3).join('.');
45
+ }
46
+
47
+ app.post('/score/login', (req, res) => {
48
+ const { user_id, ip_address, device_fingerprint } = req.body ?? {};
49
+ const reasons = [];
50
+ let score = 0;
51
+
52
+ // Rule: IP reputation. A known-bad IP (or one in a flagged /24) is a hard hit.
53
+ if (KNOWN_BAD_IPS.has(ip_address) || badSubnets.has(subnet24(ip_address ?? ''))) {
54
+ reasons.push({ code: 'IP_REPUTATION', weight: 0.7 });
55
+ score += 0.7;
56
+ }
57
+
58
+ // Rule: new device for this user.
59
+ const userDevices = devicesByUser.get(user_id) ?? new Set();
60
+ if (!userDevices.has(device_fingerprint)) {
61
+ reasons.push({ code: 'NEW_DEVICE', weight: 0.2 });
62
+ score += 0.2;
63
+ userDevices.add(device_fingerprint);
64
+ devicesByUser.set(user_id, userDevices);
65
+ }
66
+
67
+ // Rule: login velocity per user.
68
+ const logins = (loginsByUser.get(user_id) ?? 0) + 1;
69
+ loginsByUser.set(user_id, logins);
70
+ if (logins > 4) {
71
+ reasons.push({ code: 'LOGIN_VELOCITY_USER', weight: 0.35 });
72
+ score += 0.35;
73
+ }
74
+
75
+ const decision = score >= 0.6 ? 'BLOCK' : score >= 0.35 ? 'STEP_UP' : 'ALLOW';
76
+ res.json({ decision, risk_score: Math.min(score, 1), reasons });
77
+ });
78
+
79
+ app.get('/health', (req, res) => res.json({ status: 'ok' }));
80
+
81
+ const PORT = process.env.PORT || 4100;
82
+ app.listen(PORT, () => {
83
+ console.log(`Adaptive-attacker demo target listening on http://localhost:${PORT}`);
84
+ console.log('Now run: node examples/adaptive-attacker-demo/index.js');
85
+ });
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Example: LLM adaptive attacker against a local demo target.
3
+ *
4
+ * Prerequisites:
5
+ * 1. Start the demo target:
6
+ * node examples/adaptive-attacker-demo/demo-target.js
7
+ * 2. In another terminal, run this:
8
+ * node examples/adaptive-attacker-demo/index.js
9
+ *
10
+ * By default this uses the deterministic (no-API) provider, so it runs
11
+ * anywhere with no API key and produces the same result every time. To use a
12
+ * real LLM, set one of:
13
+ * OPENAI_API_KEY=sk-... node examples/adaptive-attacker-demo/index.js
14
+ * ANTHROPIC_API_KEY=sk-ant-... node examples/adaptive-attacker-demo/index.js
15
+ */
16
+
17
+ import { run } from '../../src/index.js';
18
+ import { httpAdapter } from '../../src/adapters/http.js';
19
+ import { deterministic, openai, anthropic } from '../../src/llm/index.js';
20
+
21
+ const target = httpAdapter({
22
+ baseUrl: process.env.TARGET_URL ?? 'http://localhost:4100',
23
+ endpoints: { scoreLogin: '/score/login' },
24
+ });
25
+
26
+ // Pick a provider from the environment; fall back to the deterministic one.
27
+ let llm;
28
+ if (process.env.OPENAI_API_KEY) {
29
+ llm = openai({ apiKey: process.env.OPENAI_API_KEY });
30
+ } else if (process.env.ANTHROPIC_API_KEY) {
31
+ llm = anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
32
+ } else {
33
+ llm = deterministic({ seed: 42 });
34
+ }
35
+
36
+ const C = {
37
+ block: '\x1b[31m',
38
+ stepUp: '\x1b[33m',
39
+ allow: '\x1b[32m',
40
+ dim: '\x1b[2m',
41
+ cyan: '\x1b[36m',
42
+ reset: '\x1b[0m',
43
+ };
44
+ const decisionColor = { BLOCK: C.block, STEP_UP: C.stepUp, ALLOW: C.allow };
45
+
46
+ console.log(`\n=== Adaptive attacker (provider: ${llm.name}) ===\n`);
47
+
48
+ const report = await run('adaptive-attacker', {
49
+ target,
50
+ llm,
51
+ options: { targetUserId: 'victim_user_001', maxRounds: 8, delayMs: 150 },
52
+ onEvent: (e) => {
53
+ if (e.type === 'attacker_adapts') {
54
+ console.log(`${C.cyan}Round ${e.round}${C.reset} ${C.dim}${e.reasoning}${C.reset}`);
55
+ console.log(
56
+ `${C.dim} ip=${e.parameters.ip} device=${String(e.parameters.deviceFingerprint ?? '(generated)').slice(0, 12)}…${C.reset}`
57
+ );
58
+ } else if (e.type === 'response') {
59
+ const color = decisionColor[e.response.decision] ?? '';
60
+ const reasons = (e.response.reasons ?? []).map((r) => r.code).join(', ') || '—';
61
+ console.log(
62
+ ` → ${color}${e.response.decision.padEnd(7)}${C.reset} score ${e.response.riskScore?.toFixed(2) ?? '?'} ${reasons}\n`
63
+ );
64
+ }
65
+ },
66
+ });
67
+
68
+ const outcomeColor = report.outcome === 'attacker_succeeded' ? C.block : C.allow;
69
+ console.log('=== Report ===\n');
70
+ console.log(` Outcome: ${outcomeColor}${report.outcome}${C.reset}`);
71
+ console.log(` Rounds: ${report.rounds}`);
72
+ console.log(` Blocked: ${report.blocked} Step-up: ${report.stepUp} Allowed: ${report.allowed}`);
73
+ console.log(` Duration: ${report.durationMs}ms\n`);
74
+
75
+ if (report.outcome === 'attacker_succeeded') {
76
+ console.log(
77
+ `${C.dim} The attacker found a gap: the target scores each attempt in isolation,`
78
+ );
79
+ console.log(
80
+ ` so once the attacker rotated away from the flagged IP, a fresh login was`
81
+ );
82
+ console.log(` indistinguishable from a legitimate one.${C.reset}\n`);
83
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Example: Run credential stuffing against a local mock target.
3
+ *
4
+ * Prerequisite: Start the mock target first
5
+ * npm run example:mock-target
6
+ *
7
+ * Then in another terminal:
8
+ * npm run example:basic
9
+ */
10
+
11
+ import { run } from '../../src/index.js';
12
+ import { httpAdapter } from '../../src/adapters/http.js';
13
+
14
+ const target = httpAdapter({
15
+ baseUrl: process.env.TARGET_URL ?? 'http://localhost:4000',
16
+ endpoints: {
17
+ scoreLogin: '/score/login',
18
+ scoreWithdrawal: '/score/withdrawal',
19
+ },
20
+ });
21
+
22
+ const events = [];
23
+
24
+ console.log('\n=== Credential stuffing simulation ===\n');
25
+
26
+ const report = await run('credential-stuffing', {
27
+ target,
28
+ options: {
29
+ targetUserId: 'victim_user_001',
30
+ attackerCount: 10,
31
+ attemptsPerIp: 3,
32
+ delayMs: 50,
33
+ },
34
+ onEvent: (e) => {
35
+ events.push(e);
36
+ if (e.type === 'response') {
37
+ const decisionColor = {
38
+ BLOCK: '\x1b[31m', // red
39
+ STEP_UP: '\x1b[33m', // yellow
40
+ ALLOW: '\x1b[32m', // green
41
+ ERROR: '\x1b[35m', // magenta
42
+ };
43
+ const color = decisionColor[e.response.decision] ?? '';
44
+ const reset = '\x1b[0m';
45
+ const reasons = (e.response.reasons ?? []).map((r) => r.code).join(', ') || '—';
46
+ console.log(
47
+ ` attempt ${String(e.round).padStart(3)} ${color}${e.response.decision.padEnd(7)}${reset} score ${e.response.riskScore?.toFixed(2) ?? '?'} ${reasons}`
48
+ );
49
+ }
50
+ },
51
+ });
52
+
53
+ console.log('\n=== Report ===\n');
54
+ console.log(` Attempts: ${report.attempts}`);
55
+ console.log(` Blocked: ${report.blocked}`);
56
+ console.log(` Step-up: ${report.stepUp}`);
57
+ console.log(` Allowed: ${report.allowed}`);
58
+ console.log(` Block rate: ${(report.blockRate * 100).toFixed(1)}%`);
59
+ console.log(` Duration: ${report.durationMs}ms`);
60
+ console.log();
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Mock fraud detection target for examples and integration tests.
3
+ *
4
+ * Implements a simple but realistic rules engine so scenarios produce
5
+ * meaningful block rates. This is NOT FriskLayer — it's a stripped-down
6
+ * pedagogical example showing the kind of system fraud-sim tests against.
7
+ *
8
+ * Run: `npm run example:mock-target`
9
+ * Then point a scenario at http://localhost:4000
10
+ */
11
+
12
+ import express from 'express';
13
+
14
+ const app = express();
15
+ app.use(express.json());
16
+
17
+ // In-memory state — naive but enough for demos
18
+ const seen = {
19
+ devicesByUser: new Map(), // userId → Set<deviceFp>
20
+ loginsByUser: new Map(), // userId → number (count in last 10 min)
21
+ loginsByIp: new Map(), // ip → number
22
+ payeesByUser: new Map(), // userId → Set<payeeAccount>
23
+ newPayeesByUser: new Map(), // userId → number of new payees in last 30 min
24
+ };
25
+
26
+ // Decay counters every minute
27
+ setInterval(() => {
28
+ for (const map of [seen.loginsByUser, seen.loginsByIp, seen.newPayeesByUser]) {
29
+ for (const [key, count] of map) {
30
+ const next = Math.floor(count * 0.5);
31
+ if (next === 0) map.delete(key);
32
+ else map.set(key, next);
33
+ }
34
+ }
35
+ }, 60000);
36
+
37
+ app.post('/score/login', (req, res) => {
38
+ const { user_id, ip_address, device_fingerprint } = req.body ?? {};
39
+ const reasons = [];
40
+ let score = 0;
41
+
42
+ // Increment counters
43
+ seen.loginsByUser.set(user_id, (seen.loginsByUser.get(user_id) ?? 0) + 1);
44
+ seen.loginsByIp.set(ip_address, (seen.loginsByIp.get(ip_address) ?? 0) + 1);
45
+
46
+ // Rule: velocity per user
47
+ if ((seen.loginsByUser.get(user_id) ?? 0) > 5) {
48
+ reasons.push({ code: 'LOGIN_VELOCITY_USER', weight: 0.35 });
49
+ score += 0.35;
50
+ }
51
+
52
+ // Rule: velocity per IP
53
+ if ((seen.loginsByIp.get(ip_address) ?? 0) > 3) {
54
+ reasons.push({ code: 'LOGIN_VELOCITY_IP', weight: 0.4 });
55
+ score += 0.4;
56
+ }
57
+
58
+ // Rule: new device
59
+ const userDevices = seen.devicesByUser.get(user_id) ?? new Set();
60
+ if (!userDevices.has(device_fingerprint)) {
61
+ reasons.push({ code: 'NEW_DEVICE', weight: 0.25 });
62
+ score += 0.25;
63
+ userDevices.add(device_fingerprint);
64
+ seen.devicesByUser.set(user_id, userDevices);
65
+ }
66
+
67
+ const decision = score >= 0.65 ? 'BLOCK' : score >= 0.35 ? 'STEP_UP' : 'ALLOW';
68
+
69
+ res.json({
70
+ decision,
71
+ risk_score: Math.min(score, 1.0),
72
+ reasons,
73
+ });
74
+ });
75
+
76
+ app.post('/score/withdrawal', (req, res) => {
77
+ const { user_id, transaction } = req.body ?? {};
78
+ const reasons = [];
79
+ let score = 0;
80
+
81
+ // Rule: new payee
82
+ if (transaction?.is_new_payee) {
83
+ reasons.push({ code: 'NEW_PAYEE_FIRST_TXN', weight: 0.2 });
84
+ score += 0.2;
85
+
86
+ const newCount = (seen.newPayeesByUser.get(user_id) ?? 0) + 1;
87
+ seen.newPayeesByUser.set(user_id, newCount);
88
+
89
+ // Rule: drain pattern (3+ new payees within tracked window)
90
+ if (newCount >= 3) {
91
+ reasons.push({ code: 'DRAIN_PATTERN', weight: 0.6 });
92
+ score += 0.6;
93
+ }
94
+ }
95
+
96
+ // Rule: high amount + new payee
97
+ if (transaction?.is_new_payee && transaction.amount > 1000000) {
98
+ reasons.push({ code: 'NEW_PAYEE_HIGH_AMOUNT', weight: 0.4 });
99
+ score += 0.4;
100
+ }
101
+
102
+ const decision = score >= 0.65 ? 'BLOCK' : score >= 0.35 ? 'STEP_UP' : 'ALLOW';
103
+
104
+ res.json({
105
+ decision,
106
+ risk_score: Math.min(score, 1.0),
107
+ reasons,
108
+ });
109
+ });
110
+
111
+ app.post('/event/:type', (req, res) => {
112
+ // Accept and discard — this mock doesn't process side events
113
+ res.json({ ok: true });
114
+ });
115
+
116
+ app.get('/health', (req, res) => res.json({ status: 'ok' }));
117
+
118
+ const PORT = process.env.PORT || 4000;
119
+ app.listen(PORT, () => {
120
+ console.log(`Mock fraud target listening on http://localhost:${PORT}`);
121
+ console.log('Try: npm run example:basic in another terminal');
122
+ });
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "fraud-sim",
3
+ "version": "0.2.0",
4
+ "description": "Realistic synthetic attack traffic for testing fintech fraud detection systems",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js",
9
+ "./adapters": "./src/adapters/index.js",
10
+ "./streaming": "./src/streaming/index.js",
11
+ "./llm": "./src/llm/index.js"
12
+ },
13
+ "engines": {
14
+ "node": ">=20"
15
+ },
16
+ "files": [
17
+ "src/",
18
+ "examples/",
19
+ "README.md",
20
+ "LICENSE",
21
+ "CHANGELOG.md"
22
+ ],
23
+ "scripts": {
24
+ "test": "cd test/unit && node --test",
25
+ "test:integration": "cd test/integration && node --test",
26
+ "test:all": "cd test && node --test",
27
+ "lint": "eslint .",
28
+ "lint:fix": "eslint . --fix",
29
+ "example:basic": "node examples/basic-credential-stuffing/index.js",
30
+ "example:drain": "node examples/account-drain-demo/index.js",
31
+ "example:mock-target": "node examples/express-mock-target/server.js",
32
+ "example:adaptive-target": "node examples/adaptive-attacker-demo/demo-target.js",
33
+ "example:adaptive": "node examples/adaptive-attacker-demo/index.js"
34
+ },
35
+ "keywords": [
36
+ "fraud",
37
+ "fraud-detection",
38
+ "security-testing",
39
+ "fintech",
40
+ "simulation",
41
+ "adversarial-testing",
42
+ "penetration-testing"
43
+ ],
44
+ "author": "Arthur (@arthurr-beep) <arthurraugustus@gmail.com>",
45
+ "license": "MIT",
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "https://github.com/arthurr-beep/fraudsim.git"
49
+ },
50
+ "bugs": {
51
+ "url": "https://github.com/arthurr-beep/fraudsim/issues"
52
+ },
53
+ "homepage": "https://github.com/arthurr-beep/fraudsim#readme",
54
+ "devDependencies": {
55
+ "@eslint/js": "^9.39.5",
56
+ "eslint": "^9.39.5",
57
+ "express": "^4.21.0",
58
+ "globals": "^17.11.0"
59
+ }
60
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * HTTP target adapter — sends events to a REST API.
3
+ *
4
+ * Configurable for any target's response shape via the `responseMap` function.
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.
8
+ *
9
+ * @example
10
+ * const target = httpAdapter({
11
+ * baseUrl: 'https://api.frisklayer.com',
12
+ * headers: { Authorization: `Bearer ${process.env.FRISKLAYER_KEY}` },
13
+ * endpoints: {
14
+ * scoreLogin: '/v1/score/login',
15
+ * scoreWithdrawal: '/v1/score/withdrawal',
16
+ * },
17
+ * responseMap: (raw) => ({
18
+ * decision: raw.decision,
19
+ * riskScore: raw.risk_score,
20
+ * reasons: raw.reasons,
21
+ * raw,
22
+ * }),
23
+ * });
24
+ */
25
+
26
+ const DEFAULT_ENDPOINTS = {
27
+ scoreLogin: '/v1/score/login',
28
+ scoreWithdrawal: '/v1/score/withdrawal',
29
+ scoreAction: '/v1/score/action',
30
+ reportEvent: (type) => `/v1/event/${type}`,
31
+ };
32
+
33
+ // Default responseMap — works for snake_case APIs that match FriskLayer's shape.
34
+ // Users with different APIs override this.
35
+ const DEFAULT_RESPONSE_MAP = (raw) => ({
36
+ decision: raw.decision,
37
+ riskScore: raw.risk_score ?? raw.riskScore,
38
+ reasons: raw.reasons ?? [],
39
+ raw,
40
+ });
41
+
42
+ export function httpAdapter(config) {
43
+ if (!config || !config.baseUrl) {
44
+ throw new Error('httpAdapter requires { baseUrl }');
45
+ }
46
+
47
+ const baseUrl = config.baseUrl.replace(/\/$/, '');
48
+ const headers = {
49
+ 'Content-Type': 'application/json',
50
+ ...(config.headers ?? {}),
51
+ };
52
+ const endpoints = { ...DEFAULT_ENDPOINTS, ...(config.endpoints ?? {}) };
53
+ const responseMap = config.responseMap ?? DEFAULT_RESPONSE_MAP;
54
+ const timeoutMs = config.timeoutMs ?? 5000;
55
+ const fetchImpl = config.fetch ?? globalThis.fetch;
56
+
57
+ if (!fetchImpl) {
58
+ throw new Error(
59
+ 'No fetch implementation found. Use Node 20+, or pass { fetch } to httpAdapter.'
60
+ );
61
+ }
62
+
63
+ async function call(path, body) {
64
+ const url = `${baseUrl}${path}`;
65
+ const controller = new AbortController();
66
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
67
+
68
+ try {
69
+ const res = await fetchImpl(url, {
70
+ method: 'POST',
71
+ headers,
72
+ body: JSON.stringify(body),
73
+ signal: controller.signal,
74
+ });
75
+
76
+ const raw = await res.json().catch(() => ({}));
77
+
78
+ 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.
81
+ return {
82
+ decision: raw.decision ?? 'BLOCK',
83
+ riskScore: raw.risk_score ?? 1.0,
84
+ reasons: raw.reasons ?? [{ code: 'HTTP_ERROR', label: `HTTP ${res.status}` }],
85
+ raw,
86
+ httpStatus: res.status,
87
+ };
88
+ }
89
+
90
+ return responseMap(raw);
91
+ } catch (err) {
92
+ // Network-level failures — timeout, DNS, connection refused.
93
+ // We return a synthetic ERROR decision so scenarios can decide
94
+ // whether to continue or abort.
95
+ return {
96
+ decision: 'ERROR',
97
+ riskScore: null,
98
+ reasons: [{ code: 'NETWORK_ERROR', label: err.message }],
99
+ raw: { error: err.message },
100
+ };
101
+ } finally {
102
+ clearTimeout(timer);
103
+ }
104
+ }
105
+
106
+ return {
107
+ async scoreLogin(payload) {
108
+ return call(endpoints.scoreLogin, payload);
109
+ },
110
+
111
+ async scoreWithdrawal(payload) {
112
+ return call(endpoints.scoreWithdrawal, payload);
113
+ },
114
+
115
+ async scoreAction(payload) {
116
+ return call(endpoints.scoreAction, payload);
117
+ },
118
+
119
+ async reportEvent(eventType, data) {
120
+ const path =
121
+ typeof endpoints.reportEvent === 'function'
122
+ ? endpoints.reportEvent(eventType)
123
+ : endpoints.reportEvent;
124
+ // Fire-and-forget — never block a scenario on a report
125
+ try {
126
+ await call(path, data);
127
+ } catch {
128
+ // Intentionally swallow — reports are not critical
129
+ }
130
+ },
131
+ };
132
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Target adapters — the bridge between scenarios and the system under test.
3
+ *
4
+ * A TargetAdapter has this shape:
5
+ * {
6
+ * scoreLogin(payload) => Promise<TargetResponse>
7
+ * scoreWithdrawal(payload) => Promise<TargetResponse>
8
+ * scoreAction(payload) => Promise<TargetResponse> // v0.3+
9
+ * reportEvent(type, data) => Promise<void>
10
+ * }
11
+ *
12
+ * TargetResponse shape:
13
+ * {
14
+ * decision: 'ALLOW' | 'STEP_UP' | 'BLOCK' | string,
15
+ * riskScore?: number,
16
+ * reasons?: Array<{ code: string, label?: string, weight?: number }>,
17
+ * raw?: object,
18
+ * }
19
+ */
20
+
21
+ export { httpAdapter } from './http.js';
22
+ export { mockAdapter } from './mock.js';
23
+ export { loggingAdapter } from './logging.js';
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Logging adapter — logs payloads to a logger without sending anywhere.
3
+ *
4
+ * Used to inspect what a scenario produces before pointing at a real target.
5
+ * Returns ALLOW for every call by default.
6
+ *
7
+ * @example
8
+ * import { loggingAdapter } from 'fraud-sim/adapters';
9
+ *
10
+ * const target = loggingAdapter();
11
+ * await run('credential-stuffing', { target, options: { ... } });
12
+ * // → All payloads are printed to stdout; nothing is sent to the network
13
+ */
14
+
15
+ export function loggingAdapter(config = {}) {
16
+ const logger = config.logger ?? console.log;
17
+ const defaultResponse = config.defaultResponse ?? {
18
+ decision: 'ALLOW',
19
+ riskScore: 0,
20
+ reasons: [],
21
+ };
22
+
23
+ function log(operation, payload) {
24
+ logger(`[fraud-sim:logging] ${operation}`, JSON.stringify(payload, null, 2));
25
+ }
26
+
27
+ return {
28
+ async scoreLogin(payload) {
29
+ log('scoreLogin', payload);
30
+ return defaultResponse;
31
+ },
32
+
33
+ async scoreWithdrawal(payload) {
34
+ log('scoreWithdrawal', payload);
35
+ return defaultResponse;
36
+ },
37
+
38
+ async scoreAction(payload) {
39
+ log('scoreAction', payload);
40
+ return defaultResponse;
41
+ },
42
+
43
+ async reportEvent(eventType, data) {
44
+ log(`reportEvent[${eventType}]`, data);
45
+ },
46
+ };
47
+ }