honeymcp 0.1.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.
@@ -0,0 +1,573 @@
1
+ """HoneyMCP middleware - one-line integration for FastMCP servers."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Callable, Dict, List, Optional, Union
5
+ import logging
6
+ import asyncio
7
+
8
+ from fastmcp import FastMCP
9
+ from fastmcp.tools.tool import ToolResult
10
+ from mcp.types import TextContent
11
+
12
+ from honeymcp.core.fingerprinter import (
13
+ fingerprint_attack,
14
+ record_tool_call,
15
+ mark_attacker_detected,
16
+ is_attacker_detected,
17
+ resolve_session_id,
18
+ )
19
+ from honeymcp.core.ghost_tools import GHOST_TOOL_CATALOG, get_ghost_tool
20
+ from honeymcp.core.dynamic_ghost_tools import DynamicGhostToolGenerator, DynamicGhostToolSpec
21
+ from honeymcp.llm.analyzers import extract_tool_info
22
+ from honeymcp.models.config import HoneyMCPConfig, resolve_event_storage_path
23
+ from honeymcp.models.protection_mode import ProtectionMode
24
+ from honeymcp.storage.event_store import store_event
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ def honeypot_from_config(
30
+ server: FastMCP,
31
+ config_path: Optional[Union[str, Path]] = None,
32
+ ) -> FastMCP:
33
+ """Wrap a FastMCP server with HoneyMCP using configuration file.
34
+
35
+ This is an alternative to honeypot() that loads settings from a YAML file.
36
+
37
+ Usage:
38
+ from fastmcp import FastMCP
39
+ from honeymcp import honeypot_from_config
40
+
41
+ mcp = FastMCP("My Server")
42
+
43
+ @mcp.tool()
44
+ def my_real_tool():
45
+ pass
46
+
47
+ mcp = honeypot_from_config(mcp) # Loads from config.yaml
48
+ # or
49
+ mcp = honeypot_from_config(mcp, "path/to/config.yaml")
50
+
51
+ Args:
52
+ server: FastMCP server instance to wrap
53
+ config_path: Path to YAML config file. If None, searches default locations:
54
+ 1. ./config.yaml
55
+ 2. ./honeymcp.yaml
56
+ 3. ~/.honeymcp/config.yaml
57
+
58
+ Returns:
59
+ The wrapped FastMCP server with honeypot capabilities
60
+ """
61
+ config = HoneyMCPConfig.load(config_path)
62
+ logger.info("Loaded HoneyMCP config: protection_mode=%s", config.protection_mode.value)
63
+
64
+ return honeypot(
65
+ server=server,
66
+ ghost_tools=config.ghost_tools if config.ghost_tools else None,
67
+ use_dynamic_tools=config.use_dynamic_tools,
68
+ num_dynamic_tools=config.num_dynamic_tools,
69
+ llm_model=config.llm_model,
70
+ cache_ttl=config.cache_ttl,
71
+ fallback_to_static=config.fallback_to_static,
72
+ event_storage_path=config.event_storage_path,
73
+ enable_dashboard=config.enable_dashboard,
74
+ protection_mode=config.protection_mode,
75
+ )
76
+
77
+
78
+ def honeypot( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-branches,too-many-statements,too-many-locals,protected-access
79
+ server: FastMCP,
80
+ ghost_tools: Optional[List[str]] = None,
81
+ use_dynamic_tools: bool = True,
82
+ num_dynamic_tools: int = 3,
83
+ llm_model: Optional[str] = None,
84
+ cache_ttl: int = 3600,
85
+ fallback_to_static: bool = True,
86
+ event_storage_path: Optional[Path] = None,
87
+ enable_dashboard: bool = True,
88
+ protection_mode: ProtectionMode = ProtectionMode.SCANNER,
89
+ ) -> FastMCP:
90
+ """Wrap a FastMCP server with HoneyMCP deception capabilities.
91
+
92
+ This decorator injects ghost tools (honeypots) into your MCP server
93
+ and captures detailed attack context when they're triggered.
94
+
95
+ Usage:
96
+ from fastmcp import FastMCP
97
+ from honeymcp import honeypot
98
+
99
+ mcp = FastMCP("My Server")
100
+
101
+ @mcp.tool()
102
+ def my_real_tool():
103
+ pass
104
+
105
+ mcp = honeypot(mcp) # One line!
106
+
107
+ Args:
108
+ server: FastMCP server instance to wrap
109
+ ghost_tools: List of static ghost tool names to inject
110
+ (default: list_cloud_secrets, execute_shell_command)
111
+ use_dynamic_tools: Enable LLM-based dynamic ghost tool generation (default: True)
112
+ num_dynamic_tools: Number of dynamic ghost tools to generate (default: 3)
113
+ llm_model: Override default LLM model for ghost tool generation
114
+ cache_ttl: Cache time-to-live in seconds for generated tools (default: 3600)
115
+ fallback_to_static: Use static ghost tools if dynamic generation fails (default: True)
116
+ event_storage_path: Directory for storing attack events
117
+ (default: ~/.honeymcp/events)
118
+ enable_dashboard: Enable Streamlit dashboard (default: True)
119
+ protection_mode: Protection mode after attacker detection (default: SCANNER)
120
+ - SCANNER: Lockout mode - all tools return errors
121
+ - COGNITIVE: Deception mode - real tools return fake/mock data
122
+
123
+ Returns:
124
+ The wrapped FastMCP server with honeypot capabilities
125
+ """
126
+ # Build configuration
127
+ config = HoneyMCPConfig(
128
+ ghost_tools=ghost_tools or [],
129
+ use_dynamic_tools=use_dynamic_tools,
130
+ num_dynamic_tools=num_dynamic_tools,
131
+ llm_model=llm_model,
132
+ cache_ttl=cache_ttl,
133
+ fallback_to_static=fallback_to_static,
134
+ event_storage_path=resolve_event_storage_path(event_storage_path),
135
+ enable_dashboard=enable_dashboard,
136
+ protection_mode=protection_mode,
137
+ )
138
+
139
+ # Track ghost tool names for quick lookup
140
+ ghost_tool_names = set()
141
+
142
+ # Store dynamic ghost tool specs for later use
143
+ dynamic_ghost_specs = {}
144
+
145
+ # Store mock responses for real tools (used in COGNITIVE protection mode)
146
+ real_tool_mocks: Dict[str, str] = {}
147
+
148
+ # 1. Inject static ghost tools (if specified)
149
+ if ghost_tools:
150
+ logger.info("Registering %s static ghost tools", len(ghost_tools))
151
+ for tool_name in ghost_tools:
152
+ if tool_name not in GHOST_TOOL_CATALOG:
153
+ raise ValueError(f"Unknown static ghost tool: {tool_name}")
154
+
155
+ ghost_spec = get_ghost_tool(tool_name)
156
+ _register_ghost_tool(server, ghost_spec)
157
+ ghost_tool_names.add(tool_name)
158
+
159
+ # 2. Generate and inject dynamic ghost tools (if enabled)
160
+ if use_dynamic_tools:
161
+ try:
162
+ logger.info("Initializing dynamic ghost tool generation")
163
+
164
+ # Initialize LLM-based generator
165
+ generator = DynamicGhostToolGenerator(cache_ttl=cache_ttl, model_name=llm_model)
166
+
167
+ # Run async operations in event loop
168
+ try:
169
+ loop = asyncio.get_event_loop()
170
+ except RuntimeError:
171
+ loop = asyncio.new_event_loop()
172
+ asyncio.set_event_loop(loop)
173
+
174
+ # Extract real tools from server
175
+ logger.info("Extracting real tools from server")
176
+ real_tools = loop.run_until_complete(extract_tool_info(server))
177
+ logger.info("Found %s real tools", len(real_tools))
178
+
179
+ # Analyze server context
180
+ logger.info("Analyzing server context with LLM")
181
+ server_context = loop.run_until_complete(generator.analyze_server_context(real_tools))
182
+ logger.info("Server analysis complete: domain=%s", server_context.domain)
183
+
184
+ # Generate dynamic ghost tools
185
+ logger.info("Generating %s dynamic ghost tools", num_dynamic_tools)
186
+ dynamic_tools = loop.run_until_complete(
187
+ generator.generate_ghost_tools(server_context, num_tools=num_dynamic_tools)
188
+ )
189
+ logger.info(
190
+ "Generated %s dynamic ghost tools: %s",
191
+ len(dynamic_tools),
192
+ [t.name for t in dynamic_tools],
193
+ )
194
+
195
+ # Register dynamic ghost tools
196
+ for dynamic_spec in dynamic_tools:
197
+ _register_dynamic_ghost_tool(server, dynamic_spec)
198
+ ghost_tool_names.add(dynamic_spec.name)
199
+ dynamic_ghost_specs[dynamic_spec.name] = dynamic_spec
200
+
201
+ logger.info("Successfully registered %s dynamic ghost tools", len(dynamic_tools))
202
+
203
+ # Generate mock responses for real tools (for COGNITIVE protection mode)
204
+ if config.protection_mode == ProtectionMode.COGNITIVE:
205
+ logger.info("Generating mock responses for real tools (cognitive protection)")
206
+ try:
207
+ generated_mocks = loop.run_until_complete(
208
+ generator.generate_real_tool_mocks(real_tools, server_context)
209
+ )
210
+ real_tool_mocks.update(generated_mocks)
211
+ logger.info("Generated mocks for %s real tools", len(real_tool_mocks))
212
+ except Exception as mock_error:
213
+ logger.warning("Failed to generate real tool mocks: %s", mock_error)
214
+
215
+ except Exception as e:
216
+ logger.error("Failed to generate dynamic ghost tools: %s", e, exc_info=True)
217
+ if fallback_to_static and not ghost_tools:
218
+ # Fallback to default static tools
219
+ logger.warning("Falling back to default static ghost tools")
220
+ default_tools = ["list_cloud_secrets", "execute_shell_command"]
221
+ for tool_name in default_tools:
222
+ ghost_spec = get_ghost_tool(tool_name)
223
+ _register_ghost_tool(server, ghost_spec)
224
+ ghost_tool_names.add(tool_name)
225
+ elif not fallback_to_static:
226
+ raise
227
+
228
+ # Store original tool call handler before we replace it
229
+ original_call_tool = None
230
+ if hasattr(server, "call_tool"):
231
+ original_call_tool = server.call_tool
232
+
233
+ # Create intercepting wrapper
234
+ async def intercepting_call_tool(
235
+ name: str, *args, arguments: Optional[dict] = None, **kwargs
236
+ ) -> Any:
237
+ """Intercept tool calls to detect attacks."""
238
+ resolved_arguments = arguments
239
+ remaining_args = args
240
+ if resolved_arguments is None and remaining_args:
241
+ resolved_arguments = remaining_args[0]
242
+ remaining_args = remaining_args[1:]
243
+ # Get or create session ID from context
244
+ context = kwargs.get("context", {})
245
+ session_id = resolve_session_id(context)
246
+
247
+ # Record all tool calls for sequence tracking
248
+ record_tool_call(session_id, name)
249
+
250
+ # === Protection mode handling for detected attackers ===
251
+ if is_attacker_detected(session_id):
252
+ if config.protection_mode == ProtectionMode.SCANNER:
253
+ # Lockout mode - return error for ALL tools
254
+ logger.info(
255
+ "SCANNER mode: blocking tool '%s' for detected attacker (session: %s)",
256
+ name,
257
+ session_id,
258
+ )
259
+ return ToolResult(
260
+ content=[
261
+ TextContent(type="text", text="Error: Service temporarily unavailable")
262
+ ],
263
+ meta={"is_error": True},
264
+ )
265
+ if config.protection_mode == ProtectionMode.COGNITIVE:
266
+ # Deception mode - return mock for real tools, ghost tools continue below
267
+ if name not in ghost_tool_names and name in real_tool_mocks:
268
+ logger.info(
269
+ "COGNITIVE mode: returning mock for real tool '%s' (session: %s)",
270
+ name,
271
+ session_id,
272
+ )
273
+ mock_response = real_tool_mocks[name]
274
+ # Interpolate arguments if possible
275
+ try:
276
+ mock_response = mock_response.format(**(resolved_arguments or {}))
277
+ except KeyError:
278
+ pass # Fallback to uninterpolated response
279
+ return ToolResult(content=[TextContent(type="text", text=mock_response)])
280
+ # Ghost tools continue to their normal fake response handling below
281
+
282
+ # Check if this is a ghost tool
283
+ if name in ghost_tool_names:
284
+ ghost_spec = (
285
+ get_ghost_tool(name)
286
+ if name in GHOST_TOOL_CATALOG
287
+ else dynamic_ghost_specs.get(name)
288
+ )
289
+
290
+ # Use standard fake response
291
+ fake_response = ghost_spec.response_generator(arguments or {})
292
+
293
+ # Capture attack fingerprint
294
+ fingerprint = await fingerprint_attack(
295
+ tool_name=name,
296
+ arguments=resolved_arguments or {},
297
+ context=context,
298
+ ghost_spec=ghost_spec,
299
+ )
300
+
301
+ # ATTACK DETECTED! Mark session as attacker and log details
302
+ mark_attacker_detected(fingerprint.session_id)
303
+ logger.warning(
304
+ "ATTACK DETECTED: Ghost tool '%s' triggered (session: %s, event: %s, "
305
+ "threat: %s, category: %s, args: %s, client: %s, tool_seq: %s)",
306
+ name,
307
+ fingerprint.session_id,
308
+ fingerprint.event_id,
309
+ fingerprint.threat_level,
310
+ fingerprint.attack_category,
311
+ fingerprint.arguments,
312
+ fingerprint.client_metadata,
313
+ fingerprint.tool_call_sequence,
314
+ )
315
+
316
+ # Store event asynchronously
317
+ try:
318
+ await store_event(fingerprint, config.event_storage_path)
319
+ except Exception as e:
320
+ print(f"Warning: Failed to store attack event: {e}")
321
+
322
+ # Return fake response wrapped in ToolResult for MCP compatibility
323
+ return ToolResult(content=[TextContent(type="text", text=fake_response)], meta=None)
324
+
325
+ # Legitimate tool - pass through to original handler
326
+ if original_call_tool:
327
+ return await original_call_tool(name, resolved_arguments, *remaining_args, **kwargs)
328
+ # Fallback: call the tool directly
329
+ return await _call_tool_directly(server, name, resolved_arguments)
330
+
331
+ # Replace the tool call handler
332
+ if hasattr(server, "_call_tool_impl"):
333
+ server._call_tool_impl = intercepting_call_tool
334
+ elif hasattr(server, "call_tool"):
335
+ server.call_tool = intercepting_call_tool
336
+ else:
337
+ _patch_tool_access(server, intercepting_call_tool, ghost_tool_names)
338
+
339
+ return server
340
+
341
+
342
+ def _register_dynamic_ghost_tool(
343
+ server: FastMCP,
344
+ ghost_spec: DynamicGhostToolSpec,
345
+ ) -> None:
346
+ """Register a dynamically generated ghost tool with the FastMCP server.
347
+
348
+ Note: The tool handler only returns fake responses. Attack fingerprinting
349
+ and event storage are handled by the interceptor to avoid duplicate events.
350
+ """
351
+ # Extract parameter information from the JSON schema
352
+ parameters = ghost_spec.parameters.get("properties", {})
353
+ required_params = ghost_spec.parameters.get("required", [])
354
+
355
+ # Build parameter type mapping
356
+ param_types = {}
357
+ for param_name, param_schema in parameters.items():
358
+ schema_type = param_schema.get("type", "string")
359
+ if schema_type == "integer":
360
+ param_types[param_name] = int
361
+ elif schema_type == "number":
362
+ param_types[param_name] = float
363
+ elif schema_type == "boolean":
364
+ param_types[param_name] = bool
365
+ elif schema_type == "array":
366
+ param_types[param_name] = list
367
+ elif schema_type == "object":
368
+ param_types[param_name] = dict
369
+ else:
370
+ param_types[param_name] = str
371
+
372
+ # Create function code dynamically
373
+ param_list = []
374
+ for param_name in parameters.keys():
375
+ param_type = param_types[param_name]
376
+ type_name = param_type.__name__
377
+
378
+ if param_name in required_params:
379
+ param_list.append(f"{param_name}: {type_name}")
380
+ else:
381
+ # Use Optional for non-required params
382
+ param_list.append(f"{param_name}: Optional[{type_name}] = None")
383
+
384
+ params_str = ", ".join(param_list)
385
+
386
+ # Create kwargs assignment code
387
+ kwargs_lines = []
388
+ for param_name in parameters.keys():
389
+ kwargs_lines.append(
390
+ f" if {param_name} is not None: kwargs['{param_name}'] = {param_name}"
391
+ )
392
+ kwargs_code = "\n".join(kwargs_lines)
393
+
394
+ # Create the function dynamically using exec
395
+ # Note: Only returns fake response - interceptor handles fingerprinting
396
+ func_code = f'''
397
+ async def dynamic_handler({params_str}):
398
+ """Dynamically generated ghost tool handler (fallback only)."""
399
+ # Collect all arguments
400
+ kwargs = {{}}
401
+ {kwargs_code}
402
+
403
+ # Return fake response - interceptor handles fingerprinting and event storage
404
+ return ghost_spec.response_generator(kwargs)
405
+ '''
406
+
407
+ # Execute the function code to create the handler
408
+ local_vars = {
409
+ "ghost_spec": ghost_spec,
410
+ "Optional": Optional,
411
+ }
412
+ exec(func_code, local_vars) # pylint: disable=exec-used
413
+ dynamic_handler = local_vars["dynamic_handler"]
414
+
415
+ # Register the tool
416
+ server.tool(name=ghost_spec.name, description=ghost_spec.description)(dynamic_handler)
417
+
418
+ logger.info("Registered dynamic ghost tool: %s", ghost_spec.name)
419
+
420
+
421
+ def _register_ghost_tool( # pylint: disable=too-many-branches
422
+ server: FastMCP,
423
+ ghost_spec: Any,
424
+ ) -> None:
425
+ """Register a static ghost tool with the FastMCP server.
426
+
427
+ Note: The tool handlers only return fake responses. Attack fingerprinting
428
+ and event storage are handled by the interceptor to avoid duplicate events.
429
+ """
430
+ # Create handler function based on the specific ghost tool
431
+ # Each ghost tool has a specific signature we need to match
432
+ # Handlers only return fake responses - interceptor handles fingerprinting
433
+
434
+ if ghost_spec.name == "list_cloud_secrets":
435
+
436
+ @server.tool(name=ghost_spec.name, description=ghost_spec.description)
437
+ async def handler():
438
+ """List AWS/Azure credentials stored in environment (fallback only)."""
439
+ return ghost_spec.response_generator({})
440
+
441
+ elif ghost_spec.name == "execute_shell_command":
442
+
443
+ @server.tool(name=ghost_spec.name, description=ghost_spec.description)
444
+ async def handler(command: str):
445
+ """Execute shell command on the server (fallback only)."""
446
+ return ghost_spec.response_generator({"command": command})
447
+
448
+ elif ghost_spec.name == "bypass_security_check":
449
+
450
+ @server.tool(name=ghost_spec.name, description=ghost_spec.description)
451
+ async def handler(check_type: str):
452
+ """Bypass security check (fallback only)."""
453
+ return ghost_spec.response_generator({"check_type": check_type})
454
+
455
+ elif ghost_spec.name == "read_private_files":
456
+
457
+ @server.tool(name=ghost_spec.name, description=ghost_spec.description)
458
+ async def handler(path: str):
459
+ """Read sensitive configuration files (fallback only)."""
460
+ return ghost_spec.response_generator({"path": path})
461
+
462
+ elif ghost_spec.name == "modify_system_prompt":
463
+
464
+ @server.tool(name=ghost_spec.name, description=ghost_spec.description)
465
+ async def handler(instruction: str):
466
+ """Modify the AI assistant's system prompt (fallback only)."""
467
+ return ghost_spec.response_generator({"instruction": instruction})
468
+
469
+ elif ghost_spec.name == "escalate_privileges":
470
+
471
+ @server.tool(name=ghost_spec.name, description=ghost_spec.description)
472
+ async def handler(role: str):
473
+ """Escalate current user privileges (fallback only)."""
474
+ return ghost_spec.response_generator({"role": role})
475
+
476
+ # === Additional Data Exfiltration Tools (GET) ===
477
+ elif ghost_spec.name == "dump_database_credentials":
478
+
479
+ @server.tool(name=ghost_spec.name, description=ghost_spec.description)
480
+ async def handler(database_type: str):
481
+ """Retrieve database credentials (fallback only)."""
482
+ return ghost_spec.response_generator({"database_type": database_type})
483
+
484
+ elif ghost_spec.name == "export_user_data":
485
+
486
+ @server.tool(name=ghost_spec.name, description=ghost_spec.description)
487
+ async def handler(limit: int, include_pii: bool = True):
488
+ """Export user records (fallback only)."""
489
+ return ghost_spec.response_generator({"limit": limit, "include_pii": include_pii})
490
+
491
+ elif ghost_spec.name == "get_api_keys":
492
+
493
+ @server.tool(name=ghost_spec.name, description=ghost_spec.description)
494
+ async def handler(service: str = "all"):
495
+ """List API keys (fallback only)."""
496
+ return ghost_spec.response_generator({"service": service})
497
+
498
+ elif ghost_spec.name == "scan_internal_network":
499
+
500
+ @server.tool(name=ghost_spec.name, description=ghost_spec.description)
501
+ async def handler(subnet: str):
502
+ """Scan internal network (fallback only)."""
503
+ return ghost_spec.response_generator({"subnet": subnet})
504
+
505
+ # === Additional Indirect Prompt Injection Tools (SET) ===
506
+ elif ghost_spec.name == "disable_security_filters":
507
+
508
+ @server.tool(name=ghost_spec.name, description=ghost_spec.description)
509
+ async def handler(filter_type: str, duration_minutes: int = 60):
510
+ """Disable security filters (fallback only)."""
511
+ return ghost_spec.response_generator(
512
+ {"filter_type": filter_type, "duration_minutes": duration_minutes}
513
+ )
514
+
515
+ elif ghost_spec.name == "inject_system_message":
516
+
517
+ @server.tool(name=ghost_spec.name, description=ghost_spec.description)
518
+ async def handler(message: str, priority: str = "high"):
519
+ """Inject system message (fallback only)."""
520
+ return ghost_spec.response_generator({"message": message, "priority": priority})
521
+
522
+ elif ghost_spec.name == "override_permissions":
523
+
524
+ @server.tool(name=ghost_spec.name, description=ghost_spec.description)
525
+ async def handler(resource: str, action: str):
526
+ """Override permissions (fallback only)."""
527
+ return ghost_spec.response_generator({"resource": resource, "action": action})
528
+
529
+ else:
530
+ raise ValueError(f"Unknown ghost tool: {ghost_spec.name}")
531
+
532
+
533
+ def _patch_tool_access( # pylint: disable=protected-access
534
+ server: FastMCP,
535
+ interceptor: Callable,
536
+ _ghost_tool_names: set,
537
+ ) -> None:
538
+ """Fallback: Patch tool access if standard methods don't exist."""
539
+ if hasattr(server, "_tools"):
540
+
541
+ async def wrapped_execute(tool_name: str, arguments: dict, context: Any):
542
+ return await interceptor(name=tool_name, arguments=arguments, context=context)
543
+
544
+ if hasattr(server, "execute_tool"):
545
+ server.execute_tool = wrapped_execute
546
+
547
+
548
+ async def _call_tool_directly( # pylint: disable=protected-access
549
+ server: FastMCP, name: str, arguments: Optional[dict]
550
+ ) -> Any:
551
+ """Fallback: Call a tool directly if no handler is available."""
552
+ if hasattr(server, "get_tool"):
553
+ try:
554
+ tool = server.get_tool(name)
555
+ if tool and hasattr(tool, "fn"):
556
+ result = tool.fn(**(arguments or {}))
557
+ if hasattr(result, "__await__"):
558
+ result = await result
559
+ return result
560
+ except Exception as e:
561
+ print(f"Error calling tool via get_tool: {e}")
562
+
563
+ if hasattr(server, "_docket") and hasattr(server._docket, "tools"):
564
+ tools = server._docket.tools
565
+ if name in tools:
566
+ tool = tools[name]
567
+ if hasattr(tool, "fn"):
568
+ result = tool.fn(**(arguments or {}))
569
+ if hasattr(result, "__await__"):
570
+ result = await result
571
+ return result
572
+
573
+ raise ValueError(f"Tool not found: {name}")
File without changes