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::LikelyTyposquat`] — Levenshtein distance == 1 to a
10//!   registry entry (not identical).  High-signal, warn severity.
11//! - [`UnknownVerdictKind::InformationalTyposquat`] — Levenshtein distance == 2
12//!   to a registry entry (not identical, and name length > 3).  Lower-signal,
13//!   info severity.
14//! - [`UnknownVerdictKind::Unknown`] — no match; the user has installed
15//!   an MCP server AgentSec doesn't recognise. This is **neutral**, not a
16//!   block — it just surfaces "we've never seen this".
17//!
18//! The classify step does not consume the inventory snapshot — it
19//! re-reads the source JSON because the snapshot only stores hashes, not
20//! the full structure.
21//!
22//! [`classify`] groups rows by `name`, deduplicates across files, and
23//! returns one [`UnknownVerdict`] per distinct server name with `count`
24//! (number of config files it appeared in) and `paths` (sorted list of
25//! those files).
26
27use std::collections::{BTreeMap, BTreeSet};
28use std::fs;
29use std::path::PathBuf;
30
31use serde::{Deserialize, Serialize};
32
33use crate::Paths;
34use crate::registry::Registry;
35
36const TYPOSQUAT_DISTANCE_MAX: usize = 2;
37
38/// One row of [`classify`]'s aggregated output.
39///
40/// A single `name` may appear in multiple config files.  `count` is the
41/// number of distinct files and `paths` lists them in sorted order.
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43pub struct UnknownVerdict {
44    /// MCP server name (the key under `mcpServers.<name>`).
45    pub name: String,
46    /// Classification of `name` against the registry.
47    pub verdict: UnknownVerdictKind,
48    /// Free-text rationale, suitable for one-line surface in the Markdown
49    /// report. Echoes the registry [`crate::registry::RegistryEntry::source`]
50    /// when relevant.
51    pub reason: String,
52    /// Number of config files this name was found in.
53    pub count: usize,
54    /// Sorted list of config files this name was found in.
55    pub paths: Vec<String>,
56}
57
58/// Classification levels.
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
60pub enum UnknownVerdictKind {
61    /// Exact match in the registry.
62    KnownGood,
63    /// Levenshtein distance == 1 to a registry entry (warn severity).
64    LikelyTyposquat { closest: String, distance: usize },
65    /// Levenshtein distance == 2 to a registry entry (info severity).
66    /// Suppressed for names with ≤ 3 characters.
67    InformationalTyposquat { closest: String, distance: usize },
68    /// No match at any distance ≤ 2 (or suppressed short-name case).
69    Unknown,
70}
71
72/// Read MCP server names from `.mcp.json` and `.claude.json`, classify each
73/// one against `registry`, and return one aggregated row per distinct name.
74///
75/// File-read failures are silently skipped — a missing `.mcp.json` is the
76/// common case, and a malformed `.claude.json` shouldn't fail the whole
77/// classification.
78///
79/// Output is sorted by `name` for stable reporting.
80pub fn classify(paths: &Paths, registry: &Registry) -> Vec<UnknownVerdict> {
81    // Collect (path, name) pairs — BTreeSet gives dedup + sorted order.
82    let mut named: BTreeSet<(String, String)> = BTreeSet::new();
83
84    // ── project-rooted `.mcp.json` (cwd-relative) ─────────────────────
85    let project_mcp = PathBuf::from(".mcp.json");
86    extract_names(&project_mcp, &mut named, /*per_project=*/ false);
87
88    // ── home-rooted `.claude.json` (top-level + per-project blocks) ───
89    let home_config = paths.user_home.join(".claude.json");
90    extract_names(&home_config, &mut named, /*per_project=*/ true);
91
92    // Group by name: BTreeMap keeps names sorted.
93    let mut by_name: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
94    for (path, name) in named {
95        by_name.entry(name).or_default().insert(path);
96    }
97
98    by_name
99        .into_iter()
100        .map(|(name, path_set)| {
101            let paths_vec: Vec<String> = path_set.into_iter().collect();
102            let count = paths_vec.len();
103            // Use the first path as the representative for classification;
104            // the verdict depends only on the name + registry.
105            let rep = paths_vec.first().map_or("", String::as_str);
106            let mut v = classify_one(rep, &name, registry);
107            v.count = count;
108            v.paths = paths_vec;
109            v
110        })
111        .collect()
112}
113
114fn classify_one(path: &str, name: &str, registry: &Registry) -> UnknownVerdict {
115    if let Some(entry) = registry.get(name) {
116        return UnknownVerdict {
117            name: name.to_string(),
118            verdict: UnknownVerdictKind::KnownGood,
119            reason: format!("registered (source: {})", entry.source),
120            count: 1,
121            paths: vec![path.to_string()],
122        };
123    }
124    if let Some((entry, distance)) = registry.closest(name, TYPOSQUAT_DISTANCE_MAX) {
125        // Suppress distance-2 hits for very short names (≤ 3 chars) to avoid
126        // false positives like "git" → "filesystem" (not a real threat model).
127        let char_count = name.chars().count();
128        if distance == 2 && char_count <= 3 {
129            // Fall through to Unknown below.
130        } else {
131            let verdict = if distance == 1 {
132                UnknownVerdictKind::LikelyTyposquat {
133                    closest: entry.name.clone(),
134                    distance,
135                }
136            } else {
137                UnknownVerdictKind::InformationalTyposquat {
138                    closest: entry.name.clone(),
139                    distance,
140                }
141            };
142            return UnknownVerdict {
143                name: name.to_string(),
144                verdict,
145                reason: format!("close to `{}` (distance {})", entry.name, distance),
146                count: 1,
147                paths: vec![path.to_string()],
148            };
149        }
150    }
151    UnknownVerdict {
152        name: name.to_string(),
153        verdict: UnknownVerdictKind::Unknown,
154        reason: "not in registry".to_string(),
155        count: 1,
156        paths: vec![path.to_string()],
157    }
158}
159
160/// Read `path` as JSON and extract MCP server names from top-level
161/// `mcpServers` (and, if `per_project`, from each
162/// `projects.<p>.mcpServers` block too). Adds `(display-path, name)`
163/// tuples to `out`. Silently no-ops on missing / unparseable files.
164fn extract_names(path: &PathBuf, out: &mut BTreeSet<(String, String)>, per_project: bool) {
165    let Ok(body) = fs::read_to_string(path) else {
166        return;
167    };
168    let Ok(json) = serde_json::from_str::<serde_json::Value>(&body) else {
169        return;
170    };
171    let display = path.display().to_string();
172    if let Some(top) = json
173        .get("mcpServers")
174        .and_then(serde_json::Value::as_object)
175    {
176        for name in top.keys() {
177            out.insert((display.clone(), name.clone()));
178        }
179    }
180    if per_project {
181        if let Some(projects) = json.get("projects").and_then(serde_json::Value::as_object) {
182            for (proj_name, proj_val) in projects {
183                if let Some(servers) = proj_val
184                    .get("mcpServers")
185                    .and_then(serde_json::Value::as_object)
186                {
187                    for name in servers.keys() {
188                        let scoped = format!("{display}#projects.{proj_name}.mcpServers");
189                        out.insert((scoped, name.clone()));
190                    }
191                }
192            }
193        }
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use crate::registry::RegistryEntry;
201
202    fn write_claude_json(tmp: &tempfile::TempDir, body: &str) {
203        std::fs::write(tmp.path().join(".claude.json"), body).unwrap();
204    }
205
206    fn paths_for(tmp: &tempfile::TempDir) -> Paths {
207        Paths {
208            home: tmp.path().to_path_buf(),
209            user_home: tmp.path().to_path_buf(),
210        }
211    }
212
213    fn small_registry() -> Registry {
214        Registry::from_entries(vec![
215            RegistryEntry {
216                name: "filesystem".into(),
217                source: "test-source".into(),
218            },
219            RegistryEntry {
220                name: "github".into(),
221                source: "test-source".into(),
222            },
223        ])
224    }
225
226    #[test]
227    fn known_good_is_recognised() {
228        let tmp = tempfile::tempdir().unwrap();
229        write_claude_json(&tmp, r#"{"mcpServers":{"filesystem":{"command":"x"}}}"#);
230        let verdicts = classify(&paths_for(&tmp), &small_registry());
231        assert_eq!(verdicts.len(), 1);
232        assert_eq!(verdicts[0].name, "filesystem");
233        assert_eq!(verdicts[0].verdict, UnknownVerdictKind::KnownGood);
234    }
235
236    #[test]
237    fn typosquat_is_flagged() {
238        let tmp = tempfile::tempdir().unwrap();
239        // "filesystme" — transposition of last two chars from "filesystem",
240        // Levenshtein distance 2 → InformationalTyposquat.
241        write_claude_json(&tmp, r#"{"mcpServers":{"filesystme":{"command":"x"}}}"#);
242        let verdicts = classify(&paths_for(&tmp), &small_registry());
243        assert_eq!(verdicts.len(), 1);
244        match &verdicts[0].verdict {
245            UnknownVerdictKind::InformationalTyposquat { closest, distance } => {
246                assert_eq!(closest, "filesystem");
247                assert_eq!(*distance, 2);
248            }
249            other => panic!("expected InformationalTyposquat, got {other:?}"),
250        }
251    }
252
253    #[test]
254    fn unknown_name_is_unknown() {
255        let tmp = tempfile::tempdir().unwrap();
256        write_claude_json(
257            &tmp,
258            r#"{"mcpServers":{"my-private-tool":{"command":"x"}}}"#,
259        );
260        let verdicts = classify(&paths_for(&tmp), &small_registry());
261        assert_eq!(verdicts.len(), 1);
262        assert_eq!(verdicts[0].verdict, UnknownVerdictKind::Unknown);
263    }
264
265    #[test]
266    fn per_project_servers_are_classified() {
267        let tmp = tempfile::tempdir().unwrap();
268        write_claude_json(
269            &tmp,
270            r#"{
271                "mcpServers": {"github": {"command":"x"}},
272                "projects": {
273                    "/some/proj": {
274                        "mcpServers": {"my-tool": {"command":"y"}}
275                    }
276                }
277            }"#,
278        );
279        let verdicts = classify(&paths_for(&tmp), &small_registry());
280        assert_eq!(verdicts.len(), 2);
281        let names: Vec<&str> = verdicts.iter().map(|v| v.name.as_str()).collect();
282        assert!(names.contains(&"github"));
283        assert!(names.contains(&"my-tool"));
284    }
285
286    #[test]
287    fn missing_claude_json_yields_empty_result() {
288        let tmp = tempfile::tempdir().unwrap();
289        let verdicts = classify(&paths_for(&tmp), &small_registry());
290        assert!(verdicts.is_empty());
291    }
292
293    #[test]
294    fn malformed_json_silently_skipped() {
295        let tmp = tempfile::tempdir().unwrap();
296        write_claude_json(&tmp, "not even close to JSON");
297        let verdicts = classify(&paths_for(&tmp), &small_registry());
298        assert!(verdicts.is_empty());
299    }
300
301    // ── A-2 heuristic tests ───────────────────────────────────────────────
302
303    #[test]
304    fn classify_one_short_name_distance_2_suppressed() {
305        // "git" has length 3, closest registry entry is "github" (distance 3)
306        // or "filesystem" (distance > 2). Use a custom registry with a
307        // 2-distance match to exercise the short-name suppression.
308        let reg = Registry::from_entries(vec![RegistryEntry {
309            name: "liv".into(),
310            source: "test".into(),
311        }]);
312        // "git" → "liv": levenshtein("git","liv") = 3 → actually no match at max 2.
313        // Use "gi" (2 chars) and "github" (distance 5) — too far.
314        // Directly test classify_one: name "gxt", registry has "git", distance 2.
315        let reg2 = Registry::from_entries(vec![RegistryEntry {
316            name: "git".into(),
317            source: "test".into(),
318        }]);
319        // "gxt" vs "git" = distance 1 (transposition: x→i) — actually 1, not 2.
320        // Use "ab" vs "cd": distance 2, both len 2 → suppressed.
321        let reg3 = Registry::from_entries(vec![RegistryEntry {
322            name: "ab".into(),
323            source: "test".into(),
324        }]);
325        let v = classify_one("path", "cd", &reg3);
326        // "cd" len=2 ≤ 3, distance 2 → suppressed to Unknown
327        assert_eq!(v.verdict, UnknownVerdictKind::Unknown);
328
329        // Sanity: if registry has "ab", "abc" (len 3 ≤ 3) close at distance 2
330        // to "axy" → suppressed
331        let reg4 = Registry::from_entries(vec![RegistryEntry {
332            name: "abc".into(),
333            source: "test".into(),
334        }]);
335        // "axz" vs "abc" = 2 subs → distance 2, len("axz")=3 ≤ 3 → suppressed
336        let v2 = classify_one("path", "axz", &reg4);
337        assert_eq!(v2.verdict, UnknownVerdictKind::Unknown);
338
339        // Suppress unused-var warning for reg, reg2 by using them.
340        let _ = reg;
341        let _ = reg2;
342    }
343
344    #[test]
345    fn classify_severity_split() {
346        let reg = Registry::from_entries(vec![RegistryEntry {
347            name: "filesystem".into(),
348            source: "test".into(),
349        }]);
350        // distance 1 → LikelyTyposquat
351        let v1 = classify_one("p", "filesytem", &reg); // filesytem vs filesystem: 1 missing char
352        match &v1.verdict {
353            UnknownVerdictKind::LikelyTyposquat { distance, .. } => assert_eq!(*distance, 1),
354            other => panic!("expected LikelyTyposquat, got {other:?}"),
355        }
356        // distance 2, long name → InformationalTyposquat
357        let v2 = classify_one("p", "filesystXY", &reg); // 2 substitutions
358        match &v2.verdict {
359            UnknownVerdictKind::InformationalTyposquat { distance, .. } => {
360                assert_eq!(*distance, 2);
361            }
362            other => panic!("expected InformationalTyposquat, got {other:?}"),
363        }
364    }
365
366    #[test]
367    fn classify_aggregates_dedup_count() {
368        use std::fs;
369        let tmp = tempfile::tempdir().unwrap();
370        let paths = paths_for(&tmp);
371        // Write .claude.json with "my-tool" in top-level and in two projects.
372        write_claude_json(
373            &tmp,
374            r#"{
375                "mcpServers": {"my-tool": {"command":"x"}},
376                "projects": {
377                    "/proj1": {"mcpServers": {"my-tool": {"command":"y"}}},
378                    "/proj2": {"mcpServers": {"my-tool": {"command":"z"}}}
379                }
380            }"#,
381        );
382        let reg = Registry::from_entries(vec![]);
383        let verdicts = classify(&paths, &reg);
384        // All occurrences of "my-tool" are aggregated into one row.
385        let v = verdicts.iter().find(|v| v.name == "my-tool").unwrap();
386        // 3 occurrences: top-level + 2 project scopes
387        assert_eq!(v.count, 3);
388        assert_eq!(v.paths.len(), 3);
389        // paths are sorted
390        assert!(v.paths.windows(2).all(|w| w[0] <= w[1]));
391
392        // Suppress unused import warning
393        let _ = fs::metadata(tmp.path());
394    }
395}