clawft_kernel/assessment/analyzers/
terraform.rs1use std::path::{Path, PathBuf};
4
5use crate::assessment::Finding;
6use crate::assessment::analyzer::{AnalysisContext, Analyzer};
7
8pub struct TerraformAnalyzer;
10
11impl Analyzer for TerraformAnalyzer {
12 fn id(&self) -> &str {
13 "terraform"
14 }
15
16 fn name(&self) -> &str {
17 "Terraform Analyzer"
18 }
19
20 fn categories(&self) -> &[&str] {
21 &["infrastructure", "security"]
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 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
37
38 if name == "terraform.tfstate" || name == "terraform.tfstate.backup" {
40 findings.push(Finding {
41 severity: "high".into(),
42 category: "security".into(),
43 file: rel_str.clone(),
44 line: None,
45 message: "Terraform state file committed — may contain secrets".into(),
46 });
47 continue;
48 }
49
50 if name == ".terraform.lock.hcl" {
52 let content = match std::fs::read_to_string(path) {
53 Ok(c) => c,
54 Err(_) => continue,
55 };
56 findings.push(Finding {
57 severity: "info".into(),
58 category: "infrastructure".into(),
59 file: rel_str.clone(),
60 line: None,
61 message: "Terraform lock file detected".into(),
62 });
63 extract_lock_providers(&content, &rel_str, &mut findings);
64 continue;
65 }
66
67 if ext == "tf" {
69 let content = match std::fs::read_to_string(path) {
70 Ok(c) => c,
71 Err(_) => continue,
72 };
73 findings.push(Finding {
74 severity: "info".into(),
75 category: "infrastructure".into(),
76 file: rel_str.clone(),
77 line: None,
78 message: "Terraform configuration file detected".into(),
79 });
80 extract_tf_blocks(&content, &rel_str, &mut findings);
81 continue;
82 }
83
84 if ext == "tfvars" {
86 let content = match std::fs::read_to_string(path) {
87 Ok(c) => c,
88 Err(_) => continue,
89 };
90 findings.push(Finding {
91 severity: "info".into(),
92 category: "infrastructure".into(),
93 file: rel_str.clone(),
94 line: None,
95 message: "Terraform variables file detected".into(),
96 });
97 extract_tfvars(&content, &rel_str, &mut findings);
98 continue;
99 }
100 }
101
102 findings
103 }
104}
105
106fn extract_tf_blocks(content: &str, rel_str: &str, findings: &mut Vec<Finding>) {
108 for (i, line) in content.lines().enumerate() {
109 let trimmed = line.trim();
110
111 if let Some(rest) = trimmed.strip_prefix("resource ") {
113 if let Some((rtype, rname)) = parse_two_quoted(rest) {
114 findings.push(Finding {
115 severity: "info".into(),
116 category: "infrastructure".into(),
117 file: rel_str.to_string(),
118 line: Some(i + 1),
119 message: format!("Terraform resource: {rtype}.{rname}"),
120 });
121 }
122 continue;
123 }
124
125 if let Some(rest) = trimmed.strip_prefix("data ") {
127 if let Some((dtype, dname)) = parse_two_quoted(rest) {
128 findings.push(Finding {
129 severity: "info".into(),
130 category: "infrastructure".into(),
131 file: rel_str.to_string(),
132 line: Some(i + 1),
133 message: format!("Terraform data source: {dtype}.{dname}"),
134 });
135 }
136 continue;
137 }
138
139 if let Some(rest) = trimmed.strip_prefix("provider ") {
141 if let Some(pname) = parse_one_quoted(rest) {
142 let version = extract_inline_version(rest);
144 let msg = match version {
145 Some(v) => format!("Terraform provider: {pname} {v}"),
146 None => format!("Terraform provider: {pname}"),
147 };
148 findings.push(Finding {
149 severity: "info".into(),
150 category: "infrastructure".into(),
151 file: rel_str.to_string(),
152 line: Some(i + 1),
153 message: msg,
154 });
155 }
156 continue;
157 }
158
159 if let Some(rest) = trimmed.strip_prefix("variable ") {
161 if let Some(vname) = parse_one_quoted(rest) {
162 findings.push(Finding {
163 severity: "info".into(),
164 category: "infrastructure".into(),
165 file: rel_str.to_string(),
166 line: Some(i + 1),
167 message: format!("Terraform variable: {vname}"),
168 });
169 }
170 continue;
171 }
172
173 if let Some(rest) = trimmed.strip_prefix("output ") {
175 if let Some(oname) = parse_one_quoted(rest) {
176 findings.push(Finding {
177 severity: "info".into(),
178 category: "infrastructure".into(),
179 file: rel_str.to_string(),
180 line: Some(i + 1),
181 message: format!("Terraform output: {oname}"),
182 });
183 }
184 continue;
185 }
186
187 if trimmed.starts_with("version")
189 && trimmed.contains('=')
190 && let Some(v) = extract_version_value(trimmed)
191 {
192 findings.push(Finding {
193 severity: "info".into(),
194 category: "infrastructure".into(),
195 file: rel_str.to_string(),
196 line: Some(i + 1),
197 message: format!("Terraform version constraint: {v}"),
198 });
199 }
200 }
201}
202
203fn parse_two_quoted(s: &str) -> Option<(&str, &str)> {
205 let first_start = s.find('"')? + 1;
206 let first_end = s[first_start..].find('"')? + first_start;
207 let rest = &s[first_end + 1..];
208 let second_start = rest.find('"')? + 1;
209 let second_end = rest[second_start..].find('"')? + second_start;
210 Some((&s[first_start..first_end], &rest[second_start..second_end]))
211}
212
213fn parse_one_quoted(s: &str) -> Option<&str> {
215 let start = s.find('"')? + 1;
216 let end = s[start..].find('"')? + start;
217 Some(&s[start..end])
218}
219
220fn extract_inline_version(s: &str) -> Option<&str> {
222 let idx = s.find("version")?;
223 let after = &s[idx + 7..];
224 let eq = after.find('=')?;
225 let after_eq = &after[eq + 1..];
226 let q_start = after_eq.find('"')? + 1;
227 let q_end = after_eq[q_start..].find('"')? + q_start;
228 Some(&after_eq[q_start..q_end])
229}
230
231fn extract_version_value(line: &str) -> Option<&str> {
233 let eq_pos = line.find('=')?;
234 let after_eq = &line[eq_pos + 1..];
235 let q_start = after_eq.find('"')? + 1;
236 let q_end = after_eq[q_start..].find('"')? + q_start;
237 Some(&after_eq[q_start..q_end])
238}
239
240fn extract_tfvars(content: &str, rel_str: &str, findings: &mut Vec<Finding>) {
242 for (i, line) in content.lines().enumerate() {
243 let trimmed = line.trim();
244 if trimmed.is_empty() || trimmed.starts_with('#') {
245 continue;
246 }
247
248 if let Some(eq_pos) = trimmed.find('=') {
250 let key = trimmed[..eq_pos].trim();
251 if !key.is_empty()
252 && key
253 .chars()
254 .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
255 {
256 findings.push(Finding {
257 severity: "info".into(),
258 category: "infrastructure".into(),
259 file: rel_str.to_string(),
260 line: Some(i + 1),
261 message: format!("Terraform variable assignment: {key}"),
262 });
263 }
264 }
265 }
266}
267
268fn extract_lock_providers(content: &str, rel_str: &str, findings: &mut Vec<Finding>) {
270 let mut current_provider: Option<String> = None;
273
274 for (i, line) in content.lines().enumerate() {
275 let trimmed = line.trim();
276
277 if let Some(rest) = trimmed.strip_prefix("provider ") {
278 if let Some(pname) = parse_one_quoted(rest) {
279 current_provider = Some(pname.to_string());
280 findings.push(Finding {
281 severity: "info".into(),
282 category: "infrastructure".into(),
283 file: rel_str.to_string(),
284 line: Some(i + 1),
285 message: format!("Terraform locked provider: {pname}"),
286 });
287 }
288 continue;
289 }
290
291 if trimmed.starts_with("version")
292 && trimmed.contains('=')
293 && let Some(v) = extract_version_value(trimmed)
294 && let Some(ref prov) = current_provider
295 {
296 findings.push(Finding {
297 severity: "info".into(),
298 category: "infrastructure".into(),
299 file: rel_str.to_string(),
300 line: Some(i + 1),
301 message: format!("Terraform locked provider version: {prov} = {v}"),
302 });
303 }
304
305 if trimmed == "}" {
306 current_provider = None;
307 }
308 }
309}