Skip to main content

claude_native/rules/project_specific/
iac.rs

1use crate::detection::{PrimaryType, ProjectType};
2use crate::rules::*;
3use crate::scan::ProjectContext;
4
5fn is_iac(pt: &ProjectType) -> bool {
6    matches!(pt.primary, PrimaryType::IaC(_))
7}
8
9// ── Rule IAC1: State files are NEVER readable ───────────────────────
10
11pub struct StateFilesBlocked;
12
13impl Rule for StateFilesBlocked {
14    fn id(&self) -> &str { "IAC1" }
15    fn name(&self) -> &str { "State files are blocked from reading" }
16    fn dimension(&self) -> Dimension { Dimension::ContextEfficiency }
17    fn severity(&self) -> Severity { Severity::Critical }
18
19    fn applies_to(&self, pt: &ProjectType) -> bool { is_iac(pt) }
20
21    fn check(&self, ctx: &ProjectContext) -> RuleResult {
22        let state_files = ["terraform.tfstate", "terraform.tfstate.backup"];
23        let has_state = state_files.iter().any(|f| ctx.has_file(f));
24
25        if !has_state {
26            // Check .terraform dir
27            if ctx.root.join(".terraform").is_dir()
28                && !ctx.claudeignore_contains(".terraform")
29            {
30                return self.fail(
31                    ".terraform/ directory not in .claudeignore — contains provider cache and may contain state",
32                    Suggestion {
33                        priority: SuggestionPriority::QuickWin,
34                        title: "Ignore .terraform/ directory".into(),
35                        description: "Add .terraform/ to .claudeignore. It contains provider plugins (large binaries) and possibly state with secrets.".into(),
36                        effort: Effort::Minutes,
37                    },
38                );
39            }
40            return self.pass();
41        }
42
43        let ignored = ctx.claudeignore_contains("tfstate");
44        if ignored {
45            self.pass()
46        } else {
47            self.fail(
48                "Terraform state files exist but are not blocked in .claudeignore — SECURITY RISK: state contains secrets",
49                Suggestion {
50                    priority: SuggestionPriority::QuickWin,
51                    title: "Block state files immediately".into(),
52                    description: "Add *.tfstate, *.tfstate.backup, .terraform/ to .claudeignore AND .gitignore. State files contain ALL resource attributes including passwords, tokens, and private keys.".into(),
53                    effort: Effort::Minutes,
54                },
55            )
56        }
57    }
58}
59
60// ── Rule IAC2: Plan output is filtered ──────────────────────────────
61
62pub struct PlanOutputFiltered;
63
64impl Rule for PlanOutputFiltered {
65    fn id(&self) -> &str { "IAC2" }
66    fn name(&self) -> &str { "Plan output filtering documented" }
67    fn dimension(&self) -> Dimension { Dimension::Foundation }
68    fn severity(&self) -> Severity { Severity::High }
69
70    fn applies_to(&self, pt: &ProjectType) -> bool { is_iac(pt) }
71
72    fn check(&self, ctx: &ProjectContext) -> RuleResult {
73        let content = match &ctx.claude_md_content {
74            Some(c) => c.to_lowercase(),
75            None => return self.skip(),
76        };
77
78        let mentions_filtering = content.contains("filter")
79            || content.contains("grep")
80            || content.contains("head")
81            || content.contains("show")
82            || content.contains("plan");
83
84        if mentions_filtering {
85            self.pass()
86        } else {
87            self.warn(
88                "CLAUDE.md doesn't mention filtering plan output",
89                Suggestion {
90                    priority: SuggestionPriority::NiceToHave,
91                    title: "Document plan output filtering".into(),
92                    description: "Add to CLAUDE.md: 'For terraform plan, filter to resource changes only. Raw plan output can be 50,000 lines.' Suggest using `terraform show tfplan | head -200`.".into(),
93                    effort: Effort::Minutes,
94                },
95            )
96        }
97    }
98}
99
100// ── Rule IAC3: Provider cache ignored ───────────────────────────────
101
102pub struct ProviderCacheIgnored;
103
104impl Rule for ProviderCacheIgnored {
105    fn id(&self) -> &str { "IAC3" }
106    fn name(&self) -> &str { "Provider/plugin cache is ignored" }
107    fn dimension(&self) -> Dimension { Dimension::ContextEfficiency }
108    fn severity(&self) -> Severity { Severity::High }
109
110    fn applies_to(&self, pt: &ProjectType) -> bool { is_iac(pt) }
111
112    fn check(&self, ctx: &ProjectContext) -> RuleResult {
113        let caches = [".terraform", ".pulumi", "cdk.out", "cdktf.out"];
114        let present: Vec<&&str> = caches.iter().filter(|c| ctx.root.join(c).is_dir()).collect();
115
116        if present.is_empty() {
117            return self.pass();
118        }
119
120        let all_ignored = present.iter().all(|c| ctx.claudeignore_contains(c));
121        if all_ignored {
122            self.pass()
123        } else {
124            self.fail(
125                &format!("IaC cache dirs not in .claudeignore: {}", present.iter().map(|c| c.to_string()).collect::<Vec<_>>().join(", ")),
126                Suggestion {
127                    priority: SuggestionPriority::QuickWin,
128                    title: "Ignore IaC cache directories".into(),
129                    description: format!("Add to .claudeignore: {}", present.iter().map(|c| format!("{c}/")).collect::<Vec<_>>().join(", ")),
130                    effort: Effort::Minutes,
131                },
132            )
133        }
134    }
135}
136
137// ── Rule IAC4: Secrets managed externally ───────────────────────────
138
139pub struct SecretsExternal;
140
141impl Rule for SecretsExternal {
142    fn id(&self) -> &str { "IAC4" }
143    fn name(&self) -> &str { "Secrets managed externally" }
144    fn dimension(&self) -> Dimension { Dimension::ContextEfficiency }
145    fn severity(&self) -> Severity { Severity::Critical }
146
147    fn applies_to(&self, pt: &ProjectType) -> bool { is_iac(pt) }
148
149    fn check(&self, ctx: &ProjectContext) -> RuleResult {
150        // Check for terraform.tfvars with real values
151        if let Ok(content) = std::fs::read_to_string(ctx.root.join("terraform.tfvars")) {
152            let has_real_values = content.lines().any(|l| {
153                let l = l.trim();
154                !l.is_empty() && !l.starts_with('#') && l.contains('=')
155                    && !l.contains("var.") && !l.contains("${")
156            });
157            if has_real_values {
158                return self.fail(
159                    "terraform.tfvars may contain real secret values",
160                    Suggestion {
161                        priority: SuggestionPriority::QuickWin,
162                        title: "Externalize secrets from tfvars".into(),
163                        description: "Move secrets to environment variables, HashiCorp Vault, or AWS SSM. IaC is the highest risk for credential exposure. Use terraform.tfvars.example instead.".into(),
164                        effort: Effort::Hour,
165                    },
166                );
167            }
168        }
169        self.pass()
170    }
171}
172
173// ── Rule IAC5: Module structure follows conventions ──────────────────
174
175pub struct ModuleConventions;
176
177impl Rule for ModuleConventions {
178    fn id(&self) -> &str { "IAC5" }
179    fn name(&self) -> &str { "Module structure uses conventions" }
180    fn dimension(&self) -> Dimension { Dimension::Navigation }
181    fn severity(&self) -> Severity { Severity::Medium }
182
183    fn applies_to(&self, pt: &ProjectType) -> bool { is_iac(pt) }
184
185    fn check(&self, ctx: &ProjectContext) -> RuleResult {
186        let has_variables = ctx.has_file("variables.tf");
187        let has_outputs = ctx.has_file("outputs.tf");
188        let has_main = ctx.has_file("main.tf");
189
190        if has_variables && has_outputs && has_main {
191            self.pass()
192        } else {
193            let mut missing = Vec::new();
194            if !has_variables { missing.push("variables.tf"); }
195            if !has_outputs { missing.push("outputs.tf"); }
196            if !has_main { missing.push("main.tf"); }
197            self.warn(
198                &format!("Missing standard Terraform files: {}", missing.join(", ")),
199                Suggestion {
200                    priority: SuggestionPriority::NiceToHave,
201                    title: "Follow Terraform module conventions".into(),
202                    description: format!("Create: {}. Claude navigates modules by reading variables.tf first (contract), then main.tf (implementation).", missing.join(", ")),
203                    effort: Effort::Hour,
204                },
205            )
206        }
207    }
208}