Skip to main content

mcplint_rules/
mg001_unbounded_string.rs

1use mcplint_core::{Confidence, Evidence, Finding, FindingCategory, Rule, ScanContext, Severity};
2
3/// MG001: Unbounded string to dangerous sink.
4/// Detects free-form string inputs flowing into exec, SQL, filesystem, HTTP,
5/// or eval-like behavior without constraints.
6pub struct Mg001UnboundedString;
7
8/// Sinks that indicate dangerous operations when fed unbounded strings.
9const DANGEROUS_SINK_PATTERNS: &[&str] = &[
10    "exec", "execute", "eval", "run", "shell", "command", "cmd", "query", "sql", "script",
11    "system", "spawn", "fork", "write", "delete", "remove", "fetch", "request", "http", "curl",
12    "wget",
13];
14
15/// Checks if a tool name or description indicates a dangerous sink.
16fn is_dangerous_sink(name: &str, description: &str) -> Vec<&'static str> {
17    let combined = format!("{} {}", name, description).to_lowercase();
18    DANGEROUS_SINK_PATTERNS
19        .iter()
20        .filter(|pattern| combined.contains(**pattern))
21        .copied()
22        .collect()
23}
24
25/// Returns the sink-specific maxLength threshold.
26fn sink_threshold(sink: &str) -> u64 {
27    match sink {
28        "exec" | "shell" | "command" | "eval" | "run" | "system" | "spawn" | "fork" => 500,
29        "sql" | "query" | "select" => 2_000,
30        "write" | "delete" | "remove" | "script" => 1_000,
31        "http" | "fetch" | "request" | "curl" | "wget" => 4_000,
32        _ => 10_000,
33    }
34}
35
36/// Checks if a parameter is an unbounded string (no meaningful constraints).
37/// Returns None if constrained, Some(severity) if unconstrained or weakly constrained.
38fn check_string_constraint(
39    param: &mcplint_core::ToolParameter,
40    matched_sink: &str,
41) -> Option<Severity> {
42    if param.param_type.to_lowercase() != "string" {
43        return None;
44    }
45    let has_enum = param.constraints.contains_key("enum");
46    let has_format = param.constraints.contains_key("format");
47
48    let has_meaningful_pattern = param
49        .constraints
50        .get("pattern")
51        .and_then(|v| v.as_str())
52        .is_some_and(|p| !is_trivial_pattern(p));
53
54    if has_enum || has_meaningful_pattern || has_format {
55        return None; // Properly constrained
56    }
57
58    let threshold = sink_threshold(matched_sink);
59    if let Some(max_len) = param.constraints.get("maxLength").and_then(|v| v.as_u64()) {
60        if max_len > 0 && max_len <= threshold {
61            return None; // maxLength within sink-specific threshold
62        }
63        // maxLength exists but too large for this sink type
64        return Some(Severity::Medium);
65    }
66
67    // No maxLength at all
68    Some(Severity::High)
69}
70
71/// Returns true if a regex pattern is trivially permissive.
72fn is_trivial_pattern(pattern: &str) -> bool {
73    // Normalize: strip anchors and outer grouping
74    let normalized = pattern
75        .trim()
76        .trim_start_matches('^')
77        .trim_end_matches('$')
78        .trim_start_matches('(')
79        .trim_end_matches(')');
80
81    let trivial = [
82        ".*",
83        ".+",
84        ".*?",
85        ".+?",
86        "[\\s\\S]*",
87        "[\\s\\S]+",
88        "[\\w\\W]*",
89        "[\\w\\W]+",
90        "[^]*",
91        "[^]+",
92        ".{0,}",
93        ".{1,}",
94        ".*\\S.*",
95    ];
96
97    trivial.contains(&normalized) || normalized.is_empty()
98}
99
100impl Rule for Mg001UnboundedString {
101    fn id(&self) -> &'static str {
102        "MG001"
103    }
104
105    fn description(&self) -> &'static str {
106        "Unbounded string to dangerous sink: free-form string inputs flowing into exec, SQL, \
107         filesystem, HTTP, or eval-like behavior without constraints."
108    }
109
110    fn category(&self) -> FindingCategory {
111        FindingCategory::Static
112    }
113
114    fn explain(&self) -> &'static str {
115        "MG001 detects MCP tools that accept unconstrained string parameters and pass them \
116         to dangerous operations (execution, database queries, filesystem operations, or \
117         network requests). An attacker who controls the string input can inject malicious \
118         payloads — SQL injection, command injection, path traversal, or SSRF. \
119         Remediation: add constraints such as enum values, regex patterns, maxLength limits, \
120         or format specifications to all string parameters that flow to sensitive operations."
121    }
122
123    fn cwe_ids(&self) -> Vec<&'static str> {
124        vec!["CWE-77", "CWE-89", "CWE-78"]
125    }
126
127    fn owasp_ids(&self) -> Vec<&'static str> {
128        vec!["A03:2021"]
129    }
130
131    fn owasp_mcp_ids(&self) -> Vec<&'static str> {
132        vec!["MCP05:2025", "MCP06:2025"]
133    }
134
135    fn rationale(&self) -> &'static str {
136        "Unbounded string parameters flowing to execution sinks enable injection attacks."
137    }
138
139    fn references(&self) -> Vec<&'static str> {
140        vec![
141            "https://cwe.mitre.org/data/definitions/77.html",
142            "https://owasp.org/Top10/A03_2021-Injection/",
143        ]
144    }
145
146    fn check(&self, ctx: &ScanContext) -> Vec<Finding> {
147        let mut findings = Vec::new();
148
149        for server in &ctx.config.servers {
150            for (tool_idx, tool) in server.tools.iter().enumerate() {
151                let sinks = is_dangerous_sink(&tool.name, &tool.description);
152                if sinks.is_empty() {
153                    continue;
154                }
155
156                let unbounded_params: Vec<(usize, &mcplint_core::ToolParameter, Severity, &str)> =
157                    tool.parameters
158                        .iter()
159                        .enumerate()
160                        .filter_map(|(i, p)| {
161                            // Pick the first matching sink for threshold calculation
162                            let matched = sinks.first().unwrap();
163                            check_string_constraint(p, matched).map(|sev| (i, p, sev, *matched))
164                        })
165                        .collect();
166
167                for (param_idx, param, severity, _matched_sink) in &unbounded_params {
168                    let sink_list = sinks.join(", ");
169
170                    // Try to resolve region from server pointer + tool/param path
171                    let param_pointer = ctx
172                        .server_pointer(
173                            &server.name,
174                            &format!("tools/{}/parameters/{}", tool_idx, param_idx),
175                        )
176                        .or_else(|| ctx.server_pointer(&server.name, ""));
177                    let region = param_pointer
178                        .as_ref()
179                        .and_then(|ptr| ctx.region_for(ptr).cloned());
180
181                    findings.push(Finding {
182                        id: "MG001".to_string(),
183                        title: format!(
184                            "Unbounded string '{}' flows to dangerous sink in tool '{}'",
185                            param.name, tool.name
186                        ),
187                        severity: *severity,
188                        confidence: Confidence::High,
189                        category: FindingCategory::Static,
190                        description: format!(
191                            "Parameter '{}' in tool '{}' (server '{}') is an unconstrained \
192                             string that flows to dangerous operation(s): {}. No enum, pattern, \
193                             maxLength, or format constraints are defined.",
194                            param.name, tool.name, server.name, sink_list
195                        ),
196                        exploit_scenario: format!(
197                            "An attacker controlling the '{}' parameter can inject malicious \
198                             content targeting the {} sink(s). For example, if this is a SQL \
199                             query parameter, the attacker could execute 'DROP TABLE users; --' \
200                             or exfiltrate data via UNION-based injection.",
201                            param.name, sink_list
202                        ),
203                        evidence: vec![Evidence {
204                            location: format!(
205                                "{} > servers[{}] > tools[{}] > parameters[{}]",
206                                ctx.source_path, server.name, tool.name, param.name
207                            ),
208                            description: format!(
209                                "Unconstrained string parameter '{}' with type '{}' and no \
210                                 validation constraints, in tool with dangerous sink indicators: {}",
211                                param.name, param.param_type, sink_list
212                            ),
213                            raw_value: Some(format!(
214                                "{{ \"name\": \"{}\", \"type\": \"{}\", \"constraints\": {{}} }}",
215                                param.name, param.param_type
216                            )),
217                            region,
218                            file: Some(ctx.source_path.clone()),
219                            json_pointer: param_pointer,
220                            server: Some(server.name.clone()),
221                            tool: Some(tool.name.clone()),
222                            parameter: Some(param.name.clone()),
223                        }],
224                        cwe_ids: vec![
225                            "CWE-77".to_string(),
226                            "CWE-89".to_string(),
227                            "CWE-78".to_string(),
228                        ],
229                        owasp_ids: vec!["A03:2021".to_string()],
230                        owasp_mcp_ids: vec![],
231                        remediation: format!(
232                            "Add input constraints to parameter '{}': use an enum for known \
233                             values, a regex pattern for structured input, maxLength to limit \
234                             size, or a format specifier. Consider parameterized queries for \
235                             SQL sinks and allowlists for command execution.",
236                            param.name
237                        ),
238                    });
239                }
240            }
241        }
242
243        findings
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use mcplint_core::*;
251    use std::collections::BTreeMap;
252
253    fn make_context(tools: Vec<ToolDefinition>) -> ScanContext {
254        ScanContext::new(
255            McpConfig {
256                servers: vec![McpServer {
257                    name: "test-server".into(),
258                    description: "".into(),
259                    tools,
260                    auth: AuthConfig::None,
261                    transport: "stdio".into(),
262                    url: None,
263                    command: None,
264                    args: vec![],
265                    env: BTreeMap::new(),
266                }],
267            },
268            "test.json".into(),
269        )
270    }
271
272    #[test]
273    fn detects_unbounded_sql_query() {
274        let ctx = make_context(vec![ToolDefinition {
275            name: "run_query".into(),
276            description: "Execute a SQL query against the database".into(),
277            parameters: vec![ToolParameter {
278                name: "query".into(),
279                param_type: "string".into(),
280                description: "SQL query".into(),
281                required: true,
282                constraints: BTreeMap::new(),
283            }],
284            tags: vec![],
285            provenance: ToolProvenance::default(),
286        }]);
287
288        let rule = Mg001UnboundedString;
289        let findings = rule.check(&ctx);
290        assert_eq!(findings.len(), 1);
291        assert_eq!(findings[0].id, "MG001");
292        assert_eq!(findings[0].severity, Severity::High);
293    }
294
295    #[test]
296    fn no_finding_for_constrained_param() {
297        let mut constraints = BTreeMap::new();
298        constraints.insert("enum".to_string(), serde_json::json!(["SELECT", "INSERT"]));
299
300        let ctx = make_context(vec![ToolDefinition {
301            name: "run_query".into(),
302            description: "Execute a SQL query".into(),
303            parameters: vec![ToolParameter {
304                name: "query".into(),
305                param_type: "string".into(),
306                description: "SQL query".into(),
307                required: true,
308                constraints,
309            }],
310            tags: vec![],
311            provenance: ToolProvenance::default(),
312        }]);
313
314        let rule = Mg001UnboundedString;
315        let findings = rule.check(&ctx);
316        assert!(findings.is_empty());
317    }
318
319    #[test]
320    fn detects_trivial_pattern_constraint() {
321        // pattern: ".*" is effectively no constraint
322        let mut constraints = BTreeMap::new();
323        constraints.insert("pattern".to_string(), serde_json::json!(".*"));
324
325        let ctx = make_context(vec![ToolDefinition {
326            name: "run_query".into(),
327            description: "Execute a SQL query".into(),
328            parameters: vec![ToolParameter {
329                name: "query".into(),
330                param_type: "string".into(),
331                description: "SQL query".into(),
332                required: true,
333                constraints,
334            }],
335            tags: vec![],
336            provenance: ToolProvenance::default(),
337        }]);
338
339        let rule = Mg001UnboundedString;
340        let findings = rule.check(&ctx);
341        assert_eq!(
342            findings.len(),
343            1,
344            "trivial pattern '.*' should not suppress finding"
345        );
346    }
347
348    #[test]
349    fn detects_absurd_max_length() {
350        // maxLength: 999999 is effectively no constraint
351        let mut constraints = BTreeMap::new();
352        constraints.insert("maxLength".to_string(), serde_json::json!(999_999));
353
354        let ctx = make_context(vec![ToolDefinition {
355            name: "run_query".into(),
356            description: "Execute a SQL query".into(),
357            parameters: vec![ToolParameter {
358                name: "query".into(),
359                param_type: "string".into(),
360                description: "SQL query".into(),
361                required: true,
362                constraints,
363            }],
364            tags: vec![],
365            provenance: ToolProvenance::default(),
366        }]);
367
368        let rule = Mg001UnboundedString;
369        let findings = rule.check(&ctx);
370        assert_eq!(
371            findings.len(),
372            1,
373            "absurdly large maxLength should not suppress finding"
374        );
375    }
376
377    #[test]
378    fn no_finding_for_meaningful_pattern() {
379        let mut constraints = BTreeMap::new();
380        constraints.insert("pattern".to_string(), serde_json::json!("^[a-zA-Z_]+$"));
381
382        let ctx = make_context(vec![ToolDefinition {
383            name: "run_query".into(),
384            description: "Execute a SQL query".into(),
385            parameters: vec![ToolParameter {
386                name: "query".into(),
387                param_type: "string".into(),
388                description: "SQL query".into(),
389                required: true,
390                constraints,
391            }],
392            tags: vec![],
393            provenance: ToolProvenance::default(),
394        }]);
395
396        let rule = Mg001UnboundedString;
397        let findings = rule.check(&ctx);
398        assert!(
399            findings.is_empty(),
400            "meaningful pattern should suppress finding"
401        );
402    }
403
404    #[test]
405    fn no_finding_for_safe_tool() {
406        let ctx = make_context(vec![ToolDefinition {
407            name: "get_time".into(),
408            description: "Returns the current time".into(),
409            parameters: vec![ToolParameter {
410                name: "timezone".into(),
411                param_type: "string".into(),
412                description: "Timezone name".into(),
413                required: false,
414                constraints: BTreeMap::new(),
415            }],
416            tags: vec![],
417            provenance: ToolProvenance::default(),
418        }]);
419
420        let rule = Mg001UnboundedString;
421        let findings = rule.check(&ctx);
422        assert!(findings.is_empty());
423    }
424
425    #[test]
426    fn trivial_anchored_pattern() {
427        let mut constraints = BTreeMap::new();
428        constraints.insert("pattern".to_string(), serde_json::json!("^.*$"));
429
430        let ctx = make_context(vec![ToolDefinition {
431            name: "run_query".into(),
432            description: "Execute a SQL query".into(),
433            parameters: vec![ToolParameter {
434                name: "query".into(),
435                param_type: "string".into(),
436                description: "SQL query".into(),
437                required: true,
438                constraints,
439            }],
440            tags: vec![],
441            provenance: ToolProvenance::default(),
442        }]);
443
444        let rule = Mg001UnboundedString;
445        let findings = rule.check(&ctx);
446        assert_eq!(findings.len(), 1, "^.*$ is trivial — should still flag");
447    }
448
449    #[test]
450    fn trivial_nongreedy_pattern() {
451        assert!(is_trivial_pattern(".*?"));
452        assert!(is_trivial_pattern(".+?"));
453    }
454
455    #[test]
456    fn trivial_cross_line_pattern() {
457        assert!(is_trivial_pattern("[\\s\\S]*"));
458        assert!(is_trivial_pattern("[\\w\\W]*"));
459    }
460
461    #[test]
462    fn trivial_grouped_pattern() {
463        assert!(is_trivial_pattern("(.+)"));
464    }
465
466    #[test]
467    fn nontrivial_patterns() {
468        assert!(!is_trivial_pattern("^[a-zA-Z0-9_]+$"));
469        assert!(!is_trivial_pattern("\\d{3}-\\d{4}"));
470    }
471
472    #[test]
473    fn sql_param_with_safe_max_length() {
474        // maxLength=500 is within the SQL threshold of 2000 — no finding
475        let mut constraints = BTreeMap::new();
476        constraints.insert("maxLength".to_string(), serde_json::json!(500));
477
478        let ctx = make_context(vec![ToolDefinition {
479            name: "run_query".into(),
480            description: "Execute a SQL query".into(),
481            parameters: vec![ToolParameter {
482                name: "query".into(),
483                param_type: "string".into(),
484                description: "SQL query".into(),
485                required: true,
486                constraints,
487            }],
488            tags: vec![],
489            provenance: ToolProvenance::default(),
490        }]);
491
492        let rule = Mg001UnboundedString;
493        let findings = rule.check(&ctx);
494        assert!(findings.is_empty(), "maxLength=500 for SQL should be safe");
495    }
496
497    #[test]
498    fn sql_param_above_threshold_is_medium() {
499        // maxLength=5000 exceeds SQL threshold (2000) — Medium severity
500        let mut constraints = BTreeMap::new();
501        constraints.insert("maxLength".to_string(), serde_json::json!(5000));
502
503        let ctx = make_context(vec![ToolDefinition {
504            name: "run_query".into(),
505            description: "Execute a SQL query".into(),
506            parameters: vec![ToolParameter {
507                name: "query".into(),
508                param_type: "string".into(),
509                description: "SQL query".into(),
510                required: true,
511                constraints,
512            }],
513            tags: vec![],
514            provenance: ToolProvenance::default(),
515        }]);
516
517        let rule = Mg001UnboundedString;
518        let findings = rule.check(&ctx);
519        assert_eq!(findings.len(), 1);
520        assert_eq!(findings[0].severity, Severity::Medium);
521    }
522
523    #[test]
524    fn sql_param_no_max_length_is_high() {
525        // No maxLength at all — High severity
526        let ctx = make_context(vec![ToolDefinition {
527            name: "run_query".into(),
528            description: "Execute a SQL query".into(),
529            parameters: vec![ToolParameter {
530                name: "query".into(),
531                param_type: "string".into(),
532                description: "SQL query".into(),
533                required: true,
534                constraints: BTreeMap::new(),
535            }],
536            tags: vec![],
537            provenance: ToolProvenance::default(),
538        }]);
539
540        let rule = Mg001UnboundedString;
541        let findings = rule.check(&ctx);
542        assert_eq!(findings.len(), 1);
543        assert_eq!(findings[0].severity, Severity::High);
544    }
545
546    #[test]
547    fn exec_param_with_safe_max_length() {
548        let mut constraints = BTreeMap::new();
549        constraints.insert("maxLength".to_string(), serde_json::json!(200));
550
551        let ctx = make_context(vec![ToolDefinition {
552            name: "exec_command".into(),
553            description: "Execute a shell command".into(),
554            parameters: vec![ToolParameter {
555                name: "cmd".into(),
556                param_type: "string".into(),
557                description: "command".into(),
558                required: true,
559                constraints,
560            }],
561            tags: vec![],
562            provenance: ToolProvenance::default(),
563        }]);
564
565        let rule = Mg001UnboundedString;
566        let findings = rule.check(&ctx);
567        assert!(findings.is_empty(), "maxLength=200 for exec should be safe");
568    }
569
570    #[test]
571    fn exec_param_above_threshold_is_medium() {
572        let mut constraints = BTreeMap::new();
573        constraints.insert("maxLength".to_string(), serde_json::json!(2000));
574
575        let ctx = make_context(vec![ToolDefinition {
576            name: "exec_command".into(),
577            description: "Execute a shell command".into(),
578            parameters: vec![ToolParameter {
579                name: "cmd".into(),
580                param_type: "string".into(),
581                description: "command".into(),
582                required: true,
583                constraints,
584            }],
585            tags: vec![],
586            provenance: ToolProvenance::default(),
587        }]);
588
589        let rule = Mg001UnboundedString;
590        let findings = rule.check(&ctx);
591        assert_eq!(findings.len(), 1);
592        assert_eq!(findings[0].severity, Severity::Medium);
593    }
594}