1use mcplint_core::{Confidence, Evidence, Finding, FindingCategory, Rule, ScanContext, Severity};
2
3pub struct Mg004FilesystemScope;
6
7const 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
26const 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
43fn contains_word(text: &str, keyword: &str) -> bool {
45 text.split(|c: char| !c.is_alphanumeric())
46 .any(|word| word == keyword)
47}
48
49fn 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
55fn 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(¶m.name, ¶m.description) {
117 continue;
118 }
119
120 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 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 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}