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