agentshield-sdk 7.2.0 → 7.3.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 +90 -1
- package/README.md +38 -5
- package/bin/agent-shield.js +19 -0
- package/package.json +8 -4
- package/src/attack-genome.js +536 -0
- package/src/attack-replay.js +246 -0
- package/src/audit.js +619 -0
- package/src/behavioral-dna.js +762 -0
- package/src/circuit-breaker.js +321 -321
- package/src/compliance-authority.js +803 -0
- package/src/detector-core.js +3 -3
- package/src/distributed.js +403 -359
- package/src/errors.js +9 -0
- package/src/evolution-simulator.js +650 -0
- package/src/flight-recorder.js +379 -0
- package/src/fuzzer.js +764 -764
- package/src/herd-immunity.js +521 -0
- package/src/index.js +28 -11
- package/src/intent-firewall.js +775 -0
- package/src/main.js +135 -2
- package/src/mcp-security-runtime.js +36 -10
- package/src/mcp-server.js +12 -8
- package/src/middleware.js +306 -208
- package/src/multi-agent.js +421 -404
- package/src/pii.js +404 -390
- package/src/real-attack-datasets.js +246 -0
- package/src/report-generator.js +640 -0
- package/src/soc-dashboard.js +394 -0
- package/src/stream-scanner.js +34 -4
- package/src/supply-chain.js +667 -0
- package/src/testing.js +505 -505
- package/src/threat-intel-federation.js +343 -0
- package/src/utils.js +199 -83
- package/types/index.d.ts +374 -0
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Supply Chain Verification Module
|
|
5
|
+
*
|
|
6
|
+
* Validates the entire tool chain an AI agent uses. When an agent calls tools,
|
|
7
|
+
* those tools call APIs, those APIs return data. Any link in the chain could be
|
|
8
|
+
* compromised. This module catches poisoned tool responses before the agent
|
|
9
|
+
* processes them.
|
|
10
|
+
*
|
|
11
|
+
* All detection runs locally -- no data ever leaves your environment.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { scanText } = require('./detector-core');
|
|
15
|
+
|
|
16
|
+
// =========================================================================
|
|
17
|
+
// CONSTANTS
|
|
18
|
+
// =========================================================================
|
|
19
|
+
|
|
20
|
+
/** Patterns that indicate prompt injection hidden in tool responses. */
|
|
21
|
+
const RESPONSE_INJECTION_PATTERNS = [
|
|
22
|
+
/ignore\s+(all\s+)?(previous|prior|above)\s+(instructions|rules)/i,
|
|
23
|
+
/you\s+are\s+now\s+(?:a|an)\s+(?:unrestricted|unfiltered)/i,
|
|
24
|
+
/SYSTEM\s*:\s*.{10,}/i,
|
|
25
|
+
/\bdo\s+not\s+tell\s+the\s+user\b/i,
|
|
26
|
+
/\bhidden\s+instruction\b/i,
|
|
27
|
+
/\bsecret(?:ly)?\s+(?:execute|run|send|transmit|forward)\b/i,
|
|
28
|
+
/\boverride\s+(?:all\s+)?(?:system|safety)\s+(?:settings|instructions)\b/i,
|
|
29
|
+
/\bdisregard\s+(?:all\s+)?(?:previous|prior)\s+(?:instructions|rules)\b/i,
|
|
30
|
+
/\bact\s+as\s+(?:a|an)\s+unrestricted\b/i,
|
|
31
|
+
/\bpretend\s+(?:you\s+)?(?:have\s+no|there\s+are\s+no)\s+restrictions\b/i
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/** Patterns that match exfiltration URLs in response data. */
|
|
35
|
+
const EXFILTRATION_URL_PATTERNS = [
|
|
36
|
+
/https?:\/\/[^\s"']+\.(?:ngrok|burpcollaborator|pipedream|requestbin|hookbin|webhook\.site)/i,
|
|
37
|
+
/https?:\/\/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(?::\d+)?/i,
|
|
38
|
+
/https?:\/\/[^\s"']*(?:exfil|steal|leak|extract|dump|collect)/i
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/** Patterns that match credentials or secrets in response data. */
|
|
42
|
+
const CREDENTIAL_PATTERNS = [
|
|
43
|
+
/(?:api[_-]?key|apikey)\s*[:=]\s*['"]?[A-Za-z0-9_\-]{16,}/i,
|
|
44
|
+
/(?:secret|token|password|passwd|pwd)\s*[:=]\s*['"]?[^\s'"]{8,}/i,
|
|
45
|
+
/(?:aws_access_key_id|aws_secret_access_key)\s*[:=]\s*['"]?[A-Za-z0-9/+=]{16,}/i,
|
|
46
|
+
/(?:AKIA|ABIA|ACCA|ASIA)[A-Z0-9]{16}/,
|
|
47
|
+
/-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----/,
|
|
48
|
+
/ghp_[A-Za-z0-9]{36}/,
|
|
49
|
+
/sk-[A-Za-z0-9]{32,}/,
|
|
50
|
+
/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
/** Suspicious tool call chains (read sensitive data, then send it out). */
|
|
54
|
+
const CHAIN_SUSPICIOUS_PATTERNS = [
|
|
55
|
+
{
|
|
56
|
+
name: 'credential_then_http',
|
|
57
|
+
description: 'Tool reads credentials then makes an outbound HTTP call.',
|
|
58
|
+
severity: 'critical',
|
|
59
|
+
steps: [
|
|
60
|
+
{ tool: /read|file|open|cat|get|load|fetch_secret/i, args: /\.env|cred|secret|password|token|key|auth/i },
|
|
61
|
+
{ tool: /http|fetch|curl|wget|request|post|send|upload/i }
|
|
62
|
+
]
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'db_dump_then_send',
|
|
66
|
+
description: 'Tool dumps database contents then sends data externally.',
|
|
67
|
+
severity: 'critical',
|
|
68
|
+
steps: [
|
|
69
|
+
{ tool: /sql|query|database|db|mongo|redis/i, args: /SELECT\s+\*|dump|export|find\(\)/i },
|
|
70
|
+
{ tool: /http|fetch|curl|wget|request|send|upload|write/i }
|
|
71
|
+
]
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: 'list_then_exfil',
|
|
75
|
+
description: 'Tool lists sensitive files then makes an outbound request.',
|
|
76
|
+
severity: 'high',
|
|
77
|
+
steps: [
|
|
78
|
+
{ tool: /list|ls|find|glob|readdir/i, args: /\.ssh|\.gnupg|\.aws|credentials|secrets/i },
|
|
79
|
+
{ tool: /http|fetch|curl|wget|request|send|upload/i }
|
|
80
|
+
]
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: 'config_read_then_modify',
|
|
84
|
+
description: 'Tool reads config then modifies it, possible self-modification.',
|
|
85
|
+
severity: 'high',
|
|
86
|
+
steps: [
|
|
87
|
+
{ tool: /read|cat|file|open|get/i, args: /config|settings|\.env|system/i },
|
|
88
|
+
{ tool: /write|edit|modify|update|set|put/i, args: /config|settings|\.env|system/i }
|
|
89
|
+
]
|
|
90
|
+
}
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
/** Default maximum response size in bytes (5 MB). */
|
|
94
|
+
const DEFAULT_MAX_RESPONSE_SIZE = 5 * 1024 * 1024;
|
|
95
|
+
|
|
96
|
+
/** Default maximum depth for recursive object scanning. */
|
|
97
|
+
const DEFAULT_SCAN_DEPTH = 10;
|
|
98
|
+
|
|
99
|
+
// =========================================================================
|
|
100
|
+
// DomainAllowlist
|
|
101
|
+
// =========================================================================
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Manages a set of allowed domains for URL validation.
|
|
105
|
+
*/
|
|
106
|
+
class DomainAllowlist {
|
|
107
|
+
/**
|
|
108
|
+
* @param {string[]} [allowedDomains=[]] - Initial list of allowed domains.
|
|
109
|
+
*/
|
|
110
|
+
constructor(allowedDomains = []) {
|
|
111
|
+
/** @type {Set<string>} */
|
|
112
|
+
this.domains = new Set(allowedDomains.map(d => d.toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '')));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Check if a URL's domain is in the allowlist.
|
|
117
|
+
* @param {string} url - URL to check.
|
|
118
|
+
* @returns {boolean} True if the domain is allowed.
|
|
119
|
+
*/
|
|
120
|
+
isAllowed(url) {
|
|
121
|
+
if (!url || typeof url !== 'string') return false;
|
|
122
|
+
try {
|
|
123
|
+
const domain = this._extractDomain(url);
|
|
124
|
+
if (!domain) return false;
|
|
125
|
+
for (const allowed of this.domains) {
|
|
126
|
+
if (domain === allowed || domain.endsWith('.' + allowed)) {
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return false;
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Add a domain to the allowlist.
|
|
138
|
+
* @param {string} domain
|
|
139
|
+
*/
|
|
140
|
+
add(domain) {
|
|
141
|
+
if (domain && typeof domain === 'string') {
|
|
142
|
+
this.domains.add(domain.toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, ''));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Remove a domain from the allowlist.
|
|
148
|
+
* @param {string} domain
|
|
149
|
+
*/
|
|
150
|
+
remove(domain) {
|
|
151
|
+
if (domain && typeof domain === 'string') {
|
|
152
|
+
this.domains.delete(domain.toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, ''));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Extract domain from a URL string.
|
|
158
|
+
* @param {string} url
|
|
159
|
+
* @returns {string|null}
|
|
160
|
+
* @private
|
|
161
|
+
*/
|
|
162
|
+
_extractDomain(url) {
|
|
163
|
+
const match = url.match(/^(?:https?:\/\/)?([^/:?#]+)/i);
|
|
164
|
+
return match ? match[1].toLowerCase() : null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// =========================================================================
|
|
169
|
+
// ResponseScanner
|
|
170
|
+
// =========================================================================
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Deep-scans tool responses for hidden threats: prompt injections,
|
|
174
|
+
* exfiltration URLs, embedded instructions, and credential leaks.
|
|
175
|
+
*/
|
|
176
|
+
class ResponseScanner {
|
|
177
|
+
/**
|
|
178
|
+
* @param {object} [options]
|
|
179
|
+
* @param {number} [options.maxSize=5242880] - Maximum response size in bytes.
|
|
180
|
+
* @param {number} [options.scanDepth=10] - Maximum depth for recursive scanning.
|
|
181
|
+
*/
|
|
182
|
+
constructor(options = {}) {
|
|
183
|
+
this.maxSize = options.maxSize || DEFAULT_MAX_RESPONSE_SIZE;
|
|
184
|
+
this.scanDepth = options.scanDepth || DEFAULT_SCAN_DEPTH;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Scan a tool response for threats.
|
|
189
|
+
* Accepts strings, objects, or any JSON-serializable value.
|
|
190
|
+
*
|
|
191
|
+
* @param {*} response - Tool response to scan.
|
|
192
|
+
* @returns {{ safe: boolean, threats: Array<object>, sanitizedResponse: * }}
|
|
193
|
+
*/
|
|
194
|
+
scan(response) {
|
|
195
|
+
const threats = [];
|
|
196
|
+
|
|
197
|
+
// Collect all string values from the response
|
|
198
|
+
const strings = this._extractStrings(response, 0);
|
|
199
|
+
|
|
200
|
+
// Size check
|
|
201
|
+
const totalSize = strings.reduce((sum, s) => sum + s.length, 0);
|
|
202
|
+
if (totalSize > this.maxSize) {
|
|
203
|
+
threats.push({
|
|
204
|
+
type: 'oversized_response',
|
|
205
|
+
severity: 'medium',
|
|
206
|
+
description: `Response size (${totalSize} bytes) exceeds limit (${this.maxSize} bytes).`
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let sanitizedResponse = response;
|
|
211
|
+
|
|
212
|
+
for (const str of strings) {
|
|
213
|
+
// Check for prompt injections using detector-core
|
|
214
|
+
const scanResult = scanText(str, { source: 'tool_response', sensitivity: 'high' });
|
|
215
|
+
if (scanResult.threats && scanResult.threats.length > 0) {
|
|
216
|
+
for (const t of scanResult.threats) {
|
|
217
|
+
threats.push({
|
|
218
|
+
type: 'embedded_injection',
|
|
219
|
+
severity: t.severity || 'high',
|
|
220
|
+
category: t.category,
|
|
221
|
+
description: t.description || 'Prompt injection detected in tool response.',
|
|
222
|
+
detail: t.detail
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Check response-specific injection patterns
|
|
228
|
+
for (const pattern of RESPONSE_INJECTION_PATTERNS) {
|
|
229
|
+
if (pattern.test(str)) {
|
|
230
|
+
threats.push({
|
|
231
|
+
type: 'hidden_instruction',
|
|
232
|
+
severity: 'high',
|
|
233
|
+
description: 'Hidden instruction detected in tool response data.',
|
|
234
|
+
matched: str.substring(0, 200)
|
|
235
|
+
});
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Check for exfiltration URLs
|
|
241
|
+
for (const pattern of EXFILTRATION_URL_PATTERNS) {
|
|
242
|
+
const match = str.match(pattern);
|
|
243
|
+
if (match) {
|
|
244
|
+
threats.push({
|
|
245
|
+
type: 'exfiltration_url',
|
|
246
|
+
severity: 'high',
|
|
247
|
+
description: 'Potential data exfiltration URL found in tool response.',
|
|
248
|
+
url: match[0].substring(0, 200)
|
|
249
|
+
});
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Check for credentials/secrets
|
|
255
|
+
for (const pattern of CREDENTIAL_PATTERNS) {
|
|
256
|
+
if (pattern.test(str)) {
|
|
257
|
+
threats.push({
|
|
258
|
+
type: 'credential_leak',
|
|
259
|
+
severity: 'critical',
|
|
260
|
+
description: 'Credential or secret pattern detected in tool response.'
|
|
261
|
+
});
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Sanitize if threats found
|
|
268
|
+
if (threats.length > 0) {
|
|
269
|
+
sanitizedResponse = this._sanitize(response, 0);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
safe: threats.length === 0,
|
|
274
|
+
threats,
|
|
275
|
+
sanitizedResponse
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Extract all string values from a nested structure.
|
|
281
|
+
* @param {*} value
|
|
282
|
+
* @param {number} depth
|
|
283
|
+
* @returns {string[]}
|
|
284
|
+
* @private
|
|
285
|
+
*/
|
|
286
|
+
_extractStrings(value, depth) {
|
|
287
|
+
if (depth > this.scanDepth) return [];
|
|
288
|
+
|
|
289
|
+
if (typeof value === 'string') return [value];
|
|
290
|
+
|
|
291
|
+
if (Array.isArray(value)) {
|
|
292
|
+
const result = [];
|
|
293
|
+
for (const item of value) {
|
|
294
|
+
result.push(...this._extractStrings(item, depth + 1));
|
|
295
|
+
}
|
|
296
|
+
return result;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (value && typeof value === 'object') {
|
|
300
|
+
const result = [];
|
|
301
|
+
for (const key of Object.keys(value)) {
|
|
302
|
+
// Also scan keys -- attackers can hide payloads in JSON keys
|
|
303
|
+
if (typeof key === 'string' && key.length > 20) {
|
|
304
|
+
result.push(key);
|
|
305
|
+
}
|
|
306
|
+
result.push(...this._extractStrings(value[key], depth + 1));
|
|
307
|
+
}
|
|
308
|
+
return result;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return [];
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Sanitize a response by redacting detected threats.
|
|
316
|
+
* @param {*} value
|
|
317
|
+
* @param {number} depth
|
|
318
|
+
* @returns {*}
|
|
319
|
+
* @private
|
|
320
|
+
*/
|
|
321
|
+
_sanitize(value, depth) {
|
|
322
|
+
if (depth > this.scanDepth) return value;
|
|
323
|
+
|
|
324
|
+
if (typeof value === 'string') {
|
|
325
|
+
let sanitized = value;
|
|
326
|
+
for (const pattern of RESPONSE_INJECTION_PATTERNS) {
|
|
327
|
+
sanitized = sanitized.replace(pattern, '[REDACTED:injection]');
|
|
328
|
+
}
|
|
329
|
+
for (const pattern of EXFILTRATION_URL_PATTERNS) {
|
|
330
|
+
sanitized = sanitized.replace(pattern, '[REDACTED:exfil_url]');
|
|
331
|
+
}
|
|
332
|
+
for (const pattern of CREDENTIAL_PATTERNS) {
|
|
333
|
+
sanitized = sanitized.replace(pattern, '[REDACTED:credential]');
|
|
334
|
+
}
|
|
335
|
+
return sanitized;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (Array.isArray(value)) {
|
|
339
|
+
return value.map(item => this._sanitize(item, depth + 1));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (value && typeof value === 'object') {
|
|
343
|
+
const result = {};
|
|
344
|
+
for (const key of Object.keys(value)) {
|
|
345
|
+
result[key] = this._sanitize(value[key], depth + 1);
|
|
346
|
+
}
|
|
347
|
+
return result;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return value;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// =========================================================================
|
|
355
|
+
// ToolChainValidator
|
|
356
|
+
// =========================================================================
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Validates tool calls before execution and tool responses after execution.
|
|
360
|
+
* Tracks the full chain of tool interactions to detect multi-step attacks.
|
|
361
|
+
*/
|
|
362
|
+
class ToolChainValidator {
|
|
363
|
+
/**
|
|
364
|
+
* @param {object} [options]
|
|
365
|
+
* @param {string} [options.sensitivity='medium'] - Detection sensitivity: low, medium, high.
|
|
366
|
+
* @param {string[]} [options.allowedDomains=[]] - Allowed domains for URL validation.
|
|
367
|
+
* @param {string[]} [options.blockedDomains=[]] - Blocked domains for URL validation.
|
|
368
|
+
* @param {number} [options.maxResponseSize=5242880] - Max response size in bytes.
|
|
369
|
+
* @param {boolean} [options.scanResponses=true] - Whether to scan tool responses.
|
|
370
|
+
*/
|
|
371
|
+
constructor(options = {}) {
|
|
372
|
+
this.sensitivity = options.sensitivity || 'medium';
|
|
373
|
+
this.allowedDomains = new DomainAllowlist(options.allowedDomains || []);
|
|
374
|
+
this.blockedDomains = new Set((options.blockedDomains || []).map(d => d.toLowerCase()));
|
|
375
|
+
this.maxResponseSize = options.maxResponseSize || DEFAULT_MAX_RESPONSE_SIZE;
|
|
376
|
+
this.scanResponses = options.scanResponses !== false;
|
|
377
|
+
|
|
378
|
+
this.responseScanner = new ResponseScanner({
|
|
379
|
+
maxSize: this.maxResponseSize,
|
|
380
|
+
scanDepth: DEFAULT_SCAN_DEPTH
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
/** @type {Array<{ toolName: string, args: *, timestamp: number }>} */
|
|
384
|
+
this.callHistory = [];
|
|
385
|
+
|
|
386
|
+
// Stats tracking
|
|
387
|
+
this.stats = {
|
|
388
|
+
totalValidated: 0,
|
|
389
|
+
blocked: 0,
|
|
390
|
+
passed: 0,
|
|
391
|
+
byTool: {}
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Scan tool arguments for injection before execution.
|
|
397
|
+
*
|
|
398
|
+
* @param {string} toolName - Name of the tool being called.
|
|
399
|
+
* @param {*} args - Arguments passed to the tool.
|
|
400
|
+
* @returns {{ allowed: boolean, threats: Array<object> }}
|
|
401
|
+
*/
|
|
402
|
+
validateToolCall(toolName, args) {
|
|
403
|
+
this.stats.totalValidated++;
|
|
404
|
+
this._trackTool(toolName, 'call');
|
|
405
|
+
|
|
406
|
+
const threats = [];
|
|
407
|
+
const argsStr = typeof args === 'string' ? args : JSON.stringify(args || {});
|
|
408
|
+
|
|
409
|
+
// Scan arguments with detector-core
|
|
410
|
+
const scanResult = scanText(argsStr, {
|
|
411
|
+
source: `tool_call:${toolName}`,
|
|
412
|
+
sensitivity: this.sensitivity
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
if (scanResult.threats && scanResult.threats.length > 0) {
|
|
416
|
+
for (const t of scanResult.threats) {
|
|
417
|
+
threats.push({
|
|
418
|
+
type: 'injection_in_args',
|
|
419
|
+
tool: toolName,
|
|
420
|
+
severity: t.severity || 'high',
|
|
421
|
+
category: t.category,
|
|
422
|
+
description: t.description || 'Injection detected in tool arguments.'
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Check for URLs in arguments
|
|
428
|
+
const urls = argsStr.match(/https?:\/\/[^\s"'}\]]+/gi) || [];
|
|
429
|
+
for (const url of urls) {
|
|
430
|
+
const urlResult = this.validateURL(url);
|
|
431
|
+
if (!urlResult.allowed) {
|
|
432
|
+
threats.push(...urlResult.threats.map(t => ({
|
|
433
|
+
...t,
|
|
434
|
+
tool: toolName,
|
|
435
|
+
type: 'suspicious_url_in_args'
|
|
436
|
+
})));
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Record in history for chain analysis
|
|
441
|
+
this.callHistory.push({
|
|
442
|
+
toolName,
|
|
443
|
+
args: argsStr.substring(0, 500),
|
|
444
|
+
timestamp: Date.now()
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
// Trim history to last 50 calls
|
|
448
|
+
if (this.callHistory.length > 50) {
|
|
449
|
+
this.callHistory = this.callHistory.slice(-50);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const allowed = threats.length === 0;
|
|
453
|
+
if (allowed) {
|
|
454
|
+
this.stats.passed++;
|
|
455
|
+
} else {
|
|
456
|
+
this.stats.blocked++;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return { allowed, threats };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Scan tool output for injection/exfiltration after execution.
|
|
464
|
+
*
|
|
465
|
+
* @param {string} toolName - Name of the tool that produced the response.
|
|
466
|
+
* @param {*} response - The tool's response data.
|
|
467
|
+
* @returns {{ safe: boolean, threats: Array<object>, sanitizedResponse: * }}
|
|
468
|
+
*/
|
|
469
|
+
validateToolResponse(toolName, response) {
|
|
470
|
+
this.stats.totalValidated++;
|
|
471
|
+
this._trackTool(toolName, 'response');
|
|
472
|
+
|
|
473
|
+
if (!this.scanResponses) {
|
|
474
|
+
this.stats.passed++;
|
|
475
|
+
return { safe: true, threats: [], sanitizedResponse: response };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const result = this.responseScanner.scan(response);
|
|
479
|
+
|
|
480
|
+
// Tag threats with tool name
|
|
481
|
+
for (const threat of result.threats) {
|
|
482
|
+
threat.tool = toolName;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (result.safe) {
|
|
486
|
+
this.stats.passed++;
|
|
487
|
+
} else {
|
|
488
|
+
this.stats.blocked++;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return result;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Check if a URL is in allowed/blocked lists and detect suspicious patterns.
|
|
496
|
+
*
|
|
497
|
+
* @param {string} url - URL to validate.
|
|
498
|
+
* @returns {{ allowed: boolean, threats: Array<object> }}
|
|
499
|
+
*/
|
|
500
|
+
validateURL(url) {
|
|
501
|
+
const threats = [];
|
|
502
|
+
|
|
503
|
+
if (!url || typeof url !== 'string') {
|
|
504
|
+
return { allowed: false, threats: [{ type: 'invalid_url', severity: 'medium', description: 'URL is empty or not a string.' }] };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Extract domain
|
|
508
|
+
const domainMatch = url.match(/^(?:https?:\/\/)?([^/:?#]+)/i);
|
|
509
|
+
const domain = domainMatch ? domainMatch[1].toLowerCase() : null;
|
|
510
|
+
|
|
511
|
+
if (!domain) {
|
|
512
|
+
threats.push({ type: 'malformed_url', severity: 'medium', description: 'Could not extract domain from URL.' });
|
|
513
|
+
return { allowed: false, threats };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Check blocked domains
|
|
517
|
+
for (const blocked of this.blockedDomains) {
|
|
518
|
+
if (domain === blocked || domain.endsWith('.' + blocked)) {
|
|
519
|
+
threats.push({
|
|
520
|
+
type: 'blocked_domain',
|
|
521
|
+
severity: 'high',
|
|
522
|
+
description: `Domain "${domain}" is on the blocklist.`,
|
|
523
|
+
domain
|
|
524
|
+
});
|
|
525
|
+
return { allowed: false, threats };
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// If allowlist has entries, domain must be in it
|
|
530
|
+
if (this.allowedDomains.domains.size > 0 && !this.allowedDomains.isAllowed(url)) {
|
|
531
|
+
threats.push({
|
|
532
|
+
type: 'domain_not_allowed',
|
|
533
|
+
severity: 'medium',
|
|
534
|
+
description: `Domain "${domain}" is not in the allowlist.`,
|
|
535
|
+
domain
|
|
536
|
+
});
|
|
537
|
+
return { allowed: false, threats };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Check for suspicious URL patterns (IP addresses, known exfil services)
|
|
541
|
+
for (const pattern of EXFILTRATION_URL_PATTERNS) {
|
|
542
|
+
if (pattern.test(url)) {
|
|
543
|
+
threats.push({
|
|
544
|
+
type: 'suspicious_url',
|
|
545
|
+
severity: 'high',
|
|
546
|
+
description: 'URL matches a known exfiltration or suspicious pattern.',
|
|
547
|
+
url: url.substring(0, 200)
|
|
548
|
+
});
|
|
549
|
+
return { allowed: false, threats };
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Check for data-in-URL exfiltration (long query strings, base64 in path)
|
|
554
|
+
if (url.length > 500) {
|
|
555
|
+
threats.push({
|
|
556
|
+
type: 'data_in_url',
|
|
557
|
+
severity: 'medium',
|
|
558
|
+
description: 'URL is unusually long, possibly encoding exfiltrated data.'
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const base64InPath = url.match(/\/[A-Za-z0-9+/=]{50,}/);
|
|
563
|
+
if (base64InPath) {
|
|
564
|
+
threats.push({
|
|
565
|
+
type: 'encoded_data_in_url',
|
|
566
|
+
severity: 'high',
|
|
567
|
+
description: 'URL path contains what appears to be base64-encoded data.'
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return { allowed: threats.length === 0, threats };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Validate a sequence of tool calls for suspicious patterns.
|
|
576
|
+
* Detects multi-step attacks such as reading credentials then sending them.
|
|
577
|
+
*
|
|
578
|
+
* @param {Array<{ tool: string, args: string }>} steps - Sequence of tool calls.
|
|
579
|
+
* @returns {{ safe: boolean, threats: Array<object> }}
|
|
580
|
+
*/
|
|
581
|
+
validateChain(steps) {
|
|
582
|
+
const threats = [];
|
|
583
|
+
|
|
584
|
+
if (!Array.isArray(steps) || steps.length < 2) {
|
|
585
|
+
return { safe: true, threats: [] };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
for (const pattern of CHAIN_SUSPICIOUS_PATTERNS) {
|
|
589
|
+
const patternSteps = pattern.steps;
|
|
590
|
+
|
|
591
|
+
// Sliding window: look for the pattern steps in order within the chain
|
|
592
|
+
let patternIdx = 0;
|
|
593
|
+
for (let i = 0; i < steps.length && patternIdx < patternSteps.length; i++) {
|
|
594
|
+
const step = steps[i];
|
|
595
|
+
const expected = patternSteps[patternIdx];
|
|
596
|
+
|
|
597
|
+
const toolMatches = expected.tool.test(step.tool || '');
|
|
598
|
+
const argsMatch = !expected.args || expected.args.test(step.args || '');
|
|
599
|
+
|
|
600
|
+
if (toolMatches && argsMatch) {
|
|
601
|
+
patternIdx++;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (patternIdx === patternSteps.length) {
|
|
606
|
+
threats.push({
|
|
607
|
+
type: 'suspicious_chain',
|
|
608
|
+
name: pattern.name,
|
|
609
|
+
severity: pattern.severity,
|
|
610
|
+
description: pattern.description,
|
|
611
|
+
stepsMatched: patternSteps.length,
|
|
612
|
+
totalSteps: steps.length
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return {
|
|
618
|
+
safe: threats.length === 0,
|
|
619
|
+
threats
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Return a report of validation statistics.
|
|
625
|
+
*
|
|
626
|
+
* @returns {{ totalValidated: number, blocked: number, passed: number, byTool: object }}
|
|
627
|
+
*/
|
|
628
|
+
getReport() {
|
|
629
|
+
return {
|
|
630
|
+
totalValidated: this.stats.totalValidated,
|
|
631
|
+
blocked: this.stats.blocked,
|
|
632
|
+
passed: this.stats.passed,
|
|
633
|
+
byTool: { ...this.stats.byTool }
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Track per-tool stats.
|
|
639
|
+
* @param {string} toolName
|
|
640
|
+
* @param {string} action
|
|
641
|
+
* @private
|
|
642
|
+
*/
|
|
643
|
+
_trackTool(toolName, action) {
|
|
644
|
+
if (!this.stats.byTool[toolName]) {
|
|
645
|
+
this.stats.byTool[toolName] = { calls: 0, responses: 0, blocked: 0 };
|
|
646
|
+
}
|
|
647
|
+
if (action === 'call') {
|
|
648
|
+
this.stats.byTool[toolName].calls++;
|
|
649
|
+
} else if (action === 'response') {
|
|
650
|
+
this.stats.byTool[toolName].responses++;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// =========================================================================
|
|
656
|
+
// EXPORTS
|
|
657
|
+
// =========================================================================
|
|
658
|
+
|
|
659
|
+
module.exports = {
|
|
660
|
+
ToolChainValidator,
|
|
661
|
+
ResponseScanner,
|
|
662
|
+
DomainAllowlist,
|
|
663
|
+
RESPONSE_INJECTION_PATTERNS,
|
|
664
|
+
EXFILTRATION_URL_PATTERNS,
|
|
665
|
+
CREDENTIAL_PATTERNS,
|
|
666
|
+
CHAIN_SUSPICIOUS_PATTERNS
|
|
667
|
+
};
|