Skip to main content

agentsec_core/scan/
unknown.rs

1//! BlackList check: classify installed MCP server names against a
2//! [`crate::registry::Registry`] of known-good entries.
3//!
4//! This is a **demo-level** signature check: it reads MCP server names
5//! from `.mcp.json` (cwd) and `.claude.json` (under [`crate::Paths::user_home`])
6//! at call time, then assigns each name one of three verdicts:
7//!
8//! - [`UnknownVerdictKind::KnownGood`] — exact name match in the registry.
9//! - [`UnknownVerdictKind::Typosquat`] — Levenshtein distance ≤ 2 to a
10//!   registry entry (and not identical).
11//! - [`UnknownVerdictKind::Unknown`] — no match; the user has installed
12//!   an MCP server AgentSec doesn't recognise. This is **neutral**, not a
13//!   block — it just surfaces "we've never seen this".
14//!
15//! The classify step does not consume the inventory snapshot — it
16//! re-reads the source JSON because the snapshot only stores hashes, not
17//! the full structure.
18
19use std::collections::BTreeSet;
20use std::fs;
21use std::path::PathBuf;
22
23use serde::{Deserialize, Serialize};
24
25use crate::Paths;
26use crate::registry::Registry;
27
28const TYPOSQUAT_DISTANCE_MAX: usize = 2;
29
30/// One row of [`classify`]'s output.
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32pub struct UnknownVerdict {
33    /// Source file the server name was read from
34    /// (e.g. `~/.claude.json` or `<cwd>/.mcp.json`).
35    pub path: String,
36    /// MCP server name (the key under `mcpServers.<name>`).
37    pub name: String,
38    /// Classification of `name` against the registry.
39    pub verdict: UnknownVerdictKind,
40    /// Free-text rationale, suitable for one-line surface in the Markdown
41    /// report. Echoes the registry [`crate::registry::RegistryEntry::source`]
42    /// when relevant.
43    pub reason: String,
44}
45
46/// Three-level classification.
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
48pub enum UnknownVerdictKind {
49    /// Exact match in the registry.
50    KnownGood,
51    /// Levenshtein distance ≤ 2 to a registry entry. Carries the closest
52    /// match and the distance.
53    Typosquat { closest: String, distance: usize },
54    /// No match at any distance ≤ 2.
55    Unknown,
56}
57
58/// Read MCP server names from `.mcp.json` and `.claude.json` and classify
59/// each one against `registry`.
60///
61/// File-read failures are silently skipped — a missing `.mcp.json` is the
62/// common case, and a malformed `.claude.json` shouldn't fail the whole
63/// classification.
64///
65/// Output is sorted by `(path, name)` for stable reporting.
66pub fn classify(paths: &Paths, registry: &Registry) -> Vec<UnknownVerdict> {
67    let mut named: BTreeSet<(String, String)> = BTreeSet::new();
68
69    // ── project-rooted `.mcp.json` (cwd-relative) ─────────────────────
70    let project_mcp = PathBuf::from(".mcp.json");
71    extract_names(&project_mcp, &mut named, /*per_project=*/ false);
72
73    // ── home-rooted `.claude.json` (top-level + per-project blocks) ───
74    let home_config = paths.user_home.join(".claude.json");
75    extract_names(&home_config, &mut named, /*per_project=*/ true);
76
77    named
78        .into_iter()
79        .map(|(path, name)| classify_one(&path, &name, registry))
80        .collect()
81}
82
83fn classify_one(path: &str, name: &str, registry: &Registry) -> UnknownVerdict {
84    if let Some(entry) = registry.get(name) {
85        return UnknownVerdict {
86            path: path.to_string(),
87            name: name.to_string(),
88            verdict: UnknownVerdictKind::KnownGood,
89            reason: format!("registered (source: {})", entry.source),
90        };
91    }
92    if let Some((entry, distance)) = registry.closest(name, TYPOSQUAT_DISTANCE_MAX) {
93        return UnknownVerdict {
94            path: path.to_string(),
95            name: name.to_string(),
96            verdict: UnknownVerdictKind::Typosquat {
97                closest: entry.name.clone(),
98                distance,
99            },
100            reason: format!("close to `{}` (distance {})", entry.name, distance),
101        };
102    }
103    UnknownVerdict {
104        path: path.to_string(),
105        name: name.to_string(),
106        verdict: UnknownVerdictKind::Unknown,
107        reason: "not in registry".to_string(),
108    }
109}
110
111/// Read `path` as JSON and extract MCP server names from top-level
112/// `mcpServers` (and, if `per_project`, from each
113/// `projects.<p>.mcpServers` block too). Adds `(display-path, name)`
114/// tuples to `out`. Silently no-ops on missing / unparseable files.
115fn extract_names(path: &PathBuf, out: &mut BTreeSet<(String, String)>, per_project: bool) {
116    let Ok(body) = fs::read_to_string(path) else {
117        return;
118    };
119    let Ok(json) = serde_json::from_str::<serde_json::Value>(&body) else {
120        return;
121    };
122    let display = path.display().to_string();
123    if let Some(top) = json
124        .get("mcpServers")
125        .and_then(serde_json::Value::as_object)
126    {
127        for name in top.keys() {
128            out.insert((display.clone(), name.clone()));
129        }
130    }
131    if per_project {
132        if let Some(projects) = json.get("projects").and_then(serde_json::Value::as_object) {
133            for (proj_name, proj_val) in projects {
134                if let Some(servers) = proj_val
135                    .get("mcpServers")
136                    .and_then(serde_json::Value::as_object)
137                {
138                    for name in servers.keys() {
139                        let scoped = format!("{display}#projects.{proj_name}.mcpServers");
140                        out.insert((scoped, name.clone()));
141                    }
142                }
143            }
144        }
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::registry::RegistryEntry;
152
153    fn write_claude_json(tmp: &tempfile::TempDir, body: &str) {
154        std::fs::write(tmp.path().join(".claude.json"), body).unwrap();
155    }
156
157    fn paths_for(tmp: &tempfile::TempDir) -> Paths {
158        Paths {
159            home: tmp.path().to_path_buf(),
160            user_home: tmp.path().to_path_buf(),
161        }
162    }
163
164    fn small_registry() -> Registry {
165        Registry::from_entries(vec![
166            RegistryEntry {
167                name: "filesystem".into(),
168                source: "test-source".into(),
169            },
170            RegistryEntry {
171                name: "github".into(),
172                source: "test-source".into(),
173            },
174        ])
175    }
176
177    #[test]
178    fn known_good_is_recognised() {
179        let tmp = tempfile::tempdir().unwrap();
180        write_claude_json(&tmp, r#"{"mcpServers":{"filesystem":{"command":"x"}}}"#);
181        let verdicts = classify(&paths_for(&tmp), &small_registry());
182        assert_eq!(verdicts.len(), 1);
183        assert_eq!(verdicts[0].name, "filesystem");
184        assert_eq!(verdicts[0].verdict, UnknownVerdictKind::KnownGood);
185    }
186
187    #[test]
188    fn typosquat_is_flagged() {
189        let tmp = tempfile::tempdir().unwrap();
190        // "filesystme" — single transposition typo from "filesystem".
191        write_claude_json(&tmp, r#"{"mcpServers":{"filesystme":{"command":"x"}}}"#);
192        let verdicts = classify(&paths_for(&tmp), &small_registry());
193        assert_eq!(verdicts.len(), 1);
194        match &verdicts[0].verdict {
195            UnknownVerdictKind::Typosquat { closest, distance } => {
196                assert_eq!(closest, "filesystem");
197                assert!(*distance > 0 && *distance <= 2);
198            }
199            other => panic!("expected Typosquat, got {other:?}"),
200        }
201    }
202
203    #[test]
204    fn unknown_name_is_unknown() {
205        let tmp = tempfile::tempdir().unwrap();
206        write_claude_json(
207            &tmp,
208            r#"{"mcpServers":{"my-private-tool":{"command":"x"}}}"#,
209        );
210        let verdicts = classify(&paths_for(&tmp), &small_registry());
211        assert_eq!(verdicts.len(), 1);
212        assert_eq!(verdicts[0].verdict, UnknownVerdictKind::Unknown);
213    }
214
215    #[test]
216    fn per_project_servers_are_classified() {
217        let tmp = tempfile::tempdir().unwrap();
218        write_claude_json(
219            &tmp,
220            r#"{
221                "mcpServers": {"github": {"command":"x"}},
222                "projects": {
223                    "/some/proj": {
224                        "mcpServers": {"my-tool": {"command":"y"}}
225                    }
226                }
227            }"#,
228        );
229        let verdicts = classify(&paths_for(&tmp), &small_registry());
230        assert_eq!(verdicts.len(), 2);
231        let names: Vec<&str> = verdicts.iter().map(|v| v.name.as_str()).collect();
232        assert!(names.contains(&"github"));
233        assert!(names.contains(&"my-tool"));
234    }
235
236    #[test]
237    fn missing_claude_json_yields_empty_result() {
238        let tmp = tempfile::tempdir().unwrap();
239        let verdicts = classify(&paths_for(&tmp), &small_registry());
240        assert!(verdicts.is_empty());
241    }
242
243    #[test]
244    fn malformed_json_silently_skipped() {
245        let tmp = tempfile::tempdir().unwrap();
246        write_claude_json(&tmp, "not even close to JSON");
247        let verdicts = classify(&paths_for(&tmp), &small_registry());
248        assert!(verdicts.is_empty());
249    }
250}