openclaw_scan/scanner/
network.rs1use std::path::Path;
7
8use anyhow::Result;
9use serde_json::Value;
10
11use crate::finding::{Category, Finding, Severity};
12use crate::scanner::{ScanContext, Scanner};
13
14pub struct NetworkScanner;
15
16impl Scanner for NetworkScanner {
17 fn name(&self) -> &'static str {
18 "network"
19 }
20
21 fn scan(&self, ctx: &ScanContext) -> Result<Vec<Finding>> {
22 let mut findings = Vec::new();
23
24 for name in &["settings.json", "settings.local.json"] {
25 let path = ctx.root.join(name);
26 if path.exists() {
27 if let Ok(content) = std::fs::read_to_string(&path) {
28 check_network_config(&content, &path, &mut findings);
29 }
30 }
31 }
32
33 Ok(findings)
34 }
35}
36
37fn check_network_config(content: &str, path: &Path, findings: &mut Vec<Finding>) {
38 let Ok(json): Result<Value, _> = serde_json::from_str(content) else {
39 return;
40 };
41
42 check_mcp_urls(&json, path, findings);
43}
44
45fn check_mcp_urls(json: &Value, path: &Path, findings: &mut Vec<Finding>) {
46 let Some(servers) = json
47 .pointer("/mcpServers")
48 .or_else(|| json.get("mcp_servers"))
49 .and_then(Value::as_object)
50 else {
51 return;
52 };
53
54 for (server_name, server_cfg) in servers {
55 if let Some(url) = server_cfg.get("url").and_then(Value::as_str) {
57 check_url(url, server_name, path, findings);
58 }
59 if let Some(url) = server_cfg.get("baseUrl").and_then(Value::as_str) {
61 check_url(url, server_name, path, findings);
62 }
63 if let Some(env) = server_cfg.get("env").and_then(Value::as_object) {
65 for (_, val) in env {
66 if let Some(s) = val.as_str() {
67 if s.starts_with("http://") || s.starts_with("https://") {
68 check_url(s, server_name, path, findings);
69 }
70 }
71 }
72 }
73 }
74}
75
76fn check_url(url: &str, server_name: &str, path: &Path, findings: &mut Vec<Finding>) {
77 let url_lower = url.to_lowercase();
78 let safe_url = sanitize_url(url);
80
81 if url_lower.starts_with("http://")
83 && !url_lower.contains("localhost")
84 && !url_lower.contains("127.0.0.1")
85 && !url_lower.contains("::1")
86 {
87 findings.push(
88 Finding::new(
89 Severity::High,
90 Category::NetworkSecurity,
91 format!("MCP server '{}' uses unencrypted HTTP", server_name),
92 format!(
93 "The MCP server '{}' in '{}' connects over HTTP. \
94 Credentials and tool outputs sent to this server are transmitted \
95 in plain text and can be intercepted.",
96 server_name,
97 path.display(),
98 ),
99 path,
100 format!(
101 "Update the URL for '{}' to use HTTPS: replace `http://` with `https://`.",
102 server_name
103 ),
104 )
105 .with_evidence(safe_url.clone()),
106 );
107 }
108
109 let is_ip = is_bare_ip_address(url);
111 if is_ip && !url_lower.contains("127.0.0.1") && !url_lower.contains("::1") {
112 findings.push(
113 Finding::new(
114 Severity::Low,
115 Category::NetworkSecurity,
116 format!("MCP server '{}' connects to a bare IP address", server_name),
117 format!(
118 "Server '{}' in '{}' uses a bare IP address. \
119 This makes it harder to audit what service the agent is connecting to.",
120 server_name,
121 path.display()
122 ),
123 path,
124 "Use a fully qualified domain name instead of a bare IP address.",
125 )
126 .with_evidence(safe_url),
127 );
128 }
129}
130
131fn sanitize_url(url: &str) -> String {
134 if let (Some(scheme_end), Some(at)) = (url.find("://"), url.find('@')) {
135 if at > scheme_end {
136 let scheme = &url[..scheme_end + 3];
137 let rest = &url[at + 1..];
138 return format!("{}[credentials-redacted]@{}", scheme, rest);
139 }
140 }
141 url.to_string()
142}
143
144fn is_bare_ip_address(url: &str) -> bool {
146 let host_part = if let Some(s) = url.find("://") {
148 &url[s + 3..]
149 } else {
150 url
151 };
152 let host = host_part.split('/').next().unwrap_or(host_part);
154 let host = host.split(':').next().unwrap_or(host);
155 let parts: Vec<&str> = host.split('.').collect();
157 if parts.len() == 4 && parts.iter().all(|p| p.parse::<u8>().is_ok()) {
158 return true;
159 }
160 false
161}
162
163#[cfg(test)]
166mod tests {
167 use super::*;
168 use std::path::PathBuf;
169
170 fn check(json_str: &str) -> Vec<Finding> {
171 let mut findings = Vec::new();
172 check_network_config(
173 json_str,
174 &PathBuf::from("/test/settings.json"),
175 &mut findings,
176 );
177 findings
178 }
179
180 #[test]
181 fn detects_http_external_url() {
182 let json = r#"{
183 "mcpServers": {
184 "my-server": {"url": "http://api.example.com/mcp"}
185 }
186 }"#;
187 let f = check(json);
188 assert!(f
189 .iter()
190 .any(|x| x.severity == Severity::High && x.title.contains("HTTP")));
191 }
192
193 #[test]
194 fn no_finding_for_https_url() {
195 let json = r#"{
196 "mcpServers": {
197 "my-server": {"url": "https://api.example.com/mcp"}
198 }
199 }"#;
200 assert!(check(json).is_empty());
201 }
202
203 #[test]
204 fn no_finding_for_http_localhost() {
205 let json = r#"{
206 "mcpServers": {
207 "local": {"url": "http://localhost:3000/mcp"}
208 }
209 }"#;
210 assert!(
211 check(json).is_empty(),
212 "localhost HTTP should not be flagged"
213 );
214 }
215
216 #[test]
217 fn detects_bare_ip_url() {
218 let json = r#"{
219 "mcpServers": {
220 "remote": {"url": "https://192.168.1.100:8080/mcp"}
221 }
222 }"#;
223 let f = check(json);
224 assert!(f.iter().any(|x| x.title.contains("bare IP")));
225 }
226
227 #[test]
228 fn no_finding_for_localhost_ip() {
229 let json = r#"{
230 "mcpServers": {
231 "local": {"url": "http://127.0.0.1:3000/mcp"}
232 }
233 }"#;
234 let f = check(json);
236 assert!(!f.iter().any(|x| x.severity == Severity::High));
237 }
238
239 #[test]
240 fn is_bare_ip_address_true() {
241 assert!(is_bare_ip_address("https://192.168.1.1/path"));
242 assert!(is_bare_ip_address("http://10.0.0.1:8080"));
243 }
244
245 #[test]
246 fn is_bare_ip_address_false() {
247 assert!(!is_bare_ip_address("https://api.example.com/mcp"));
248 assert!(!is_bare_ip_address("http://localhost:3000"));
249 }
250
251 #[test]
252 fn sanitize_url_strips_credentials() {
253 assert_eq!(
254 sanitize_url("http://user:pass@host.com/path"),
255 "http://[credentials-redacted]@host.com/path"
256 );
257 }
258
259 #[test]
260 fn sanitize_url_no_credentials_unchanged() {
261 let clean = "https://api.example.com/mcp";
262 assert_eq!(sanitize_url(clean), clean);
263 }
264}