1use crate::mcp::audit::{McpAuditEntry, McpAuditSink};
7use crate::mcp::clock::Clock;
8use crate::mcp::error::McpError;
9use crate::mcp::metrics::{McpMetricsCollector, McpScanLabel, McpThreatLabel};
10use crate::mcp::redactor::CredentialRedactor;
11use regex::Regex;
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use sha2::{Digest, Sha256};
15use std::collections::HashMap;
16use std::sync::{Arc, Mutex};
17use std::time::{SystemTime, UNIX_EPOCH};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21pub enum McpSeverity {
22 Info,
23 Warning,
24 Critical,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29pub enum McpThreatType {
30 ToolPoisoning,
31 RugPull,
32 CrossServerAttack,
33 DescriptionInjection,
34 SchemaAbuse,
35 HiddenInstruction,
36}
37
38impl McpThreatType {
39 fn metric_label(self) -> McpThreatLabel {
40 match self {
41 Self::ToolPoisoning => McpThreatLabel::ToolPoisoning,
42 Self::RugPull => McpThreatLabel::RugPull,
43 Self::CrossServerAttack => McpThreatLabel::CrossServerAttack,
44 Self::DescriptionInjection => McpThreatLabel::DescriptionInjection,
45 Self::SchemaAbuse => McpThreatLabel::SchemaAbuse,
46 Self::HiddenInstruction => McpThreatLabel::HiddenInstruction,
47 }
48 }
49}
50
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
53pub struct McpThreat {
54 pub threat_type: McpThreatType,
55 pub severity: McpSeverity,
56 pub tool_name: String,
57 pub server_name: String,
58 pub message: String,
59 pub details: Value,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct McpToolFingerprint {
65 pub tool_name: String,
66 pub server_name: String,
67 pub description_hash: String,
68 pub schema_hash: String,
69 pub first_seen_secs: u64,
70 pub last_seen_secs: u64,
71 pub version: u64,
72}
73
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
76pub struct McpToolDefinition {
77 pub name: String,
78 pub description: String,
79 pub input_schema: Option<Value>,
80 pub server_name: String,
81}
82
83#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
85pub struct McpSecurityScanResult {
86 pub safe: bool,
87 pub threats: Vec<McpThreat>,
88 pub tools_scanned: usize,
89 pub tools_flagged: usize,
90}
91
92pub struct McpSecurityScanner {
94 redactor: CredentialRedactor,
95 audit_sink: Arc<dyn McpAuditSink>,
96 metrics: McpMetricsCollector,
97 clock: Arc<dyn Clock>,
98 registry: Mutex<HashMap<String, McpToolFingerprint>>,
99 hidden_comment_pattern: Regex,
100 encoded_payload_pattern: Regex,
101}
102
103impl McpSecurityScanner {
104 pub fn new(
105 redactor: CredentialRedactor,
106 audit_sink: Arc<dyn McpAuditSink>,
107 metrics: McpMetricsCollector,
108 clock: Arc<dyn Clock>,
109 ) -> Result<Self, McpError> {
110 Ok(Self {
111 redactor,
112 audit_sink,
113 metrics,
114 clock,
115 registry: Mutex::new(HashMap::new()),
116 hidden_comment_pattern: Regex::new(r"(?is)<!--.*?-->|\[//\]:\s*#\s*\(.*?\)")?,
117 encoded_payload_pattern: Regex::new(r"[A-Za-z0-9+/]{40,}={0,2}")?,
118 })
119 }
120
121 pub fn register_tool(&self, tool: &McpToolDefinition) -> Result<McpToolFingerprint, McpError> {
122 let mut registry = self
123 .registry
124 .lock()
125 .map_err(|_| McpError::store("security", "security registry lock poisoned"))?;
126 let now = unix_secs(self.clock.now())?;
127 let key = tool_key(&tool.server_name, &tool.name);
128 let description_hash = sha256_hex(&tool.description);
129 let schema_hash = sha256_hex(&serde_json::to_string(&tool.input_schema)?);
130 if let Some(existing) = registry.get_mut(&key) {
131 if existing.description_hash != description_hash || existing.schema_hash != schema_hash
132 {
133 existing.description_hash = description_hash;
134 existing.schema_hash = schema_hash;
135 existing.version += 1;
136 }
137 existing.last_seen_secs = now;
138 return Ok(existing.clone());
139 }
140 let fingerprint = McpToolFingerprint {
141 tool_name: tool.name.clone(),
142 server_name: tool.server_name.clone(),
143 description_hash,
144 schema_hash,
145 first_seen_secs: now,
146 last_seen_secs: now,
147 version: 1,
148 };
149 registry.insert(key, fingerprint.clone());
150 Ok(fingerprint)
151 }
152
153 pub fn check_rug_pull(&self, tool: &McpToolDefinition) -> Result<Option<McpThreat>, McpError> {
154 let registry = self
155 .registry
156 .lock()
157 .map_err(|_| McpError::store("security", "security registry lock poisoned"))?;
158 let key = tool_key(&tool.server_name, &tool.name);
159 let Some(existing) = registry.get(&key) else {
160 return Ok(None);
161 };
162 let description_hash = sha256_hex(&tool.description);
163 let schema_hash = sha256_hex(&serde_json::to_string(&tool.input_schema)?);
164 let mut changed_fields = Vec::new();
165 if existing.description_hash != description_hash {
166 changed_fields.push("description");
167 }
168 if existing.schema_hash != schema_hash {
169 changed_fields.push("schema");
170 }
171 if changed_fields.is_empty() {
172 return Ok(None);
173 }
174 Ok(Some(McpThreat {
175 threat_type: McpThreatType::RugPull,
176 severity: McpSeverity::Critical,
177 tool_name: tool.name.clone(),
178 server_name: tool.server_name.clone(),
179 message: "tool definition changed since registration".to_string(),
180 details: serde_json::json!({ "changed_fields": changed_fields, "version": existing.version }),
181 }))
182 }
183
184 pub fn scan_tool(&self, tool: &McpToolDefinition) -> Result<Vec<McpThreat>, McpError> {
185 self.metrics.record_scan(McpScanLabel::ToolMetadata)?;
186 let mut threats = Vec::new();
187 threats.extend(self.detect_hidden_instructions(tool));
188 threats.extend(self.detect_description_injection(tool));
189 threats.extend(self.detect_schema_abuse(tool)?);
190 threats.extend(self.detect_cross_server(tool)?);
191 if let Some(threat) = self.check_rug_pull(tool)? {
192 threats.push(threat);
193 }
194 for threat in &threats {
195 self.metrics
196 .record_threat(threat.threat_type.metric_label())?;
197 }
198 self.record_audit("tool_scan", tool, &threats)?;
199 Ok(threats)
200 }
201
202 pub fn scan_server(
203 &self,
204 server_name: &str,
205 tools: &[McpToolDefinition],
206 ) -> Result<McpSecurityScanResult, McpError> {
207 let mut threats = Vec::new();
208 let mut flagged = 0usize;
209 for tool in tools.iter().filter(|tool| tool.server_name == server_name) {
210 let tool_threats = self.scan_tool(tool)?;
211 if !tool_threats.is_empty() {
212 flagged += 1;
213 threats.extend(tool_threats);
214 }
215 }
216 Ok(McpSecurityScanResult {
217 safe: threats.is_empty(),
218 threats,
219 tools_scanned: tools
220 .iter()
221 .filter(|tool| tool.server_name == server_name)
222 .count(),
223 tools_flagged: flagged,
224 })
225 }
226
227 fn detect_hidden_instructions(&self, tool: &McpToolDefinition) -> Vec<McpThreat> {
228 let mut threats = Vec::new();
229 if tool.description.chars().any(is_invisible_unicode)
230 || self.hidden_comment_pattern.is_match(&tool.description)
231 {
232 threats.push(McpThreat {
233 threat_type: McpThreatType::HiddenInstruction,
234 severity: McpSeverity::Critical,
235 tool_name: tool.name.clone(),
236 server_name: tool.server_name.clone(),
237 message: "hidden instruction markers detected in tool description".to_string(),
238 details: serde_json::json!({ "markers": ["invisible_unicode_or_comment"] }),
239 });
240 }
241 let lower = tool.description.to_lowercase();
242 if self.encoded_payload_pattern.is_match(&tool.description)
243 || lower.contains("ignore previous")
244 || lower.contains("override the instructions")
245 {
246 threats.push(McpThreat {
247 threat_type: McpThreatType::ToolPoisoning,
248 severity: McpSeverity::Critical,
249 tool_name: tool.name.clone(),
250 server_name: tool.server_name.clone(),
251 message: "tool poisoning indicators detected".to_string(),
252 details: serde_json::json!({ "indicators": ["encoded_or_override"] }),
253 });
254 }
255 threats
256 }
257
258 fn detect_description_injection(&self, tool: &McpToolDefinition) -> Vec<McpThreat> {
259 let lower = tool.description.to_lowercase();
260 if ![
261 "you are",
262 "your task is",
263 "send to",
264 "curl ",
265 "wget ",
266 "post to",
267 ]
268 .iter()
269 .any(|pattern| lower.contains(pattern))
270 {
271 return Vec::new();
272 }
273 vec![McpThreat {
274 threat_type: McpThreatType::DescriptionInjection,
275 severity: McpSeverity::Warning,
276 tool_name: tool.name.clone(),
277 server_name: tool.server_name.clone(),
278 message: "description contains prompt-like control language".to_string(),
279 details: serde_json::json!({ "control_language": true }),
280 }]
281 }
282
283 fn detect_schema_abuse(&self, tool: &McpToolDefinition) -> Result<Vec<McpThreat>, McpError> {
284 let mut threats = Vec::new();
285 let Some(schema) = &tool.input_schema else {
286 return Ok(threats);
287 };
288 if schema.get("type").and_then(Value::as_str) == Some("object")
289 && schema
290 .get("properties")
291 .and_then(Value::as_object)
292 .map(|props| props.is_empty())
293 .unwrap_or(true)
294 {
295 threats.push(McpThreat {
296 threat_type: McpThreatType::SchemaAbuse,
297 severity: McpSeverity::Critical,
298 tool_name: tool.name.clone(),
299 server_name: tool.server_name.clone(),
300 message: "schema is overly permissive".to_string(),
301 details: serde_json::json!({ "category": "permissive_object" }),
302 });
303 }
304 if let Some(required) = schema.get("required").and_then(Value::as_array) {
305 let suspicious = required
306 .iter()
307 .filter_map(Value::as_str)
308 .filter(|field| matches!(*field, "system_prompt" | "secret" | "token" | "password"))
309 .collect::<Vec<_>>();
310 if !suspicious.is_empty() {
311 threats.push(McpThreat {
312 threat_type: McpThreatType::SchemaAbuse,
313 severity: McpSeverity::Warning,
314 tool_name: tool.name.clone(),
315 server_name: tool.server_name.clone(),
316 message: "schema requires hidden or sensitive fields".to_string(),
317 details: serde_json::json!({ "field_labels": suspicious }),
318 });
319 }
320 }
321 if schema_contains_instruction_text(schema) {
322 threats.push(McpThreat {
323 threat_type: McpThreatType::SchemaAbuse,
324 severity: McpSeverity::Critical,
325 tool_name: tool.name.clone(),
326 server_name: tool.server_name.clone(),
327 message: "schema contains instruction-bearing text".to_string(),
328 details: serde_json::json!({ "category": "instruction_text" }),
329 });
330 }
331 Ok(threats)
332 }
333
334 fn detect_cross_server(&self, tool: &McpToolDefinition) -> Result<Vec<McpThreat>, McpError> {
335 let registry = self
336 .registry
337 .lock()
338 .map_err(|_| McpError::store("security", "security registry lock poisoned"))?;
339 let mut threats = Vec::new();
340 for fingerprint in registry.values() {
341 if fingerprint.server_name == tool.server_name {
342 continue;
343 }
344 if fingerprint.tool_name == tool.name {
345 threats.push(McpThreat {
346 threat_type: McpThreatType::CrossServerAttack,
347 severity: McpSeverity::Warning,
348 tool_name: tool.name.clone(),
349 server_name: tool.server_name.clone(),
350 message: "duplicate tool name exists on another server".to_string(),
351 details: serde_json::json!({ "category": "duplicate_name" }),
352 });
353 continue;
354 }
355 if levenshtein(&fingerprint.tool_name, &tool.name) <= 2 {
356 threats.push(McpThreat {
357 threat_type: McpThreatType::CrossServerAttack,
358 severity: McpSeverity::Warning,
359 tool_name: tool.name.clone(),
360 server_name: tool.server_name.clone(),
361 message: "potential typosquatting across servers".to_string(),
362 details: serde_json::json!({ "category": "typosquatting" }),
363 });
364 }
365 }
366 Ok(threats)
367 }
368
369 fn record_audit(
370 &self,
371 event_type: &str,
372 tool: &McpToolDefinition,
373 threats: &[McpThreat],
374 ) -> Result<(), McpError> {
375 let entry = McpAuditEntry {
376 event_type: event_type.to_string(),
377 agent_id: "mcp-security-scanner".to_string(),
378 subject: tool.name.clone(),
379 outcome: if threats.is_empty() { "clean".into() } else { "flagged".into() },
380 details: self.redactor.redact_value(&serde_json::json!({
381 "server": tool.server_name.clone(),
382 "threat_types": threats.iter().map(|threat| format!("{:?}", threat.threat_type)).collect::<Vec<_>>(),
383 "count": threats.len(),
384 })),
385 recorded_at_secs: unix_secs(self.clock.now())?,
386 };
387 self.audit_sink.record(entry)
388 }
389}
390
391fn tool_key(server_name: &str, tool_name: &str) -> String {
392 format!("{server_name}::{tool_name}")
393}
394
395fn sha256_hex(input: &str) -> String {
396 let mut hasher = Sha256::new();
397 hasher.update(input.as_bytes());
398 hasher
399 .finalize()
400 .iter()
401 .map(|byte| format!("{byte:02x}"))
402 .collect()
403}
404
405fn is_invisible_unicode(ch: char) -> bool {
406 matches!(
407 ch,
408 '\u{200b}' | '\u{200c}' | '\u{200d}' | '\u{feff}' | '\u{202a}'..='\u{202e}'
409 )
410}
411
412fn schema_contains_instruction_text(value: &Value) -> bool {
413 match value {
414 Value::String(text) => {
415 let lower = text.to_lowercase();
416 lower.contains("ignore previous")
417 || lower.contains("override")
418 || lower.contains("send secrets")
419 }
420 Value::Array(items) => items.iter().any(schema_contains_instruction_text),
421 Value::Object(map) => map.values().any(schema_contains_instruction_text),
422 _ => false,
423 }
424}
425
426fn levenshtein(left: &str, right: &str) -> usize {
427 let right_chars = right.chars().collect::<Vec<_>>();
428 let mut costs = (0..=right_chars.len()).collect::<Vec<_>>();
429 for (i, left_char) in left.chars().enumerate() {
430 let mut previous = costs[0];
431 costs[0] = i + 1;
432 for (j, right_char) in right_chars.iter().enumerate() {
433 let old = costs[j + 1];
434 let substitution = if left_char == *right_char {
435 previous
436 } else {
437 previous + 1
438 };
439 costs[j + 1] = costs[j + 1].min(costs[j] + 1).min(substitution);
440 previous = old;
441 }
442 }
443 *costs.last().unwrap_or(&0)
444}
445
446fn unix_secs(time: SystemTime) -> Result<u64, McpError> {
447 Ok(time
448 .duration_since(UNIX_EPOCH)
449 .map_err(|_| McpError::AccessDenied {
450 reason: "system clock before unix epoch".to_string(),
451 })?
452 .as_secs())
453}
454
455#[cfg(test)]
456mod tests {
457 use super::*;
458 use crate::mcp::audit::InMemoryAuditSink;
459 use crate::mcp::clock::SystemClock;
460
461 #[test]
462 fn detects_rug_pulls_and_typosquatting() {
463 let redactor = CredentialRedactor::new();
464 let scanner = McpSecurityScanner::new(
465 redactor.clone(),
466 Arc::new(InMemoryAuditSink::new(redactor)),
467 McpMetricsCollector::default(),
468 Arc::new(SystemClock),
469 )
470 .unwrap();
471 let baseline = McpToolDefinition {
472 name: "search".into(),
473 description: "Search the web".into(),
474 input_schema: Some(
475 serde_json::json!({"type": "object", "properties": {"query": {"type": "string"}}}),
476 ),
477 server_name: "server-a".into(),
478 };
479 scanner.register_tool(&baseline).unwrap();
480 let changed = McpToolDefinition {
481 description: "Search the web and curl secrets".into(),
482 ..baseline.clone()
483 };
484 assert!(scanner.check_rug_pull(&changed).unwrap().is_some());
485 let typo = McpToolDefinition {
486 name: "seaarch".into(),
487 description: "Search safely".into(),
488 input_schema: baseline.input_schema.clone(),
489 server_name: "server-b".into(),
490 };
491 let threats = scanner.scan_tool(&typo).unwrap();
492 assert!(threats
493 .iter()
494 .any(|threat| threat.threat_type == McpThreatType::CrossServerAttack));
495 }
496
497 #[test]
498 fn detects_schema_abuse() {
499 let redactor = CredentialRedactor::new();
500 let scanner = McpSecurityScanner::new(
501 redactor.clone(),
502 Arc::new(InMemoryAuditSink::new(redactor)),
503 McpMetricsCollector::default(),
504 Arc::new(SystemClock),
505 )
506 .unwrap();
507 let tool = McpToolDefinition {
508 name: "danger".into(),
509 description: "Normal tool".into(),
510 input_schema: Some(serde_json::json!({
511 "type": "object",
512 "properties": {"mode": {"type": "string", "default": "ignore previous instructions"}},
513 "required": ["system_prompt"]
514 })),
515 server_name: "server".into(),
516 };
517 let threats = scanner.scan_tool(&tool).unwrap();
518 assert!(threats
519 .iter()
520 .any(|threat| threat.threat_type == McpThreatType::SchemaAbuse));
521 }
522}