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/CHANGELOG.md +91 -0
- package/LICENSE +21 -0
- package/README.md +485 -0
- package/examples/account-drain-demo/index.js +51 -0
- package/examples/adaptive-attacker-demo/README.md +61 -0
- package/examples/adaptive-attacker-demo/demo-target.js +85 -0
- package/examples/adaptive-attacker-demo/index.js +83 -0
- package/examples/basic-credential-stuffing/index.js +60 -0
- package/examples/express-mock-target/server.js +122 -0
- package/package.json +60 -0
- package/src/adapters/http.js +132 -0
- package/src/adapters/index.js +23 -0
- package/src/adapters/logging.js +47 -0
- package/src/adapters/mock.js +80 -0
- package/src/index.js +155 -0
- package/src/llm/anthropic.js +129 -0
- package/src/llm/deterministic.js +125 -0
- package/src/llm/index.js +19 -0
- package/src/llm/openai.js +112 -0
- package/src/runner.js +87 -0
- package/src/scenarios/account-drain.js +255 -0
- package/src/scenarios/adaptive-attacker.js +206 -0
- package/src/scenarios/credential-stuffing.js +124 -0
- package/src/scenarios/index.js +25 -0
- package/src/streaming/index.js +6 -0
- package/src/streaming/sse.js +105 -0
- package/src/utils/faker-helpers.js +76 -0
- package/src/utils/random.js +114 -0
- package/src/utils/rng.js +76 -0
- package/src/utils/sleep.js +32 -0
|
@@ -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
|
+
}
|