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
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import { sleep } from '../utils/sleep.js';
|
|
23
23
|
import { buildLoginPayload } from '../utils/faker-helpers.js';
|
|
24
24
|
import { deterministic } from '../llm/deterministic.js';
|
|
25
|
+
import { randomInt } from '../utils/random.js';
|
|
25
26
|
|
|
26
27
|
export default {
|
|
27
28
|
id: 'adaptive-attacker',
|
|
@@ -45,8 +46,13 @@ export default {
|
|
|
45
46
|
throw new Error('adaptive-attacker requires options.targetUserId');
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
// Fall back to
|
|
49
|
-
|
|
49
|
+
// Fall back to the deterministic provider when no LLM is supplied. Its seed
|
|
50
|
+
// is drawn from the run's own random source rather than being hardcoded:
|
|
51
|
+
// under a seeded run that makes the strategy sequence reproducible, and it
|
|
52
|
+
// makes two different run seeds actually produce different attacks. A fixed
|
|
53
|
+
// provider seed would have made every seeded run of this scenario identical
|
|
54
|
+
// no matter what seed the caller passed.
|
|
55
|
+
const llm = ctx.llm ?? deterministic({ seed: randomInt(1, 2 ** 31 - 1) });
|
|
50
56
|
|
|
51
57
|
const results = {
|
|
52
58
|
attempts: 0,
|
|
@@ -68,10 +74,13 @@ export default {
|
|
|
68
74
|
timestamp: Date.now(),
|
|
69
75
|
});
|
|
70
76
|
|
|
77
|
+
// Declared outside the loop: each round's strategy is merged over the
|
|
78
|
+
// parameters actually sent the round before.
|
|
79
|
+
let parameters;
|
|
80
|
+
|
|
71
81
|
for (let round = 1; round <= options.maxRounds; round++) {
|
|
72
82
|
if (signal.aborted) break;
|
|
73
83
|
|
|
74
|
-
let parameters;
|
|
75
84
|
let reasoning;
|
|
76
85
|
|
|
77
86
|
if (round === 1) {
|
|
@@ -109,6 +118,27 @@ export default {
|
|
|
109
118
|
reasoning = strategy?.reasoning ?? '';
|
|
110
119
|
}
|
|
111
120
|
|
|
121
|
+
const payload = buildLoginPayload(options.targetUserId, {
|
|
122
|
+
ip_address: parameters.ip,
|
|
123
|
+
user_agent: parameters.userAgent,
|
|
124
|
+
...(parameters.deviceFingerprint
|
|
125
|
+
? { device_fingerprint: parameters.deviceFingerprint }
|
|
126
|
+
: {}),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Record what was actually sent, not what was asked for. A parameter the
|
|
130
|
+
// strategy left unset is filled in by the payload builder, and the
|
|
131
|
+
// attacker has to see that value: round 1 never names a fingerprint, so
|
|
132
|
+
// without this the history said `null` and the device the defender
|
|
133
|
+
// challenged could never be replayed. It also makes an omitted key mean
|
|
134
|
+
// "keep it" rather than silently rotating it every round.
|
|
135
|
+
parameters = {
|
|
136
|
+
...parameters,
|
|
137
|
+
ip: payload.ip_address,
|
|
138
|
+
userAgent: payload.user_agent,
|
|
139
|
+
deviceFingerprint: payload.device_fingerprint,
|
|
140
|
+
};
|
|
141
|
+
|
|
112
142
|
// Emit the adaptation BEFORE sending — this is the demo moment.
|
|
113
143
|
emit({
|
|
114
144
|
type: 'attacker_adapts',
|
|
@@ -119,14 +149,6 @@ export default {
|
|
|
119
149
|
});
|
|
120
150
|
results.strategyHistory.push({ round, reasoning, parameters });
|
|
121
151
|
|
|
122
|
-
const payload = buildLoginPayload(options.targetUserId, {
|
|
123
|
-
ip_address: parameters.ip,
|
|
124
|
-
user_agent: parameters.userAgent,
|
|
125
|
-
...(parameters.deviceFingerprint
|
|
126
|
-
? { device_fingerprint: parameters.deviceFingerprint }
|
|
127
|
-
: {}),
|
|
128
|
-
});
|
|
129
|
-
|
|
130
152
|
const t0 = Date.now();
|
|
131
153
|
emit({ type: 'attempt', round, payload, timestamp: t0 });
|
|
132
154
|
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scenario: card-testing
|
|
3
|
+
*
|
|
4
|
+
* An attacker holding a list of stolen card numbers needs to know which ones
|
|
5
|
+
* are still live. They probe each card with a tiny authorisation — small enough
|
|
6
|
+
* that a cardholder may not notice and a fraud system may not care — and then
|
|
7
|
+
* escalate to a real charge on whichever cards come back approved.
|
|
8
|
+
*
|
|
9
|
+
* The tell is not any single transaction. It is the shape of the traffic: many
|
|
10
|
+
* distinct cards, drawn from a handful of BIN ranges, all card-not-present, all
|
|
11
|
+
* for near-identical trivial amounts, in a short window.
|
|
12
|
+
*
|
|
13
|
+
* Defensive systems should catch this via:
|
|
14
|
+
* - Micro-authorisation velocity (many tiny auths in a short window)
|
|
15
|
+
* - BIN range velocity (many cards sharing few BIN prefixes)
|
|
16
|
+
* - CNP transaction patterns (no cardholder present, no address match)
|
|
17
|
+
* - Decline-rate monitoring (a high decline ratio is itself the signal)
|
|
18
|
+
*
|
|
19
|
+
* Note on card data: `fraud-sim` never generates Luhn-valid numbers and never
|
|
20
|
+
* emits a full PAN. Payloads carry an opaque token, a published test BIN, and
|
|
21
|
+
* four synthetic digits — which is what a risk API receives in practice.
|
|
22
|
+
*
|
|
23
|
+
* Realistic block rates against a well-tuned system: 80-95%, and most of the
|
|
24
|
+
* value is in *how early* the block comes.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { sleep } from '../utils/sleep.js';
|
|
28
|
+
import { buildCardAuthPayload } from '../utils/faker-helpers.js';
|
|
29
|
+
import { newResults, scoreAndCount } from '../utils/results.js';
|
|
30
|
+
import { cardBins, randomLast4, newCardToken, randomIp, pickDemoIp } from '../utils/random.js';
|
|
31
|
+
|
|
32
|
+
export default {
|
|
33
|
+
id: 'card-testing',
|
|
34
|
+
name: 'Card testing / BIN attack',
|
|
35
|
+
description:
|
|
36
|
+
'An attacker validates a list of stolen cards with micro-authorisations, then escalates to real charges on the cards that come back approved.',
|
|
37
|
+
|
|
38
|
+
defaultOptions: {
|
|
39
|
+
attackerUserId: 'card_tester_001', // the merchant-side account doing the probing
|
|
40
|
+
cardCount: 60, // distinct cards in the stolen list
|
|
41
|
+
binCount: 3, // how many BIN ranges the list is drawn from — few is the tell
|
|
42
|
+
microAmountMinor: 5000, // NGN 50 probe
|
|
43
|
+
largeAmountMinor: 15000000, // NGN 150,000 once a card is known good
|
|
44
|
+
escalateAfter: 1, // approvals on a card before attempting the real charge
|
|
45
|
+
delayMs: 80,
|
|
46
|
+
maxTotalAttempts: 300,
|
|
47
|
+
ipPersona: 'random',
|
|
48
|
+
reuseIp: true, // card testers usually run from one host until it stops working
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
async run(ctx) {
|
|
52
|
+
const { options, emit, signal } = ctx;
|
|
53
|
+
|
|
54
|
+
if (options.cardCount < 1) {
|
|
55
|
+
throw new Error('card-testing requires options.cardCount >= 1');
|
|
56
|
+
}
|
|
57
|
+
if (options.binCount < 1) {
|
|
58
|
+
throw new Error('card-testing requires options.binCount >= 1');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const results = newResults({
|
|
62
|
+
cardsTested: 0,
|
|
63
|
+
cardsValidated: 0, // probes that came back ALLOW
|
|
64
|
+
escalations: 0, // large charges attempted
|
|
65
|
+
escalationsAllowed: 0, // large charges that got through — actual loss
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const bins = cardBins().slice(0, options.binCount);
|
|
69
|
+
const attacker = resolveIp(options);
|
|
70
|
+
|
|
71
|
+
emit({
|
|
72
|
+
type: 'milestone',
|
|
73
|
+
message:
|
|
74
|
+
`Probing ${options.cardCount} cards across ${bins.length} BIN range(s) ` +
|
|
75
|
+
`with NGN ${(options.microAmountMinor / 100).toLocaleString()} authorisations`,
|
|
76
|
+
timestamp: Date.now(),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
for (let i = 0; i < options.cardCount; i++) {
|
|
80
|
+
if (signal.aborted) break;
|
|
81
|
+
if (results.attempts >= options.maxTotalAttempts) break;
|
|
82
|
+
|
|
83
|
+
const card = {
|
|
84
|
+
token: newCardToken(),
|
|
85
|
+
bin: bins[i % bins.length],
|
|
86
|
+
last4: randomLast4(),
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// A fresh host per card would defeat the point — the attacker is trying
|
|
90
|
+
// to stay cheap, and reusing infrastructure is what makes them catchable.
|
|
91
|
+
const ip = options.reuseIp ? attacker : resolveIp(options);
|
|
92
|
+
|
|
93
|
+
const probe = await scoreAndCount({
|
|
94
|
+
ctx,
|
|
95
|
+
method: 'scoreAction',
|
|
96
|
+
payload: buildCardAuthPayload(options.attackerUserId, card, options.microAmountMinor, {
|
|
97
|
+
ip_address: ip.ip,
|
|
98
|
+
details: { probe: true },
|
|
99
|
+
}),
|
|
100
|
+
results,
|
|
101
|
+
stage: 'probe',
|
|
102
|
+
ipIntel: ip.intel,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
results.cardsTested++;
|
|
106
|
+
|
|
107
|
+
if (probe?.decision === 'ALLOW') {
|
|
108
|
+
results.cardsValidated++;
|
|
109
|
+
|
|
110
|
+
if (results.cardsValidated % options.escalateAfter === 0) {
|
|
111
|
+
if (results.attempts >= options.maxTotalAttempts) break;
|
|
112
|
+
|
|
113
|
+
emit({
|
|
114
|
+
type: 'milestone',
|
|
115
|
+
message: `Card ${card.bin}••${card.last4} approved — escalating to a real charge`,
|
|
116
|
+
timestamp: Date.now(),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const charge = await scoreAndCount({
|
|
120
|
+
ctx,
|
|
121
|
+
method: 'scoreAction',
|
|
122
|
+
payload: buildCardAuthPayload(
|
|
123
|
+
options.attackerUserId,
|
|
124
|
+
card,
|
|
125
|
+
options.largeAmountMinor,
|
|
126
|
+
{ ip_address: ip.ip, details: { probe: false } }
|
|
127
|
+
),
|
|
128
|
+
results,
|
|
129
|
+
stage: 'escalation',
|
|
130
|
+
ipIntel: ip.intel,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
results.escalations++;
|
|
134
|
+
if (charge?.decision === 'ALLOW') results.escalationsAllowed++;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
await sleep(options.delayMs, signal);
|
|
140
|
+
} catch {
|
|
141
|
+
break; // aborted
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const lossMinor = results.escalationsAllowed * options.largeAmountMinor;
|
|
146
|
+
|
|
147
|
+
emit({
|
|
148
|
+
type: 'milestone',
|
|
149
|
+
message:
|
|
150
|
+
`${results.cardsValidated} of ${results.cardsTested} cards validated; ` +
|
|
151
|
+
`${results.escalationsAllowed} of ${results.escalations} charges succeeded ` +
|
|
152
|
+
`(NGN ${(lossMinor / 100).toLocaleString()} at risk)`,
|
|
153
|
+
timestamp: Date.now(),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
...results,
|
|
158
|
+
// The headline number for this scenario is not the block rate — it is
|
|
159
|
+
// whether any escalated charge got through at all.
|
|
160
|
+
estimatedLossKobo: lossMinor,
|
|
161
|
+
attackerSucceeded: results.escalationsAllowed > 0,
|
|
162
|
+
binsUsed: bins,
|
|
163
|
+
};
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
function resolveIp(options) {
|
|
168
|
+
if (!options.ipPersona || options.ipPersona === 'random') {
|
|
169
|
+
return { ip: randomIp(), intel: undefined };
|
|
170
|
+
}
|
|
171
|
+
const persona = options.ipPersona === 'mixed' ? 'hosting' : options.ipPersona;
|
|
172
|
+
const { ip, ...intel } = pickDemoIp(persona);
|
|
173
|
+
return { ip, intel };
|
|
174
|
+
}
|
|
@@ -1,45 +1,78 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Scenario: credential-stuffing
|
|
3
3
|
*
|
|
4
|
-
* A botnet
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* A botnet replays a stolen credential dump against a login endpoint. Attempts
|
|
5
|
+
* come from many IPs, rotate user agents, and — crucially — spray across many
|
|
6
|
+
* *different* user accounts, because a credential dump is a list of victims,
|
|
7
|
+
* not one victim tried repeatedly. This is the most common automated attack
|
|
8
|
+
* against fintech login endpoints.
|
|
8
9
|
*
|
|
9
10
|
* Defensive systems should catch this via:
|
|
10
11
|
* - LOGIN_VELOCITY_USER (many attempts on one user)
|
|
11
12
|
* - LOGIN_VELOCITY_IP (many attempts from one IP)
|
|
12
|
-
* - IP_PROXY_VPN (datacenter / VPN IPs)
|
|
13
13
|
* - IP_MANY_USERS (one IP trying many user accounts)
|
|
14
|
+
* - IP_PROXY_VPN (datacenter / VPN IPs)
|
|
14
15
|
* - NEW_DEVICE (every attempt is a new device fingerprint)
|
|
15
16
|
*
|
|
16
17
|
* Realistic block rates against a well-tuned system: 85-95%.
|
|
18
|
+
*
|
|
19
|
+
* ── Calibration note ──────────────────────────────────────────────────
|
|
20
|
+
* The defaults below are deliberately tuned to *cross* the thresholds that
|
|
21
|
+
* typical rule engines ship with, rather than to look gentle. Two settings
|
|
22
|
+
* matter most:
|
|
23
|
+
*
|
|
24
|
+
* - `attemptsPerIp` must exceed the target's per-IP velocity limit within
|
|
25
|
+
* its window, or that rule never fires. Common defaults sit near 20 per
|
|
26
|
+
* 5 minutes; the default here is 25.
|
|
27
|
+
* - `targetUserIds` must contain more accounts than the target's
|
|
28
|
+
* "one IP, many users" limit (commonly 10), or that rule never fires.
|
|
29
|
+
*
|
|
30
|
+
* A run that stays under both limits will report a low block rate that says
|
|
31
|
+
* nothing about the defence — it only means the attack was too quiet to be
|
|
32
|
+
* worth catching. Turn these *down* to probe where a system's floor is; leave
|
|
33
|
+
* them at the defaults to measure whether it catches an ordinary attack.
|
|
17
34
|
*/
|
|
18
35
|
|
|
19
36
|
import { sleep } from '../utils/sleep.js';
|
|
20
37
|
import { buildLoginPayload } from '../utils/faker-helpers.js';
|
|
21
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
randomIp,
|
|
40
|
+
randomUserAgent,
|
|
41
|
+
randomFloat,
|
|
42
|
+
pickDemoIp,
|
|
43
|
+
DEMO_IP_POOL,
|
|
44
|
+
} from '../utils/random.js';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Personas drawn from DEMO_IP_POOL when `ipPersona: 'mixed'`.
|
|
48
|
+
*
|
|
49
|
+
* Includes `domestic` on purpose: a real botnet rents residential proxies in
|
|
50
|
+
* the victim's own country precisely to avoid the geo signals that the other
|
|
51
|
+
* personas trip. An all-foreign mix makes every attempt look identical to the
|
|
52
|
+
* defence, which flatters the block rate and produces a monotonous event feed.
|
|
53
|
+
*/
|
|
54
|
+
const MIXED_PERSONAS = ['hosting', 'proxy', 'foreign', 'domestic'];
|
|
22
55
|
|
|
23
56
|
export default {
|
|
24
57
|
id: 'credential-stuffing',
|
|
25
58
|
name: 'Credential stuffing attack',
|
|
26
59
|
description:
|
|
27
|
-
'A botnet
|
|
60
|
+
'A botnet replays a stolen credential dump. Distinct IPs, rotating user agents, sprayed across many accounts to evade per-user rate limits.',
|
|
28
61
|
|
|
29
62
|
defaultOptions: {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
63
|
+
targetUserIds: null, // required — array of accounts from the "dump"
|
|
64
|
+
targetUserId: null, // back-compat: single account, equivalent to [targetUserId]
|
|
65
|
+
attackerCount: 20, // distinct IPs
|
|
66
|
+
attemptsPerIp: 25, // attempts before rotating IP — must beat per-IP velocity limits
|
|
67
|
+
delayMs: 100, // ms between attempts
|
|
68
|
+
maxTotalAttempts: 500, // safety ceiling — prevents runaway sims
|
|
69
|
+
ipPersona: 'random', // 'random' | 'mixed' | 'domestic' | 'hosting' | 'proxy' | 'foreign'
|
|
35
70
|
},
|
|
36
71
|
|
|
37
72
|
async run(ctx) {
|
|
38
73
|
const { options, target, emit, signal } = ctx;
|
|
39
74
|
|
|
40
|
-
|
|
41
|
-
throw new Error('credential-stuffing requires options.targetUserId');
|
|
42
|
-
}
|
|
75
|
+
const userIds = resolveUserIds(options);
|
|
43
76
|
|
|
44
77
|
const results = {
|
|
45
78
|
attempts: 0,
|
|
@@ -49,31 +82,64 @@ export default {
|
|
|
49
82
|
errors: 0,
|
|
50
83
|
};
|
|
51
84
|
|
|
85
|
+
const usersSeen = new Set();
|
|
86
|
+
|
|
52
87
|
emit({
|
|
53
88
|
type: 'milestone',
|
|
54
|
-
message:
|
|
89
|
+
message:
|
|
90
|
+
`Starting credential stuffing: ${options.attackerCount} IPs × ${options.attemptsPerIp} attempts ` +
|
|
91
|
+
`across ${userIds.length} account${userIds.length === 1 ? '' : 's'} ` +
|
|
92
|
+
`(max ${options.maxTotalAttempts} total)`,
|
|
55
93
|
timestamp: Date.now(),
|
|
56
94
|
});
|
|
57
95
|
|
|
96
|
+
if (userIds.length === 1) {
|
|
97
|
+
emit({
|
|
98
|
+
type: 'milestone',
|
|
99
|
+
message:
|
|
100
|
+
'Only one target account supplied — "one IP, many users" detection cannot fire. ' +
|
|
101
|
+
'Pass options.targetUserIds with several accounts to exercise it.',
|
|
102
|
+
timestamp: Date.now(),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Draw pool IPs without replacement so each attacker is distinct until the
|
|
107
|
+
// pool is exhausted, then cycle — a real botnet reuses its exits too.
|
|
108
|
+
const ipSource = makeIpSource(options.ipPersona);
|
|
109
|
+
|
|
58
110
|
outer: for (let i = 0; i < options.attackerCount; i++) {
|
|
59
111
|
if (signal.aborted) break;
|
|
60
112
|
|
|
61
|
-
// Each "attacker
|
|
62
|
-
const
|
|
113
|
+
// Each "attacker" is a different IP + user agent combination
|
|
114
|
+
const attacker = ipSource(i);
|
|
115
|
+
const attackerIp = attacker.ip;
|
|
63
116
|
const userAgent = randomUserAgent();
|
|
64
117
|
|
|
65
118
|
for (let j = 0; j < options.attemptsPerIp; j++) {
|
|
66
119
|
if (signal.aborted) break outer;
|
|
67
120
|
if (results.attempts >= options.maxTotalAttempts) break outer;
|
|
68
121
|
|
|
69
|
-
|
|
122
|
+
// Spray across the dump: this IP walks the account list rather than
|
|
123
|
+
// hammering one user, which is what makes it credential stuffing.
|
|
124
|
+
const userId = userIds[results.attempts % userIds.length];
|
|
125
|
+
usersSeen.add(userId);
|
|
126
|
+
|
|
127
|
+
const payload = buildLoginPayload(userId, {
|
|
70
128
|
ip_address: attackerIp,
|
|
71
129
|
user_agent: userAgent,
|
|
72
130
|
// Crucially: new device fingerprint per attempt (botnet behaviour)
|
|
73
131
|
});
|
|
74
132
|
|
|
75
133
|
const t0 = Date.now();
|
|
76
|
-
emit({
|
|
134
|
+
emit({
|
|
135
|
+
type: 'attempt',
|
|
136
|
+
round: results.attempts,
|
|
137
|
+
payload,
|
|
138
|
+
// Present only for pool-backed IPs — lets a target pre-warm its
|
|
139
|
+
// IP-intel cache instead of doing live lookups mid-run.
|
|
140
|
+
ipIntel: attacker.intel,
|
|
141
|
+
timestamp: t0,
|
|
142
|
+
});
|
|
77
143
|
|
|
78
144
|
let response;
|
|
79
145
|
try {
|
|
@@ -119,6 +185,66 @@ export default {
|
|
|
119
185
|
stepUp: results.stepUp,
|
|
120
186
|
allowed: results.allowed,
|
|
121
187
|
errors: results.errors,
|
|
188
|
+
accountsTargeted: usersSeen.size,
|
|
122
189
|
};
|
|
123
190
|
},
|
|
124
191
|
};
|
|
192
|
+
|
|
193
|
+
// ── internals ───────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Accept either `targetUserIds` (preferred) or the legacy single
|
|
197
|
+
* `targetUserId`, and validate.
|
|
198
|
+
*/
|
|
199
|
+
function resolveUserIds(options) {
|
|
200
|
+
const { targetUserIds, targetUserId } = options;
|
|
201
|
+
|
|
202
|
+
if (targetUserIds != null) {
|
|
203
|
+
if (!Array.isArray(targetUserIds) || targetUserIds.length === 0) {
|
|
204
|
+
throw new Error('credential-stuffing: options.targetUserIds must be a non-empty array');
|
|
205
|
+
}
|
|
206
|
+
return targetUserIds;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (targetUserId) return [targetUserId];
|
|
210
|
+
|
|
211
|
+
throw new Error(
|
|
212
|
+
'credential-stuffing requires options.targetUserIds (an array of accounts), ' +
|
|
213
|
+
'or options.targetUserId for a single account'
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Build a function that returns the i-th attacker's IP and, where known, its
|
|
219
|
+
* declared intel. Pool draws are without replacement until exhausted.
|
|
220
|
+
*/
|
|
221
|
+
function makeIpSource(persona) {
|
|
222
|
+
if (persona === 'random' || persona == null) {
|
|
223
|
+
return () => ({ ip: randomIp(), intel: undefined });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (persona === 'mixed') {
|
|
227
|
+
const shuffled = shuffle(DEMO_IP_POOL.filter((e) => MIXED_PERSONAS.includes(e.persona)));
|
|
228
|
+
return (i) => toAttacker(shuffled[i % shuffled.length]);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// A single named persona — validate eagerly so a typo fails on the first
|
|
232
|
+
// attacker rather than silently behaving like 'random'.
|
|
233
|
+
pickDemoIp(persona);
|
|
234
|
+
const shuffled = shuffle(DEMO_IP_POOL.filter((e) => e.persona === persona));
|
|
235
|
+
return (i) => toAttacker(shuffled[i % shuffled.length]);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function toAttacker(entry) {
|
|
239
|
+
const { ip, ...intel } = entry;
|
|
240
|
+
return { ip, intel };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function shuffle(arr) {
|
|
244
|
+
const out = [...arr];
|
|
245
|
+
for (let i = out.length - 1; i > 0; i--) {
|
|
246
|
+
const j = Math.floor(randomFloat() * (i + 1));
|
|
247
|
+
[out[i], out[j]] = [out[j], out[i]];
|
|
248
|
+
}
|
|
249
|
+
return out;
|
|
250
|
+
}
|
package/src/scenarios/index.js
CHANGED
|
@@ -1,25 +1,56 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Scenario registry — all built-in scenarios.
|
|
3
3
|
*
|
|
4
|
-
* v0.1 shipped
|
|
4
|
+
* v0.1 shipped:
|
|
5
5
|
* - credential-stuffing
|
|
6
6
|
* - account-drain
|
|
7
7
|
*
|
|
8
|
-
* v0.2
|
|
8
|
+
* v0.2 added:
|
|
9
9
|
* - adaptive-attacker (LLM-driven)
|
|
10
10
|
*
|
|
11
|
-
* v0.3
|
|
12
|
-
* - promo-abuse
|
|
11
|
+
* v0.3 adds seven, completing the planned library:
|
|
13
12
|
* - card-testing
|
|
14
|
-
* -
|
|
13
|
+
* - promo-abuse
|
|
15
14
|
* - mule-network
|
|
16
15
|
* - slow-drain
|
|
17
16
|
* - session-hijack
|
|
17
|
+
* - sim-swap-takeover
|
|
18
18
|
* - velocity-evasion
|
|
19
|
+
*
|
|
20
|
+
* v0.5 adds the other half of the measurement:
|
|
21
|
+
* - legitimate-users — ordinary customers, not an attack. Every block it
|
|
22
|
+
* receives is a false positive. Listed first so a suite measures customers
|
|
23
|
+
* before its own attacks have burned any shared state on the target.
|
|
24
|
+
*
|
|
25
|
+
* Endpoint requirements differ by scenario. `credential-stuffing`,
|
|
26
|
+
* `account-drain`, `adaptive-attacker`, `slow-drain` and `velocity-evasion`
|
|
27
|
+
* need only `scoreLogin` / `scoreWithdrawal`. `card-testing`, `promo-abuse` and
|
|
28
|
+
* `session-hijack` also need `scoreAction`; a scenario throws with a clear
|
|
29
|
+
* message if the adapter does not implement what it needs.
|
|
19
30
|
*/
|
|
20
31
|
|
|
32
|
+
import legitimateUsers from './legitimate-users.js';
|
|
21
33
|
import credentialStuffing from './credential-stuffing.js';
|
|
22
34
|
import accountDrain from './account-drain.js';
|
|
23
35
|
import adaptiveAttacker from './adaptive-attacker.js';
|
|
36
|
+
import cardTesting from './card-testing.js';
|
|
37
|
+
import promoAbuse from './promo-abuse.js';
|
|
38
|
+
import muleNetwork from './mule-network.js';
|
|
39
|
+
import slowDrain from './slow-drain.js';
|
|
40
|
+
import sessionHijack from './session-hijack.js';
|
|
41
|
+
import simSwapTakeover from './sim-swap-takeover.js';
|
|
42
|
+
import velocityEvasion from './velocity-evasion.js';
|
|
24
43
|
|
|
25
|
-
export const scenarios = [
|
|
44
|
+
export const scenarios = [
|
|
45
|
+
legitimateUsers,
|
|
46
|
+
credentialStuffing,
|
|
47
|
+
accountDrain,
|
|
48
|
+
adaptiveAttacker,
|
|
49
|
+
cardTesting,
|
|
50
|
+
promoAbuse,
|
|
51
|
+
muleNetwork,
|
|
52
|
+
slowDrain,
|
|
53
|
+
sessionHijack,
|
|
54
|
+
simSwapTakeover,
|
|
55
|
+
velocityEvasion,
|
|
56
|
+
];
|