Skip to main content

clawft_kernel/assessment/analyzers/
network.rs

1//! Network analyzer — discovers HTTP endpoints, webhooks, gRPC, and WebSocket URLs.
2
3use std::path::{Path, PathBuf};
4
5use crate::assessment::Finding;
6use crate::assessment::analyzer::{AnalysisContext, Analyzer};
7
8/// Analyzer that scans for network endpoint references in config and source files.
9pub struct NetworkAnalyzer;
10
11/// File extensions considered config files for network scanning.
12fn is_config_ext(path: &Path) -> bool {
13    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
14    matches!(ext, "toml" | "json" | "yaml" | "yml" | "env")
15        || path
16            .file_name()
17            .and_then(|n| n.to_str())
18            .map(|n| n.starts_with(".env"))
19            .unwrap_or(false)
20}
21
22/// File extensions considered source code (for hardcoded-URL detection).
23fn is_source_ext(path: &Path) -> bool {
24    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
25    matches!(
26        ext,
27        "rs" | "ts" | "tsx" | "js" | "jsx" | "py" | "go" | "rb" | "java"
28    )
29}
30
31/// Webhook path patterns.
32const WEBHOOK_PATTERNS: &[&str] = &["/webhook", "/hook", "/callback"];
33
34/// API base-URL variable name patterns (case-insensitive match).
35const API_VAR_PATTERNS: &[&str] = &["api_url", "base_url", "endpoint"];
36
37impl Analyzer for NetworkAnalyzer {
38    fn id(&self) -> &str {
39        "network"
40    }
41
42    fn name(&self) -> &str {
43        "Network Analyzer"
44    }
45
46    fn categories(&self) -> &[&str] {
47        &["network"]
48    }
49
50    fn analyze(
51        &self,
52        project: &Path,
53        files: &[PathBuf],
54        _context: &AnalysisContext,
55    ) -> Vec<Finding> {
56        let mut findings = Vec::new();
57
58        for path in files {
59            let is_config = is_config_ext(path);
60            let is_source = is_source_ext(path);
61
62            if !is_config && !is_source {
63                continue;
64            }
65
66            let rel = path.strip_prefix(project).unwrap_or(path);
67            let rel_str = rel.display().to_string();
68
69            let content = match std::fs::read_to_string(path) {
70                Ok(c) => c,
71                Err(_) => continue,
72            };
73
74            for (i, line) in content.lines().enumerate() {
75                let trimmed = line.trim();
76                let lower = line.to_lowercase();
77                let line_num = i + 1;
78
79                // Skip comment lines
80                if trimmed.starts_with("//")
81                    || trimmed.starts_with('#')
82                    || trimmed.starts_with("/*")
83                    || trimmed.starts_with('*')
84                {
85                    continue;
86                }
87
88                // --- HTTP/HTTPS URLs ---
89                let has_http = lower.contains("http://") || lower.contains("https://");
90                if has_http {
91                    // Extract URLs via simple scan
92                    let urls = extract_urls(line);
93                    for url in &urls {
94                        // Determine severity: production URLs in source = medium,
95                        // URLs in config = info
96                        let severity = if is_source && is_production_url(url) {
97                            "medium"
98                        } else {
99                            "info"
100                        };
101
102                        findings.push(Finding {
103                            severity: severity.into(),
104                            category: "network".into(),
105                            file: rel_str.clone(),
106                            line: Some(line_num),
107                            message: format!("HTTP endpoint: {url}"),
108                        });
109
110                        // Check for webhook patterns in the URL
111                        let url_lower = url.to_lowercase();
112                        for pattern in WEBHOOK_PATTERNS {
113                            if url_lower.contains(pattern) {
114                                findings.push(Finding {
115                                    severity: "info".into(),
116                                    category: "network".into(),
117                                    file: rel_str.clone(),
118                                    line: Some(line_num),
119                                    message: format!("Webhook URL detected: {url}"),
120                                });
121                                break;
122                            }
123                        }
124                    }
125                }
126
127                // --- API base URL variable names in config ---
128                if is_config {
129                    for var_pat in API_VAR_PATTERNS {
130                        if lower.contains(var_pat) {
131                            findings.push(Finding {
132                                severity: "info".into(),
133                                category: "network".into(),
134                                file: rel_str.clone(),
135                                line: Some(line_num),
136                                message: format!(
137                                    "API base URL variable detected (matched '{var_pat}')"
138                                ),
139                            });
140                            break;
141                        }
142                    }
143                }
144
145                // --- gRPC endpoints ---
146                if lower.contains("grpc://") {
147                    findings.push(Finding {
148                        severity: "info".into(),
149                        category: "network".into(),
150                        file: rel_str.clone(),
151                        line: Some(line_num),
152                        message: "gRPC endpoint reference detected".into(),
153                    });
154                }
155                if lower.contains("50051") {
156                    findings.push(Finding {
157                        severity: "info".into(),
158                        category: "network".into(),
159                        file: rel_str.clone(),
160                        line: Some(line_num),
161                        message: "Possible gRPC port (50051) reference".into(),
162                    });
163                }
164
165                // --- WebSocket URLs ---
166                if lower.contains("ws://") || lower.contains("wss://") {
167                    findings.push(Finding {
168                        severity: "info".into(),
169                        category: "network".into(),
170                        file: rel_str.clone(),
171                        line: Some(line_num),
172                        message: "WebSocket endpoint detected".into(),
173                    });
174                }
175            }
176        }
177
178        findings
179    }
180}
181
182/// Extract `http://` and `https://` URLs from a line of text.
183fn extract_urls(line: &str) -> Vec<String> {
184    let mut urls = Vec::new();
185    let mut search = line;
186
187    loop {
188        let start = if let Some(pos) = search.find("https://") {
189            pos
190        } else if let Some(pos) = search.find("http://") {
191            pos
192        } else {
193            break;
194        };
195
196        let rest = &search[start..];
197        // URL ends at whitespace or quote/backtick delimiter
198        let end = rest
199            .find(|c: char| {
200                c.is_whitespace()
201                    || c == '"'
202                    || c == '\''
203                    || c == '`'
204                    || c == '>'
205                    || c == ')'
206                    || c == ']'
207            })
208            .unwrap_or(rest.len());
209        let url = &rest[..end];
210        if url.len() > 8 {
211            urls.push(url.to_string());
212        }
213        search = &rest[end..];
214    }
215
216    urls
217}
218
219/// Heuristic: does this URL look like a production endpoint?
220fn is_production_url(url: &str) -> bool {
221    let lower = url.to_lowercase();
222    // Exclude localhost, example.com, placeholder domains
223    if lower.contains("localhost")
224        || lower.contains("127.0.0.1")
225        || lower.contains("0.0.0.0")
226        || lower.contains("example.com")
227        || lower.contains("example.org")
228        || lower.contains("placeholder")
229        || lower.contains("{")
230    {
231        return false;
232    }
233    // If it looks like a real domain with a TLD, treat as production
234    // Simple heuristic: contains a dot after the scheme
235    let after_scheme = if let Some(rest) = lower.strip_prefix("https://") {
236        rest
237    } else if let Some(rest) = lower.strip_prefix("http://") {
238        rest
239    } else {
240        return false;
241    };
242    after_scheme.contains('.')
243}