diff --git a/README.md b/README.md index 48a53b9..e480f8c 100644 --- a/README.md +++ b/README.md @@ -100,14 +100,14 @@ From a scan of **109 MCP server entries** collected from public GitHub configs + - **100%** missing tool allowlists (AW-007) - **8.26%** had unrestricted filesystem access (AW-002) - **1.83%** exposed hardcoded secrets (AW-004) -- Insecure cleartext transport (`http://` and `ws://`) still present in public configs (AW-005) +- Insecure cleartext transport and wildcard bind exposure (`http://`, `ws://`, `0.0.0.0`, `[::]`) still show up in public configs (AW-005) Full methodology, source attribution, and raw output are in [`research/FINDINGS.md`](research/FINDINGS.md) and [`research/scan-results.json`](research/scan-results.json). ## Trust signals - **4.0 MB** release binary -- **203/203 tests passing** +- **255/255 tests passing** - **0 clippy warnings** with `-D warnings` - **0 known Rust dependency vulnerabilities** (`cargo audit`) @@ -196,7 +196,7 @@ agentwise auto-detects and scans: | AW-002 | Overpermissioned filesystem access | Critical | | AW-003 | Unrestricted shell/exec access | Critical | | AW-004 | Secrets in plaintext config | High | -| AW-005 | Insecure transport (`http://` or `ws://`) | High | +| AW-005 | Insecure transport or wildcard bind exposure (`http://`, `ws://`, `0.0.0.0`, `[::]`) | High | | AW-006 | Known CVE match (embedded + OSV) | Critical/High | | AW-007 | Missing tool allowlist | Medium | | AW-008 | Write-capable tools without opt-in | Medium | diff --git a/src/inspect.rs b/src/inspect.rs index 059fe7f..dace29c 100644 --- a/src/inspect.rs +++ b/src/inspect.rs @@ -2,6 +2,7 @@ use crate::config::{ extract_all_package_names, extract_package_info, has_effective_allowed_tools, has_global_wildcard_allowed_tools, McpServer, }; +use crate::rules::transport::has_wildcard_bind_exposure; use crate::scanner; use serde::Serialize; use std::fmt::Write; @@ -84,6 +85,7 @@ pub fn inspect(path: &str) -> InspectResult { .iter() .filter(|s| { s.risk_tags.iter().any(|t| t == "remote_no_auth") + || s.risk_tags.iter().any(|t| t == "wildcard_bind") || s.risk_tags.iter().any(|t| t == "broad_filesystem") || s.risk_tags.len() >= 2 }) @@ -105,6 +107,7 @@ fn inspect_server(server_name: &str, config_file: &str, server: &McpServer) -> I let auth_present = has_auth(server); let allowlist_present = has_effective_allowed_tools(server); let wildcard_allowlist = has_global_wildcard_allowed_tools(server); + let wildcard_bind = has_wildcard_bind_exposure(server).is_some(); let network_tool = is_network_tool(server_name, server); let network_restricted = if network_tool { @@ -141,6 +144,9 @@ fn inspect_server(server_name: &str, config_file: &str, server: &McpServer) -> I if wildcard_allowlist { risk_tags.push("wildcard_allowlist".to_string()); } + if wildcard_bind { + risk_tags.push("wildcard_bind".to_string()); + } if !network_restricted { risk_tags.push("unrestricted_network".to_string()); } @@ -462,4 +468,19 @@ mod tests { .any(|t| t == "wildcard_allowlist")); assert!(inspected.risk_tags.iter().any(|t| t == "no_allowlist")); } + + #[test] + fn test_wildcard_bind_marked_as_risk() { + let server = McpServer { + args: Some(vec![ + "server.py".to_string(), + "--host".to_string(), + "0.0.0.0".to_string(), + ]), + ..Default::default() + }; + + let inspected = inspect_server("bridge", "test.json", &server); + assert!(inspected.risk_tags.iter().any(|t| t == "wildcard_bind")); + } } diff --git a/src/rules/transport.rs b/src/rules/transport.rs index c7ab1e6..ed9594c 100644 --- a/src/rules/transport.rs +++ b/src/rules/transport.rs @@ -1,7 +1,30 @@ use crate::config::McpServer; use crate::rules::{Finding, Rule, Severity}; +use std::collections::HashMap; -/// AW-005: Flag cleartext remote transport endpoints (`http://`, `ws://`). +const BIND_FLAGS: &[&str] = &[ + "--host", + "--bind", + "--listen", + "--address", + "--addr", + "--hostname", +]; + +const BIND_ENV_KEYS: &[&str] = &[ + "host", + "bind", + "bind_host", + "bind_address", + "listen_host", + "listen_addr", + "listen_address", + "mcp_host", + "server_host", +]; + +/// AW-005: Flag cleartext remote transport endpoints (`http://`, `ws://`) and +/// wildcard bind addresses that expose local MCP services beyond loopback. pub struct TransportRule; impl TransportRule { @@ -65,6 +88,14 @@ impl TransportRule { } } +pub(crate) fn has_wildcard_bind_exposure(server: &McpServer) -> Option { + server + .args + .as_ref() + .and_then(|args| wildcard_bind_from_args(args)) + .or_else(|| server.env.as_ref().and_then(wildcard_bind_from_env)) +} + impl Rule for TransportRule { fn id(&self) -> &'static str { "AW-005" @@ -87,10 +118,88 @@ impl Rule for TransportRule { } } + if let Some(bind_hint) = has_wildcard_bind_exposure(server) { + findings.push(Finding { + rule_id: self.id().to_string(), + severity: Severity::High, + title: "Wildcard bind address".to_string(), + message: format!( + "Server '{}' binds a local MCP service to all interfaces via {}, which can expose it beyond loopback", + server_name, bind_hint + ), + fix: "Bind the server to 127.0.0.1 or ::1 unless remote exposure is intentional and protected by auth + TLS".to_string(), + config_file: config_file.to_string(), + server_name: server_name.to_string(), + source: None, + epss: None, + sub_items: None, + }); + } + findings } } +fn wildcard_bind_from_args(args: &[String]) -> Option { + for (idx, arg) in args.iter().enumerate() { + let lowered = arg.to_lowercase(); + + for flag in BIND_FLAGS { + if lowered == *flag { + if let Some(next) = args.get(idx + 1) { + if is_wildcard_bind_value(next) { + return Some(format!("{} {}", arg, next)); + } + } + } + + let inline_prefix = format!("{}=", flag); + if lowered.starts_with(&inline_prefix) { + let value = &arg[inline_prefix.len()..]; + if is_wildcard_bind_value(value) { + return Some(arg.clone()); + } + } + } + } + + None +} + +fn wildcard_bind_from_env(env: &HashMap) -> Option { + for (key, value) in env { + let lowered = key.to_lowercase(); + if BIND_ENV_KEYS.contains(&lowered.as_str()) && is_wildcard_bind_value(value) { + return Some(format!("{}={}", key, value)); + } + } + + None +} + +fn is_wildcard_bind_value(value: &str) -> bool { + let trimmed = value + .trim() + .trim_matches(|c| c == '"' || c == '\'') + .to_lowercase(); + + if trimmed == "*" { + return true; + } + + let host = trimmed + .split("://") + .nth(1) + .unwrap_or(trimmed.as_str()) + .trim(); + + host == "0.0.0.0" + || host.starts_with("0.0.0.0:") + || host == "::" + || host == "[::]" + || host.starts_with("[::]:") +} + fn is_localhost(url: &str) -> bool { let url_lower = url.to_lowercase(); url_lower.contains("://localhost") @@ -101,6 +210,7 @@ fn is_localhost(url: &str) -> bool { #[cfg(test)] mod tests { use super::*; + use std::collections::HashMap; #[test] fn test_http_url_flagged() { @@ -185,6 +295,60 @@ mod tests { assert_eq!(findings[0].title, "Insecure cleartext URL in args"); } + #[test] + fn test_wildcard_bind_arg_flagged() { + let rule = TransportRule; + let server = McpServer { + args: Some(vec![ + "server.py".to_string(), + "--host".to_string(), + "0.0.0.0".to_string(), + ]), + ..Default::default() + }; + let findings = rule.check("remote", &server, "test.json"); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].title, "Wildcard bind address"); + assert!(findings[0].message.contains("0.0.0.0")); + } + + #[test] + fn test_wildcard_bind_inline_arg_flagged() { + let rule = TransportRule; + let server = McpServer { + args: Some(vec!["--bind=0.0.0.0:3000".to_string()]), + ..Default::default() + }; + let findings = rule.check("remote", &server, "test.json"); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].severity, Severity::High); + } + + #[test] + fn test_wildcard_bind_env_flagged() { + let rule = TransportRule; + let mut env = HashMap::new(); + env.insert("HOST".to_string(), "0.0.0.0".to_string()); + let server = McpServer { + env: Some(env), + ..Default::default() + }; + let findings = rule.check("remote", &server, "test.json"); + assert_eq!(findings.len(), 1); + assert!(findings[0].message.contains("HOST=0.0.0.0")); + } + + #[test] + fn test_loopback_bind_ok() { + let rule = TransportRule; + let server = McpServer { + args: Some(vec!["--host".to_string(), "127.0.0.1:3000".to_string()]), + ..Default::default() + }; + let findings = rule.check("local", &server, "test.json"); + assert!(findings.is_empty()); + } + #[test] fn test_no_url_ok() { let rule = TransportRule; diff --git a/testdata/wildcard-bind.json b/testdata/wildcard-bind.json new file mode 100644 index 0000000..804924f --- /dev/null +++ b/testdata/wildcard-bind.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "winremote": { + "command": "python3", + "args": ["server.py", "--host", "0.0.0.0", "--port", "3000"], + "allowedTools": ["snapshot"] + } + } +} diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 8140a6a..bd637e6 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -311,6 +311,25 @@ fn test_detects_insecure_websocket_transport() { ); } +#[test] +fn test_detects_wildcard_bind_exposure() { + let output = agentwise() + .args(["scan", "testdata/wildcard-bind.json", "--format", "json"]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let findings = parsed["findings"].as_array().unwrap(); + + assert!( + findings.iter().any(|f| f["rule_id"] == "AW-005" + && f["title"] == "Wildcard bind address" + && f["message"].as_str().is_some_and(|m| m.contains("0.0.0.0"))), + "Expected AW-005 wildcard bind finding, got: {}", + stdout + ); +} + #[test] fn test_detects_secrets() { let output = agentwise()