fraud-sim 0.2.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +462 -0
- package/README.md +621 -81
- package/bin/fraud-sim.js +625 -0
- package/examples/account-drain-demo/index.js +1 -1
- package/examples/adaptive-hardened-demo/index.js +106 -0
- package/examples/basic-credential-stuffing/index.js +4 -2
- package/examples/express-hardened-target/server.js +230 -0
- package/examples/express-mock-target/server.js +153 -2
- package/package.json +40 -8
- package/src/adapters/http.js +49 -7
- package/src/index.js +126 -13
- package/src/llm/anthropic.js +7 -0
- package/src/llm/deterministic.js +21 -7
- package/src/llm/openai.js +7 -0
- package/src/locales/index.js +193 -0
- package/src/reporting/aggregate.js +338 -0
- package/src/reporting/html.js +424 -0
- package/src/reporting/index.js +241 -0
- package/src/reporting/junit.js +171 -0
- package/src/reporting/money.js +105 -0
- package/src/runner.js +295 -12
- package/src/scenarios/account-drain.js +10 -2
- package/src/scenarios/adaptive-attacker.js +33 -11
- package/src/scenarios/card-testing.js +174 -0
- package/src/scenarios/credential-stuffing.js +146 -20
- package/src/scenarios/index.js +37 -6
- package/src/scenarios/legitimate-users.js +290 -0
- package/src/scenarios/mule-network.js +201 -0
- package/src/scenarios/promo-abuse.js +168 -0
- package/src/scenarios/session-hijack.js +234 -0
- package/src/scenarios/sim-swap-takeover.js +253 -0
- package/src/scenarios/slow-drain.js +171 -0
- package/src/scenarios/velocity-evasion.js +179 -0
- package/src/types.js +160 -0
- package/src/utils/clock.js +77 -0
- package/src/utils/faker-helpers.js +87 -2
- package/src/utils/index.js +69 -0
- package/src/utils/random-source.js +104 -0
- package/src/utils/random.js +257 -44
- package/src/utils/results.js +84 -0
- package/src/utils/rng.js +3 -4
- package/src/utils/throttle.js +127 -0
- package/types/adapters/http.d.ts +6 -0
- package/types/adapters/index.d.ts +3 -0
- package/types/adapters/logging.d.ts +19 -0
- package/types/adapters/mock.d.ts +32 -0
- package/types/index.d.ts +79 -0
- package/types/llm/anthropic.d.ts +17 -0
- package/types/llm/deterministic.d.ts +11 -0
- package/types/llm/index.d.ts +3 -0
- package/types/llm/openai.d.ts +17 -0
- package/types/locales/index.d.ts +146 -0
- package/types/reporting/aggregate.d.ts +126 -0
- package/types/reporting/html.d.ts +1 -0
- package/types/reporting/index.d.ts +99 -0
- package/types/reporting/junit.d.ts +13 -0
- package/types/reporting/money.d.ts +20 -0
- package/types/runner.d.ts +10 -0
- package/types/scenarios/account-drain.d.ts +25 -0
- package/types/scenarios/adaptive-attacker.d.ts +22 -0
- package/types/scenarios/card-testing.d.ts +28 -0
- package/types/scenarios/credential-stuffing.d.ts +23 -0
- package/types/scenarios/index.d.ts +261 -0
- package/types/scenarios/legitimate-users.d.ts +49 -0
- package/types/scenarios/mule-network.d.ts +17 -0
- package/types/scenarios/promo-abuse.d.ts +28 -0
- package/types/scenarios/session-hijack.d.ts +15 -0
- package/types/scenarios/sim-swap-takeover.d.ts +16 -0
- package/types/scenarios/slow-drain.d.ts +28 -0
- package/types/scenarios/velocity-evasion.d.ts +31 -0
- package/types/streaming/index.d.ts +1 -0
- package/types/streaming/sse.d.ts +34 -0
- package/types/types.d.ts +191 -0
- package/types/utils/clock.d.ts +39 -0
- package/types/utils/faker-helpers.d.ts +114 -0
- package/types/utils/index.d.ts +8 -0
- package/types/utils/random-source.d.ts +39 -0
- package/types/utils/random.d.ts +89 -0
- package/types/utils/results.d.ts +46 -0
- package/types/utils/rng.d.ts +36 -0
- package/types/utils/sleep.d.ts +7 -0
- package/types/utils/throttle.d.ts +33 -0
package/src/utils/random.js
CHANGED
|
@@ -8,34 +8,18 @@
|
|
|
8
8
|
* If you need richer fake data in your own scenarios (real-looking names,
|
|
9
9
|
* locale-specific data, etc.), import @faker-js/faker in your scenario file —
|
|
10
10
|
* it is not bundled with fraud-sim, but you can use it freely alongside it.
|
|
11
|
+
*
|
|
12
|
+
* Every helper here is built on exactly three primitives — `randomInt`,
|
|
13
|
+
* `pickOne` and `alphanumeric` — and all three draw from the run-scoped source
|
|
14
|
+
* in `random-source.js`. Seed a run and every value below replays identically.
|
|
15
|
+
* A helper that reaches for `Math.random()` directly would silently opt itself
|
|
16
|
+
* out of that, so none of them do.
|
|
11
17
|
*/
|
|
12
18
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const NIGERIAN_BANKS = [
|
|
16
|
-
'GTBank',
|
|
17
|
-
'UBA',
|
|
18
|
-
'Access',
|
|
19
|
-
'Zenith',
|
|
20
|
-
'First Bank',
|
|
21
|
-
'FCMB',
|
|
22
|
-
'Sterling',
|
|
23
|
-
'Stanbic IBTC',
|
|
24
|
-
'Fidelity',
|
|
25
|
-
'Wema',
|
|
26
|
-
];
|
|
27
|
-
|
|
28
|
-
const FIRST_NAMES = [
|
|
29
|
-
'Adaeze', 'Chinedu', 'Folake', 'Tunde', 'Aisha', 'Emeka', 'Nkechi', 'Bayo',
|
|
30
|
-
'Ngozi', 'Olumide', 'Funmi', 'Kayode', 'Hauwa', 'Ifeanyi', 'Toyin', 'Segun',
|
|
31
|
-
'James', 'Mary', 'David', 'Sarah', 'Michael', 'Grace', 'John', 'Esther',
|
|
32
|
-
];
|
|
19
|
+
import { randomFloat, currentLocale } from './random-source.js';
|
|
20
|
+
import { resolveLocale } from '../locales/index.js';
|
|
33
21
|
|
|
34
|
-
const
|
|
35
|
-
'Adeyemi', 'Okonkwo', 'Ibrahim', 'Nwosu', 'Bello', 'Eze', 'Yusuf', 'Okoye',
|
|
36
|
-
'Lawal', 'Obi', 'Musa', 'Achebe', 'Akin', 'Adamu', 'Ojo', 'Chukwu',
|
|
37
|
-
'Smith', 'Brown', 'Williams', 'Johnson',
|
|
38
|
-
];
|
|
22
|
+
const ALPHANUM = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
39
23
|
|
|
40
24
|
const USER_AGENTS = [
|
|
41
25
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
@@ -52,14 +36,14 @@ const USER_AGENTS = [
|
|
|
52
36
|
* Random integer in [min, max] inclusive.
|
|
53
37
|
*/
|
|
54
38
|
export function randomInt(min, max) {
|
|
55
|
-
return Math.floor(
|
|
39
|
+
return Math.floor(randomFloat() * (max - min + 1)) + min;
|
|
56
40
|
}
|
|
57
41
|
|
|
58
42
|
/**
|
|
59
43
|
* Pick one element from an array.
|
|
60
44
|
*/
|
|
61
45
|
export function pickOne(arr) {
|
|
62
|
-
return arr[Math.floor(
|
|
46
|
+
return arr[Math.floor(randomFloat() * arr.length)];
|
|
63
47
|
}
|
|
64
48
|
|
|
65
49
|
/**
|
|
@@ -68,47 +52,276 @@ export function pickOne(arr) {
|
|
|
68
52
|
export function alphanumeric(length) {
|
|
69
53
|
let out = '';
|
|
70
54
|
for (let i = 0; i < length; i++) {
|
|
71
|
-
out += ALPHANUM.charAt(Math.floor(
|
|
55
|
+
out += ALPHANUM.charAt(Math.floor(randomFloat() * ALPHANUM.length));
|
|
72
56
|
}
|
|
73
57
|
return out;
|
|
74
58
|
}
|
|
75
59
|
|
|
76
60
|
/**
|
|
77
|
-
*
|
|
78
|
-
*
|
|
61
|
+
* IPv4 ranges that are reserved, private, or otherwise never routed on the
|
|
62
|
+
* public internet. A generator that emits these produces addresses that no
|
|
63
|
+
* IP-intelligence provider can resolve — which silently disables every
|
|
64
|
+
* geo/reputation rule on the target being tested. We skip them.
|
|
65
|
+
*
|
|
66
|
+
* @param {number} a - first octet
|
|
67
|
+
* @param {number} b - second octet
|
|
68
|
+
* @returns {boolean}
|
|
69
|
+
*/
|
|
70
|
+
function isReservedIpv4(a, b) {
|
|
71
|
+
if (a === 0 || a === 10 || a === 127) return true; // this-network, private, loopback
|
|
72
|
+
if (a >= 224) return true; // multicast + reserved + broadcast
|
|
73
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT 100.64.0.0/10
|
|
74
|
+
if (a === 169 && b === 254) return true; // link-local
|
|
75
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // private 172.16.0.0/12
|
|
76
|
+
if (a === 192 && b === 168) return true; // private
|
|
77
|
+
if (a === 192 && b === 0) return true; // 192.0.0.0/24 + TEST-NET-1
|
|
78
|
+
if (a === 192 && b === 88) return true; // 6to4 relay anycast
|
|
79
|
+
if (a === 198 && (b === 18 || b === 19)) return true; // benchmarking
|
|
80
|
+
if (a === 198 && b === 51) return true; // TEST-NET-2
|
|
81
|
+
if (a === 203 && b === 0) return true; // TEST-NET-3
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Generate a synthetic but *publicly routable* IPv4 address.
|
|
87
|
+
*
|
|
88
|
+
* Deliberately excludes private, loopback, CGNAT, link-local, multicast and
|
|
89
|
+
* documentation ranges. Addresses from those blocks cannot be resolved by any
|
|
90
|
+
* geo/reputation provider, so a target that scores them will fall back to
|
|
91
|
+
* neutral intel and never fire its proxy, abuse-score, or geo rules — making a
|
|
92
|
+
* defence look weaker than it is.
|
|
93
|
+
*
|
|
94
|
+
* These addresses are used only as payload field values. `fraud-sim` never
|
|
95
|
+
* connects to them.
|
|
96
|
+
*
|
|
97
|
+
* For a stable, pre-seedable set with known geolocation, use
|
|
98
|
+
* {@link DEMO_IP_POOL} / {@link pickDemoIp} instead.
|
|
79
99
|
*/
|
|
80
100
|
export function randomIp() {
|
|
81
|
-
|
|
101
|
+
let a, b;
|
|
102
|
+
do {
|
|
103
|
+
a = randomInt(1, 223);
|
|
104
|
+
b = randomInt(0, 255);
|
|
105
|
+
} while (isReservedIpv4(a, b));
|
|
106
|
+
return `${a}.${b}.${randomInt(0, 255)}.${randomInt(1, 254)}`;
|
|
82
107
|
}
|
|
83
108
|
|
|
84
109
|
/**
|
|
85
|
-
*
|
|
110
|
+
* A fixed pool of attacker IPs with known, declared geolocation and
|
|
111
|
+
* reputation metadata.
|
|
112
|
+
*
|
|
113
|
+
* Why this exists: most fraud systems resolve IP intelligence *asynchronously*
|
|
114
|
+
* and fall back to neutral values on a cache miss. In a short simulation every
|
|
115
|
+
* IP is seen for the first time, so geo and reputation rules never fire and the
|
|
116
|
+
* run under-reports what the defence can actually catch. Because this pool is
|
|
117
|
+
* fixed and its metadata is declared here, the system under test can pre-warm
|
|
118
|
+
* its IP-intel cache from `DEMO_IP_POOL` before a run — no live lookups, no
|
|
119
|
+
* third-party rate limits, and reproducible results.
|
|
120
|
+
*
|
|
121
|
+
* Addresses are drawn from the RFC 5737 documentation ranges (192.0.2.0/24,
|
|
122
|
+
* 198.51.100.0/24, 203.0.113.0/24). They are reserved for documentation, are
|
|
123
|
+
* never routed, and belong to no real network — so attack traffic in a
|
|
124
|
+
* simulation is never attributed to a real operator. The geolocation and
|
|
125
|
+
* reputation fields are synthetic labels chosen to exercise distinct rule
|
|
126
|
+
* paths, not claims about these addresses.
|
|
127
|
+
*
|
|
128
|
+
* Personas covered:
|
|
129
|
+
* - `domestic` residential → the victim's home country; the quiet baseline
|
|
130
|
+
* - `hosting` → datacenter ranges; should fire hosting rules
|
|
131
|
+
* - `proxy` → VPN/proxy exits; should fire proxy rules
|
|
132
|
+
* - `foreign` residential → new-country and impossible-travel paths
|
|
133
|
+
*
|
|
134
|
+
* @type {ReadonlyArray<{ip: string, countryCode: string, country: string,
|
|
135
|
+
* lat: number, lon: number, proxy: boolean, hosting: boolean,
|
|
136
|
+
* abuseScore: number, isp: string, persona: string}>}
|
|
86
137
|
*/
|
|
87
|
-
|
|
88
|
-
|
|
138
|
+
const FOREIGN_POOL = Object.freeze([
|
|
139
|
+
// ── Datacenter / hosting — bot traffic rarely comes from a living room
|
|
140
|
+
f('198.51.100.20', 'NL', 'Netherlands', 52.3676, 4.9041, false, true, 78, 'Simulated Cloud Hosting (Amsterdam)', 'hosting'),
|
|
141
|
+
f('198.51.100.21', 'DE', 'Germany', 50.1109, 8.6821, false, true, 84, 'Simulated Cloud Hosting (Frankfurt)', 'hosting'),
|
|
142
|
+
f('198.51.100.22', 'US', 'United States', 39.0438, -77.4874, false, true, 91, 'Simulated Cloud Hosting (Ashburn)', 'hosting'),
|
|
143
|
+
f('198.51.100.23', 'SG', 'Singapore', 1.3521, 103.8198, false, true, 66, 'Simulated Cloud Hosting (Singapore)', 'hosting'),
|
|
144
|
+
|
|
145
|
+
// ── Proxy / VPN exits — the usual answer to a blocked datacenter range
|
|
146
|
+
f('203.0.113.30', 'RU', 'Russia', 55.7558, 37.6173, true, false, 72, 'Simulated VPN Exit (Moscow)', 'proxy'),
|
|
147
|
+
f('203.0.113.31', 'UA', 'Ukraine', 50.4501, 30.5234, true, false, 58, 'Simulated VPN Exit (Kyiv)', 'proxy'),
|
|
148
|
+
f('203.0.113.32', 'CN', 'China', 22.5431, 114.0579, true, false, 69, 'Simulated Proxy Exit (Shenzhen)', 'proxy'),
|
|
149
|
+
|
|
150
|
+
// ── Foreign residential — new country + impossible travel, low reputation signal
|
|
151
|
+
f('203.0.113.40', 'BR', 'Brazil', -23.5505, -46.6333, false, false, 12, 'Simulated Residential ISP (Sao Paulo)', 'foreign'),
|
|
152
|
+
f('203.0.113.41', 'IN', 'India', 19.076, 72.8777, false, false, 8, 'Simulated Residential ISP (Mumbai)', 'foreign'),
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Domestic entries for the active market.
|
|
157
|
+
*
|
|
158
|
+
* "Domestic" only means anything relative to a market: an address in Lagos is
|
|
159
|
+
* the quiet baseline for a Nigerian bank and a new-country signal for a British
|
|
160
|
+
* one. These are generated from the locale's cities so the pool means the same
|
|
161
|
+
* thing wherever the tool is pointed.
|
|
162
|
+
*/
|
|
163
|
+
function domesticEntries(locale) {
|
|
164
|
+
return locale.cities.map((place, i) =>
|
|
165
|
+
f(
|
|
166
|
+
`192.0.2.${10 + i}`,
|
|
167
|
+
locale.id,
|
|
168
|
+
locale.name,
|
|
169
|
+
place.lat,
|
|
170
|
+
place.lon,
|
|
171
|
+
false,
|
|
172
|
+
false,
|
|
173
|
+
0,
|
|
174
|
+
`Simulated Residential ISP (${place.city})`,
|
|
175
|
+
'domestic'
|
|
176
|
+
)
|
|
177
|
+
);
|
|
89
178
|
}
|
|
90
179
|
|
|
91
180
|
/**
|
|
92
|
-
*
|
|
181
|
+
* The attacker IP pool for the run's market: domestic entries from the active
|
|
182
|
+
* locale, plus the fixed set of hosting, proxy and foreign exits.
|
|
183
|
+
*
|
|
184
|
+
* Exposed as a getter so it tracks the locale in scope without callers having
|
|
185
|
+
* to pass one. `demoIpPool()` is the explicit form.
|
|
93
186
|
*/
|
|
94
|
-
export function
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
187
|
+
export function demoIpPool(locale) {
|
|
188
|
+
return Object.freeze([
|
|
189
|
+
...domesticEntries(locale ?? currentLocale()),
|
|
190
|
+
...FOREIGN_POOL,
|
|
191
|
+
]);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Compact constructor for DEMO_IP_POOL rows — keeps the table above readable. */
|
|
195
|
+
function f(ip, countryCode, country, lat, lon, proxy, hosting, abuseScore, isp, persona) {
|
|
196
|
+
return Object.freeze({ ip, countryCode, country, lat, lon, proxy, hosting, abuseScore, isp, persona });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Pick an entry from {@link DEMO_IP_POOL}, optionally filtered by persona.
|
|
201
|
+
*
|
|
202
|
+
* @param {'any'|'domestic'|'hosting'|'proxy'|'foreign'} [persona='any']
|
|
203
|
+
* @returns {(typeof DEMO_IP_POOL)[number]}
|
|
204
|
+
*/
|
|
205
|
+
export function pickDemoIp(persona = 'any') {
|
|
206
|
+
const pool = demoIpPool();
|
|
207
|
+
if (persona === 'any') return pickOne(pool);
|
|
208
|
+
const matches = pool.filter((entry) => entry.persona === persona);
|
|
209
|
+
if (matches.length === 0) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
`Unknown IP persona: "${persona}". Expected one of: any, domestic, hosting, proxy, foreign.`
|
|
212
|
+
);
|
|
98
213
|
}
|
|
214
|
+
return pickOne(matches);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Publicly documented test BIN prefixes.
|
|
219
|
+
*
|
|
220
|
+
* These are the card prefixes every payment processor publishes for sandbox
|
|
221
|
+
* use. `fraud-sim` deliberately does NOT generate Luhn-valid card numbers and
|
|
222
|
+
* never emits a full PAN — a fraud API receives a token, a BIN, and the last
|
|
223
|
+
* four digits, so that is all a scenario produces. Anything more would make the
|
|
224
|
+
* library useful for something other than testing your own defences.
|
|
225
|
+
*/
|
|
226
|
+
const TEST_CARD_BINS = [
|
|
227
|
+
'411111', // Visa
|
|
228
|
+
'400000', // Visa
|
|
229
|
+
'555555', // Mastercard
|
|
230
|
+
'510510', // Mastercard
|
|
231
|
+
'601111', // Discover
|
|
232
|
+
'371449', // Amex
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
/** Pick one of the published test BINs. */
|
|
236
|
+
export function randomCardBin() {
|
|
237
|
+
return pickOne(TEST_CARD_BINS);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** All test BINs, for scenarios that need to walk a BIN range deliberately. */
|
|
241
|
+
export function cardBins() {
|
|
242
|
+
return [...TEST_CARD_BINS];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Four synthetic trailing digits. Not derived from any real card. */
|
|
246
|
+
export function randomLast4() {
|
|
247
|
+
let out = '';
|
|
248
|
+
for (let i = 0; i < 4; i++) out += randomInt(0, 9).toString();
|
|
99
249
|
return out;
|
|
100
250
|
}
|
|
101
251
|
|
|
252
|
+
/** An opaque card token, the way a processor would hand one to a risk API. */
|
|
253
|
+
export function newCardToken() {
|
|
254
|
+
return `tok_sim_${alphanumeric(24)}`;
|
|
255
|
+
}
|
|
256
|
+
|
|
102
257
|
/**
|
|
103
|
-
*
|
|
258
|
+
* Generate an IP inside the same /24 as `ip`.
|
|
259
|
+
*
|
|
260
|
+
* Promo-abuse and other single-actor attacks look like many users, but the
|
|
261
|
+
* addresses cluster. Subnet clustering is usually the signal that catches them.
|
|
104
262
|
*/
|
|
105
|
-
export function
|
|
106
|
-
|
|
263
|
+
export function ipInSameSubnet(ip) {
|
|
264
|
+
const octets = String(ip).split('.');
|
|
265
|
+
if (octets.length !== 4) {
|
|
266
|
+
throw new Error(`ipInSameSubnet: "${ip}" is not a dotted-quad IPv4 address`);
|
|
267
|
+
}
|
|
268
|
+
return `${octets[0]}.${octets[1]}.${octets[2]}.${randomInt(1, 254)}`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** A subscriber number in the active market's format. */
|
|
272
|
+
export function randomPhoneNumber() {
|
|
273
|
+
return currentLocale().phoneNumber(randomFloat);
|
|
107
274
|
}
|
|
108
275
|
|
|
109
276
|
/**
|
|
110
|
-
*
|
|
277
|
+
* Pick a realistic user agent string.
|
|
111
278
|
*/
|
|
279
|
+
export function randomUserAgent() {
|
|
280
|
+
return pickOne(USER_AGENTS);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* A real browser's user agent — never a tool. For legitimate traffic, where a
|
|
285
|
+
* `curl` agent would be a false positive the simulator manufactured.
|
|
286
|
+
*/
|
|
287
|
+
export function randomBrowserUserAgent() {
|
|
288
|
+
return pickOne(USER_AGENTS.filter((ua) => ua.startsWith('Mozilla/')));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* An account identifier shaped like the active market's — 10 digits in Nigeria,
|
|
293
|
+
* 8 in the UK, a variable length in the US. Format matters: a target that
|
|
294
|
+
* validates account numbers rejects a wrongly-shaped one before it ever scores
|
|
295
|
+
* it, and a run full of validation errors measures nothing.
|
|
296
|
+
*/
|
|
297
|
+
export function randomAccountNumber() {
|
|
298
|
+
return currentLocale().accountNumber(randomFloat);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** A bank from the active market. */
|
|
302
|
+
export function randomBank() {
|
|
303
|
+
return pickOne(currentLocale().banks);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** A plausible name for the active market. Synthetic; nobody real. */
|
|
112
307
|
export function randomFullName() {
|
|
113
|
-
|
|
308
|
+
const locale = currentLocale();
|
|
309
|
+
return `${pickOne(locale.firstNames)} ${pickOne(locale.lastNames)}`;
|
|
114
310
|
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* A float in [0, 1) from the run's random source.
|
|
314
|
+
*
|
|
315
|
+
* Custom scenarios should use this rather than `Math.random()` — anything that
|
|
316
|
+
* bypasses it will not replay when the run is seeded.
|
|
317
|
+
*/
|
|
318
|
+
export { randomFloat } from './random-source.js';
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The pool for the market currently in scope.
|
|
322
|
+
*
|
|
323
|
+
* Retained as a named export because callers pre-date `demoIpPool()`; it is a
|
|
324
|
+
* snapshot of the default market rather than a live view, so a target
|
|
325
|
+
* pre-seeding intel for a non-default locale should call `demoIpPool()`.
|
|
326
|
+
*/
|
|
327
|
+
export const DEMO_IP_POOL = demoIpPool(resolveLocale());
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared decision tallying.
|
|
3
|
+
*
|
|
4
|
+
* Every scenario counts the same four outcomes and emits the same
|
|
5
|
+
* attempt/response event pair around each call to the target. Centralising it
|
|
6
|
+
* keeps the counts consistent and means a scenario file contains only the
|
|
7
|
+
* attack logic.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** A fresh tally. */
|
|
11
|
+
export function newResults(extra = {}) {
|
|
12
|
+
return { attempts: 0, blocked: 0, stepUp: 0, allowed: 0, errors: 0, ...extra };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Record one decision against a tally. Anything that isn't a recognised
|
|
17
|
+
* decision counts as an error rather than being silently dropped.
|
|
18
|
+
*/
|
|
19
|
+
export function countDecision(results, decision) {
|
|
20
|
+
if (decision === 'BLOCK') results.blocked++;
|
|
21
|
+
else if (decision === 'STEP_UP') results.stepUp++;
|
|
22
|
+
else if (decision === 'ALLOW') results.allowed++;
|
|
23
|
+
else results.errors++;
|
|
24
|
+
return decision;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Call one of the target's scoring methods, emitting the attempt/response
|
|
29
|
+
* events and updating the tally.
|
|
30
|
+
*
|
|
31
|
+
* Returns the target's response, or `null` if the call threw — callers decide
|
|
32
|
+
* whether a failed call ends the scenario or is skipped. A throw is counted as
|
|
33
|
+
* an error but does NOT increment `attempts`, because nothing was scored.
|
|
34
|
+
*
|
|
35
|
+
* @param {object} args
|
|
36
|
+
* @param {object} args.ctx - The scenario context ({ target, emit }).
|
|
37
|
+
* @param {string} args.method - 'scoreLogin' | 'scoreWithdrawal' | 'scoreAction'
|
|
38
|
+
* @param {object} args.payload - The payload to send.
|
|
39
|
+
* @param {object} args.results - Tally from `newResults()`.
|
|
40
|
+
* @param {string} [args.stage] - Optional label carried on both events.
|
|
41
|
+
* @param {object} [args.ipIntel] - Declared intel for the payload's IP, if known.
|
|
42
|
+
* @returns {Promise<object|null>}
|
|
43
|
+
*/
|
|
44
|
+
export async function scoreAndCount({ ctx, method, payload, results, stage, ipIntel }) {
|
|
45
|
+
const { target, emit } = ctx;
|
|
46
|
+
|
|
47
|
+
if (typeof target?.[method] !== 'function') {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`This scenario needs target.${method}(), which the adapter does not implement. ` +
|
|
50
|
+
'httpAdapter supports it — map it to your endpoint via the `endpoints` option.'
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const round = results.attempts;
|
|
55
|
+
const t0 = Date.now();
|
|
56
|
+
emit({ type: 'attempt', round, payload, stage, ipIntel, timestamp: t0 });
|
|
57
|
+
|
|
58
|
+
let response;
|
|
59
|
+
try {
|
|
60
|
+
response = await target[method](payload);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
results.errors++;
|
|
63
|
+
emit({
|
|
64
|
+
type: 'error',
|
|
65
|
+
message: `${stage ?? method} failed: ${err.message}`,
|
|
66
|
+
round,
|
|
67
|
+
timestamp: Date.now(),
|
|
68
|
+
});
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
emit({
|
|
73
|
+
type: 'response',
|
|
74
|
+
round,
|
|
75
|
+
response,
|
|
76
|
+
stage,
|
|
77
|
+
latencyMs: Date.now() - t0,
|
|
78
|
+
timestamp: Date.now(),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
results.attempts++;
|
|
82
|
+
countDecision(results, response?.decision ?? 'ERROR');
|
|
83
|
+
return response;
|
|
84
|
+
}
|
package/src/utils/rng.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Seeded pseudo-random number generator.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* uses this instead.
|
|
4
|
+
* Backs both seeded runs (installed as the run's random source by the runner —
|
|
5
|
+
* see `random-source.js`) and the deterministic LLM provider, which needs the
|
|
6
|
+
* same seed to produce the same strategy for the same history.
|
|
8
7
|
*
|
|
9
8
|
* Algorithm is mulberry32 — small, fast, and good enough for synthetic data.
|
|
10
9
|
* It is NOT cryptographically secure and must never be used for anything that
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbound request rate limiting.
|
|
3
|
+
*
|
|
4
|
+
* `fraud-sim` generates attack traffic, so the difference between a useful
|
|
5
|
+
* simulation and a denial-of-service against your own staging environment is
|
|
6
|
+
* one mistyped `delayMs`. Per-scenario pacing is a convention; this is the
|
|
7
|
+
* guard rail that holds when the convention is ignored.
|
|
8
|
+
*
|
|
9
|
+
* The limiter wraps the *target adapter* rather than living inside scenarios,
|
|
10
|
+
* so every call from every scenario passes through it — including scenarios
|
|
11
|
+
* written by users, which cannot be expected to police themselves.
|
|
12
|
+
*
|
|
13
|
+
* It is a sliding window, not a fixed one: a run cannot spend its whole budget
|
|
14
|
+
* in the first 50ms of each second and call the average acceptable.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const SCORING_METHODS = ['scoreLogin', 'scoreWithdrawal', 'scoreAction', 'reportEvent'];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Wrap a target adapter so its outbound calls never exceed `maxPerSecond`.
|
|
21
|
+
*
|
|
22
|
+
* Passing `Infinity` (or a non-finite value) returns the target untouched —
|
|
23
|
+
* useful in tests, and for users who have their own pacing and mean it.
|
|
24
|
+
*
|
|
25
|
+
* @param {object} target - Any TargetAdapter.
|
|
26
|
+
* @param {number} maxPerSecond
|
|
27
|
+
* @param {{ onThrottle?: (waitMs: number) => void, signal?: AbortSignal }} [hooks]
|
|
28
|
+
* @returns {object} A adapter with the same shape as `target`.
|
|
29
|
+
*/
|
|
30
|
+
export function throttleTarget(target, maxPerSecond, hooks = {}) {
|
|
31
|
+
if (!Number.isFinite(maxPerSecond)) return target;
|
|
32
|
+
if (maxPerSecond <= 0) {
|
|
33
|
+
throw new Error(`maxRequestsPerSecond must be greater than 0, received: ${maxPerSecond}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return applyLimiter(target, rateLimiter(maxPerSecond, hooks));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Wrap a target with an already-constructed limiter.
|
|
41
|
+
*
|
|
42
|
+
* Separate from `throttleTarget` because several concurrent runs need to share
|
|
43
|
+
* a single limiter instance: one limiter per run would let the effective rate
|
|
44
|
+
* scale with the concurrency, which is the opposite of what a ceiling is for.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} target
|
|
47
|
+
* @param {() => Promise<void>} limiter - From `rateLimiter()`.
|
|
48
|
+
* @param {(waitMs: number) => void} [onThrottle] - Per-run accounting.
|
|
49
|
+
* @returns {object}
|
|
50
|
+
*/
|
|
51
|
+
export function applyLimiter(target, limiter, onThrottle) {
|
|
52
|
+
const wrapped = Object.create(Object.getPrototypeOf(target) ?? Object.prototype);
|
|
53
|
+
|
|
54
|
+
// Copy anything the adapter exposes that we are not wrapping — test helpers
|
|
55
|
+
// like mockAdapter's `_calls` have to survive the wrapping.
|
|
56
|
+
for (const key of allKeys(target)) {
|
|
57
|
+
const value = target[key];
|
|
58
|
+
wrapped[key] = typeof value === 'function' ? value.bind(target) : value;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const method of SCORING_METHODS) {
|
|
62
|
+
if (typeof target[method] !== 'function') continue;
|
|
63
|
+
wrapped[method] = async (...args) => {
|
|
64
|
+
const t0 = Date.now();
|
|
65
|
+
await limiter();
|
|
66
|
+
const waited = Date.now() - t0;
|
|
67
|
+
if (waited > 0) onThrottle?.(waited);
|
|
68
|
+
return target[method](...args);
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return wrapped;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A sliding-window limiter. Resolves immediately while under the limit;
|
|
77
|
+
* otherwise waits until the oldest request in the window ages out.
|
|
78
|
+
*/
|
|
79
|
+
export function rateLimiter(maxPerSecond, hooks = {}) {
|
|
80
|
+
const windowMs = 1000;
|
|
81
|
+
/** @type {number[]} timestamps of recent requests, oldest first */
|
|
82
|
+
const recent = [];
|
|
83
|
+
|
|
84
|
+
return async function acquire() {
|
|
85
|
+
for (;;) {
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
|
|
88
|
+
// Drop anything that has aged out of the window.
|
|
89
|
+
while (recent.length > 0 && now - recent[0] >= windowMs) recent.shift();
|
|
90
|
+
|
|
91
|
+
if (recent.length < maxPerSecond) {
|
|
92
|
+
recent.push(now);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const waitMs = windowMs - (now - recent[0]);
|
|
97
|
+
hooks.onThrottle?.(waitMs);
|
|
98
|
+
await delay(waitMs, hooks.signal);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function delay(ms, signal) {
|
|
104
|
+
if (ms <= 0) return Promise.resolve();
|
|
105
|
+
return new Promise((resolve) => {
|
|
106
|
+
const timer = setTimeout(finish, ms);
|
|
107
|
+
function finish() {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
signal?.removeEventListener?.('abort', finish);
|
|
110
|
+
resolve();
|
|
111
|
+
}
|
|
112
|
+
// An aborted run should stop waiting on a rate limit immediately; the
|
|
113
|
+
// scenario's own abort check is what ends the loop.
|
|
114
|
+
signal?.addEventListener?.('abort', finish, { once: true });
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Own and inherited enumerable keys, so adapters built either way survive. */
|
|
119
|
+
function allKeys(target) {
|
|
120
|
+
const keys = new Set();
|
|
121
|
+
for (let obj = target; obj && obj !== Object.prototype; obj = Object.getPrototypeOf(obj)) {
|
|
122
|
+
for (const key of Object.getOwnPropertyNames(obj)) {
|
|
123
|
+
if (key !== 'constructor') keys.add(key);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return keys;
|
|
127
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logging adapter — logs payloads to a logger without sending anywhere.
|
|
3
|
+
*
|
|
4
|
+
* Used to inspect what a scenario produces before pointing at a real target.
|
|
5
|
+
* Returns ALLOW for every call by default.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* import { loggingAdapter } from 'fraud-sim/adapters';
|
|
9
|
+
*
|
|
10
|
+
* const target = loggingAdapter();
|
|
11
|
+
* await run('credential-stuffing', { target, options: { ... } });
|
|
12
|
+
* // → All payloads are printed to stdout; nothing is sent to the network
|
|
13
|
+
*/
|
|
14
|
+
export function loggingAdapter(config?: {}): {
|
|
15
|
+
scoreLogin(payload: any): Promise<any>;
|
|
16
|
+
scoreWithdrawal(payload: any): Promise<any>;
|
|
17
|
+
scoreAction(payload: any): Promise<any>;
|
|
18
|
+
reportEvent(eventType: any, data: any): Promise<void>;
|
|
19
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock adapter — returns predetermined responses in sequence.
|
|
3
|
+
*
|
|
4
|
+
* Primary use case: unit testing scenarios. Lets a test verify that a
|
|
5
|
+
* scenario behaves correctly given a known sequence of target responses.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* const target = mockAdapter({
|
|
9
|
+
* scoreLogin: [
|
|
10
|
+
* { decision: 'ALLOW' },
|
|
11
|
+
* { decision: 'STEP_UP', riskScore: 0.5 },
|
|
12
|
+
* { decision: 'BLOCK', riskScore: 0.9, reasons: [{ code: 'NEW_DEVICE' }] },
|
|
13
|
+
* ],
|
|
14
|
+
* });
|
|
15
|
+
*
|
|
16
|
+
* After the queue is exhausted, the adapter returns the last response repeatedly,
|
|
17
|
+
* unless `defaultResponse` is provided.
|
|
18
|
+
*/
|
|
19
|
+
export function mockAdapter(config?: {}): {
|
|
20
|
+
scoreLogin(payload: any): Promise<any>;
|
|
21
|
+
scoreWithdrawal(payload: any): Promise<any>;
|
|
22
|
+
scoreAction(payload: any): Promise<any>;
|
|
23
|
+
reportEvent(eventType: any, data: any): Promise<void>;
|
|
24
|
+
/**
|
|
25
|
+
* Test helper: return all payloads received by a given endpoint.
|
|
26
|
+
*/
|
|
27
|
+
_calls(name: any): any;
|
|
28
|
+
/**
|
|
29
|
+
* Test helper: return all reported events.
|
|
30
|
+
*/
|
|
31
|
+
_reportedEvents(): any[];
|
|
32
|
+
};
|