Skip to main content

mcplint_rules/
mg004_filesystem_scope.rs

1use mcplint_core::{Confidence, Evidence, Finding, FindingCategory, Rule, ScanContext, Severity};
2
3/// MG004: Filesystem scope violations.
4/// Detects arbitrary path access, globbing, or write access without confinement.
5pub struct Mg004FilesystemScope;
6
7/// Keywords indicating filesystem operations.
8const FS_TOOL_PATTERNS: &[&str] = &[
9    "file",
10    "path",
11    "directory",
12    "dir",
13    "folder",
14    "fs",
15    "filesystem",
16    "read_file",
17    "write_file",
18    "delete_file",
19    "list_dir",
20    "mkdir",
21    "copy",
22    "move",
23    "rename",
24];
25
26/// Keywords indicating path parameters that may allow traversal.
27const PATH_PARAM_PATTERNS: &[&str] = &[
28    "path",
29    "file",
30    "filename",
31    "filepath",
32    "directory",
33    "dir",
34    "folder",
35    "location",
36    "target",
37    "source",
38    "destination",
39    "glob",
40    "pattern",
41];
42
43/// Check if `text` contains `keyword` as a whole word (delimited by non-alphanumeric chars).
44fn contains_word(text: &str, keyword: &str) -> bool {
45    text.split(|c: char| !c.is_alphanumeric())
46        .any(|word| word == keyword)
47}
48
49/// Checks if a tool operates on the filesystem.
50fn is_filesystem_tool(name: &str, description: &str) -> bool {
51    let combined = format!("{} {}", name, description).to_lowercase();
52    FS_TOOL_PATTERNS.iter().any(|p| contains_word(&combined, p))
53}
54
55/// Checks if a parameter represents a file path.
56fn is_path_parameter(name: &str, description: &str) -> bool {
57    let combined = format!("{} {}", name, description).to_lowercase();
58    PATH_PARAM_PATTERNS
59        .iter()
60        .any(|p| contains_word(&combined, p))
61}
62
63impl Rule for Mg004FilesystemScope {
64    fn id(&self) -> &'static str {
65        "MG004"
66    }
67
68    fn description(&self) -> &'static str {
69        "Filesystem scope violations: arbitrary path access, globbing, or write access \
70         without confinement."
71    }
72
73    fn category(&self) -> FindingCategory {
74        FindingCategory::Static
75    }
76
77    fn explain(&self) -> &'static str {
78        "MG004 identifies MCP tools that provide filesystem access through unconstrained \
79         path parameters. Without explicit path confinement (e.g., allowlisted directories, \
80         chroot, or path validation), these tools allow path traversal attacks (../../etc/passwd), \
81         arbitrary file reads/writes, and glob-based data discovery. \
82         Remediation: constrain path parameters to specific directories using allowlists, \
83         validate paths to prevent traversal, and limit glob patterns to safe scopes."
84    }
85
86    fn cwe_ids(&self) -> Vec<&'static str> {
87        vec!["CWE-22", "CWE-73"]
88    }
89
90    fn owasp_ids(&self) -> Vec<&'static str> {
91        vec!["A01:2021"]
92    }
93
94    fn owasp_mcp_ids(&self) -> Vec<&'static str> {
95        vec!["MCP05:2025", "MCP10:2025"]
96    }
97
98    fn rationale(&self) -> &'static str {
99        "Filesystem tools without path confinement allow reading or writing arbitrary files."
100    }
101
102    fn references(&self) -> Vec<&'static str> {
103        vec!["https://cwe.mitre.org/data/definitions/22.html"]
104    }
105
106    fn check(&self, ctx: &ScanContext) -> Vec<Finding> {
107        let mut findings = Vec::new();
108
109        for server in &ctx.config.servers {
110            for (tool_idx, tool) in server.tools.iter().enumerate() {
111                if !is_filesystem_tool(&tool.name, &tool.description) {
112                    continue;
113                }
114
115                for (param_idx, param) in tool.parameters.iter().enumerate() {
116                    if !is_path_parameter(&param.name, &param.description) {
117                        continue;
118                    }
119
120                    // Check for lack of constraints
121                    let has_pattern = param.constraints.contains_key("pattern");
122                    let has_enum = param.constraints.contains_key("enum");
123                    let has_allowed_dirs = param.constraints.contains_key("allowedDirectories")
124                        || param.constraints.contains_key("allowed_directories")
125                        || param.constraints.contains_key("basePath")
126                        || param.constraints.contains_key("base_path");
127
128                    if has_pattern || has_enum || has_allowed_dirs {
129                        continue;
130                    }
131
132                    // Check if tool name implies write operations (higher severity)
133                    let is_write = {
134                        let lower = format!("{} {}", tool.name, tool.description).to_lowercase();
135                        lower.contains("write")
136                            || lower.contains("delete")
137                            || lower.contains("remove")
138                            || lower.contains("create")
139                            || lower.contains("modify")
140                    };
141
142                    let param_pointer = ctx
143                        .server_pointer(
144                            &server.name,
145                            &format!("tools/{}/parameters/{}", tool_idx, param_idx),
146                        )
147                        .or_else(|| ctx.server_pointer(&server.name, ""));
148                    let region = param_pointer
149                        .as_ref()
150                        .and_then(|ptr| ctx.region_for(ptr).cloned());
151
152                    findings.push(Finding {
153                        id: "MG004".to_string(),
154                        title: format!(
155                            "Unconfined filesystem access in tool '{}' via parameter '{}'",
156                            tool.name, param.name
157                        ),
158                        severity: if is_write {
159                            Severity::Critical
160                        } else {
161                            Severity::High
162                        },
163                        confidence: Confidence::High,
164                        category: FindingCategory::Static,
165                        description: format!(
166                            "Tool '{}' (server '{}') accepts an unconstrained path parameter \
167                             '{}' for filesystem operations. No allowedDirectories, basePath, \
168                             pattern, or enum constraints are defined, allowing arbitrary \
169                             filesystem access{}.",
170                            tool.name,
171                            server.name,
172                            param.name,
173                            if is_write { " including writes" } else { "" }
174                        ),
175                        exploit_scenario: format!(
176                            "An attacker provides a path like '../../etc/passwd' or \
177                             '/etc/shadow' to parameter '{}' in tool '{}', traversing \
178                             outside any intended directory scope to {} sensitive files.",
179                            param.name,
180                            tool.name,
181                            if is_write {
182                                "overwrite or delete"
183                            } else {
184                                "read"
185                            }
186                        ),
187                        evidence: vec![Evidence {
188                            location: format!(
189                                "{} > servers[{}] > tools[{}] > parameters[{}]",
190                                ctx.source_path, server.name, tool.name, param.name
191                            ),
192                            description: format!(
193                                "Unconstrained path parameter '{}' in filesystem tool with \
194                                 no directory scoping",
195                                param.name
196                            ),
197                            raw_value: Some(format!(
198                                "{{ \"name\": \"{}\", \"type\": \"{}\", \"constraints\": {{}} }}",
199                                param.name, param.param_type
200                            )),
201                            region,
202                            file: Some(ctx.source_path.clone()),
203                            json_pointer: param_pointer,
204                            server: Some(server.name.clone()),
205                            tool: Some(tool.name.clone()),
206                            parameter: Some(param.name.clone()),
207                        }],
208                        cwe_ids: vec!["CWE-22".to_string(), "CWE-73".to_string()],
209                        owasp_ids: vec!["A01:2021".to_string()],
210                        owasp_mcp_ids: vec![],
211                        remediation: format!(
212                            "Add path constraints to parameter '{}': define \
213                             'allowedDirectories' or 'basePath' to confine access to specific \
214                             directories. Add a 'pattern' constraint to reject path traversal \
215                             sequences. Consider using a chroot or sandbox for filesystem tools.",
216                            param.name
217                        ),
218                    });
219                }
220            }
221        }
222
223        findings
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use mcplint_core::*;
231    use std::collections::BTreeMap;
232
233    fn make_context(tools: Vec<ToolDefinition>) -> ScanContext {
234        ScanContext::new(
235            McpConfig {
236                servers: vec![McpServer {
237                    name: "test-server".into(),
238                    description: "".into(),
239                    tools,
240                    auth: AuthConfig::None,
241                    transport: "stdio".into(),
242                    url: None,
243                    command: None,
244                    args: vec![],
245                    env: BTreeMap::new(),
246                }],
247            },
248            "test.json".into(),
249        )
250    }
251
252    #[test]
253    fn detects_unconfined_read() {
254        let ctx = make_context(vec![ToolDefinition {
255            name: "read_file".into(),
256            description: "Read a file from disk".into(),
257            parameters: vec![ToolParameter {
258                name: "path".into(),
259                param_type: "string".into(),
260                description: "File path to read".into(),
261                required: true,
262                constraints: BTreeMap::new(),
263            }],
264            tags: vec![],
265            provenance: ToolProvenance::default(),
266        }]);
267
268        let rule = Mg004FilesystemScope;
269        let findings = rule.check(&ctx);
270        assert_eq!(findings.len(), 1);
271        assert_eq!(findings[0].severity, Severity::High);
272    }
273
274    #[test]
275    fn detects_unconfined_write_as_critical() {
276        let ctx = make_context(vec![ToolDefinition {
277            name: "write_file".into(),
278            description: "Write content to a file".into(),
279            parameters: vec![ToolParameter {
280                name: "path".into(),
281                param_type: "string".into(),
282                description: "File path to write".into(),
283                required: true,
284                constraints: BTreeMap::new(),
285            }],
286            tags: vec![],
287            provenance: ToolProvenance::default(),
288        }]);
289
290        let rule = Mg004FilesystemScope;
291        let findings = rule.check(&ctx);
292        assert_eq!(findings.len(), 1);
293        assert_eq!(findings[0].severity, Severity::Critical);
294    }
295
296    #[test]
297    fn no_finding_for_constrained_path() {
298        let mut constraints = BTreeMap::new();
299        constraints.insert(
300            "allowedDirectories".to_string(),
301            serde_json::json!(["/tmp", "/var/data"]),
302        );
303
304        let ctx = make_context(vec![ToolDefinition {
305            name: "read_file".into(),
306            description: "Read a file from disk".into(),
307            parameters: vec![ToolParameter {
308                name: "path".into(),
309                param_type: "string".into(),
310                description: "File path to read".into(),
311                required: true,
312                constraints,
313            }],
314            tags: vec![],
315            provenance: ToolProvenance::default(),
316        }]);
317
318        let rule = Mg004FilesystemScope;
319        let findings = rule.check(&ctx);
320        assert!(findings.is_empty());
321    }
322
323    #[test]
324    fn no_false_positive_on_substring_match() {
325        // "file" should not match "profile", "path" should not match "xpath"
326        let ctx = make_context(vec![ToolDefinition {
327            name: "get_profile".into(),
328            description: "Get user profile via xpath query".into(),
329            parameters: vec![ToolParameter {
330                name: "xpath_expression".into(),
331                param_type: "string".into(),
332                description: "The classpath for the profile query".into(),
333                required: true,
334                constraints: BTreeMap::new(),
335            }],
336            tags: vec![],
337            provenance: ToolProvenance::default(),
338        }]);
339
340        let rule = Mg004FilesystemScope;
341        let findings = rule.check(&ctx);
342        assert!(
343            findings.is_empty(),
344            "should not match 'file' in 'profile' or 'path' in 'xpath/classpath'"
345        );
346    }
347}