claude_native/rules/project_specific/
iac.rs1use 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
9pub 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 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
60pub 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
100pub 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
137pub 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 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
173pub 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}