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.
Files changed (82) hide show
  1. package/CHANGELOG.md +462 -0
  2. package/README.md +621 -81
  3. package/bin/fraud-sim.js +625 -0
  4. package/examples/account-drain-demo/index.js +1 -1
  5. package/examples/adaptive-hardened-demo/index.js +106 -0
  6. package/examples/basic-credential-stuffing/index.js +4 -2
  7. package/examples/express-hardened-target/server.js +230 -0
  8. package/examples/express-mock-target/server.js +153 -2
  9. package/package.json +40 -8
  10. package/src/adapters/http.js +49 -7
  11. package/src/index.js +126 -13
  12. package/src/llm/anthropic.js +7 -0
  13. package/src/llm/deterministic.js +21 -7
  14. package/src/llm/openai.js +7 -0
  15. package/src/locales/index.js +193 -0
  16. package/src/reporting/aggregate.js +338 -0
  17. package/src/reporting/html.js +424 -0
  18. package/src/reporting/index.js +241 -0
  19. package/src/reporting/junit.js +171 -0
  20. package/src/reporting/money.js +105 -0
  21. package/src/runner.js +295 -12
  22. package/src/scenarios/account-drain.js +10 -2
  23. package/src/scenarios/adaptive-attacker.js +33 -11
  24. package/src/scenarios/card-testing.js +174 -0
  25. package/src/scenarios/credential-stuffing.js +146 -20
  26. package/src/scenarios/index.js +37 -6
  27. package/src/scenarios/legitimate-users.js +290 -0
  28. package/src/scenarios/mule-network.js +201 -0
  29. package/src/scenarios/promo-abuse.js +168 -0
  30. package/src/scenarios/session-hijack.js +234 -0
  31. package/src/scenarios/sim-swap-takeover.js +253 -0
  32. package/src/scenarios/slow-drain.js +171 -0
  33. package/src/scenarios/velocity-evasion.js +179 -0
  34. package/src/types.js +160 -0
  35. package/src/utils/clock.js +77 -0
  36. package/src/utils/faker-helpers.js +87 -2
  37. package/src/utils/index.js +69 -0
  38. package/src/utils/random-source.js +104 -0
  39. package/src/utils/random.js +257 -44
  40. package/src/utils/results.js +84 -0
  41. package/src/utils/rng.js +3 -4
  42. package/src/utils/throttle.js +127 -0
  43. package/types/adapters/http.d.ts +6 -0
  44. package/types/adapters/index.d.ts +3 -0
  45. package/types/adapters/logging.d.ts +19 -0
  46. package/types/adapters/mock.d.ts +32 -0
  47. package/types/index.d.ts +79 -0
  48. package/types/llm/anthropic.d.ts +17 -0
  49. package/types/llm/deterministic.d.ts +11 -0
  50. package/types/llm/index.d.ts +3 -0
  51. package/types/llm/openai.d.ts +17 -0
  52. package/types/locales/index.d.ts +146 -0
  53. package/types/reporting/aggregate.d.ts +126 -0
  54. package/types/reporting/html.d.ts +1 -0
  55. package/types/reporting/index.d.ts +99 -0
  56. package/types/reporting/junit.d.ts +13 -0
  57. package/types/reporting/money.d.ts +20 -0
  58. package/types/runner.d.ts +10 -0
  59. package/types/scenarios/account-drain.d.ts +25 -0
  60. package/types/scenarios/adaptive-attacker.d.ts +22 -0
  61. package/types/scenarios/card-testing.d.ts +28 -0
  62. package/types/scenarios/credential-stuffing.d.ts +23 -0
  63. package/types/scenarios/index.d.ts +261 -0
  64. package/types/scenarios/legitimate-users.d.ts +49 -0
  65. package/types/scenarios/mule-network.d.ts +17 -0
  66. package/types/scenarios/promo-abuse.d.ts +28 -0
  67. package/types/scenarios/session-hijack.d.ts +15 -0
  68. package/types/scenarios/sim-swap-takeover.d.ts +16 -0
  69. package/types/scenarios/slow-drain.d.ts +28 -0
  70. package/types/scenarios/velocity-evasion.d.ts +31 -0
  71. package/types/streaming/index.d.ts +1 -0
  72. package/types/streaming/sse.d.ts +34 -0
  73. package/types/types.d.ts +191 -0
  74. package/types/utils/clock.d.ts +39 -0
  75. package/types/utils/faker-helpers.d.ts +114 -0
  76. package/types/utils/index.d.ts +8 -0
  77. package/types/utils/random-source.d.ts +39 -0
  78. package/types/utils/random.d.ts +89 -0
  79. package/types/utils/results.d.ts +46 -0
  80. package/types/utils/rng.d.ts +36 -0
  81. package/types/utils/sleep.d.ts +7 -0
  82. 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
- targetUserId: 'victim_user_001',
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: 3,
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 [seen.loginsByUser, seen.loginsByIp, seen.newPayeesByUser]) {
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.2.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
- ".": "./src/index.js",
9
- "./adapters": "./src/adapters/index.js",
10
- "./streaming": "./src/streaming/index.js",
11
- "./llm": "./src/llm/index.js"
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
  }