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 match yaml::parse_yaml_dir(path) {
223 Ok(objects) => {
224 for obj in objects {
225 ctx.add_object(obj);
226 }
227 }
228 Err(err) => return Err(format!("Failed to parse YAML directory: {}", err)),
229 }
230 } else {
231 match yaml::parse_yaml_file(path) {
233 Ok(objects) => {
234 for obj in objects {
235 ctx.add_object(obj);
236 }
237 }
238 Err(err) => return Err(format!("Failed to parse YAML file: {}", err)),
239 }
240 }
241
242 Ok((ctx, warning))
243}
244
245fn run_checks(ctx: &LintContextImpl, config: &KubelintConfig) -> LintResult {
247 use crate::analyzer::kubelint::templates;
248 use crate::analyzer::kubelint::types::CheckFailure;
249
250 let mut result = LintResult::new();
251
252 let all_checks = builtin_checks();
254
255 let mut available_checks: Vec<&CheckSpec> = all_checks.iter().collect();
257 for custom in &config.custom_checks {
258 available_checks.push(custom);
259 }
260
261 let checks_to_run = config.resolve_checks(&all_checks);
263
264 result.summary.objects_analyzed = ctx.objects().len();
265 result.summary.checks_run = checks_to_run.len();
266
267 let mut check_funcs: std::collections::HashMap<String, Box<dyn templates::CheckFunc>> =
269 std::collections::HashMap::new();
270
271 for check in &checks_to_run {
273 if let Some(template) = templates::get_template(&check.template) {
274 match template.instantiate(&check.params) {
275 Ok(func) => {
276 check_funcs.insert(check.name.clone(), func);
277 }
278 Err(e) => {
279 eprintln!(
281 "Warning: Failed to instantiate check '{}': {}",
282 check.name, e
283 );
284 }
285 }
286 }
287 }
288
289 for obj in ctx.objects() {
291 for check in &checks_to_run {
292 if !check.scope.object_kinds.matches(&obj.kind()) {
294 continue;
295 }
296
297 if should_ignore_check(obj, &check.name) {
299 continue;
300 }
301
302 if let Some(func) = check_funcs.get(&check.name) {
304 let diagnostics = func.check(obj);
305
306 for diag in diagnostics {
308 let mut failure = CheckFailure::new(
309 check.name.as_str(),
310 Severity::Warning, &diag.message,
312 &obj.metadata.file_path,
313 obj.name(),
314 obj.kind().as_str(),
315 );
316
317 if let Some(ns) = obj.namespace() {
318 failure = failure.with_namespace(ns);
319 }
320
321 if let Some(line) = obj.metadata.line_number {
322 failure = failure.with_line(line);
323 }
324
325 if let Some(remediation) = diag.remediation {
326 failure = failure.with_remediation(remediation);
327 }
328
329 result.failures.push(failure);
330 }
331 }
332 }
333 }
334
335 result.filter_by_threshold(config.failure_threshold);
337
338 result.sort();
340
341 result.summary.passed = !result.should_fail(config);
343
344 result
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350
351 #[test]
352 fn test_lint_result_new() {
353 let result = LintResult::new();
354 assert!(result.failures.is_empty());
355 assert!(result.parse_errors.is_empty());
356 assert!(result.summary.passed);
357 }
358
359 #[test]
360 fn test_lint_content_empty() {
361 let result = lint_content("", &KubelintConfig::default());
362 assert!(result.failures.is_empty());
363 }
364
365 #[test]
366 fn test_should_fail() {
367 let mut result = LintResult::new();
368 result.failures.push(CheckFailure::new(
369 "test-check",
370 Severity::Warning,
371 "test message",
372 "test.yaml",
373 "test-obj",
374 "Deployment",
375 ));
376
377 let config = KubelintConfig::default().with_threshold(Severity::Warning);
378 assert!(result.should_fail(&config));
379
380 let config = KubelintConfig::default().with_threshold(Severity::Error);
381 assert!(!result.should_fail(&config));
382
383 let mut no_fail_config = KubelintConfig::default();
384 no_fail_config.no_fail = true;
385 assert!(!result.should_fail(&no_fail_config));
386 }
387
388 #[test]
389 fn test_lint_real_file() {
390 let test_file = std::path::Path::new("test-lint/k8s/insecure-deployment.yaml");
392 if !test_file.exists() {
393 eprintln!("Test file not found, skipping: {:?}", test_file);
394 return;
395 }
396
397 let content = std::fs::read_to_string(test_file).unwrap();
399 println!("=== File Content ===\n{}\n", content);
400
401 let config = KubelintConfig::default().with_all_builtin();
403 println!("=== Config ===");
404 println!("add_all_builtin: {}", config.add_all_builtin);
405
406 let result_content = lint_content(&content, &config);
408 println!("\n=== Lint Content Result ===");
409 println!(
410 "Objects analyzed: {}",
411 result_content.summary.objects_analyzed
412 );
413 println!("Checks run: {}", result_content.summary.checks_run);
414 println!("Failures: {}", result_content.failures.len());
415 for f in &result_content.failures {
416 println!(" - {} [{:?}]: {}", f.code, f.severity, f.message);
417 }
418 for e in &result_content.parse_errors {
419 println!(" Parse error: {}", e);
420 }
421
422 let result_file = lint_file(test_file, &config);
424 println!("\n=== Lint File Result ===");
425 println!("Objects analyzed: {}", result_file.summary.objects_analyzed);
426 println!("Checks run: {}", result_file.summary.checks_run);
427 println!("Failures: {}", result_file.failures.len());
428 for f in &result_file.failures {
429 println!(" - {} [{:?}]: {}", f.code, f.severity, f.message);
430 }
431 for e in &result_file.parse_errors {
432 println!(" Parse error: {}", e);
433 }
434
435 assert!(
437 result_content.has_failures() || result_file.has_failures(),
438 "Expected to find security issues in the test file!"
439 );
440 }
441
442 #[test]
443 fn test_lint_content_finds_issues() {
444 let yaml = r#"
446apiVersion: apps/v1
447kind: Deployment
448metadata:
449 name: insecure-deploy
450spec:
451 replicas: 1
452 selector:
453 matchLabels:
454 app: test
455 template:
456 spec:
457 containers:
458 - name: nginx
459 image: nginx:latest
460 securityContext:
461 privileged: true
462"#;
463 let config = KubelintConfig::default().with_all_builtin();
465 let result = lint_content(yaml, &config);
466
467 assert!(
469 result.has_failures(),
470 "Expected linting failures for insecure deployment"
471 );
472
473 let privileged_failures: Vec<_> = result
475 .failures
476 .iter()
477 .filter(|f| f.code.as_str() == "privileged-container")
478 .collect();
479 assert!(
480 !privileged_failures.is_empty(),
481 "Should detect privileged container"
482 );
483
484 let latest_tag_failures: Vec<_> = result
486 .failures
487 .iter()
488 .filter(|f| f.code.as_str() == "latest-tag")
489 .collect();
490 assert!(!latest_tag_failures.is_empty(), "Should detect latest tag");
491 }
492
493 #[test]
494 fn test_lint_content_secure_deployment() {
495 let yaml = r#"
497apiVersion: apps/v1
498kind: Deployment
499metadata:
500 name: secure-deploy
501spec:
502 replicas: 1
503 selector:
504 matchLabels:
505 app: test
506 template:
507 spec:
508 serviceAccountName: my-service-account
509 securityContext:
510 runAsNonRoot: true
511 containers:
512 - name: nginx
513 image: nginx:1.21.0
514 securityContext:
515 privileged: false
516 allowPrivilegeEscalation: false
517 readOnlyRootFilesystem: true
518 capabilities:
519 drop:
520 - ALL
521 resources:
522 requests:
523 cpu: 100m
524 memory: 128Mi
525 limits:
526 cpu: 200m
527 memory: 256Mi
528 livenessProbe:
529 httpGet:
530 path: /healthz
531 port: 8080
532 readinessProbe:
533 httpGet:
534 path: /ready
535 port: 8080
536"#;
537 let config = KubelintConfig::default()
539 .include("privileged-container")
540 .include("latest-tag");
541
542 let result = lint_content(yaml, &config);
543
544 let critical_failures: Vec<_> = result
546 .failures
547 .iter()
548 .filter(|f| {
549 f.code.as_str() == "privileged-container" || f.code.as_str() == "latest-tag"
550 })
551 .collect();
552 assert!(
553 critical_failures.is_empty(),
554 "Secure deployment should not have privileged/latest-tag failures: {:?}",
555 critical_failures
556 );
557 }
558
559 #[test]
560 fn test_lint_content_with_ignore_annotation() {
561 let yaml = r#"
563apiVersion: apps/v1
564kind: Deployment
565metadata:
566 name: ignored-deploy
567 annotations:
568 ignore-check.kube-linter.io/privileged-container: "intentionally privileged"
569spec:
570 replicas: 1
571 selector:
572 matchLabels:
573 app: test
574 template:
575 spec:
576 containers:
577 - name: nginx
578 image: nginx:1.21.0
579 securityContext:
580 privileged: true
581"#;
582 let config = KubelintConfig::default().include("privileged-container");
583 let result = lint_content(yaml, &config);
584
585 let privileged_failures: Vec<_> = result
587 .failures
588 .iter()
589 .filter(|f| f.code.as_str() == "privileged-container")
590 .collect();
591 assert!(
592 privileged_failures.is_empty(),
593 "Ignored check should not produce failures"
594 );
595 }
596}