syncable_cli/analyzer/helmlint/rules/
hl4xxx.rs

1//! HL4xxx - Security Rules
2//!
3//! Rules for validating container and Kubernetes security settings.
4
5use crate::analyzer::helmlint::parser::template::TemplateToken;
6use crate::analyzer::helmlint::rules::{LintContext, Rule};
7use crate::analyzer::helmlint::types::{CheckFailure, RuleCategory, Severity};
8
9/// Get all HL4xxx rules.
10pub fn rules() -> Vec<Box<dyn Rule>> {
11    vec![
12        Box::new(HL4001),
13        Box::new(HL4002),
14        Box::new(HL4003),
15        Box::new(HL4004),
16        Box::new(HL4005),
17        Box::new(HL4006),
18        Box::new(HL4011),
19        Box::new(HL4012),
20    ]
21}
22
23/// HL4001: Container running as root
24pub struct HL4001;
25
26impl Rule for HL4001 {
27    fn code(&self) -> &'static str {
28        "HL4001"
29    }
30
31    fn severity(&self) -> Severity {
32        Severity::Warning
33    }
34
35    fn name(&self) -> &'static str {
36        "container-runs-as-root"
37    }
38
39    fn description(&self) -> &'static str {
40        "Container may run as root user"
41    }
42
43    fn check(&self, ctx: &LintContext) -> Vec<CheckFailure> {
44        let mut failures = Vec::new();
45
46        // Check values.yaml for runAsNonRoot settings
47        if let Some(values) = ctx.values {
48            // Look for securityContext settings
49            let has_run_as_non_root = values
50                .defined_paths
51                .iter()
52                .any(|p| p.to_lowercase().contains("runasnonroot"));
53
54            let has_run_as_user = values
55                .defined_paths
56                .iter()
57                .any(|p| p.to_lowercase().contains("runasuser"));
58
59            if !has_run_as_non_root && !has_run_as_user {
60                failures.push(CheckFailure::new(
61                    "HL4001",
62                    Severity::Warning,
63                    "No runAsNonRoot or runAsUser setting found. Container may run as root",
64                    "values.yaml",
65                    1,
66                    RuleCategory::Security,
67                ));
68            }
69        }
70
71        // Check templates for hardcoded security contexts
72        for template in ctx.templates {
73            let content = template
74                .tokens
75                .iter()
76                .filter_map(|t| match t {
77                    TemplateToken::Text { content, .. } => Some(content.as_str()),
78                    _ => None,
79                })
80                .collect::<Vec<_>>()
81                .join("");
82
83            // Check for runAsUser: 0 (root)
84            if content.contains("runAsUser: 0") || content.contains("runAsUser:0") {
85                failures.push(CheckFailure::new(
86                    "HL4001",
87                    Severity::Warning,
88                    "Container is configured to run as root (runAsUser: 0)",
89                    &template.path,
90                    1,
91                    RuleCategory::Security,
92                ));
93            }
94        }
95
96        failures
97    }
98}
99
100/// HL4002: Privileged container
101pub struct HL4002;
102
103impl Rule for HL4002 {
104    fn code(&self) -> &'static str {
105        "HL4002"
106    }
107
108    fn severity(&self) -> Severity {
109        Severity::Error
110    }
111
112    fn name(&self) -> &'static str {
113        "privileged-container"
114    }
115
116    fn description(&self) -> &'static str {
117        "Container runs in privileged mode"
118    }
119
120    fn check(&self, ctx: &LintContext) -> Vec<CheckFailure> {
121        let mut failures = Vec::new();
122
123        // Check values.yaml
124        if let Some(values) = ctx.values {
125            for path in &values.defined_paths {
126                if path.to_lowercase().contains("privileged")
127                    && let Some(value) = values.get(path)
128                    && is_truthy(value)
129                {
130                    let line = values.line_for_path(path).unwrap_or(1);
131                    failures.push(CheckFailure::new(
132                        "HL4002",
133                        Severity::Error,
134                        format!("Privileged mode enabled at '{}'", path),
135                        "values.yaml",
136                        line,
137                        RuleCategory::Security,
138                    ));
139                }
140            }
141        }
142
143        // Check templates for hardcoded privileged: true
144        for template in ctx.templates {
145            for token in &template.tokens {
146                if let TemplateToken::Text { content, line } = token
147                    && content.contains("privileged: true")
148                {
149                    failures.push(CheckFailure::new(
150                        "HL4002",
151                        Severity::Error,
152                        "Container is configured with privileged: true",
153                        &template.path,
154                        *line,
155                        RuleCategory::Security,
156                    ));
157                }
158            }
159        }
160
161        failures
162    }
163}
164
165/// HL4003: HostPath volume mount
166pub struct HL4003;
167
168impl Rule for HL4003 {
169    fn code(&self) -> &'static str {
170        "HL4003"
171    }
172
173    fn severity(&self) -> Severity {
174        Severity::Warning
175    }
176
177    fn name(&self) -> &'static str {
178        "hostpath-volume"
179    }
180
181    fn description(&self) -> &'static str {
182        "Using hostPath volumes can expose host filesystem"
183    }
184
185    fn check(&self, ctx: &LintContext) -> Vec<CheckFailure> {
186        let mut failures = Vec::new();
187
188        for template in ctx.templates {
189            for token in &template.tokens {
190                if let TemplateToken::Text { content, line } = token
191                    && content.contains("hostPath:")
192                {
193                    failures.push(CheckFailure::new(
194                            "HL4003",
195                            Severity::Warning,
196                            "Using hostPath volume mount. This can expose the host filesystem to the container",
197                            &template.path,
198                            *line,
199                            RuleCategory::Security,
200                        ));
201                }
202            }
203        }
204
205        failures
206    }
207}
208
209/// HL4004: HostNetwork enabled
210pub struct HL4004;
211
212impl Rule for HL4004 {
213    fn code(&self) -> &'static str {
214        "HL4004"
215    }
216
217    fn severity(&self) -> Severity {
218        Severity::Warning
219    }
220
221    fn name(&self) -> &'static str {
222        "host-network"
223    }
224
225    fn description(&self) -> &'static str {
226        "Using host network can bypass network policies"
227    }
228
229    fn check(&self, ctx: &LintContext) -> Vec<CheckFailure> {
230        let mut failures = Vec::new();
231
232        // Check values.yaml
233        if let Some(values) = ctx.values {
234            for path in &values.defined_paths {
235                if path.to_lowercase().contains("hostnetwork")
236                    && let Some(value) = values.get(path)
237                    && is_truthy(value)
238                {
239                    let line = values.line_for_path(path).unwrap_or(1);
240                    failures.push(CheckFailure::new(
241                        "HL4004",
242                        Severity::Warning,
243                        format!("Host network enabled at '{}'", path),
244                        "values.yaml",
245                        line,
246                        RuleCategory::Security,
247                    ));
248                }
249            }
250        }
251
252        // Check templates
253        for template in ctx.templates {
254            for token in &template.tokens {
255                if let TemplateToken::Text { content, line } = token
256                    && content.contains("hostNetwork: true")
257                {
258                    failures.push(CheckFailure::new(
259                        "HL4004",
260                        Severity::Warning,
261                        "Pod uses host network. This bypasses network policies",
262                        &template.path,
263                        *line,
264                        RuleCategory::Security,
265                    ));
266                }
267            }
268        }
269
270        failures
271    }
272}
273
274/// HL4005: HostPID enabled
275pub struct HL4005;
276
277impl Rule for HL4005 {
278    fn code(&self) -> &'static str {
279        "HL4005"
280    }
281
282    fn severity(&self) -> Severity {
283        Severity::Warning
284    }
285
286    fn name(&self) -> &'static str {
287        "host-pid"
288    }
289
290    fn description(&self) -> &'static str {
291        "Using host PID namespace can expose host processes"
292    }
293
294    fn check(&self, ctx: &LintContext) -> Vec<CheckFailure> {
295        let mut failures = Vec::new();
296
297        for template in ctx.templates {
298            for token in &template.tokens {
299                if let TemplateToken::Text { content, line } = token
300                    && content.contains("hostPID: true")
301                {
302                    failures.push(CheckFailure::new(
303                        "HL4005",
304                        Severity::Warning,
305                        "Pod uses host PID namespace. This can expose host processes",
306                        &template.path,
307                        *line,
308                        RuleCategory::Security,
309                    ));
310                }
311            }
312        }
313
314        failures
315    }
316}
317
318/// HL4006: Missing securityContext
319pub struct HL4006;
320
321impl Rule for HL4006 {
322    fn code(&self) -> &'static str {
323        "HL4006"
324    }
325
326    fn severity(&self) -> Severity {
327        Severity::Info
328    }
329
330    fn name(&self) -> &'static str {
331        "missing-security-context"
332    }
333
334    fn description(&self) -> &'static str {
335        "Container or pod is missing securityContext"
336    }
337
338    fn check(&self, ctx: &LintContext) -> Vec<CheckFailure> {
339        let mut failures = Vec::new();
340
341        // Check if values.yaml has any security context settings
342        if let Some(values) = ctx.values {
343            let has_security_context = values
344                .defined_paths
345                .iter()
346                .any(|p| p.to_lowercase().contains("securitycontext"));
347
348            if !has_security_context {
349                failures.push(CheckFailure::new(
350                    "HL4006",
351                    Severity::Info,
352                    "No securityContext configuration found in values.yaml",
353                    "values.yaml",
354                    1,
355                    RuleCategory::Security,
356                ));
357            }
358        }
359
360        failures
361    }
362}
363
364/// HL4011: Secret in environment variable
365pub struct HL4011;
366
367impl Rule for HL4011 {
368    fn code(&self) -> &'static str {
369        "HL4011"
370    }
371
372    fn severity(&self) -> Severity {
373        Severity::Warning
374    }
375
376    fn name(&self) -> &'static str {
377        "secret-in-env"
378    }
379
380    fn description(&self) -> &'static str {
381        "Sensitive value passed via environment variable instead of mounted secret"
382    }
383
384    fn check(&self, ctx: &LintContext) -> Vec<CheckFailure> {
385        let mut failures = Vec::new();
386
387        // Look for environment variables with sensitive names and direct values
388        let sensitive_patterns = [
389            "PASSWORD",
390            "SECRET",
391            "TOKEN",
392            "API_KEY",
393            "APIKEY",
394            "PRIVATE_KEY",
395            "CREDENTIALS",
396        ];
397
398        for template in ctx.templates {
399            for token in &template.tokens {
400                if let TemplateToken::Text { content, line } = token {
401                    // Check if this looks like an env definition with a sensitive name
402                    for pattern in &sensitive_patterns {
403                        let search = format!("name: {}", pattern);
404                        let search_lower = format!("name: {}", pattern.to_lowercase());
405                        if (content.contains(&search) || content.contains(&search_lower))
406                            && content.contains("value:")
407                            && !content.contains("valueFrom:")
408                            && !content.contains("secretKeyRef:")
409                        {
410                            failures.push(CheckFailure::new(
411                                "HL4011",
412                                Severity::Warning,
413                                format!(
414                                    "Environment variable matching '{}' should use secretKeyRef instead of direct value",
415                                    pattern
416                                ),
417                                &template.path,
418                                *line,
419                                RuleCategory::Security,
420                            ));
421                        }
422                    }
423                }
424            }
425        }
426
427        failures
428    }
429}
430
431/// HL4012: Hardcoded credentials detected
432pub struct HL4012;
433
434impl Rule for HL4012 {
435    fn code(&self) -> &'static str {
436        "HL4012"
437    }
438
439    fn severity(&self) -> Severity {
440        Severity::Error
441    }
442
443    fn name(&self) -> &'static str {
444        "hardcoded-credentials"
445    }
446
447    fn description(&self) -> &'static str {
448        "Hardcoded credentials or secrets detected in templates"
449    }
450
451    fn check(&self, ctx: &LintContext) -> Vec<CheckFailure> {
452        let mut failures = Vec::new();
453
454        // Credential types to check for
455        let credential_types = [
456            ("password:", "password"),
457            ("secret:", "secret"),
458            ("apikey:", "API key"),
459            ("token:", "token"),
460        ];
461
462        for template in ctx.templates {
463            for token in &template.tokens {
464                if let TemplateToken::Text { content, line } = token {
465                    let lower_content = content.to_lowercase();
466
467                    for (pattern, cred_type) in &credential_types {
468                        // Check for patterns that look like credentials
469                        if lower_content.contains(pattern) {
470                            // Make sure it's not using a template variable
471                            let has_template_var = content.contains("{{") && content.contains("}}");
472                            let is_empty = content.contains("\"\"") || content.contains("''");
473
474                            if !has_template_var && !is_empty {
475                                // Additional check: line should have an actual value
476                                let parts: Vec<&str> = content.split(':').collect();
477                                if parts.len() >= 2 {
478                                    let value_part = parts[1].trim();
479                                    if !value_part.is_empty()
480                                        && !value_part.starts_with('{')
481                                        && !value_part.starts_with('$')
482                                        && value_part != "\"\""
483                                        && value_part != "''"
484                                    {
485                                        failures.push(CheckFailure::new(
486                                            "HL4012",
487                                            Severity::Error,
488                                            format!(
489                                                "Possible hardcoded {} detected. Use Secrets instead",
490                                                cred_type
491                                            ),
492                                            &template.path,
493                                            *line,
494                                            RuleCategory::Security,
495                                        ));
496                                        break;
497                                    }
498                                }
499                            }
500                        }
501                    }
502                }
503            }
504        }
505
506        failures
507    }
508}
509
510/// Check if a YAML value is truthy.
511fn is_truthy(value: &serde_yaml::Value) -> bool {
512    match value {
513        serde_yaml::Value::Bool(b) => *b,
514        serde_yaml::Value::String(s) => {
515            let lower = s.to_lowercase();
516            lower == "true" || lower == "yes" || lower == "1"
517        }
518        serde_yaml::Value::Number(n) => n.as_i64().map(|i| i != 0).unwrap_or(false),
519        _ => false,
520    }
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526
527    #[test]
528    fn test_is_truthy() {
529        assert!(is_truthy(&serde_yaml::Value::Bool(true)));
530        assert!(!is_truthy(&serde_yaml::Value::Bool(false)));
531        assert!(is_truthy(&serde_yaml::Value::String("true".to_string())));
532        assert!(is_truthy(&serde_yaml::Value::String("yes".to_string())));
533        assert!(!is_truthy(&serde_yaml::Value::String("false".to_string())));
534        assert!(is_truthy(&serde_yaml::Value::Number(1.into())));
535        assert!(!is_truthy(&serde_yaml::Value::Number(0.into())));
536    }
537
538    #[test]
539    fn test_rules_exist() {
540        let all_rules = rules();
541        assert!(!all_rules.is_empty());
542    }
543}