agent_os_kernel 3.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.
- agent_control_plane/__init__.py +662 -0
- agent_control_plane/a2a_adapter.py +543 -0
- agent_control_plane/adapter.py +417 -0
- agent_control_plane/agent_hibernation.py +394 -0
- agent_control_plane/agent_kernel.py +470 -0
- agent_control_plane/compliance.py +720 -0
- agent_control_plane/constraint_graphs.py +478 -0
- agent_control_plane/control_plane.py +854 -0
- agent_control_plane/example_executors.py +195 -0
- agent_control_plane/execution_engine.py +231 -0
- agent_control_plane/flight_recorder.py +846 -0
- agent_control_plane/governance_layer.py +435 -0
- agent_control_plane/hf_utils.py +563 -0
- agent_control_plane/interfaces/__init__.py +55 -0
- agent_control_plane/interfaces/kernel_interface.py +361 -0
- agent_control_plane/interfaces/plugin_interface.py +497 -0
- agent_control_plane/interfaces/protocol_interfaces.py +387 -0
- agent_control_plane/kernel_space.py +1009 -0
- agent_control_plane/langchain_adapter.py +424 -0
- agent_control_plane/lifecycle.py +3113 -0
- agent_control_plane/mcp_adapter.py +653 -0
- agent_control_plane/ml_safety.py +563 -0
- agent_control_plane/multimodal.py +727 -0
- agent_control_plane/mute_agent.py +422 -0
- agent_control_plane/observability.py +787 -0
- agent_control_plane/orchestrator.py +482 -0
- agent_control_plane/plugin_registry.py +750 -0
- agent_control_plane/policy_engine.py +954 -0
- agent_control_plane/process_isolation.py +777 -0
- agent_control_plane/shadow_mode.py +310 -0
- agent_control_plane/signals.py +493 -0
- agent_control_plane/supervisor_agents.py +430 -0
- agent_control_plane/time_travel_debugger.py +557 -0
- agent_control_plane/tool_registry.py +452 -0
- agent_control_plane/vfs.py +697 -0
- agent_kernel/__init__.py +69 -0
- agent_kernel/analyzer.py +435 -0
- agent_kernel/auditor.py +36 -0
- agent_kernel/completeness_auditor.py +237 -0
- agent_kernel/detector.py +203 -0
- agent_kernel/kernel.py +744 -0
- agent_kernel/memory_manager.py +85 -0
- agent_kernel/models.py +374 -0
- agent_kernel/nudge_mechanism.py +263 -0
- agent_kernel/outcome_analyzer.py +338 -0
- agent_kernel/patcher.py +582 -0
- agent_kernel/semantic_analyzer.py +316 -0
- agent_kernel/semantic_purge.py +349 -0
- agent_kernel/simulator.py +449 -0
- agent_kernel/teacher.py +85 -0
- agent_kernel/triage.py +152 -0
- agent_os/__init__.py +409 -0
- agent_os/_adversarial_impl.py +200 -0
- agent_os/_circuit_breaker_impl.py +232 -0
- agent_os/_mcp_metrics.py +193 -0
- agent_os/adversarial.py +20 -0
- agent_os/agents_compat.py +490 -0
- agent_os/audit_logger.py +135 -0
- agent_os/base_agent.py +651 -0
- agent_os/circuit_breaker.py +34 -0
- agent_os/cli/__init__.py +659 -0
- agent_os/cli/cmd_audit.py +128 -0
- agent_os/cli/cmd_init.py +152 -0
- agent_os/cli/cmd_policy.py +41 -0
- agent_os/cli/cmd_policy_gen.py +180 -0
- agent_os/cli/cmd_validate.py +258 -0
- agent_os/cli/mcp_scan.py +265 -0
- agent_os/cli/output.py +192 -0
- agent_os/cli/policy_checker.py +330 -0
- agent_os/compat.py +74 -0
- agent_os/constraint_graph.py +234 -0
- agent_os/content_governance.py +140 -0
- agent_os/context_budget.py +305 -0
- agent_os/credential_redactor.py +224 -0
- agent_os/diff_policy.py +89 -0
- agent_os/egress_policy.py +159 -0
- agent_os/escalation.py +276 -0
- agent_os/event_bus.py +124 -0
- agent_os/exceptions.py +180 -0
- agent_os/execution_context_policy.py +141 -0
- agent_os/github_enterprise.py +96 -0
- agent_os/health.py +20 -0
- agent_os/integrations/__init__.py +279 -0
- agent_os/integrations/a2a_adapter.py +279 -0
- agent_os/integrations/agent_lightning/__init__.py +30 -0
- agent_os/integrations/anthropic_adapter.py +420 -0
- agent_os/integrations/autogen_adapter.py +620 -0
- agent_os/integrations/base.py +1137 -0
- agent_os/integrations/compat.py +229 -0
- agent_os/integrations/config.py +98 -0
- agent_os/integrations/conversation_guardian.py +957 -0
- agent_os/integrations/crewai_adapter.py +467 -0
- agent_os/integrations/drift_detector.py +425 -0
- agent_os/integrations/dry_run.py +124 -0
- agent_os/integrations/escalation.py +582 -0
- agent_os/integrations/gemini_adapter.py +364 -0
- agent_os/integrations/google_adk_adapter.py +633 -0
- agent_os/integrations/guardrails_adapter.py +394 -0
- agent_os/integrations/health.py +197 -0
- agent_os/integrations/langchain_adapter.py +654 -0
- agent_os/integrations/llamafirewall.py +343 -0
- agent_os/integrations/llamaindex_adapter.py +188 -0
- agent_os/integrations/logging.py +191 -0
- agent_os/integrations/maf_adapter.py +631 -0
- agent_os/integrations/mistral_adapter.py +365 -0
- agent_os/integrations/openai_adapter.py +816 -0
- agent_os/integrations/openai_agents_sdk.py +406 -0
- agent_os/integrations/policy_compose.py +171 -0
- agent_os/integrations/profiling.py +144 -0
- agent_os/integrations/pydantic_ai_adapter.py +420 -0
- agent_os/integrations/rate_limiter.py +130 -0
- agent_os/integrations/rbac.py +143 -0
- agent_os/integrations/registry.py +113 -0
- agent_os/integrations/scope_guard.py +303 -0
- agent_os/integrations/semantic_kernel_adapter.py +769 -0
- agent_os/integrations/smolagents_adapter.py +629 -0
- agent_os/integrations/templates.py +178 -0
- agent_os/integrations/token_budget.py +134 -0
- agent_os/integrations/tool_aliases.py +190 -0
- agent_os/integrations/webhooks.py +177 -0
- agent_os/lite.py +208 -0
- agent_os/mcp_gateway.py +385 -0
- agent_os/mcp_message_signer.py +273 -0
- agent_os/mcp_protocols.py +161 -0
- agent_os/mcp_response_scanner.py +232 -0
- agent_os/mcp_security.py +924 -0
- agent_os/mcp_session_auth.py +231 -0
- agent_os/mcp_sliding_rate_limiter.py +184 -0
- agent_os/memory_guard.py +409 -0
- agent_os/metrics.py +134 -0
- agent_os/mute.py +428 -0
- agent_os/mute_agent.py +209 -0
- agent_os/policies/__init__.py +77 -0
- agent_os/policies/async_evaluator.py +275 -0
- agent_os/policies/backends.py +670 -0
- agent_os/policies/bridge.py +169 -0
- agent_os/policies/budget.py +85 -0
- agent_os/policies/cli.py +294 -0
- agent_os/policies/conflict_resolution.py +270 -0
- agent_os/policies/data_classification.py +252 -0
- agent_os/policies/evaluator.py +239 -0
- agent_os/policies/policy_schema.json +228 -0
- agent_os/policies/rate_limiting.py +145 -0
- agent_os/policies/schema.py +115 -0
- agent_os/policies/shared.py +331 -0
- agent_os/prompt_injection.py +694 -0
- agent_os/providers.py +182 -0
- agent_os/py.typed +0 -0
- agent_os/retry.py +81 -0
- agent_os/reversibility.py +251 -0
- agent_os/sandbox.py +432 -0
- agent_os/sandbox_provider.py +140 -0
- agent_os/secure_codegen.py +525 -0
- agent_os/security_skills.py +538 -0
- agent_os/semantic_policy.py +422 -0
- agent_os/server/__init__.py +15 -0
- agent_os/server/__main__.py +25 -0
- agent_os/server/app.py +277 -0
- agent_os/server/models.py +104 -0
- agent_os/shift_left_metrics.py +130 -0
- agent_os/stateless.py +742 -0
- agent_os/supervisor.py +148 -0
- agent_os/task_outcome.py +148 -0
- agent_os/transparency.py +181 -0
- agent_os/trust_root.py +128 -0
- agent_os_kernel-3.1.0.dist-info/METADATA +1269 -0
- agent_os_kernel-3.1.0.dist-info/RECORD +337 -0
- agent_os_kernel-3.1.0.dist-info/WHEEL +4 -0
- agent_os_kernel-3.1.0.dist-info/entry_points.txt +2 -0
- agent_os_kernel-3.1.0.dist-info/licenses/LICENSE +21 -0
- agent_os_observability/__init__.py +27 -0
- agent_os_observability/dashboards.py +898 -0
- agent_os_observability/metrics.py +398 -0
- agent_os_observability/server.py +223 -0
- agent_os_observability/tracer.py +232 -0
- agent_primitives/__init__.py +24 -0
- agent_primitives/failures.py +84 -0
- agent_primitives/py.typed +0 -0
- amb_core/__init__.py +177 -0
- amb_core/adapters/__init__.py +57 -0
- amb_core/adapters/aws_sqs_broker.py +376 -0
- amb_core/adapters/azure_servicebus_broker.py +340 -0
- amb_core/adapters/kafka_broker.py +260 -0
- amb_core/adapters/nats_broker.py +285 -0
- amb_core/adapters/rabbitmq_broker.py +235 -0
- amb_core/adapters/redis_broker.py +262 -0
- amb_core/broker.py +145 -0
- amb_core/bus.py +481 -0
- amb_core/cloudevents.py +509 -0
- amb_core/dlq.py +345 -0
- amb_core/hf_utils.py +536 -0
- amb_core/memory_broker.py +410 -0
- amb_core/models.py +141 -0
- amb_core/persistence.py +529 -0
- amb_core/schema.py +294 -0
- amb_core/tracing.py +358 -0
- atr/__init__.py +640 -0
- atr/access.py +348 -0
- atr/composition.py +645 -0
- atr/decorator.py +357 -0
- atr/executor.py +384 -0
- atr/health.py +557 -0
- atr/hf_utils.py +449 -0
- atr/injection.py +422 -0
- atr/metrics.py +440 -0
- atr/policies.py +403 -0
- atr/py.typed +2 -0
- atr/registry.py +452 -0
- atr/schema.py +480 -0
- atr/tools/safe/__init__.py +75 -0
- atr/tools/safe/calculator.py +467 -0
- atr/tools/safe/datetime_tool.py +443 -0
- atr/tools/safe/file_reader.py +402 -0
- atr/tools/safe/http_client.py +316 -0
- atr/tools/safe/json_parser.py +374 -0
- atr/tools/safe/text_tool.py +537 -0
- atr/tools/safe/toolkit.py +175 -0
- caas/__init__.py +162 -0
- caas/api/__init__.py +7 -0
- caas/api/server.py +1328 -0
- caas/caching.py +834 -0
- caas/cli.py +210 -0
- caas/conversation.py +223 -0
- caas/decay.py +72 -0
- caas/detection/__init__.py +9 -0
- caas/detection/detector.py +238 -0
- caas/enrichment.py +130 -0
- caas/gateway/__init__.py +27 -0
- caas/gateway/trust_gateway.py +474 -0
- caas/hf_utils.py +479 -0
- caas/ingestion/__init__.py +23 -0
- caas/ingestion/processors.py +253 -0
- caas/ingestion/structure_parser.py +188 -0
- caas/models.py +356 -0
- caas/pragmatic_truth.py +444 -0
- caas/routing/__init__.py +10 -0
- caas/routing/heuristic_router.py +58 -0
- caas/storage/__init__.py +9 -0
- caas/storage/store.py +389 -0
- caas/triad.py +213 -0
- caas/tuning/__init__.py +9 -0
- caas/tuning/tuner.py +329 -0
- caas/vfs/__init__.py +14 -0
- caas/vfs/filesystem.py +452 -0
- cmvk/__init__.py +218 -0
- cmvk/audit.py +402 -0
- cmvk/benchmarks.py +478 -0
- cmvk/constitutional.py +904 -0
- cmvk/hf_utils.py +301 -0
- cmvk/metrics.py +473 -0
- cmvk/profiles.py +300 -0
- cmvk/py.typed +0 -0
- cmvk/types.py +12 -0
- cmvk/verification.py +956 -0
- emk/__init__.py +89 -0
- emk/causal.py +352 -0
- emk/hf_utils.py +421 -0
- emk/indexer.py +83 -0
- emk/py.typed +0 -0
- emk/schema.py +204 -0
- emk/sleep_cycle.py +347 -0
- emk/store.py +281 -0
- iatp/__init__.py +166 -0
- iatp/attestation.py +461 -0
- iatp/cli.py +317 -0
- iatp/hf_utils.py +472 -0
- iatp/ipc_pipes.py +580 -0
- iatp/main.py +412 -0
- iatp/models/__init__.py +447 -0
- iatp/policy_engine.py +337 -0
- iatp/py.typed +2 -0
- iatp/recovery.py +321 -0
- iatp/security/__init__.py +270 -0
- iatp/sidecar/__init__.py +519 -0
- iatp/telemetry/__init__.py +164 -0
- iatp/tests/__init__.py +1 -0
- iatp/tests/test_attestation.py +370 -0
- iatp/tests/test_cli.py +131 -0
- iatp/tests/test_ed25519_attestation.py +211 -0
- iatp/tests/test_models.py +130 -0
- iatp/tests/test_policy_engine.py +347 -0
- iatp/tests/test_recovery.py +281 -0
- iatp/tests/test_security.py +222 -0
- iatp/tests/test_sidecar.py +167 -0
- iatp/tests/test_telemetry.py +175 -0
- mcp_kernel_server/__init__.py +28 -0
- mcp_kernel_server/cli.py +274 -0
- mcp_kernel_server/resources.py +217 -0
- mcp_kernel_server/server.py +564 -0
- mcp_kernel_server/tools.py +1174 -0
- mute_agent/__init__.py +68 -0
- mute_agent/core/__init__.py +1 -0
- mute_agent/core/execution_agent.py +166 -0
- mute_agent/core/handshake_protocol.py +201 -0
- mute_agent/core/reasoning_agent.py +238 -0
- mute_agent/knowledge_graph/__init__.py +1 -0
- mute_agent/knowledge_graph/graph_elements.py +65 -0
- mute_agent/knowledge_graph/multidimensional_graph.py +170 -0
- mute_agent/knowledge_graph/subgraph.py +224 -0
- mute_agent/listener/__init__.py +43 -0
- mute_agent/listener/adapters/__init__.py +31 -0
- mute_agent/listener/adapters/base_adapter.py +189 -0
- mute_agent/listener/adapters/caas_adapter.py +344 -0
- mute_agent/listener/adapters/control_plane_adapter.py +436 -0
- mute_agent/listener/adapters/iatp_adapter.py +332 -0
- mute_agent/listener/adapters/scak_adapter.py +251 -0
- mute_agent/listener/listener.py +610 -0
- mute_agent/listener/state_observer.py +436 -0
- mute_agent/listener/threshold_config.py +313 -0
- mute_agent/super_system/__init__.py +1 -0
- mute_agent/super_system/router.py +204 -0
- mute_agent/visualization/__init__.py +10 -0
- mute_agent/visualization/graph_debugger.py +502 -0
- nexus/README.md +60 -0
- nexus/__init__.py +51 -0
- nexus/arbiter.py +359 -0
- nexus/client.py +466 -0
- nexus/dmz.py +444 -0
- nexus/escrow.py +430 -0
- nexus/exceptions.py +286 -0
- nexus/pyproject.toml +36 -0
- nexus/registry.py +393 -0
- nexus/reputation.py +425 -0
- nexus/schemas/__init__.py +51 -0
- nexus/schemas/compliance.py +276 -0
- nexus/schemas/escrow.py +251 -0
- nexus/schemas/manifest.py +225 -0
- nexus/schemas/receipt.py +208 -0
- nexus/tests/__init__.py +0 -0
- nexus/tests/conftest.py +146 -0
- nexus/tests/test_arbiter.py +192 -0
- nexus/tests/test_dmz.py +194 -0
- nexus/tests/test_escrow.py +276 -0
- nexus/tests/test_exceptions.py +225 -0
- nexus/tests/test_registry.py +232 -0
- nexus/tests/test_reputation.py +328 -0
- nexus/tests/test_schemas.py +295 -0
agent_os/mcp_security.py
ADDED
|
@@ -0,0 +1,924 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
"""MCP Security — defense against tool poisoning, rug pulls, and protocol attacks.
|
|
4
|
+
|
|
5
|
+
Screens MCP tool definitions for adversarial manipulation where attackers
|
|
6
|
+
embed hidden instructions in tool descriptions/metadata that are invisible
|
|
7
|
+
to users but executed by LLMs.
|
|
8
|
+
|
|
9
|
+
Public Preview protections:
|
|
10
|
+
- **Tool poisoning detection**: Catches hidden instructions, invisible
|
|
11
|
+
unicode, markdown/HTML comments, and encoded payloads in tool
|
|
12
|
+
descriptions.
|
|
13
|
+
- **Description injection**: Detects prompt injection patterns
|
|
14
|
+
embedded within MCP tool metadata.
|
|
15
|
+
- **Schema abuse**: Flags overly permissive schemas, hidden required
|
|
16
|
+
fields, and instruction-bearing default values.
|
|
17
|
+
- **Rug pull detection**: Fingerprints registered tools and alerts
|
|
18
|
+
when descriptions or schemas change silently between sessions.
|
|
19
|
+
- **Cross-server attacks**: Detects tool impersonation and
|
|
20
|
+
typosquatting across MCP server boundaries.
|
|
21
|
+
- **Audit trail**: Logs every scan with timestamp and tool identity
|
|
22
|
+
for forensic review.
|
|
23
|
+
|
|
24
|
+
Architecture:
|
|
25
|
+
MCPSecurityScanner
|
|
26
|
+
├─ scan_tool() — scan a single tool definition
|
|
27
|
+
├─ scan_server() — batch-scan all tools from a server
|
|
28
|
+
├─ register_tool() — fingerprint a tool for rug-pull detection
|
|
29
|
+
├─ check_rug_pull() — compare current definition to fingerprint
|
|
30
|
+
└─ audit_log — inspection trail
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import base64
|
|
36
|
+
import hashlib
|
|
37
|
+
import json
|
|
38
|
+
import logging
|
|
39
|
+
import os
|
|
40
|
+
import re
|
|
41
|
+
import time
|
|
42
|
+
import warnings
|
|
43
|
+
from dataclasses import dataclass, field
|
|
44
|
+
from datetime import datetime, timezone
|
|
45
|
+
from enum import Enum
|
|
46
|
+
from typing import Any, Callable
|
|
47
|
+
|
|
48
|
+
from agent_os._mcp_metrics import MCPMetrics, MCPMetricsRecorder
|
|
49
|
+
from agent_os.mcp_protocols import InMemoryAuditSink, MCPAuditSink
|
|
50
|
+
from agent_os.prompt_injection import PromptInjectionDetector
|
|
51
|
+
|
|
52
|
+
logger = logging.getLogger(__name__)
|
|
53
|
+
|
|
54
|
+
_SAMPLE_DISCLAIMER = (
|
|
55
|
+
"\u26a0\ufe0f These are SAMPLE MCP security rules provided as a starting point. "
|
|
56
|
+
"You MUST review, customise, and extend them for your specific use case "
|
|
57
|
+
"before deploying to production."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# Data models
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class MCPThreatType(Enum):
|
|
67
|
+
"""Classification of an MCP-layer threat."""
|
|
68
|
+
|
|
69
|
+
TOOL_POISONING = "tool_poisoning"
|
|
70
|
+
RUG_PULL = "rug_pull"
|
|
71
|
+
CROSS_SERVER_ATTACK = "cross_server_attack"
|
|
72
|
+
CONFUSED_DEPUTY = "confused_deputy"
|
|
73
|
+
HIDDEN_INSTRUCTION = "hidden_instruction"
|
|
74
|
+
DESCRIPTION_INJECTION = "description_injection"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class MCPSeverity(Enum):
|
|
78
|
+
"""Severity of an MCP threat."""
|
|
79
|
+
|
|
80
|
+
INFO = "info"
|
|
81
|
+
WARNING = "warning"
|
|
82
|
+
CRITICAL = "critical"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class MCPThreat:
|
|
87
|
+
"""A single threat finding from an MCP tool scan."""
|
|
88
|
+
|
|
89
|
+
threat_type: MCPThreatType
|
|
90
|
+
severity: MCPSeverity
|
|
91
|
+
tool_name: str
|
|
92
|
+
server_name: str
|
|
93
|
+
message: str
|
|
94
|
+
matched_pattern: str | None = None
|
|
95
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class ToolFingerprint:
|
|
100
|
+
"""Cryptographic fingerprint of a tool definition."""
|
|
101
|
+
|
|
102
|
+
tool_name: str
|
|
103
|
+
server_name: str
|
|
104
|
+
description_hash: str
|
|
105
|
+
schema_hash: str
|
|
106
|
+
first_seen: float
|
|
107
|
+
last_seen: float
|
|
108
|
+
version: int
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class ScanResult:
|
|
113
|
+
"""Aggregate outcome of scanning one or more tools."""
|
|
114
|
+
|
|
115
|
+
safe: bool
|
|
116
|
+
threats: list[MCPThreat]
|
|
117
|
+
tools_scanned: int
|
|
118
|
+
tools_flagged: int
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
# Detection patterns (compiled at import time, following memory_guard.py style)
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
# Invisible unicode characters used to hide instructions
|
|
126
|
+
_INVISIBLE_UNICODE_PATTERNS: list[re.Pattern[str]] = [
|
|
127
|
+
re.compile(r"[\u200b\u200c\u200d\ufeff]"), # zero-width spaces/joiners/BOM
|
|
128
|
+
re.compile(r"[\u202a-\u202e]"), # bidi embedding/override
|
|
129
|
+
re.compile(r"[\u2066-\u2069]"), # bidi isolates
|
|
130
|
+
re.compile(r"[\u00ad]"), # soft hyphen
|
|
131
|
+
re.compile(r"[\u2060\u180e]"), # word joiner, mongolian vowel separator
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
# Markdown/HTML comments that hide text from users
|
|
135
|
+
_HIDDEN_COMMENT_PATTERNS: list[re.Pattern[str]] = [
|
|
136
|
+
re.compile(r"<!--.*?-->", re.DOTALL), # HTML comments
|
|
137
|
+
re.compile(r"\[//\]:\s*#\s*\(.*?\)", re.DOTALL), # Markdown reference comments
|
|
138
|
+
re.compile(r"\[comment\]:\s*<>\s*\(.*?\)", re.DOTALL), # alternative MD comment
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
# Instruction-like patterns hidden in descriptions
|
|
142
|
+
_HIDDEN_INSTRUCTION_PATTERNS: list[re.Pattern[str]] = [
|
|
143
|
+
re.compile(r"ignore\s+(all\s+)?previous", re.IGNORECASE),
|
|
144
|
+
re.compile(r"override\s+(the\s+)?(previous|above|original)", re.IGNORECASE),
|
|
145
|
+
re.compile(r"instead\s+of\s+(the\s+)?(above|previous|described)", re.IGNORECASE),
|
|
146
|
+
re.compile(r"actually\s+do", re.IGNORECASE),
|
|
147
|
+
re.compile(r"\bsystem\s*:", re.IGNORECASE),
|
|
148
|
+
re.compile(r"\bassistant\s*:", re.IGNORECASE),
|
|
149
|
+
re.compile(r"do\s+not\s+follow", re.IGNORECASE),
|
|
150
|
+
re.compile(r"disregard\s+(all\s+)?(above|prior|previous)", re.IGNORECASE),
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
# Encoded payload patterns
|
|
154
|
+
_ENCODED_PAYLOAD_PATTERNS: list[re.Pattern[str]] = [
|
|
155
|
+
re.compile(r"[A-Za-z0-9+/]{40,}={0,2}"), # long base64 strings
|
|
156
|
+
re.compile(r"(?:\\x[0-9a-fA-F]{2}){4,}"), # hex sequences
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
# Data exfiltration patterns
|
|
160
|
+
_EXFILTRATION_PATTERNS: list[re.Pattern[str]] = [
|
|
161
|
+
re.compile(r"\bcurl\b", re.IGNORECASE),
|
|
162
|
+
re.compile(r"\bwget\b", re.IGNORECASE),
|
|
163
|
+
re.compile(r"\bfetch\s*\(", re.IGNORECASE),
|
|
164
|
+
re.compile(r"https?://", re.IGNORECASE),
|
|
165
|
+
re.compile(r"\bsend\s+email\b", re.IGNORECASE),
|
|
166
|
+
re.compile(r"\bsend\s+to\b", re.IGNORECASE),
|
|
167
|
+
re.compile(r"\bpost\s+to\b", re.IGNORECASE),
|
|
168
|
+
re.compile(r"include\s+the\s+contents?\s+of\b", re.IGNORECASE),
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
# Privilege escalation in descriptions
|
|
172
|
+
_PRIVILEGE_ESCALATION_PATTERNS: list[re.Pattern[str]] = [
|
|
173
|
+
re.compile(r"\bsudo\b", re.IGNORECASE),
|
|
174
|
+
re.compile(r"\badmin\s+access\b", re.IGNORECASE),
|
|
175
|
+
re.compile(r"\broot\s+access\b", re.IGNORECASE),
|
|
176
|
+
re.compile(r"\belevate\s+privile", re.IGNORECASE),
|
|
177
|
+
re.compile(r"\bexec\s*\(", re.IGNORECASE),
|
|
178
|
+
re.compile(r"\beval\s*\(", re.IGNORECASE),
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
# Role override patterns
|
|
182
|
+
_ROLE_OVERRIDE_PATTERNS: list[re.Pattern[str]] = [
|
|
183
|
+
re.compile(r"you\s+are\b", re.IGNORECASE),
|
|
184
|
+
re.compile(r"your\s+task\s+is\b", re.IGNORECASE),
|
|
185
|
+
re.compile(r"respond\s+with\b", re.IGNORECASE),
|
|
186
|
+
re.compile(r"always\s+return\b", re.IGNORECASE),
|
|
187
|
+
re.compile(r"you\s+must\b", re.IGNORECASE),
|
|
188
|
+
re.compile(r"your\s+role\s+is\b", re.IGNORECASE),
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
# Content after excessive whitespace (hidden instructions at the end)
|
|
192
|
+
_EXCESSIVE_WHITESPACE_PATTERN: re.Pattern[str] = re.compile(r"\n{5,}.+", re.DOTALL)
|
|
193
|
+
|
|
194
|
+
# Suspicious keywords in decoded base64
|
|
195
|
+
_SUSPICIOUS_DECODED_KEYWORDS: list[str] = [
|
|
196
|
+
"ignore",
|
|
197
|
+
"override",
|
|
198
|
+
"system",
|
|
199
|
+
"password",
|
|
200
|
+
"secret",
|
|
201
|
+
"admin",
|
|
202
|
+
"root",
|
|
203
|
+
"exec",
|
|
204
|
+
"eval",
|
|
205
|
+
"import os",
|
|
206
|
+
"send",
|
|
207
|
+
"curl",
|
|
208
|
+
"fetch",
|
|
209
|
+
]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
# Externalised configuration dataclass
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass
|
|
218
|
+
class MCPSecurityConfig:
|
|
219
|
+
"""Structured configuration for MCP security scanning, loadable from YAML.
|
|
220
|
+
|
|
221
|
+
Attributes:
|
|
222
|
+
invisible_unicode_patterns: Regex strings for invisible unicode detection.
|
|
223
|
+
hidden_comment_patterns: Regex strings for hidden comments.
|
|
224
|
+
hidden_instruction_patterns: Regex strings for instruction-like text.
|
|
225
|
+
encoded_payload_patterns: Regex strings for encoded payloads.
|
|
226
|
+
exfiltration_patterns: Regex strings for data exfiltration.
|
|
227
|
+
privilege_escalation_patterns: Regex strings for privilege escalation.
|
|
228
|
+
role_override_patterns: Regex strings for role overrides.
|
|
229
|
+
excessive_whitespace_pattern: Regex string for excessive whitespace.
|
|
230
|
+
suspicious_decoded_keywords: Keywords to check in decoded payloads.
|
|
231
|
+
disclaimer: Disclaimer text shown in logs.
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
invisible_unicode_patterns: list[str] = field(
|
|
235
|
+
default_factory=lambda: [p.pattern for p in _INVISIBLE_UNICODE_PATTERNS]
|
|
236
|
+
)
|
|
237
|
+
hidden_comment_patterns: list[str] = field(
|
|
238
|
+
default_factory=lambda: [p.pattern for p in _HIDDEN_COMMENT_PATTERNS]
|
|
239
|
+
)
|
|
240
|
+
hidden_instruction_patterns: list[str] = field(
|
|
241
|
+
default_factory=lambda: [p.pattern for p in _HIDDEN_INSTRUCTION_PATTERNS]
|
|
242
|
+
)
|
|
243
|
+
encoded_payload_patterns: list[str] = field(
|
|
244
|
+
default_factory=lambda: [p.pattern for p in _ENCODED_PAYLOAD_PATTERNS]
|
|
245
|
+
)
|
|
246
|
+
exfiltration_patterns: list[str] = field(
|
|
247
|
+
default_factory=lambda: [p.pattern for p in _EXFILTRATION_PATTERNS]
|
|
248
|
+
)
|
|
249
|
+
privilege_escalation_patterns: list[str] = field(
|
|
250
|
+
default_factory=lambda: [p.pattern for p in _PRIVILEGE_ESCALATION_PATTERNS]
|
|
251
|
+
)
|
|
252
|
+
role_override_patterns: list[str] = field(
|
|
253
|
+
default_factory=lambda: [p.pattern for p in _ROLE_OVERRIDE_PATTERNS]
|
|
254
|
+
)
|
|
255
|
+
excessive_whitespace_pattern: str = field(
|
|
256
|
+
default_factory=lambda: _EXCESSIVE_WHITESPACE_PATTERN.pattern
|
|
257
|
+
)
|
|
258
|
+
suspicious_decoded_keywords: list[str] = field(
|
|
259
|
+
default_factory=lambda: list(_SUSPICIOUS_DECODED_KEYWORDS)
|
|
260
|
+
)
|
|
261
|
+
disclaimer: str = ""
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def load_mcp_security_config(path: str) -> MCPSecurityConfig:
|
|
265
|
+
"""Load MCP security configuration from a YAML file.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
path: Path to a YAML file with a ``detection_patterns`` section.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
MCPSecurityConfig populated from the YAML data.
|
|
272
|
+
|
|
273
|
+
Raises:
|
|
274
|
+
FileNotFoundError: If the config file does not exist.
|
|
275
|
+
ValueError: If the YAML is missing the ``detection_patterns`` section.
|
|
276
|
+
"""
|
|
277
|
+
import yaml
|
|
278
|
+
|
|
279
|
+
if not os.path.exists(path):
|
|
280
|
+
raise FileNotFoundError(f"MCP security config not found: {path}")
|
|
281
|
+
|
|
282
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
283
|
+
data = yaml.safe_load(fh.read())
|
|
284
|
+
|
|
285
|
+
if not isinstance(data, dict) or "detection_patterns" not in data:
|
|
286
|
+
raise ValueError(f"YAML file must contain a 'detection_patterns' section: {path}")
|
|
287
|
+
|
|
288
|
+
dp = data["detection_patterns"]
|
|
289
|
+
return MCPSecurityConfig(
|
|
290
|
+
invisible_unicode_patterns=dp.get(
|
|
291
|
+
"invisible_unicode", [p.pattern for p in _INVISIBLE_UNICODE_PATTERNS]
|
|
292
|
+
),
|
|
293
|
+
hidden_comment_patterns=dp.get(
|
|
294
|
+
"hidden_comments", [p.pattern for p in _HIDDEN_COMMENT_PATTERNS]
|
|
295
|
+
),
|
|
296
|
+
hidden_instruction_patterns=dp.get(
|
|
297
|
+
"hidden_instructions", [p.pattern for p in _HIDDEN_INSTRUCTION_PATTERNS]
|
|
298
|
+
),
|
|
299
|
+
encoded_payload_patterns=dp.get(
|
|
300
|
+
"encoded_payloads", [p.pattern for p in _ENCODED_PAYLOAD_PATTERNS]
|
|
301
|
+
),
|
|
302
|
+
exfiltration_patterns=dp.get("exfiltration", [p.pattern for p in _EXFILTRATION_PATTERNS]),
|
|
303
|
+
privilege_escalation_patterns=dp.get(
|
|
304
|
+
"privilege_escalation", [p.pattern for p in _PRIVILEGE_ESCALATION_PATTERNS]
|
|
305
|
+
),
|
|
306
|
+
role_override_patterns=dp.get(
|
|
307
|
+
"role_override", [p.pattern for p in _ROLE_OVERRIDE_PATTERNS]
|
|
308
|
+
),
|
|
309
|
+
excessive_whitespace_pattern=dp.get(
|
|
310
|
+
"excessive_whitespace", _EXCESSIVE_WHITESPACE_PATTERN.pattern
|
|
311
|
+
),
|
|
312
|
+
suspicious_decoded_keywords=data.get(
|
|
313
|
+
"suspicious_decoded_keywords", list(_SUSPICIOUS_DECODED_KEYWORDS)
|
|
314
|
+
),
|
|
315
|
+
disclaimer=data.get("disclaimer", ""),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
# ---------------------------------------------------------------------------
|
|
320
|
+
# MCPSecurityScanner
|
|
321
|
+
# ---------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class MCPSecurityScanner:
|
|
325
|
+
"""Scans MCP tool definitions for poisoning, rug pulls, and protocol attacks.
|
|
326
|
+
|
|
327
|
+
Usage::
|
|
328
|
+
|
|
329
|
+
scanner = MCPSecurityScanner()
|
|
330
|
+
threats = scanner.scan_tool(
|
|
331
|
+
"search", "Search the web for information",
|
|
332
|
+
server_name="web-tools"
|
|
333
|
+
)
|
|
334
|
+
if threats:
|
|
335
|
+
print(f"Found {len(threats)} threat(s)")
|
|
336
|
+
"""
|
|
337
|
+
|
|
338
|
+
def __init__(
|
|
339
|
+
self,
|
|
340
|
+
metrics: MCPMetricsRecorder | None = None,
|
|
341
|
+
*,
|
|
342
|
+
audit_sink: MCPAuditSink | None = None,
|
|
343
|
+
clock: Callable[[], float] = time.time,
|
|
344
|
+
) -> None:
|
|
345
|
+
warnings.warn(
|
|
346
|
+
"MCPSecurityScanner() uses built-in sample rules that may not "
|
|
347
|
+
"cover all MCP tool poisoning techniques. For production use, load an "
|
|
348
|
+
"explicit config with load_mcp_security_config(). "
|
|
349
|
+
"See examples/policies/mcp-security.yaml for a sample configuration.",
|
|
350
|
+
stacklevel=2,
|
|
351
|
+
)
|
|
352
|
+
self._tool_registry: dict[str, ToolFingerprint] = {}
|
|
353
|
+
self._audit_log: list[dict[str, Any]] = []
|
|
354
|
+
self._injection_detector = PromptInjectionDetector()
|
|
355
|
+
self._metrics = metrics or MCPMetrics()
|
|
356
|
+
self._audit_sink = audit_sink or InMemoryAuditSink()
|
|
357
|
+
self._clock = clock
|
|
358
|
+
|
|
359
|
+
# -- public API ---------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
def scan_tool(
|
|
362
|
+
self,
|
|
363
|
+
tool_name: str,
|
|
364
|
+
description: str,
|
|
365
|
+
schema: dict[str, Any] | None = None,
|
|
366
|
+
server_name: str = "unknown",
|
|
367
|
+
) -> list[MCPThreat]:
|
|
368
|
+
"""Scan a single MCP tool definition for threats.
|
|
369
|
+
|
|
370
|
+
Args:
|
|
371
|
+
tool_name: Name of the tool.
|
|
372
|
+
description: Tool description (primary attack surface).
|
|
373
|
+
schema: Optional JSON Schema for tool inputs.
|
|
374
|
+
server_name: Name of the MCP server providing this tool.
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
List of ``MCPThreat`` findings (empty if clean).
|
|
378
|
+
"""
|
|
379
|
+
try:
|
|
380
|
+
threats: list[MCPThreat] = []
|
|
381
|
+
|
|
382
|
+
threats.extend(self._check_hidden_instructions(description, tool_name, server_name))
|
|
383
|
+
threats.extend(self._check_description_injection(description, tool_name, server_name))
|
|
384
|
+
if schema is not None:
|
|
385
|
+
threats.extend(self._check_schema_abuse(schema, tool_name, server_name))
|
|
386
|
+
threats.extend(self._check_cross_server(tool_name, server_name))
|
|
387
|
+
|
|
388
|
+
rug_pull = self.check_rug_pull(tool_name, description, schema, server_name)
|
|
389
|
+
if rug_pull is not None:
|
|
390
|
+
threats.append(rug_pull)
|
|
391
|
+
|
|
392
|
+
self._record_audit("scan_tool", tool_name, server_name, threats)
|
|
393
|
+
self._metrics.record_scan(
|
|
394
|
+
operation="scan_tool",
|
|
395
|
+
tool_name=tool_name,
|
|
396
|
+
server_name=server_name,
|
|
397
|
+
)
|
|
398
|
+
self._metrics.record_threats_detected(
|
|
399
|
+
len(threats),
|
|
400
|
+
tool_name=tool_name,
|
|
401
|
+
server_name=server_name,
|
|
402
|
+
)
|
|
403
|
+
return threats
|
|
404
|
+
except Exception:
|
|
405
|
+
logger.error(
|
|
406
|
+
"MCP tool scan failed closed | tool=%s server=%s",
|
|
407
|
+
tool_name,
|
|
408
|
+
server_name,
|
|
409
|
+
exc_info=True,
|
|
410
|
+
)
|
|
411
|
+
return [
|
|
412
|
+
MCPThreat(
|
|
413
|
+
threat_type=MCPThreatType.TOOL_POISONING,
|
|
414
|
+
severity=MCPSeverity.CRITICAL,
|
|
415
|
+
tool_name=tool_name,
|
|
416
|
+
server_name=server_name,
|
|
417
|
+
message="Scan error \u2014 fail closed",
|
|
418
|
+
)
|
|
419
|
+
]
|
|
420
|
+
|
|
421
|
+
def scan_server(
|
|
422
|
+
self,
|
|
423
|
+
server_name: str,
|
|
424
|
+
tools: list[dict[str, Any]],
|
|
425
|
+
) -> ScanResult:
|
|
426
|
+
"""Scan all tools from an MCP server.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
server_name: Name of the MCP server.
|
|
430
|
+
tools: List of tool dicts with keys: ``name``, ``description``,
|
|
431
|
+
and optionally ``inputSchema``.
|
|
432
|
+
|
|
433
|
+
Returns:
|
|
434
|
+
Aggregate ``ScanResult``.
|
|
435
|
+
"""
|
|
436
|
+
all_threats: list[MCPThreat] = []
|
|
437
|
+
flagged_tools: set[str] = set()
|
|
438
|
+
|
|
439
|
+
for tool in tools:
|
|
440
|
+
name = tool.get("name", "unknown")
|
|
441
|
+
description = tool.get("description", "")
|
|
442
|
+
schema = tool.get("inputSchema")
|
|
443
|
+
tool_threats = self.scan_tool(name, description, schema, server_name)
|
|
444
|
+
if tool_threats:
|
|
445
|
+
flagged_tools.add(name)
|
|
446
|
+
all_threats.extend(tool_threats)
|
|
447
|
+
|
|
448
|
+
self._metrics.record_scan(
|
|
449
|
+
operation="scan_server",
|
|
450
|
+
tool_name="*",
|
|
451
|
+
server_name=server_name,
|
|
452
|
+
)
|
|
453
|
+
return ScanResult(
|
|
454
|
+
safe=len(all_threats) == 0,
|
|
455
|
+
threats=all_threats,
|
|
456
|
+
tools_scanned=len(tools),
|
|
457
|
+
tools_flagged=len(flagged_tools),
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
def register_tool(
|
|
461
|
+
self,
|
|
462
|
+
tool_name: str,
|
|
463
|
+
description: str,
|
|
464
|
+
schema: dict[str, Any] | None,
|
|
465
|
+
server_name: str,
|
|
466
|
+
) -> ToolFingerprint:
|
|
467
|
+
"""Register a tool with a cryptographic fingerprint.
|
|
468
|
+
|
|
469
|
+
If already registered, updates last_seen and increments version
|
|
470
|
+
only when the definition changed.
|
|
471
|
+
|
|
472
|
+
Returns:
|
|
473
|
+
The ``ToolFingerprint`` for this tool.
|
|
474
|
+
"""
|
|
475
|
+
key = f"{server_name}::{tool_name}"
|
|
476
|
+
now = self._clock()
|
|
477
|
+
desc_hash = hashlib.sha256(description.encode("utf-8")).hexdigest()
|
|
478
|
+
schema_hash = hashlib.sha256(
|
|
479
|
+
json.dumps(schema, sort_keys=True, default=str).encode("utf-8") if schema else b""
|
|
480
|
+
).hexdigest()
|
|
481
|
+
|
|
482
|
+
existing = self._tool_registry.get(key)
|
|
483
|
+
if existing is not None:
|
|
484
|
+
if existing.description_hash != desc_hash or existing.schema_hash != schema_hash:
|
|
485
|
+
existing.description_hash = desc_hash
|
|
486
|
+
existing.schema_hash = schema_hash
|
|
487
|
+
existing.last_seen = now
|
|
488
|
+
existing.version += 1
|
|
489
|
+
else:
|
|
490
|
+
existing.last_seen = now
|
|
491
|
+
return existing
|
|
492
|
+
|
|
493
|
+
fp = ToolFingerprint(
|
|
494
|
+
tool_name=tool_name,
|
|
495
|
+
server_name=server_name,
|
|
496
|
+
description_hash=desc_hash,
|
|
497
|
+
schema_hash=schema_hash,
|
|
498
|
+
first_seen=now,
|
|
499
|
+
last_seen=now,
|
|
500
|
+
version=1,
|
|
501
|
+
)
|
|
502
|
+
self._tool_registry[key] = fp
|
|
503
|
+
return fp
|
|
504
|
+
|
|
505
|
+
def check_rug_pull(
|
|
506
|
+
self,
|
|
507
|
+
tool_name: str,
|
|
508
|
+
description: str,
|
|
509
|
+
schema: dict[str, Any] | None,
|
|
510
|
+
server_name: str,
|
|
511
|
+
) -> MCPThreat | None:
|
|
512
|
+
"""Check if a tool definition changed since registration (rug pull).
|
|
513
|
+
|
|
514
|
+
Returns:
|
|
515
|
+
An ``MCPThreat`` if a rug pull is detected, else ``None``.
|
|
516
|
+
"""
|
|
517
|
+
key = f"{server_name}::{tool_name}"
|
|
518
|
+
existing = self._tool_registry.get(key)
|
|
519
|
+
if existing is None:
|
|
520
|
+
return None
|
|
521
|
+
|
|
522
|
+
desc_hash = hashlib.sha256(description.encode("utf-8")).hexdigest()
|
|
523
|
+
schema_hash = hashlib.sha256(
|
|
524
|
+
json.dumps(schema, sort_keys=True, default=str).encode("utf-8") if schema else b""
|
|
525
|
+
).hexdigest()
|
|
526
|
+
|
|
527
|
+
changes: list[str] = []
|
|
528
|
+
if existing.description_hash != desc_hash:
|
|
529
|
+
changes.append("description")
|
|
530
|
+
if existing.schema_hash != schema_hash:
|
|
531
|
+
changes.append("schema")
|
|
532
|
+
|
|
533
|
+
if changes:
|
|
534
|
+
return MCPThreat(
|
|
535
|
+
threat_type=MCPThreatType.RUG_PULL,
|
|
536
|
+
severity=MCPSeverity.CRITICAL,
|
|
537
|
+
tool_name=tool_name,
|
|
538
|
+
server_name=server_name,
|
|
539
|
+
message=(
|
|
540
|
+
f"Tool definition changed since registration: "
|
|
541
|
+
f"{', '.join(changes)} modified (version {existing.version})"
|
|
542
|
+
),
|
|
543
|
+
details={"changed_fields": changes, "version": existing.version},
|
|
544
|
+
)
|
|
545
|
+
return None
|
|
546
|
+
|
|
547
|
+
@property
|
|
548
|
+
def audit_log(self) -> list[dict[str, Any]]:
|
|
549
|
+
"""Return a copy of the scan audit history."""
|
|
550
|
+
return list(self._audit_log)
|
|
551
|
+
|
|
552
|
+
# -- private detection methods ------------------------------------------
|
|
553
|
+
|
|
554
|
+
def _check_hidden_instructions(
|
|
555
|
+
self,
|
|
556
|
+
description: str,
|
|
557
|
+
tool_name: str,
|
|
558
|
+
server_name: str,
|
|
559
|
+
) -> list[MCPThreat]:
|
|
560
|
+
"""Detect hidden instructions in tool descriptions."""
|
|
561
|
+
threats: list[MCPThreat] = []
|
|
562
|
+
|
|
563
|
+
# 1. Invisible unicode characters
|
|
564
|
+
for pattern in _INVISIBLE_UNICODE_PATTERNS:
|
|
565
|
+
match = pattern.search(description)
|
|
566
|
+
if match:
|
|
567
|
+
threats.append(
|
|
568
|
+
MCPThreat(
|
|
569
|
+
threat_type=MCPThreatType.HIDDEN_INSTRUCTION,
|
|
570
|
+
severity=MCPSeverity.CRITICAL,
|
|
571
|
+
tool_name=tool_name,
|
|
572
|
+
server_name=server_name,
|
|
573
|
+
message="Invisible unicode characters detected in tool description",
|
|
574
|
+
matched_pattern=pattern.pattern,
|
|
575
|
+
details={"char_ord": ord(match.group()[0])},
|
|
576
|
+
)
|
|
577
|
+
)
|
|
578
|
+
break # one finding per category is enough
|
|
579
|
+
|
|
580
|
+
# 2. Markdown/HTML comments hiding text
|
|
581
|
+
for pattern in _HIDDEN_COMMENT_PATTERNS:
|
|
582
|
+
match = pattern.search(description)
|
|
583
|
+
if match:
|
|
584
|
+
threats.append(
|
|
585
|
+
MCPThreat(
|
|
586
|
+
threat_type=MCPThreatType.HIDDEN_INSTRUCTION,
|
|
587
|
+
severity=MCPSeverity.CRITICAL,
|
|
588
|
+
tool_name=tool_name,
|
|
589
|
+
server_name=server_name,
|
|
590
|
+
message="Hidden comment detected in tool description",
|
|
591
|
+
matched_pattern=pattern.pattern,
|
|
592
|
+
details={"comment_preview": match.group()[:80]},
|
|
593
|
+
)
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
# 3. Encoded instructions (base64, hex)
|
|
597
|
+
for pattern in _ENCODED_PAYLOAD_PATTERNS:
|
|
598
|
+
match = pattern.search(description)
|
|
599
|
+
if match:
|
|
600
|
+
candidate = match.group()
|
|
601
|
+
# For base64, try to decode and check for suspicious content
|
|
602
|
+
is_suspicious = False
|
|
603
|
+
if len(candidate) >= 40 and not candidate.startswith("\\x"):
|
|
604
|
+
try:
|
|
605
|
+
decoded = base64.b64decode(candidate).decode("utf-8", errors="ignore")
|
|
606
|
+
decoded_lower = decoded.lower()
|
|
607
|
+
for keyword in _SUSPICIOUS_DECODED_KEYWORDS:
|
|
608
|
+
if keyword in decoded_lower:
|
|
609
|
+
is_suspicious = True
|
|
610
|
+
break
|
|
611
|
+
except Exception:
|
|
612
|
+
pass
|
|
613
|
+
if not is_suspicious:
|
|
614
|
+
# Long base64 in a tool description is suspicious regardless
|
|
615
|
+
is_suspicious = True
|
|
616
|
+
|
|
617
|
+
if is_suspicious or candidate.startswith("\\x"):
|
|
618
|
+
threats.append(
|
|
619
|
+
MCPThreat(
|
|
620
|
+
threat_type=MCPThreatType.HIDDEN_INSTRUCTION,
|
|
621
|
+
severity=MCPSeverity.WARNING,
|
|
622
|
+
tool_name=tool_name,
|
|
623
|
+
server_name=server_name,
|
|
624
|
+
message="Encoded payload detected in tool description",
|
|
625
|
+
matched_pattern=pattern.pattern,
|
|
626
|
+
)
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
# 4. Hidden instructions after excessive whitespace/newlines
|
|
630
|
+
if _EXCESSIVE_WHITESPACE_PATTERN.search(description):
|
|
631
|
+
threats.append(
|
|
632
|
+
MCPThreat(
|
|
633
|
+
threat_type=MCPThreatType.HIDDEN_INSTRUCTION,
|
|
634
|
+
severity=MCPSeverity.WARNING,
|
|
635
|
+
tool_name=tool_name,
|
|
636
|
+
server_name=server_name,
|
|
637
|
+
message="Instructions hidden after excessive whitespace",
|
|
638
|
+
matched_pattern=_EXCESSIVE_WHITESPACE_PATTERN.pattern,
|
|
639
|
+
)
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
# 5. Instruction-like patterns
|
|
643
|
+
for pattern in _HIDDEN_INSTRUCTION_PATTERNS:
|
|
644
|
+
if pattern.search(description):
|
|
645
|
+
threats.append(
|
|
646
|
+
MCPThreat(
|
|
647
|
+
threat_type=MCPThreatType.HIDDEN_INSTRUCTION,
|
|
648
|
+
severity=MCPSeverity.CRITICAL,
|
|
649
|
+
tool_name=tool_name,
|
|
650
|
+
server_name=server_name,
|
|
651
|
+
message=f"Instruction-like pattern in tool description: {pattern.pattern}",
|
|
652
|
+
matched_pattern=pattern.pattern,
|
|
653
|
+
)
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
return threats
|
|
657
|
+
|
|
658
|
+
def _check_description_injection(
|
|
659
|
+
self,
|
|
660
|
+
description: str,
|
|
661
|
+
tool_name: str,
|
|
662
|
+
server_name: str,
|
|
663
|
+
) -> list[MCPThreat]:
|
|
664
|
+
"""Detect prompt injection patterns in tool descriptions."""
|
|
665
|
+
threats: list[MCPThreat] = []
|
|
666
|
+
|
|
667
|
+
# Reuse prompt_injection.py detector
|
|
668
|
+
result = self._injection_detector.detect(
|
|
669
|
+
description, source=f"mcp:{server_name}:{tool_name}"
|
|
670
|
+
)
|
|
671
|
+
if result.is_injection:
|
|
672
|
+
threats.append(
|
|
673
|
+
MCPThreat(
|
|
674
|
+
threat_type=MCPThreatType.DESCRIPTION_INJECTION,
|
|
675
|
+
severity=MCPSeverity.CRITICAL,
|
|
676
|
+
tool_name=tool_name,
|
|
677
|
+
server_name=server_name,
|
|
678
|
+
message=f"Prompt injection detected in description: {result.explanation}",
|
|
679
|
+
matched_pattern=result.matched_patterns[0] if result.matched_patterns else None,
|
|
680
|
+
details={
|
|
681
|
+
"injection_type": result.injection_type.value
|
|
682
|
+
if result.injection_type
|
|
683
|
+
else None
|
|
684
|
+
},
|
|
685
|
+
)
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
# Role assignment patterns
|
|
689
|
+
for pattern in _ROLE_OVERRIDE_PATTERNS:
|
|
690
|
+
if pattern.search(description):
|
|
691
|
+
threats.append(
|
|
692
|
+
MCPThreat(
|
|
693
|
+
threat_type=MCPThreatType.DESCRIPTION_INJECTION,
|
|
694
|
+
severity=MCPSeverity.WARNING,
|
|
695
|
+
tool_name=tool_name,
|
|
696
|
+
server_name=server_name,
|
|
697
|
+
message=f"Role override pattern in description: {pattern.pattern}",
|
|
698
|
+
matched_pattern=pattern.pattern,
|
|
699
|
+
)
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
# Data exfiltration patterns
|
|
703
|
+
for pattern in _EXFILTRATION_PATTERNS:
|
|
704
|
+
if pattern.search(description):
|
|
705
|
+
threats.append(
|
|
706
|
+
MCPThreat(
|
|
707
|
+
threat_type=MCPThreatType.DESCRIPTION_INJECTION,
|
|
708
|
+
severity=MCPSeverity.CRITICAL,
|
|
709
|
+
tool_name=tool_name,
|
|
710
|
+
server_name=server_name,
|
|
711
|
+
message=f"Data exfiltration pattern in description: {pattern.pattern}",
|
|
712
|
+
matched_pattern=pattern.pattern,
|
|
713
|
+
)
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
return threats
|
|
717
|
+
|
|
718
|
+
def _check_schema_abuse(
|
|
719
|
+
self,
|
|
720
|
+
schema: dict[str, Any],
|
|
721
|
+
tool_name: str,
|
|
722
|
+
server_name: str,
|
|
723
|
+
) -> list[MCPThreat]:
|
|
724
|
+
"""Check tool input schemas for suspicious patterns."""
|
|
725
|
+
threats: list[MCPThreat] = []
|
|
726
|
+
|
|
727
|
+
# 1. Overly permissive: top-level type is "object" with no properties
|
|
728
|
+
if schema.get("type") == "object" and not schema.get("properties"):
|
|
729
|
+
if schema.get("additionalProperties") is not False:
|
|
730
|
+
threats.append(
|
|
731
|
+
MCPThreat(
|
|
732
|
+
threat_type=MCPThreatType.TOOL_POISONING,
|
|
733
|
+
severity=MCPSeverity.WARNING,
|
|
734
|
+
tool_name=tool_name,
|
|
735
|
+
server_name=server_name,
|
|
736
|
+
message="Overly permissive schema: object type with no defined properties",
|
|
737
|
+
)
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
properties = schema.get("properties", {})
|
|
741
|
+
required = schema.get("required", [])
|
|
742
|
+
|
|
743
|
+
for prop_name, prop_def in properties.items():
|
|
744
|
+
if not isinstance(prop_def, dict):
|
|
745
|
+
continue
|
|
746
|
+
|
|
747
|
+
# 2. Hidden required fields with suspicious names
|
|
748
|
+
suspicious_field_names = [
|
|
749
|
+
"system_prompt",
|
|
750
|
+
"instructions",
|
|
751
|
+
"override",
|
|
752
|
+
"command",
|
|
753
|
+
"exec",
|
|
754
|
+
"eval",
|
|
755
|
+
"callback_url",
|
|
756
|
+
"webhook",
|
|
757
|
+
"target_url",
|
|
758
|
+
]
|
|
759
|
+
if prop_name in required:
|
|
760
|
+
for sus_name in suspicious_field_names:
|
|
761
|
+
if sus_name in prop_name.lower():
|
|
762
|
+
threats.append(
|
|
763
|
+
MCPThreat(
|
|
764
|
+
threat_type=MCPThreatType.TOOL_POISONING,
|
|
765
|
+
severity=MCPSeverity.CRITICAL,
|
|
766
|
+
tool_name=tool_name,
|
|
767
|
+
server_name=server_name,
|
|
768
|
+
message=f"Suspicious required field: '{prop_name}'",
|
|
769
|
+
details={"field_name": prop_name},
|
|
770
|
+
)
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
# 3. Default values containing instructions
|
|
774
|
+
default_val = prop_def.get("default")
|
|
775
|
+
if isinstance(default_val, str) and len(default_val) > 10:
|
|
776
|
+
for pattern in _HIDDEN_INSTRUCTION_PATTERNS:
|
|
777
|
+
if pattern.search(default_val):
|
|
778
|
+
threats.append(
|
|
779
|
+
MCPThreat(
|
|
780
|
+
threat_type=MCPThreatType.TOOL_POISONING,
|
|
781
|
+
severity=MCPSeverity.CRITICAL,
|
|
782
|
+
tool_name=tool_name,
|
|
783
|
+
server_name=server_name,
|
|
784
|
+
message=f"Instruction in default value for field '{prop_name}'",
|
|
785
|
+
matched_pattern=pattern.pattern,
|
|
786
|
+
details={"field_name": prop_name},
|
|
787
|
+
)
|
|
788
|
+
)
|
|
789
|
+
break
|
|
790
|
+
|
|
791
|
+
# 4. Hidden instructions in property descriptions
|
|
792
|
+
prop_desc = prop_def.get("description", "")
|
|
793
|
+
if isinstance(prop_desc, str):
|
|
794
|
+
for pattern in _HIDDEN_INSTRUCTION_PATTERNS:
|
|
795
|
+
if pattern.search(prop_desc):
|
|
796
|
+
threats.append(
|
|
797
|
+
MCPThreat(
|
|
798
|
+
threat_type=MCPThreatType.TOOL_POISONING,
|
|
799
|
+
severity=MCPSeverity.CRITICAL,
|
|
800
|
+
tool_name=tool_name,
|
|
801
|
+
server_name=server_name,
|
|
802
|
+
message=f"Hidden instruction in property '{prop_name}' description",
|
|
803
|
+
matched_pattern=pattern.pattern,
|
|
804
|
+
details={"field_name": prop_name},
|
|
805
|
+
)
|
|
806
|
+
)
|
|
807
|
+
break
|
|
808
|
+
|
|
809
|
+
return threats
|
|
810
|
+
|
|
811
|
+
def _check_cross_server(
|
|
812
|
+
self,
|
|
813
|
+
tool_name: str,
|
|
814
|
+
server_name: str,
|
|
815
|
+
) -> list[MCPThreat]:
|
|
816
|
+
"""Check for cross-server attack patterns."""
|
|
817
|
+
threats: list[MCPThreat] = []
|
|
818
|
+
|
|
819
|
+
for _key, fp in self._tool_registry.items():
|
|
820
|
+
# Same tool name from a different server
|
|
821
|
+
if fp.tool_name == tool_name and fp.server_name != server_name:
|
|
822
|
+
threats.append(
|
|
823
|
+
MCPThreat(
|
|
824
|
+
threat_type=MCPThreatType.CROSS_SERVER_ATTACK,
|
|
825
|
+
severity=MCPSeverity.CRITICAL,
|
|
826
|
+
tool_name=tool_name,
|
|
827
|
+
server_name=server_name,
|
|
828
|
+
message=(
|
|
829
|
+
f"Tool '{tool_name}' already registered from server "
|
|
830
|
+
f"'{fp.server_name}' — potential impersonation"
|
|
831
|
+
),
|
|
832
|
+
details={"original_server": fp.server_name},
|
|
833
|
+
)
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
# Typosquatting: similar name from a different server
|
|
837
|
+
if fp.server_name != server_name and fp.tool_name != tool_name:
|
|
838
|
+
if self._is_typosquat(tool_name, fp.tool_name):
|
|
839
|
+
threats.append(
|
|
840
|
+
MCPThreat(
|
|
841
|
+
threat_type=MCPThreatType.CROSS_SERVER_ATTACK,
|
|
842
|
+
severity=MCPSeverity.WARNING,
|
|
843
|
+
tool_name=tool_name,
|
|
844
|
+
server_name=server_name,
|
|
845
|
+
message=(
|
|
846
|
+
f"Tool name '{tool_name}' resembles "
|
|
847
|
+
f"'{fp.tool_name}' from server '{fp.server_name}' "
|
|
848
|
+
f"— potential typosquatting"
|
|
849
|
+
),
|
|
850
|
+
details={
|
|
851
|
+
"similar_tool": fp.tool_name,
|
|
852
|
+
"similar_server": fp.server_name,
|
|
853
|
+
},
|
|
854
|
+
)
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
return threats
|
|
858
|
+
|
|
859
|
+
# -- helpers ------------------------------------------------------------
|
|
860
|
+
|
|
861
|
+
@staticmethod
|
|
862
|
+
def _is_typosquat(name_a: str, name_b: str) -> bool:
|
|
863
|
+
"""Check if two tool names are suspiciously similar (edit distance ≤ 2)."""
|
|
864
|
+
if name_a == name_b:
|
|
865
|
+
return False
|
|
866
|
+
# Simple Levenshtein check for short names
|
|
867
|
+
la, lb = name_a.lower(), name_b.lower()
|
|
868
|
+
if abs(len(la) - len(lb)) > 2:
|
|
869
|
+
return False
|
|
870
|
+
# Compute Levenshtein distance
|
|
871
|
+
dist = _levenshtein(la, lb)
|
|
872
|
+
# Typosquat if 1-2 edits on names of length ≥ 4
|
|
873
|
+
return 1 <= dist <= 2 and min(len(la), len(lb)) >= 4
|
|
874
|
+
|
|
875
|
+
def _record_audit(
|
|
876
|
+
self,
|
|
877
|
+
action: str,
|
|
878
|
+
tool_name: str,
|
|
879
|
+
server_name: str,
|
|
880
|
+
threats: list[MCPThreat],
|
|
881
|
+
) -> None:
|
|
882
|
+
record = {
|
|
883
|
+
"timestamp": datetime.fromtimestamp(
|
|
884
|
+
self._clock(),
|
|
885
|
+
timezone.utc,
|
|
886
|
+
).isoformat(),
|
|
887
|
+
"action": action,
|
|
888
|
+
"tool_name": tool_name,
|
|
889
|
+
"server_name": server_name,
|
|
890
|
+
"threats_found": len(threats),
|
|
891
|
+
"threat_types": [t.threat_type.value for t in threats],
|
|
892
|
+
}
|
|
893
|
+
self._audit_log.append(record)
|
|
894
|
+
self._audit_sink.record(record)
|
|
895
|
+
|
|
896
|
+
if threats:
|
|
897
|
+
logger.warning(
|
|
898
|
+
"MCP scan found %d threat(s) | tool=%s server=%s",
|
|
899
|
+
len(threats),
|
|
900
|
+
tool_name,
|
|
901
|
+
server_name,
|
|
902
|
+
)
|
|
903
|
+
else:
|
|
904
|
+
logger.debug(
|
|
905
|
+
"MCP scan clean | tool=%s server=%s",
|
|
906
|
+
tool_name,
|
|
907
|
+
server_name,
|
|
908
|
+
)
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def _levenshtein(s: str, t: str) -> int:
|
|
912
|
+
"""Compute Levenshtein edit distance between two strings."""
|
|
913
|
+
if len(s) < len(t):
|
|
914
|
+
return _levenshtein(t, s)
|
|
915
|
+
if len(t) == 0:
|
|
916
|
+
return len(s)
|
|
917
|
+
prev = list(range(len(t) + 1))
|
|
918
|
+
for i, cs in enumerate(s):
|
|
919
|
+
curr = [i + 1]
|
|
920
|
+
for j, ct in enumerate(t):
|
|
921
|
+
cost = 0 if cs == ct else 1
|
|
922
|
+
curr.append(min(curr[j] + 1, prev[j + 1] + 1, prev[j] + cost))
|
|
923
|
+
prev = curr
|
|
924
|
+
return prev[-1]
|