syncable_cli/analyzer/helmlint/rules/
hl2xxx.rs

1//! HL2xxx - Values Validation Rules
2//!
3//! Rules for validating values.yaml configuration.
4
5use crate::analyzer::helmlint::rules::{LintContext, Rule};
6use crate::analyzer::helmlint::types::{CheckFailure, RuleCategory, Severity};
7
8/// Get all HL2xxx rules.
9pub fn rules() -> Vec<Box<dyn Rule>> {
10    vec![
11        Box::new(HL2002),
12        Box::new(HL2003),
13        Box::new(HL2004),
14        Box::new(HL2005),
15        Box::new(HL2007),
16        Box::new(HL2008),
17    ]
18}
19
20/// HL2002: Value referenced in template but not defined
21pub struct HL2002;
22
23impl Rule for HL2002 {
24    fn code(&self) -> &'static str {
25        "HL2002"
26    }
27
28    fn severity(&self) -> Severity {
29        Severity::Warning
30    }
31
32    fn name(&self) -> &'static str {
33        "undefined-value"
34    }
35
36    fn description(&self) -> &'static str {
37        "Value is referenced in template but not defined in values.yaml"
38    }
39
40    fn check(&self, ctx: &LintContext) -> Vec<CheckFailure> {
41        let mut failures = Vec::new();
42
43        // Skip if no values file
44        let values = match ctx.values {
45            Some(v) => v,
46            None => return failures,
47        };
48
49        // Check each template reference
50        for ref_path in &ctx.template_value_refs {
51            // Check if base path exists (allow nested access to undefined)
52            let base_path = ref_path.split('.').next().unwrap_or(ref_path);
53            if !values.has_path(base_path) && !values.has_path(ref_path) {
54                // Check if any parent path exists
55                let mut found_parent = false;
56                let parts: Vec<&str> = ref_path.split('.').collect();
57                for i in 1..parts.len() {
58                    let partial = parts[..i].join(".");
59                    if values.has_path(&partial) {
60                        found_parent = true;
61                        break;
62                    }
63                }
64
65                if !found_parent {
66                    failures.push(CheckFailure::new(
67                        "HL2002",
68                        Severity::Warning,
69                        format!(
70                            "Value '.Values.{}' is referenced but not defined in values.yaml",
71                            ref_path
72                        ),
73                        "values.yaml",
74                        1,
75                        RuleCategory::Values,
76                    ));
77                }
78            }
79        }
80
81        failures
82    }
83}
84
85/// HL2003: Value defined but never used
86pub struct HL2003;
87
88impl Rule for HL2003 {
89    fn code(&self) -> &'static str {
90        "HL2003"
91    }
92
93    fn severity(&self) -> Severity {
94        Severity::Info
95    }
96
97    fn name(&self) -> &'static str {
98        "unused-value"
99    }
100
101    fn description(&self) -> &'static str {
102        "Value is defined in values.yaml but never used in templates"
103    }
104
105    fn check(&self, ctx: &LintContext) -> Vec<CheckFailure> {
106        let mut failures = Vec::new();
107
108        let values = match ctx.values {
109            Some(v) => v,
110            None => return failures,
111        };
112
113        // Check each defined value
114        for path in &values.defined_paths {
115            // Skip if any template references this path or a child path
116            let is_used = ctx
117                .template_value_refs
118                .iter()
119                .any(|ref_path| ref_path == path || ref_path.starts_with(&format!("{}.", path)));
120
121            // Also skip if a parent path is referenced (e.g., toYaml .Values.config)
122            let parent_is_used = ctx
123                .template_value_refs
124                .iter()
125                .any(|ref_path| path.starts_with(&format!("{}.", ref_path)));
126
127            if !is_used && !parent_is_used {
128                let line = values.line_for_path(path).unwrap_or(1);
129                failures.push(CheckFailure::new(
130                    "HL2003",
131                    Severity::Info,
132                    format!("Value '{}' is defined but never used in templates", path),
133                    "values.yaml",
134                    line,
135                    RuleCategory::Values,
136                ));
137            }
138        }
139
140        failures
141    }
142}
143
144/// HL2004: Sensitive value not marked as secret
145pub struct HL2004;
146
147impl Rule for HL2004 {
148    fn code(&self) -> &'static str {
149        "HL2004"
150    }
151
152    fn severity(&self) -> Severity {
153        Severity::Warning
154    }
155
156    fn name(&self) -> &'static str {
157        "sensitive-value-exposed"
158    }
159
160    fn description(&self) -> &'static str {
161        "Sensitive value should be handled as a Kubernetes Secret"
162    }
163
164    fn check(&self, ctx: &LintContext) -> Vec<CheckFailure> {
165        let mut failures = Vec::new();
166
167        let values = match ctx.values {
168            Some(v) => v,
169            None => return failures,
170        };
171
172        for path in values.sensitive_paths() {
173            // Check if the value has a non-empty default
174            if let Some(value) = values.get(path) {
175                let has_hardcoded_value = match value {
176                    serde_yaml::Value::String(s) => !s.is_empty() && !s.starts_with("$"),
177                    _ => false,
178                };
179
180                if has_hardcoded_value {
181                    let line = values.line_for_path(path).unwrap_or(1);
182                    failures.push(CheckFailure::new(
183                        "HL2004",
184                        Severity::Warning,
185                        format!(
186                            "Sensitive value '{}' has a hardcoded default. Consider using a Secret reference",
187                            path
188                        ),
189                        "values.yaml",
190                        line,
191                        RuleCategory::Values,
192                    ));
193                }
194            }
195        }
196
197        failures
198    }
199}
200
201/// HL2005: Port number out of valid range
202pub struct HL2005;
203
204impl Rule for HL2005 {
205    fn code(&self) -> &'static str {
206        "HL2005"
207    }
208
209    fn severity(&self) -> Severity {
210        Severity::Error
211    }
212
213    fn name(&self) -> &'static str {
214        "invalid-port"
215    }
216
217    fn description(&self) -> &'static str {
218        "Port number must be between 1 and 65535"
219    }
220
221    fn check(&self, ctx: &LintContext) -> Vec<CheckFailure> {
222        let mut failures = Vec::new();
223
224        let values = match ctx.values {
225            Some(v) => v,
226            None => return failures,
227        };
228
229        // Look for common port patterns
230        let port_patterns = [
231            "port",
232            "containerPort",
233            "targetPort",
234            "hostPort",
235            "nodePort",
236        ];
237
238        for path in &values.defined_paths {
239            let lower_path = path.to_lowercase();
240            let is_port_field = port_patterns.iter().any(|p| lower_path.ends_with(p));
241
242            if is_port_field
243                && let Some(value) = values.get(path)
244                && let Some(port) = extract_port_number(value)
245                && !(1..=65535).contains(&port)
246            {
247                let line = values.line_for_path(path).unwrap_or(1);
248                failures.push(CheckFailure::new(
249                    "HL2005",
250                    Severity::Error,
251                    format!(
252                        "Invalid port number {} at '{}'. Must be between 1 and 65535",
253                        port, path
254                    ),
255                    "values.yaml",
256                    line,
257                    RuleCategory::Values,
258                ));
259            }
260        }
261
262        failures
263    }
264}
265
266/// HL2007: Image tag is 'latest'
267pub struct HL2007;
268
269impl Rule for HL2007 {
270    fn code(&self) -> &'static str {
271        "HL2007"
272    }
273
274    fn severity(&self) -> Severity {
275        Severity::Warning
276    }
277
278    fn name(&self) -> &'static str {
279        "image-tag-latest"
280    }
281
282    fn description(&self) -> &'static str {
283        "Using 'latest' tag is prone to unexpected changes"
284    }
285
286    fn check(&self, ctx: &LintContext) -> Vec<CheckFailure> {
287        let mut failures = Vec::new();
288
289        let values = match ctx.values {
290            Some(v) => v,
291            None => return failures,
292        };
293
294        // Look for image.tag or similar patterns
295        for path in &values.defined_paths {
296            let lower_path = path.to_lowercase();
297            if (lower_path.ends_with(".tag") || lower_path.ends_with("imagetag"))
298                && let Some(serde_yaml::Value::String(tag)) = values.get(path)
299                && tag == "latest"
300            {
301                let line = values.line_for_path(path).unwrap_or(1);
302                failures.push(CheckFailure::new(
303                            "HL2007",
304                            Severity::Warning,
305                            format!(
306                                "Image tag at '{}' is 'latest'. Pin to a specific version for reproducibility",
307                                path
308                            ),
309                            "values.yaml",
310                            line,
311                            RuleCategory::Values,
312                        ));
313            }
314        }
315
316        failures
317    }
318}
319
320/// HL2008: Replica count is zero
321pub struct HL2008;
322
323impl Rule for HL2008 {
324    fn code(&self) -> &'static str {
325        "HL2008"
326    }
327
328    fn severity(&self) -> Severity {
329        Severity::Warning
330    }
331
332    fn name(&self) -> &'static str {
333        "zero-replicas"
334    }
335
336    fn description(&self) -> &'static str {
337        "Replica count is zero which means no pods will be created"
338    }
339
340    fn check(&self, ctx: &LintContext) -> Vec<CheckFailure> {
341        let mut failures = Vec::new();
342
343        let values = match ctx.values {
344            Some(v) => v,
345            None => return failures,
346        };
347
348        for path in &values.defined_paths {
349            let lower_path = path.to_lowercase();
350            if (lower_path.ends_with("replicacount") || lower_path.ends_with("replicas"))
351                && let Some(value) = values.get(path)
352                && let Some(count) = extract_number(value)
353                && count == 0
354            {
355                let line = values.line_for_path(path).unwrap_or(1);
356                failures.push(CheckFailure::new(
357                    "HL2008",
358                    Severity::Warning,
359                    format!(
360                        "Replica count at '{}' is 0. No pods will be created by default",
361                        path
362                    ),
363                    "values.yaml",
364                    line,
365                    RuleCategory::Values,
366                ));
367            }
368        }
369
370        failures
371    }
372}
373
374/// Extract a port number from a YAML value.
375fn extract_port_number(value: &serde_yaml::Value) -> Option<i64> {
376    match value {
377        serde_yaml::Value::Number(n) => n.as_i64(),
378        serde_yaml::Value::String(s) => s.parse().ok(),
379        _ => None,
380    }
381}
382
383/// Extract a number from a YAML value.
384fn extract_number(value: &serde_yaml::Value) -> Option<i64> {
385    match value {
386        serde_yaml::Value::Number(n) => n.as_i64(),
387        serde_yaml::Value::String(s) => s.parse().ok(),
388        _ => None,
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    #[test]
397    fn test_extract_port_number() {
398        assert_eq!(
399            extract_port_number(&serde_yaml::Value::Number(80.into())),
400            Some(80)
401        );
402        assert_eq!(
403            extract_port_number(&serde_yaml::Value::String("8080".to_string())),
404            Some(8080)
405        );
406        assert_eq!(extract_port_number(&serde_yaml::Value::Bool(true)), None);
407    }
408}