Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down Expand Up @@ -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 |
Expand Down
21 changes: 21 additions & 0 deletions src/inspect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
})
Expand All @@ -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 {
Expand Down Expand Up @@ -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());
}
Expand Down Expand Up @@ -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"));
}
}
166 changes: 165 additions & 1 deletion src/rules/transport.rs
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -65,6 +88,14 @@ impl TransportRule {
}
}

pub(crate) fn has_wildcard_bind_exposure(server: &McpServer) -> Option<String> {
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"
Expand All @@ -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<String> {
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<String, String>) -> Option<String> {
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")
Expand All @@ -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() {
Expand Down Expand Up @@ -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;
Expand Down
9 changes: 9 additions & 0 deletions testdata/wildcard-bind.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"mcpServers": {
"winremote": {
"command": "python3",
"args": ["server.py", "--host", "0.0.0.0", "--port", "3000"],
"allowedTools": ["snapshot"]
}
}
}
19 changes: 19 additions & 0 deletions tests/integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading