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;
29
30use serde::{Deserialize, Serialize};
31
32use crate::Paths;
33use crate::platform::PlatformProbe;
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(
81    paths: &Paths,
82    registry: &Registry,
83    probes: &[&dyn PlatformProbe],
84) -> Vec<UnknownVerdict> {
85    // Collect (path, name) pairs — BTreeSet gives dedup + sorted order.
86    let mut named: BTreeSet<(String, String)> = BTreeSet::new();
87
88    // Enumerate MCP config files via each registered platform probe; for
89    // each existing file, parse its content with that probe's
90    // decomposition rules. File-read / JSON-parse failures are silently
91    // swallowed so a single bad config does not fail the whole
92    // classification (a missing `.mcp.json` is the common case).
93    for probe in probes {
94        for path in probe.mcp_config_paths(paths) {
95            let Ok(body) = fs::read_to_string(&path) else {
96                continue;
97            };
98            let Ok(entries) = probe.extract_mcp_servers(&body, &path) else {
99                continue;
100            };
101            for entry in entries {
102                named.insert((entry.display_path, entry.name));
103            }
104        }
105    }
106
107    // Group by name: BTreeMap keeps names sorted.
108    let mut by_name: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
109    for (path, name) in named {
110        by_name.entry(name).or_default().insert(path);
111    }
112
113    by_name
114        .into_iter()
115        .map(|(name, path_set)| {
116            let paths_vec: Vec<String> = path_set.into_iter().collect();
117            let count = paths_vec.len();
118            // Use the first path as the representative for classification;
119            // the verdict depends only on the name + registry.
120            let rep = paths_vec.first().map_or("", String::as_str);
121            let mut v = classify_one(rep, &name, registry);
122            v.count = count;
123            v.paths = paths_vec;
124            v
125        })
126        .collect()
127}
128
129fn classify_one(path: &str, name: &str, registry: &Registry) -> UnknownVerdict {
130    if let Some(entry) = registry.get(name) {
131        return UnknownVerdict {
132            name: name.to_string(),
133            verdict: UnknownVerdictKind::KnownGood,
134            reason: format!("registered (source: {})", entry.source),
135            count: 1,
136            paths: vec![path.to_string()],
137        };
138    }
139    if let Some((entry, distance)) = registry.closest(name, TYPOSQUAT_DISTANCE_MAX) {
140        // Suppress distance-2 hits for very short names (≤ 3 chars) to avoid
141        // false positives like "git" → "filesystem" (not a real threat model).
142        let char_count = name.chars().count();
143        if distance == 2 && char_count <= 3 {
144            // Fall through to Unknown below.
145        } else {
146            let verdict = if distance == 1 {
147                UnknownVerdictKind::LikelyTyposquat {
148                    closest: entry.name.clone(),
149                    distance,
150                }
151            } else {
152                UnknownVerdictKind::InformationalTyposquat {
153                    closest: entry.name.clone(),
154                    distance,
155                }
156            };
157            return UnknownVerdict {
158                name: name.to_string(),
159                verdict,
160                reason: format!("close to `{}` (distance {})", entry.name, distance),
161                count: 1,
162                paths: vec![path.to_string()],
163            };
164        }
165    }
166    UnknownVerdict {
167        name: name.to_string(),
168        verdict: UnknownVerdictKind::Unknown,
169        reason: "not in registry".to_string(),
170        count: 1,
171        paths: vec![path.to_string()],
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use crate::platform::McpServerEntry;
179    use crate::registry::RegistryEntry;
180    use std::path::{Path, PathBuf};
181
182    /// Test-only Claude Code probe stub. Mirrors just the bits these
183    /// `classify`-based tests exercise: it points at the home-rooted
184    /// `.claude.json` and parses `mcpServers` + per-project blocks.
185    /// The real probe lives in `agentsec-platform-claude`; we don't
186    /// take it as a dev-dependency here because the cargo cycle
187    /// (platform-claude → core → platform-claude) makes cargo treat
188    /// the trait as two separate instances at lib-test build time.
189    struct TestClaudeProbe;
190
191    impl PlatformProbe for TestClaudeProbe {
192        fn id(&self) -> &'static str {
193            "claude-code-test"
194        }
195        fn target_roots(&self, _paths: &Paths) -> Vec<(String, PathBuf)> {
196            Vec::new()
197        }
198        fn mcp_config_paths(&self, paths: &Paths) -> Vec<PathBuf> {
199            vec![
200                paths.user_home.join(".claude.json"),
201                PathBuf::from(".mcp.json"),
202            ]
203        }
204        fn extract_mcp_servers(
205            &self,
206            content: &str,
207            path: &Path,
208        ) -> crate::error::Result<Vec<McpServerEntry>> {
209            let json: serde_json::Value = serde_json::from_str(content)?;
210            let display = path.display().to_string();
211            let mut out = Vec::new();
212            if let Some(top) = json
213                .get("mcpServers")
214                .and_then(serde_json::Value::as_object)
215            {
216                for name in top.keys() {
217                    out.push(McpServerEntry {
218                        name: name.clone(),
219                        display_path: display.clone(),
220                    });
221                }
222            }
223            if path
224                .file_name()
225                .and_then(|f| f.to_str())
226                .is_some_and(|n| n == ".claude.json")
227            {
228                if let Some(projects) = json.get("projects").and_then(serde_json::Value::as_object)
229                {
230                    for (proj_name, proj_val) in projects {
231                        if let Some(servers) = proj_val
232                            .get("mcpServers")
233                            .and_then(serde_json::Value::as_object)
234                        {
235                            for name in servers.keys() {
236                                let scoped = format!("{display}#projects.{proj_name}.mcpServers");
237                                out.push(McpServerEntry {
238                                    name: name.clone(),
239                                    display_path: scoped,
240                                });
241                            }
242                        }
243                    }
244                }
245            }
246            Ok(out)
247        }
248    }
249
250    fn claude_only(probe: &TestClaudeProbe) -> [&dyn PlatformProbe; 1] {
251        [probe as &dyn PlatformProbe]
252    }
253
254    fn write_claude_json(tmp: &tempfile::TempDir, body: &str) {
255        std::fs::write(tmp.path().join(".claude.json"), body).unwrap();
256    }
257
258    fn paths_for(tmp: &tempfile::TempDir) -> Paths {
259        Paths {
260            home: tmp.path().to_path_buf(),
261            user_home: tmp.path().to_path_buf(),
262        }
263    }
264
265    fn small_registry() -> Registry {
266        Registry::from_entries(vec![
267            RegistryEntry {
268                name: "filesystem".into(),
269                source: "test-source".into(),
270            },
271            RegistryEntry {
272                name: "github".into(),
273                source: "test-source".into(),
274            },
275        ])
276    }
277
278    #[test]
279    fn known_good_is_recognised() {
280        let tmp = tempfile::tempdir().unwrap();
281        write_claude_json(&tmp, r#"{"mcpServers":{"filesystem":{"command":"x"}}}"#);
282        let verdicts = classify(
283            &paths_for(&tmp),
284            &small_registry(),
285            &claude_only(&TestClaudeProbe),
286        );
287        assert_eq!(verdicts.len(), 1);
288        assert_eq!(verdicts[0].name, "filesystem");
289        assert_eq!(verdicts[0].verdict, UnknownVerdictKind::KnownGood);
290    }
291
292    #[test]
293    fn typosquat_is_flagged() {
294        let tmp = tempfile::tempdir().unwrap();
295        // "filesystme" — transposition of last two chars from "filesystem",
296        // Levenshtein distance 2 → InformationalTyposquat.
297        write_claude_json(&tmp, r#"{"mcpServers":{"filesystme":{"command":"x"}}}"#);
298        let verdicts = classify(
299            &paths_for(&tmp),
300            &small_registry(),
301            &claude_only(&TestClaudeProbe),
302        );
303        assert_eq!(verdicts.len(), 1);
304        match &verdicts[0].verdict {
305            UnknownVerdictKind::InformationalTyposquat { closest, distance } => {
306                assert_eq!(closest, "filesystem");
307                assert_eq!(*distance, 2);
308            }
309            other => panic!("expected InformationalTyposquat, got {other:?}"),
310        }
311    }
312
313    #[test]
314    fn unknown_name_is_unknown() {
315        let tmp = tempfile::tempdir().unwrap();
316        write_claude_json(
317            &tmp,
318            r#"{"mcpServers":{"my-private-tool":{"command":"x"}}}"#,
319        );
320        let verdicts = classify(
321            &paths_for(&tmp),
322            &small_registry(),
323            &claude_only(&TestClaudeProbe),
324        );
325        assert_eq!(verdicts.len(), 1);
326        assert_eq!(verdicts[0].verdict, UnknownVerdictKind::Unknown);
327    }
328
329    #[test]
330    fn per_project_servers_are_classified() {
331        let tmp = tempfile::tempdir().unwrap();
332        write_claude_json(
333            &tmp,
334            r#"{
335                "mcpServers": {"github": {"command":"x"}},
336                "projects": {
337                    "/some/proj": {
338                        "mcpServers": {"my-tool": {"command":"y"}}
339                    }
340                }
341            }"#,
342        );
343        let verdicts = classify(
344            &paths_for(&tmp),
345            &small_registry(),
346            &claude_only(&TestClaudeProbe),
347        );
348        assert_eq!(verdicts.len(), 2);
349        let names: Vec<&str> = verdicts.iter().map(|v| v.name.as_str()).collect();
350        assert!(names.contains(&"github"));
351        assert!(names.contains(&"my-tool"));
352    }
353
354    #[test]
355    fn missing_claude_json_yields_empty_result() {
356        let tmp = tempfile::tempdir().unwrap();
357        let verdicts = classify(
358            &paths_for(&tmp),
359            &small_registry(),
360            &claude_only(&TestClaudeProbe),
361        );
362        assert!(verdicts.is_empty());
363    }
364
365    #[test]
366    fn malformed_json_silently_skipped() {
367        let tmp = tempfile::tempdir().unwrap();
368        write_claude_json(&tmp, "not even close to JSON");
369        let verdicts = classify(
370            &paths_for(&tmp),
371            &small_registry(),
372            &claude_only(&TestClaudeProbe),
373        );
374        assert!(verdicts.is_empty());
375    }
376
377    // ── A-2 heuristic tests ───────────────────────────────────────────────
378
379    #[test]
380    fn classify_one_short_name_distance_2_suppressed() {
381        // "git" has length 3, closest registry entry is "github" (distance 3)
382        // or "filesystem" (distance > 2). Use a custom registry with a
383        // 2-distance match to exercise the short-name suppression.
384        let reg = Registry::from_entries(vec![RegistryEntry {
385            name: "liv".into(),
386            source: "test".into(),
387        }]);
388        // "git" → "liv": levenshtein("git","liv") = 3 → actually no match at max 2.
389        // Use "gi" (2 chars) and "github" (distance 5) — too far.
390        // Directly test classify_one: name "gxt", registry has "git", distance 2.
391        let reg2 = Registry::from_entries(vec![RegistryEntry {
392            name: "git".into(),
393            source: "test".into(),
394        }]);
395        // "gxt" vs "git" = distance 1 (transposition: x→i) — actually 1, not 2.
396        // Use "ab" vs "cd": distance 2, both len 2 → suppressed.
397        let reg3 = Registry::from_entries(vec![RegistryEntry {
398            name: "ab".into(),
399            source: "test".into(),
400        }]);
401        let v = classify_one("path", "cd", &reg3);
402        // "cd" len=2 ≤ 3, distance 2 → suppressed to Unknown
403        assert_eq!(v.verdict, UnknownVerdictKind::Unknown);
404
405        // Sanity: if registry has "ab", "abc" (len 3 ≤ 3) close at distance 2
406        // to "axy" → suppressed
407        let reg4 = Registry::from_entries(vec![RegistryEntry {
408            name: "abc".into(),
409            source: "test".into(),
410        }]);
411        // "axz" vs "abc" = 2 subs → distance 2, len("axz")=3 ≤ 3 → suppressed
412        let v2 = classify_one("path", "axz", &reg4);
413        assert_eq!(v2.verdict, UnknownVerdictKind::Unknown);
414
415        // Suppress unused-var warning for reg, reg2 by using them.
416        let _ = reg;
417        let _ = reg2;
418    }
419
420    #[test]
421    fn classify_severity_split() {
422        let reg = Registry::from_entries(vec![RegistryEntry {
423            name: "filesystem".into(),
424            source: "test".into(),
425        }]);
426        // distance 1 → LikelyTyposquat
427        let v1 = classify_one("p", "filesytem", &reg); // filesytem vs filesystem: 1 missing char
428        match &v1.verdict {
429            UnknownVerdictKind::LikelyTyposquat { distance, .. } => assert_eq!(*distance, 1),
430            other => panic!("expected LikelyTyposquat, got {other:?}"),
431        }
432        // distance 2, long name → InformationalTyposquat
433        let v2 = classify_one("p", "filesystXY", &reg); // 2 substitutions
434        match &v2.verdict {
435            UnknownVerdictKind::InformationalTyposquat { distance, .. } => {
436                assert_eq!(*distance, 2);
437            }
438            other => panic!("expected InformationalTyposquat, got {other:?}"),
439        }
440    }
441
442    #[test]
443    fn classify_aggregates_dedup_count() {
444        use std::fs;
445        let tmp = tempfile::tempdir().unwrap();
446        let paths = paths_for(&tmp);
447        // Write .claude.json with "my-tool" in top-level and in two projects.
448        write_claude_json(
449            &tmp,
450            r#"{
451                "mcpServers": {"my-tool": {"command":"x"}},
452                "projects": {
453                    "/proj1": {"mcpServers": {"my-tool": {"command":"y"}}},
454                    "/proj2": {"mcpServers": {"my-tool": {"command":"z"}}}
455                }
456            }"#,
457        );
458        let reg = Registry::from_entries(vec![]);
459        let verdicts = classify(&paths, &reg, &claude_only(&TestClaudeProbe));
460        // All occurrences of "my-tool" are aggregated into one row.
461        let v = verdicts.iter().find(|v| v.name == "my-tool").unwrap();
462        // 3 occurrences: top-level + 2 project scopes
463        assert_eq!(v.count, 3);
464        assert_eq!(v.paths.len(), 3);
465        // paths are sorted
466        assert!(v.paths.windows(2).all(|w| w[0] <= w[1]));
467
468        // Suppress unused import warning
469        let _ = fs::metadata(tmp.path());
470    }
471}