fraud-sim 0.2.0 β†’ 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +462 -0
  2. package/README.md +621 -81
  3. package/bin/fraud-sim.js +625 -0
  4. package/examples/account-drain-demo/index.js +1 -1
  5. package/examples/adaptive-hardened-demo/index.js +106 -0
  6. package/examples/basic-credential-stuffing/index.js +4 -2
  7. package/examples/express-hardened-target/server.js +230 -0
  8. package/examples/express-mock-target/server.js +153 -2
  9. package/package.json +40 -8
  10. package/src/adapters/http.js +49 -7
  11. package/src/index.js +126 -13
  12. package/src/llm/anthropic.js +7 -0
  13. package/src/llm/deterministic.js +21 -7
  14. package/src/llm/openai.js +7 -0
  15. package/src/locales/index.js +193 -0
  16. package/src/reporting/aggregate.js +338 -0
  17. package/src/reporting/html.js +424 -0
  18. package/src/reporting/index.js +241 -0
  19. package/src/reporting/junit.js +171 -0
  20. package/src/reporting/money.js +105 -0
  21. package/src/runner.js +295 -12
  22. package/src/scenarios/account-drain.js +10 -2
  23. package/src/scenarios/adaptive-attacker.js +33 -11
  24. package/src/scenarios/card-testing.js +174 -0
  25. package/src/scenarios/credential-stuffing.js +146 -20
  26. package/src/scenarios/index.js +37 -6
  27. package/src/scenarios/legitimate-users.js +290 -0
  28. package/src/scenarios/mule-network.js +201 -0
  29. package/src/scenarios/promo-abuse.js +168 -0
  30. package/src/scenarios/session-hijack.js +234 -0
  31. package/src/scenarios/sim-swap-takeover.js +253 -0
  32. package/src/scenarios/slow-drain.js +171 -0
  33. package/src/scenarios/velocity-evasion.js +179 -0
  34. package/src/types.js +160 -0
  35. package/src/utils/clock.js +77 -0
  36. package/src/utils/faker-helpers.js +87 -2
  37. package/src/utils/index.js +69 -0
  38. package/src/utils/random-source.js +104 -0
  39. package/src/utils/random.js +257 -44
  40. package/src/utils/results.js +84 -0
  41. package/src/utils/rng.js +3 -4
  42. package/src/utils/throttle.js +127 -0
  43. package/types/adapters/http.d.ts +6 -0
  44. package/types/adapters/index.d.ts +3 -0
  45. package/types/adapters/logging.d.ts +19 -0
  46. package/types/adapters/mock.d.ts +32 -0
  47. package/types/index.d.ts +79 -0
  48. package/types/llm/anthropic.d.ts +17 -0
  49. package/types/llm/deterministic.d.ts +11 -0
  50. package/types/llm/index.d.ts +3 -0
  51. package/types/llm/openai.d.ts +17 -0
  52. package/types/locales/index.d.ts +146 -0
  53. package/types/reporting/aggregate.d.ts +126 -0
  54. package/types/reporting/html.d.ts +1 -0
  55. package/types/reporting/index.d.ts +99 -0
  56. package/types/reporting/junit.d.ts +13 -0
  57. package/types/reporting/money.d.ts +20 -0
  58. package/types/runner.d.ts +10 -0
  59. package/types/scenarios/account-drain.d.ts +25 -0
  60. package/types/scenarios/adaptive-attacker.d.ts +22 -0
  61. package/types/scenarios/card-testing.d.ts +28 -0
  62. package/types/scenarios/credential-stuffing.d.ts +23 -0
  63. package/types/scenarios/index.d.ts +261 -0
  64. package/types/scenarios/legitimate-users.d.ts +49 -0
  65. package/types/scenarios/mule-network.d.ts +17 -0
  66. package/types/scenarios/promo-abuse.d.ts +28 -0
  67. package/types/scenarios/session-hijack.d.ts +15 -0
  68. package/types/scenarios/sim-swap-takeover.d.ts +16 -0
  69. package/types/scenarios/slow-drain.d.ts +28 -0
  70. package/types/scenarios/velocity-evasion.d.ts +31 -0
  71. package/types/streaming/index.d.ts +1 -0
  72. package/types/streaming/sse.d.ts +34 -0
  73. package/types/types.d.ts +191 -0
  74. package/types/utils/clock.d.ts +39 -0
  75. package/types/utils/faker-helpers.d.ts +114 -0
  76. package/types/utils/index.d.ts +8 -0
  77. package/types/utils/random-source.d.ts +39 -0
  78. package/types/utils/random.d.ts +89 -0
  79. package/types/utils/results.d.ts +46 -0
  80. package/types/utils/rng.d.ts +36 -0
  81. package/types/utils/sleep.d.ts +7 -0
  82. package/types/utils/throttle.d.ts +33 -0
@@ -0,0 +1,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
- amount: randomInt(100000, 5000000),
67
- currency: 'NGN',
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
+ }