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,290 @@
1
+ /**
2
+ * Scenario: legitimate-users
3
+ *
4
+ * Not an attack. Ordinary customers doing ordinary things — logging in from
5
+ * their own phone, paying people they have paid before, occasionally buying a
6
+ * new phone or travelling — which is the traffic a defence must let through.
7
+ *
8
+ * Every other scenario measures how much attack traffic gets stopped. On its
9
+ * own that number is easy to game: a defence that blocks everything has a 100%
10
+ * block rate. This scenario is the other half of the measurement. A block here
11
+ * is a false positive — a real customer refused — and a step-up is friction.
12
+ *
13
+ * Each customer has a stable identity: one device, one browser, one home
14
+ * address in a domestic city, and a couple of payees they already know. A short
15
+ * warm-up gives the target some history before anything is measured, because a
16
+ * real customer is not a stranger to the system the way a simulated one is.
17
+ * Warm-up decisions are reported separately and excluded from the rates.
18
+ *
19
+ * Sessions are spread across days on a simulated clock, not sent as a burst,
20
+ * so a velocity rule only fires on this traffic if it would fire on a real
21
+ * customer's week. That holds only if the target reads the event timestamp —
22
+ * see `simulatedTime` in the report.
23
+ *
24
+ * Behaviour, per measured session:
25
+ * - routine own device, home network → should ALLOW
26
+ * - new device a new phone, home network → STEP_UP is fair
27
+ * - travel own device, a foreign residential network → STEP_UP is fair
28
+ * - withdrawal to a known payee, a normal amount → should ALLOW
29
+ * - new payee a small first payment to someone new → STEP_UP is fair
30
+ *
31
+ * A customer whose login is blocked gives up for that session; one who is
32
+ * challenged is assumed to pass the challenge and carry on, as most do.
33
+ */
34
+
35
+ import { sleep } from '../utils/sleep.js';
36
+ import { simulatedClock, humaniseMs } from '../utils/clock.js';
37
+ import {
38
+ buildLoginPayload,
39
+ buildWithdrawalPayload,
40
+ newDeviceFingerprint,
41
+ newPayee,
42
+ newSessionId,
43
+ } from '../utils/faker-helpers.js';
44
+ import { newResults, scoreAndCount } from '../utils/results.js';
45
+ import { randomFloat } from '../utils/random-source.js';
46
+ import { demoIpPool, pickDemoIp, pickOne, randomBrowserUserAgent, randomInt } from '../utils/random.js';
47
+
48
+ /** Stages, in the order the report lists them. */
49
+ const STAGES = [
50
+ 'routine-login',
51
+ 'new-device-login',
52
+ 'travel-login',
53
+ 'known-payee-withdrawal',
54
+ 'new-payee-withdrawal',
55
+ ];
56
+
57
+ export default {
58
+ id: 'legitimate-users',
59
+ name: 'Legitimate customers',
60
+ traffic: 'legitimate',
61
+ description:
62
+ 'Ordinary customers logging in and paying people they know. Not an attack: measures false positives and friction.',
63
+
64
+ defaultOptions: {
65
+ targetUserIds: null, // required — one customer per id
66
+ warmupLogins: 2, // history per customer before anything is measured
67
+ sessionsPerUser: 4, // measured sessions per customer
68
+ sessionGapHours: 8, // simulated time between a customer's sessions
69
+ withdrawalRate: 0.5, // share of sessions that include a payment
70
+ newDeviceRate: 0.1, // share of sessions on a new phone
71
+ travelRate: 0.05, // share of sessions from abroad
72
+ newPayeeRate: 0.1, // share of payments to someone new
73
+ amountMinor: 2500000, // typical payment (25,000 major units)
74
+ delayMs: 20,
75
+ },
76
+
77
+ async run(ctx) {
78
+ const { options, emit, signal } = ctx;
79
+
80
+ const userIds = options.targetUserIds;
81
+ if (!Array.isArray(userIds) || userIds.length === 0) {
82
+ throw new Error('legitimate-users requires options.targetUserIds (one id per customer)');
83
+ }
84
+ for (const key of ['withdrawalRate', 'newDeviceRate', 'travelRate', 'newPayeeRate']) {
85
+ if (!(options[key] >= 0 && options[key] <= 1)) {
86
+ throw new Error(`legitimate-users requires options.${key} between 0 and 1`);
87
+ }
88
+ }
89
+ if (options.newDeviceRate + options.travelRate > 1) {
90
+ throw new Error('legitimate-users requires newDeviceRate + travelRate <= 1');
91
+ }
92
+
93
+ const results = newResults();
94
+ const warmup = newResults();
95
+ const byStage = Object.fromEntries(
96
+ STAGES.map((s) => [s, { events: 0, allowed: 0, stepUp: 0, blocked: 0 }])
97
+ );
98
+ const blockedCustomers = new Set();
99
+ const falsePositiveReasons = new Map();
100
+ let valueBlockedMinor = 0;
101
+
102
+ const clock = simulatedClock({ start: Date.now(), stepMs: options.sessionGapHours * 3_600_000 });
103
+ const customers = userIds.map((id, i) => newCustomer(id, i));
104
+
105
+ emit({
106
+ type: 'milestone',
107
+ message:
108
+ `${customers.length} ordinary customers, ${options.sessionsPerUser} sessions each — ` +
109
+ 'every block from here on is a false positive',
110
+ timestamp: Date.now(),
111
+ });
112
+
113
+ const send = async (customer, method, payload, stage, ipIntel, tally) => {
114
+ const response = await scoreAndCount({ ctx, method, payload, results: tally, stage, ipIntel });
115
+ if (tally === results && response) {
116
+ const decision = response.decision;
117
+ const row = byStage[stage];
118
+ row.events++;
119
+ if (decision === 'ALLOW') row.allowed++;
120
+ else if (decision === 'STEP_UP') row.stepUp++;
121
+ else if (decision === 'BLOCK') {
122
+ row.blocked++;
123
+ blockedCustomers.add(customer.id);
124
+ for (const reason of response.reasons ?? []) {
125
+ const code = typeof reason === 'string' ? reason : reason?.code;
126
+ if (code) falsePositiveReasons.set(code, (falsePositiveReasons.get(code) ?? 0) + 1);
127
+ }
128
+ }
129
+ }
130
+ try {
131
+ await sleep(options.delayMs, signal);
132
+ } catch {
133
+ // aborted — the loops check `signal` before the next send
134
+ }
135
+ return response;
136
+ };
137
+
138
+ // ── Warm-up: the history a real customer would already have ─────────
139
+ for (let i = 0; i < options.warmupLogins && !signal.aborted; i++) {
140
+ for (const customer of customers) {
141
+ if (signal.aborted) break;
142
+ await send(
143
+ customer,
144
+ 'scoreLogin',
145
+ loginPayload(customer, customer.home.ip, clock),
146
+ 'warmup-login',
147
+ customer.home.intel,
148
+ warmup
149
+ );
150
+ }
151
+ clock.advance();
152
+ }
153
+
154
+ // ── Measured sessions ───────────────────────────────────────────────
155
+ for (let s = 0; s < options.sessionsPerUser && !signal.aborted; s++) {
156
+ for (const customer of customers) {
157
+ if (signal.aborted) break;
158
+
159
+ const roll = randomFloat();
160
+ let stage = 'routine-login';
161
+ let network = customer.home;
162
+
163
+ if (roll < options.travelRate) {
164
+ stage = 'travel-login';
165
+ network = foreignNetwork();
166
+ } else if (roll < options.travelRate + options.newDeviceRate) {
167
+ stage = 'new-device-login';
168
+ // A new phone, so a new fingerprint — and from now on it is theirs.
169
+ customer.device = newDeviceFingerprint();
170
+ }
171
+
172
+ const loginSent = loginPayload(customer, network.ip, clock);
173
+ const login = await send(
174
+ customer,
175
+ 'scoreLogin',
176
+ loginSent,
177
+ stage,
178
+ network.intel,
179
+ results
180
+ );
181
+
182
+ if (!login || login.decision === 'BLOCK') continue;
183
+ if (randomFloat() >= options.withdrawalRate) continue;
184
+
185
+ const toNewPayee = randomFloat() < options.newPayeeRate;
186
+ // A first payment to someone new is usually small; a payment to a
187
+ // known payee varies around the customer's usual amount.
188
+ const amount = toNewPayee
189
+ ? Math.round(options.amountMinor * (0.1 + randomFloat() * 0.2))
190
+ : Math.round(options.amountMinor * (0.5 + randomFloat()));
191
+ const payee = toNewPayee ? newPayee() : pickOne(customer.payees);
192
+
193
+ const withdrawal = await send(
194
+ customer,
195
+ 'scoreWithdrawal',
196
+ buildWithdrawalPayload(
197
+ customer.id,
198
+ { amount, ...payee, is_new_payee: toNewPayee },
199
+ {
200
+ // Same session, device and network as the login, a few minutes on.
201
+ session_id: loginSent.session_id,
202
+ ip_address: network.ip,
203
+ device_fingerprint: customer.device,
204
+ timestamp: new Date(Date.parse(loginSent.timestamp) + randomInt(1, 10) * 60_000).toISOString(),
205
+ }
206
+ ),
207
+ toNewPayee ? 'new-payee-withdrawal' : 'known-payee-withdrawal',
208
+ network.intel,
209
+ results
210
+ );
211
+
212
+ if (withdrawal?.decision === 'BLOCK') valueBlockedMinor += amount;
213
+ // Once paid, a new payee is a known one next time.
214
+ if (toNewPayee) customer.payees.push(payee);
215
+ }
216
+ clock.advance();
217
+ }
218
+
219
+ const scored = Math.max(0, results.attempts - results.errors);
220
+
221
+ if (results.blocked > 0) {
222
+ emit({
223
+ type: 'milestone',
224
+ message:
225
+ `${results.blocked} ordinary customer event${results.blocked === 1 ? ' was' : 's were'} ` +
226
+ `blocked — ${blockedCustomers.size} of ${customers.length} customers refused at least once`,
227
+ timestamp: Date.now(),
228
+ });
229
+ }
230
+
231
+ return {
232
+ ...results,
233
+ customers: customers.length,
234
+ customersBlocked: blockedCustomers.size,
235
+ falsePositiveRate: scored > 0 ? results.blocked / scored : 0,
236
+ frictionRate: scored > 0 ? (results.blocked + results.stepUp) / scored : 0,
237
+ valueBlockedMinor,
238
+ byStage,
239
+ falsePositiveReasons: Object.fromEntries(
240
+ [...falsePositiveReasons].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
241
+ ),
242
+ warmup: {
243
+ attempts: warmup.attempts,
244
+ blocked: warmup.blocked,
245
+ stepUp: warmup.stepUp,
246
+ allowed: warmup.allowed,
247
+ errors: warmup.errors,
248
+ },
249
+ simulatedTime: true,
250
+ simulatedSpan: humaniseMs(clock.elapsedMs()),
251
+ };
252
+ },
253
+ };
254
+
255
+ /**
256
+ * One customer's stable identity.
257
+ *
258
+ * Each gets their own home address in the documentation range, carrying the
259
+ * geolocation of a domestic city. Sharing the market's few domestic pool
260
+ * addresses would put a dozen customers on one IP and trip any users-per-IP
261
+ * rule — a false positive manufactured by the simulator, not found by it.
262
+ */
263
+ function newCustomer(id, index) {
264
+ const domestic = demoIpPool().filter((e) => e.persona === 'domestic');
265
+ const { ip: _poolIp, ...intel } = domestic[index % domestic.length];
266
+ return {
267
+ id,
268
+ device: newDeviceFingerprint(),
269
+ userAgent: randomBrowserUserAgent(),
270
+ // 192.0.2.100–254: RFC 5737, clear of the shared pool's .10–.12.
271
+ home: { ip: `192.0.2.${100 + (index % 155)}`, intel },
272
+ payees: [newPayee(), newPayee()],
273
+ };
274
+ }
275
+
276
+ function foreignNetwork() {
277
+ const { ip, ...intel } = pickDemoIp('foreign');
278
+ return { ip, intel };
279
+ }
280
+
281
+ function loginPayload(customer, ip, clock) {
282
+ // Sessions minutes apart within a round, not all on the same millisecond.
283
+ return buildLoginPayload(customer.id, {
284
+ session_id: newSessionId('sess'),
285
+ ip_address: ip,
286
+ user_agent: customer.userAgent,
287
+ device_fingerprint: customer.device,
288
+ timestamp: new Date(clock.epoch() + randomInt(0, 90) * 60_000).toISOString(),
289
+ });
290
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Scenario: mule-network
3
+ *
4
+ * Stolen funds are rarely withdrawn in one move. They are layered: a source
5
+ * account pushes money to a set of intermediary "mule" accounts, each of which
6
+ * forwards nearly all of it onward to a common destination, keeping a small cut
7
+ * as payment. The mules are often real people with real, previously clean
8
+ * accounts — which is exactly why per-account rules struggle here.
9
+ *
10
+ * No individual transfer in this scenario need look unusual. What gives it away
11
+ * is the graph: a fan-out followed by a fan-in, with high pass-through and low
12
+ * retention at every intermediate hop.
13
+ *
14
+ * Defensive systems should catch this via:
15
+ * - Graph clustering (accounts linked by transaction flow)
16
+ * - Pass-through ratio (money in ≈ money out, within hours)
17
+ * - Fan-in concentration (many unrelated accounts, one destination)
18
+ * - Dormancy-then-activity (a quiet account suddenly moving funds)
19
+ *
20
+ * A system scoring transactions one at a time, with no memory of the graph,
21
+ * will pass this scenario cleanly — which is the point of running it.
22
+ */
23
+
24
+ import { sleep } from '../utils/sleep.js';
25
+ import {
26
+ buildTransferPayload,
27
+ newDeviceFingerprint,
28
+ } from '../utils/faker-helpers.js';
29
+ import { newResults, scoreAndCount } from '../utils/results.js';
30
+ import { simulatedClock, humaniseMs } from '../utils/clock.js';
31
+ import { randomAccountNumber, randomIp } from '../utils/random.js';
32
+
33
+ export default {
34
+ id: 'mule-network',
35
+ name: 'Mule network layering',
36
+ description:
37
+ 'A source account fans funds out to intermediary mules, each of which forwards nearly all of it to one final destination.',
38
+
39
+ defaultOptions: {
40
+ sourceUserId: null, // required — the compromised or complicit origin
41
+ muleCount: 8,
42
+ amountPerMuleMinor: 30000000, // NGN 300,000 to each mule
43
+ retentionRatio: 0.05, // the cut each mule keeps; the rest is forwarded
44
+ finalDestinationAccount: null, // null → generated
45
+ forwardDelayMinutes: 45, // simulated gap between receiving and forwarding
46
+ delayMs: 100, // wall-clock pacing, unrelated to the simulated gap
47
+ maxTotalAttempts: 100,
48
+ },
49
+
50
+ async run(ctx) {
51
+ const { options, emit, signal } = ctx;
52
+
53
+ if (!options.sourceUserId) {
54
+ throw new Error('mule-network requires options.sourceUserId');
55
+ }
56
+ if (options.muleCount < 1) {
57
+ throw new Error('mule-network requires options.muleCount >= 1');
58
+ }
59
+ if (options.retentionRatio < 0 || options.retentionRatio >= 1) {
60
+ throw new Error('mule-network requires 0 <= options.retentionRatio < 1');
61
+ }
62
+
63
+ const results = newResults({
64
+ fanOutTransfers: 0,
65
+ forwardTransfers: 0,
66
+ fanOutAllowed: 0,
67
+ forwardAllowed: 0,
68
+ totalMovedMinor: 0,
69
+ reachedDestinationMinor: 0,
70
+ });
71
+
72
+ const destination = options.finalDestinationAccount ?? randomAccountNumber();
73
+ const clock = simulatedClock({ start: Date.now() });
74
+
75
+ // Each mule is a distinct account, device and network — they are separate
76
+ // people. That is what makes the graph, not the endpoints, the signal.
77
+ const mules = Array.from({ length: options.muleCount }, (_, i) => ({
78
+ userId: `mule_${String(i + 1).padStart(2, '0')}`,
79
+ account: randomAccountNumber(),
80
+ device: newDeviceFingerprint(),
81
+ ip: randomIp(),
82
+ }));
83
+
84
+ emit({
85
+ type: 'milestone',
86
+ message:
87
+ `Layering NGN ${((options.amountPerMuleMinor * mules.length) / 100).toLocaleString()} ` +
88
+ `through ${mules.length} mules to ${destination}, ` +
89
+ `each keeping ${(options.retentionRatio * 100).toFixed(0)}%`,
90
+ timestamp: Date.now(),
91
+ });
92
+
93
+ // ── Stage 1: fan out from the source to every mule ──────────────────
94
+ for (const mule of mules) {
95
+ if (signal.aborted) break;
96
+ if (results.attempts >= options.maxTotalAttempts) break;
97
+
98
+ const response = await scoreAndCount({
99
+ ctx,
100
+ method: 'scoreWithdrawal',
101
+ payload: buildTransferPayload(
102
+ options.sourceUserId,
103
+ mule.account,
104
+ options.amountPerMuleMinor,
105
+ { timestamp: clock.iso() }
106
+ ),
107
+ results,
108
+ stage: 'fan-out',
109
+ });
110
+
111
+ results.fanOutTransfers++;
112
+ if (response?.decision === 'ALLOW') {
113
+ results.fanOutAllowed++;
114
+ results.totalMovedMinor += options.amountPerMuleMinor;
115
+ mule.funded = true;
116
+ }
117
+
118
+ clock.advance(60_000); // a minute between outbound transfers
119
+
120
+ try {
121
+ await sleep(options.delayMs, signal);
122
+ } catch {
123
+ break;
124
+ }
125
+ }
126
+
127
+ if (results.fanOutAllowed === 0) {
128
+ emit({
129
+ type: 'milestone',
130
+ message: 'Every outbound transfer was stopped — the network never funded',
131
+ timestamp: Date.now(),
132
+ });
133
+ return finalise(results, destination, mules, clock);
134
+ }
135
+
136
+ // ── Stage 2: each funded mule forwards its cut onward ───────────────
137
+ clock.advance(options.forwardDelayMinutes * 60_000);
138
+
139
+ emit({
140
+ type: 'milestone',
141
+ message:
142
+ `${results.fanOutAllowed} mules funded — forwarding onward after ` +
143
+ `${humaniseMs(options.forwardDelayMinutes * 60_000)}`,
144
+ timestamp: Date.now(),
145
+ });
146
+
147
+ const forwardMinor = Math.round(options.amountPerMuleMinor * (1 - options.retentionRatio));
148
+
149
+ for (const mule of mules) {
150
+ if (signal.aborted) break;
151
+ if (!mule.funded) continue;
152
+ if (results.attempts >= options.maxTotalAttempts) break;
153
+
154
+ const response = await scoreAndCount({
155
+ ctx,
156
+ method: 'scoreWithdrawal',
157
+ payload: buildTransferPayload(mule.userId, destination, forwardMinor, {
158
+ ip_address: mule.ip,
159
+ device_fingerprint: mule.device,
160
+ timestamp: clock.iso(),
161
+ }),
162
+ results,
163
+ stage: 'forward',
164
+ });
165
+
166
+ results.forwardTransfers++;
167
+ if (response?.decision === 'ALLOW') {
168
+ results.forwardAllowed++;
169
+ results.reachedDestinationMinor += forwardMinor;
170
+ }
171
+
172
+ clock.advance(5 * 60_000);
173
+
174
+ try {
175
+ await sleep(options.delayMs, signal);
176
+ } catch {
177
+ break;
178
+ }
179
+ }
180
+
181
+ return finalise(results, destination, mules, clock);
182
+ },
183
+ };
184
+
185
+ function finalise(results, destination, mules, clock) {
186
+ const passThroughRatio =
187
+ results.totalMovedMinor > 0 ? results.reachedDestinationMinor / results.totalMovedMinor : 0;
188
+
189
+ return {
190
+ ...results,
191
+ finalDestinationAccount: destination,
192
+ muleAccounts: mules.map((m) => m.account),
193
+ // The ratio a graph-aware system should find alarming: money arriving and
194
+ // leaving an intermediary almost untouched.
195
+ passThroughRatio: Math.round(passThroughRatio * 1000) / 1000,
196
+ estimatedLossKobo: results.reachedDestinationMinor,
197
+ attackerSucceeded: results.reachedDestinationMinor > 0,
198
+ simulatedTime: true,
199
+ simulatedSpan: humaniseMs(clock.elapsedMs()),
200
+ };
201
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Scenario: promo-abuse
3
+ *
4
+ * One person creating many accounts to farm a signup bonus or referral reward.
5
+ * Each account is individually unremarkable — a plausible name, a plausible
6
+ * phone number, a real signup flow. The fraud is only visible in aggregate.
7
+ *
8
+ * The attacker's constraint is effort: creating a genuinely independent
9
+ * identity per account (new device, new network, new payout destination) costs
10
+ * more than the bonus is worth. So they reuse something. This scenario models
11
+ * that reuse explicitly and lets you dial it up or down.
12
+ *
13
+ * Defensive systems should catch this via:
14
+ * - Device clustering (one fingerprint behind many accounts)
15
+ * - IP /24 clustering (accounts from one subnet)
16
+ * - Referral graph anomalies (one referrer, many referees, no onward activity)
17
+ * - Payout concentration (many accounts cashing out to one destination)
18
+ *
19
+ * The interesting question this scenario answers is not "is it blocked" but
20
+ * "how many accounts got through before it was". A system that catches account
21
+ * 40 has already paid 39 bonuses.
22
+ */
23
+
24
+ import { sleep } from '../utils/sleep.js';
25
+ import {
26
+ buildSignupPayload,
27
+ buildActionPayload,
28
+ newDeviceFingerprint,
29
+ } from '../utils/faker-helpers.js';
30
+ import { newResults, scoreAndCount } from '../utils/results.js';
31
+ import { randomIp, ipInSameSubnet, randomAccountNumber, alphanumeric } from '../utils/random.js';
32
+
33
+ export default {
34
+ id: 'promo-abuse',
35
+ name: 'Promo and referral abuse',
36
+ description:
37
+ 'One actor creates many accounts to farm signup bonuses, reusing devices, subnets, and a shared payout destination.',
38
+
39
+ defaultOptions: {
40
+ accountCount: 40,
41
+ bonusMinor: 200000, // NGN 2,000 per signup
42
+ deviceCount: 3, // distinct fingerprints spread across all accounts
43
+ subnetCount: 2, // distinct /24s the signups come from
44
+ sharedPayoutAccount: null, // null → generated; all cash-outs land here
45
+ claimBonus: true, // also attempt the bonus claim after signup
46
+ delayMs: 120,
47
+ maxTotalAttempts: 200,
48
+ },
49
+
50
+ async run(ctx) {
51
+ const { options, emit, signal } = ctx;
52
+
53
+ if (options.accountCount < 1) {
54
+ throw new Error('promo-abuse requires options.accountCount >= 1');
55
+ }
56
+ if (options.deviceCount < 1 || options.subnetCount < 1) {
57
+ throw new Error('promo-abuse requires deviceCount and subnetCount >= 1');
58
+ }
59
+
60
+ const results = newResults({
61
+ accountsAttempted: 0,
62
+ accountsCreated: 0, // signups that were not blocked
63
+ bonusesClaimed: 0, // claims that came back ALLOW — actual payout
64
+ firstBlockedAtAccount: null,
65
+ });
66
+
67
+ // The reused resources. Fewer of these = a lazier, more catchable attacker.
68
+ const devices = Array.from({ length: options.deviceCount }, () => newDeviceFingerprint());
69
+ const subnetSeeds = Array.from({ length: options.subnetCount }, () => randomIp());
70
+ const payoutAccount = options.sharedPayoutAccount ?? randomAccountNumber();
71
+ const referrer = `promo_farmer_${alphanumeric(6)}`;
72
+
73
+ emit({
74
+ type: 'milestone',
75
+ message:
76
+ `Farming ${options.accountCount} accounts for NGN ` +
77
+ `${(options.bonusMinor / 100).toLocaleString()} each across ${devices.length} device(s) ` +
78
+ `and ${subnetSeeds.length} subnet(s), all paying out to ${payoutAccount}`,
79
+ timestamp: Date.now(),
80
+ });
81
+
82
+ for (let i = 0; i < options.accountCount; i++) {
83
+ if (signal.aborted) break;
84
+ if (results.attempts >= options.maxTotalAttempts) break;
85
+
86
+ const userId = `promo_acct_${String(i + 1).padStart(3, '0')}`;
87
+ const device = devices[i % devices.length];
88
+ const ip = ipInSameSubnet(subnetSeeds[i % subnetSeeds.length]);
89
+
90
+ results.accountsAttempted++;
91
+
92
+ const signup = await scoreAndCount({
93
+ ctx,
94
+ method: 'scoreAction',
95
+ payload: buildSignupPayload(userId, {
96
+ ip_address: ip,
97
+ device_fingerprint: device,
98
+ details: {
99
+ referred_by: referrer,
100
+ signup_bonus_kobo: options.bonusMinor,
101
+ payout_account: payoutAccount,
102
+ },
103
+ }),
104
+ results,
105
+ stage: 'signup',
106
+ });
107
+
108
+ if (signup?.decision === 'BLOCK') {
109
+ if (results.firstBlockedAtAccount == null) {
110
+ results.firstBlockedAtAccount = i + 1;
111
+ emit({
112
+ type: 'milestone',
113
+ message: `First signup blocked at account ${i + 1} — ${results.bonusesClaimed} bonuses already paid`,
114
+ timestamp: Date.now(),
115
+ });
116
+ }
117
+ } else {
118
+ results.accountsCreated++;
119
+
120
+ if (options.claimBonus && results.attempts < options.maxTotalAttempts) {
121
+ const claim = await scoreAndCount({
122
+ ctx,
123
+ method: 'scoreAction',
124
+ payload: buildActionPayload(
125
+ userId,
126
+ 'promo_claim',
127
+ {
128
+ bonus_kobo: options.bonusMinor,
129
+ payout_account: payoutAccount,
130
+ referred_by: referrer,
131
+ },
132
+ { ip_address: ip, device_fingerprint: device }
133
+ ),
134
+ results,
135
+ stage: 'claim',
136
+ });
137
+
138
+ if (claim?.decision === 'ALLOW') results.bonusesClaimed++;
139
+ }
140
+ }
141
+
142
+ try {
143
+ await sleep(options.delayMs, signal);
144
+ } catch {
145
+ break;
146
+ }
147
+ }
148
+
149
+ const paidMinor = results.bonusesClaimed * options.bonusMinor;
150
+
151
+ emit({
152
+ type: 'milestone',
153
+ message:
154
+ `${results.accountsCreated} of ${results.accountsAttempted} accounts created, ` +
155
+ `${results.bonusesClaimed} bonuses paid (NGN ${(paidMinor / 100).toLocaleString()})`,
156
+ timestamp: Date.now(),
157
+ });
158
+
159
+ return {
160
+ ...results,
161
+ estimatedLossKobo: paidMinor,
162
+ attackerSucceeded: results.bonusesClaimed > 0,
163
+ sharedPayoutAccount: payoutAccount,
164
+ devicesUsed: devices.length,
165
+ subnetsUsed: subnetSeeds.length,
166
+ };
167
+ },
168
+ };