From 8aad598da5e740854709e127877111da89345272 Mon Sep 17 00:00:00 2001 From: brandonwise Date: Fri, 24 Jul 2026 21:13:40 -0400 Subject: [PATCH] feat: detect Docker host-control exposure --- Cargo.lock | 12 +- README.md | 5 +- src/rules/docker.rs | 340 ++++++++++++++++++++++++++++++ src/rules/mod.rs | 4 +- testdata/docker-host-control.json | 27 +++ tests/integration_tests.rs | 37 ++++ 6 files changed, 416 insertions(+), 9 deletions(-) create mode 100644 src/rules/docker.rs create mode 100644 testdata/docker-host-control.json diff --git a/Cargo.lock b/Cargo.lock index 5e86dd0..354ea8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -705,9 +705,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ "bytes", "getrandom 0.3.4", @@ -755,9 +755,9 @@ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "rand" -version = "0.9.2" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha", "rand_core", @@ -895,9 +895,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", diff --git a/README.md b/README.md index e480f8c..148bf1a 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ Full methodology, source attribution, and raw output are in [`research/FINDINGS. ## Trust signals - **4.0 MB** release binary -- **255/255 tests passing** +- **261/261 tests passing** - **0 clippy warnings** with `-D warnings` - **0 known Rust dependency vulnerabilities** (`cargo audit`) @@ -188,7 +188,7 @@ agentwise auto-detects and scans: ## Detection Rules -12 built-in rules, covering misconfigurations, known CVEs, and supply chain risks: +13 built-in rules, covering misconfigurations, known CVEs, host-control exposure, and supply chain risks: | ID | Rule | Severity | |----|------|----------| @@ -204,6 +204,7 @@ agentwise auto-detects and scans: | AW-010 | Prompt injection surface | Medium | | AW-011 | Supply chain risk signals | High/Medium | | AW-012 | Deep dependency chain (deps.dev) | High/Medium | +| AW-013 | Docker socket or privileged host controls | Critical/High | ## Live Mode diff --git a/src/rules/docker.rs b/src/rules/docker.rs new file mode 100644 index 0000000..0d0090a --- /dev/null +++ b/src/rules/docker.rs @@ -0,0 +1,340 @@ +use crate::config::McpServer; +use crate::rules::{Finding, Rule, Severity}; + +const DOCKER_SOCKET_MARKERS: &[&str] = &["/var/run/docker.sock", "docker.sock"]; +const HOST_NAMESPACE_FLAGS: &[&str] = &["--pid=host", "--network=host", "--net=host"]; +const HOST_CAPABILITIES: &[&str] = &["all", "sys_admin", "sys-admin"]; + +/// AW-013: Flag Docker host-control exposure from agent configs. +pub struct DockerRule; + +impl DockerRule { + fn is_docker_related(server_name: &str, server: &McpServer) -> bool { + let mut text = server_name.to_lowercase(); + + if let Some(command) = &server.command { + text.push(' '); + text.push_str(&command.to_lowercase()); + } + + if let Some(args) = &server.args { + text.push(' '); + text.push_str(&args.join(" ").to_lowercase()); + } + + if let Some(env) = &server.env { + for (key, value) in env { + text.push(' '); + text.push_str(&key.to_lowercase()); + text.push('='); + text.push_str(&value.to_lowercase()); + } + } + + text.contains("docker") || text.contains("container") + } + + fn docker_socket_signals(server: &McpServer) -> Vec { + let mut signals = Vec::new(); + + if let Some(args) = &server.args { + for arg in args { + let lower = arg.to_lowercase(); + if DOCKER_SOCKET_MARKERS + .iter() + .any(|marker| lower.contains(marker)) + { + signals.push(format!("argument exposes {}", arg)); + } + } + } + + if let Some(env) = &server.env { + for (key, value) in env { + let combined = format!("{}={}", key, value).to_lowercase(); + if DOCKER_SOCKET_MARKERS + .iter() + .any(|marker| combined.contains(marker)) + { + signals.push(format!("env var {} points at Docker socket", key)); + } + } + } + + signals + } + + fn privileged_signals(server: &McpServer) -> Vec { + let mut signals = Vec::new(); + let Some(args) = &server.args else { + return signals; + }; + + for (idx, arg) in args.iter().enumerate() { + let lower = arg.to_lowercase(); + + if lower == "--privileged" || lower.starts_with("--privileged=") { + signals.push("uses --privileged".to_string()); + } + + if HOST_NAMESPACE_FLAGS.iter().any(|flag| lower == *flag) { + signals.push(format!("uses {}", arg)); + } + + if (lower == "--pid" || lower == "--network" || lower == "--net") + && args + .get(idx + 1) + .is_some_and(|next| next.eq_ignore_ascii_case("host")) + { + signals.push(format!("uses {} host", arg)); + } + + if let Some(value) = lower.strip_prefix("--cap-add=") { + if HOST_CAPABILITIES.contains(&value) { + signals.push(format!("adds host capability {}", arg)); + } + } else if lower == "--cap-add" { + if let Some(next) = args.get(idx + 1) { + let next_lower = next.to_lowercase(); + if HOST_CAPABILITIES.contains(&next_lower.as_str()) { + signals.push(format!("adds host capability {}", next)); + } + } + } + + if !Self::previous_arg_is_mount_flag(args, idx) && Self::contains_host_root_bind(arg) { + signals.push(format!("binds host root via {}", arg)); + } + + if (lower == "-v" || lower == "--volume" || lower == "--mount") + && args + .get(idx + 1) + .is_some_and(|next| Self::contains_host_root_bind(next)) + { + signals.push(format!( + "binds host root via {} {}", + arg, + args.get(idx + 1).unwrap() + )); + } + } + + signals.sort(); + signals.dedup(); + signals + } + + fn contains_host_root_bind(value: &str) -> bool { + let lower = value.to_lowercase(); + + if let Some(volume) = lower + .strip_prefix("--volume=") + .or_else(|| lower.strip_prefix("-v=")) + { + return Self::short_volume_binds_root(volume); + } + + if lower.starts_with("--mount=") { + return Self::mount_binds_root(&lower); + } + + Self::short_volume_binds_root(&lower) || Self::mount_binds_root(&lower) + } + + fn previous_arg_is_mount_flag(args: &[String], idx: usize) -> bool { + idx > 0 + && matches!( + args[idx - 1].to_lowercase().as_str(), + "-v" | "--volume" | "--mount" + ) + } + + fn short_volume_binds_root(value: &str) -> bool { + let mut parts = value.split(':'); + let Some(host_path) = parts.next() else { + return false; + }; + let Some(container_path) = parts.next() else { + return false; + }; + + Self::is_host_root(host_path) && !container_path.trim().is_empty() + } + + fn mount_binds_root(value: &str) -> bool { + value + .split(',') + .filter_map(|part| part.split_once('=')) + .any(|(key, val)| { + matches!(key.trim(), "src" | "source" | "from") && Self::is_host_root(val.trim()) + }) + } + + fn is_host_root(path: &str) -> bool { + let trimmed = path.trim().trim_matches('"').trim_matches('\''); + trimmed == "/" || trimmed == "c:\\" || trimmed == "c:/" + } +} + +impl Rule for DockerRule { + fn id(&self) -> &'static str { + "AW-013" + } + + fn check(&self, server_name: &str, server: &McpServer, config_file: &str) -> Vec { + let mut findings = Vec::new(); + + if !Self::is_docker_related(server_name, server) { + return findings; + } + + let socket_signals = Self::docker_socket_signals(server); + if !socket_signals.is_empty() { + findings.push(Finding { + rule_id: self.id().to_string(), + severity: Severity::Critical, + title: "Docker socket exposed to agent".to_string(), + message: format!( + "Server '{}' can reach the host Docker daemon, which can become host-level code execution", + server_name + ), + fix: "Remove Docker socket mounts/DOCKER_HOST from agent configs, or proxy Docker behind a least-privilege service with explicit allowedTools".to_string(), + config_file: config_file.to_string(), + server_name: server_name.to_string(), + source: None, + epss: None, + sub_items: Some(socket_signals), + }); + } + + let privileged_signals = Self::privileged_signals(server); + if !privileged_signals.is_empty() { + findings.push(Finding { + rule_id: self.id().to_string(), + severity: Severity::High, + title: "Privileged Docker host controls enabled".to_string(), + message: format!( + "Server '{}' uses Docker host-level flags that weaken the container boundary", + server_name + ), + fix: "Remove host namespace, host root mount, --privileged, and broad --cap-add flags from MCP server launch args".to_string(), + config_file: config_file.to_string(), + server_name: server_name.to_string(), + source: None, + epss: None, + sub_items: Some(privileged_signals), + }); + } + + findings + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + #[test] + fn test_docker_socket_mount_flagged_critical() { + let rule = DockerRule; + let server = McpServer { + command: Some("docker".to_string()), + args: Some(vec![ + "run".to_string(), + "-v".to_string(), + "/var/run/docker.sock:/var/run/docker.sock".to_string(), + "mcp/docker-tools".to_string(), + ]), + ..Default::default() + }; + + let findings = rule.check("docker", &server, "test.json"); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].severity, Severity::Critical); + } + + #[test] + fn test_docker_host_env_flagged_critical() { + let rule = DockerRule; + let mut env = HashMap::new(); + env.insert( + "DOCKER_HOST".to_string(), + "unix:///var/run/docker.sock".to_string(), + ); + let server = McpServer { + command: Some("npx".to_string()), + args: Some(vec!["docker-mcp-server".to_string()]), + env: Some(env), + ..Default::default() + }; + + let findings = rule.check("docker", &server, "test.json"); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].severity, Severity::Critical); + } + + #[test] + fn test_privileged_flags_flagged_high() { + let rule = DockerRule; + let server = McpServer { + command: Some("docker".to_string()), + args: Some(vec![ + "run".to_string(), + "--privileged".to_string(), + "--network=host".to_string(), + "--cap-add".to_string(), + "SYS_ADMIN".to_string(), + "-v".to_string(), + "/:/host".to_string(), + "mcp/docker-tools".to_string(), + ]), + ..Default::default() + }; + + let findings = rule.check("docker", &server, "test.json"); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].severity, Severity::High); + assert_eq!( + findings[0].sub_items.as_ref().unwrap(), + &vec![ + "adds host capability SYS_ADMIN".to_string(), + "binds host root via -v /:/host".to_string(), + "uses --network=host".to_string(), + "uses --privileged".to_string(), + ] + ); + } + + #[test] + fn test_scoped_docker_container_ok() { + let rule = DockerRule; + let server = McpServer { + command: Some("docker".to_string()), + args: Some(vec![ + "run".to_string(), + "--read-only".to_string(), + "-v".to_string(), + "/tmp/project:/workspace:ro".to_string(), + "mcp/docker-tools".to_string(), + ]), + ..Default::default() + }; + + let findings = rule.check("docker", &server, "test.json"); + assert!(findings.is_empty()); + } + + #[test] + fn test_non_docker_server_ignored() { + let rule = DockerRule; + let server = McpServer { + command: Some("npx".to_string()), + args: Some(vec!["mcp-memory-server".to_string()]), + ..Default::default() + }; + + let findings = rule.check("memory", &server, "test.json"); + assert!(findings.is_empty()); + } +} diff --git a/src/rules/mod.rs b/src/rules/mod.rs index b556605..5d9dd74 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -10,6 +10,7 @@ pub mod transport; pub mod write_tools; pub mod deps; +pub mod docker; pub mod supply_chain; use crate::config::McpServer; @@ -95,6 +96,7 @@ pub fn all_rules() -> Vec> { Box::new(write_tools::WriteToolsRule), Box::new(network::NetworkRule), Box::new(injection::InjectionRule::new()), + Box::new(docker::DockerRule), ] } @@ -121,7 +123,7 @@ mod tests { #[test] fn test_all_rules_count() { let rules = all_rules(); - assert_eq!(rules.len(), 10); + assert_eq!(rules.len(), 11); } #[test] diff --git a/testdata/docker-host-control.json b/testdata/docker-host-control.json new file mode 100644 index 0000000..27ee845 --- /dev/null +++ b/testdata/docker-host-control.json @@ -0,0 +1,27 @@ +{ + "mcpServers": { + "docker-admin": { + "command": "docker", + "args": [ + "run", + "--rm", + "-v", + "/var/run/docker.sock:/var/run/docker.sock", + "example/docker-mcp" + ], + "allowedTools": ["container_list"] + }, + "privileged-builder": { + "command": "docker", + "args": [ + "run", + "--privileged", + "--network=host", + "-v", + "/:/host", + "example/builder-mcp" + ], + "allowedTools": ["build_image"] + } + } +} diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index bd637e6..c421ec8 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -463,6 +463,43 @@ fn test_detects_network_access() { assert!(findings.iter().any(|f| f["rule_id"] == "AW-009")); } +#[test] +fn test_detects_docker_host_control() { + let output = agentwise() + .args([ + "scan", + "testdata/docker-host-control.json", + "--format", + "json", + ]) + .output() + .unwrap(); + assert!(output.status.success()); + + 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-013" + && f["severity"] == "Critical" + && f["title"] == "Docker socket exposed to agent" + }), + "Expected AW-013 critical Docker socket finding, got: {}", + stdout + ); + assert!( + findings.iter().any(|f| { + f["rule_id"] == "AW-013" + && f["severity"] == "High" + && f["title"] == "Privileged Docker host controls enabled" + }), + "Expected AW-013 high privileged Docker finding, got: {}", + stdout + ); +} + #[test] fn test_detects_cve_in_claude_desktop() { let output = agentwise()