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
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The adaptation demo: the same attacker against two different defences.
|
|
3
|
+
*
|
|
4
|
+
* Run the hardened target first:
|
|
5
|
+
* npm run example:hardened-target (terminal 1, port 4001)
|
|
6
|
+
* npm run example:mock-target (terminal 2, port 4000)
|
|
7
|
+
* npm run example:adaptive-hardened (terminal 3)
|
|
8
|
+
*
|
|
9
|
+
* Against the simple target the attacker wins on move one and never adapts —
|
|
10
|
+
* which tells you about that target's cold-start gap and nothing at all about
|
|
11
|
+
* the attacker. Against the hardened target it has to work, and the round log
|
|
12
|
+
* shows what it changed and why.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { run } from '../../src/index.js';
|
|
16
|
+
import { httpAdapter } from '../../src/adapters/http.js';
|
|
17
|
+
|
|
18
|
+
const SIMPLE = 'http://localhost:4000';
|
|
19
|
+
const HARDENED = 'http://localhost:4001';
|
|
20
|
+
const SEED = 7;
|
|
21
|
+
|
|
22
|
+
const COLOUR = { BLOCK: '\x1b[31m', STEP_UP: '\x1b[33m', ALLOW: '\x1b[32m', ERROR: '\x1b[35m' };
|
|
23
|
+
const RESET = '\x1b[0m';
|
|
24
|
+
const DIM = '\x1b[2m';
|
|
25
|
+
|
|
26
|
+
async function attack(label, baseUrl) {
|
|
27
|
+
const target = httpAdapter({
|
|
28
|
+
baseUrl,
|
|
29
|
+
endpoints: { scoreLogin: '/score/login' },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Clear the target's memory so each run starts from the same place.
|
|
33
|
+
try {
|
|
34
|
+
await fetch(`${baseUrl}/reset`, { method: 'POST' });
|
|
35
|
+
} catch {
|
|
36
|
+
console.error(` Could not reach ${baseUrl} — is that target running?`);
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
console.log(`\n${'═'.repeat(72)}`);
|
|
41
|
+
console.log(` ${label} ${DIM}${baseUrl}${RESET}`);
|
|
42
|
+
console.log('═'.repeat(72));
|
|
43
|
+
|
|
44
|
+
let pendingReasoning = '';
|
|
45
|
+
|
|
46
|
+
const report = await run('adaptive-attacker', {
|
|
47
|
+
target,
|
|
48
|
+
options: { targetUserId: 'victim_001', maxRounds: 8, delayMs: 120 },
|
|
49
|
+
seed: SEED,
|
|
50
|
+
onEvent: (event) => {
|
|
51
|
+
if (event.type === 'attacker_adapts') {
|
|
52
|
+
pendingReasoning = event.reasoning;
|
|
53
|
+
} else if (event.type === 'response') {
|
|
54
|
+
const decision = event.response?.decision ?? 'ERROR';
|
|
55
|
+
const colour = COLOUR[decision] ?? '';
|
|
56
|
+
const codes = (event.response?.reasons ?? []).map((r) => r.code).join(', ') || '—';
|
|
57
|
+
const score = event.response?.riskScore;
|
|
58
|
+
|
|
59
|
+
console.log(
|
|
60
|
+
`\n round ${String(event.round).padStart(2)} ` +
|
|
61
|
+
`${colour}${decision.padEnd(8)}${RESET}` +
|
|
62
|
+
`${DIM}risk ${score != null ? score.toFixed(2) : '—'}${RESET} ${codes}`
|
|
63
|
+
);
|
|
64
|
+
console.log(` ${DIM}↳ ${pendingReasoning}${RESET}`);
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const verdict =
|
|
70
|
+
report.outcome === 'attacker_succeeded'
|
|
71
|
+
? `${COLOUR.ALLOW}attacker succeeded${RESET} on round ${report.attempts}`
|
|
72
|
+
: `${COLOUR.BLOCK}attacker defeated${RESET} after ${report.attempts} rounds`;
|
|
73
|
+
|
|
74
|
+
console.log(`\n ${verdict}`);
|
|
75
|
+
console.log(
|
|
76
|
+
` ${DIM}blocked ${report.blocked} · challenged ${report.stepUp} · allowed ${report.allowed}${RESET}`
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
return report;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const simple = await attack('SIMPLE TARGET', SIMPLE);
|
|
83
|
+
const hardened = await attack('HARDENED TARGET', HARDENED);
|
|
84
|
+
|
|
85
|
+
if (simple && hardened) {
|
|
86
|
+
console.log(`\n${'═'.repeat(72)}`);
|
|
87
|
+
console.log(' WHAT CHANGED');
|
|
88
|
+
console.log('═'.repeat(72));
|
|
89
|
+
console.log(`
|
|
90
|
+
Same scenario, same seed (${SEED}), two defences.
|
|
91
|
+
|
|
92
|
+
Simple target ${simple.attempts} round(s) — ${simple.outcome}
|
|
93
|
+
Hardened target ${hardened.attempts} round(s) — ${hardened.outcome}
|
|
94
|
+
|
|
95
|
+
The simple target allows an unrecognised device outright, so the attacker's
|
|
96
|
+
opening move works and no adaptation is ever needed or visible. The hardened
|
|
97
|
+
target only ever challenges an unrecognised device, so rotation can win back
|
|
98
|
+
a step-up but never an allow.
|
|
99
|
+
|
|
100
|
+
The gap the hardened target leaves: a device that was challenged is
|
|
101
|
+
remembered as seen, not as bad. An attacker that reuses it from a clean IP is
|
|
102
|
+
allowed through. The deterministic provider never tries that — it rotates its
|
|
103
|
+
fingerprint every round. Point a real LLM at it with --llm and see whether it
|
|
104
|
+
finds what the scripted attacker cannot.
|
|
105
|
+
`);
|
|
106
|
+
}
|
|
@@ -26,9 +26,11 @@ console.log('\n=== Credential stuffing simulation ===\n');
|
|
|
26
26
|
const report = await run('credential-stuffing', {
|
|
27
27
|
target,
|
|
28
28
|
options: {
|
|
29
|
-
|
|
29
|
+
// A credential dump is a list of victims. Spraying across accounts is
|
|
30
|
+
// what lets the target's "one IP, many users" rule fire at all.
|
|
31
|
+
targetUserIds: Array.from({ length: 12 }, (_, i) => `victim_user_${i + 1}`),
|
|
30
32
|
attackerCount: 10,
|
|
31
|
-
attemptsPerIp:
|
|
33
|
+
attemptsPerIp: 25,
|
|
32
34
|
delayMs: 50,
|
|
33
35
|
},
|
|
34
36
|
onEvent: (e) => {
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hardened mock fraud target — the one that makes an attacker work for it.
|
|
3
|
+
*
|
|
4
|
+
* The basic example target (`examples/express-mock-target`) has a gap the
|
|
5
|
+
* adaptive attacker walks straight through: a first login from an unknown
|
|
6
|
+
* device scores below the challenge threshold, so the attacker wins on move one
|
|
7
|
+
* and never has to adapt. That is a fine demonstration of a cold-start weakness
|
|
8
|
+
* and a poor demonstration of an adaptive adversary.
|
|
9
|
+
*
|
|
10
|
+
* This target closes that gap. Its central rule is simple and strict:
|
|
11
|
+
*
|
|
12
|
+
* **An unrecognised device is never allowed outright — only challenged.**
|
|
13
|
+
*
|
|
14
|
+
* That single decision is what defeats an attacker whose whole strategy is
|
|
15
|
+
* rotation. Rotating the IP can get you from BLOCK back to STEP_UP; nothing you
|
|
16
|
+
* rotate gets you to ALLOW, because rotating *creates* the unrecognised device
|
|
17
|
+
* that triggers the challenge.
|
|
18
|
+
*
|
|
19
|
+
* Supporting rules:
|
|
20
|
+
* - **IP reputation** — ~57% of address space is treated as known-abusive, so
|
|
21
|
+
* an attacker's exit is usually burned and it has to keep looking.
|
|
22
|
+
* - **Device recidivism** — a fingerprint present on a blocked attempt is
|
|
23
|
+
* refused permanently, so a burned device cannot come back.
|
|
24
|
+
* - **Rotation and velocity** — many networks or many attempts against one
|
|
25
|
+
* account in a short window add score, so a long campaign gets harder.
|
|
26
|
+
*
|
|
27
|
+
* ── The gap this target deliberately leaves ───────────────────────────
|
|
28
|
+
* A device that was only *challenged* is remembered as seen, not as bad. Replay
|
|
29
|
+
* it from a clean IP and you get an ALLOW. The shipped deterministic attacker
|
|
30
|
+
* never finds this, because it rotates its fingerprint every single round. An
|
|
31
|
+
* attacker that reasons "step-up is not a block — reuse that device" beats this
|
|
32
|
+
* target immediately.
|
|
33
|
+
*
|
|
34
|
+
* That asymmetry is the point of running both targets. Against the simple
|
|
35
|
+
* target the difference between a scripted and an adaptive adversary does not
|
|
36
|
+
* show up, because everything wins on move one. Here it does: the same scenario
|
|
37
|
+
* is defeated with the deterministic provider and can succeed with a capable
|
|
38
|
+
* LLM behind it.
|
|
39
|
+
*
|
|
40
|
+
* Run: `npm run example:hardened-target` → http://localhost:4001
|
|
41
|
+
* Then: `npm run example:adaptive-hardened`
|
|
42
|
+
*
|
|
43
|
+
* Still a teaching implementation, not a production engine. In particular,
|
|
44
|
+
* challenging every unrecognised device is a real trade: it stops this attacker
|
|
45
|
+
* and it puts a step-up in front of every customer who buys a new phone.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
import express from 'express';
|
|
50
|
+
|
|
51
|
+
const app = express();
|
|
52
|
+
app.use(express.json());
|
|
53
|
+
|
|
54
|
+
// ── configuration ───────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
const WEIGHTS = {
|
|
57
|
+
NEW_DEVICE: 0.35,
|
|
58
|
+
IP_KNOWN_ABUSIVE: 0.55,
|
|
59
|
+
IP_ROTATION_PATTERN: 0.3,
|
|
60
|
+
LOGIN_VELOCITY_USER: 0.35,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const THRESHOLDS = { allowMax: 0.35, blockMin: 0.6 };
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Synthetic IP reputation. Roughly 57% of the first-octet space is treated as
|
|
67
|
+
* known-abusive — high, but not unrealistic for a list that includes hosting
|
|
68
|
+
* and proxy ranges, and high enough that an attacker's opening exit usually
|
|
69
|
+
* fails. These are invented ranges for the demo, not real reputation data.
|
|
70
|
+
*/
|
|
71
|
+
const ABUSIVE_OCTET_RANGES = [
|
|
72
|
+
[1, 50],
|
|
73
|
+
[100, 140],
|
|
74
|
+
[200, 254],
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
/** Distinct networks per user before rotation itself becomes the signal. */
|
|
78
|
+
const ROTATION_LIMIT = 6;
|
|
79
|
+
|
|
80
|
+
/** Attempts per user, in-window, before velocity fires. */
|
|
81
|
+
const VELOCITY_LIMIT = 8;
|
|
82
|
+
|
|
83
|
+
// ── state ───────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
const seen = {
|
|
86
|
+
attemptsByUser: new Map(), // userId → count in window
|
|
87
|
+
networksByUser: new Map(), // userId → Set<first octet>
|
|
88
|
+
devicesByUser: new Map(), // userId → Set<fingerprint>
|
|
89
|
+
blockedDevices: new Set(), // fingerprints refused on sight
|
|
90
|
+
blockedIps: new Set(),
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// Decay the windowed counters, as a real engine's TTLs would.
|
|
94
|
+
setInterval(() => {
|
|
95
|
+
for (const [user, count] of seen.attemptsByUser) {
|
|
96
|
+
const next = Math.floor(count * 0.5);
|
|
97
|
+
if (next === 0) seen.attemptsByUser.delete(user);
|
|
98
|
+
else seen.attemptsByUser.set(user, next);
|
|
99
|
+
}
|
|
100
|
+
seen.networksByUser.clear();
|
|
101
|
+
}, 60000);
|
|
102
|
+
|
|
103
|
+
// ── scoring ─────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
function isAbusive(ip) {
|
|
106
|
+
const octet = Number(String(ip).split('.')[0]);
|
|
107
|
+
if (!Number.isInteger(octet)) return false;
|
|
108
|
+
return ABUSIVE_OCTET_RANGES.some(([lo, hi]) => octet >= lo && octet <= hi);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isBrowserAgent(ua) {
|
|
112
|
+
return typeof ua === 'string' && /Mozilla\/5\.0/.test(ua);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
app.post('/score/login', (req, res) => {
|
|
116
|
+
const { user_id, ip_address, device_fingerprint, user_agent } = req.body ?? {};
|
|
117
|
+
const reasons = [];
|
|
118
|
+
let score = 0;
|
|
119
|
+
let forceBlock = false;
|
|
120
|
+
|
|
121
|
+
const add = (code, weight, hard = false) => {
|
|
122
|
+
reasons.push({ code, weight });
|
|
123
|
+
score += weight;
|
|
124
|
+
if (hard) forceBlock = true;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// A tool user agent is not a person. Refused outright, which is why the
|
|
128
|
+
// adaptive attacker's browser-agent baseline is the right opening move.
|
|
129
|
+
if (!isBrowserAgent(user_agent)) {
|
|
130
|
+
add('UA_NOT_BROWSER', 1, true);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// A fingerprint that was present on a blocked attempt does not get a second
|
|
134
|
+
// chance — so a working device cannot simply be replayed.
|
|
135
|
+
if (device_fingerprint && seen.blockedDevices.has(device_fingerprint)) {
|
|
136
|
+
add('DEVICE_RECIDIVIST', 1, true);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (ip_address && seen.blockedIps.has(ip_address)) {
|
|
140
|
+
add('IP_PREVIOUSLY_BLOCKED', 1, true);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (isAbusive(ip_address)) {
|
|
144
|
+
add('IP_KNOWN_ABUSIVE', WEIGHTS.IP_KNOWN_ABUSIVE);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const attempts = (seen.attemptsByUser.get(user_id) ?? 0) + 1;
|
|
148
|
+
seen.attemptsByUser.set(user_id, attempts);
|
|
149
|
+
if (attempts > VELOCITY_LIMIT) {
|
|
150
|
+
add('LOGIN_VELOCITY_USER', WEIGHTS.LOGIN_VELOCITY_USER);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Rotation as a signal. One person does not reach an account from four
|
|
154
|
+
// unrelated networks in a few minutes; an attacker burning through exits
|
|
155
|
+
// does exactly that.
|
|
156
|
+
const networks = seen.networksByUser.get(user_id) ?? new Set();
|
|
157
|
+
networks.add(String(ip_address).split('.')[0]);
|
|
158
|
+
seen.networksByUser.set(user_id, networks);
|
|
159
|
+
if (networks.size > ROTATION_LIMIT) {
|
|
160
|
+
add('IP_ROTATION_PATTERN', WEIGHTS.IP_ROTATION_PATTERN);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const devices = seen.devicesByUser.get(user_id) ?? new Set();
|
|
164
|
+
if (device_fingerprint && !devices.has(device_fingerprint)) {
|
|
165
|
+
add('NEW_DEVICE', WEIGHTS.NEW_DEVICE);
|
|
166
|
+
devices.add(device_fingerprint);
|
|
167
|
+
seen.devicesByUser.set(user_id, devices);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const riskScore = Math.min(score, 1);
|
|
171
|
+
const decision =
|
|
172
|
+
forceBlock || riskScore >= THRESHOLDS.blockMin
|
|
173
|
+
? 'BLOCK'
|
|
174
|
+
: riskScore >= THRESHOLDS.allowMax
|
|
175
|
+
? 'STEP_UP'
|
|
176
|
+
: 'ALLOW';
|
|
177
|
+
|
|
178
|
+
// Remember what we refused. This is the memory that makes rotation the only
|
|
179
|
+
// way forward, and eventually makes rotation itself expensive.
|
|
180
|
+
if (decision === 'BLOCK') {
|
|
181
|
+
if (device_fingerprint) seen.blockedDevices.add(device_fingerprint);
|
|
182
|
+
if (ip_address) seen.blockedIps.add(ip_address);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
res.json({ decision, risk_score: riskScore, reasons });
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Withdrawals and actions reuse the simple target's posture — this example
|
|
189
|
+
// exists to harden the *login* path, which is where adaptation happens.
|
|
190
|
+
app.post('/score/withdrawal', (req, res) => {
|
|
191
|
+
const { transaction } = req.body ?? {};
|
|
192
|
+
const reasons = [];
|
|
193
|
+
let score = 0;
|
|
194
|
+
|
|
195
|
+
if (transaction?.is_new_payee) {
|
|
196
|
+
reasons.push({ code: 'NEW_PAYEE_FIRST_TXN', weight: 0.3 });
|
|
197
|
+
score += 0.3;
|
|
198
|
+
}
|
|
199
|
+
if (transaction?.is_new_payee && transaction.amount > 5000000) {
|
|
200
|
+
reasons.push({ code: 'NEW_PAYEE_HIGH_AMOUNT', weight: 0.45 });
|
|
201
|
+
score += 0.45;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const decision = score >= 0.6 ? 'BLOCK' : score >= 0.35 ? 'STEP_UP' : 'ALLOW';
|
|
205
|
+
res.json({ decision, risk_score: Math.min(score, 1), reasons });
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
app.post('/score/action', (req, res) => {
|
|
209
|
+
res.json({ decision: 'ALLOW', risk_score: 0, reasons: [] });
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
app.post('/event/:type', (req, res) => res.json({ ok: true }));
|
|
213
|
+
|
|
214
|
+
/** Test-only, as in the simple target — see its /reset for why. */
|
|
215
|
+
app.post('/reset', (req, res) => {
|
|
216
|
+
seen.attemptsByUser.clear();
|
|
217
|
+
seen.networksByUser.clear();
|
|
218
|
+
seen.devicesByUser.clear();
|
|
219
|
+
seen.blockedDevices.clear();
|
|
220
|
+
seen.blockedIps.clear();
|
|
221
|
+
res.json({ ok: true });
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
app.get('/health', (req, res) => res.json({ status: 'ok', profile: 'hardened' }));
|
|
225
|
+
|
|
226
|
+
const PORT = process.env.PORT || 4001;
|
|
227
|
+
app.listen(PORT, () => {
|
|
228
|
+
console.log(`Hardened mock fraud target listening on http://localhost:${PORT}`);
|
|
229
|
+
console.log('Try: npm run example:adaptive-hardened in another terminal');
|
|
230
|
+
});
|
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
* meaningful block rates. This is NOT FriskLayer — it's a stripped-down
|
|
6
6
|
* pedagogical example showing the kind of system fraud-sim tests against.
|
|
7
7
|
*
|
|
8
|
+
* Covers all three scoring endpoints (`/score/login`, `/score/withdrawal`,
|
|
9
|
+
* `/score/action`), so every shipped scenario can run against it.
|
|
10
|
+
*
|
|
8
11
|
* Run: `npm run example:mock-target`
|
|
9
12
|
* Then point a scenario at http://localhost:4000
|
|
10
13
|
*/
|
|
@@ -21,11 +24,22 @@ const seen = {
|
|
|
21
24
|
loginsByIp: new Map(), // ip → number
|
|
22
25
|
payeesByUser: new Map(), // userId → Set<payeeAccount>
|
|
23
26
|
newPayeesByUser: new Map(), // userId → number of new payees in last 30 min
|
|
27
|
+
cardAuthsByUser: new Map(), // userId → number of card auths in window
|
|
28
|
+
binsByUser: new Map(), // userId → Map<bin, count>
|
|
29
|
+
signupsByDevice: new Map(), // deviceFp → number of signups
|
|
30
|
+
signupsBySubnet: new Map(), // /24 → number of signups
|
|
31
|
+
payoutAccounts: new Map(), // payoutAccount → Set<userId>
|
|
32
|
+
sessionOrigin: new Map(), // sessionId → { device, ip }
|
|
24
33
|
};
|
|
25
34
|
|
|
26
35
|
// Decay counters every minute
|
|
27
36
|
setInterval(() => {
|
|
28
|
-
for (const map of [
|
|
37
|
+
for (const map of [
|
|
38
|
+
seen.loginsByUser,
|
|
39
|
+
seen.loginsByIp,
|
|
40
|
+
seen.newPayeesByUser,
|
|
41
|
+
seen.cardAuthsByUser,
|
|
42
|
+
]) {
|
|
29
43
|
for (const [key, count] of map) {
|
|
30
44
|
const next = Math.floor(count * 0.5);
|
|
31
45
|
if (next === 0) map.delete(key);
|
|
@@ -64,6 +78,14 @@ app.post('/score/login', (req, res) => {
|
|
|
64
78
|
seen.devicesByUser.set(user_id, userDevices);
|
|
65
79
|
}
|
|
66
80
|
|
|
81
|
+
// Remember where this session began, so later activity can be compared to it.
|
|
82
|
+
if (req.body?.session_id) {
|
|
83
|
+
seen.sessionOrigin.set(req.body.session_id, {
|
|
84
|
+
device: device_fingerprint,
|
|
85
|
+
ip: ip_address,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
67
89
|
const decision = score >= 0.65 ? 'BLOCK' : score >= 0.35 ? 'STEP_UP' : 'ALLOW';
|
|
68
90
|
|
|
69
91
|
res.json({
|
|
@@ -74,10 +96,24 @@ app.post('/score/login', (req, res) => {
|
|
|
74
96
|
});
|
|
75
97
|
|
|
76
98
|
app.post('/score/withdrawal', (req, res) => {
|
|
77
|
-
const { user_id, transaction } = req.body ?? {};
|
|
99
|
+
const { user_id, transaction, session_id, device_fingerprint, ip_address } = req.body ?? {};
|
|
78
100
|
const reasons = [];
|
|
79
101
|
let score = 0;
|
|
80
102
|
|
|
103
|
+
// Rule: session continuity at the point money moves — the check that catches
|
|
104
|
+
// a hijacked session, which never produced a login event of its own.
|
|
105
|
+
if (session_id) {
|
|
106
|
+
const origin = seen.sessionOrigin.get(session_id);
|
|
107
|
+
if (origin && origin.device !== device_fingerprint) {
|
|
108
|
+
reasons.push({ code: 'DEVICE_SESSION_MISMATCH', weight: 0.5 });
|
|
109
|
+
score += 0.5;
|
|
110
|
+
}
|
|
111
|
+
if (origin && origin.ip !== ip_address) {
|
|
112
|
+
reasons.push({ code: 'IP_SESSION_MISMATCH', weight: 0.3 });
|
|
113
|
+
score += 0.3;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
81
117
|
// Rule: new payee
|
|
82
118
|
if (transaction?.is_new_payee) {
|
|
83
119
|
reasons.push({ code: 'NEW_PAYEE_FIRST_TXN', weight: 0.2 });
|
|
@@ -108,11 +144,126 @@ app.post('/score/withdrawal', (req, res) => {
|
|
|
108
144
|
});
|
|
109
145
|
});
|
|
110
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Generic action scoring — signups, promo claims, card authorisations, and
|
|
149
|
+
* in-session activity. The `card-testing`, `promo-abuse` and `session-hijack`
|
|
150
|
+
* scenarios need this endpoint; without it they cannot run at all.
|
|
151
|
+
*
|
|
152
|
+
* As with the rules above, these are pedagogical: enough structure for a
|
|
153
|
+
* scenario to produce a meaningful result, not a real detection engine.
|
|
154
|
+
*/
|
|
155
|
+
app.post('/score/action', (req, res) => {
|
|
156
|
+
const { user_id, action_type, ip_address, device_fingerprint, session_id, details } =
|
|
157
|
+
req.body ?? {};
|
|
158
|
+
const reasons = [];
|
|
159
|
+
let score = 0;
|
|
160
|
+
|
|
161
|
+
const subnet = String(ip_address ?? '').split('.').slice(0, 3).join('.');
|
|
162
|
+
|
|
163
|
+
if (action_type === 'card_auth') {
|
|
164
|
+
// Rule: micro-authorisation velocity. Card testers probe fast and cheap.
|
|
165
|
+
const authCount = (seen.cardAuthsByUser.get(user_id) ?? 0) + 1;
|
|
166
|
+
seen.cardAuthsByUser.set(user_id, authCount);
|
|
167
|
+
if (authCount > 8) {
|
|
168
|
+
reasons.push({ code: 'CARD_AUTH_VELOCITY', weight: 0.4 });
|
|
169
|
+
score += 0.4;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Rule: BIN concentration. A genuine merchant sees many BINs; a stolen
|
|
173
|
+
// list drawn from one breach sees very few.
|
|
174
|
+
const bins = seen.binsByUser.get(user_id) ?? new Map();
|
|
175
|
+
const bin = details?.card_bin ?? 'unknown';
|
|
176
|
+
bins.set(bin, (bins.get(bin) ?? 0) + 1);
|
|
177
|
+
seen.binsByUser.set(user_id, bins);
|
|
178
|
+
const busiestBin = Math.max(...bins.values());
|
|
179
|
+
if (busiestBin > 5 && bins.size <= 4) {
|
|
180
|
+
reasons.push({ code: 'BIN_CONCENTRATION', weight: 0.35 });
|
|
181
|
+
score += 0.35;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Rule: trivial amount, card not present — the shape of a probe.
|
|
185
|
+
if (details?.entry_mode === 'card_not_present' && (details?.amount ?? 0) < 10000) {
|
|
186
|
+
reasons.push({ code: 'MICRO_AUTH_CNP', weight: 0.2 });
|
|
187
|
+
score += 0.2;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (action_type === 'signup' || action_type === 'promo_claim') {
|
|
192
|
+
// Rule: device reuse across accounts.
|
|
193
|
+
const perDevice = (seen.signupsByDevice.get(device_fingerprint) ?? 0) + 1;
|
|
194
|
+
seen.signupsByDevice.set(device_fingerprint, perDevice);
|
|
195
|
+
if (perDevice > 3) {
|
|
196
|
+
reasons.push({ code: 'DEVICE_MANY_ACCOUNTS', weight: 0.45 });
|
|
197
|
+
score += 0.45;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Rule: subnet clustering.
|
|
201
|
+
const perSubnet = (seen.signupsBySubnet.get(subnet) ?? 0) + 1;
|
|
202
|
+
seen.signupsBySubnet.set(subnet, perSubnet);
|
|
203
|
+
if (perSubnet > 6) {
|
|
204
|
+
reasons.push({ code: 'SUBNET_MANY_SIGNUPS', weight: 0.3 });
|
|
205
|
+
score += 0.3;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Rule: payout concentration — many accounts, one destination.
|
|
209
|
+
const payout = details?.payout_account;
|
|
210
|
+
if (payout) {
|
|
211
|
+
const users = seen.payoutAccounts.get(payout) ?? new Set();
|
|
212
|
+
users.add(user_id);
|
|
213
|
+
seen.payoutAccounts.set(payout, users);
|
|
214
|
+
if (users.size > 4) {
|
|
215
|
+
reasons.push({ code: 'PAYOUT_CONCENTRATION', weight: 0.4 });
|
|
216
|
+
score += 0.4;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Rule: session continuity. The device and network a session was opened from
|
|
222
|
+
// should be the ones it continues from.
|
|
223
|
+
if (session_id) {
|
|
224
|
+
const origin = seen.sessionOrigin.get(session_id);
|
|
225
|
+
if (!origin) {
|
|
226
|
+
seen.sessionOrigin.set(session_id, { device: device_fingerprint, ip: ip_address });
|
|
227
|
+
} else {
|
|
228
|
+
if (origin.device !== device_fingerprint) {
|
|
229
|
+
reasons.push({ code: 'DEVICE_SESSION_MISMATCH', weight: 0.5 });
|
|
230
|
+
score += 0.5;
|
|
231
|
+
}
|
|
232
|
+
if (origin.ip !== ip_address) {
|
|
233
|
+
reasons.push({ code: 'IP_SESSION_MISMATCH', weight: 0.3 });
|
|
234
|
+
score += 0.3;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const decision = score >= 0.65 ? 'BLOCK' : score >= 0.35 ? 'STEP_UP' : 'ALLOW';
|
|
240
|
+
|
|
241
|
+
res.json({
|
|
242
|
+
decision,
|
|
243
|
+
risk_score: Math.min(score, 1.0),
|
|
244
|
+
reasons,
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
111
248
|
app.post('/event/:type', (req, res) => {
|
|
112
249
|
// Accept and discard — this mock doesn't process side events
|
|
113
250
|
res.json({ ok: true });
|
|
114
251
|
});
|
|
115
252
|
|
|
253
|
+
/**
|
|
254
|
+
* Clear all in-memory state.
|
|
255
|
+
*
|
|
256
|
+
* Test-only. A real fraud system has no such endpoint — but when running
|
|
257
|
+
* several scenarios in sequence against one instance, counters and device
|
|
258
|
+
* history from an earlier scenario leak into a later one and make each
|
|
259
|
+
* scenario look guiltier than it is. Reset between scenarios to measure each
|
|
260
|
+
* attack independently.
|
|
261
|
+
*/
|
|
262
|
+
app.post('/reset', (req, res) => {
|
|
263
|
+
for (const map of Object.values(seen)) map.clear();
|
|
264
|
+
res.json({ ok: true });
|
|
265
|
+
});
|
|
266
|
+
|
|
116
267
|
app.get('/health', (req, res) => res.json({ status: 'ok' }));
|
|
117
268
|
|
|
118
269
|
const PORT = process.env.PORT || 4000;
|
package/package.json
CHANGED
|
@@ -1,20 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fraud-sim",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Realistic synthetic attack traffic for testing fintech fraud detection systems",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
|
8
|
-
".":
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./types/index.d.ts",
|
|
10
|
+
"default": "./src/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./adapters": {
|
|
13
|
+
"types": "./types/adapters/index.d.ts",
|
|
14
|
+
"default": "./src/adapters/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./streaming": {
|
|
17
|
+
"types": "./types/streaming/index.d.ts",
|
|
18
|
+
"default": "./src/streaming/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./llm": {
|
|
21
|
+
"types": "./types/llm/index.d.ts",
|
|
22
|
+
"default": "./src/llm/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./utils": {
|
|
25
|
+
"types": "./types/utils/index.d.ts",
|
|
26
|
+
"default": "./src/utils/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./reporting": {
|
|
29
|
+
"types": "./types/reporting/index.d.ts",
|
|
30
|
+
"default": "./src/reporting/index.js"
|
|
31
|
+
}
|
|
12
32
|
},
|
|
13
33
|
"engines": {
|
|
14
34
|
"node": ">=20"
|
|
15
35
|
},
|
|
16
36
|
"files": [
|
|
37
|
+
"bin/",
|
|
17
38
|
"src/",
|
|
39
|
+
"types/",
|
|
18
40
|
"examples/",
|
|
19
41
|
"README.md",
|
|
20
42
|
"LICENSE",
|
|
@@ -29,8 +51,12 @@
|
|
|
29
51
|
"example:basic": "node examples/basic-credential-stuffing/index.js",
|
|
30
52
|
"example:drain": "node examples/account-drain-demo/index.js",
|
|
31
53
|
"example:mock-target": "node examples/express-mock-target/server.js",
|
|
54
|
+
"example:hardened-target": "node examples/express-hardened-target/server.js",
|
|
32
55
|
"example:adaptive-target": "node examples/adaptive-attacker-demo/demo-target.js",
|
|
33
|
-
"example:adaptive": "node examples/adaptive-attacker-demo/index.js"
|
|
56
|
+
"example:adaptive": "node examples/adaptive-attacker-demo/index.js",
|
|
57
|
+
"build:types": "tsc",
|
|
58
|
+
"prepublishOnly": "npm run build:types && npm run lint && npm run test:all",
|
|
59
|
+
"example:adaptive-hardened": "node examples/adaptive-hardened-demo/index.js"
|
|
34
60
|
},
|
|
35
61
|
"keywords": [
|
|
36
62
|
"fraud",
|
|
@@ -45,7 +71,7 @@
|
|
|
45
71
|
"license": "MIT",
|
|
46
72
|
"repository": {
|
|
47
73
|
"type": "git",
|
|
48
|
-
"url": "https://github.com/arthurr-beep/fraudsim.git"
|
|
74
|
+
"url": "git+https://github.com/arthurr-beep/fraudsim.git"
|
|
49
75
|
},
|
|
50
76
|
"bugs": {
|
|
51
77
|
"url": "https://github.com/arthurr-beep/fraudsim/issues"
|
|
@@ -53,8 +79,14 @@
|
|
|
53
79
|
"homepage": "https://github.com/arthurr-beep/fraudsim#readme",
|
|
54
80
|
"devDependencies": {
|
|
55
81
|
"@eslint/js": "^9.39.5",
|
|
82
|
+
"@types/node": "^20.19.43",
|
|
56
83
|
"eslint": "^9.39.5",
|
|
57
84
|
"express": "^4.21.0",
|
|
58
|
-
"globals": "^17.11.0"
|
|
85
|
+
"globals": "^17.11.0",
|
|
86
|
+
"typescript": "^5.9.3"
|
|
87
|
+
},
|
|
88
|
+
"types": "./types/index.d.ts",
|
|
89
|
+
"bin": {
|
|
90
|
+
"fraud-sim": "bin/fraud-sim.js"
|
|
59
91
|
}
|
|
60
92
|
}
|