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
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scenario: velocity-evasion
|
|
3
|
+
*
|
|
4
|
+
* An attacker who has already probed the target and learned roughly where its
|
|
5
|
+
* rate limits sit, and now paces attempts to stay just underneath them. Not a
|
|
6
|
+
* botnet β one attacker, being deliberately patient.
|
|
7
|
+
*
|
|
8
|
+
* This is a direct test of a specific weakness: a counter with a fixed
|
|
9
|
+
* threshold is a line, and any line can be stayed under. The scenario
|
|
10
|
+
* parameterises the attacker's *belief* about the limit and derives its pacing
|
|
11
|
+
* from that, so you can also run it with a deliberately wrong belief to see how
|
|
12
|
+
* much margin your thresholds actually have.
|
|
13
|
+
*
|
|
14
|
+
* Defensive systems should catch this via:
|
|
15
|
+
* - Adaptive thresholds keyed to the user's own baseline
|
|
16
|
+
* - Multi-window counters (staying under an hourly limit still breaks a daily one)
|
|
17
|
+
* - Statistical anomaly detection on inter-arrival times β perfectly spaced
|
|
18
|
+
* attempts are themselves unnatural
|
|
19
|
+
* - Device and network novelty, which pacing does nothing to hide
|
|
20
|
+
*
|
|
21
|
+
* ββ On simulated time βββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
22
|
+
* Evasion is defined by spacing, so this scenario advances the `timestamp`
|
|
23
|
+
* field rather than sleeping for hours. That is faithful only if the target
|
|
24
|
+
* derives its windows from the event timestamp; a system stamping server-side
|
|
25
|
+
* arrival time will see a burst and block it, which tells you nothing about
|
|
26
|
+
* evasion. `simulatedTime: true` is set on the report.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { sleep } from '../utils/sleep.js';
|
|
30
|
+
import {
|
|
31
|
+
buildLoginPayload,
|
|
32
|
+
newDeviceFingerprint,
|
|
33
|
+
} from '../utils/faker-helpers.js';
|
|
34
|
+
import { newResults, scoreAndCount } from '../utils/results.js';
|
|
35
|
+
import { simulatedClock, humaniseMs } from '../utils/clock.js';
|
|
36
|
+
import { randomIp, randomInt, pickDemoIp } from '../utils/random.js';
|
|
37
|
+
|
|
38
|
+
export default {
|
|
39
|
+
id: 'velocity-evasion',
|
|
40
|
+
name: 'Velocity evasion',
|
|
41
|
+
description:
|
|
42
|
+
'An attacker who has learned where the rate limits sit and paces attempts to stay just under them.',
|
|
43
|
+
|
|
44
|
+
defaultOptions: {
|
|
45
|
+
targetUserId: null, // required
|
|
46
|
+
attemptCount: 24,
|
|
47
|
+
assumedLimit: 5, // attempts the attacker believes are allowed...
|
|
48
|
+
assumedWindowSeconds: 600, // ...within this window
|
|
49
|
+
safetyMargin: 1.25, // how far under the limit they stay (1.0 = exactly at it)
|
|
50
|
+
jitterRatio: 0.15, // randomise spacing β perfect regularity is its own signal
|
|
51
|
+
rotateDevice: true, // pacing hides volume, not novelty
|
|
52
|
+
ipPersona: 'random',
|
|
53
|
+
delayMs: 50, // wall-clock pacing only
|
|
54
|
+
maxTotalAttempts: 200,
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
async run(ctx) {
|
|
58
|
+
const { options, emit, signal } = ctx;
|
|
59
|
+
|
|
60
|
+
if (!options.targetUserId) {
|
|
61
|
+
throw new Error('velocity-evasion requires options.targetUserId');
|
|
62
|
+
}
|
|
63
|
+
if (options.assumedLimit < 1) {
|
|
64
|
+
throw new Error('velocity-evasion requires options.assumedLimit >= 1');
|
|
65
|
+
}
|
|
66
|
+
if (options.safetyMargin <= 0) {
|
|
67
|
+
throw new Error('velocity-evasion requires options.safetyMargin > 0');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// To stay under `limit` attempts per `window`, space them at least
|
|
71
|
+
// window/limit apart, times a margin the attacker keeps for safety.
|
|
72
|
+
const baseSpacingMs =
|
|
73
|
+
((options.assumedWindowSeconds * 1000) / options.assumedLimit) * options.safetyMargin;
|
|
74
|
+
|
|
75
|
+
const results = newResults({
|
|
76
|
+
attemptsMade: 0,
|
|
77
|
+
firstNonAllowAtAttempt: null,
|
|
78
|
+
evaded: false,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const clock = simulatedClock({ start: Date.now() });
|
|
82
|
+
const fixedDevice = options.rotateDevice ? null : newDeviceFingerprint();
|
|
83
|
+
const fixedIp = options.ipPersona === 'random' ? randomIp() : null;
|
|
84
|
+
|
|
85
|
+
const effectiveRate = (options.assumedWindowSeconds * 1000) / baseSpacingMs;
|
|
86
|
+
|
|
87
|
+
emit({
|
|
88
|
+
type: 'milestone',
|
|
89
|
+
message:
|
|
90
|
+
`Pacing at one attempt every ${humaniseMs(baseSpacingMs)} β ` +
|
|
91
|
+
`${effectiveRate.toFixed(1)} per ${options.assumedWindowSeconds}s window, ` +
|
|
92
|
+
`against an assumed limit of ${options.assumedLimit}`,
|
|
93
|
+
timestamp: Date.now(),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
emit({
|
|
97
|
+
type: 'milestone',
|
|
98
|
+
message:
|
|
99
|
+
'Timestamps are simulated. If the target uses server arrival time for its windows, ' +
|
|
100
|
+
'this run measures a burst rather than an evasion attempt.',
|
|
101
|
+
timestamp: Date.now(),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
for (let i = 0; i < options.attemptCount; i++) {
|
|
105
|
+
if (signal.aborted) break;
|
|
106
|
+
if (results.attempts >= options.maxTotalAttempts) break;
|
|
107
|
+
|
|
108
|
+
const ip = fixedIp ?? pickIp(options.ipPersona);
|
|
109
|
+
const overrides = {
|
|
110
|
+
timestamp: clock.iso(),
|
|
111
|
+
ip_address: typeof ip === 'string' ? ip : ip.ip,
|
|
112
|
+
};
|
|
113
|
+
if (fixedDevice) overrides.device_fingerprint = fixedDevice;
|
|
114
|
+
|
|
115
|
+
const response = await scoreAndCount({
|
|
116
|
+
ctx,
|
|
117
|
+
method: 'scoreLogin',
|
|
118
|
+
payload: buildLoginPayload(options.targetUserId, overrides),
|
|
119
|
+
results,
|
|
120
|
+
stage: 'paced-attempt',
|
|
121
|
+
ipIntel: typeof ip === 'string' ? undefined : ip.intel,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
results.attemptsMade++;
|
|
125
|
+
|
|
126
|
+
if (response && response.decision !== 'ALLOW' && results.firstNonAllowAtAttempt == null) {
|
|
127
|
+
results.firstNonAllowAtAttempt = i + 1;
|
|
128
|
+
emit({
|
|
129
|
+
type: 'milestone',
|
|
130
|
+
message:
|
|
131
|
+
`Pushback at attempt ${i + 1} (${response.decision}) after ` +
|
|
132
|
+
`${humaniseMs(clock.elapsedMs())} of simulated time β pacing did not save them`,
|
|
133
|
+
timestamp: Date.now(),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Jitter the spacing. A perfectly regular interval is itself detectable.
|
|
138
|
+
const jitter = options.jitterRatio
|
|
139
|
+
? randomInt(-baseSpacingMs * options.jitterRatio, baseSpacingMs * options.jitterRatio)
|
|
140
|
+
: 0;
|
|
141
|
+
clock.advance(Math.max(1, Math.round(baseSpacingMs + jitter)));
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
await sleep(options.delayMs, signal);
|
|
145
|
+
} catch {
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
results.evaded = results.firstNonAllowAtAttempt == null && results.attemptsMade > 0;
|
|
151
|
+
|
|
152
|
+
if (results.evaded) {
|
|
153
|
+
emit({
|
|
154
|
+
type: 'milestone',
|
|
155
|
+
message:
|
|
156
|
+
`All ${results.attemptsMade} attempts allowed across ${humaniseMs(clock.elapsedMs())}. ` +
|
|
157
|
+
'Fixed-threshold counters can be paced under; a baseline-aware model is what catches this.',
|
|
158
|
+
timestamp: Date.now(),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
...results,
|
|
164
|
+
attackerSucceeded: results.evaded,
|
|
165
|
+
pacingMs: Math.round(baseSpacingMs),
|
|
166
|
+
effectiveRatePerWindow: Math.round(effectiveRate * 100) / 100,
|
|
167
|
+
assumedLimit: options.assumedLimit,
|
|
168
|
+
simulatedTime: true,
|
|
169
|
+
simulatedSpan: humaniseMs(clock.elapsedMs()),
|
|
170
|
+
};
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
function pickIp(persona) {
|
|
175
|
+
if (!persona || persona === 'random') return { ip: randomIp(), intel: undefined };
|
|
176
|
+
const resolved = persona === 'mixed' ? 'proxy' : persona;
|
|
177
|
+
const { ip, ...intel } = pickDemoIp(resolved);
|
|
178
|
+
return { ip, intel };
|
|
179
|
+
}
|
package/src/types.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared type definitions.
|
|
3
|
+
*
|
|
4
|
+
* `fraud-sim` is plain JavaScript; these JSDoc typedefs are the single source
|
|
5
|
+
* of truth for its public shapes, and `npm run build:types` compiles them into
|
|
6
|
+
* the `.d.ts` files consumers actually see. Names referenced from JSDoc
|
|
7
|
+
* elsewhere in the codebase resolve here.
|
|
8
|
+
*
|
|
9
|
+
* This module exports no runtime values.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* What a target says about one scored event.
|
|
14
|
+
*
|
|
15
|
+
* `decision` is deliberately typed loosely: a target may return a value outside
|
|
16
|
+
* the three canonical ones, and a scenario counts anything unrecognised as an
|
|
17
|
+
* error rather than guessing. `'ERROR'` is produced by the adapters themselves
|
|
18
|
+
* for failures that never reached a decision.
|
|
19
|
+
*
|
|
20
|
+
* @typedef {object} TargetResponse
|
|
21
|
+
* @property {'ALLOW'|'STEP_UP'|'BLOCK'|'ERROR'|string} decision
|
|
22
|
+
* @property {number|null} [riskScore]
|
|
23
|
+
* @property {Array<{code: string, label?: string, weight?: number}>} [reasons]
|
|
24
|
+
* @property {object} [raw] - The target's unmapped response body.
|
|
25
|
+
* @property {number} [httpStatus]
|
|
26
|
+
* @property {boolean} [infraError] - True when the call failed rather than
|
|
27
|
+
* being scored: rate limiting, a 5xx, a timeout. Excluded from block rates.
|
|
28
|
+
* @property {string|null} [retryAfter]
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The bridge between scenarios and the system under test.
|
|
33
|
+
*
|
|
34
|
+
* Not every target implements every method. A scenario that needs one you have
|
|
35
|
+
* not provided fails with a message naming it, rather than silently skipping
|
|
36
|
+
* that part of the attack.
|
|
37
|
+
*
|
|
38
|
+
* @typedef {object} TargetAdapter
|
|
39
|
+
* @property {(payload: object) => Promise<TargetResponse>} [scoreLogin]
|
|
40
|
+
* @property {(payload: object) => Promise<TargetResponse>} [scoreWithdrawal]
|
|
41
|
+
* @property {(payload: object) => Promise<TargetResponse>} [scoreAction]
|
|
42
|
+
* @property {(eventType: string, data: object) => Promise<void>} [reportEvent]
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* One thing that happened during a run.
|
|
47
|
+
*
|
|
48
|
+
* @typedef {object} ScenarioEvent
|
|
49
|
+
* @property {'attempt'|'response'|'milestone'|'warning'|'error'|'summary'|'attacker_adapts'|string} type
|
|
50
|
+
* @property {number} timestamp
|
|
51
|
+
* @property {number} [round]
|
|
52
|
+
* @property {string} [stage] - Which phase of a multi-stage attack this belongs to.
|
|
53
|
+
* @property {object} [payload]
|
|
54
|
+
* @property {TargetResponse} [response]
|
|
55
|
+
* @property {number} [latencyMs]
|
|
56
|
+
* @property {string} [message]
|
|
57
|
+
* @property {object} [ipIntel] - Declared geo/reputation for the payload's IP,
|
|
58
|
+
* present only for pool-backed addresses.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* What the runner provides to a scenario.
|
|
63
|
+
*
|
|
64
|
+
* @typedef {object} ScenarioContext
|
|
65
|
+
* @property {object} options - Defaults merged with the caller's options.
|
|
66
|
+
* @property {TargetAdapter} target - Already wrapped with the run's rate limit.
|
|
67
|
+
* @property {object} [llm] - Present only for scenarios that use one.
|
|
68
|
+
* @property {(event: ScenarioEvent) => void} emit
|
|
69
|
+
* @property {AbortSignal} signal
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* An attack. Everything concrete about how traffic is generated lives here.
|
|
74
|
+
*
|
|
75
|
+
* `defaultOptions` is not documentation β it is the scenario's option surface.
|
|
76
|
+
* The runner rejects any option key it does not declare, so a typo fails
|
|
77
|
+
* loudly rather than silently falling back to a default.
|
|
78
|
+
*
|
|
79
|
+
* @typedef {object} Scenario
|
|
80
|
+
* @property {string} id
|
|
81
|
+
* @property {string} name
|
|
82
|
+
* @property {string} description
|
|
83
|
+
* @property {object} [defaultOptions]
|
|
84
|
+
* @property {(ctx: ScenarioContext) => Promise<object>} run
|
|
85
|
+
*/
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Scenario metadata, as returned by `listScenarios()`.
|
|
89
|
+
*
|
|
90
|
+
* @typedef {object} ScenarioMetadata
|
|
91
|
+
* @property {string} id
|
|
92
|
+
* @property {string} name
|
|
93
|
+
* @property {string} description
|
|
94
|
+
* @property {object} [defaultOptions]
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The result of one run.
|
|
99
|
+
*
|
|
100
|
+
* Note the denominators. `blockRate` and `stepUpRate` are computed over
|
|
101
|
+
* `scoredAttempts`, not `attempts`: an attempt that failed before reaching a
|
|
102
|
+
* decision is not evidence about the defence either way, so counting it as a
|
|
103
|
+
* block would let a target look better the more it fell over. Check
|
|
104
|
+
* `errorRate` before reading anything into a run.
|
|
105
|
+
*
|
|
106
|
+
* Scenarios add their own fields β `attackerSucceeded`, `estimatedLossKobo`,
|
|
107
|
+
* `blockedAtStage` and so on β which is why this is not a closed shape.
|
|
108
|
+
*
|
|
109
|
+
* @typedef {object} ScenarioReport
|
|
110
|
+
* @property {string} scenarioId
|
|
111
|
+
* @property {string} scenarioName
|
|
112
|
+
* @property {number} startedAt
|
|
113
|
+
* @property {number} completedAt
|
|
114
|
+
* @property {number} durationMs
|
|
115
|
+
* @property {number} attempts - Everything the scenario tried.
|
|
116
|
+
* @property {number} scoredAttempts - Attempts that came back with a decision.
|
|
117
|
+
* @property {number} blocked
|
|
118
|
+
* @property {number} stepUp
|
|
119
|
+
* @property {number} allowed
|
|
120
|
+
* @property {number} errors - Attempts that never reached a decision.
|
|
121
|
+
* @property {number} blockRate - `blocked / scoredAttempts`.
|
|
122
|
+
* @property {number} stepUpRate - `stepUp / scoredAttempts`.
|
|
123
|
+
* @property {number} errorRate - `errors / attempts`.
|
|
124
|
+
* @property {number} [seed] - Present only when the run was seeded.
|
|
125
|
+
* @property {boolean} [simulatedTime] - True when the scenario advanced
|
|
126
|
+
* timestamps rather than sleeping; results are only faithful if the target
|
|
127
|
+
* derives its windows from the event timestamp.
|
|
128
|
+
* @property {string} [simulatedSpan]
|
|
129
|
+
* @property {boolean} [attackerSucceeded]
|
|
130
|
+
* @property {number} [estimatedLossKobo]
|
|
131
|
+
*/
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Totals across a suite. Same denominator rule as a single run.
|
|
135
|
+
*
|
|
136
|
+
* @typedef {object} SuiteAggregate
|
|
137
|
+
* @property {number} attempts
|
|
138
|
+
* @property {number} scoredAttempts
|
|
139
|
+
* @property {number} blocked
|
|
140
|
+
* @property {number} stepUp
|
|
141
|
+
* @property {number} allowed
|
|
142
|
+
* @property {number} errors
|
|
143
|
+
* @property {number} blockRate
|
|
144
|
+
* @property {number} stepUpRate
|
|
145
|
+
* @property {number} errorRate
|
|
146
|
+
*/
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The result of `runSuite()`.
|
|
150
|
+
*
|
|
151
|
+
* @typedef {object} SuiteReport
|
|
152
|
+
* @property {string} suiteName
|
|
153
|
+
* @property {string} startedAt
|
|
154
|
+
* @property {string} completedAt
|
|
155
|
+
* @property {number} concurrency - How many scenarios ran at once.
|
|
156
|
+
* @property {ScenarioReport[]} scenarioReports
|
|
157
|
+
* @property {SuiteAggregate} aggregate
|
|
158
|
+
*/
|
|
159
|
+
|
|
160
|
+
export {};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simulated clock.
|
|
3
|
+
*
|
|
4
|
+
* Some attacks are defined by their *timing* rather than their volume. A slow
|
|
5
|
+
* drain spreads withdrawals over days; a velocity-evading attacker paces
|
|
6
|
+
* attempts just under a rolling limit. Reproducing those in wall-clock time
|
|
7
|
+
* would make a single run take days, so instead we advance the `timestamp`
|
|
8
|
+
* field the target receives while sleeping only briefly β or not at all.
|
|
9
|
+
*
|
|
10
|
+
* This is honest as long as the target derives its windows from the event
|
|
11
|
+
* timestamp. A system that stamps arrival time server-side will see the whole
|
|
12
|
+
* run compressed into seconds and will (correctly) treat it as a burst. When
|
|
13
|
+
* that happens the scenario is measuring the wrong thing, so scenarios using a
|
|
14
|
+
* simulated clock say so in their report via `simulatedTime: true` and emit a
|
|
15
|
+
* milestone explaining it.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* const clock = simulatedClock({ stepMs: 90 * 60 * 1000 }); // 90 minutes
|
|
19
|
+
* payload.timestamp = clock.iso();
|
|
20
|
+
* clock.advance();
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {object} [config]
|
|
25
|
+
* @param {number} [config.start] - Epoch ms to start from. Defaults to now.
|
|
26
|
+
* @param {number} [config.stepMs] - Default advance applied by `advance()`.
|
|
27
|
+
* @returns {{iso: () => string, epoch: () => number, advance: (ms?: number) => void,
|
|
28
|
+
* elapsedMs: () => number}}
|
|
29
|
+
*/
|
|
30
|
+
export function simulatedClock(config = {}) {
|
|
31
|
+
const start = config.start ?? Date.now();
|
|
32
|
+
const stepMs = config.stepMs ?? 0;
|
|
33
|
+
|
|
34
|
+
if (!Number.isFinite(start)) {
|
|
35
|
+
throw new Error('simulatedClock: start must be a finite epoch in milliseconds');
|
|
36
|
+
}
|
|
37
|
+
if (!Number.isFinite(stepMs) || stepMs < 0) {
|
|
38
|
+
throw new Error('simulatedClock: stepMs must be a non-negative number');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let now = start;
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
/** Current simulated time as an ISO-8601 string, for payload timestamps. */
|
|
45
|
+
iso() {
|
|
46
|
+
return new Date(now).toISOString();
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
/** Current simulated time in epoch milliseconds. */
|
|
50
|
+
epoch() {
|
|
51
|
+
return now;
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
/** Move the clock forward. Defaults to the configured step. */
|
|
55
|
+
advance(ms) {
|
|
56
|
+
const delta = ms ?? stepMs;
|
|
57
|
+
if (!Number.isFinite(delta) || delta < 0) {
|
|
58
|
+
throw new Error('simulatedClock.advance: expected a non-negative number of milliseconds');
|
|
59
|
+
}
|
|
60
|
+
now += delta;
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
/** How much simulated time has passed since the start. */
|
|
64
|
+
elapsedMs() {
|
|
65
|
+
return now - start;
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Format a millisecond span as a short human string, for milestone messages. */
|
|
71
|
+
export function humaniseMs(ms) {
|
|
72
|
+
const minutes = ms / 60000;
|
|
73
|
+
if (minutes < 60) return `${Math.round(minutes)} min`;
|
|
74
|
+
const hours = minutes / 60;
|
|
75
|
+
if (hours < 48) return `${hours.toFixed(1)} hours`;
|
|
76
|
+
return `${(hours / 24).toFixed(1)} days`;
|
|
77
|
+
}
|
|
@@ -14,8 +14,13 @@ import {
|
|
|
14
14
|
randomBank,
|
|
15
15
|
randomFullName,
|
|
16
16
|
randomInt,
|
|
17
|
+
randomCardBin,
|
|
18
|
+
randomLast4,
|
|
19
|
+
newCardToken,
|
|
20
|
+
randomPhoneNumber,
|
|
17
21
|
pickOne,
|
|
18
22
|
} from './random.js';
|
|
23
|
+
import { currentLocale } from './random-source.js';
|
|
19
24
|
|
|
20
25
|
export function newSessionId(prefix = 'sim') {
|
|
21
26
|
return `${prefix}_${alphanumeric(10)}`;
|
|
@@ -63,8 +68,11 @@ export function buildWithdrawalPayload(userId, transaction = {}, overrides = {})
|
|
|
63
68
|
device_fingerprint: newDeviceFingerprint(),
|
|
64
69
|
timestamp: new Date().toISOString(),
|
|
65
70
|
transaction: {
|
|
66
|
-
|
|
67
|
-
|
|
71
|
+
// 50,000 - 250,000 major units, expressed in minor units. An earlier
|
|
72
|
+
// ceiling sat exactly on the "new payee, high amount" threshold most
|
|
73
|
+
// engines ship with, and those rules compare with `>`, so it never fired.
|
|
74
|
+
amount: randomInt(5000000, 25000000),
|
|
75
|
+
currency: currentLocale().currency,
|
|
68
76
|
...payee,
|
|
69
77
|
is_new_payee: true,
|
|
70
78
|
reference: newReference(),
|
|
@@ -74,3 +82,80 @@ export function buildWithdrawalPayload(userId, transaction = {}, overrides = {})
|
|
|
74
82
|
...overrides,
|
|
75
83
|
};
|
|
76
84
|
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Generic non-login, non-withdrawal event, sent to the target's `scoreAction`
|
|
88
|
+
* endpoint. Signups, promo claims, card authorisations and profile changes all
|
|
89
|
+
* ride on this shape so a target only has to map one extra endpoint.
|
|
90
|
+
*/
|
|
91
|
+
export function buildActionPayload(userId, actionType, details = {}, overrides = {}) {
|
|
92
|
+
// `details` is merged, not replaced. Spreading `overrides` wholesale would
|
|
93
|
+
// let a caller adding one detail field silently drop every other one β
|
|
94
|
+
// which is how a card payload can lose its BIN and still look well-formed.
|
|
95
|
+
const { details: detailOverrides, ...topLevel } = overrides;
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
user_id: userId,
|
|
99
|
+
session_id: newSessionId(),
|
|
100
|
+
action_type: actionType,
|
|
101
|
+
ip_address: randomIp(),
|
|
102
|
+
user_agent: randomUserAgent(),
|
|
103
|
+
device_fingerprint: newDeviceFingerprint(),
|
|
104
|
+
timestamp: new Date().toISOString(),
|
|
105
|
+
metadata: { _simulated: true },
|
|
106
|
+
...topLevel,
|
|
107
|
+
details: { ...details, ...(detailOverrides ?? {}) },
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A new-account signup, as promo abuse would generate it. */
|
|
112
|
+
export function buildSignupPayload(userId, overrides = {}) {
|
|
113
|
+
return buildActionPayload(
|
|
114
|
+
userId,
|
|
115
|
+
'signup',
|
|
116
|
+
{
|
|
117
|
+
email: `${userId}@example.invalid`,
|
|
118
|
+
phone: randomPhoneNumber(),
|
|
119
|
+
full_name: randomFullName(),
|
|
120
|
+
},
|
|
121
|
+
overrides
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* A card authorisation attempt.
|
|
127
|
+
*
|
|
128
|
+
* Carries a token, BIN and last four β never a full card number. See
|
|
129
|
+
* `randomCardBin` for why.
|
|
130
|
+
*/
|
|
131
|
+
export function buildCardAuthPayload(userId, card = {}, amountMinor = 5000, overrides = {}) {
|
|
132
|
+
return buildActionPayload(
|
|
133
|
+
userId,
|
|
134
|
+
'card_auth',
|
|
135
|
+
{
|
|
136
|
+
card_token: card.token ?? newCardToken(),
|
|
137
|
+
card_bin: card.bin ?? randomCardBin(),
|
|
138
|
+
card_last4: card.last4 ?? randomLast4(),
|
|
139
|
+
amount: amountMinor,
|
|
140
|
+
currency: currentLocale().currency,
|
|
141
|
+
entry_mode: 'card_not_present',
|
|
142
|
+
},
|
|
143
|
+
overrides
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* An account-to-account transfer. Structurally a withdrawal, but names the
|
|
149
|
+
* destination account explicitly so mule-network runs can be traced.
|
|
150
|
+
*/
|
|
151
|
+
export function buildTransferPayload(userId, destinationAccount, amountMinor, overrides = {}) {
|
|
152
|
+
return buildWithdrawalPayload(
|
|
153
|
+
userId,
|
|
154
|
+
{
|
|
155
|
+
amount: amountMinor,
|
|
156
|
+
payee_account: destinationAccount,
|
|
157
|
+
is_new_payee: overrides.isNewPayee ?? true,
|
|
158
|
+
},
|
|
159
|
+
overrides
|
|
160
|
+
);
|
|
161
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scenario-authoring toolkit β `fraud-sim/utils`.
|
|
3
|
+
*
|
|
4
|
+
* Everything the built-in scenarios use to construct an attack. Custom
|
|
5
|
+
* scenarios should build on these rather than hand-rolling equivalents, for
|
|
6
|
+
* two reasons that are easy to get wrong:
|
|
7
|
+
*
|
|
8
|
+
* - **Randomness.** Every generator here draws from the run's random source,
|
|
9
|
+
* so a seeded run replays your scenario too. A scenario calling
|
|
10
|
+
* `Math.random()` directly silently opts out of that.
|
|
11
|
+
* - **Address realism.** `randomIp()` avoids private, loopback, CGNAT and
|
|
12
|
+
* documentation ranges. Addresses in those blocks resolve at no IP-intel
|
|
13
|
+
* provider, so a target scoring them falls back to neutral values and its
|
|
14
|
+
* geo and reputation rules never fire β the attack looks harmless because
|
|
15
|
+
* the defence was never given anything to look at.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* import { buildLoginPayload, randomIp, sleep } from 'fraud-sim/utils';
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// ββ pacing ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
22
|
+
export { sleep } from './sleep.js';
|
|
23
|
+
export { simulatedClock, humaniseMs } from './clock.js';
|
|
24
|
+
|
|
25
|
+
// ββ decision tallying βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
26
|
+
export { newResults, countDecision, scoreAndCount } from './results.js';
|
|
27
|
+
|
|
28
|
+
// ββ payload builders ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
29
|
+
export {
|
|
30
|
+
buildLoginPayload,
|
|
31
|
+
buildWithdrawalPayload,
|
|
32
|
+
buildActionPayload,
|
|
33
|
+
buildSignupPayload,
|
|
34
|
+
buildCardAuthPayload,
|
|
35
|
+
buildTransferPayload,
|
|
36
|
+
newSessionId,
|
|
37
|
+
newDeviceFingerprint,
|
|
38
|
+
newReference,
|
|
39
|
+
newPayee,
|
|
40
|
+
} from './faker-helpers.js';
|
|
41
|
+
|
|
42
|
+
// ββ synthetic data ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
43
|
+
export {
|
|
44
|
+
randomFloat,
|
|
45
|
+
randomInt,
|
|
46
|
+
pickOne,
|
|
47
|
+
alphanumeric,
|
|
48
|
+
randomIp,
|
|
49
|
+
ipInSameSubnet,
|
|
50
|
+
randomUserAgent,
|
|
51
|
+
randomAccountNumber,
|
|
52
|
+
randomBank,
|
|
53
|
+
randomFullName,
|
|
54
|
+
randomPhoneNumber,
|
|
55
|
+
randomCardBin,
|
|
56
|
+
cardBins,
|
|
57
|
+
randomLast4,
|
|
58
|
+
newCardToken,
|
|
59
|
+
DEMO_IP_POOL,
|
|
60
|
+
demoIpPool,
|
|
61
|
+
pickDemoIp,
|
|
62
|
+
} from './random.js';
|
|
63
|
+
|
|
64
|
+
// ββ reproducibility βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
65
|
+
// `withSeed` is exported for tooling that needs to seed something outside a
|
|
66
|
+
// scenario run; inside a run the runner has already installed the source.
|
|
67
|
+
export { withSeed, isSeeded, withLocale, currentLocale } from './random-source.js';
|
|
68
|
+
export { LOCALES, localeIds, resolveLocale, DEFAULT_LOCALE } from '../locales/index.js';
|
|
69
|
+
export { DeterministicRng } from './rng.js';
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run-scoped source of randomness.
|
|
3
|
+
*
|
|
4
|
+
* Every synthetic value `fraud-sim` produces β IPs, device fingerprints,
|
|
5
|
+
* amounts, jitter, pool ordering β ultimately comes from one function here.
|
|
6
|
+
* That indirection is what makes a run reproducible: seed it, and the same
|
|
7
|
+
* attack replays choice for choice.
|
|
8
|
+
*
|
|
9
|
+
* ββ Why AsyncLocalStorage rather than a module-level variable ββββββββββ
|
|
10
|
+
* A mutable module global would work for one run at a time, and would silently
|
|
11
|
+
* corrupt two. Runs share this module, so a second run starting while a first
|
|
12
|
+
* is awaiting would swap the generator underneath it and both would lose
|
|
13
|
+
* reproducibility β with no error to say so. `AsyncLocalStorage` scopes the
|
|
14
|
+
* generator to one async execution context instead, so concurrent runs stay
|
|
15
|
+
* independent and no helper needs an extra parameter threaded through it.
|
|
16
|
+
*
|
|
17
|
+
* ββ What seeding does and does not fix ββββββββββββββββββββββββββββββββ
|
|
18
|
+
* Seeding controls the *choices*: which IP, which device, which amount, in
|
|
19
|
+
* which order. It does not control wall-clock time β payload timestamps still
|
|
20
|
+
* come from the real clock (or a scenario's simulated one), so two seeded runs
|
|
21
|
+
* are identical in shape but not byte-identical. That is enough to replay an
|
|
22
|
+
* attack and to keep a CI block rate stable; it is not a byte-for-byte record.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
26
|
+
|
|
27
|
+
import { DeterministicRng } from './rng.js';
|
|
28
|
+
import { resolveLocale, DEFAULT_LOCALE } from '../locales/index.js';
|
|
29
|
+
|
|
30
|
+
const storage = new AsyncLocalStorage();
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The market in scope for the current run.
|
|
34
|
+
*
|
|
35
|
+
* Scoped exactly like the random source, and for the same reason: a module-level
|
|
36
|
+
* variable would work for one run and silently corrupt two concurrent ones,
|
|
37
|
+
* generating one market's payloads inside another market's run with no error to
|
|
38
|
+
* show for it.
|
|
39
|
+
*/
|
|
40
|
+
const localeStorage = new AsyncLocalStorage();
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The default, unseeded source. Fine for generating attack traffic; useless
|
|
44
|
+
* for replaying it.
|
|
45
|
+
*/
|
|
46
|
+
const systemSource = { next: () => Math.random() };
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The generator in scope right now β a seeded one inside `withSeed`, the
|
|
50
|
+
* system one otherwise.
|
|
51
|
+
*
|
|
52
|
+
* Consumers only ever need `next()`, so any object with that method works.
|
|
53
|
+
* Keeping the interface to a single function means a seeded run and an
|
|
54
|
+
* unseeded one draw from identical distributions.
|
|
55
|
+
*
|
|
56
|
+
* @returns {{ next: () => number }}
|
|
57
|
+
*/
|
|
58
|
+
export function currentSource() {
|
|
59
|
+
return storage.getStore() ?? systemSource;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A float in [0, 1) from the current source. */
|
|
63
|
+
export function randomFloat() {
|
|
64
|
+
return currentSource().next();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Run `fn` with a seeded generator in scope. Everything it does β including
|
|
69
|
+
* across `await` boundaries β draws from that generator.
|
|
70
|
+
*
|
|
71
|
+
* @param {number} seed - Any integer. The same seed replays the same sequence.
|
|
72
|
+
* @param {() => T} fn
|
|
73
|
+
* @returns {T}
|
|
74
|
+
* @template T
|
|
75
|
+
*/
|
|
76
|
+
export function withSeed(seed, fn) {
|
|
77
|
+
if (!Number.isFinite(seed)) {
|
|
78
|
+
throw new Error(`seed must be a finite number, received: ${JSON.stringify(seed)}`);
|
|
79
|
+
}
|
|
80
|
+
return storage.run(new DeterministicRng(seed), fn);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** True when a seeded generator is currently in scope. */
|
|
84
|
+
export function isSeeded() {
|
|
85
|
+
return storage.getStore() !== undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The locale pack in scope, or the default when a run did not name one. */
|
|
89
|
+
export function currentLocale() {
|
|
90
|
+
return localeStorage.getStore() ?? resolveLocale(DEFAULT_LOCALE);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Run `fn` with a market in scope. Every generator inside it β account numbers,
|
|
95
|
+
* banks, names, phone numbers, payload currency β draws from that pack.
|
|
96
|
+
*
|
|
97
|
+
* @param {string|object} locale - A bundled locale id, or a locale object.
|
|
98
|
+
* @param {() => T} fn
|
|
99
|
+
* @returns {T}
|
|
100
|
+
* @template T
|
|
101
|
+
*/
|
|
102
|
+
export function withLocale(locale, fn) {
|
|
103
|
+
return localeStorage.run(resolveLocale(locale), fn);
|
|
104
|
+
}
|