syncable_cli/agent/tools/
kubelint.rs

1//! Kubelint tool - Native Kubernetes manifest linting using Rig's Tool trait
2//!
3//! Lints **rendered Kubernetes manifests** for security and best practices.
4//! Works on raw YAML files, Helm charts (renders them), and Kustomize directories.
5//!
6//! **Use this for:** Security issues, K8s resource best practices, RBAC, probes, resource limits.
7//! **Use HelmlintTool for:** Helm chart structure, template syntax, Chart.yaml validation.
8//!
9//! Output is optimized for AI agent decision-making with:
10//! - Categorized issues (security, best-practice, validation, rbac)
11//! - Priority rankings (critical, high, medium, low)
12//! - Actionable remediation recommendations
13
14use super::compression::{CompressionConfig, compress_tool_output};
15use super::error::{ErrorCategory, format_error_for_llm};
16use rig::completion::ToolDefinition;
17use rig::tool::Tool;
18use serde::{Deserialize, Serialize};
19use serde_json::json;
20use std::path::PathBuf;
21
22use crate::analyzer::kubelint::{
23    KubelintConfig, LintResult, Severity, lint, lint_content, lint_file,
24};
25
26/// Arguments for the kubelint tool
27#[derive(Debug, Deserialize)]
28pub struct KubelintArgs {
29    /// Path to K8s manifest file or directory (relative to project root)
30    /// Can be: YAML file, directory with YAMLs, Helm chart dir, Kustomize dir
31    #[serde(default)]
32    pub path: Option<String>,
33
34    /// Inline YAML content to lint (alternative to path)
35    #[serde(default)]
36    pub content: Option<String>,
37
38    /// Checks to include (if empty, uses defaults)
39    #[serde(default)]
40    pub include: Vec<String>,
41
42    /// Checks to exclude
43    #[serde(default)]
44    pub exclude: Vec<String>,
45
46    /// Minimum severity threshold: "error", "warning", "info"
47    #[serde(default)]
48    pub threshold: Option<String>,
49}
50
51/// Error type for kubelint tool
52#[derive(Debug, thiserror::Error)]
53#[error("Kubelint error: {0}")]
54pub struct KubelintError(String);
55
56/// Tool to lint Kubernetes manifests natively
57///
58/// **When to use:**
59/// - Checking security issues (privileged containers, missing probes, etc.)
60/// - Validating K8s resource best practices
61/// - RBAC configuration validation
62/// - Resource limits and requests checking
63///
64/// **When to use HelmlintTool instead:**
65/// - Helm chart structure validation (Chart.yaml, values.yaml)
66/// - Go template syntax checking
67/// - Helm-specific best practices
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct KubelintTool {
70    project_path: PathBuf,
71}
72
73impl KubelintTool {
74    pub fn new(project_path: PathBuf) -> Self {
75        Self { project_path }
76    }
77
78    fn parse_threshold(threshold: &str) -> Severity {
79        match threshold.to_lowercase().as_str() {
80            "error" => Severity::Error,
81            "warning" => Severity::Warning,
82            "info" => Severity::Info,
83            _ => Severity::Warning,
84        }
85    }
86
87    /// Get category for a check code
88    fn get_check_category(code: &str) -> &'static str {
89        match code {
90            // Security checks
91            "privileged-container"
92            | "privilege-escalation"
93            | "run-as-non-root"
94            | "read-only-root-fs"
95            | "drop-net-raw-capability"
96            | "hostnetwork"
97            | "hostpid"
98            | "hostipc"
99            | "host-mounts"
100            | "writable-host-mount"
101            | "docker-sock"
102            | "unsafe-proc-mount"
103            | "scc-deny-privileged-container" => "security",
104
105            // Best practice checks
106            "latest-tag"
107            | "no-liveness-probe"
108            | "no-readiness-probe"
109            | "unset-cpu-requirements"
110            | "unset-memory-requirements"
111            | "minimum-replicas"
112            | "no-anti-affinity"
113            | "no-rolling-update-strategy"
114            | "default-service-account"
115            | "deprecated-service-account"
116            | "env-var-secret"
117            | "read-secret-from-env-var"
118            | "priority-class-name"
119            | "no-node-affinity"
120            | "restart-policy"
121            | "sysctls"
122            | "dnsconfig-options" => "best-practice",
123
124            // RBAC checks
125            "access-to-secrets"
126            | "access-to-create-pods"
127            | "cluster-admin-role-binding"
128            | "wildcard-in-rules" => "rbac",
129
130            // Validation checks
131            "dangling-service"
132            | "dangling-ingress"
133            | "dangling-horizontalpodautoscaler"
134            | "dangling-networkpolicy"
135            | "mismatching-selector"
136            | "duplicate-env-var"
137            | "invalid-target-ports"
138            | "non-existent-service-account"
139            | "non-isolated-pod"
140            | "use-namespace"
141            | "env-var-value-from"
142            | "job-ttl-seconds-after-finished" => "validation",
143
144            // Port checks
145            "ssh-port" | "privileged-ports" | "liveness-port" | "readiness-port"
146            | "startup-port" => "ports",
147
148            // PDB checks
149            "pdb-max-unavailable" | "pdb-min-available" | "pdb-unhealthy-pod-eviction-policy" => {
150                "disruption-budget"
151            }
152
153            // HPA checks
154            "hpa-minimum-replicas" => "autoscaling",
155
156            // Deprecated API checks
157            "no-extensions-v1beta" => "deprecated-api",
158
159            // Service checks
160            "service-type" => "service",
161
162            _ => "other",
163        }
164    }
165
166    /// Get priority based on severity and check code
167    fn get_priority(severity: Severity, code: &str) -> &'static str {
168        let category = Self::get_check_category(code);
169        match (severity, category) {
170            (Severity::Error, "security") => "critical",
171            (Severity::Error, "rbac") => "critical",
172            (Severity::Error, _) => "high",
173            (Severity::Warning, "security") => "high",
174            (Severity::Warning, "rbac") => "high",
175            (Severity::Warning, "validation") => "medium",
176            (Severity::Warning, "best-practice") => "medium",
177            (Severity::Warning, _) => "medium",
178            (Severity::Info, _) => "low",
179        }
180    }
181
182    /// Format result optimized for agent decision-making
183    fn format_result(result: &LintResult, source: &str) -> String {
184        // Categorize and enrich failures
185        let enriched_failures: Vec<serde_json::Value> = result
186            .failures
187            .iter()
188            .map(|f| {
189                let code = f.code.as_str();
190                let category = Self::get_check_category(code);
191                let priority = Self::get_priority(f.severity, code);
192
193                json!({
194                    "check": code,
195                    "severity": format!("{:?}", f.severity).to_lowercase(),
196                    "priority": priority,
197                    "category": category,
198                    "message": f.message,
199                    "object": {
200                        "name": f.object_name,
201                        "kind": f.object_kind,
202                        "namespace": f.object_namespace,
203                    },
204                    "file": f.file_path.display().to_string(),
205                    "line": f.line,
206                    "remediation": f.remediation,
207                })
208            })
209            .collect();
210
211        // Group by priority
212        let critical: Vec<_> = enriched_failures
213            .iter()
214            .filter(|f| f["priority"] == "critical")
215            .cloned()
216            .collect();
217        let high: Vec<_> = enriched_failures
218            .iter()
219            .filter(|f| f["priority"] == "high")
220            .cloned()
221            .collect();
222        let medium: Vec<_> = enriched_failures
223            .iter()
224            .filter(|f| f["priority"] == "medium")
225            .cloned()
226            .collect();
227        let low: Vec<_> = enriched_failures
228            .iter()
229            .filter(|f| f["priority"] == "low")
230            .cloned()
231            .collect();
232
233        // Group by category
234        let mut by_category: std::collections::HashMap<&str, usize> =
235            std::collections::HashMap::new();
236        for f in &result.failures {
237            let cat = Self::get_check_category(f.code.as_str());
238            *by_category.entry(cat).or_default() += 1;
239        }
240
241        // Build decision context
242        let decision_context = if critical.is_empty() && high.is_empty() {
243            if medium.is_empty() && low.is_empty() {
244                "Kubernetes manifests follow security best practices. No issues found."
245            } else if medium.is_empty() {
246                "Minor improvements possible. Low priority issues only."
247            } else {
248                "Good baseline. Medium priority improvements recommended."
249            }
250        } else if !critical.is_empty() {
251            "CRITICAL security issues found. Fix before deployment to production."
252        } else {
253            "High priority issues found. Review security and best practice violations."
254        };
255
256        // Build agent-optimized output
257        let mut output = json!({
258            "source": source,
259            "success": result.summary.passed,
260            "decision_context": decision_context,
261            "tool_guidance": "Use kubelint for K8s manifest security/best practices. Use helmlint for Helm chart structure/template syntax.",
262            "summary": {
263                "total_issues": result.failures.len(),
264                "objects_analyzed": result.summary.objects_analyzed,
265                "checks_run": result.summary.checks_run,
266                "by_priority": {
267                    "critical": critical.len(),
268                    "high": high.len(),
269                    "medium": medium.len(),
270                    "low": low.len(),
271                },
272                "by_category": by_category,
273            },
274            "action_plan": {
275                "critical": critical,
276                "high": high,
277                "medium": medium,
278                "low": low,
279            },
280        });
281
282        // Add quick fixes summary
283        if !enriched_failures.is_empty() {
284            let quick_fixes: Vec<String> = enriched_failures
285                .iter()
286                .filter(|f| f["priority"] == "critical" || f["priority"] == "high")
287                .take(5)
288                .map(|f| {
289                    let remediation = f["remediation"]
290                        .as_str()
291                        .unwrap_or("Review the check documentation.");
292                    format!(
293                        "{}/{}: {} - {}",
294                        f["object"]["kind"].as_str().unwrap_or(""),
295                        f["object"]["name"].as_str().unwrap_or(""),
296                        f["check"].as_str().unwrap_or(""),
297                        remediation
298                    )
299                })
300                .collect();
301
302            if !quick_fixes.is_empty() {
303                output["quick_fixes"] = json!(quick_fixes);
304            }
305        }
306
307        if !result.parse_errors.is_empty() {
308            output["parse_errors"] = json!(result.parse_errors);
309        }
310
311        // Use smart compression with RAG retrieval pattern
312        // This preserves all data while keeping context size manageable
313        let config = CompressionConfig::default();
314        compress_tool_output(&output, "kubelint", &config)
315    }
316}
317
318impl Tool for KubelintTool {
319    const NAME: &'static str = "kubelint";
320
321    type Error = KubelintError;
322    type Args = KubelintArgs;
323    type Output = String;
324
325    async fn definition(&self, _prompt: String) -> ToolDefinition {
326        ToolDefinition {
327            name: Self::NAME.to_string(),
328            description: "Native Kubernetes manifest linting for SECURITY and BEST PRACTICES.
329
330Analyzes rendered K8s manifests (YAML files, Helm charts, Kustomize) for:
331- **Security**: privileged containers, privilege escalation, host access, capabilities
332- **Resources**: missing limits/requests, missing probes (liveness/readiness)
333- **RBAC**: overprivileged roles, cluster-admin bindings, wildcard permissions
334- **Best Practice**: latest tag, missing labels, deprecated APIs, service accounts
335
336**Use kubelint for:** Security analysis of deployed/rendered Kubernetes resources.
337**Use helmlint for:** Helm chart structure, template syntax, Chart.yaml validation.
338
339**Parameters:**
340- path: K8s manifest file, directory, Helm chart dir, or Kustomize dir
341- content: Inline YAML to lint (alternative to path)
342- include: Run only specific checks (e.g., ['privileged-container'])
343- exclude: Skip specific checks (e.g., ['minimum-replicas'])
344- threshold: Minimum severity to report ('error', 'warning', 'info')
345
346**Output:** Issues categorized by priority (critical/high/medium/low) with remediation steps.
347Large outputs are compressed with retrieval_id - use retrieve_output for full details."
348                .to_string(),
349            parameters: json!({
350                "type": "object",
351                "properties": {
352                    "path": {
353                        "type": "string",
354                        "description": "Path to K8s manifest(s) relative to project root. Can be: \
355                            single YAML file, directory with YAMLs, Helm chart directory, or Kustomize directory."
356                    },
357                    "content": {
358                        "type": "string",
359                        "description": "Inline YAML content to lint. Use this to validate generated manifests before writing."
360                    },
361                    "include": {
362                        "type": "array",
363                        "items": { "type": "string" },
364                        "description": "Specific checks to run (e.g., ['privileged-container', 'latest-tag']). If empty, runs all default checks."
365                    },
366                    "exclude": {
367                        "type": "array",
368                        "items": { "type": "string" },
369                        "description": "Checks to skip (e.g., ['no-liveness-probe', 'minimum-replicas'])"
370                    },
371                    "threshold": {
372                        "type": "string",
373                        "enum": ["error", "warning", "info"],
374                        "description": "Minimum severity to report. Default is 'warning'."
375                    }
376                }
377            }),
378        }
379    }
380
381    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
382        // Build configuration
383        let mut config = KubelintConfig::default().with_all_builtin();
384
385        // Apply includes
386        for check in &args.include {
387            config = config.include(check.as_str());
388        }
389
390        // Apply excludes
391        for check in &args.exclude {
392            config = config.exclude(check.as_str());
393        }
394
395        // Apply threshold
396        if let Some(threshold) = &args.threshold {
397            config = config.with_threshold(Self::parse_threshold(threshold));
398        }
399
400        // Determine source and lint
401        // IMPORTANT: Treat empty content as None - this fixes the issue where
402        // AI agents pass empty strings and the tool lints nothing instead of the path
403        let (result, source) = if args.content.as_ref().is_some_and(|c| !c.trim().is_empty()) {
404            // Lint non-empty inline content
405            (
406                lint_content(args.content.as_ref().unwrap(), &config),
407                "<inline>".to_string(),
408            )
409        } else if let Some(path) = &args.path {
410            // Lint file or directory
411            let full_path = self.project_path.join(path);
412
413            if !full_path.exists() {
414                return Ok(format_error_for_llm(
415                    "kubelint",
416                    ErrorCategory::FileNotFound,
417                    &format!("Path '{}' does not exist", full_path.display()),
418                    Some(vec![
419                        "Check if the path is correct relative to project root",
420                        "Use list_directory to explore available paths",
421                        "Provide inline YAML via 'content' parameter instead",
422                    ]),
423                ));
424            }
425
426            if full_path.is_file() {
427                (lint_file(&full_path, &config), path.clone())
428            } else {
429                (lint(&full_path, &config), path.clone())
430            }
431        } else {
432            // Look for common K8s manifest locations
433            let candidates = [
434                "kubernetes",
435                "k8s",
436                "manifests",
437                "deploy",
438                "deployment",
439                "helm",
440                "charts",
441                "test-lint",     // For testing
442                "test-lint/k8s", // For testing
443                ".",
444            ];
445
446            let mut found = None;
447            for candidate in &candidates {
448                let candidate_path = self.project_path.join(candidate);
449                if candidate_path.exists() {
450                    // Check if it has YAML files or is a Helm/Kustomize directory
451                    if candidate_path.join("Chart.yaml").exists()
452                        || candidate_path.join("kustomization.yaml").exists()
453                        || candidate_path.join("kustomization.yml").exists()
454                    {
455                        found = Some((candidate_path, candidate.to_string()));
456                        break;
457                    }
458                    // Check for YAML files
459                    if let Ok(entries) = std::fs::read_dir(&candidate_path) {
460                        let has_yaml = entries.filter_map(|e| e.ok()).any(|e| {
461                            e.path()
462                                .extension()
463                                .map(|ext| ext == "yaml" || ext == "yml")
464                                .unwrap_or(false)
465                        });
466                        if has_yaml {
467                            found = Some((candidate_path, candidate.to_string()));
468                            break;
469                        }
470                    }
471                }
472            }
473
474            if let Some((path, name)) = found {
475                (lint(&path, &config), name)
476            } else {
477                return Ok(format_error_for_llm(
478                    "kubelint",
479                    ErrorCategory::ValidationFailed,
480                    "No valid Kubernetes manifests found",
481                    Some(vec![
482                        "Specify a path with 'path' parameter (e.g., 'k8s/', 'deployment.yaml')",
483                        "Provide inline YAML via 'content' parameter",
484                        "Ensure files have .yaml or .yml extension",
485                        "Files must have 'apiVersion' and 'kind' fields to be valid K8s manifests",
486                    ]),
487                ));
488            }
489        };
490
491        // Check for parse errors and empty results
492        if !result.parse_errors.is_empty() {
493            log::warn!("K8s manifest parse errors: {:?}", result.parse_errors);
494        }
495
496        // Handle edge case: no K8s objects found (empty dir, non-K8s YAML, or all parse errors)
497        if result.summary.objects_analyzed == 0 {
498            if !result.parse_errors.is_empty() {
499                // YAML parsing failed
500                return Ok(format_error_for_llm(
501                    "kubelint",
502                    ErrorCategory::ValidationFailed,
503                    "Failed to parse Kubernetes manifests",
504                    Some(vec![
505                        &format!("Parse errors: {}", result.parse_errors.join("; ")),
506                        "Check YAML syntax (proper indentation, valid structure)",
507                        "Ensure files contain valid Kubernetes manifests with 'apiVersion' and 'kind'",
508                        "Use helmlint for Helm chart template syntax issues",
509                    ]),
510                ));
511            } else {
512                // No K8s objects found (valid YAML but not K8s manifests, or empty directory)
513                return Ok(format_error_for_llm(
514                    "kubelint",
515                    ErrorCategory::ValidationFailed,
516                    &format!("No Kubernetes objects found in '{}'", source),
517                    Some(vec![
518                        "Directory may be empty or contain no .yaml/.yml files",
519                        "Files may be valid YAML but not Kubernetes manifests",
520                        "Kubernetes manifests require 'apiVersion' and 'kind' fields",
521                        "Try specifying a different path or use 'content' for inline YAML",
522                    ]),
523                ));
524            }
525        }
526
527        Ok(Self::format_result(&result, &source))
528    }
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534    use std::fs;
535    use tempfile::TempDir;
536
537    #[tokio::test]
538    async fn test_kubelint_inline_content() {
539        let temp_dir = TempDir::new().unwrap();
540        let tool = KubelintTool::new(temp_dir.path().to_path_buf());
541
542        let yaml = r#"
543apiVersion: apps/v1
544kind: Deployment
545metadata:
546  name: insecure-deploy
547spec:
548  replicas: 1
549  selector:
550    matchLabels:
551      app: test
552  template:
553    spec:
554      containers:
555      - name: nginx
556        image: nginx:latest
557        securityContext:
558          privileged: true
559"#;
560
561        let args = KubelintArgs {
562            path: None,
563            content: Some(yaml.to_string()),
564            include: vec!["privileged-container".to_string(), "latest-tag".to_string()],
565            exclude: vec![],
566            threshold: None,
567        };
568
569        let result = tool.call(args).await.unwrap();
570        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
571
572        // Should find issues
573        assert!(parsed["summary"]["total_issues"].as_u64().unwrap_or(0) > 0);
574        assert!(parsed["decision_context"].is_string());
575        assert!(parsed["tool_guidance"].is_string());
576    }
577
578    #[tokio::test]
579    async fn test_kubelint_secure_deployment() {
580        let temp_dir = TempDir::new().unwrap();
581        let tool = KubelintTool::new(temp_dir.path().to_path_buf());
582
583        let yaml = r#"
584apiVersion: apps/v1
585kind: Deployment
586metadata:
587  name: secure-deploy
588spec:
589  replicas: 3
590  selector:
591    matchLabels:
592      app: test
593  template:
594    spec:
595      serviceAccountName: my-service-account
596      securityContext:
597        runAsNonRoot: true
598      containers:
599      - name: nginx
600        image: nginx:1.25.0
601        securityContext:
602          privileged: false
603          allowPrivilegeEscalation: false
604          readOnlyRootFilesystem: true
605          capabilities:
606            drop:
607            - ALL
608"#;
609
610        let args = KubelintArgs {
611            path: None,
612            content: Some(yaml.to_string()),
613            include: vec!["privileged-container".to_string(), "latest-tag".to_string()],
614            exclude: vec![],
615            threshold: None,
616        };
617
618        let result = tool.call(args).await.unwrap();
619        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
620
621        // Should pass for privileged and latest-tag checks
622        let critical = parsed["summary"]["by_priority"]["critical"]
623            .as_u64()
624            .unwrap_or(99);
625        let high = parsed["summary"]["by_priority"]["high"]
626            .as_u64()
627            .unwrap_or(99);
628        assert_eq!(critical, 0);
629        assert_eq!(high, 0);
630    }
631
632    #[tokio::test]
633    async fn test_kubelint_file() {
634        let temp_dir = TempDir::new().unwrap();
635        let manifest_path = temp_dir.path().join("deployment.yaml");
636
637        fs::write(
638            &manifest_path,
639            r#"apiVersion: apps/v1
640kind: Deployment
641metadata:
642  name: test
643spec:
644  replicas: 1
645  selector:
646    matchLabels:
647      app: test
648  template:
649    spec:
650      containers:
651      - name: nginx
652        image: nginx:1.25.0
653"#,
654        )
655        .unwrap();
656
657        let tool = KubelintTool::new(temp_dir.path().to_path_buf());
658        let args = KubelintArgs {
659            path: Some("deployment.yaml".to_string()),
660            content: None,
661            include: vec![],
662            exclude: vec![],
663            threshold: None,
664        };
665
666        let result = tool.call(args).await.unwrap();
667        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
668
669        assert!(
670            parsed["source"]
671                .as_str()
672                .unwrap()
673                .contains("deployment.yaml")
674        );
675        assert!(parsed["summary"]["objects_analyzed"].as_u64().unwrap_or(0) >= 1);
676    }
677
678    #[tokio::test]
679    async fn test_kubelint_output_format() {
680        let temp_dir = TempDir::new().unwrap();
681        let tool = KubelintTool::new(temp_dir.path().to_path_buf());
682
683        let yaml = r#"
684apiVersion: apps/v1
685kind: Deployment
686metadata:
687  name: insecure-deploy
688spec:
689  replicas: 1
690  selector:
691    matchLabels:
692      app: test
693  template:
694    spec:
695      containers:
696      - name: nginx
697        image: nginx:latest
698        securityContext:
699          privileged: true
700"#;
701
702        let args = KubelintArgs {
703            path: None,
704            content: Some(yaml.to_string()),
705            include: vec![], // Use all defaults + builtin
706            exclude: vec![],
707            threshold: None,
708        };
709
710        let result = tool.call(args).await.unwrap();
711        println!("\n=== KUBELINT OUTPUT ===\n{}\n", result);
712
713        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
714
715        // Verify structure
716        assert!(
717            parsed["summary"]["total_issues"].as_u64().unwrap() > 0,
718            "Expected issues but got none. Output: {}",
719            result
720        );
721        assert!(
722            !parsed["action_plan"]["critical"]
723                .as_array()
724                .unwrap()
725                .is_empty()
726                || !parsed["action_plan"]["high"].as_array().unwrap().is_empty(),
727            "Expected critical or high priority issues"
728        );
729    }
730
731    #[tokio::test]
732    async fn test_kubelint_excludes() {
733        let temp_dir = TempDir::new().unwrap();
734        let tool = KubelintTool::new(temp_dir.path().to_path_buf());
735
736        let yaml = r#"
737apiVersion: apps/v1
738kind: Deployment
739metadata:
740  name: test
741spec:
742  replicas: 1
743  selector:
744    matchLabels:
745      app: test
746  template:
747    spec:
748      containers:
749      - name: nginx
750        image: nginx:latest
751        securityContext:
752          privileged: true
753"#;
754
755        let args = KubelintArgs {
756            path: None,
757            content: Some(yaml.to_string()),
758            include: vec![],
759            exclude: vec!["privileged-container".to_string(), "latest-tag".to_string()],
760            threshold: None,
761        };
762
763        let result = tool.call(args).await.unwrap();
764        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
765
766        // Excluded checks should not appear
767        let all_issues: Vec<_> = ["critical", "high", "medium", "low"]
768            .iter()
769            .flat_map(|p| {
770                parsed["action_plan"][p]
771                    .as_array()
772                    .cloned()
773                    .unwrap_or_default()
774            })
775            .collect();
776
777        assert!(
778            !all_issues
779                .iter()
780                .any(|i| i["check"] == "privileged-container")
781        );
782        assert!(!all_issues.iter().any(|i| i["check"] == "latest-tag"));
783    }
784
785    #[test]
786    fn test_parse_threshold() {
787        assert_eq!(KubelintTool::parse_threshold("error"), Severity::Error);
788        assert_eq!(KubelintTool::parse_threshold("warning"), Severity::Warning);
789        assert_eq!(KubelintTool::parse_threshold("info"), Severity::Info);
790        // Case insensitive
791        assert_eq!(KubelintTool::parse_threshold("ERROR"), Severity::Error);
792        assert_eq!(KubelintTool::parse_threshold("Warning"), Severity::Warning);
793        // Invalid defaults to Warning
794        assert_eq!(KubelintTool::parse_threshold("invalid"), Severity::Warning);
795        assert_eq!(KubelintTool::parse_threshold(""), Severity::Warning);
796    }
797
798    #[test]
799    fn test_get_check_category() {
800        // Security checks
801        assert_eq!(
802            KubelintTool::get_check_category("privileged-container"),
803            "security"
804        );
805        assert_eq!(
806            KubelintTool::get_check_category("run-as-non-root"),
807            "security"
808        );
809        assert_eq!(KubelintTool::get_check_category("hostnetwork"), "security");
810        assert_eq!(KubelintTool::get_check_category("hostpid"), "security");
811        assert_eq!(
812            KubelintTool::get_check_category("privilege-escalation"),
813            "security"
814        );
815        assert_eq!(
816            KubelintTool::get_check_category("read-only-root-fs"),
817            "security"
818        );
819
820        // Best practice checks
821        assert_eq!(
822            KubelintTool::get_check_category("latest-tag"),
823            "best-practice"
824        );
825        assert_eq!(
826            KubelintTool::get_check_category("no-liveness-probe"),
827            "best-practice"
828        );
829        assert_eq!(
830            KubelintTool::get_check_category("unset-cpu-requirements"),
831            "best-practice"
832        );
833
834        // RBAC checks
835        assert_eq!(
836            KubelintTool::get_check_category("access-to-secrets"),
837            "rbac"
838        );
839        assert_eq!(
840            KubelintTool::get_check_category("cluster-admin-role-binding"),
841            "rbac"
842        );
843        assert_eq!(
844            KubelintTool::get_check_category("wildcard-in-rules"),
845            "rbac"
846        );
847
848        // Validation checks
849        assert_eq!(
850            KubelintTool::get_check_category("dangling-service"),
851            "validation"
852        );
853        assert_eq!(
854            KubelintTool::get_check_category("duplicate-env-var"),
855            "validation"
856        );
857
858        // Port checks
859        assert_eq!(KubelintTool::get_check_category("ssh-port"), "ports");
860        assert_eq!(
861            KubelintTool::get_check_category("privileged-ports"),
862            "ports"
863        );
864
865        // Disruption budget checks
866        assert_eq!(
867            KubelintTool::get_check_category("pdb-max-unavailable"),
868            "disruption-budget"
869        );
870
871        // Autoscaling checks
872        assert_eq!(
873            KubelintTool::get_check_category("hpa-minimum-replicas"),
874            "autoscaling"
875        );
876
877        // Deprecated API checks
878        assert_eq!(
879            KubelintTool::get_check_category("no-extensions-v1beta"),
880            "deprecated-api"
881        );
882
883        // Service checks
884        assert_eq!(KubelintTool::get_check_category("service-type"), "service");
885
886        // Unknown checks default to "other"
887        assert_eq!(KubelintTool::get_check_category("unknown-check"), "other");
888    }
889
890    #[test]
891    fn test_get_priority() {
892        // Critical: Error severity + security/rbac
893        assert_eq!(
894            KubelintTool::get_priority(Severity::Error, "privileged-container"),
895            "critical"
896        );
897        assert_eq!(
898            KubelintTool::get_priority(Severity::Error, "access-to-secrets"),
899            "critical"
900        );
901
902        // High: Error severity + other categories
903        assert_eq!(
904            KubelintTool::get_priority(Severity::Error, "latest-tag"),
905            "high"
906        );
907        assert_eq!(
908            KubelintTool::get_priority(Severity::Error, "dangling-service"),
909            "high"
910        );
911
912        // High: Warning severity + security/rbac
913        assert_eq!(
914            KubelintTool::get_priority(Severity::Warning, "run-as-non-root"),
915            "high"
916        );
917        assert_eq!(
918            KubelintTool::get_priority(Severity::Warning, "wildcard-in-rules"),
919            "high"
920        );
921
922        // Medium: Warning severity + validation/best-practice
923        assert_eq!(
924            KubelintTool::get_priority(Severity::Warning, "duplicate-env-var"),
925            "medium"
926        );
927        assert_eq!(
928            KubelintTool::get_priority(Severity::Warning, "no-liveness-probe"),
929            "medium"
930        );
931        assert_eq!(
932            KubelintTool::get_priority(Severity::Warning, "ssh-port"),
933            "medium"
934        );
935
936        // Low: Info severity
937        assert_eq!(
938            KubelintTool::get_priority(Severity::Info, "privileged-container"),
939            "low"
940        );
941        assert_eq!(
942            KubelintTool::get_priority(Severity::Info, "latest-tag"),
943            "low"
944        );
945    }
946}