Skip to main content

cc_audit/
pinning.rs

1//! MCP tool pinning for rug-pull attack detection.
2//!
3//! This module provides functionality to pin MCP server configurations
4//! and detect unauthorized changes that may indicate supply chain attacks.
5
6use crate::error::{AuditError, Result};
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9use std::collections::HashMap;
10use std::fs;
11use std::path::Path;
12
13/// Default filename for pinning data.
14pub const PINNING_FILENAME: &str = ".cc-audit-pins.json";
15
16/// Represents pinned MCP tool configurations.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ToolPins {
19    /// Version of the pinning format.
20    pub version: String,
21    /// When the pins were first created.
22    pub created_at: String,
23    /// When the pins were last updated.
24    pub updated_at: String,
25    /// Pinned tools by name.
26    pub tools: HashMap<String, PinnedTool>,
27}
28
29/// A single pinned tool entry.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct PinnedTool {
32    /// SHA-256 hash of the tool configuration.
33    pub hash: String,
34    /// Source of the tool (e.g., "npx @anthropic/mcp-server-github").
35    pub source: String,
36    /// When this tool was pinned.
37    pub pinned_at: String,
38    /// Optional version info extracted from source.
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub version: Option<String>,
41}
42
43/// Result of verifying pins against current configuration.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct PinVerifyResult {
46    /// Tools that have been modified since pinning.
47    pub modified: Vec<PinMismatch>,
48    /// Tools that were added since pinning.
49    pub added: Vec<String>,
50    /// Tools that were removed since pinning.
51    pub removed: Vec<String>,
52    /// Whether any changes were detected.
53    pub has_changes: bool,
54}
55
56/// A mismatch between pinned and current configuration.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct PinMismatch {
59    /// Name of the tool.
60    pub name: String,
61    /// Original pinned hash.
62    pub pinned_hash: String,
63    /// Current hash.
64    pub current_hash: String,
65    /// Source of the tool.
66    pub source: String,
67}
68
69impl ToolPins {
70    /// Create new pins from an MCP configuration file.
71    pub fn from_mcp_config(path: &Path) -> Result<Self> {
72        let content = fs::read_to_string(path).map_err(|e| AuditError::ReadError {
73            path: path.display().to_string(),
74            source: e,
75        })?;
76
77        Self::from_mcp_content(&content, path)
78    }
79
80    /// Create new pins from MCP configuration content.
81    pub fn from_mcp_content(content: &str, path: &Path) -> Result<Self> {
82        let config: serde_json::Value =
83            serde_json::from_str(content).map_err(|e| AuditError::ParseError {
84                path: path.display().to_string(),
85                message: e.to_string(),
86            })?;
87
88        let mut tools = HashMap::new();
89        let now = chrono::Utc::now().to_rfc3339();
90
91        // Extract mcpServers from the config
92        if let Some(mcp_servers) = config.get("mcpServers").and_then(|v| v.as_object()) {
93            for (name, server_config) in mcp_servers {
94                let pinned_tool = Self::create_pinned_tool(name, server_config, &now);
95                tools.insert(name.clone(), pinned_tool);
96            }
97        }
98
99        Ok(Self {
100            version: "1".to_string(),
101            created_at: now.clone(),
102            updated_at: now,
103            tools,
104        })
105    }
106
107    /// Create a pinned tool entry from server configuration.
108    fn create_pinned_tool(_name: &str, config: &serde_json::Value, timestamp: &str) -> PinnedTool {
109        // Compute hash of the entire server configuration
110        let config_str = serde_json::to_string(config).unwrap_or_default();
111        let hash = Self::compute_hash(&config_str);
112
113        // Extract source from command and args
114        let source = Self::extract_source(config);
115
116        // Try to extract version from source
117        let version = Self::extract_version(&source);
118
119        PinnedTool {
120            hash,
121            source,
122            pinned_at: timestamp.to_string(),
123            version,
124        }
125    }
126
127    /// Compute SHA-256 hash of content.
128    fn compute_hash(content: &str) -> String {
129        let mut hasher = Sha256::new();
130        hasher.update(content.as_bytes());
131        format!("sha256:{:x}", hasher.finalize())
132    }
133
134    /// Extract source string from server configuration.
135    fn extract_source(config: &serde_json::Value) -> String {
136        let command = config
137            .get("command")
138            .and_then(|v| v.as_str())
139            .unwrap_or_default();
140
141        let args = config
142            .get("args")
143            .and_then(|v| v.as_array())
144            .map(|arr| {
145                arr.iter()
146                    .filter_map(|v| v.as_str())
147                    .collect::<Vec<_>>()
148                    .join(" ")
149            })
150            .unwrap_or_default();
151
152        if !command.is_empty() && !args.is_empty() {
153            format!("{} {}", command, args)
154        } else if !command.is_empty() {
155            command.to_string()
156        } else if !args.is_empty() {
157            args
158        } else {
159            config
160                .get("url")
161                .and_then(|v| v.as_str())
162                .unwrap_or("unknown")
163                .to_string()
164        }
165    }
166
167    /// Try to extract version from source string.
168    fn extract_version(source: &str) -> Option<String> {
169        // Look for common version patterns:
170        // @scope/package@version, package@version, package:version
171        let patterns = [
172            // npm scoped package: @scope/package@version
173            regex::Regex::new(r"@[\w-]+/[\w-]+@([\d.]+[\w.-]*)").ok()?,
174            // npm package: package@version
175            regex::Regex::new(r"[\w-]+@([\d.]+[\w.-]*)").ok()?,
176            // docker: package:version (allows word tags like "latest")
177            regex::Regex::new(r"[\w-]+:([\w][\w.-]*)").ok()?,
178        ];
179
180        for pattern in &patterns {
181            if let Some(caps) = pattern.captures(source)
182                && let Some(version) = caps.get(1)
183            {
184                return Some(version.as_str().to_string());
185            }
186        }
187
188        None
189    }
190
191    /// Save pins to the default location.
192    pub fn save(&self, dir: &Path) -> Result<()> {
193        let pin_path = if dir.is_file() {
194            dir.parent()
195                .unwrap_or(Path::new("."))
196                .join(PINNING_FILENAME)
197        } else {
198            dir.join(PINNING_FILENAME)
199        };
200
201        self.save_to_file(&pin_path)
202    }
203
204    /// Save pins to a specific file.
205    pub fn save_to_file(&self, path: &Path) -> Result<()> {
206        let json = serde_json::to_string_pretty(self).map_err(|e| AuditError::ParseError {
207            path: path.display().to_string(),
208            message: e.to_string(),
209        })?;
210
211        fs::write(path, json).map_err(|e| AuditError::ReadError {
212            path: path.display().to_string(),
213            source: e,
214        })?;
215
216        Ok(())
217    }
218
219    /// Load pins from the default location.
220    pub fn load(dir: &Path) -> Result<Self> {
221        let pin_path = if dir.is_file() {
222            dir.parent()
223                .unwrap_or(Path::new("."))
224                .join(PINNING_FILENAME)
225        } else {
226            dir.join(PINNING_FILENAME)
227        };
228
229        Self::load_from_file(&pin_path)
230    }
231
232    /// Load pins from a specific file.
233    pub fn load_from_file(path: &Path) -> Result<Self> {
234        if !path.exists() {
235            return Err(AuditError::FileNotFound(path.display().to_string()));
236        }
237
238        let content = fs::read_to_string(path).map_err(|e| AuditError::ReadError {
239            path: path.display().to_string(),
240            source: e,
241        })?;
242
243        serde_json::from_str(&content).map_err(|e| AuditError::ParseError {
244            path: path.display().to_string(),
245            message: e.to_string(),
246        })
247    }
248
249    /// Check if a pins file exists.
250    pub fn exists(dir: &Path) -> bool {
251        let pin_path = if dir.is_file() {
252            dir.parent()
253                .unwrap_or(Path::new("."))
254                .join(PINNING_FILENAME)
255        } else {
256            dir.join(PINNING_FILENAME)
257        };
258
259        pin_path.exists()
260    }
261
262    /// Verify current configuration against pins.
263    pub fn verify(&self, mcp_path: &Path) -> Result<PinVerifyResult> {
264        let current = Self::from_mcp_config(mcp_path)?;
265
266        let mut modified = Vec::new();
267        let mut added = Vec::new();
268        let mut removed = Vec::new();
269
270        // Check for modified and removed tools
271        for (name, pinned) in &self.tools {
272            match current.tools.get(name) {
273                Some(current_tool) => {
274                    if pinned.hash != current_tool.hash {
275                        modified.push(PinMismatch {
276                            name: name.clone(),
277                            pinned_hash: pinned.hash.clone(),
278                            current_hash: current_tool.hash.clone(),
279                            source: current_tool.source.clone(),
280                        });
281                    }
282                }
283                None => {
284                    removed.push(name.clone());
285                }
286            }
287        }
288
289        // Check for added tools
290        for name in current.tools.keys() {
291            if !self.tools.contains_key(name) {
292                added.push(name.clone());
293            }
294        }
295
296        let has_changes = !modified.is_empty() || !added.is_empty() || !removed.is_empty();
297
298        Ok(PinVerifyResult {
299            modified,
300            added,
301            removed,
302            has_changes,
303        })
304    }
305
306    /// Update pins with current configuration.
307    pub fn update(&mut self, mcp_path: &Path) -> Result<()> {
308        let current = Self::from_mcp_config(mcp_path)?;
309
310        self.tools = current.tools;
311        self.updated_at = chrono::Utc::now().to_rfc3339();
312
313        Ok(())
314    }
315}
316
317impl PinVerifyResult {
318    /// Format the result for terminal output.
319    pub fn format_terminal(&self) -> String {
320        use colored::Colorize;
321
322        let mut output = String::new();
323
324        if !self.has_changes {
325            output.push_str(
326                &"✅ All MCP tool pins verified. No changes detected.\n"
327                    .green()
328                    .to_string(),
329            );
330            return output;
331        }
332
333        output.push_str(&format!(
334            "{}\n\n",
335            "━━━ MCP TOOL PIN MISMATCH (Potential Rug Pull) ━━━"
336                .red()
337                .bold()
338        ));
339
340        if !self.modified.is_empty() {
341            output.push_str(&format!("{}\n", "Modified tools:".yellow().bold()));
342            for mismatch in &self.modified {
343                output.push_str(&format!("  {} {}\n", "~".yellow(), mismatch.name));
344                output.push_str(&format!("    Source: {}\n", mismatch.source));
345                let pinned_display = if mismatch.pinned_hash.len() > 23 {
346                    &mismatch.pinned_hash[..23]
347                } else {
348                    &mismatch.pinned_hash
349                };
350                let current_display = if mismatch.current_hash.len() > 23 {
351                    &mismatch.current_hash[..23]
352                } else {
353                    &mismatch.current_hash
354                };
355                output.push_str(&format!("    Pinned:  {}...\n", pinned_display));
356                output.push_str(&format!("    Current: {}...\n", current_display));
357            }
358            output.push('\n');
359        }
360
361        if !self.added.is_empty() {
362            output.push_str(&format!("{}\n", "Added tools:".green().bold()));
363            for name in &self.added {
364                output.push_str(&format!("  {} {}\n", "+".green(), name));
365            }
366            output.push('\n');
367        }
368
369        if !self.removed.is_empty() {
370            output.push_str(&format!("{}\n", "Removed tools:".red().bold()));
371            for name in &self.removed {
372                output.push_str(&format!("  {} {}\n", "-".red(), name));
373            }
374            output.push('\n');
375        }
376
377        output.push_str(&format!(
378            "Summary: {} modified, {} added, {} removed\n",
379            self.modified.len(),
380            self.added.len(),
381            self.removed.len()
382        ));
383
384        output.push_str(&format!(
385            "\n{}\n",
386            "Run 'cc-audit pin --update' to accept these changes."
387                .cyan()
388                .dimmed()
389        ));
390
391        output
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398    use tempfile::TempDir;
399
400    fn create_test_mcp_config() -> &'static str {
401        r#"{
402            "mcpServers": {
403                "github": {
404                    "command": "npx",
405                    "args": ["-y", "@anthropic/mcp-server-github"]
406                },
407                "filesystem": {
408                    "command": "npx",
409                    "args": ["-y", "@anthropic/mcp-server-filesystem", "/path"]
410                }
411            }
412        }"#
413    }
414
415    #[test]
416    fn test_create_pins_from_config() {
417        let temp_dir = TempDir::new().unwrap();
418        let config_path = temp_dir.path().join("mcp.json");
419        fs::write(&config_path, create_test_mcp_config()).unwrap();
420
421        let pins = ToolPins::from_mcp_config(&config_path).unwrap();
422
423        assert_eq!(pins.version, "1");
424        assert_eq!(pins.tools.len(), 2);
425        assert!(pins.tools.contains_key("github"));
426        assert!(pins.tools.contains_key("filesystem"));
427    }
428
429    #[test]
430    fn test_pinned_tool_hash() {
431        let temp_dir = TempDir::new().unwrap();
432        let config_path = temp_dir.path().join("mcp.json");
433        fs::write(&config_path, create_test_mcp_config()).unwrap();
434
435        let pins = ToolPins::from_mcp_config(&config_path).unwrap();
436        let github = pins.tools.get("github").unwrap();
437
438        assert!(github.hash.starts_with("sha256:"));
439        assert!(github.source.contains("@anthropic/mcp-server-github"));
440    }
441
442    #[test]
443    fn test_save_and_load_pins() {
444        let temp_dir = TempDir::new().unwrap();
445        let config_path = temp_dir.path().join("mcp.json");
446        fs::write(&config_path, create_test_mcp_config()).unwrap();
447
448        let pins = ToolPins::from_mcp_config(&config_path).unwrap();
449        pins.save(temp_dir.path()).unwrap();
450
451        let loaded = ToolPins::load(temp_dir.path()).unwrap();
452        assert_eq!(pins.tools.len(), loaded.tools.len());
453    }
454
455    #[test]
456    fn test_verify_no_changes() {
457        let temp_dir = TempDir::new().unwrap();
458        let config_path = temp_dir.path().join("mcp.json");
459        fs::write(&config_path, create_test_mcp_config()).unwrap();
460
461        let pins = ToolPins::from_mcp_config(&config_path).unwrap();
462        let result = pins.verify(&config_path).unwrap();
463
464        assert!(!result.has_changes);
465        assert!(result.modified.is_empty());
466        assert!(result.added.is_empty());
467        assert!(result.removed.is_empty());
468    }
469
470    #[test]
471    fn test_verify_modified_tool() {
472        let temp_dir = TempDir::new().unwrap();
473        let config_path = temp_dir.path().join("mcp.json");
474        fs::write(&config_path, create_test_mcp_config()).unwrap();
475
476        let pins = ToolPins::from_mcp_config(&config_path).unwrap();
477
478        // Modify the config
479        let modified_config = r#"{
480            "mcpServers": {
481                "github": {
482                    "command": "npx",
483                    "args": ["-y", "@evil/mcp-server-github"]
484                },
485                "filesystem": {
486                    "command": "npx",
487                    "args": ["-y", "@anthropic/mcp-server-filesystem", "/path"]
488                }
489            }
490        }"#;
491        fs::write(&config_path, modified_config).unwrap();
492
493        let result = pins.verify(&config_path).unwrap();
494
495        assert!(result.has_changes);
496        assert_eq!(result.modified.len(), 1);
497        assert_eq!(result.modified[0].name, "github");
498    }
499
500    #[test]
501    fn test_verify_added_tool() {
502        let temp_dir = TempDir::new().unwrap();
503        let config_path = temp_dir.path().join("mcp.json");
504        fs::write(&config_path, create_test_mcp_config()).unwrap();
505
506        let pins = ToolPins::from_mcp_config(&config_path).unwrap();
507
508        // Add a new tool
509        let modified_config = r#"{
510            "mcpServers": {
511                "github": {
512                    "command": "npx",
513                    "args": ["-y", "@anthropic/mcp-server-github"]
514                },
515                "filesystem": {
516                    "command": "npx",
517                    "args": ["-y", "@anthropic/mcp-server-filesystem", "/path"]
518                },
519                "new-tool": {
520                    "command": "npx",
521                    "args": ["-y", "@malicious/tool"]
522                }
523            }
524        }"#;
525        fs::write(&config_path, modified_config).unwrap();
526
527        let result = pins.verify(&config_path).unwrap();
528
529        assert!(result.has_changes);
530        assert_eq!(result.added.len(), 1);
531        assert!(result.added.contains(&"new-tool".to_string()));
532    }
533
534    #[test]
535    fn test_verify_removed_tool() {
536        let temp_dir = TempDir::new().unwrap();
537        let config_path = temp_dir.path().join("mcp.json");
538        fs::write(&config_path, create_test_mcp_config()).unwrap();
539
540        let pins = ToolPins::from_mcp_config(&config_path).unwrap();
541
542        // Remove a tool
543        let modified_config = r#"{
544            "mcpServers": {
545                "github": {
546                    "command": "npx",
547                    "args": ["-y", "@anthropic/mcp-server-github"]
548                }
549            }
550        }"#;
551        fs::write(&config_path, modified_config).unwrap();
552
553        let result = pins.verify(&config_path).unwrap();
554
555        assert!(result.has_changes);
556        assert_eq!(result.removed.len(), 1);
557        assert!(result.removed.contains(&"filesystem".to_string()));
558    }
559
560    #[test]
561    fn test_update_pins() {
562        let temp_dir = TempDir::new().unwrap();
563        let config_path = temp_dir.path().join("mcp.json");
564        fs::write(&config_path, create_test_mcp_config()).unwrap();
565
566        let mut pins = ToolPins::from_mcp_config(&config_path).unwrap();
567        let original_created = pins.created_at.clone();
568
569        // Modify and update
570        let modified_config = r#"{
571            "mcpServers": {
572                "new-tool": {
573                    "command": "npx",
574                    "args": ["-y", "@new/tool"]
575                }
576            }
577        }"#;
578        fs::write(&config_path, modified_config).unwrap();
579
580        pins.update(&config_path).unwrap();
581
582        assert_eq!(pins.created_at, original_created);
583        assert_ne!(pins.updated_at, original_created);
584        assert_eq!(pins.tools.len(), 1);
585        assert!(pins.tools.contains_key("new-tool"));
586    }
587
588    #[test]
589    fn test_pins_exists() {
590        let temp_dir = TempDir::new().unwrap();
591
592        assert!(!ToolPins::exists(temp_dir.path()));
593
594        let pins = ToolPins {
595            version: "1".to_string(),
596            created_at: "2024-01-01".to_string(),
597            updated_at: "2024-01-01".to_string(),
598            tools: HashMap::new(),
599        };
600        pins.save(temp_dir.path()).unwrap();
601
602        assert!(ToolPins::exists(temp_dir.path()));
603    }
604
605    #[test]
606    fn test_extract_version() {
607        // npm scoped package
608        assert_eq!(
609            ToolPins::extract_version("npx @anthropic/mcp-server@1.2.3"),
610            Some("1.2.3".to_string())
611        );
612
613        // npm package
614        assert_eq!(
615            ToolPins::extract_version("npx mcp-server@2.0.0-beta.1"),
616            Some("2.0.0-beta.1".to_string())
617        );
618
619        // docker
620        assert_eq!(
621            ToolPins::extract_version("docker run server:latest"),
622            Some("latest".to_string())
623        );
624
625        // No version
626        assert_eq!(ToolPins::extract_version("npx @anthropic/mcp-server"), None);
627    }
628
629    #[test]
630    fn test_compute_hash_consistency() {
631        let content = "test content";
632        let hash1 = ToolPins::compute_hash(content);
633        let hash2 = ToolPins::compute_hash(content);
634
635        assert_eq!(hash1, hash2);
636        assert!(hash1.starts_with("sha256:"));
637    }
638
639    #[test]
640    fn test_format_terminal_no_changes() {
641        let result = PinVerifyResult {
642            modified: vec![],
643            added: vec![],
644            removed: vec![],
645            has_changes: false,
646        };
647
648        let output = result.format_terminal();
649        assert!(output.contains("verified"));
650    }
651
652    #[test]
653    fn test_format_terminal_with_changes() {
654        let result = PinVerifyResult {
655            modified: vec![PinMismatch {
656                name: "github".to_string(),
657                pinned_hash: "sha256:abc123".to_string(),
658                current_hash: "sha256:def456".to_string(),
659                source: "npx @anthropic/mcp-server-github".to_string(),
660            }],
661            added: vec!["new-tool".to_string()],
662            removed: vec!["old-tool".to_string()],
663            has_changes: true,
664        };
665
666        let output = result.format_terminal();
667        assert!(output.contains("MISMATCH"));
668        assert!(output.contains("Modified"));
669        assert!(output.contains("Added"));
670        assert!(output.contains("Removed"));
671    }
672
673    #[test]
674    fn test_load_nonexistent_pins() {
675        let temp_dir = TempDir::new().unwrap();
676        let result = ToolPins::load(temp_dir.path());
677        assert!(result.is_err());
678    }
679
680    #[test]
681    fn test_extract_source_with_url() {
682        let config: serde_json::Value = serde_json::json!({
683            "url": "https://mcp.example.com/server"
684        });
685
686        let source = ToolPins::extract_source(&config);
687        assert_eq!(source, "https://mcp.example.com/server");
688    }
689
690    #[test]
691    fn test_extract_source_command_only() {
692        let config: serde_json::Value = serde_json::json!({
693            "command": "python"
694        });
695
696        let source = ToolPins::extract_source(&config);
697        assert_eq!(source, "python");
698    }
699
700    #[test]
701    fn test_pinned_tool_serialization() {
702        let tool = PinnedTool {
703            hash: "sha256:abc123".to_string(),
704            source: "npx @anthropic/mcp-server".to_string(),
705            pinned_at: "2024-01-01".to_string(),
706            version: Some("1.0.0".to_string()),
707        };
708
709        let json = serde_json::to_string(&tool).unwrap();
710        let parsed: PinnedTool = serde_json::from_str(&json).unwrap();
711
712        assert_eq!(tool.hash, parsed.hash);
713        assert_eq!(tool.version, parsed.version);
714    }
715
716    #[test]
717    fn test_pin_mismatch_serialization() {
718        let mismatch = PinMismatch {
719            name: "test".to_string(),
720            pinned_hash: "sha256:abc".to_string(),
721            current_hash: "sha256:def".to_string(),
722            source: "npx test".to_string(),
723        };
724
725        let json = serde_json::to_string(&mismatch).unwrap();
726        let parsed: PinMismatch = serde_json::from_str(&json).unwrap();
727
728        assert_eq!(mismatch.name, parsed.name);
729    }
730}