fraud-sim 0.2.0 → 0.5.1
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 +480 -0
- package/README.md +639 -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 +43 -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 +246 -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,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
|
+
};
|