1use mcplint_core::{Confidence, Evidence, Finding, FindingCategory, Rule, ScanContext, Severity};
2
3pub struct Mg001UnboundedString;
7
8const 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
15fn 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
25fn 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
36fn 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; }
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; }
63 return Some(Severity::Medium);
65 }
66
67 Some(Severity::High)
69}
70
71fn is_trivial_pattern(pattern: &str) -> bool {
73 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 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 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 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 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 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 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 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}