syncable_cli/analyzer/kubelint/
lint.rs1use crate::analyzer::kubelint::checks::builtin_checks;
7use crate::analyzer::kubelint::config::{CheckSpec, KubelintConfig};
8use crate::analyzer::kubelint::context::{LintContext, LintContextImpl};
9use crate::analyzer::kubelint::parser::{helm, kustomize, yaml};
10use crate::analyzer::kubelint::pragma::should_ignore_check;
11use crate::analyzer::kubelint::types::{CheckFailure, Severity};
12
13use std::path::Path;
14
15#[derive(Debug, Clone)]
17pub struct LintResult {
18 pub failures: Vec<CheckFailure>,
20 pub parse_errors: Vec<String>,
22 pub summary: LintSummary,
24}
25
26#[derive(Debug, Clone)]
28pub struct LintSummary {
29 pub objects_analyzed: usize,
31 pub checks_run: usize,
33 pub passed: bool,
35}
36
37impl LintResult {
38 pub fn new() -> Self {
40 Self {
41 failures: Vec::new(),
42 parse_errors: Vec::new(),
43 summary: LintSummary {
44 objects_analyzed: 0,
45 checks_run: 0,
46 passed: true,
47 },
48 }
49 }
50
51 pub fn has_failures(&self) -> bool {
53 !self.failures.is_empty()
54 }
55
56 pub fn has_errors(&self) -> bool {
58 self.failures.iter().any(|f| f.severity == Severity::Error)
59 }
60
61 pub fn has_warnings(&self) -> bool {
63 self.failures
64 .iter()
65 .any(|f| f.severity == Severity::Warning)
66 }
67
68 pub fn max_severity(&self) -> Option<Severity> {
70 self.failures.iter().map(|f| f.severity).max()
71 }
72
73 pub fn should_fail(&self, config: &KubelintConfig) -> bool {
75 if config.no_fail {
76 return false;
77 }
78
79 if let Some(max) = self.max_severity() {
80 max >= config.failure_threshold
81 } else {
82 false
83 }
84 }
85
86 pub fn filter_by_threshold(&mut self, threshold: Severity) {
88 self.failures.retain(|f| f.severity >= threshold);
89 }
90
91 pub fn sort(&mut self) {
93 self.failures.sort();
94 }
95}
96
97impl Default for LintResult {
98 fn default() -> Self {
99 Self::new()
100 }
101}
102
103pub fn lint(path: &Path, config: &KubelintConfig) -> LintResult {
111 let mut result = LintResult::new();
112
113 if config.should_ignore_path(path) {
115 return result;
116 }
117
118 let (ctx, warning) = match load_context(path, config) {
120 Ok((ctx, warning)) => (ctx, warning),
121 Err(err) => {
122 result.parse_errors.push(err);
123 return result;
124 }
125 };
126
127 if let Some(warn) = warning {
129 result.parse_errors.push(warn);
130 }
131
132 result = run_checks(&ctx, config);
134 result
135}
136
137pub fn lint_file(path: &Path, config: &KubelintConfig) -> LintResult {
139 lint(path, config)
140}
141
142pub fn lint_content(content: &str, config: &KubelintConfig) -> LintResult {
144 let mut result = LintResult::new();
145 let mut ctx = LintContextImpl::new();
146
147 match yaml::parse_yaml(content) {
149 Ok(objects) => {
150 for obj in objects {
151 ctx.add_object(obj);
152 }
153 }
154 Err(err) => {
155 result.parse_errors.push(err.to_string());
156 return result;
157 }
158 }
159
160 run_checks(&ctx, config)
162}
163
164fn load_context(
167 path: &Path,
168 _config: &KubelintConfig,
169) -> Result<(LintContextImpl, Option<String>), String> {
170 let mut ctx = LintContextImpl::new();
171 let mut warning: Option<String> = None;
172
173 if helm::is_helm_chart(path) {
174 match helm::render_helm_chart(path, None) {
176 Ok(objects) => {
177 for obj in objects {
178 ctx.add_object(obj);
179 }
180 }
181 Err(err) => {
182 let templates_dir = path.join("templates");
185 if templates_dir.exists() {
186 warning = Some(format!(
187 "Helm render failed ({}), falling back to raw template parsing",
188 err
189 ));
190 match yaml::parse_yaml_dir(&templates_dir) {
192 Ok(objects) => {
193 for obj in objects {
194 ctx.add_object(obj);
195 }
196 }
197 Err(yaml_err) => {
198 return Err(format!(
200 "Failed to render Helm chart: {}. Fallback YAML parsing also failed: {}",
201 err, yaml_err
202 ));
203 }
204 }
205 } else {
206 return Err(format!("Failed to render Helm chart: {}", err));
207 }
208 }
209 }
210 } else if kustomize::is_kustomize_dir(path) {
211 match kustomize::render_kustomize(path) {
213 Ok(objects) => {
214 for obj in objects {
215 ctx.add_object(obj);
216 }
217 }
218 Err(err) => return Err(format!("Failed to render Kustomize: {}", err)),
219 }
220 } else if path.is_dir() {
221 load_directory_with_rendering(&mut ctx, path)?;
223 } else {
224 match yaml::parse_yaml_file(path) {
226 Ok(objects) => {
227 for obj in objects {
228 ctx.add_object(obj);
229 }
230 }
231 Err(err) => return Err(format!("Failed to parse YAML file: {}", err)),
232 }
233 }
234
235 Ok((ctx, warning))
236}
237
238fn load_directory_with_rendering(ctx: &mut LintContextImpl, path: &Path) -> Result<(), String> {
240 use std::collections::HashSet;
241
242 let mut processed_dirs: HashSet<std::path::PathBuf> = HashSet::new();
243
244 for entry in walkdir::WalkDir::new(path)
246 .follow_links(true)
247 .into_iter()
248 .filter_map(|e| e.ok())
249 {
250 let entry_path = entry.path();
251 if entry_path.is_dir() {
252 if helm::is_helm_chart(entry_path) {
254 if let Ok(objects) = helm::render_helm_chart(entry_path, None) {
255 for obj in objects {
256 ctx.add_object(obj);
257 }
258 }
259 processed_dirs.insert(entry_path.to_path_buf());
261 continue;
262 }
263
264 if kustomize::is_kustomize_dir(entry_path) {
266 if let Ok(objects) = kustomize::render_kustomize(entry_path) {
267 for obj in objects {
268 ctx.add_object(obj);
269 }
270 }
271 processed_dirs.insert(entry_path.to_path_buf());
273 continue;
274 }
275 }
276 }
277
278 for entry in walkdir::WalkDir::new(path)
280 .follow_links(true)
281 .into_iter()
282 .filter_map(|e| e.ok())
283 {
284 let entry_path = entry.path();
285 if entry_path.is_file() {
286 let should_skip = processed_dirs
288 .iter()
289 .any(|processed| entry_path.starts_with(processed));
290 if should_skip {
291 continue;
292 }
293
294 let ext = entry_path.extension().and_then(|e| e.to_str());
296 if matches!(ext, Some("yaml") | Some("yml"))
297 && let Ok(objects) = yaml::parse_yaml_file(entry_path)
298 {
299 for obj in objects {
300 ctx.add_object(obj);
301 }
302 }
303 }
304 }
305
306 Ok(())
307}
308
309fn run_checks(ctx: &LintContextImpl, config: &KubelintConfig) -> LintResult {
311 use crate::analyzer::kubelint::templates;
312 use crate::analyzer::kubelint::types::CheckFailure;
313
314 let mut result = LintResult::new();
315
316 let all_checks = builtin_checks();
318
319 let mut available_checks: Vec<&CheckSpec> = all_checks.iter().collect();
321 for custom in &config.custom_checks {
322 available_checks.push(custom);
323 }
324
325 let checks_to_run = config.resolve_checks(&all_checks);
327
328 result.summary.objects_analyzed = ctx.objects().len();
329 result.summary.checks_run = checks_to_run.len();
330
331 let mut check_funcs: std::collections::HashMap<String, Box<dyn templates::CheckFunc>> =
333 std::collections::HashMap::new();
334
335 for check in &checks_to_run {
337 if let Some(template) = templates::get_template(&check.template) {
338 match template.instantiate(&check.params) {
339 Ok(func) => {
340 check_funcs.insert(check.name.clone(), func);
341 }
342 Err(e) => {
343 eprintln!(
345 "Warning: Failed to instantiate check '{}': {}",
346 check.name, e
347 );
348 }
349 }
350 }
351 }
352
353 for obj in ctx.objects() {
355 for check in &checks_to_run {
356 if !check.scope.object_kinds.matches(&obj.kind()) {
358 continue;
359 }
360
361 if should_ignore_check(obj, &check.name) {
363 continue;
364 }
365
366 if let Some(func) = check_funcs.get(&check.name) {
368 let diagnostics = func.check(obj);
369
370 for diag in diagnostics {
372 let mut failure = CheckFailure::new(
373 check.name.as_str(),
374 Severity::Warning, &diag.message,
376 &obj.metadata.file_path,
377 obj.name(),
378 obj.kind().as_str(),
379 );
380
381 if let Some(ns) = obj.namespace() {
382 failure = failure.with_namespace(ns);
383 }
384
385 if let Some(line) = obj.metadata.line_number {
386 failure = failure.with_line(line);
387 }
388
389 if let Some(remediation) = diag.remediation {
390 failure = failure.with_remediation(remediation);
391 }
392
393 result.failures.push(failure);
394 }
395 }
396 }
397 }
398
399 result.filter_by_threshold(config.failure_threshold);
401
402 result.sort();
404
405 result.summary.passed = !result.should_fail(config);
407
408 result
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414
415 #[test]
416 fn test_lint_result_new() {
417 let result = LintResult::new();
418 assert!(result.failures.is_empty());
419 assert!(result.parse_errors.is_empty());
420 assert!(result.summary.passed);
421 }
422
423 #[test]
424 fn test_lint_content_empty() {
425 let result = lint_content("", &KubelintConfig::default());
426 assert!(result.failures.is_empty());
427 }
428
429 #[test]
430 fn test_should_fail() {
431 let mut result = LintResult::new();
432 result.failures.push(CheckFailure::new(
433 "test-check",
434 Severity::Warning,
435 "test message",
436 "test.yaml",
437 "test-obj",
438 "Deployment",
439 ));
440
441 let config = KubelintConfig::default().with_threshold(Severity::Warning);
442 assert!(result.should_fail(&config));
443
444 let config = KubelintConfig::default().with_threshold(Severity::Error);
445 assert!(!result.should_fail(&config));
446
447 let mut no_fail_config = KubelintConfig::default();
448 no_fail_config.no_fail = true;
449 assert!(!result.should_fail(&no_fail_config));
450 }
451
452 #[test]
453 fn test_lint_real_file() {
454 let test_file = std::path::Path::new("test-lint/k8s/insecure-deployment.yaml");
456 if !test_file.exists() {
457 eprintln!("Test file not found, skipping: {:?}", test_file);
458 return;
459 }
460
461 let content = std::fs::read_to_string(test_file).unwrap();
463 println!("=== File Content ===\n{}\n", content);
464
465 let config = KubelintConfig::default().with_all_builtin();
467 println!("=== Config ===");
468 println!("add_all_builtin: {}", config.add_all_builtin);
469
470 let result_content = lint_content(&content, &config);
472 println!("\n=== Lint Content Result ===");
473 println!(
474 "Objects analyzed: {}",
475 result_content.summary.objects_analyzed
476 );
477 println!("Checks run: {}", result_content.summary.checks_run);
478 println!("Failures: {}", result_content.failures.len());
479 for f in &result_content.failures {
480 println!(" - {} [{:?}]: {}", f.code, f.severity, f.message);
481 }
482 for e in &result_content.parse_errors {
483 println!(" Parse error: {}", e);
484 }
485
486 let result_file = lint_file(test_file, &config);
488 println!("\n=== Lint File Result ===");
489 println!("Objects analyzed: {}", result_file.summary.objects_analyzed);
490 println!("Checks run: {}", result_file.summary.checks_run);
491 println!("Failures: {}", result_file.failures.len());
492 for f in &result_file.failures {
493 println!(" - {} [{:?}]: {}", f.code, f.severity, f.message);
494 }
495 for e in &result_file.parse_errors {
496 println!(" Parse error: {}", e);
497 }
498
499 assert!(
501 result_content.has_failures() || result_file.has_failures(),
502 "Expected to find security issues in the test file!"
503 );
504 }
505
506 #[test]
507 fn test_lint_content_finds_issues() {
508 let yaml = r#"
510apiVersion: apps/v1
511kind: Deployment
512metadata:
513 name: insecure-deploy
514spec:
515 replicas: 1
516 selector:
517 matchLabels:
518 app: test
519 template:
520 spec:
521 containers:
522 - name: nginx
523 image: nginx:latest
524 securityContext:
525 privileged: true
526"#;
527 let config = KubelintConfig::default().with_all_builtin();
529 let result = lint_content(yaml, &config);
530
531 assert!(
533 result.has_failures(),
534 "Expected linting failures for insecure deployment"
535 );
536
537 let privileged_failures: Vec<_> = result
539 .failures
540 .iter()
541 .filter(|f| f.code.as_str() == "privileged-container")
542 .collect();
543 assert!(
544 !privileged_failures.is_empty(),
545 "Should detect privileged container"
546 );
547
548 let latest_tag_failures: Vec<_> = result
550 .failures
551 .iter()
552 .filter(|f| f.code.as_str() == "latest-tag")
553 .collect();
554 assert!(!latest_tag_failures.is_empty(), "Should detect latest tag");
555 }
556
557 #[test]
558 fn test_lint_content_secure_deployment() {
559 let yaml = r#"
561apiVersion: apps/v1
562kind: Deployment
563metadata:
564 name: secure-deploy
565spec:
566 replicas: 1
567 selector:
568 matchLabels:
569 app: test
570 template:
571 spec:
572 serviceAccountName: my-service-account
573 securityContext:
574 runAsNonRoot: true
575 containers:
576 - name: nginx
577 image: nginx:1.21.0
578 securityContext:
579 privileged: false
580 allowPrivilegeEscalation: false
581 readOnlyRootFilesystem: true
582 capabilities:
583 drop:
584 - ALL
585 resources:
586 requests:
587 cpu: 100m
588 memory: 128Mi
589 limits:
590 cpu: 200m
591 memory: 256Mi
592 livenessProbe:
593 httpGet:
594 path: /healthz
595 port: 8080
596 readinessProbe:
597 httpGet:
598 path: /ready
599 port: 8080
600"#;
601 let config = KubelintConfig::default()
603 .include("privileged-container")
604 .include("latest-tag");
605
606 let result = lint_content(yaml, &config);
607
608 let critical_failures: Vec<_> = result
610 .failures
611 .iter()
612 .filter(|f| {
613 f.code.as_str() == "privileged-container" || f.code.as_str() == "latest-tag"
614 })
615 .collect();
616 assert!(
617 critical_failures.is_empty(),
618 "Secure deployment should not have privileged/latest-tag failures: {:?}",
619 critical_failures
620 );
621 }
622
623 #[test]
624 fn test_lint_content_with_ignore_annotation() {
625 let yaml = r#"
627apiVersion: apps/v1
628kind: Deployment
629metadata:
630 name: ignored-deploy
631 annotations:
632 ignore-check.kube-linter.io/privileged-container: "intentionally privileged"
633spec:
634 replicas: 1
635 selector:
636 matchLabels:
637 app: test
638 template:
639 spec:
640 containers:
641 - name: nginx
642 image: nginx:1.21.0
643 securityContext:
644 privileged: true
645"#;
646 let config = KubelintConfig::default().include("privileged-container");
647 let result = lint_content(yaml, &config);
648
649 let privileged_failures: Vec<_> = result
651 .failures
652 .iter()
653 .filter(|f| f.code.as_str() == "privileged-container")
654 .collect();
655 assert!(
656 privileged_failures.is_empty(),
657 "Ignored check should not produce failures"
658 );
659 }
660}