use std::path::{Path, PathBuf};
use crate::assessment::analyzer::{AnalysisContext, Analyzer};
use crate::assessment::Finding;
pub struct NetworkAnalyzer;
fn is_config_ext(path: &Path) -> bool {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
matches!(ext, "toml" | "json" | "yaml" | "yml" | "env")
|| path
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with(".env"))
.unwrap_or(false)
}
fn is_source_ext(path: &Path) -> bool {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
matches!(
ext,
"rs" | "ts" | "tsx" | "js" | "jsx" | "py" | "go" | "rb" | "java"
)
}
const WEBHOOK_PATTERNS: &[&str] = &["/webhook", "/hook", "/callback"];
const API_VAR_PATTERNS: &[&str] = &["api_url", "base_url", "endpoint"];
impl Analyzer for NetworkAnalyzer {
fn id(&self) -> &str {
"network"
}
fn name(&self) -> &str {
"Network Analyzer"
}
fn categories(&self) -> &[&str] {
&["network"]
}
fn analyze(
&self,
project: &Path,
files: &[PathBuf],
_context: &AnalysisContext,
) -> Vec<Finding> {
let mut findings = Vec::new();
for path in files {
let is_config = is_config_ext(path);
let is_source = is_source_ext(path);
if !is_config && !is_source {
continue;
}
let rel = path.strip_prefix(project).unwrap_or(path);
let rel_str = rel.display().to_string();
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
};
for (i, line) in content.lines().enumerate() {
let trimmed = line.trim();
let lower = line.to_lowercase();
let line_num = i + 1;
if trimmed.starts_with("//")
|| trimmed.starts_with('#')
|| trimmed.starts_with("/*")
|| trimmed.starts_with('*')
{
continue;
}
let has_http = lower.contains("http://") || lower.contains("https://");
if has_http {
let urls = extract_urls(line);
for url in &urls {
let severity = if is_source && is_production_url(url) {
"medium"
} else {
"info"
};
findings.push(Finding {
severity: severity.into(),
category: "network".into(),
file: rel_str.clone(),
line: Some(line_num),
message: format!("HTTP endpoint: {url}"),
});
let url_lower = url.to_lowercase();
for pattern in WEBHOOK_PATTERNS {
if url_lower.contains(pattern) {
findings.push(Finding {
severity: "info".into(),
category: "network".into(),
file: rel_str.clone(),
line: Some(line_num),
message: format!("Webhook URL detected: {url}"),
});
break;
}
}
}
}
if is_config {
for var_pat in API_VAR_PATTERNS {
if lower.contains(var_pat) {
findings.push(Finding {
severity: "info".into(),
category: "network".into(),
file: rel_str.clone(),
line: Some(line_num),
message: format!(
"API base URL variable detected (matched '{var_pat}')"
),
});
break;
}
}
}
if lower.contains("grpc://") {
findings.push(Finding {
severity: "info".into(),
category: "network".into(),
file: rel_str.clone(),
line: Some(line_num),
message: "gRPC endpoint reference detected".into(),
});
}
if lower.contains("50051") {
findings.push(Finding {
severity: "info".into(),
category: "network".into(),
file: rel_str.clone(),
line: Some(line_num),
message: "Possible gRPC port (50051) reference".into(),
});
}
if lower.contains("ws://") || lower.contains("wss://") {
findings.push(Finding {
severity: "info".into(),
category: "network".into(),
file: rel_str.clone(),
line: Some(line_num),
message: "WebSocket endpoint detected".into(),
});
}
}
}
findings
}
}
fn extract_urls(line: &str) -> Vec<String> {
let mut urls = Vec::new();
let mut search = line;
loop {
let start = if let Some(pos) = search.find("https://") {
pos
} else if let Some(pos) = search.find("http://") {
pos
} else {
break;
};
let rest = &search[start..];
let end = rest
.find(|c: char| c.is_whitespace() || c == '"' || c == '\'' || c == '`' || c == '>' || c == ')' || c == ']')
.unwrap_or(rest.len());
let url = &rest[..end];
if url.len() > 8 {
urls.push(url.to_string());
}
search = &rest[end..];
}
urls
}
fn is_production_url(url: &str) -> bool {
let lower = url.to_lowercase();
if lower.contains("localhost")
|| lower.contains("127.0.0.1")
|| lower.contains("0.0.0.0")
|| lower.contains("example.com")
|| lower.contains("example.org")
|| lower.contains("placeholder")
|| lower.contains("{")
{
return false;
}
let after_scheme = if let Some(rest) = lower.strip_prefix("https://") {
rest
} else if let Some(rest) = lower.strip_prefix("http://") {
rest
} else {
return false;
};
after_scheme.contains('.')
}