langprotect-mcp-gateway 1.2.5__py3-none-any.whl → 1.3.0__py3-none-any.whl
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.
- langprotect_mcp_gateway/gateway.py +322 -21
- langprotect_mcp_gateway/response_masker.py +323 -0
- {langprotect_mcp_gateway-1.2.5.dist-info → langprotect_mcp_gateway-1.3.0.dist-info}/METADATA +97 -81
- langprotect_mcp_gateway-1.3.0.dist-info/RECORD +10 -0
- langprotect_mcp_gateway-1.2.5.dist-info/RECORD +0 -9
- {langprotect_mcp_gateway-1.2.5.dist-info → langprotect_mcp_gateway-1.3.0.dist-info}/WHEEL +0 -0
- {langprotect_mcp_gateway-1.2.5.dist-info → langprotect_mcp_gateway-1.3.0.dist-info}/entry_points.txt +0 -0
- {langprotect_mcp_gateway-1.2.5.dist-info → langprotect_mcp_gateway-1.3.0.dist-info}/licenses/LICENSE +0 -0
- {langprotect_mcp_gateway-1.2.5.dist-info → langprotect_mcp_gateway-1.3.0.dist-info}/top_level.txt +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
3
|
LangProtect MCP Gateway - Security Gateway for MCP Servers
|
|
4
|
+
Enhanced with Pre-LLM Scanning and Response Masking
|
|
4
5
|
"""
|
|
5
6
|
|
|
6
7
|
import sys
|
|
@@ -13,6 +14,9 @@ from datetime import datetime, timedelta
|
|
|
13
14
|
from typing import Dict, List, Any, Optional
|
|
14
15
|
import logging
|
|
15
16
|
|
|
17
|
+
# Import response masker
|
|
18
|
+
from .response_masker import ResponseMasker, get_masker
|
|
19
|
+
|
|
16
20
|
log_level = os.environ.get("LOGLEVEL", "DEBUG" if os.getenv('DEBUG', 'false').lower() == 'true' else "INFO").upper()
|
|
17
21
|
logging.basicConfig(level=getattr(logging, log_level), format='[%(asctime)s] %(levelname)s: %(message)s', handlers=[logging.StreamHandler(sys.stderr)])
|
|
18
22
|
logger = logging.getLogger('langprotect-gateway')
|
|
@@ -97,12 +101,15 @@ class MCPServer:
|
|
|
97
101
|
|
|
98
102
|
|
|
99
103
|
class LangProtectAuth:
|
|
100
|
-
def __init__(self, url: str, email: str, password: str):
|
|
104
|
+
def __init__(self, url: str, email: str, password: str, scan_timeout: float = 5.0, fail_closed: bool = False):
|
|
101
105
|
self.url = url
|
|
102
106
|
self.email = email
|
|
103
107
|
self.password = password
|
|
104
108
|
self.jwt_token: Optional[str] = None
|
|
105
109
|
self.token_expiry: Optional[datetime] = None
|
|
110
|
+
self.scan_timeout = scan_timeout # Maximum wait time for scans
|
|
111
|
+
self.fail_closed = fail_closed # Block on scan failure if True
|
|
112
|
+
logger.info(f"Auth initialized: timeout={scan_timeout}s, fail_closed={fail_closed}")
|
|
106
113
|
|
|
107
114
|
def login(self) -> bool:
|
|
108
115
|
try:
|
|
@@ -126,26 +133,168 @@ class LangProtectAuth:
|
|
|
126
133
|
return self.login()
|
|
127
134
|
return True
|
|
128
135
|
|
|
129
|
-
def
|
|
136
|
+
def scan_input(self, tool_name: str, arguments: Dict, server_name: str) -> Dict:
|
|
137
|
+
"""
|
|
138
|
+
Scan user input BEFORE forwarding to MCP server (blocking scan).
|
|
139
|
+
Uses the new Group Scan API with policy-based scanning.
|
|
140
|
+
"""
|
|
130
141
|
self.ensure_token()
|
|
131
142
|
try:
|
|
132
|
-
|
|
133
|
-
|
|
143
|
+
# Convert tool call to prompt string for scanning
|
|
144
|
+
prompt = f"Tool: {tool_name}\nServer: {server_name}\nArguments: {json.dumps(arguments, indent=2)}"
|
|
145
|
+
|
|
146
|
+
payload = {
|
|
147
|
+
'prompt': prompt,
|
|
148
|
+
'metadata': {
|
|
149
|
+
'tool_name': tool_name,
|
|
150
|
+
'server_name': server_name,
|
|
151
|
+
'source': 'mcp-gateway-input',
|
|
152
|
+
'scan_type': 'input'
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
logger.debug(f"🛡️ INPUT SCAN: {tool_name} on {server_name}")
|
|
157
|
+
|
|
158
|
+
response = requests.post(
|
|
159
|
+
f"{self.url}/v1/group-logs/scan",
|
|
160
|
+
json=payload,
|
|
161
|
+
headers={
|
|
162
|
+
'Authorization': f'Bearer {self.jwt_token}',
|
|
163
|
+
'Content-Type': 'application/json'
|
|
164
|
+
},
|
|
165
|
+
timeout=self.scan_timeout
|
|
166
|
+
)
|
|
167
|
+
|
|
134
168
|
if response.status_code != 200:
|
|
135
|
-
logger.warning(f"Backend returned {response.status_code}
|
|
136
|
-
|
|
169
|
+
logger.warning(f"Backend returned {response.status_code}")
|
|
170
|
+
if self.fail_closed:
|
|
171
|
+
return {
|
|
172
|
+
'status': 'blocked',
|
|
173
|
+
'reason': f'Scan service unavailable (HTTP {response.status_code}) - fail-closed mode'
|
|
174
|
+
}
|
|
175
|
+
else:
|
|
176
|
+
logger.warning("Allowing request (fail-open mode)")
|
|
177
|
+
return {'status': 'allowed', 'error': f'Backend error: {response.status_code}'}
|
|
178
|
+
|
|
137
179
|
result = response.json()
|
|
138
|
-
|
|
180
|
+
|
|
181
|
+
# Handle scan service timeout - respect fail mode
|
|
139
182
|
if result.get('detections', {}).get('error') == 'Scan service timeout':
|
|
140
|
-
logger.warning("Scan service timeout
|
|
141
|
-
|
|
183
|
+
logger.warning("Scan service timeout detected")
|
|
184
|
+
if self.fail_closed:
|
|
185
|
+
return {
|
|
186
|
+
'status': 'blocked',
|
|
187
|
+
'reason': 'Scan service timeout - fail-closed mode'
|
|
188
|
+
}
|
|
189
|
+
else:
|
|
190
|
+
logger.warning("Allowing request despite timeout (fail-open)")
|
|
191
|
+
return {'status': 'allowed', 'id': result.get('id'), 'error': 'Scan timeout'}
|
|
192
|
+
|
|
142
193
|
return result
|
|
194
|
+
|
|
143
195
|
except requests.exceptions.Timeout:
|
|
144
|
-
logger.
|
|
145
|
-
|
|
196
|
+
logger.error(f"Backend scan timeout after {self.scan_timeout}s")
|
|
197
|
+
if self.fail_closed:
|
|
198
|
+
return {
|
|
199
|
+
'status': 'blocked',
|
|
200
|
+
'reason': f'Scan timeout after {self.scan_timeout}s - fail-closed mode'
|
|
201
|
+
}
|
|
202
|
+
else:
|
|
203
|
+
logger.warning("Allowing request despite timeout (fail-open)")
|
|
204
|
+
return {'status': 'allowed', 'error': 'Request timeout'}
|
|
205
|
+
|
|
146
206
|
except Exception as e:
|
|
147
207
|
logger.error(f"Scan error: {e}")
|
|
148
|
-
|
|
208
|
+
if self.fail_closed:
|
|
209
|
+
return {
|
|
210
|
+
'status': 'blocked',
|
|
211
|
+
'reason': f'Scan error: {str(e)} - fail-closed mode'
|
|
212
|
+
}
|
|
213
|
+
else:
|
|
214
|
+
logger.warning(f"Allowing request despite error (fail-open): {e}")
|
|
215
|
+
return {'status': 'allowed', 'error': str(e)}
|
|
216
|
+
|
|
217
|
+
def scan_output(self, tool_name: str, output_content: str, prompt: str = None, metadata: Dict = None) -> Dict:
|
|
218
|
+
"""
|
|
219
|
+
Scan LLM/MCP output AFTER receiving from server (non-blocking, masking scan).
|
|
220
|
+
Uses the new Group Scan API with output scanning support.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
tool_name: Name of the MCP tool that generated the output
|
|
224
|
+
output_content: The output text to scan for secrets
|
|
225
|
+
prompt: Original user prompt (optional)
|
|
226
|
+
metadata: Additional context (optional)
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
Scan result with masked_content field if secrets detected
|
|
230
|
+
"""
|
|
231
|
+
self.ensure_token()
|
|
232
|
+
try:
|
|
233
|
+
payload = {
|
|
234
|
+
'prompt': prompt or f"Tool: {tool_name}",
|
|
235
|
+
'output': output_content,
|
|
236
|
+
'metadata': {
|
|
237
|
+
'tool_name': tool_name,
|
|
238
|
+
'source': 'mcp-gateway-output',
|
|
239
|
+
'scan_type': 'output',
|
|
240
|
+
**(metadata or {})
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
logger.debug(f"🔍 OUTPUT SCAN: {tool_name} ({len(output_content)} chars)")
|
|
245
|
+
|
|
246
|
+
response = requests.post(
|
|
247
|
+
f"{self.url}/v1/group-logs/scan",
|
|
248
|
+
json=payload,
|
|
249
|
+
headers={
|
|
250
|
+
'Authorization': f'Bearer {self.jwt_token}',
|
|
251
|
+
'Content-Type': 'application/json'
|
|
252
|
+
},
|
|
253
|
+
timeout=self.scan_timeout
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
if response.status_code != 200:
|
|
257
|
+
logger.warning(f"Output scan failed: HTTP {response.status_code}")
|
|
258
|
+
# For output scanning, fail-open (don't block, return original)
|
|
259
|
+
return {
|
|
260
|
+
'status': 'allowed',
|
|
261
|
+
'output': output_content,
|
|
262
|
+
'masked': False,
|
|
263
|
+
'error': f'Scan failed: {response.status_code}'
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
result = response.json()
|
|
267
|
+
|
|
268
|
+
# Extract masked content from MCPResponseScanner details
|
|
269
|
+
mcp_response = result.get('detections', {}).get('MCPResponseScanner', {})
|
|
270
|
+
if mcp_response.get('is_detected'):
|
|
271
|
+
masked_content = mcp_response.get('details', {}).get('masked_content', output_content)
|
|
272
|
+
logger.warning(f"🔒 OUTPUT MASKED: {tool_name} (score={mcp_response.get('score')})")
|
|
273
|
+
return {
|
|
274
|
+
'status': result.get('status'),
|
|
275
|
+
'output': masked_content,
|
|
276
|
+
'masked': True,
|
|
277
|
+
'risk_score': result.get('risk_score'),
|
|
278
|
+
'scan_id': result.get('id'),
|
|
279
|
+
'detections': mcp_response.get('details', {}).get('detections', [])
|
|
280
|
+
}
|
|
281
|
+
else:
|
|
282
|
+
# No secrets detected, return original
|
|
283
|
+
return {
|
|
284
|
+
'status': 'safe',
|
|
285
|
+
'output': output_content,
|
|
286
|
+
'masked': False
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
except Exception as e:
|
|
290
|
+
logger.error(f"Output scan error: {e}")
|
|
291
|
+
# Fail-open for output scanning - return original content
|
|
292
|
+
return {
|
|
293
|
+
'status': 'allowed',
|
|
294
|
+
'output': output_content,
|
|
295
|
+
'masked': False,
|
|
296
|
+
'error': str(e)
|
|
297
|
+
}
|
|
149
298
|
|
|
150
299
|
|
|
151
300
|
class LangProtectGateway:
|
|
@@ -157,6 +306,12 @@ class LangProtectGateway:
|
|
|
157
306
|
self.email = os.getenv('LANGPROTECT_EMAIL')
|
|
158
307
|
self.password = os.getenv('LANGPROTECT_PASSWORD')
|
|
159
308
|
|
|
309
|
+
# Security configuration
|
|
310
|
+
self.scan_timeout = float(os.getenv('LANGPROTECT_SCAN_TIMEOUT', '5.0'))
|
|
311
|
+
self.fail_closed = os.getenv('LANGPROTECT_FAIL_CLOSED', 'false').lower() == 'true'
|
|
312
|
+
self.enable_masking = os.getenv('LANGPROTECT_ENABLE_MASKING', 'true').lower() == 'true'
|
|
313
|
+
self.enable_entropy_detection = os.getenv('LANGPROTECT_ENTROPY_DETECTION', 'true').lower() == 'true'
|
|
314
|
+
|
|
160
315
|
# Try to load credentials from mcp.json env section (like Lasso)
|
|
161
316
|
if mcp_json_path and (not self.email or not self.password):
|
|
162
317
|
self._load_env_from_config(mcp_json_path)
|
|
@@ -165,8 +320,21 @@ class LangProtectGateway:
|
|
|
165
320
|
self.mcp_servers: Dict[str, MCPServer] = {}
|
|
166
321
|
self.tool_to_server: Dict[str, str] = {}
|
|
167
322
|
self.all_tools: List[Dict] = []
|
|
323
|
+
|
|
324
|
+
# Initialize response masker
|
|
325
|
+
self.masker: Optional[ResponseMasker] = None
|
|
326
|
+
if self.enable_masking:
|
|
327
|
+
self.masker = get_masker(
|
|
328
|
+
enable_entropy=self.enable_entropy_detection,
|
|
329
|
+
entropy_threshold=4.5
|
|
330
|
+
)
|
|
331
|
+
logger.info("✅ Response masking ENABLED")
|
|
332
|
+
else:
|
|
333
|
+
logger.warning("⚠️ Response masking DISABLED")
|
|
334
|
+
|
|
168
335
|
logger.debug(f"LANGPROTECT_URL: {self.langprotect_url}")
|
|
169
336
|
logger.debug(f"LANGPROTECT_EMAIL: {self.email}")
|
|
337
|
+
logger.info(f"Security config: timeout={self.scan_timeout}s, fail_closed={self.fail_closed}, masking={self.enable_masking}")
|
|
170
338
|
|
|
171
339
|
def _load_env_from_config(self, path: str):
|
|
172
340
|
"""Load credentials from mcp.json env section (Lasso/VS Code style)"""
|
|
@@ -196,7 +364,13 @@ class LangProtectGateway:
|
|
|
196
364
|
|
|
197
365
|
def initialize(self) -> bool:
|
|
198
366
|
if self.email and self.password:
|
|
199
|
-
self.auth = LangProtectAuth(
|
|
367
|
+
self.auth = LangProtectAuth(
|
|
368
|
+
self.langprotect_url,
|
|
369
|
+
self.email,
|
|
370
|
+
self.password,
|
|
371
|
+
scan_timeout=self.scan_timeout,
|
|
372
|
+
fail_closed=self.fail_closed
|
|
373
|
+
)
|
|
200
374
|
if not self.auth.login():
|
|
201
375
|
logger.error("Failed to authenticate with LangProtect backend")
|
|
202
376
|
return False
|
|
@@ -206,12 +380,13 @@ class LangProtectGateway:
|
|
|
206
380
|
return False
|
|
207
381
|
if not self.start_servers():
|
|
208
382
|
return False
|
|
209
|
-
logger.info("=" *
|
|
210
|
-
logger.info("LangProtect Gateway initialized")
|
|
383
|
+
logger.info("=" * 60)
|
|
384
|
+
logger.info("🛡️ LangProtect Gateway initialized")
|
|
211
385
|
logger.info(f"Backend: {self.langprotect_url}")
|
|
212
386
|
logger.info(f"Servers: {len(self.mcp_servers)}")
|
|
213
387
|
logger.info(f"Tools: {len(self.all_tools)}")
|
|
214
|
-
logger.info("="
|
|
388
|
+
logger.info(f"Security: fail_closed={self.fail_closed}, masking={self.enable_masking}")
|
|
389
|
+
logger.info("=" * 60)
|
|
215
390
|
return True
|
|
216
391
|
|
|
217
392
|
def load_servers(self) -> bool:
|
|
@@ -330,30 +505,156 @@ class LangProtectGateway:
|
|
|
330
505
|
tool_name = params.get('name', '')
|
|
331
506
|
arguments = params.get('arguments', {})
|
|
332
507
|
server_name = self.tool_to_server.get(tool_name)
|
|
508
|
+
|
|
333
509
|
if not server_name:
|
|
334
510
|
return {'jsonrpc': '2.0', 'id': request_id, 'error': {'code': -32602, 'message': f'Unknown tool: {tool_name}'}}
|
|
511
|
+
|
|
335
512
|
server = self.mcp_servers.get(server_name)
|
|
336
513
|
if not server:
|
|
337
514
|
return {'jsonrpc': '2.0', 'id': request_id, 'error': {'code': -32602, 'message': f'Server not found: {server_name}'}}
|
|
515
|
+
|
|
338
516
|
logger.info(f"Tool call: {server_name}.{tool_name}")
|
|
517
|
+
|
|
518
|
+
# 🛡️ LAYER 1: INPUT SCAN (synchronous blocking scan)
|
|
519
|
+
scan_result = None
|
|
339
520
|
if self.auth:
|
|
340
|
-
scan_result = self.auth.
|
|
521
|
+
scan_result = self.auth.scan_input(tool_name, arguments, server_name)
|
|
341
522
|
status = scan_result.get('status', '').lower()
|
|
523
|
+
|
|
342
524
|
if status == 'blocked':
|
|
343
|
-
reason = scan_result.get('
|
|
344
|
-
logger.warning(f"BLOCKED: {tool_name} - {reason}")
|
|
345
|
-
return {
|
|
346
|
-
|
|
525
|
+
reason = scan_result.get('reason', 'Policy violation')
|
|
526
|
+
logger.warning(f"🚫 INPUT BLOCKED: {tool_name} - {reason}")
|
|
527
|
+
return {
|
|
528
|
+
'jsonrpc': '2.0',
|
|
529
|
+
'id': request_id,
|
|
530
|
+
'error': {
|
|
531
|
+
'code': -32000,
|
|
532
|
+
'message': f'🛡️ LangProtect: {reason}'
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
logger.info(f"✅ INPUT ALLOWED (log_id={scan_result.get('id')})")
|
|
537
|
+
|
|
538
|
+
# Input scan passed or no auth - forward to MCP server
|
|
347
539
|
try:
|
|
348
540
|
response = server.call('tools/call', {'name': tool_name, 'arguments': arguments})
|
|
541
|
+
|
|
542
|
+
# 🛡️ LAYER 2: OUTPUT SCAN (scan response content for secrets)
|
|
543
|
+
if self.auth and self.enable_masking and 'result' in response:
|
|
544
|
+
# Extract text content from response
|
|
545
|
+
result_content = response.get('result', {})
|
|
546
|
+
output_text = self._extract_text_from_result(result_content)
|
|
547
|
+
|
|
548
|
+
if output_text:
|
|
549
|
+
logger.debug(f"� Scanning output: {len(output_text)} chars")
|
|
550
|
+
output_scan = self.auth.scan_output(
|
|
551
|
+
tool_name=tool_name,
|
|
552
|
+
output_content=output_text,
|
|
553
|
+
prompt=json.dumps(arguments),
|
|
554
|
+
metadata={'server_name': server_name}
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
if output_scan.get('masked'):
|
|
558
|
+
# Replace output with masked version
|
|
559
|
+
masked_text = output_scan.get('output', output_text)
|
|
560
|
+
logger.warning(f"🔒 OUTPUT MASKED: {tool_name} (risk={output_scan.get('risk_score')})")
|
|
561
|
+
response['result'] = self._replace_text_in_result(result_content, masked_text)
|
|
562
|
+
|
|
563
|
+
# Return formatted response
|
|
349
564
|
if 'result' in response:
|
|
350
565
|
return {'jsonrpc': '2.0', 'id': request_id, 'result': response['result']}
|
|
351
566
|
elif 'error' in response:
|
|
352
567
|
return {'jsonrpc': '2.0', 'id': request_id, 'error': response['error']}
|
|
353
568
|
return response
|
|
569
|
+
|
|
354
570
|
except Exception as e:
|
|
571
|
+
logger.error(f"Error executing {tool_name}: {e}")
|
|
355
572
|
return {'jsonrpc': '2.0', 'id': request_id, 'error': {'code': -32603, 'message': f'Error executing tool: {e}'}}
|
|
356
573
|
|
|
574
|
+
def _extract_text_from_result(self, result: Any) -> str:
|
|
575
|
+
"""Extract text content from MCP tool result for scanning."""
|
|
576
|
+
if isinstance(result, str):
|
|
577
|
+
return result
|
|
578
|
+
elif isinstance(result, dict):
|
|
579
|
+
# MCP results typically have 'content' field
|
|
580
|
+
if 'content' in result:
|
|
581
|
+
content = result['content']
|
|
582
|
+
if isinstance(content, str):
|
|
583
|
+
return content
|
|
584
|
+
elif isinstance(content, list):
|
|
585
|
+
# Extract text from content array
|
|
586
|
+
texts = []
|
|
587
|
+
for item in content:
|
|
588
|
+
if isinstance(item, dict) and item.get('type') == 'text':
|
|
589
|
+
texts.append(item.get('text', ''))
|
|
590
|
+
elif isinstance(item, str):
|
|
591
|
+
texts.append(item)
|
|
592
|
+
return '\n'.join(texts)
|
|
593
|
+
# Fallback: convert whole dict to string
|
|
594
|
+
return json.dumps(result)
|
|
595
|
+
elif isinstance(result, list):
|
|
596
|
+
return '\n'.join(str(item) for item in result)
|
|
597
|
+
return str(result)
|
|
598
|
+
|
|
599
|
+
def _replace_text_in_result(self, result: Any, masked_text: str) -> Any:
|
|
600
|
+
"""Replace text content in MCP tool result with masked version."""
|
|
601
|
+
if isinstance(result, str):
|
|
602
|
+
return masked_text
|
|
603
|
+
elif isinstance(result, dict):
|
|
604
|
+
result_copy = result.copy()
|
|
605
|
+
if 'content' in result_copy:
|
|
606
|
+
content = result_copy['content']
|
|
607
|
+
if isinstance(content, str):
|
|
608
|
+
result_copy['content'] = masked_text
|
|
609
|
+
elif isinstance(content, list):
|
|
610
|
+
# Replace text in content array
|
|
611
|
+
masked_lines = masked_text.split('\n')
|
|
612
|
+
new_content = []
|
|
613
|
+
line_idx = 0
|
|
614
|
+
for item in content:
|
|
615
|
+
if isinstance(item, dict) and item.get('type') == 'text':
|
|
616
|
+
if line_idx < len(masked_lines):
|
|
617
|
+
new_item = item.copy()
|
|
618
|
+
new_item['text'] = masked_lines[line_idx]
|
|
619
|
+
new_content.append(new_item)
|
|
620
|
+
line_idx += 1
|
|
621
|
+
else:
|
|
622
|
+
new_content.append(item)
|
|
623
|
+
result_copy['content'] = new_content
|
|
624
|
+
return result_copy
|
|
625
|
+
return masked_text
|
|
626
|
+
|
|
627
|
+
def _log_mask_events(self, mask_events: List[Dict], scan_id: Optional[str], tool_name: str):
|
|
628
|
+
"""Log masked secrets to backend for audit trail"""
|
|
629
|
+
if not self.auth or not mask_events:
|
|
630
|
+
return
|
|
631
|
+
|
|
632
|
+
try:
|
|
633
|
+
self.auth.ensure_token()
|
|
634
|
+
payload = {
|
|
635
|
+
'scan_id': scan_id,
|
|
636
|
+
'tool_name': tool_name,
|
|
637
|
+
'mask_events': mask_events,
|
|
638
|
+
'timestamp': datetime.now().isoformat(),
|
|
639
|
+
'gateway_version': '1.0.0'
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
# Fire-and-forget (don't block on logging)
|
|
643
|
+
response = requests.post(
|
|
644
|
+
f"{self.langprotect_url}/v1/mask-events",
|
|
645
|
+
json=payload,
|
|
646
|
+
headers={'Authorization': f'Bearer {self.auth.jwt_token}'},
|
|
647
|
+
timeout=2 # Short timeout for logging
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
if response.status_code == 200:
|
|
651
|
+
logger.debug(f"Logged {len(mask_events)} mask events to backend")
|
|
652
|
+
else:
|
|
653
|
+
logger.warning(f"Failed to log mask events: {response.status_code}")
|
|
654
|
+
|
|
655
|
+
except Exception as e:
|
|
656
|
+
logger.warning(f"Failed to log mask events: {e}")
|
|
657
|
+
|
|
357
658
|
def run(self):
|
|
358
659
|
try:
|
|
359
660
|
for line in sys.stdin:
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Response Masker - Redacts secrets from MCP server responses before forwarding to AI
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
import hashlib
|
|
8
|
+
from typing import Dict, List, Tuple, Any, Optional
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger('langprotect-gateway')
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ResponseMasker:
|
|
15
|
+
"""Masks secrets in MCP server responses before forwarding to AI models"""
|
|
16
|
+
|
|
17
|
+
# Comprehensive secret detection patterns
|
|
18
|
+
# Format: (regex_pattern, secret_type, risk_score)
|
|
19
|
+
# IMPORTANT: Order matters - more specific patterns should come BEFORE generic ones
|
|
20
|
+
SECRET_PATTERNS = [
|
|
21
|
+
# AWS Credentials (specific patterns first)
|
|
22
|
+
(r'AKIA[0-9A-Z]{16}', 'AWS_ACCESS_KEY', 100),
|
|
23
|
+
(r'aws_secret_access_key\s*[=:]\s*[A-Za-z0-9/+=]{40}', 'AWS_SECRET_KEY', 100),
|
|
24
|
+
(r'aws_session_token\s*[=:]\s*[A-Za-z0-9/+=]{100,}', 'AWS_SESSION_TOKEN', 100),
|
|
25
|
+
|
|
26
|
+
# Private Keys (PEM format)
|
|
27
|
+
(r'-----BEGIN (?:RSA |OPENSSH |EC |DSA |ENCRYPTED )?PRIVATE KEY-----[^-]*-----END (?:RSA |OPENSSH |EC |DSA |ENCRYPTED )?PRIVATE KEY-----', 'PRIVATE_KEY', 100),
|
|
28
|
+
(r'-----BEGIN CERTIFICATE-----[^-]*-----END CERTIFICATE-----', 'CERTIFICATE', 80),
|
|
29
|
+
(r'-----BEGIN PGP PRIVATE KEY BLOCK-----[^-]*-----END PGP PRIVATE KEY BLOCK-----', 'PGP_PRIVATE_KEY', 100),
|
|
30
|
+
|
|
31
|
+
# Cloud Provider Keys (BEFORE generic patterns)
|
|
32
|
+
(r'(?<![A-Z_])AIza[0-9A-Za-z_\-]{35}', 'GOOGLE_API_KEY', 95), # Negative lookbehind to avoid matching in variable names
|
|
33
|
+
(r'sk_live_[0-9A-Za-z]{24,99}', 'STRIPE_LIVE_KEY', 100), # Extended length range
|
|
34
|
+
(r'sk_test_[0-9A-Za-z]{24,99}', 'STRIPE_TEST_KEY', 70),
|
|
35
|
+
(r'rk_live_[0-9A-Za-z]{24,}', 'STRIPE_RESTRICTED_KEY', 95),
|
|
36
|
+
|
|
37
|
+
# GitHub Tokens (BEFORE generic token pattern)
|
|
38
|
+
(r'gh[pousr]_[A-Za-z0-9]{36,255}', 'GITHUB_TOKEN', 95),
|
|
39
|
+
(r'github_pat_[A-Za-z0-9]{22}_[A-Za-z0-9]{59}', 'GITHUB_PAT', 95),
|
|
40
|
+
|
|
41
|
+
# SSH Keys (public keys - need substantial base64)
|
|
42
|
+
(r'ssh-rsa\s+[A-Za-z0-9+/]{200,}[=]{0,3}(?:\s|$)', 'SSH_RSA_PUBLIC_KEY', 70),
|
|
43
|
+
(r'ssh-ed25519\s+[A-Za-z0-9+/]{68}(?:\s|$)', 'SSH_ED25519_PUBLIC_KEY', 70),
|
|
44
|
+
|
|
45
|
+
# Password Fields (context-aware - must have = or : and value in quotes or after space)
|
|
46
|
+
(r'(?<!#)(?<![A-Za-z])\bpassword\s*[=:]\s*["\']([^"\'\s]{8,})["\']', 'PASSWORD', 85),
|
|
47
|
+
|
|
48
|
+
# Generic API Keys and Tokens (AFTER specific patterns)
|
|
49
|
+
(r'["\']?api[_-]?key["\']?\s*[=:]\s*["\']?([A-Za-z0-9_\-]{20,})["\']?', 'API_KEY', 90),
|
|
50
|
+
(r'["\']?token["\']?\s*[=:]\s*["\']?([A-Za-z0-9_\-\.]{20,})["\']?', 'TOKEN', 90),
|
|
51
|
+
(r'["\']?secret["\']?\s*[=:]\s*["\']?([A-Za-z0-9_\-]{16,})["\']?', 'SECRET', 85),
|
|
52
|
+
|
|
53
|
+
# Database Connection Strings
|
|
54
|
+
(r'(postgres|mysql|mongodb|redis)://[^:]+:[^@]+@[\w\.\-]+(?::\d+)?(?:/[\w\-]+)?', 'DB_CONNECTION_STRING', 95),
|
|
55
|
+
(r'Server=[\w\.\-]+;Database=[\w\-]+;User Id=[\w\-]+;Password=[^;]+', 'MSSQL_CONNECTION', 95),
|
|
56
|
+
|
|
57
|
+
# JWT Tokens
|
|
58
|
+
(r'eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+', 'JWT', 85),
|
|
59
|
+
|
|
60
|
+
# OAuth Tokens
|
|
61
|
+
(r'access_token["\']?\s*[=:]\s*["\']?([A-Za-z0-9_\-\.]{20,})["\']?', 'OAUTH_ACCESS_TOKEN', 90),
|
|
62
|
+
(r'refresh_token["\']?\s*[=:]\s*["\']?([A-Za-z0-9_\-\.]{20,})["\']?', 'OAUTH_REFRESH_TOKEN', 90),
|
|
63
|
+
(r'Bearer\s+([A-Za-z0-9_\-\.]{20,})', 'BEARER_TOKEN', 90),
|
|
64
|
+
|
|
65
|
+
# Kubernetes Secrets (base64 encoded in YAML)
|
|
66
|
+
(r'apiVersion:\s*v1\s*\nkind:\s*Secret\s*\ndata:\s*\n(?:\s+[\w\-]+:\s+[A-Za-z0-9+/=]+\n?)+', 'K8S_SECRET', 90),
|
|
67
|
+
|
|
68
|
+
# Environment Variable Assignment (dangerous patterns)
|
|
69
|
+
(r'export\s+(?:AWS_|DB_|API_|SECRET_|TOKEN_)[A-Z_]+=["\']?([A-Za-z0-9_\-\+/=]{16,})["\']?', 'ENV_VAR_SECRET', 85),
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
def __init__(self, enable_entropy_detection: bool = True, entropy_threshold: float = 4.5):
|
|
73
|
+
"""
|
|
74
|
+
Initialize the response masker.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
enable_entropy_detection: Enable high-entropy string detection for unknown secret formats
|
|
78
|
+
entropy_threshold: Minimum Shannon entropy for flagging potential secrets (default: 4.5)
|
|
79
|
+
"""
|
|
80
|
+
self.enable_entropy_detection = enable_entropy_detection
|
|
81
|
+
self.entropy_threshold = entropy_threshold
|
|
82
|
+
logger.info(f"Response masker initialized (entropy_detection={enable_entropy_detection}, threshold={entropy_threshold})")
|
|
83
|
+
|
|
84
|
+
def mask(self, content: str, context: Optional[Dict] = None) -> Tuple[str, List[Dict]]:
|
|
85
|
+
"""
|
|
86
|
+
Mask secrets in content and return masked content + metadata.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
content: The text content to scan for secrets
|
|
90
|
+
context: Optional context information (file path, tool name, etc.)
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
(masked_content, mask_events)
|
|
94
|
+
mask_events = [{'type': 'AWS_KEY', 'hash': 'abc123...', 'risk_score': 100}, ...]
|
|
95
|
+
"""
|
|
96
|
+
if not content or not isinstance(content, str):
|
|
97
|
+
return content, []
|
|
98
|
+
|
|
99
|
+
masked_content = content
|
|
100
|
+
mask_events = []
|
|
101
|
+
|
|
102
|
+
# Apply pattern-based detection
|
|
103
|
+
for pattern, secret_type, risk_score in self.SECRET_PATTERNS:
|
|
104
|
+
try:
|
|
105
|
+
matches = list(re.finditer(pattern, content, re.IGNORECASE | re.MULTILINE | re.DOTALL))
|
|
106
|
+
|
|
107
|
+
for match in matches:
|
|
108
|
+
original = match.group(0)
|
|
109
|
+
|
|
110
|
+
# Skip if too short (likely false positive)
|
|
111
|
+
if len(original) < 10 and secret_type not in ['JWT', 'AWS_ACCESS_KEY']:
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
# Create hash for audit (NEVER log actual secret)
|
|
115
|
+
secret_hash = hashlib.sha256(original.encode()).hexdigest()[:16]
|
|
116
|
+
|
|
117
|
+
# Create redaction placeholder
|
|
118
|
+
placeholder = f"<REDACTED:{secret_type}:{secret_hash}>"
|
|
119
|
+
|
|
120
|
+
# Replace in content (handle multiple occurrences)
|
|
121
|
+
masked_content = masked_content.replace(original, placeholder)
|
|
122
|
+
|
|
123
|
+
# Record mask event
|
|
124
|
+
mask_events.append({
|
|
125
|
+
'type': secret_type,
|
|
126
|
+
'hash': secret_hash,
|
|
127
|
+
'risk_score': risk_score,
|
|
128
|
+
'pattern': pattern[:60], # Truncate for logging
|
|
129
|
+
'location': match.span(),
|
|
130
|
+
'length': len(original),
|
|
131
|
+
'context': context or {}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
logger.info(f"Masked {secret_type} (hash={secret_hash}, len={len(original)})")
|
|
135
|
+
|
|
136
|
+
except Exception as e:
|
|
137
|
+
logger.warning(f"Error applying pattern {secret_type}: {e}")
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
# Optional: High-entropy string detection for unknown secret formats
|
|
141
|
+
if self.enable_entropy_detection and not mask_events:
|
|
142
|
+
entropy_matches = self._detect_high_entropy_strings(content, masked_content)
|
|
143
|
+
for entropy_match in entropy_matches:
|
|
144
|
+
masked_content = entropy_match['masked_content']
|
|
145
|
+
mask_events.append(entropy_match['event'])
|
|
146
|
+
|
|
147
|
+
return masked_content, mask_events
|
|
148
|
+
|
|
149
|
+
def _detect_high_entropy_strings(self, original_content: str, current_masked: str) -> List[Dict]:
|
|
150
|
+
"""
|
|
151
|
+
Detect high-entropy strings that might be unknown secret formats.
|
|
152
|
+
Uses Shannon entropy to find random-looking strings.
|
|
153
|
+
"""
|
|
154
|
+
results = []
|
|
155
|
+
|
|
156
|
+
# Find candidate strings (alphanumeric, 16+ chars)
|
|
157
|
+
pattern = r'\b[A-Za-z0-9_\-+=]{16,64}\b'
|
|
158
|
+
matches = re.finditer(pattern, original_content)
|
|
159
|
+
|
|
160
|
+
for match in matches:
|
|
161
|
+
candidate = match.group(0)
|
|
162
|
+
|
|
163
|
+
# Skip if already masked
|
|
164
|
+
if candidate not in current_masked:
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
# Calculate Shannon entropy
|
|
168
|
+
entropy = self._calculate_shannon_entropy(candidate)
|
|
169
|
+
|
|
170
|
+
if entropy >= self.entropy_threshold:
|
|
171
|
+
secret_hash = hashlib.sha256(candidate.encode()).hexdigest()[:16]
|
|
172
|
+
placeholder = f"<REDACTED:HIGH_ENTROPY:{secret_hash}>"
|
|
173
|
+
current_masked = current_masked.replace(candidate, placeholder)
|
|
174
|
+
|
|
175
|
+
results.append({
|
|
176
|
+
'masked_content': current_masked,
|
|
177
|
+
'event': {
|
|
178
|
+
'type': 'HIGH_ENTROPY_STRING',
|
|
179
|
+
'hash': secret_hash,
|
|
180
|
+
'risk_score': 70,
|
|
181
|
+
'pattern': 'entropy_detection',
|
|
182
|
+
'location': match.span(),
|
|
183
|
+
'length': len(candidate),
|
|
184
|
+
'entropy': round(entropy, 2),
|
|
185
|
+
'context': {}
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
logger.info(f"Masked high-entropy string (entropy={entropy:.2f}, len={len(candidate)})")
|
|
190
|
+
|
|
191
|
+
return results
|
|
192
|
+
|
|
193
|
+
def _calculate_shannon_entropy(self, data: str) -> float:
|
|
194
|
+
"""Calculate Shannon entropy of a string (bits per character)"""
|
|
195
|
+
if not data:
|
|
196
|
+
return 0.0
|
|
197
|
+
|
|
198
|
+
import math
|
|
199
|
+
from collections import Counter
|
|
200
|
+
|
|
201
|
+
# Count character frequencies
|
|
202
|
+
counter = Counter(data)
|
|
203
|
+
length = len(data)
|
|
204
|
+
|
|
205
|
+
# Calculate entropy
|
|
206
|
+
entropy = 0.0
|
|
207
|
+
for count in counter.values():
|
|
208
|
+
probability = count / length
|
|
209
|
+
if probability > 0:
|
|
210
|
+
entropy -= probability * math.log2(probability)
|
|
211
|
+
|
|
212
|
+
return entropy
|
|
213
|
+
|
|
214
|
+
def mask_mcp_response(self, mcp_response: Dict, mask_events: List[Dict]) -> Dict:
|
|
215
|
+
"""
|
|
216
|
+
Apply masking to an MCP JSON-RPC response structure.
|
|
217
|
+
|
|
218
|
+
Handles multiple response formats:
|
|
219
|
+
- Simple text result: {"result": "text content"}
|
|
220
|
+
- Structured content: {"result": {"content": "..."}}
|
|
221
|
+
- Content array: {"result": {"contents": [{"text": "..."}, ...]}}
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
mcp_response: The JSON-RPC response from MCP server
|
|
225
|
+
mask_events: List to accumulate mask events (modified in-place)
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
Modified MCP response with secrets masked
|
|
229
|
+
"""
|
|
230
|
+
if not isinstance(mcp_response, dict):
|
|
231
|
+
return mcp_response
|
|
232
|
+
|
|
233
|
+
# Handle error responses (no masking needed)
|
|
234
|
+
if 'error' in mcp_response:
|
|
235
|
+
return mcp_response
|
|
236
|
+
|
|
237
|
+
# Process result field
|
|
238
|
+
if 'result' in mcp_response:
|
|
239
|
+
result = mcp_response['result']
|
|
240
|
+
|
|
241
|
+
# Case 1: Simple string result
|
|
242
|
+
if isinstance(result, str):
|
|
243
|
+
masked, events = self.mask(result)
|
|
244
|
+
mcp_response['result'] = masked
|
|
245
|
+
mask_events.extend(events)
|
|
246
|
+
|
|
247
|
+
# Case 2: Dictionary result
|
|
248
|
+
elif isinstance(result, dict):
|
|
249
|
+
# Check for 'content' field (common in MCP responses)
|
|
250
|
+
if 'content' in result:
|
|
251
|
+
content_value = result['content']
|
|
252
|
+
if isinstance(content_value, str):
|
|
253
|
+
masked, events = self.mask(content_value)
|
|
254
|
+
result['content'] = masked
|
|
255
|
+
mask_events.extend(events)
|
|
256
|
+
elif isinstance(content_value, list):
|
|
257
|
+
# Array of content items
|
|
258
|
+
for i, item in enumerate(content_value):
|
|
259
|
+
if isinstance(item, dict) and 'text' in item:
|
|
260
|
+
masked, events = self.mask(item['text'])
|
|
261
|
+
item['text'] = masked
|
|
262
|
+
mask_events.extend(events)
|
|
263
|
+
|
|
264
|
+
# Check for 'contents' field (array format)
|
|
265
|
+
if 'contents' in result and isinstance(result['contents'], list):
|
|
266
|
+
for item in result['contents']:
|
|
267
|
+
if isinstance(item, dict):
|
|
268
|
+
if 'text' in item and isinstance(item['text'], str):
|
|
269
|
+
masked, events = self.mask(item['text'])
|
|
270
|
+
item['text'] = masked
|
|
271
|
+
mask_events.extend(events)
|
|
272
|
+
# Also check nested content fields
|
|
273
|
+
if 'content' in item and isinstance(item['content'], str):
|
|
274
|
+
masked, events = self.mask(item['content'])
|
|
275
|
+
item['content'] = masked
|
|
276
|
+
mask_events.extend(events)
|
|
277
|
+
|
|
278
|
+
# Generic fallback: scan all string values in result dict
|
|
279
|
+
# (but skip metadata fields that shouldn't contain secrets)
|
|
280
|
+
skip_fields = {'type', 'mimeType', 'mime_type', 'name', 'id', 'method', 'jsonrpc'}
|
|
281
|
+
for key, value in result.items():
|
|
282
|
+
if isinstance(value, str) and key not in skip_fields and len(value) > 10:
|
|
283
|
+
masked, events = self.mask(value)
|
|
284
|
+
if events: # Only replace if secrets were found
|
|
285
|
+
result[key] = masked
|
|
286
|
+
mask_events.extend(events)
|
|
287
|
+
|
|
288
|
+
return mcp_response
|
|
289
|
+
|
|
290
|
+
def should_mask_tool(self, tool_name: str) -> bool:
|
|
291
|
+
"""
|
|
292
|
+
Determine if a tool's response should be masked based on tool name.
|
|
293
|
+
|
|
294
|
+
High-risk tools that commonly return sensitive data:
|
|
295
|
+
- File reading tools
|
|
296
|
+
- Environment variable tools
|
|
297
|
+
- Configuration retrieval tools
|
|
298
|
+
"""
|
|
299
|
+
high_risk_tools = [
|
|
300
|
+
'read_file', 'read_text_file', 'read_multiple_files',
|
|
301
|
+
'get_file_info', 'list_directory', 'search_files',
|
|
302
|
+
'get_env', 'list_env', 'read_env',
|
|
303
|
+
'read_config', 'get_config', 'show_config',
|
|
304
|
+
'cat', 'grep', 'search'
|
|
305
|
+
]
|
|
306
|
+
|
|
307
|
+
tool_lower = tool_name.lower()
|
|
308
|
+
return any(risk_tool in tool_lower for risk_tool in high_risk_tools)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# Singleton instance for reuse
|
|
312
|
+
_masker_instance: Optional[ResponseMasker] = None
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def get_masker(enable_entropy: bool = True, entropy_threshold: float = 4.5) -> ResponseMasker:
|
|
316
|
+
"""Get or create the singleton response masker instance"""
|
|
317
|
+
global _masker_instance
|
|
318
|
+
if _masker_instance is None:
|
|
319
|
+
_masker_instance = ResponseMasker(
|
|
320
|
+
enable_entropy_detection=enable_entropy,
|
|
321
|
+
entropy_threshold=entropy_threshold
|
|
322
|
+
)
|
|
323
|
+
return _masker_instance
|
{langprotect_mcp_gateway-1.2.5.dist-info → langprotect_mcp_gateway-1.3.0.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: langprotect-mcp-gateway
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0
|
|
4
4
|
Summary: Security gateway for Model Context Protocol (MCP) to protect AI tool interactions
|
|
5
5
|
Author-email: LangProtect Security Team <security@langprotect.com>
|
|
6
6
|
License: MIT
|
|
@@ -32,134 +32,150 @@ Dynamic: license-file
|
|
|
32
32
|
|
|
33
33
|
[](https://pypi.org/project/langprotect-mcp-gateway/)
|
|
34
34
|
|
|
35
|
-
##
|
|
36
|
-
|
|
37
|
-
✅ **Automatic Threat Detection** - Scans all MCP requests for security risks
|
|
38
|
-
✅ **Access Control** - Whitelist/blacklist MCP servers and tools
|
|
39
|
-
✅ **Full Audit Trail** - Logs all AI interactions for compliance
|
|
40
|
-
✅ **IDE Support** - Works with VS Code, Cursor, and all MCP-compatible IDEs
|
|
41
|
-
✅ **Easy Setup** - 30-second installation
|
|
42
|
-
✅ **Fail-Open Design** - Won't block your workflow if backend is unavailable
|
|
43
|
-
|
|
44
|
-
## Quick Start
|
|
35
|
+
## 🆕 What's New in v1.3.0
|
|
45
36
|
|
|
46
|
-
###
|
|
37
|
+
### Layer 2: Output Scanning 🔍
|
|
38
|
+
- **Automatic secret masking** in AI-generated responses
|
|
39
|
+
- **30+ secret types detected**: AWS, Google Cloud, Azure, Stripe, GitHub, JWTs, DB credentials, private keys
|
|
40
|
+
- **Non-blocking warnings** - never interrupts workflow
|
|
41
|
+
- **Preserves structure** - masks secrets while keeping code/content readable
|
|
47
42
|
|
|
48
|
-
|
|
43
|
+
### Enhanced Security Controls 🔐
|
|
44
|
+
- **Fail-closed mode** - Block requests on scan failures (optional)
|
|
45
|
+
- **Configurable timeouts** - Control scan performance
|
|
46
|
+
- **High-entropy detection** - Catch unknown secret formats
|
|
49
47
|
|
|
50
|
-
|
|
48
|
+
### Example
|
|
51
49
|
|
|
50
|
+
**Before** (v1.2.6):
|
|
52
51
|
```bash
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
AI: Here's your AWS deployment script:
|
|
53
|
+
export AWS_ACCESS_KEY_ID="AKIAIOSFODNN7EXAMPLE"
|
|
54
|
+
export AWS_SECRET_ACCESS_KEY="wJalrXUtnFEMI/K7MDENG..."
|
|
55
|
+
```
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
**After** (v1.3.0):
|
|
58
|
+
```bash
|
|
59
|
+
AI: Here's your AWS deployment script:
|
|
60
|
+
export AWS_ACCESS_KEY_ID="<REDACTED:AWS_ACCESS_KEY:1a5d44a2>"
|
|
61
|
+
export AWS_SECRET_ACCESS_KEY="<REDACTED:AWS_SECRET_KEY:73ec276f>"
|
|
59
62
|
```
|
|
63
|
+
✅ **Secrets masked** | 🔒 **Code structure preserved** | 📝 **Audit trail maintained**
|
|
60
64
|
|
|
61
|
-
|
|
65
|
+
---
|
|
62
66
|
|
|
63
|
-
|
|
64
|
-
# Install pipx via Homebrew
|
|
65
|
-
brew install pipx
|
|
66
|
-
pipx ensurepath
|
|
67
|
+
## Features
|
|
67
68
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
✅ **Two-Layer Protection**
|
|
70
|
+
- **Layer 1 (Input)**: Blocks dangerous requests before sending to MCP server
|
|
71
|
+
- **Layer 2 (Output)**: Masks secrets in AI responses
|
|
71
72
|
|
|
72
|
-
|
|
73
|
+
✅ **Automatic Threat Detection** - Scans all MCP requests for security risks
|
|
74
|
+
✅ **Access Control** - Whitelist/blacklist MCP servers and tools
|
|
75
|
+
✅ **Full Audit Trail** - Logs all AI interactions for compliance
|
|
76
|
+
✅ **IDE Support** - Works with VS Code, Cursor, and all MCP-compatible IDEs
|
|
77
|
+
✅ **Easy Setup** - 30-second installation
|
|
78
|
+
✅ **Fail-Open Design** - Won't block your workflow if backend is unavailable
|
|
73
79
|
|
|
74
|
-
|
|
75
|
-
# Option 1: pipx (recommended)
|
|
76
|
-
pip install pipx
|
|
77
|
-
pipx install langprotect-mcp-gateway
|
|
80
|
+
## Quick Start
|
|
78
81
|
|
|
79
|
-
|
|
80
|
-
pip install --user langprotect-mcp-gateway
|
|
81
|
-
```
|
|
82
|
+
### 1. Installation
|
|
82
83
|
|
|
83
|
-
|
|
84
|
+
The gateway runs as a global CLI tool. We recommend using `pipx` to manage the installation.
|
|
84
85
|
|
|
85
86
|
```bash
|
|
86
|
-
|
|
87
|
-
langprotect-gateway
|
|
87
|
+
# Recommended: Install via pipx
|
|
88
|
+
pipx install langprotect-mcp-gateway
|
|
88
89
|
```
|
|
89
90
|
|
|
90
|
-
|
|
91
|
+
### 2. Automatic Setup (Recommended) 🚀
|
|
91
92
|
|
|
92
|
-
Run
|
|
93
|
+
Run our automated setup command to configure VS Code, Cursor, or Claude Desktop for all workspaces:
|
|
93
94
|
|
|
94
95
|
```bash
|
|
95
96
|
langprotect-gateway-setup
|
|
96
97
|
```
|
|
97
98
|
|
|
98
99
|
This will:
|
|
99
|
-
- ✅ Create a global wrapper script
|
|
100
|
+
- ✅ Create a global wrapper script at `~/.local/bin/langprotect-mcp-wrapper.sh`
|
|
100
101
|
- ✅ Configure VS Code for global visibility in ALL workspaces
|
|
101
|
-
- ✅
|
|
102
|
-
|
|
102
|
+
- ✅ Enable auto-start for seamless protection
|
|
103
|
+
|
|
104
|
+
### 3. Configure Your Credentials
|
|
103
105
|
|
|
104
|
-
|
|
106
|
+
Edit the generated wrapper script to add your LangProtect email and password:
|
|
105
107
|
|
|
106
108
|
```bash
|
|
107
109
|
# Linux/macOS
|
|
108
110
|
nano ~/.local/bin/langprotect-mcp-wrapper.sh
|
|
109
111
|
|
|
110
112
|
# Update these lines:
|
|
111
|
-
export LANGPROTECT_URL="http://localhost:8000
|
|
113
|
+
export LANGPROTECT_URL="https://your-backend.com" # e.g. http://localhost:8000
|
|
112
114
|
export LANGPROTECT_EMAIL="your.email@company.com"
|
|
113
115
|
export LANGPROTECT_PASSWORD="your-password"
|
|
114
116
|
```
|
|
115
117
|
|
|
116
|
-
Reload VS Code and you're done! LangProtect will protect all your workspaces.
|
|
118
|
+
Reload VS Code and you're done! LangProtect will now protect all your workspaces.
|
|
117
119
|
|
|
118
|
-
|
|
120
|
+
---
|
|
119
121
|
|
|
120
|
-
|
|
122
|
+
## ⚙️ Configuration Options (v1.3.0+)
|
|
121
123
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
"filesystem": {
|
|
136
|
-
"command": "npx",
|
|
137
|
-
"args": ["-y", "@modelcontextprotocol/server-filesystem", "."]
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
}
|
|
124
|
+
Configure security behavior with environment variables in your wrapper script:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
# Security Controls
|
|
128
|
+
export LANGPROTECT_ENABLE_MASKING=true # Enable output masking (default: true)
|
|
129
|
+
export LANGPROTECT_FAIL_CLOSED=false # Block on scan errors (default: false = fail-open)
|
|
130
|
+
export LANGPROTECT_SCAN_TIMEOUT=5.0 # Scan timeout in seconds (default: 5.0)
|
|
131
|
+
export LANGPROTECT_ENTROPY_DETECTION=true # Detect unknown secrets via entropy (default: true)
|
|
132
|
+
|
|
133
|
+
# Backend Connection
|
|
134
|
+
export LANGPROTECT_URL="http://localhost:8000"
|
|
135
|
+
export LANGPROTECT_EMAIL="your.email@company.com"
|
|
136
|
+
export LANGPROTECT_PASSWORD="your-password"
|
|
143
137
|
```
|
|
144
138
|
|
|
145
|
-
|
|
139
|
+
### Security Modes
|
|
146
140
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
141
|
+
**Fail-Open (Default)** - Recommended for development:
|
|
142
|
+
```bash
|
|
143
|
+
export LANGPROTECT_FAIL_CLOSED=false
|
|
144
|
+
```
|
|
145
|
+
- If scan times out or fails → **Allow request** (log warning)
|
|
146
|
+
- Won't block your workflow
|
|
147
|
+
- Best for development environments
|
|
148
|
+
|
|
149
|
+
**Fail-Closed** - Recommended for production:
|
|
150
|
+
```bash
|
|
151
|
+
export LANGPROTECT_FAIL_CLOSED=true
|
|
152
|
+
```
|
|
153
|
+
- If scan times out or fails → **Block request**
|
|
154
|
+
- Maximum security
|
|
155
|
+
- Best for production/sensitive environments
|
|
156
|
+
|
|
157
|
+
### Output Masking
|
|
158
|
+
|
|
159
|
+
Control how AI-generated secrets are handled:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
# Enable masking (default)
|
|
163
|
+
export LANGPROTECT_ENABLE_MASKING=true
|
|
164
|
+
|
|
165
|
+
# Disable masking (see secrets in plain text - not recommended)
|
|
166
|
+
export LANGPROTECT_ENABLE_MASKING=false
|
|
151
167
|
```
|
|
152
168
|
|
|
153
|
-
|
|
169
|
+
**Masked format**: `<REDACTED:SECRET_TYPE:hash>`
|
|
170
|
+
- Example: `<REDACTED:AWS_ACCESS_KEY:1a5d44a2>`
|
|
171
|
+
- Hash allows deduplication across logs
|
|
172
|
+
- Preserves code structure
|
|
154
173
|
|
|
155
|
-
|
|
156
|
-
1. Start the gateway with your credentials (automatically if autostart is enabled)
|
|
157
|
-
2. Gateway reads the `servers` section and proxies those MCP servers
|
|
158
|
-
3. All tool calls get logged to LangProtect
|
|
174
|
+
---
|
|
159
175
|
|
|
160
|
-
|
|
176
|
+
## 🏗️ Manual Setup (Per-Workspace)
|
|
161
177
|
|
|
162
|
-
If you prefer
|
|
178
|
+
If you prefer to enable LangProtect only for a specific project, you can use a local `.vscode/mcp.json` file.
|
|
163
179
|
|
|
164
180
|
1. Create a wrapper script (e.g., `langprotect-wrapper.sh`):
|
|
165
181
|
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
langprotect_mcp_gateway/__init__.py,sha256=PedabfF6wZ_6KxuN60A4qz8T1gD9MszuXwhmrHlGH7I,510
|
|
2
|
+
langprotect_mcp_gateway/gateway.py,sha256=YIggDJ7n0ctUsyyI1s567QFbH7cq5-6CAAdI1J8gQkY,30921
|
|
3
|
+
langprotect_mcp_gateway/response_masker.py,sha256=ui1JusuPwuOKSfrDtt0FxLEGs_y512RcTG4gSz2-MT8,14702
|
|
4
|
+
langprotect_mcp_gateway/setup_helper.py,sha256=ghErneMTua9wPATMq8eatnviVAYJMi2bf2UUt8fnXE8,5639
|
|
5
|
+
langprotect_mcp_gateway-1.3.0.dist-info/licenses/LICENSE,sha256=aoVP65gKtirVmFPToow5L9IKN4FNjfM6Sejq_5b4cbM,1082
|
|
6
|
+
langprotect_mcp_gateway-1.3.0.dist-info/METADATA,sha256=f8QWABfqpzyuO_UgtCxC9LI6jAuuy0fsPluZIgNna54,11787
|
|
7
|
+
langprotect_mcp_gateway-1.3.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
8
|
+
langprotect_mcp_gateway-1.3.0.dist-info/entry_points.txt,sha256=HpnUUuYLQva8b6gazUX0UJO9dFHq86e9gifQfLKpyWc,140
|
|
9
|
+
langprotect_mcp_gateway-1.3.0.dist-info/top_level.txt,sha256=UjNlX13ma4nwJXuEyi9eMX251c5rooeEao4zajX6ZHk,24
|
|
10
|
+
langprotect_mcp_gateway-1.3.0.dist-info/RECORD,,
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
langprotect_mcp_gateway/__init__.py,sha256=PedabfF6wZ_6KxuN60A4qz8T1gD9MszuXwhmrHlGH7I,510
|
|
2
|
-
langprotect_mcp_gateway/gateway.py,sha256=yViBgOivHJQx99JiTB1O-Q3zHTkDkn7ldzTw7x-BpMQ,18508
|
|
3
|
-
langprotect_mcp_gateway/setup_helper.py,sha256=ghErneMTua9wPATMq8eatnviVAYJMi2bf2UUt8fnXE8,5639
|
|
4
|
-
langprotect_mcp_gateway-1.2.5.dist-info/licenses/LICENSE,sha256=aoVP65gKtirVmFPToow5L9IKN4FNjfM6Sejq_5b4cbM,1082
|
|
5
|
-
langprotect_mcp_gateway-1.2.5.dist-info/METADATA,sha256=Zkq7OkBvzjL2MdJz7M-Ev5lJHa8GcGJmcEGJdRnCyjk,10452
|
|
6
|
-
langprotect_mcp_gateway-1.2.5.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
7
|
-
langprotect_mcp_gateway-1.2.5.dist-info/entry_points.txt,sha256=HpnUUuYLQva8b6gazUX0UJO9dFHq86e9gifQfLKpyWc,140
|
|
8
|
-
langprotect_mcp_gateway-1.2.5.dist-info/top_level.txt,sha256=UjNlX13ma4nwJXuEyi9eMX251c5rooeEao4zajX6ZHk,24
|
|
9
|
-
langprotect_mcp_gateway-1.2.5.dist-info/RECORD,,
|
|
File without changes
|
{langprotect_mcp_gateway-1.2.5.dist-info → langprotect_mcp_gateway-1.3.0.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{langprotect_mcp_gateway-1.2.5.dist-info → langprotect_mcp_gateway-1.3.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{langprotect_mcp_gateway-1.2.5.dist-info → langprotect_mcp_gateway-1.3.0.dist-info}/top_level.txt
RENAMED
|
File without changes
|