Skip to main content

openclaw_scan/scanner/
network.rs

1//! Network security scanner.
2//!
3//! Inspects MCP server endpoint URLs and OAuth configurations in settings
4//! files for insecure or suspicious network settings.
5
6use 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        // Check `url` field
56        if let Some(url) = server_cfg.get("url").and_then(Value::as_str) {
57            check_url(url, server_name, path, findings);
58        }
59        // Check `baseUrl` field
60        if let Some(url) = server_cfg.get("baseUrl").and_then(Value::as_str) {
61            check_url(url, server_name, path, findings);
62        }
63        // Check env vars for embedded URLs
64        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    // Strip embedded credentials before storing as evidence (H-1).
79    let safe_url = sanitize_url(url);
80
81    // HTTP (non-TLS) to a non-localhost address is a risk
82    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    // IP address instead of hostname (harder to audit intent)
110    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
131/// Replace `user:password@` in a URL with `[credentials-redacted]@`
132/// so embedded credentials are never stored in evidence fields (H-1).
133fn 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
144/// Returns `true` if the URL host part looks like a raw IPv4/IPv6 address.
145fn is_bare_ip_address(url: &str) -> bool {
146    // Strip scheme
147    let host_part = if let Some(s) = url.find("://") {
148        &url[s + 3..]
149    } else {
150        url
151    };
152    // Strip path/port
153    let host = host_part.split('/').next().unwrap_or(host_part);
154    let host = host.split(':').next().unwrap_or(host);
155    // IPv4: four dotted decimal octets
156    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// ── Tests ─────────────────────────────────────────────────────────────────────
164
165#[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        // 127.0.0.1 HTTP is low-risk (local only); should not fire the HTTP finding
235        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}