clawft_kernel/assessment/analyzers/
network.rs1use std::path::{Path, PathBuf};
4
5use crate::assessment::Finding;
6use crate::assessment::analyzer::{AnalysisContext, Analyzer};
7
8pub struct NetworkAnalyzer;
10
11fn 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
22fn 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
31const WEBHOOK_PATTERNS: &[&str] = &["/webhook", "/hook", "/callback"];
33
34const 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 if trimmed.starts_with("//")
81 || trimmed.starts_with('#')
82 || trimmed.starts_with("/*")
83 || trimmed.starts_with('*')
84 {
85 continue;
86 }
87
88 let has_http = lower.contains("http://") || lower.contains("https://");
90 if has_http {
91 let urls = extract_urls(line);
93 for url in &urls {
94 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 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 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 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 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
182fn 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 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
219fn is_production_url(url: &str) -> bool {
221 let lower = url.to_lowercase();
222 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 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}