contextguard 0.1.1

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,527 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Copyright (c) 2025 Amir Mironi
5
+ *
6
+ * This source code is licensed under the MIT license found in the
7
+ * LICENSE file in the root directory of this source tree.
8
+ */
9
+
10
+ import { spawn, ChildProcess } from "child_process";
11
+ import * as fs from "fs";
12
+ import { createHash } from "crypto";
13
+
14
+ interface SecurityConfig {
15
+ maxToolCallsPerMinute?: number;
16
+ blockedPatterns?: string[];
17
+ allowedFilePaths?: string[];
18
+ alertThreshold?: number;
19
+ enablePromptInjectionDetection?: boolean;
20
+ enableSensitiveDataDetection?: boolean;
21
+ }
22
+
23
+ interface SecurityEvent {
24
+ timestamp: string;
25
+ eventType: string;
26
+ severity: "LOW" | "MEDIUM" | "HIGH" | "CRITICAL";
27
+ details: Record<string, unknown>;
28
+ sessionId: string;
29
+ }
30
+
31
+ interface MCPMessage {
32
+ jsonrpc: string;
33
+ id?: string | number;
34
+ method?: string;
35
+ params?: {
36
+ name?: string;
37
+ arguments?: Record<string, string>;
38
+ path?: string;
39
+ filePath?: string;
40
+ [key: string]: unknown;
41
+ };
42
+ result?: unknown;
43
+ error?: unknown;
44
+ }
45
+
46
+ class SecurityPolicy {
47
+ private config: SecurityConfig;
48
+ private sensitiveDataPatterns: RegExp[];
49
+ private promptInjectionPatterns: RegExp[];
50
+
51
+ constructor(config: SecurityConfig) {
52
+ this.config = {
53
+ maxToolCallsPerMinute: 30,
54
+ blockedPatterns: [],
55
+ allowedFilePaths: [],
56
+ alertThreshold: 5,
57
+ enablePromptInjectionDetection: true,
58
+ enableSensitiveDataDetection: true,
59
+ ...config,
60
+ };
61
+
62
+ // Sensitive data patterns
63
+ this.sensitiveDataPatterns = [
64
+ /(?:password|secret|api[_-]?key|token)\s*[:=]\s*['"]?[\w\-.]+['"]?/gi,
65
+ /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, // Email
66
+ /\b\d{3}-\d{2}-\d{4}\b/g, // SSN
67
+ /sk-[a-zA-Z0-9]{48}/g, // OpenAI API keys
68
+ /ghp_[a-zA-Z0-9]{36}/g, // GitHub tokens
69
+ /AKIA[0-9A-Z]{16}/g, // AWS Access Keys
70
+ ];
71
+
72
+ // Prompt injection patterns
73
+ this.promptInjectionPatterns = [
74
+ /ignore\s+(previous|all)\s+(instructions|prompts)/gi,
75
+ /system:\s*you\s+are\s+now/gi,
76
+ /forget\s+(everything|all)/gi,
77
+ /new\s+instructions:/gi,
78
+ /\[INST\].*?\[\/INST\]/gs,
79
+ /<\|im_start\|>/g,
80
+ /disregard\s+previous/gi,
81
+ /override\s+previous/gi,
82
+ ];
83
+ }
84
+
85
+ checkPromptInjection(text: string): string[] {
86
+ if (!this.config.enablePromptInjectionDetection) return [];
87
+ const violations: string[] = [];
88
+ for (const pattern of this.promptInjectionPatterns) {
89
+ const matches = text.match(pattern);
90
+ if (matches) {
91
+ violations.push(
92
+ `Potential prompt injection detected: "${matches[0].substring(
93
+ 0,
94
+ 50
95
+ )}..."`
96
+ );
97
+ }
98
+ }
99
+ return violations;
100
+ }
101
+
102
+ checkSensitiveData(text: string): string[] {
103
+ if (!this.config.enableSensitiveDataDetection) return [];
104
+ const violations: string[] = [];
105
+ for (const pattern of this.sensitiveDataPatterns) {
106
+ const matches = text.match(pattern);
107
+ if (matches) {
108
+ violations.push(
109
+ `Sensitive data pattern detected (redacted): ${pattern.source.substring(
110
+ 0,
111
+ 30
112
+ )}...`
113
+ );
114
+ }
115
+ }
116
+ return violations;
117
+ }
118
+
119
+ checkFileAccess(filePath: string): string[] {
120
+ const violations: string[] = [];
121
+ if (filePath.includes("..")) {
122
+ violations.push(`Path traversal attempt detected: ${filePath}`);
123
+ }
124
+ const dangerousPaths = [
125
+ "/etc",
126
+ "/root",
127
+ "/sys",
128
+ "/proc",
129
+ "C:\\Windows\\System32",
130
+ ];
131
+ if (dangerousPaths.some((dangerous) => filePath.startsWith(dangerous))) {
132
+ violations.push(`Access to dangerous path detected: ${filePath}`);
133
+ }
134
+ if (
135
+ this.config.allowedFilePaths &&
136
+ this.config.allowedFilePaths.length > 0
137
+ ) {
138
+ const isAllowed = this.config.allowedFilePaths.some((allowed) =>
139
+ filePath.startsWith(allowed)
140
+ );
141
+ if (!isAllowed) {
142
+ violations.push(`File path not in allowed list: ${filePath}`);
143
+ }
144
+ }
145
+ return violations;
146
+ }
147
+
148
+ checkRateLimit(timestamps: number[]): boolean {
149
+ const oneMinuteAgo = Date.now() - 60000;
150
+ const recentCalls = timestamps.filter((t) => t > oneMinuteAgo);
151
+ return recentCalls.length < (this.config.maxToolCallsPerMinute || 30);
152
+ }
153
+ }
154
+
155
+ class SecurityLogger {
156
+ private logFile: string;
157
+ private events: SecurityEvent[] = [];
158
+
159
+ constructor(logFile: string = "mcp_security.log") {
160
+ this.logFile = logFile;
161
+ }
162
+
163
+ logEvent(
164
+ eventType: string,
165
+ severity: SecurityEvent["severity"],
166
+ details: Record<string, unknown>,
167
+ sessionId: string
168
+ ): void {
169
+ const event: SecurityEvent = {
170
+ timestamp: new Date().toISOString(),
171
+ eventType,
172
+ severity,
173
+ details,
174
+ sessionId,
175
+ };
176
+ this.events.push(event);
177
+ fs.appendFileSync(this.logFile, JSON.stringify(event) + "\n");
178
+ if (severity === "HIGH" || severity === "CRITICAL") {
179
+ console.error(
180
+ `[SECURITY ALERT] ${eventType}: ${JSON.stringify(details)}`
181
+ );
182
+ }
183
+ }
184
+
185
+ getStatistics(): Record<string, unknown> {
186
+ return {
187
+ totalEvents: this.events.length,
188
+ eventsByType: this.countByField("eventType"),
189
+ eventsBySeverity: this.countByField("severity"),
190
+ recentEvents: this.events.slice(-10),
191
+ };
192
+ }
193
+
194
+ private countByField(field: keyof SecurityEvent): Record<string, number> {
195
+ const counts: Record<string, number> = {};
196
+ for (const event of this.events) {
197
+ const value = String(event[field]);
198
+ counts[value] = (counts[value] || 0) + 1;
199
+ }
200
+ return counts;
201
+ }
202
+ }
203
+
204
+ class MCPSecurityWrapper {
205
+ private serverCommand: string[];
206
+ private policy: SecurityPolicy;
207
+ private logger: SecurityLogger;
208
+ private process: ChildProcess | null = null;
209
+ private toolCallTimestamps: number[] = [];
210
+ private sessionId: string;
211
+ private clientMessageBuffer: string = "";
212
+ private serverMessageBuffer: string = "";
213
+
214
+ constructor(
215
+ serverCommand: string[],
216
+ policy: SecurityPolicy,
217
+ logger: SecurityLogger
218
+ ) {
219
+ this.serverCommand = serverCommand;
220
+ this.policy = policy;
221
+ this.logger = logger;
222
+ this.sessionId = createHash("md5")
223
+ .update(Date.now().toString())
224
+ .digest("hex")
225
+ .substring(0, 8);
226
+ }
227
+
228
+ async start(): Promise<void> {
229
+ this.process = spawn(this.serverCommand[0], this.serverCommand.slice(1), {
230
+ stdio: ["pipe", "pipe", "pipe"],
231
+ });
232
+
233
+ if (!this.process.stdout || !this.process.stdin || !this.process.stderr) {
234
+ throw new Error("Failed to create child process streams");
235
+ }
236
+
237
+ this.logger.logEvent(
238
+ "SERVER_START",
239
+ "LOW",
240
+ {
241
+ command: this.serverCommand.join(" "),
242
+ pid: this.process.pid,
243
+ },
244
+ this.sessionId
245
+ );
246
+
247
+ this.process.stderr.pipe(process.stderr);
248
+
249
+ this.process.stdout.on("data", (data: Buffer) => {
250
+ const output = data.toString();
251
+ this.handleServerOutput(output);
252
+ });
253
+
254
+ process.stdin.on("data", (data: Buffer) => {
255
+ const input = data.toString();
256
+ this.handleClientInput(input);
257
+ });
258
+
259
+ this.process.on("exit", (code) => {
260
+ this.logger.logEvent(
261
+ "SERVER_EXIT",
262
+ "MEDIUM",
263
+ {
264
+ exitCode: code,
265
+ },
266
+ this.sessionId
267
+ );
268
+ console.error("\n=== MCP Security Statistics ===");
269
+ console.error(JSON.stringify(this.logger.getStatistics(), null, 2));
270
+ // Use setImmediate to allow pending I/O to complete
271
+ setImmediate(() => {
272
+ process.exit(code || 0);
273
+ });
274
+ });
275
+
276
+ this.process.on("error", (err) => {
277
+ this.logger.logEvent(
278
+ "SERVER_ERROR",
279
+ "HIGH",
280
+ {
281
+ error: err.message,
282
+ },
283
+ this.sessionId
284
+ );
285
+ console.error("Server process error:", err);
286
+ });
287
+ }
288
+
289
+ private handleClientInput(input: string): void {
290
+ this.clientMessageBuffer += input;
291
+ const lines = this.clientMessageBuffer.split("\n");
292
+ this.clientMessageBuffer = lines.pop() || "";
293
+ for (const line of lines) {
294
+ if (line.trim()) {
295
+ this.processClientMessage(line);
296
+ }
297
+ }
298
+ }
299
+
300
+ private processClientMessage(line: string): void {
301
+ try {
302
+ const message: MCPMessage = JSON.parse(line);
303
+ this.logger.logEvent(
304
+ "CLIENT_REQUEST",
305
+ "LOW",
306
+ {
307
+ method: message.method,
308
+ id: message.id,
309
+ },
310
+ this.sessionId
311
+ );
312
+
313
+ const violations: string[] = [];
314
+ let shouldBlock = false;
315
+
316
+ if (message.method === "tools/call") {
317
+ const now = Date.now();
318
+ this.toolCallTimestamps.push(now);
319
+
320
+ // Clean up old timestamps to prevent memory leak
321
+ const oneMinuteAgo = now - 60000;
322
+ this.toolCallTimestamps = this.toolCallTimestamps.filter(
323
+ (t) => t > oneMinuteAgo
324
+ );
325
+
326
+ if (!this.policy.checkRateLimit(this.toolCallTimestamps)) {
327
+ violations.push("Rate limit exceeded for tool calls");
328
+ shouldBlock = true;
329
+ this.logger.logEvent(
330
+ "RATE_LIMIT_EXCEEDED",
331
+ "HIGH",
332
+ {
333
+ method: message.method,
334
+ toolName: message.params?.name,
335
+ },
336
+ this.sessionId
337
+ );
338
+ }
339
+
340
+ const paramsStr = JSON.stringify(message.params);
341
+ const injectionViolations = this.policy.checkPromptInjection(paramsStr);
342
+ violations.push(...injectionViolations);
343
+ const sensitiveViolations = this.policy.checkSensitiveData(paramsStr);
344
+ violations.push(...sensitiveViolations);
345
+
346
+ // Check multiple possible file path parameter locations
347
+ const filePathParams = [
348
+ message.params?.arguments?.path,
349
+ message.params?.arguments?.filePath,
350
+ message.params?.arguments?.file,
351
+ message.params?.arguments?.directory,
352
+ message.params?.path,
353
+ message.params?.filePath,
354
+ ].filter((path): path is string => typeof path === 'string');
355
+
356
+ for (const filePath of filePathParams) {
357
+ const fileViolations = this.policy.checkFileAccess(filePath);
358
+ violations.push(...fileViolations);
359
+ }
360
+
361
+ this.logger.logEvent(
362
+ "TOOL_CALL",
363
+ violations.length > 0 ? "HIGH" : "LOW",
364
+ {
365
+ toolName: message.params?.name,
366
+ hasViolations: violations.length > 0,
367
+ violations,
368
+ },
369
+ this.sessionId
370
+ );
371
+ }
372
+
373
+ if (violations.length > 0) {
374
+ this.logger.logEvent(
375
+ "SECURITY_VIOLATION",
376
+ "CRITICAL",
377
+ {
378
+ violations,
379
+ message: message,
380
+ blocked: shouldBlock,
381
+ },
382
+ this.sessionId
383
+ );
384
+ console.error(
385
+ `\n⚠️ SECURITY VIOLATIONS DETECTED:\n${violations.join("\n")}\n`
386
+ );
387
+
388
+ if (shouldBlock) {
389
+ console.error("🚫 REQUEST BLOCKED\n");
390
+ // Send error response back to client
391
+ if (message.id !== undefined) {
392
+ const errorResponse: MCPMessage = {
393
+ jsonrpc: message.jsonrpc,
394
+ id: message.id,
395
+ error: {
396
+ code: -32000,
397
+ message: "Security violation: Request blocked",
398
+ data: { violations },
399
+ },
400
+ };
401
+ process.stdout.write(JSON.stringify(errorResponse) + "\n");
402
+ }
403
+ return; // Don't forward to server
404
+ }
405
+ }
406
+
407
+ if (this.process && this.process.stdin) {
408
+ this.process.stdin.write(line + "\n");
409
+ }
410
+ } catch (err) {
411
+ this.logger.logEvent(
412
+ "PARSE_ERROR",
413
+ "MEDIUM",
414
+ {
415
+ error: err instanceof Error ? err.message : String(err),
416
+ line: line.substring(0, 100),
417
+ },
418
+ this.sessionId
419
+ );
420
+ console.error(`Failed to parse client message: ${err}`);
421
+ // Forward unparseable messages
422
+ if (this.process && this.process.stdin) {
423
+ this.process.stdin.write(line + "\n");
424
+ }
425
+ }
426
+ }
427
+
428
+ private handleServerOutput(output: string): void {
429
+ // Forward output immediately
430
+ process.stdout.write(output);
431
+
432
+ // Buffer and parse for logging
433
+ this.serverMessageBuffer += output;
434
+ const lines = this.serverMessageBuffer.split("\n");
435
+ this.serverMessageBuffer = lines.pop() || "";
436
+
437
+ for (const line of lines) {
438
+ if (line.trim()) {
439
+ try {
440
+ const message: MCPMessage = JSON.parse(line);
441
+ this.logger.logEvent(
442
+ "SERVER_RESPONSE",
443
+ "LOW",
444
+ {
445
+ id: message.id,
446
+ hasError: !!message.error,
447
+ },
448
+ this.sessionId
449
+ );
450
+ } catch (err) {
451
+ // Log parse errors for server output
452
+ this.logger.logEvent(
453
+ "SERVER_PARSE_ERROR",
454
+ "LOW",
455
+ {
456
+ error: err instanceof Error ? err.message : String(err),
457
+ line: line.substring(0, 100),
458
+ },
459
+ this.sessionId
460
+ );
461
+ }
462
+ }
463
+ }
464
+ }
465
+ }
466
+
467
+ async function main() {
468
+ const args = process.argv.slice(2);
469
+ if (args.length === 0 || args.includes("--help")) {
470
+ console.log(`
471
+ MCP Security Wrapper - MVP
472
+
473
+ Usage:
474
+ npx ts-node mcp-security-wrapper.ts --server "node server.js" [--config security.json]
475
+
476
+ Options:
477
+ --server <command> Command to start the MCP server (required)
478
+ --config <file> Path to security config JSON file (optional)
479
+ --help Show this help message
480
+
481
+ Example:
482
+ npx ts-node mcp-security-wrapper.ts --server "node server.js" --config security.json
483
+ `);
484
+ process.exit(0);
485
+ }
486
+
487
+ let serverCommand: string = "";
488
+ let configFile: string = "";
489
+
490
+ for (let i = 0; i < args.length; i++) {
491
+ if (args[i] === "--server" && args[i + 1]) {
492
+ serverCommand = args[i + 1];
493
+ i++;
494
+ } else if (args[i] === "--config" && args[i + 1]) {
495
+ configFile = args[i + 1];
496
+ i++;
497
+ }
498
+ }
499
+
500
+ if (!serverCommand) {
501
+ console.error("Error: --server argument is required");
502
+ process.exit(1);
503
+ }
504
+
505
+ let config: SecurityConfig = {};
506
+ if (configFile && fs.existsSync(configFile)) {
507
+ config = JSON.parse(fs.readFileSync(configFile, "utf-8"));
508
+ }
509
+
510
+ const policy = new SecurityPolicy(config);
511
+ const logger = new SecurityLogger();
512
+ const wrapper = new MCPSecurityWrapper(
513
+ serverCommand.split(" "),
514
+ policy,
515
+ logger
516
+ );
517
+ await wrapper.start();
518
+ }
519
+
520
+ if (require.main === module) {
521
+ main().catch((err) => {
522
+ console.error("Fatal error:", err);
523
+ process.exit(1);
524
+ });
525
+ }
526
+
527
+ export { MCPSecurityWrapper, SecurityPolicy, SecurityLogger };