Skip to main content

clawft_kernel/assessment/analyzers/
topology.rs

1//! Topology analyzer — discovers infrastructure descriptors (Docker, k8s, .env).
2
3use std::path::{Path, PathBuf};
4
5use crate::assessment::analyzer::Analyzer;
6use crate::assessment::{Finding, analyzer::AnalysisContext};
7
8/// Analyzer that identifies infrastructure topology files and extracts metadata.
9pub 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            // docker-compose files
38            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            // Dockerfile
57            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                // Extract image references from FROM lines
70                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            // Kubernetes manifests (YAML with apiVersion)
96            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            // .env files (just note existence; SecurityAnalyzer handles the warning)
114            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
129/// Extract service names and ports from a docker-compose YAML (heuristic parse).
130fn 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            // Top-level key under services (indented exactly 2 spaces typically)
144            if !line.starts_with(' ') && !line.is_empty() {
145                // We've left the services block
146                in_services = false;
147                current_service = None;
148                continue;
149            }
150
151            // Service name: line starts with some indent and ends with ':'
152            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            // Image reference
166            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            // Port mappings (e.g. - "8080:80")
180            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}