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.
- package/CHANGELOG.md +91 -0
- package/LICENSE +21 -0
- package/README.md +485 -0
- package/examples/account-drain-demo/index.js +51 -0
- package/examples/adaptive-attacker-demo/README.md +61 -0
- package/examples/adaptive-attacker-demo/demo-target.js +85 -0
- package/examples/adaptive-attacker-demo/index.js +83 -0
- package/examples/basic-credential-stuffing/index.js +60 -0
- package/examples/express-mock-target/server.js +122 -0
- package/package.json +60 -0
- package/src/adapters/http.js +132 -0
- package/src/adapters/index.js +23 -0
- package/src/adapters/logging.js +47 -0
- package/src/adapters/mock.js +80 -0
- package/src/index.js +155 -0
- package/src/llm/anthropic.js +129 -0
- package/src/llm/deterministic.js +125 -0
- package/src/llm/index.js +19 -0
- package/src/llm/openai.js +112 -0
- package/src/runner.js +87 -0
- package/src/scenarios/account-drain.js +255 -0
- package/src/scenarios/adaptive-attacker.js +206 -0
- package/src/scenarios/credential-stuffing.js +124 -0
- package/src/scenarios/index.js +25 -0
- package/src/streaming/index.js +6 -0
- package/src/streaming/sse.js +105 -0
- package/src/utils/faker-helpers.js +76 -0
- package/src/utils/random.js +114 -0
- package/src/utils/rng.js +76 -0
- package/src/utils/sleep.js +32 -0
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
|
|
20
|
+
export function mockAdapter(config = {}) {
|
|
21
|
+
const state = {
|
|
22
|
+
scoreLogin: queueFrom(config.scoreLogin),
|
|
23
|
+
scoreWithdrawal: queueFrom(config.scoreWithdrawal),
|
|
24
|
+
scoreAction: queueFrom(config.scoreAction),
|
|
25
|
+
reportedEvents: [],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const defaultResponse = config.defaultResponse ?? {
|
|
29
|
+
decision: 'ALLOW',
|
|
30
|
+
riskScore: 0,
|
|
31
|
+
reasons: [],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function dequeue(name) {
|
|
35
|
+
const queue = state[name];
|
|
36
|
+
if (queue.length === 0) return defaultResponse;
|
|
37
|
+
return queue.length === 1 ? queue[0] : queue.shift();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
async scoreLogin(payload) {
|
|
42
|
+
state.scoreLogin._calls.push(payload);
|
|
43
|
+
return dequeue('scoreLogin');
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
async scoreWithdrawal(payload) {
|
|
47
|
+
state.scoreWithdrawal._calls.push(payload);
|
|
48
|
+
return dequeue('scoreWithdrawal');
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
async scoreAction(payload) {
|
|
52
|
+
state.scoreAction._calls.push(payload);
|
|
53
|
+
return dequeue('scoreAction');
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
async reportEvent(eventType, data) {
|
|
57
|
+
state.reportedEvents.push({ type: eventType, data });
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Test helper: return all payloads received by a given endpoint.
|
|
62
|
+
*/
|
|
63
|
+
_calls(name) {
|
|
64
|
+
return state[name]?._calls ?? [];
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Test helper: return all reported events.
|
|
69
|
+
*/
|
|
70
|
+
_reportedEvents() {
|
|
71
|
+
return state.reportedEvents;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function queueFrom(responses) {
|
|
77
|
+
const arr = Array.isArray(responses) ? [...responses] : [];
|
|
78
|
+
arr._calls = [];
|
|
79
|
+
return arr;
|
|
80
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fraud-sim — public API surface
|
|
3
|
+
*
|
|
4
|
+
* Realistic synthetic attack traffic for testing fintech fraud detection systems.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* import { run, runSuite, listScenarios, registerScenario } from 'fraud-sim';
|
|
8
|
+
* import { httpAdapter } from 'fraud-sim/adapters';
|
|
9
|
+
*
|
|
10
|
+
* const report = await run('credential-stuffing', { target, options: {...} });
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Runner } from './runner.js';
|
|
14
|
+
import { scenarios as builtInScenarios } from './scenarios/index.js';
|
|
15
|
+
|
|
16
|
+
// Module-level registry — extensible via registerScenario()
|
|
17
|
+
const registry = new Map();
|
|
18
|
+
for (const scenario of builtInScenarios) {
|
|
19
|
+
registry.set(scenario.id, scenario);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Run a single scenario against a target.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} scenarioId - The ID of a registered scenario
|
|
26
|
+
* @param {object} config
|
|
27
|
+
* @param {object} config.target - A TargetAdapter
|
|
28
|
+
* @param {object} [config.options] - Scenario-specific options (merged with defaults)
|
|
29
|
+
* @param {function} [config.onEvent] - Event callback (event) => void
|
|
30
|
+
* @param {AbortSignal} [config.signal] - For cancellation
|
|
31
|
+
* @returns {Promise<ScenarioReport>}
|
|
32
|
+
*/
|
|
33
|
+
export async function run(scenarioId, config = {}) {
|
|
34
|
+
const scenario = registry.get(scenarioId);
|
|
35
|
+
if (!scenario) {
|
|
36
|
+
const available = [...registry.keys()].join(', ');
|
|
37
|
+
throw new Error(`Unknown scenario: "${scenarioId}". Available: ${available}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!config.target) {
|
|
41
|
+
throw new Error('A target adapter is required. Use httpAdapter() or mockAdapter().');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const runner = new Runner();
|
|
45
|
+
return runner.run(scenario, config);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Run a suite of scenarios as a benchmark.
|
|
50
|
+
*
|
|
51
|
+
* v0.1 — runs sequentially. v0.4 will add parallel execution and full reporting.
|
|
52
|
+
*
|
|
53
|
+
* @param {object} config
|
|
54
|
+
* @param {string} config.name - Human-readable suite name
|
|
55
|
+
* @param {Array<{id: string, options?: object}>} config.scenarios
|
|
56
|
+
* @param {object} config.target
|
|
57
|
+
* @param {function} [config.onEvent]
|
|
58
|
+
* @returns {Promise<SuiteReport>}
|
|
59
|
+
*/
|
|
60
|
+
export async function runSuite(config = {}) {
|
|
61
|
+
if (!config.scenarios || config.scenarios.length === 0) {
|
|
62
|
+
throw new Error('A suite requires at least one scenario.');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const startedAt = new Date().toISOString();
|
|
66
|
+
const scenarioReports = [];
|
|
67
|
+
|
|
68
|
+
for (const entry of config.scenarios) {
|
|
69
|
+
const report = await run(entry.id, {
|
|
70
|
+
target: config.target,
|
|
71
|
+
options: entry.options ?? {},
|
|
72
|
+
onEvent: config.onEvent,
|
|
73
|
+
});
|
|
74
|
+
scenarioReports.push(report);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
suiteName: config.name ?? 'unnamed-suite',
|
|
79
|
+
startedAt,
|
|
80
|
+
completedAt: new Date().toISOString(),
|
|
81
|
+
scenarioReports,
|
|
82
|
+
aggregate: aggregateReports(scenarioReports),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Returns metadata for all registered scenarios.
|
|
88
|
+
* @returns {Array<ScenarioMetadata>}
|
|
89
|
+
*/
|
|
90
|
+
export function listScenarios() {
|
|
91
|
+
return [...registry.values()].map((s) => ({
|
|
92
|
+
id: s.id,
|
|
93
|
+
name: s.name,
|
|
94
|
+
description: s.description,
|
|
95
|
+
defaultOptions: s.defaultOptions,
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Register a custom scenario. Useful for users extending the library
|
|
101
|
+
* or for testing internal scenarios in isolation.
|
|
102
|
+
*
|
|
103
|
+
* @param {Scenario} scenario
|
|
104
|
+
*/
|
|
105
|
+
export function registerScenario(scenario) {
|
|
106
|
+
validateScenario(scenario);
|
|
107
|
+
registry.set(scenario.id, scenario);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Remove a scenario from the registry. Primarily for testing.
|
|
112
|
+
* @param {string} scenarioId
|
|
113
|
+
*/
|
|
114
|
+
export function unregisterScenario(scenarioId) {
|
|
115
|
+
registry.delete(scenarioId);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── internals ───────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
function validateScenario(scenario) {
|
|
121
|
+
if (!scenario || typeof scenario !== 'object') {
|
|
122
|
+
throw new Error('Scenario must be an object.');
|
|
123
|
+
}
|
|
124
|
+
for (const field of ['id', 'name', 'description', 'run']) {
|
|
125
|
+
if (!scenario[field]) {
|
|
126
|
+
throw new Error(`Scenario is missing required field: "${field}".`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (typeof scenario.run !== 'function') {
|
|
130
|
+
throw new Error('Scenario.run must be a function.');
|
|
131
|
+
}
|
|
132
|
+
if (scenario.defaultOptions && typeof scenario.defaultOptions !== 'object') {
|
|
133
|
+
throw new Error('Scenario.defaultOptions must be an object.');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function aggregateReports(reports) {
|
|
138
|
+
const totals = {
|
|
139
|
+
attempts: 0,
|
|
140
|
+
blocked: 0,
|
|
141
|
+
stepUp: 0,
|
|
142
|
+
allowed: 0,
|
|
143
|
+
};
|
|
144
|
+
for (const r of reports) {
|
|
145
|
+
totals.attempts += r.attempts ?? 0;
|
|
146
|
+
totals.blocked += r.blocked ?? 0;
|
|
147
|
+
totals.stepUp += r.stepUp ?? 0;
|
|
148
|
+
totals.allowed += r.allowed ?? 0;
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
...totals,
|
|
152
|
+
blockRate: totals.attempts > 0 ? totals.blocked / totals.attempts : 0,
|
|
153
|
+
stepUpRate: totals.attempts > 0 ? totals.stepUp / totals.attempts : 0,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic-backed LLM provider.
|
|
3
|
+
*
|
|
4
|
+
* Same contract as the OpenAI provider (src/llm/openai.js) but built on the
|
|
5
|
+
* Anthropic Messages API. Owns prompt construction and response parsing, falls
|
|
6
|
+
* back to a randomised strategy on any error, and never throws.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* import { anthropic } from 'fraud-sim/llm';
|
|
10
|
+
*
|
|
11
|
+
* const provider = anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
|
12
|
+
* await run('adaptive-attacker', { target, llm: provider, options: { targetUserId: 'u1' } });
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { deterministic } from './deterministic.js';
|
|
16
|
+
|
|
17
|
+
const ENDPOINT = 'https://api.anthropic.com/v1/messages';
|
|
18
|
+
const API_VERSION = '2023-06-01';
|
|
19
|
+
const TIMEOUT_MS = 10000;
|
|
20
|
+
const DEFAULT_MAX_TOKENS = 1024;
|
|
21
|
+
|
|
22
|
+
const SYSTEM_PROMPT = `You are simulating a fraud actor probing a fintech security system.
|
|
23
|
+
You can only observe API responses — you cannot see the rules.
|
|
24
|
+
Your goal: based on the attack history, decide what to change for the next attempt.
|
|
25
|
+
Respond ONLY with valid JSON: { "reasoning": "...", "parameters": {...} }`;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {object} config
|
|
29
|
+
* @param {string} config.apiKey - Anthropic API key.
|
|
30
|
+
* @param {string} [config.model='claude-sonnet-5']
|
|
31
|
+
* @param {number} [config.maxTokens=1024]
|
|
32
|
+
* @param {function} [config.fetch] - Injectable fetch, for testing.
|
|
33
|
+
* @returns {{ name: string, generateStrategy: function }}
|
|
34
|
+
*/
|
|
35
|
+
export function anthropic(config = {}) {
|
|
36
|
+
const { apiKey, model = 'claude-sonnet-5', maxTokens = DEFAULT_MAX_TOKENS } = config;
|
|
37
|
+
const fetchImpl = config.fetch ?? globalThis.fetch;
|
|
38
|
+
const fallback = deterministic({ seed: config.seed ?? 1 });
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
name: 'anthropic',
|
|
42
|
+
|
|
43
|
+
async generateStrategy(input) {
|
|
44
|
+
const { scenarioId, history = [], round = 1, maxRound = 10 } = input ?? {};
|
|
45
|
+
|
|
46
|
+
const userMessage = `Scenario: ${scenarioId}
|
|
47
|
+
Round: ${round} of ${maxRound}
|
|
48
|
+
History: ${JSON.stringify(history)}
|
|
49
|
+
|
|
50
|
+
What is your next strategy?`;
|
|
51
|
+
|
|
52
|
+
const controller = new AbortController();
|
|
53
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const res = await fetchImpl(ENDPOINT, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: {
|
|
59
|
+
'Content-Type': 'application/json',
|
|
60
|
+
'x-api-key': apiKey,
|
|
61
|
+
'anthropic-version': API_VERSION,
|
|
62
|
+
},
|
|
63
|
+
body: JSON.stringify({
|
|
64
|
+
model,
|
|
65
|
+
max_tokens: maxTokens,
|
|
66
|
+
system: SYSTEM_PROMPT,
|
|
67
|
+
messages: [{ role: 'user', content: userMessage }],
|
|
68
|
+
}),
|
|
69
|
+
signal: controller.signal,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (!res.ok) {
|
|
73
|
+
return fallback.generateStrategy(input);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const body = await res.json();
|
|
77
|
+
// Messages API returns content as an array of blocks; we want the text.
|
|
78
|
+
const content = extractText(body);
|
|
79
|
+
const parsed = parseStrategy(content);
|
|
80
|
+
if (!parsed) {
|
|
81
|
+
return fallback.generateStrategy(input);
|
|
82
|
+
}
|
|
83
|
+
return parsed;
|
|
84
|
+
} catch {
|
|
85
|
+
return fallback.generateStrategy(input);
|
|
86
|
+
} finally {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function extractText(body) {
|
|
94
|
+
const blocks = body?.content;
|
|
95
|
+
if (!Array.isArray(blocks)) return null;
|
|
96
|
+
const textBlock = blocks.find((b) => b?.type === 'text' && typeof b.text === 'string');
|
|
97
|
+
return textBlock ? textBlock.text : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Parse a strategy from raw model text. Tolerates prose around the JSON by
|
|
102
|
+
* extracting the first {...} block if a bare parse fails.
|
|
103
|
+
* @returns {{ reasoning: string, parameters: object } | null}
|
|
104
|
+
*/
|
|
105
|
+
function parseStrategy(content) {
|
|
106
|
+
if (typeof content !== 'string') return null;
|
|
107
|
+
const obj = tryParse(content) ?? tryParse(extractJsonObject(content));
|
|
108
|
+
if (!obj || typeof obj !== 'object') return null;
|
|
109
|
+
const reasoning = typeof obj.reasoning === 'string' ? obj.reasoning : '';
|
|
110
|
+
const parameters =
|
|
111
|
+
obj.parameters && typeof obj.parameters === 'object' ? obj.parameters : {};
|
|
112
|
+
return { reasoning, parameters };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function tryParse(str) {
|
|
116
|
+
if (typeof str !== 'string') return null;
|
|
117
|
+
try {
|
|
118
|
+
return JSON.parse(str);
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function extractJsonObject(str) {
|
|
125
|
+
const start = str.indexOf('{');
|
|
126
|
+
const end = str.lastIndexOf('}');
|
|
127
|
+
if (start === -1 || end === -1 || end <= start) return null;
|
|
128
|
+
return str.slice(start, end + 1);
|
|
129
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic LLM provider — a no-API attacker heuristic.
|
|
3
|
+
*
|
|
4
|
+
* fraud-sim's headline feature is an attacker that adapts based on what gets
|
|
5
|
+
* blocked. The real thing calls an LLM; this is the fallback for when there's
|
|
6
|
+
* no API key — CI, air-gapped machines, contributors without funds. It plays
|
|
7
|
+
* the same role in the interface without any network calls.
|
|
8
|
+
*
|
|
9
|
+
* The "logic" is simple: look at why the last attempt was blocked and change
|
|
10
|
+
* the thing that most likely caused it. It's seeded so a given history always
|
|
11
|
+
* produces the same strategy — reproducibility matters more than cleverness.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* import { deterministic } from 'fraud-sim/llm';
|
|
15
|
+
*
|
|
16
|
+
* const provider = deterministic({ seed: 42 });
|
|
17
|
+
* const strategy = await provider.generateStrategy({
|
|
18
|
+
* scenarioId: 'adaptive-attacker',
|
|
19
|
+
* history: [{ round: 1, decision: 'BLOCK', reasons: [{ code: 'IP_REPUTATION' }] }],
|
|
20
|
+
* round: 2,
|
|
21
|
+
* maxRound: 10,
|
|
22
|
+
* });
|
|
23
|
+
* // → { reasoning: '...', parameters: { ip: '...', ... } }
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { DeterministicRng } from '../utils/rng.js';
|
|
27
|
+
|
|
28
|
+
// Substrings we look for in block-reason codes to decide what to change.
|
|
29
|
+
// Matched case-insensitively against each reason's `code`.
|
|
30
|
+
const IP_SIGNALS = ['IP', 'GEO', 'PROXY', 'VPN', 'SUBNET'];
|
|
31
|
+
const DEVICE_SIGNALS = ['DEVICE', 'FINGERPRINT', 'SESSION'];
|
|
32
|
+
const VELOCITY_SIGNALS = ['VELOCITY', 'RATE', 'FREQUENCY', 'BURST'];
|
|
33
|
+
|
|
34
|
+
const COMMON_USER_AGENTS = [
|
|
35
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
36
|
+
'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',
|
|
37
|
+
'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',
|
|
38
|
+
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36',
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {object} [config]
|
|
43
|
+
* @param {number} [config.seed=1] - Seed for reproducible strategies.
|
|
44
|
+
* @returns {{ name: string, generateStrategy: function }}
|
|
45
|
+
*/
|
|
46
|
+
export function deterministic(config = {}) {
|
|
47
|
+
const seed = config.seed ?? 1;
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
name: 'deterministic',
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {object} input
|
|
54
|
+
* @param {string} input.scenarioId
|
|
55
|
+
* @param {Array} input.history - Prior rounds: { round, parameters?, decision, reasons? }
|
|
56
|
+
* @param {number} input.round
|
|
57
|
+
* @param {number} input.maxRound
|
|
58
|
+
* @returns {Promise<{ reasoning: string, parameters: object }>}
|
|
59
|
+
*/
|
|
60
|
+
async generateStrategy({ history = [], round = 1 } = {}) {
|
|
61
|
+
// Seed off (seed, round) so each round is deterministic yet distinct —
|
|
62
|
+
// otherwise "randomise everything" would return the same values forever.
|
|
63
|
+
const rng = new DeterministicRng(seed + round);
|
|
64
|
+
|
|
65
|
+
const lastAttempt = history.length > 0 ? history[history.length - 1] : null;
|
|
66
|
+
const reasonCodes = extractReasonCodes(lastAttempt);
|
|
67
|
+
const category = classify(reasonCodes);
|
|
68
|
+
|
|
69
|
+
const parameters = {
|
|
70
|
+
ip: rng.ip(),
|
|
71
|
+
deviceFingerprint: rng.alphanumeric(32),
|
|
72
|
+
userAgent: rng.pick(COMMON_USER_AGENTS),
|
|
73
|
+
delayMs: rng.int(500, 3000),
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
let reasoning;
|
|
77
|
+
switch (category) {
|
|
78
|
+
case 'ip':
|
|
79
|
+
reasoning = `Last attempt was blocked on an IP-related reason (${reasonCodes.join(', ')}). Rotating to a fresh IP address.`;
|
|
80
|
+
break;
|
|
81
|
+
case 'device':
|
|
82
|
+
reasoning = `Last attempt was blocked on a device-related reason (${reasonCodes.join(', ')}). Rotating the device fingerprint and user agent.`;
|
|
83
|
+
break;
|
|
84
|
+
case 'velocity':
|
|
85
|
+
// Velocity blocks: slow down AND change IP to dodge rate limits.
|
|
86
|
+
parameters.delayMs = rng.int(5000, 15000);
|
|
87
|
+
reasoning = `Last attempt was blocked on a velocity-related reason (${reasonCodes.join(', ')}). Waiting longer between attempts and changing IP.`;
|
|
88
|
+
break;
|
|
89
|
+
case 'none':
|
|
90
|
+
reasoning =
|
|
91
|
+
round === 1
|
|
92
|
+
? 'First round — establishing a residential-looking baseline before adapting.'
|
|
93
|
+
: 'No clear block pattern to react to. Randomising all attributes to probe for gaps.';
|
|
94
|
+
break;
|
|
95
|
+
default:
|
|
96
|
+
reasoning = `Blocked for an unrecognised reason (${reasonCodes.join(', ')}). Randomising all attributes.`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { reasoning, parameters };
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function extractReasonCodes(attempt) {
|
|
105
|
+
if (!attempt || !Array.isArray(attempt.reasons)) return [];
|
|
106
|
+
return attempt.reasons
|
|
107
|
+
.map((r) => (typeof r === 'string' ? r : r?.code))
|
|
108
|
+
.filter((code) => typeof code === 'string');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function classify(reasonCodes) {
|
|
112
|
+
if (reasonCodes.length === 0) return 'none';
|
|
113
|
+
const matches = (signals) =>
|
|
114
|
+
reasonCodes.some((code) => {
|
|
115
|
+
const upper = code.toUpperCase();
|
|
116
|
+
return signals.some((sig) => upper.includes(sig));
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Velocity and device are checked before IP because velocity codes can also
|
|
120
|
+
// mention IP (e.g. IP_VELOCITY) and the velocity response is the stronger one.
|
|
121
|
+
if (matches(VELOCITY_SIGNALS)) return 'velocity';
|
|
122
|
+
if (matches(DEVICE_SIGNALS)) return 'device';
|
|
123
|
+
if (matches(IP_SIGNALS)) return 'ip';
|
|
124
|
+
return 'unknown';
|
|
125
|
+
}
|
package/src/llm/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM providers — pluggable strategy generators for the adaptive attacker.
|
|
3
|
+
*
|
|
4
|
+
* All providers share one interface:
|
|
5
|
+
* {
|
|
6
|
+
* name: string,
|
|
7
|
+
* async generateStrategy({ scenarioId, history, round, maxRound }) => {
|
|
8
|
+
* return { reasoning: string, parameters: object };
|
|
9
|
+
* }
|
|
10
|
+
* }
|
|
11
|
+
*
|
|
12
|
+
* `deterministic` needs no API and is the fallback used when no `llm` is passed
|
|
13
|
+
* to a scenario. `openai` and `anthropic` call their respective APIs and fall
|
|
14
|
+
* back to randomised behaviour on any error.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export { deterministic } from './deterministic.js';
|
|
18
|
+
export { openai } from './openai.js';
|
|
19
|
+
export { anthropic } from './anthropic.js';
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI-backed LLM provider.
|
|
3
|
+
*
|
|
4
|
+
* Uses the OpenAI Chat Completions API to drive the adaptive attacker. The
|
|
5
|
+
* provider owns prompt construction and response parsing. It is defensive by
|
|
6
|
+
* design: any failure — network error, timeout, malformed JSON — falls back to
|
|
7
|
+
* a randomised strategy rather than crashing the scenario. It never throws.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* import { openai } from 'fraud-sim/llm';
|
|
11
|
+
*
|
|
12
|
+
* const provider = openai({ apiKey: process.env.OPENAI_API_KEY });
|
|
13
|
+
* await run('adaptive-attacker', { target, llm: provider, options: { targetUserId: 'u1' } });
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { deterministic } from './deterministic.js';
|
|
17
|
+
|
|
18
|
+
const ENDPOINT = 'https://api.openai.com/v1/chat/completions';
|
|
19
|
+
const TIMEOUT_MS = 10000;
|
|
20
|
+
|
|
21
|
+
const SYSTEM_PROMPT = `You are simulating a fraud actor probing a fintech security system.
|
|
22
|
+
You can only observe API responses — you cannot see the rules.
|
|
23
|
+
Your goal: based on the attack history, decide what to change for the next attempt.
|
|
24
|
+
Respond ONLY with valid JSON: { "reasoning": "...", "parameters": {...} }`;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {object} config
|
|
28
|
+
* @param {string} config.apiKey - OpenAI API key.
|
|
29
|
+
* @param {string} [config.model='gpt-4o']
|
|
30
|
+
* @param {number} [config.temperature=0.7]
|
|
31
|
+
* @param {function} [config.fetch] - Injectable fetch, for testing.
|
|
32
|
+
* @returns {{ name: string, generateStrategy: function }}
|
|
33
|
+
*/
|
|
34
|
+
export function openai(config = {}) {
|
|
35
|
+
const { apiKey, model = 'gpt-4o', temperature = 0.7 } = config;
|
|
36
|
+
const fetchImpl = config.fetch ?? globalThis.fetch;
|
|
37
|
+
// A seeded fallback so error paths are still deterministic.
|
|
38
|
+
const fallback = deterministic({ seed: config.seed ?? 1 });
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
name: 'openai',
|
|
42
|
+
|
|
43
|
+
async generateStrategy(input) {
|
|
44
|
+
const { scenarioId, history = [], round = 1, maxRound = 10 } = input ?? {};
|
|
45
|
+
|
|
46
|
+
const userMessage = `Scenario: ${scenarioId}
|
|
47
|
+
Round: ${round} of ${maxRound}
|
|
48
|
+
History: ${JSON.stringify(history)}
|
|
49
|
+
|
|
50
|
+
What is your next strategy?`;
|
|
51
|
+
|
|
52
|
+
const controller = new AbortController();
|
|
53
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const res = await fetchImpl(ENDPOINT, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: {
|
|
59
|
+
'Content-Type': 'application/json',
|
|
60
|
+
Authorization: `Bearer ${apiKey}`,
|
|
61
|
+
},
|
|
62
|
+
body: JSON.stringify({
|
|
63
|
+
model,
|
|
64
|
+
temperature,
|
|
65
|
+
messages: [
|
|
66
|
+
{ role: 'system', content: SYSTEM_PROMPT },
|
|
67
|
+
{ role: 'user', content: userMessage },
|
|
68
|
+
],
|
|
69
|
+
response_format: { type: 'json_object' },
|
|
70
|
+
}),
|
|
71
|
+
signal: controller.signal,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
return fallback.generateStrategy(input);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const body = await res.json();
|
|
79
|
+
const content = body?.choices?.[0]?.message?.content;
|
|
80
|
+
const parsed = parseStrategy(content);
|
|
81
|
+
if (!parsed) {
|
|
82
|
+
return fallback.generateStrategy(input);
|
|
83
|
+
}
|
|
84
|
+
return parsed;
|
|
85
|
+
} catch {
|
|
86
|
+
// Network error, timeout/abort, or JSON body error — fall back.
|
|
87
|
+
return fallback.generateStrategy(input);
|
|
88
|
+
} finally {
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Parse and validate a strategy from raw model output.
|
|
97
|
+
* @returns {{ reasoning: string, parameters: object } | null}
|
|
98
|
+
*/
|
|
99
|
+
function parseStrategy(content) {
|
|
100
|
+
if (typeof content !== 'string') return null;
|
|
101
|
+
let obj;
|
|
102
|
+
try {
|
|
103
|
+
obj = JSON.parse(content);
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
if (!obj || typeof obj !== 'object') return null;
|
|
108
|
+
const reasoning = typeof obj.reasoning === 'string' ? obj.reasoning : '';
|
|
109
|
+
const parameters =
|
|
110
|
+
obj.parameters && typeof obj.parameters === 'object' ? obj.parameters : {};
|
|
111
|
+
return { reasoning, parameters };
|
|
112
|
+
}
|