@agent-wall/core 0.1.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/.turbo/turbo-build.log +17 -0
- package/.turbo/turbo-test.log +30 -0
- package/LICENSE +21 -0
- package/README.md +80 -0
- package/dist/index.d.ts +1297 -0
- package/dist/index.js +3067 -0
- package/dist/index.js.map +1 -0
- package/package.json +48 -0
- package/src/audit-logger-security.test.ts +225 -0
- package/src/audit-logger.test.ts +93 -0
- package/src/audit-logger.ts +458 -0
- package/src/chain-detector.test.ts +100 -0
- package/src/chain-detector.ts +269 -0
- package/src/dashboard-server.test.ts +362 -0
- package/src/dashboard-server.ts +454 -0
- package/src/egress-control.test.ts +177 -0
- package/src/egress-control.ts +274 -0
- package/src/index.ts +137 -0
- package/src/injection-detector.test.ts +207 -0
- package/src/injection-detector.ts +397 -0
- package/src/kill-switch.test.ts +119 -0
- package/src/kill-switch.ts +198 -0
- package/src/policy-engine-security.test.ts +227 -0
- package/src/policy-engine.test.ts +453 -0
- package/src/policy-engine.ts +414 -0
- package/src/policy-loader.test.ts +202 -0
- package/src/policy-loader.ts +485 -0
- package/src/proxy.ts +786 -0
- package/src/read-buffer-security.test.ts +59 -0
- package/src/read-buffer.test.ts +135 -0
- package/src/read-buffer.ts +126 -0
- package/src/response-scanner.test.ts +464 -0
- package/src/response-scanner.ts +587 -0
- package/src/types.test.ts +152 -0
- package/src/types.ts +146 -0
- package/tsconfig.json +8 -0
- package/tsup.config.ts +9 -0
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Wall Response Scanner
|
|
3
|
+
*
|
|
4
|
+
* Inspects MCP server responses BEFORE they reach the AI agent.
|
|
5
|
+
* This is the second half of the firewall:
|
|
6
|
+
* - Policy Engine controls what goes IN (tool calls)
|
|
7
|
+
* - Response Scanner controls what comes OUT (tool results)
|
|
8
|
+
*
|
|
9
|
+
* Detects:
|
|
10
|
+
* 1. Secret leaks (API keys, tokens, passwords in response text)
|
|
11
|
+
* 2. Data exfiltration markers (base64 blobs, large hex dumps)
|
|
12
|
+
* 3. Sensitive file content (private keys, certificates, AWS creds)
|
|
13
|
+
* 4. Oversized responses (context window stuffing)
|
|
14
|
+
* 5. Custom patterns (user-defined via YAML config)
|
|
15
|
+
*
|
|
16
|
+
* Security:
|
|
17
|
+
* - ReDoS-safe pattern validation (rejects dangerous regex)
|
|
18
|
+
* - Max pattern count enforcement
|
|
19
|
+
* - Configurable base64 blob action
|
|
20
|
+
* - Generic redaction markers (no pattern name leak)
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// ── Types ───────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export type ResponseAction = "pass" | "redact" | "block";
|
|
26
|
+
|
|
27
|
+
/** A single pattern to match against response content. */
|
|
28
|
+
export interface ResponsePattern {
|
|
29
|
+
/** Unique name for this pattern */
|
|
30
|
+
name: string;
|
|
31
|
+
/** Regex pattern to match against response text */
|
|
32
|
+
pattern: string;
|
|
33
|
+
/** Flags for the regex (default: "gi") */
|
|
34
|
+
flags?: string;
|
|
35
|
+
/** What to do when the pattern matches */
|
|
36
|
+
action: ResponseAction;
|
|
37
|
+
/** Human-readable description */
|
|
38
|
+
message?: string;
|
|
39
|
+
/** Category for grouping in audit logs */
|
|
40
|
+
category?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Configuration for response scanning. */
|
|
44
|
+
export interface ResponseScannerConfig {
|
|
45
|
+
/** Enable/disable the response scanner (default: true) */
|
|
46
|
+
enabled?: boolean;
|
|
47
|
+
/** Maximum response size in bytes before blocking (0 = no limit) */
|
|
48
|
+
maxResponseSize?: number;
|
|
49
|
+
/** Action when response exceeds maxResponseSize: "block" or "redact" (truncate) */
|
|
50
|
+
oversizeAction?: "block" | "redact";
|
|
51
|
+
/** Built-in secret detection (default: true) */
|
|
52
|
+
detectSecrets?: boolean;
|
|
53
|
+
/** Built-in PII detection (default: false — opt-in) */
|
|
54
|
+
detectPII?: boolean;
|
|
55
|
+
/** Action for base64 blob detection: "pass" (default), "redact", or "block" */
|
|
56
|
+
base64Action?: ResponseAction;
|
|
57
|
+
/** Maximum number of custom patterns allowed (default: 100) */
|
|
58
|
+
maxPatterns?: number;
|
|
59
|
+
/** Custom patterns to match against response content */
|
|
60
|
+
patterns?: ResponsePattern[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Result of scanning a response. */
|
|
64
|
+
export interface ScanResult {
|
|
65
|
+
/** Whether the response is clean */
|
|
66
|
+
clean: boolean;
|
|
67
|
+
/** The final action to take */
|
|
68
|
+
action: ResponseAction;
|
|
69
|
+
/** All findings */
|
|
70
|
+
findings: ScanFinding[];
|
|
71
|
+
/** If action is "redact", the redacted response text */
|
|
72
|
+
redactedText?: string;
|
|
73
|
+
/** Original size in bytes */
|
|
74
|
+
originalSize: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A single finding from the scanner. */
|
|
78
|
+
export interface ScanFinding {
|
|
79
|
+
/** Name of the pattern that matched */
|
|
80
|
+
pattern: string;
|
|
81
|
+
/** Category */
|
|
82
|
+
category: string;
|
|
83
|
+
/** The action this pattern requests */
|
|
84
|
+
action: ResponseAction;
|
|
85
|
+
/** Human-readable message */
|
|
86
|
+
message: string;
|
|
87
|
+
/** Number of matches found */
|
|
88
|
+
matchCount: number;
|
|
89
|
+
/** Preview of matched content (redacted) */
|
|
90
|
+
preview?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── ReDoS Protection ────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Dangerous regex patterns that indicate potential ReDoS:
|
|
97
|
+
* - Nested quantifiers: (a+)+ , (a*)*
|
|
98
|
+
* - Overlapping alternations with quantifiers
|
|
99
|
+
*/
|
|
100
|
+
const REDOS_DANGEROUS_PATTERNS = [
|
|
101
|
+
/\([^)]*[+*][^)]*\)[+*]/, // (x+)+ or (x*)* nested quantifiers
|
|
102
|
+
/\([^)]*\|[^)]*\)[+*]{/, // (a|a)+ overlapping alternation with quantifier
|
|
103
|
+
/(.+)\1[+*]/, // backreference with quantifier
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
/** Maximum allowed regex pattern length */
|
|
107
|
+
const MAX_PATTERN_LENGTH = 1000;
|
|
108
|
+
|
|
109
|
+
/** Default max number of custom patterns */
|
|
110
|
+
const DEFAULT_MAX_PATTERNS = 100;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Validate a regex pattern is safe from ReDoS attacks.
|
|
114
|
+
* Returns true if the pattern is safe, false if potentially dangerous.
|
|
115
|
+
*/
|
|
116
|
+
export function isRegexSafe(pattern: string): boolean {
|
|
117
|
+
// Length check
|
|
118
|
+
if (pattern.length > MAX_PATTERN_LENGTH) return false;
|
|
119
|
+
|
|
120
|
+
// Check for dangerous nested quantifier patterns
|
|
121
|
+
for (const dangerous of REDOS_DANGEROUS_PATTERNS) {
|
|
122
|
+
if (dangerous.test(pattern)) return false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Verify it's valid regex
|
|
126
|
+
try {
|
|
127
|
+
new RegExp(pattern);
|
|
128
|
+
return true;
|
|
129
|
+
} catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Built-in Secret Patterns ────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
const SECRET_PATTERNS: ResponsePattern[] = [
|
|
137
|
+
// ── API Keys & Tokens ──
|
|
138
|
+
{
|
|
139
|
+
name: "aws-access-key",
|
|
140
|
+
pattern: "(?:^|[^A-Za-z0-9])AKIA[0-9A-Z]{16}(?:[^A-Za-z0-9]|$)",
|
|
141
|
+
action: "redact",
|
|
142
|
+
message: "AWS Access Key ID detected in response",
|
|
143
|
+
category: "secrets",
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: "aws-secret-key",
|
|
147
|
+
pattern: "(?:aws_secret_access_key|aws_secret_key|secret_access_key)\\s*[=:]\\s*[A-Za-z0-9/+=]{40}",
|
|
148
|
+
flags: "gi",
|
|
149
|
+
action: "redact",
|
|
150
|
+
message: "AWS Secret Access Key detected in response",
|
|
151
|
+
category: "secrets",
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
name: "github-token",
|
|
155
|
+
pattern: "(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,255}",
|
|
156
|
+
action: "redact",
|
|
157
|
+
message: "GitHub token detected in response",
|
|
158
|
+
category: "secrets",
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: "openai-api-key",
|
|
162
|
+
pattern: "sk-[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20}",
|
|
163
|
+
action: "redact",
|
|
164
|
+
message: "OpenAI API key detected in response",
|
|
165
|
+
category: "secrets",
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
name: "generic-api-key",
|
|
169
|
+
pattern: "(?:api[_-]?key|apikey|api[_-]?secret)\\s*[=:]+\\s*[\"']?[A-Za-z0-9_\\-]{20,}",
|
|
170
|
+
flags: "gi",
|
|
171
|
+
action: "redact",
|
|
172
|
+
message: "Generic API key detected in response",
|
|
173
|
+
category: "secrets",
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
name: "bearer-token",
|
|
177
|
+
pattern: "Bearer\\s+[A-Za-z0-9\\-._~+/]+=*",
|
|
178
|
+
flags: "gi",
|
|
179
|
+
action: "redact",
|
|
180
|
+
message: "Bearer token detected in response",
|
|
181
|
+
category: "secrets",
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
name: "jwt-token",
|
|
185
|
+
pattern: "eyJ[A-Za-z0-9_-]{10,}\\.eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_\\-]{10,}",
|
|
186
|
+
action: "redact",
|
|
187
|
+
message: "JWT token detected in response",
|
|
188
|
+
category: "secrets",
|
|
189
|
+
},
|
|
190
|
+
// ── Private Keys & Certificates ──
|
|
191
|
+
{
|
|
192
|
+
name: "private-key",
|
|
193
|
+
pattern: "-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----",
|
|
194
|
+
action: "block",
|
|
195
|
+
message: "Private key detected in response — blocking entirely",
|
|
196
|
+
category: "secrets",
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
name: "certificate",
|
|
200
|
+
pattern: "-----BEGIN CERTIFICATE-----",
|
|
201
|
+
action: "redact",
|
|
202
|
+
message: "Certificate detected in response",
|
|
203
|
+
category: "secrets",
|
|
204
|
+
},
|
|
205
|
+
// ── Connection Strings ──
|
|
206
|
+
{
|
|
207
|
+
name: "database-url",
|
|
208
|
+
pattern: "(?:postgres|mysql|mongodb|redis|amqp)://[^\\s\"']+:[^\\s\"']+@[^\\s\"']+",
|
|
209
|
+
flags: "gi",
|
|
210
|
+
action: "redact",
|
|
211
|
+
message: "Database connection string with credentials detected",
|
|
212
|
+
category: "secrets",
|
|
213
|
+
},
|
|
214
|
+
// ── Password Patterns ──
|
|
215
|
+
{
|
|
216
|
+
name: "password-assignment",
|
|
217
|
+
pattern: "(?:password|passwd|pwd)\\s*[=:]\\s*[\"']?[^\\s\"']{8,}",
|
|
218
|
+
flags: "gi",
|
|
219
|
+
action: "redact",
|
|
220
|
+
message: "Password assignment detected in response",
|
|
221
|
+
category: "secrets",
|
|
222
|
+
},
|
|
223
|
+
];
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Exfiltration marker patterns. Base64 action is configurable.
|
|
227
|
+
*/
|
|
228
|
+
function getExfiltrationPatterns(base64Action: ResponseAction): ResponsePattern[] {
|
|
229
|
+
return [
|
|
230
|
+
{
|
|
231
|
+
name: "large-base64-blob",
|
|
232
|
+
pattern: "(?:[A-Za-z0-9+/]{100,}={0,2})",
|
|
233
|
+
action: base64Action,
|
|
234
|
+
message: "Large base64-encoded blob detected",
|
|
235
|
+
category: "exfiltration",
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
name: "hex-dump",
|
|
239
|
+
pattern: "(?:[0-9a-f]{2}[:\\s]){32,}",
|
|
240
|
+
flags: "gi",
|
|
241
|
+
action: "pass",
|
|
242
|
+
message: "Large hex dump detected (informational)",
|
|
243
|
+
category: "exfiltration",
|
|
244
|
+
},
|
|
245
|
+
];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const PII_PATTERNS: ResponsePattern[] = [
|
|
249
|
+
{
|
|
250
|
+
name: "email-address",
|
|
251
|
+
pattern: "[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}",
|
|
252
|
+
action: "redact",
|
|
253
|
+
message: "Email address detected in response",
|
|
254
|
+
category: "pii",
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
name: "phone-number",
|
|
258
|
+
pattern: "(?:\\+?1[\\s.-]?)?\\(?[0-9]{3}\\)?[\\s.-]?[0-9]{3}[\\s.-]?[0-9]{4}",
|
|
259
|
+
action: "redact",
|
|
260
|
+
message: "Phone number detected in response",
|
|
261
|
+
category: "pii",
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
name: "ssn",
|
|
265
|
+
pattern: "\\b\\d{3}-\\d{2}-\\d{4}\\b",
|
|
266
|
+
action: "block",
|
|
267
|
+
message: "Social Security Number detected — blocking response",
|
|
268
|
+
category: "pii",
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
name: "credit-card",
|
|
272
|
+
pattern: "\\b(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|3[47][0-9]{13}|6(?:011|5[0-9]{2})[0-9]{12})\\b",
|
|
273
|
+
action: "block",
|
|
274
|
+
message: "Credit card number detected — blocking response",
|
|
275
|
+
category: "pii",
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
name: "ip-address",
|
|
279
|
+
pattern: "\\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\b",
|
|
280
|
+
action: "pass",
|
|
281
|
+
message: "IP address detected (informational)",
|
|
282
|
+
category: "pii",
|
|
283
|
+
},
|
|
284
|
+
];
|
|
285
|
+
|
|
286
|
+
// ── Response Scanner Engine ─────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
export class ResponseScanner {
|
|
289
|
+
private config: Required<Pick<ResponseScannerConfig, "enabled" | "maxResponseSize" | "oversizeAction" | "detectSecrets" | "detectPII" | "base64Action" | "maxPatterns">> & { patterns: ResponsePattern[] };
|
|
290
|
+
private compiledPatterns: Array<{
|
|
291
|
+
pattern: ResponsePattern;
|
|
292
|
+
regex: RegExp;
|
|
293
|
+
}> = [];
|
|
294
|
+
private rejectedPatterns: string[] = [];
|
|
295
|
+
|
|
296
|
+
constructor(config: ResponseScannerConfig = {}) {
|
|
297
|
+
this.config = {
|
|
298
|
+
enabled: config.enabled ?? true,
|
|
299
|
+
maxResponseSize: config.maxResponseSize ?? 0,
|
|
300
|
+
oversizeAction: config.oversizeAction ?? "redact",
|
|
301
|
+
detectSecrets: config.detectSecrets ?? true,
|
|
302
|
+
detectPII: config.detectPII ?? false,
|
|
303
|
+
base64Action: config.base64Action ?? "pass",
|
|
304
|
+
maxPatterns: config.maxPatterns ?? DEFAULT_MAX_PATTERNS,
|
|
305
|
+
patterns: config.patterns ?? [],
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
this.compilePatterns();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Compile all regex patterns upfront for performance.
|
|
313
|
+
* Validates each pattern for ReDoS safety before compilation.
|
|
314
|
+
*/
|
|
315
|
+
private compilePatterns(): void {
|
|
316
|
+
this.compiledPatterns = [];
|
|
317
|
+
this.rejectedPatterns = [];
|
|
318
|
+
|
|
319
|
+
// Built-in secret patterns (pre-validated, always safe)
|
|
320
|
+
if (this.config.detectSecrets) {
|
|
321
|
+
for (const p of SECRET_PATTERNS) {
|
|
322
|
+
this.safeCompile(p, true);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Exfiltration patterns with configurable base64 action
|
|
327
|
+
if (this.config.detectSecrets) {
|
|
328
|
+
for (const p of getExfiltrationPatterns(this.config.base64Action)) {
|
|
329
|
+
this.safeCompile(p, true);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Built-in PII patterns (pre-validated, always safe)
|
|
334
|
+
if (this.config.detectPII) {
|
|
335
|
+
for (const p of PII_PATTERNS) {
|
|
336
|
+
this.safeCompile(p, true);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// User-defined patterns — enforce max count and ReDoS validation
|
|
341
|
+
const userPatterns = this.config.patterns ?? [];
|
|
342
|
+
const maxAllowed = this.config.maxPatterns;
|
|
343
|
+
const limited = userPatterns.slice(0, maxAllowed);
|
|
344
|
+
|
|
345
|
+
if (userPatterns.length > maxAllowed) {
|
|
346
|
+
this.rejectedPatterns.push(
|
|
347
|
+
`${userPatterns.length - maxAllowed} patterns exceeded max limit of ${maxAllowed}`
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
for (const p of limited) {
|
|
352
|
+
this.safeCompile(p, false);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Safely compile a pattern. For user patterns, validate ReDoS safety first.
|
|
358
|
+
*/
|
|
359
|
+
private safeCompile(pattern: ResponsePattern, trusted: boolean): void {
|
|
360
|
+
// ReDoS validation for untrusted (user) patterns
|
|
361
|
+
if (!trusted) {
|
|
362
|
+
if (!isRegexSafe(pattern.pattern)) {
|
|
363
|
+
this.rejectedPatterns.push(
|
|
364
|
+
`Pattern "${pattern.name}" rejected: potentially unsafe regex (ReDoS risk)`
|
|
365
|
+
);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
try {
|
|
371
|
+
const regex = new RegExp(pattern.pattern, pattern.flags ?? "gi");
|
|
372
|
+
this.compiledPatterns.push({ pattern, regex });
|
|
373
|
+
} catch {
|
|
374
|
+
this.rejectedPatterns.push(
|
|
375
|
+
`Pattern "${pattern.name}" rejected: invalid regex`
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Scan a response text for sensitive content.
|
|
382
|
+
*/
|
|
383
|
+
scan(text: string): ScanResult {
|
|
384
|
+
if (!this.config.enabled) {
|
|
385
|
+
return {
|
|
386
|
+
clean: true,
|
|
387
|
+
action: "pass",
|
|
388
|
+
findings: [],
|
|
389
|
+
originalSize: Buffer.byteLength(text, "utf-8"),
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const originalSize = Buffer.byteLength(text, "utf-8");
|
|
394
|
+
const findings: ScanFinding[] = [];
|
|
395
|
+
|
|
396
|
+
// 1. Check response size
|
|
397
|
+
if (this.config.maxResponseSize && this.config.maxResponseSize > 0) {
|
|
398
|
+
if (originalSize > this.config.maxResponseSize) {
|
|
399
|
+
findings.push({
|
|
400
|
+
pattern: "__oversize__",
|
|
401
|
+
category: "size",
|
|
402
|
+
action: this.config.oversizeAction ?? "redact",
|
|
403
|
+
message: `Response size (${originalSize} bytes) exceeds limit (${this.config.maxResponseSize} bytes)`,
|
|
404
|
+
matchCount: 1,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// 2. Run all compiled patterns against the text
|
|
410
|
+
for (const { pattern, regex } of this.compiledPatterns) {
|
|
411
|
+
// Reset lastIndex for stateful regexes
|
|
412
|
+
regex.lastIndex = 0;
|
|
413
|
+
const matches = text.match(regex);
|
|
414
|
+
|
|
415
|
+
if (matches && matches.length > 0) {
|
|
416
|
+
findings.push({
|
|
417
|
+
pattern: pattern.name,
|
|
418
|
+
category: pattern.category ?? "custom",
|
|
419
|
+
action: pattern.action,
|
|
420
|
+
message: pattern.message ?? `Pattern "${pattern.name}" matched`,
|
|
421
|
+
matchCount: matches.length,
|
|
422
|
+
preview: this.createPreview(matches[0]),
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (findings.length === 0) {
|
|
428
|
+
return { clean: true, action: "pass", findings: [], originalSize };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// 3. Determine overall action (highest severity wins)
|
|
432
|
+
const overallAction = this.resolveAction(findings);
|
|
433
|
+
|
|
434
|
+
// 4. Build result
|
|
435
|
+
const result: ScanResult = {
|
|
436
|
+
clean: false,
|
|
437
|
+
action: overallAction,
|
|
438
|
+
findings,
|
|
439
|
+
originalSize,
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
// 5. Produce redacted text if action is "redact"
|
|
443
|
+
if (overallAction === "redact") {
|
|
444
|
+
result.redactedText = this.redactText(text, findings);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return result;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Scan the content array from an MCP tools/call response.
|
|
452
|
+
* MCP responses have the shape: { content: [{ type: "text", text: "..." }, ...] }
|
|
453
|
+
*/
|
|
454
|
+
scanMcpResponse(result: unknown): ScanResult {
|
|
455
|
+
const text = this.extractText(result);
|
|
456
|
+
return this.scan(text);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Extract all text content from an MCP response result.
|
|
461
|
+
*/
|
|
462
|
+
private extractText(result: unknown): string {
|
|
463
|
+
if (typeof result === "string") return result;
|
|
464
|
+
if (!result || typeof result !== "object") return "";
|
|
465
|
+
|
|
466
|
+
const obj = result as Record<string, unknown>;
|
|
467
|
+
|
|
468
|
+
// MCP standard: { content: [{ type: "text", text: "..." }] }
|
|
469
|
+
if (Array.isArray(obj.content)) {
|
|
470
|
+
return obj.content
|
|
471
|
+
.filter((c: any) => c?.type === "text" && typeof c?.text === "string")
|
|
472
|
+
.map((c: any) => c.text)
|
|
473
|
+
.join("\n");
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Fallback: stringify the result
|
|
477
|
+
try {
|
|
478
|
+
return JSON.stringify(result);
|
|
479
|
+
} catch {
|
|
480
|
+
return "";
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Resolve the highest-severity action from all findings.
|
|
486
|
+
* Priority: block > redact > pass
|
|
487
|
+
*/
|
|
488
|
+
private resolveAction(findings: ScanFinding[]): ResponseAction {
|
|
489
|
+
let highest: ResponseAction = "pass";
|
|
490
|
+
for (const f of findings) {
|
|
491
|
+
if (f.action === "block") return "block";
|
|
492
|
+
if (f.action === "redact") highest = "redact";
|
|
493
|
+
}
|
|
494
|
+
return highest;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Redact matched patterns from the text.
|
|
499
|
+
* Uses generic [REDACTED] marker — never leaks pattern names.
|
|
500
|
+
*/
|
|
501
|
+
private redactText(text: string, findings: ScanFinding[]): string {
|
|
502
|
+
let redacted = text;
|
|
503
|
+
|
|
504
|
+
// Handle oversize: truncate
|
|
505
|
+
if (this.config.maxResponseSize && this.config.maxResponseSize > 0) {
|
|
506
|
+
const sizeExceeded = findings.some((f) => f.pattern === "__oversize__");
|
|
507
|
+
if (sizeExceeded) {
|
|
508
|
+
const limit = this.config.maxResponseSize;
|
|
509
|
+
redacted = redacted.slice(0, limit) + "\n\n[Agent Wall: Response truncated — exceeded size limit]";
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Redact each finding's pattern matches
|
|
514
|
+
for (const { pattern, regex } of this.compiledPatterns) {
|
|
515
|
+
const finding = findings.find((f) => f.pattern === pattern.name);
|
|
516
|
+
if (!finding || finding.action !== "redact") continue;
|
|
517
|
+
|
|
518
|
+
regex.lastIndex = 0;
|
|
519
|
+
redacted = redacted.replace(regex, `[REDACTED]`);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
return redacted;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Create a safe preview of matched content (first 4 chars + last 4).
|
|
527
|
+
*/
|
|
528
|
+
private createPreview(match: string): string {
|
|
529
|
+
if (match.length <= 8) return "***";
|
|
530
|
+
const start = match.slice(0, 4);
|
|
531
|
+
const end = match.slice(-4);
|
|
532
|
+
return `${start}...${end}`;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Get the current configuration.
|
|
537
|
+
*/
|
|
538
|
+
getConfig(): ResponseScannerConfig {
|
|
539
|
+
return { ...this.config };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Get the number of compiled patterns.
|
|
544
|
+
*/
|
|
545
|
+
getPatternCount(): number {
|
|
546
|
+
return this.compiledPatterns.length;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Get list of patterns that were rejected during compilation.
|
|
551
|
+
*/
|
|
552
|
+
getRejectedPatterns(): string[] {
|
|
553
|
+
return [...this.rejectedPatterns];
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Update configuration (e.g., after policy file reload).
|
|
558
|
+
*/
|
|
559
|
+
updateConfig(config: ResponseScannerConfig): void {
|
|
560
|
+
this.config = {
|
|
561
|
+
enabled: config.enabled ?? true,
|
|
562
|
+
maxResponseSize: config.maxResponseSize ?? 0,
|
|
563
|
+
oversizeAction: config.oversizeAction ?? "redact",
|
|
564
|
+
detectSecrets: config.detectSecrets ?? true,
|
|
565
|
+
detectPII: config.detectPII ?? false,
|
|
566
|
+
base64Action: config.base64Action ?? "pass",
|
|
567
|
+
maxPatterns: config.maxPatterns ?? DEFAULT_MAX_PATTERNS,
|
|
568
|
+
patterns: config.patterns ?? [],
|
|
569
|
+
};
|
|
570
|
+
this.compilePatterns();
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// ── Convenience: Default scanner ────────────────────────────────────
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Create a scanner with sensible defaults (secrets ON, PII OFF).
|
|
578
|
+
*/
|
|
579
|
+
export function createDefaultScanner(): ResponseScanner {
|
|
580
|
+
return new ResponseScanner({
|
|
581
|
+
enabled: true,
|
|
582
|
+
maxResponseSize: 5 * 1024 * 1024, // 5MB
|
|
583
|
+
oversizeAction: "redact",
|
|
584
|
+
detectSecrets: true,
|
|
585
|
+
detectPII: false,
|
|
586
|
+
});
|
|
587
|
+
}
|