Skip to main content

clawft_kernel/assessment/analyzers/
terraform.rs

1//! Terraform analyzer — discovers IaC resources, providers, and state files.
2
3use std::path::{Path, PathBuf};
4
5use crate::assessment::Finding;
6use crate::assessment::analyzer::{AnalysisContext, Analyzer};
7
8/// Analyzer that identifies Terraform configuration, resources, providers, and state files.
9pub 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            // terraform.tfstate — committed state file is a security risk
39            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            // .terraform.lock.hcl — extract provider versions
51            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            // *.tf files — extract resource, provider, data, variable, output blocks
68            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            // *.tfvars files — extract variable assignments
85            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
106/// Extract `resource`, `provider`, `data`, `variable`, and `output` blocks from a `.tf` file.
107fn 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        // resource "type" "name" {
112        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        // data "type" "name" {
126        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        // provider "name" {  — may have version inside block, but we capture inline version too
140        if let Some(rest) = trimmed.strip_prefix("provider ") {
141            if let Some(pname) = parse_one_quoted(rest) {
142                // Try to find version on same line or nearby (simple heuristic)
143                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        // variable "name" {
160        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        // output "name" {
174        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        // version constraint inside provider/required_providers block
188        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
203/// Parse two quoted strings: `"foo" "bar"` -> Some(("foo", "bar")).
204fn 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
213/// Parse one quoted string: `"foo" {` -> Some("foo").
214fn 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
220/// Try to extract an inline `version = "..."` from remaining text on the same line.
221fn 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
231/// Extract the version value from a line like `version = "~> 4.0"`.
232fn 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
240/// Extract variable assignments from `.tfvars` files.
241fn 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        // Simple pattern: key = value
249        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
268/// Extract provider versions from `.terraform.lock.hcl`.
269fn extract_lock_providers(content: &str, rel_str: &str, findings: &mut Vec<Finding>) {
270    // Lines like: provider "registry.terraform.io/hashicorp/aws" {
271    // Then later:   version = "4.67.0"
272    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}