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 rig::completion::ToolDefinition;
15use rig::tool::Tool;
16use serde::{Deserialize, Serialize};
17use serde_json::json;
18use std::path::PathBuf;
19
20use crate::analyzer::kubelint::{
21    KubelintConfig, LintResult, Severity, lint, lint_content, lint_file,
22};
23
24/// Arguments for the kubelint tool
25#[derive(Debug, Deserialize)]
26pub struct KubelintArgs {
27    /// Path to K8s manifest file or directory (relative to project root)
28    /// Can be: YAML file, directory with YAMLs, Helm chart dir, Kustomize dir
29    #[serde(default)]
30    pub path: Option<String>,
31
32    /// Inline YAML content to lint (alternative to path)
33    #[serde(default)]
34    pub content: Option<String>,
35
36    /// Checks to include (if empty, uses defaults)
37    #[serde(default)]
38    pub include: Vec<String>,
39
40    /// Checks to exclude
41    #[serde(default)]
42    pub exclude: Vec<String>,
43
44    /// Minimum severity threshold: "error", "warning", "info"
45    #[serde(default)]
46    pub threshold: Option<String>,
47}
48
49/// Error type for kubelint tool
50#[derive(Debug, thiserror::Error)]
51#[error("Kubelint error: {0}")]
52pub struct KubelintError(String);
53
54/// Tool to lint Kubernetes manifests natively
55///
56/// **When to use:**
57/// - Checking security issues (privileged containers, missing probes, etc.)
58/// - Validating K8s resource best practices
59/// - RBAC configuration validation
60/// - Resource limits and requests checking
61///
62/// **When to use HelmlintTool instead:**
63/// - Helm chart structure validation (Chart.yaml, values.yaml)
64/// - Go template syntax checking
65/// - Helm-specific best practices
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct KubelintTool {
68    project_path: PathBuf,
69}
70
71impl KubelintTool {
72    pub fn new(project_path: PathBuf) -> Self {
73        Self { project_path }
74    }
75
76    fn parse_threshold(threshold: &str) -> Severity {
77        match threshold.to_lowercase().as_str() {
78            "error" => Severity::Error,
79            "warning" => Severity::Warning,
80            "info" => Severity::Info,
81            _ => Severity::Warning,
82        }
83    }
84
85    /// Get category for a check code
86    fn get_check_category(code: &str) -> &'static str {
87        match code {
88            // Security checks
89            "privileged-container"
90            | "privilege-escalation"
91            | "run-as-non-root"
92            | "read-only-root-fs"
93            | "drop-net-raw-capability"
94            | "hostnetwork"
95            | "hostpid"
96            | "hostipc"
97            | "host-mounts"
98            | "writable-host-mount"
99            | "docker-sock"
100            | "unsafe-proc-mount"
101            | "scc-deny-privileged-container" => "security",
102
103            // Best practice checks
104            "latest-tag"
105            | "no-liveness-probe"
106            | "no-readiness-probe"
107            | "unset-cpu-requirements"
108            | "unset-memory-requirements"
109            | "minimum-replicas"
110            | "no-anti-affinity"
111            | "no-rolling-update-strategy"
112            | "default-service-account"
113            | "deprecated-service-account"
114            | "env-var-secret"
115            | "read-secret-from-env-var"
116            | "priority-class-name"
117            | "no-node-affinity"
118            | "restart-policy"
119            | "sysctls"
120            | "dnsconfig-options" => "best-practice",
121
122            // RBAC checks
123            "access-to-secrets"
124            | "access-to-create-pods"
125            | "cluster-admin-role-binding"
126            | "wildcard-in-rules" => "rbac",
127
128            // Validation checks
129            "dangling-service"
130            | "dangling-ingress"
131            | "dangling-horizontalpodautoscaler"
132            | "dangling-networkpolicy"
133            | "mismatching-selector"
134            | "duplicate-env-var"
135            | "invalid-target-ports"
136            | "non-existent-service-account"
137            | "non-isolated-pod"
138            | "use-namespace"
139            | "env-var-value-from"
140            | "job-ttl-seconds-after-finished" => "validation",
141
142            // Port checks
143            "ssh-port" | "privileged-ports" | "liveness-port" | "readiness-port"
144            | "startup-port" => "ports",
145
146            // PDB checks
147            "pdb-max-unavailable" | "pdb-min-available" | "pdb-unhealthy-pod-eviction-policy" => {
148                "disruption-budget"
149            }
150
151            // HPA checks
152            "hpa-minimum-replicas" => "autoscaling",
153
154            // Deprecated API checks
155            "no-extensions-v1beta" => "deprecated-api",
156
157            // Service checks
158            "service-type" => "service",
159
160            _ => "other",
161        }
162    }
163
164    /// Get priority based on severity and check code
165    fn get_priority(severity: Severity, code: &str) -> &'static str {
166        let category = Self::get_check_category(code);
167        match (severity, category) {
168            (Severity::Error, "security") => "critical",
169            (Severity::Error, "rbac") => "critical",
170            (Severity::Error, _) => "high",
171            (Severity::Warning, "security") => "high",
172            (Severity::Warning, "rbac") => "high",
173            (Severity::Warning, "validation") => "medium",
174            (Severity::Warning, "best-practice") => "medium",
175            (Severity::Warning, _) => "medium",
176            (Severity::Info, _) => "low",
177        }
178    }
179
180    /// Format result optimized for agent decision-making
181    fn format_result(result: &LintResult, source: &str) -> String {
182        // Categorize and enrich failures
183        let enriched_failures: Vec<serde_json::Value> = result
184            .failures
185            .iter()
186            .map(|f| {
187                let code = f.code.as_str();
188                let category = Self::get_check_category(code);
189                let priority = Self::get_priority(f.severity, code);
190
191                json!({
192                    "check": code,
193                    "severity": format!("{:?}", f.severity).to_lowercase(),
194                    "priority": priority,
195                    "category": category,
196                    "message": f.message,
197                    "object": {
198                        "name": f.object_name,
199                        "kind": f.object_kind,
200                        "namespace": f.object_namespace,
201                    },
202                    "file": f.file_path.display().to_string(),
203                    "line": f.line,
204                    "remediation": f.remediation,
205                })
206            })
207            .collect();
208
209        // Group by priority
210        let critical: Vec<_> = enriched_failures
211            .iter()
212            .filter(|f| f["priority"] == "critical")
213            .cloned()
214            .collect();
215        let high: Vec<_> = enriched_failures
216            .iter()
217            .filter(|f| f["priority"] == "high")
218            .cloned()
219            .collect();
220        let medium: Vec<_> = enriched_failures
221            .iter()
222            .filter(|f| f["priority"] == "medium")
223            .cloned()
224            .collect();
225        let low: Vec<_> = enriched_failures
226            .iter()
227            .filter(|f| f["priority"] == "low")
228            .cloned()
229            .collect();
230
231        // Group by category
232        let mut by_category: std::collections::HashMap<&str, usize> =
233            std::collections::HashMap::new();
234        for f in &result.failures {
235            let cat = Self::get_check_category(f.code.as_str());
236            *by_category.entry(cat).or_default() += 1;
237        }
238
239        // Build decision context
240        let decision_context = if critical.is_empty() && high.is_empty() {
241            if medium.is_empty() && low.is_empty() {
242                "Kubernetes manifests follow security best practices. No issues found."
243            } else if medium.is_empty() {
244                "Minor improvements possible. Low priority issues only."
245            } else {
246                "Good baseline. Medium priority improvements recommended."
247            }
248        } else if !critical.is_empty() {
249            "CRITICAL security issues found. Fix before deployment to production."
250        } else {
251            "High priority issues found. Review security and best practice violations."
252        };
253
254        // Build agent-optimized output
255        let mut output = json!({
256            "source": source,
257            "success": result.summary.passed,
258            "decision_context": decision_context,
259            "tool_guidance": "Use kubelint for K8s manifest security/best practices. Use helmlint for Helm chart structure/template syntax.",
260            "summary": {
261                "total_issues": result.failures.len(),
262                "objects_analyzed": result.summary.objects_analyzed,
263                "checks_run": result.summary.checks_run,
264                "by_priority": {
265                    "critical": critical.len(),
266                    "high": high.len(),
267                    "medium": medium.len(),
268                    "low": low.len(),
269                },
270                "by_category": by_category,
271            },
272            "action_plan": {
273                "critical": critical,
274                "high": high,
275                "medium": medium,
276                "low": low,
277            },
278        });
279
280        // Add quick fixes summary
281        if !enriched_failures.is_empty() {
282            let quick_fixes: Vec<String> = enriched_failures
283                .iter()
284                .filter(|f| f["priority"] == "critical" || f["priority"] == "high")
285                .take(5)
286                .map(|f| {
287                    let remediation = f["remediation"]
288                        .as_str()
289                        .unwrap_or("Review the check documentation.");
290                    format!(
291                        "{}/{}: {} - {}",
292                        f["object"]["kind"].as_str().unwrap_or(""),
293                        f["object"]["name"].as_str().unwrap_or(""),
294                        f["check"].as_str().unwrap_or(""),
295                        remediation
296                    )
297                })
298                .collect();
299
300            if !quick_fixes.is_empty() {
301                output["quick_fixes"] = json!(quick_fixes);
302            }
303        }
304
305        if !result.parse_errors.is_empty() {
306            output["parse_errors"] = json!(result.parse_errors);
307        }
308
309        serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string())
310    }
311}
312
313impl Tool for KubelintTool {
314    const NAME: &'static str = "kubelint";
315
316    type Error = KubelintError;
317    type Args = KubelintArgs;
318    type Output = String;
319
320    async fn definition(&self, _prompt: String) -> ToolDefinition {
321        ToolDefinition {
322            name: Self::NAME.to_string(),
323            description: "Lint Kubernetes manifests for SECURITY and BEST PRACTICES. \
324                Works on raw YAML files, Helm charts (renders them first), and Kustomize directories. \
325                \n\n**IMPORTANT:** Always specify the `path` parameter to lint specific files or directories. \
326                \n\n**Use kubelint for:** Security issues (privileged containers, missing probes), \
327                resource best practices (limits, RBAC), manifest validation. \
328                \n**Use helmlint for:** Helm chart structure, template syntax, Chart.yaml/values.yaml validation. \
329                \n\nReturns AI-optimized JSON with issues categorized by priority (critical/high/medium/low) \
330                and type (security/rbac/best-practice/validation). Each issue includes remediation steps."
331                .to_string(),
332            parameters: json!({
333                "type": "object",
334                "properties": {
335                    "path": {
336                        "type": "string",
337                        "description": "Path to K8s manifest(s) relative to project root. Can be: \
338                            single YAML file, directory with YAMLs, Helm chart directory, or Kustomize directory."
339                    },
340                    "content": {
341                        "type": "string",
342                        "description": "Inline YAML content to lint. Use this to validate generated manifests before writing."
343                    },
344                    "include": {
345                        "type": "array",
346                        "items": { "type": "string" },
347                        "description": "Specific checks to run (e.g., ['privileged-container', 'latest-tag']). If empty, runs all default checks."
348                    },
349                    "exclude": {
350                        "type": "array",
351                        "items": { "type": "string" },
352                        "description": "Checks to skip (e.g., ['no-liveness-probe', 'minimum-replicas'])"
353                    },
354                    "threshold": {
355                        "type": "string",
356                        "enum": ["error", "warning", "info"],
357                        "description": "Minimum severity to report. Default is 'warning'."
358                    }
359                }
360            }),
361        }
362    }
363
364    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
365        // Build configuration
366        let mut config = KubelintConfig::default().with_all_builtin();
367
368        // Apply includes
369        for check in &args.include {
370            config = config.include(check.as_str());
371        }
372
373        // Apply excludes
374        for check in &args.exclude {
375            config = config.exclude(check.as_str());
376        }
377
378        // Apply threshold
379        if let Some(threshold) = &args.threshold {
380            config = config.with_threshold(Self::parse_threshold(threshold));
381        }
382
383        // Determine source and lint
384        let (result, source) = if let Some(content) = &args.content {
385            // Lint inline content
386            (lint_content(content, &config), "<inline>".to_string())
387        } else if let Some(path) = &args.path {
388            // Lint file or directory
389            let full_path = self.project_path.join(path);
390
391            if !full_path.exists() {
392                return Err(KubelintError(format!(
393                    "Path '{}' does not exist.",
394                    full_path.display()
395                )));
396            }
397
398            if full_path.is_file() {
399                (lint_file(&full_path, &config), path.clone())
400            } else {
401                (lint(&full_path, &config), path.clone())
402            }
403        } else {
404            // Look for common K8s manifest locations
405            let candidates = [
406                "kubernetes",
407                "k8s",
408                "manifests",
409                "deploy",
410                "deployment",
411                "helm",
412                "charts",
413                "test-lint",     // For testing
414                "test-lint/k8s", // For testing
415                ".",
416            ];
417
418            let mut found = None;
419            for candidate in &candidates {
420                let candidate_path = self.project_path.join(candidate);
421                if candidate_path.exists() {
422                    // Check if it has YAML files or is a Helm/Kustomize directory
423                    if candidate_path.join("Chart.yaml").exists()
424                        || candidate_path.join("kustomization.yaml").exists()
425                        || candidate_path.join("kustomization.yml").exists()
426                    {
427                        found = Some((candidate_path, candidate.to_string()));
428                        break;
429                    }
430                    // Check for YAML files
431                    if let Ok(entries) = std::fs::read_dir(&candidate_path) {
432                        let has_yaml = entries.filter_map(|e| e.ok()).any(|e| {
433                            e.path()
434                                .extension()
435                                .map(|ext| ext == "yaml" || ext == "yml")
436                                .unwrap_or(false)
437                        });
438                        if has_yaml {
439                            found = Some((candidate_path, candidate.to_string()));
440                            break;
441                        }
442                    }
443                }
444            }
445
446            if let Some((path, name)) = found {
447                (lint(&path, &config), name)
448            } else {
449                return Err(KubelintError(
450                    "No path specified and no K8s manifests found. \
451                    Specify a path with 'path' parameter or provide 'content' to lint."
452                        .to_string(),
453                ));
454            }
455        };
456
457        // Check for parse errors
458        if !result.parse_errors.is_empty() {
459            log::warn!("K8s manifest parse errors: {:?}", result.parse_errors);
460        }
461
462        Ok(Self::format_result(&result, &source))
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469    use std::fs;
470    use tempfile::TempDir;
471
472    #[tokio::test]
473    async fn test_kubelint_inline_content() {
474        let temp_dir = TempDir::new().unwrap();
475        let tool = KubelintTool::new(temp_dir.path().to_path_buf());
476
477        let yaml = r#"
478apiVersion: apps/v1
479kind: Deployment
480metadata:
481  name: insecure-deploy
482spec:
483  replicas: 1
484  selector:
485    matchLabels:
486      app: test
487  template:
488    spec:
489      containers:
490      - name: nginx
491        image: nginx:latest
492        securityContext:
493          privileged: true
494"#;
495
496        let args = KubelintArgs {
497            path: None,
498            content: Some(yaml.to_string()),
499            include: vec!["privileged-container".to_string(), "latest-tag".to_string()],
500            exclude: vec![],
501            threshold: None,
502        };
503
504        let result = tool.call(args).await.unwrap();
505        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
506
507        // Should find issues
508        assert!(parsed["summary"]["total_issues"].as_u64().unwrap_or(0) > 0);
509        assert!(parsed["decision_context"].is_string());
510        assert!(parsed["tool_guidance"].is_string());
511    }
512
513    #[tokio::test]
514    async fn test_kubelint_secure_deployment() {
515        let temp_dir = TempDir::new().unwrap();
516        let tool = KubelintTool::new(temp_dir.path().to_path_buf());
517
518        let yaml = r#"
519apiVersion: apps/v1
520kind: Deployment
521metadata:
522  name: secure-deploy
523spec:
524  replicas: 3
525  selector:
526    matchLabels:
527      app: test
528  template:
529    spec:
530      serviceAccountName: my-service-account
531      securityContext:
532        runAsNonRoot: true
533      containers:
534      - name: nginx
535        image: nginx:1.25.0
536        securityContext:
537          privileged: false
538          allowPrivilegeEscalation: false
539          readOnlyRootFilesystem: true
540          capabilities:
541            drop:
542            - ALL
543"#;
544
545        let args = KubelintArgs {
546            path: None,
547            content: Some(yaml.to_string()),
548            include: vec!["privileged-container".to_string(), "latest-tag".to_string()],
549            exclude: vec![],
550            threshold: None,
551        };
552
553        let result = tool.call(args).await.unwrap();
554        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
555
556        // Should pass for privileged and latest-tag checks
557        let critical = parsed["summary"]["by_priority"]["critical"]
558            .as_u64()
559            .unwrap_or(99);
560        let high = parsed["summary"]["by_priority"]["high"]
561            .as_u64()
562            .unwrap_or(99);
563        assert_eq!(critical, 0);
564        assert_eq!(high, 0);
565    }
566
567    #[tokio::test]
568    async fn test_kubelint_file() {
569        let temp_dir = TempDir::new().unwrap();
570        let manifest_path = temp_dir.path().join("deployment.yaml");
571
572        fs::write(
573            &manifest_path,
574            r#"apiVersion: apps/v1
575kind: Deployment
576metadata:
577  name: test
578spec:
579  replicas: 1
580  selector:
581    matchLabels:
582      app: test
583  template:
584    spec:
585      containers:
586      - name: nginx
587        image: nginx:1.25.0
588"#,
589        )
590        .unwrap();
591
592        let tool = KubelintTool::new(temp_dir.path().to_path_buf());
593        let args = KubelintArgs {
594            path: Some("deployment.yaml".to_string()),
595            content: None,
596            include: vec![],
597            exclude: vec![],
598            threshold: None,
599        };
600
601        let result = tool.call(args).await.unwrap();
602        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
603
604        assert!(
605            parsed["source"]
606                .as_str()
607                .unwrap()
608                .contains("deployment.yaml")
609        );
610        assert!(parsed["summary"]["objects_analyzed"].as_u64().unwrap_or(0) >= 1);
611    }
612
613    #[tokio::test]
614    async fn test_kubelint_output_format() {
615        let temp_dir = TempDir::new().unwrap();
616        let tool = KubelintTool::new(temp_dir.path().to_path_buf());
617
618        let yaml = r#"
619apiVersion: apps/v1
620kind: Deployment
621metadata:
622  name: insecure-deploy
623spec:
624  replicas: 1
625  selector:
626    matchLabels:
627      app: test
628  template:
629    spec:
630      containers:
631      - name: nginx
632        image: nginx:latest
633        securityContext:
634          privileged: true
635"#;
636
637        let args = KubelintArgs {
638            path: None,
639            content: Some(yaml.to_string()),
640            include: vec![], // Use all defaults + builtin
641            exclude: vec![],
642            threshold: None,
643        };
644
645        let result = tool.call(args).await.unwrap();
646        println!("\n=== KUBELINT OUTPUT ===\n{}\n", result);
647
648        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
649
650        // Verify structure
651        assert!(
652            parsed["summary"]["total_issues"].as_u64().unwrap() > 0,
653            "Expected issues but got none. Output: {}",
654            result
655        );
656        assert!(
657            !parsed["action_plan"]["critical"]
658                .as_array()
659                .unwrap()
660                .is_empty()
661                || !parsed["action_plan"]["high"].as_array().unwrap().is_empty(),
662            "Expected critical or high priority issues"
663        );
664    }
665
666    #[tokio::test]
667    async fn test_kubelint_excludes() {
668        let temp_dir = TempDir::new().unwrap();
669        let tool = KubelintTool::new(temp_dir.path().to_path_buf());
670
671        let yaml = r#"
672apiVersion: apps/v1
673kind: Deployment
674metadata:
675  name: test
676spec:
677  replicas: 1
678  selector:
679    matchLabels:
680      app: test
681  template:
682    spec:
683      containers:
684      - name: nginx
685        image: nginx:latest
686        securityContext:
687          privileged: true
688"#;
689
690        let args = KubelintArgs {
691            path: None,
692            content: Some(yaml.to_string()),
693            include: vec![],
694            exclude: vec!["privileged-container".to_string(), "latest-tag".to_string()],
695            threshold: None,
696        };
697
698        let result = tool.call(args).await.unwrap();
699        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
700
701        // Excluded checks should not appear
702        let all_issues: Vec<_> = ["critical", "high", "medium", "low"]
703            .iter()
704            .flat_map(|p| {
705                parsed["action_plan"][p]
706                    .as_array()
707                    .cloned()
708                    .unwrap_or_default()
709            })
710            .collect();
711
712        assert!(
713            !all_issues
714                .iter()
715                .any(|i| i["check"] == "privileged-container")
716        );
717        assert!(!all_issues.iter().any(|i| i["check"] == "latest-tag"));
718    }
719}