fraud-sim 0.2.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.
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Server-Sent Events streaming helper.
3
+ *
4
+ * Wraps an Express-style response object to stream simulation events to a
5
+ * browser dashboard as they happen, instead of waiting for the full report.
6
+ *
7
+ * @example
8
+ * import { sseStream } from 'fraud-sim/streaming';
9
+ *
10
+ * app.get('/simulate/:scenario/stream', async (req, res) => {
11
+ * const stream = sseStream(res);
12
+ * await run(req.params.scenario, {
13
+ * target,
14
+ * onEvent: (event) => stream.send(event),
15
+ * });
16
+ * stream.close();
17
+ * });
18
+ */
19
+
20
+ /**
21
+ * Set up SSE headers on `res` and return a `{ send, close }` handle.
22
+ *
23
+ * Handles backpressure: if `res.write` reports the internal buffer is full
24
+ * (returns `false`), further events queue in memory until the response
25
+ * emits `drain`, then flush in order.
26
+ *
27
+ * @param {object} res - Any Express-style response with `write`, `end`,
28
+ * `writeHead` (or settable `.statusCode`/`.setHeader`), and an
29
+ * EventEmitter-style `on('drain', ...)`.
30
+ * @returns {{ send: (event: object) => void, close: () => void }}
31
+ */
32
+ export function sseStream(res) {
33
+ if (typeof res?.write !== 'function' || typeof res?.end !== 'function') {
34
+ throw new Error('sseStream(res) requires a response object with write() and end() methods');
35
+ }
36
+
37
+ if (typeof res.writeHead === 'function') {
38
+ res.writeHead(200, {
39
+ 'Content-Type': 'text/event-stream',
40
+ 'Cache-Control': 'no-cache',
41
+ Connection: 'keep-alive',
42
+ });
43
+ } else {
44
+ res.statusCode = 200;
45
+ res.setHeader?.('Content-Type', 'text/event-stream');
46
+ res.setHeader?.('Cache-Control', 'no-cache');
47
+ res.setHeader?.('Connection', 'keep-alive');
48
+ }
49
+
50
+ const queue = [];
51
+ let draining = false;
52
+ let closed = false;
53
+ let closing = false;
54
+
55
+ function frame(event) {
56
+ return `data: ${JSON.stringify(event)}\n\n`;
57
+ }
58
+
59
+ function flush() {
60
+ while (queue.length > 0) {
61
+ // write() sends the chunk regardless of its return value — a `false`
62
+ // return just signals "the internal buffer is full, pause before
63
+ // writing more", matching Node's own Writable stream contract. So the
64
+ // chunk is dequeued either way; only further writes wait for drain.
65
+ const next = queue.shift();
66
+ const ok = res.write(next);
67
+ if (!ok) {
68
+ if (!draining) {
69
+ draining = true;
70
+ res.once?.('drain', () => {
71
+ draining = false;
72
+ flush();
73
+ });
74
+ }
75
+ return;
76
+ }
77
+ }
78
+ // Everything queued so far has been flushed — safe to end now.
79
+ if (closing && !closed) {
80
+ closed = true;
81
+ res.end();
82
+ }
83
+ }
84
+
85
+ function write(raw) {
86
+ if (closed || closing) return;
87
+ queue.push(raw);
88
+ if (!draining) flush();
89
+ }
90
+
91
+ return {
92
+ /** Send one event to the client as an SSE `data:` frame. */
93
+ send(event) {
94
+ write(frame(event));
95
+ },
96
+
97
+ /** Send the done sentinel, flush any pending backpressure, then end. */
98
+ close() {
99
+ if (closing || closed) return;
100
+ queue.push(frame({ type: 'done' }));
101
+ closing = true;
102
+ if (!draining) flush();
103
+ },
104
+ };
105
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Shared payload builders. Centralised here so all scenarios produce
3
+ * structurally consistent payloads — and so adding a field to all
4
+ * scenarios at once is a one-line change.
5
+ *
6
+ * Uses the internal `random.js` utilities to avoid a faker dependency.
7
+ */
8
+
9
+ import {
10
+ alphanumeric,
11
+ randomIp,
12
+ randomUserAgent,
13
+ randomAccountNumber,
14
+ randomBank,
15
+ randomFullName,
16
+ randomInt,
17
+ pickOne,
18
+ } from './random.js';
19
+
20
+ export function newSessionId(prefix = 'sim') {
21
+ return `${prefix}_${alphanumeric(10)}`;
22
+ }
23
+
24
+ export function newDeviceFingerprint() {
25
+ return alphanumeric(32);
26
+ }
27
+
28
+ export function newReference() {
29
+ return `TXN_${Date.now()}_${alphanumeric(6)}`;
30
+ }
31
+
32
+ export function newPayee() {
33
+ return {
34
+ payee_account: randomAccountNumber(),
35
+ payee_bank: randomBank(),
36
+ payee_name: randomFullName(),
37
+ };
38
+ }
39
+
40
+ export function buildLoginPayload(userId, overrides = {}) {
41
+ return {
42
+ user_id: userId,
43
+ session_id: newSessionId(),
44
+ ip_address: randomIp(),
45
+ user_agent: randomUserAgent(),
46
+ device_fingerprint: newDeviceFingerprint(),
47
+ timestamp: new Date().toISOString(),
48
+ metadata: {
49
+ login_method: 'password',
50
+ platform: pickOne(['web', 'android', 'ios']),
51
+ _simulated: true,
52
+ },
53
+ ...overrides,
54
+ };
55
+ }
56
+
57
+ export function buildWithdrawalPayload(userId, transaction = {}, overrides = {}) {
58
+ const payee = newPayee();
59
+ return {
60
+ user_id: userId,
61
+ session_id: newSessionId(),
62
+ ip_address: randomIp(),
63
+ device_fingerprint: newDeviceFingerprint(),
64
+ timestamp: new Date().toISOString(),
65
+ transaction: {
66
+ amount: randomInt(100000, 5000000),
67
+ currency: 'NGN',
68
+ ...payee,
69
+ is_new_payee: true,
70
+ reference: newReference(),
71
+ ...transaction,
72
+ },
73
+ metadata: { _simulated: true },
74
+ ...overrides,
75
+ };
76
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Internal random data utilities.
3
+ *
4
+ * fraud-sim deliberately does not depend on a faker library. We need a handful
5
+ * of synthetic data generators and the simpler the dependency graph, the safer
6
+ * the library is to adopt. This module provides everything we use.
7
+ *
8
+ * If you need richer fake data in your own scenarios (real-looking names,
9
+ * locale-specific data, etc.), import @faker-js/faker in your scenario file —
10
+ * it is not bundled with fraud-sim, but you can use it freely alongside it.
11
+ */
12
+
13
+ const ALPHANUM = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
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
+ ];
33
+
34
+ const LAST_NAMES = [
35
+ 'Adeyemi', 'Okonkwo', 'Ibrahim', 'Nwosu', 'Bello', 'Eze', 'Yusuf', 'Okoye',
36
+ 'Lawal', 'Obi', 'Musa', 'Achebe', 'Akin', 'Adamu', 'Ojo', 'Chukwu',
37
+ 'Smith', 'Brown', 'Williams', 'Johnson',
38
+ ];
39
+
40
+ const USER_AGENTS = [
41
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
42
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
43
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
44
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
45
+ 'Mozilla/5.0 (Linux; Android 13; SM-A546B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36',
46
+ 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36',
47
+ 'curl/8.4.0',
48
+ 'python-requests/2.31.0',
49
+ ];
50
+
51
+ /**
52
+ * Random integer in [min, max] inclusive.
53
+ */
54
+ export function randomInt(min, max) {
55
+ return Math.floor(Math.random() * (max - min + 1)) + min;
56
+ }
57
+
58
+ /**
59
+ * Pick one element from an array.
60
+ */
61
+ export function pickOne(arr) {
62
+ return arr[Math.floor(Math.random() * arr.length)];
63
+ }
64
+
65
+ /**
66
+ * Generate a random alphanumeric string of the given length.
67
+ */
68
+ export function alphanumeric(length) {
69
+ let out = '';
70
+ for (let i = 0; i < length; i++) {
71
+ out += ALPHANUM.charAt(Math.floor(Math.random() * ALPHANUM.length));
72
+ }
73
+ return out;
74
+ }
75
+
76
+ /**
77
+ * Generate a synthetic IPv4 address.
78
+ * Not from any real range — purely synthetic for testing.
79
+ */
80
+ export function randomIp() {
81
+ return `${randomInt(1, 254)}.${randomInt(0, 255)}.${randomInt(0, 255)}.${randomInt(1, 254)}`;
82
+ }
83
+
84
+ /**
85
+ * Pick a realistic user agent string.
86
+ */
87
+ export function randomUserAgent() {
88
+ return pickOne(USER_AGENTS);
89
+ }
90
+
91
+ /**
92
+ * Generate a synthetic Nigerian-style account number (10 digits).
93
+ */
94
+ export function randomAccountNumber() {
95
+ let out = '';
96
+ for (let i = 0; i < 10; i++) {
97
+ out += randomInt(0, 9).toString();
98
+ }
99
+ return out;
100
+ }
101
+
102
+ /**
103
+ * Pick a Nigerian bank name.
104
+ */
105
+ export function randomBank() {
106
+ return pickOne(NIGERIAN_BANKS);
107
+ }
108
+
109
+ /**
110
+ * Generate a random full name.
111
+ */
112
+ export function randomFullName() {
113
+ return `${pickOne(FIRST_NAMES)} ${pickOne(LAST_NAMES)}`;
114
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Seeded pseudo-random number generator.
3
+ *
4
+ * `random.js` uses `Math.random()`, which is fine for generating attack traffic
5
+ * but useless when a run has to be reproducible. The deterministic LLM provider
6
+ * needs the same seed to produce the same strategy for the same history, so it
7
+ * uses this instead.
8
+ *
9
+ * Algorithm is mulberry32 — small, fast, and good enough for synthetic data.
10
+ * It is NOT cryptographically secure and must never be used for anything that
11
+ * needs real randomness.
12
+ */
13
+
14
+ const ALPHANUM = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
15
+
16
+ export class DeterministicRng {
17
+ /**
18
+ * @param {number} [seed] - Any integer. The same seed always replays the same sequence.
19
+ */
20
+ constructor(seed = 1) {
21
+ // Coerce to a 32-bit integer so string/float seeds don't silently misbehave.
22
+ this.state = Math.trunc(seed) >>> 0;
23
+ }
24
+
25
+ /**
26
+ * Next float in [0, 1).
27
+ * @returns {number}
28
+ */
29
+ next() {
30
+ this.state = (this.state + 0x6d2b79f5) >>> 0;
31
+ let t = this.state;
32
+ t = Math.imul(t ^ (t >>> 15), t | 1);
33
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
34
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
35
+ }
36
+
37
+ /**
38
+ * Integer in [min, max] inclusive.
39
+ * @param {number} min
40
+ * @param {number} max
41
+ * @returns {number}
42
+ */
43
+ int(min, max) {
44
+ return Math.floor(this.next() * (max - min + 1)) + min;
45
+ }
46
+
47
+ /**
48
+ * Pick one element from an array.
49
+ * @param {Array} arr
50
+ * @returns {*}
51
+ */
52
+ pick(arr) {
53
+ return arr[Math.floor(this.next() * arr.length)];
54
+ }
55
+
56
+ /**
57
+ * Random alphanumeric string of the given length.
58
+ * @param {number} length
59
+ * @returns {string}
60
+ */
61
+ alphanumeric(length) {
62
+ let out = '';
63
+ for (let i = 0; i < length; i++) {
64
+ out += ALPHANUM.charAt(Math.floor(this.next() * ALPHANUM.length));
65
+ }
66
+ return out;
67
+ }
68
+
69
+ /**
70
+ * Synthetic IPv4 address.
71
+ * @returns {string}
72
+ */
73
+ ip() {
74
+ return `${this.int(1, 254)}.${this.int(0, 255)}.${this.int(0, 255)}.${this.int(1, 254)}`;
75
+ }
76
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Sleep utility — abort-aware.
3
+ *
4
+ * Used by scenarios to space out attack attempts. Respects AbortSignal
5
+ * so scenarios cancel promptly when the user aborts.
6
+ */
7
+
8
+ export function sleep(ms, signal) {
9
+ return new Promise((resolve, reject) => {
10
+ if (signal?.aborted) {
11
+ reject(new Error('Aborted'));
12
+ return;
13
+ }
14
+
15
+ const timer = setTimeout(() => {
16
+ cleanup();
17
+ resolve();
18
+ }, ms);
19
+
20
+ const onAbort = () => {
21
+ cleanup();
22
+ reject(new Error('Aborted'));
23
+ };
24
+
25
+ const cleanup = () => {
26
+ clearTimeout(timer);
27
+ signal?.removeEventListener?.('abort', onAbort);
28
+ };
29
+
30
+ signal?.addEventListener?.('abort', onAbort, { once: true });
31
+ });
32
+ }