clawft_kernel/assessment/analyzers/
topology.rs1use std::path::{Path, PathBuf};
4
5use crate::assessment::analyzer::Analyzer;
6use crate::assessment::{Finding, analyzer::AnalysisContext};
7
8pub struct TopologyAnalyzer;
10
11impl Analyzer for TopologyAnalyzer {
12 fn id(&self) -> &str {
13 "topology"
14 }
15
16 fn name(&self) -> &str {
17 "Topology Analyzer"
18 }
19
20 fn categories(&self) -> &[&str] {
21 &["topology"]
22 }
23
24 fn analyze(
25 &self,
26 project: &Path,
27 files: &[PathBuf],
28 _context: &AnalysisContext,
29 ) -> Vec<Finding> {
30 let mut findings = Vec::new();
31
32 for path in files {
33 let rel = path.strip_prefix(project).unwrap_or(path);
34 let rel_str = rel.display().to_string();
35 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
36
37 if name.starts_with("docker-compose")
39 && (name.ends_with(".yml") || name.ends_with(".yaml"))
40 {
41 let content = match std::fs::read_to_string(path) {
42 Ok(c) => c,
43 Err(_) => continue,
44 };
45 findings.push(Finding {
46 severity: "info".into(),
47 category: "topology".into(),
48 file: rel_str.clone(),
49 line: None,
50 message: "Docker Compose file detected".into(),
51 });
52 extract_compose_services(&content, &rel_str, &mut findings);
53 continue;
54 }
55
56 if name == "Dockerfile" || name.starts_with("Dockerfile.") {
58 let content = match std::fs::read_to_string(path) {
59 Ok(c) => c,
60 Err(_) => continue,
61 };
62 findings.push(Finding {
63 severity: "info".into(),
64 category: "topology".into(),
65 file: rel_str.clone(),
66 line: None,
67 message: "Dockerfile detected".into(),
68 });
69 for (i, line) in content.lines().enumerate() {
71 let trimmed = line.trim();
72 if let Some(rest) = trimmed.strip_prefix("FROM ") {
73 let image = rest.split_whitespace().next().unwrap_or(rest);
74 findings.push(Finding {
75 severity: "info".into(),
76 category: "topology".into(),
77 file: rel_str.clone(),
78 line: Some(i + 1),
79 message: format!("Base image: {image}"),
80 });
81 }
82 if let Some(rest) = trimmed.strip_prefix("EXPOSE ") {
83 findings.push(Finding {
84 severity: "info".into(),
85 category: "topology".into(),
86 file: rel_str.clone(),
87 line: Some(i + 1),
88 message: format!("Exposed port: {}", rest.trim()),
89 });
90 }
91 }
92 continue;
93 }
94
95 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
97 if (ext == "yaml" || ext == "yml") && !name.starts_with("docker-compose") {
98 let content = match std::fs::read_to_string(path) {
99 Ok(c) => c,
100 Err(_) => continue,
101 };
102 if content.contains("apiVersion:") && content.contains("kind:") {
103 findings.push(Finding {
104 severity: "info".into(),
105 category: "topology".into(),
106 file: rel_str.clone(),
107 line: None,
108 message: "Kubernetes manifest detected".into(),
109 });
110 }
111 }
112
113 if name == ".env" || name.starts_with(".env.") {
115 findings.push(Finding {
116 severity: "info".into(),
117 category: "topology".into(),
118 file: rel_str.clone(),
119 line: None,
120 message: "Environment configuration file detected".into(),
121 });
122 }
123 }
124
125 findings
126 }
127}
128
129fn extract_compose_services(content: &str, rel_str: &str, findings: &mut Vec<Finding>) {
131 let mut in_services = false;
132 let mut current_service: Option<String> = None;
133
134 for line in content.lines() {
135 let trimmed = line.trim();
136
137 if trimmed == "services:" {
138 in_services = true;
139 continue;
140 }
141
142 if in_services {
143 if !line.starts_with(' ') && !line.is_empty() {
145 in_services = false;
147 current_service = None;
148 continue;
149 }
150
151 let stripped = line.trim_start();
153 if stripped.ends_with(':') && !stripped.contains(' ') {
154 let svc_name = stripped.trim_end_matches(':');
155 current_service = Some(svc_name.to_string());
156 findings.push(Finding {
157 severity: "info".into(),
158 category: "topology".into(),
159 file: rel_str.to_string(),
160 line: None,
161 message: format!("Service: {svc_name}"),
162 });
163 }
164
165 if let Some(rest) = stripped.strip_prefix("image:") {
167 let image = rest.trim().trim_matches('"').trim_matches('\'');
168 if let Some(ref svc) = current_service {
169 findings.push(Finding {
170 severity: "info".into(),
171 category: "topology".into(),
172 file: rel_str.to_string(),
173 line: None,
174 message: format!("Service '{svc}' image: {image}"),
175 });
176 }
177 }
178
179 if stripped.starts_with("- \"")
181 || stripped.starts_with("- '")
182 || stripped.starts_with("- ")
183 {
184 let val = stripped
185 .trim_start_matches("- ")
186 .trim_matches('"')
187 .trim_matches('\'');
188 if val.contains(':')
189 && val.chars().all(|c| c.is_ascii_digit() || c == ':')
190 && let Some(ref svc) = current_service
191 {
192 findings.push(Finding {
193 severity: "info".into(),
194 category: "topology".into(),
195 file: rel_str.to_string(),
196 line: None,
197 message: format!("Service '{svc}' port mapping: {val}"),
198 });
199 }
200 }
201 }
202 }
203}