1use rig::completion::ToolDefinition;
13use rig::tool::Tool;
14use serde::{Deserialize, Serialize};
15use serde_json::json;
16use std::path::PathBuf;
17
18use crate::analyzer::hadolint::{lint, lint_file, HadolintConfig, LintResult, Severity};
19
20#[derive(Debug, Deserialize)]
22pub struct HadolintArgs {
23 #[serde(default)]
25 pub dockerfile: Option<String>,
26
27 #[serde(default)]
29 pub content: Option<String>,
30
31 #[serde(default)]
33 pub ignore: Vec<String>,
34
35 #[serde(default)]
37 pub threshold: Option<String>,
38}
39
40#[derive(Debug, thiserror::Error)]
42#[error("Hadolint error: {0}")]
43pub struct HadolintError(String);
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct HadolintTool {
48 project_path: PathBuf,
49}
50
51impl HadolintTool {
52 pub fn new(project_path: PathBuf) -> Self {
53 Self { project_path }
54 }
55
56 fn parse_threshold(threshold: &str) -> Severity {
57 match threshold.to_lowercase().as_str() {
58 "error" => Severity::Error,
59 "warning" => Severity::Warning,
60 "info" => Severity::Info,
61 "style" => Severity::Style,
62 _ => Severity::Warning, }
64 }
65
66 fn get_rule_category(code: &str) -> &'static str {
68 match code {
69 "DL3000" | "DL3002" | "DL3004" | "DL3047" => "security",
71 "DL3003" | "DL3006" | "DL3007" | "DL3008" | "DL3009" | "DL3013" |
73 "DL3014" | "DL3015" | "DL3016" | "DL3018" | "DL3019" | "DL3020" |
74 "DL3025" | "DL3027" | "DL3028" | "DL3033" | "DL3042" | "DL3059" => "best-practice",
75 "DL3005" | "DL3010" | "DL3021" | "DL3022" | "DL3023" | "DL3024" |
77 "DL3026" | "DL3029" | "DL3030" | "DL3032" | "DL3034" | "DL3035" |
78 "DL3036" | "DL3044" | "DL3045" | "DL3048" | "DL3049" | "DL3050" |
79 "DL3051" | "DL3052" | "DL3053" | "DL3054" | "DL3055" | "DL3056" |
80 "DL3057" | "DL3058" | "DL3060" | "DL3061" => "maintainability",
81 "DL3001" | "DL3011" | "DL3017" | "DL3031" | "DL3037" | "DL3038" |
83 "DL3039" | "DL3040" | "DL3041" | "DL3046" | "DL3062" => "performance",
84 "DL4000" | "DL4001" | "DL4003" | "DL4005" | "DL4006" => "deprecated",
86 _ if code.starts_with("SC") => "shell",
88 _ => "other",
89 }
90 }
91
92 fn get_priority(severity: Severity, category: &str) -> &'static str {
94 match (severity, category) {
95 (Severity::Error, "security") => "critical",
96 (Severity::Error, _) => "high",
97 (Severity::Warning, "security") => "high",
98 (Severity::Warning, "best-practice") => "medium",
99 (Severity::Warning, _) => "medium",
100 (Severity::Info, _) => "low",
101 (Severity::Style, _) => "low",
102 (Severity::Ignore, _) => "info",
103 }
104 }
105
106 fn get_fix_recommendation(code: &str) -> &'static str {
108 match code {
109 "DL3000" => "Use absolute WORKDIR paths like '/app' instead of relative paths.",
110 "DL3001" => "Remove commands that have no effect in Docker (like 'ssh', 'mount').",
111 "DL3002" => "Remove the last USER instruction setting root, or add 'USER <non-root>' at the end.",
112 "DL3003" => "Use WORKDIR to change directories instead of 'cd' in RUN commands.",
113 "DL3004" => "Remove 'sudo' from RUN commands. Docker runs as root by default, or use proper USER switching.",
114 "DL3005" => "Remove 'apt-get upgrade' or 'dist-upgrade'. Pin packages instead for reproducibility.",
115 "DL3006" => "Add explicit version tag to base image, e.g., 'FROM node:18-alpine' instead of 'FROM node'.",
116 "DL3007" => "Use specific version tag instead of ':latest', e.g., 'nginx:1.25-alpine'.",
117 "DL3008" => "Pin apt package versions: 'apt-get install package=version' or use '--no-install-recommends'.",
118 "DL3009" => "Add 'rm -rf /var/lib/apt/lists/*' after apt-get install to reduce image size.",
119 "DL3010" => "Use ADD only for extracting archives. For other files, use COPY.",
120 "DL3011" => "Use valid port numbers (0-65535) in EXPOSE.",
121 "DL3013" => "Pin pip package versions: 'pip install package==version'.",
122 "DL3014" => "Add '-y' flag to apt-get install for non-interactive mode.",
123 "DL3015" => "Add '--no-install-recommends' to apt-get install to minimize image size.",
124 "DL3016" => "Pin npm package versions: 'npm install package@version'.",
125 "DL3017" => "Remove 'apt-get upgrade'. Pin specific package versions instead.",
126 "DL3018" => "Pin apk package versions: 'apk add package=version'.",
127 "DL3019" => "Add '--no-cache' to apk add instead of separate cache cleanup.",
128 "DL3020" => "Use COPY instead of ADD for files from build context. ADD is for URLs and archives.",
129 "DL3021" => "Use COPY with --from for multi-stage builds instead of COPY from external images.",
130 "DL3022" => "Use COPY --from=stage instead of --from=image for multi-stage builds.",
131 "DL3023" => "Reference build stage by name instead of number in COPY --from.",
132 "DL3024" => "Use lowercase for 'as' in multi-stage builds: 'FROM image AS builder'.",
133 "DL3025" => "Use JSON array format for CMD/ENTRYPOINT: CMD [\"executable\", \"arg1\"].",
134 "DL3026" => "Use official Docker images when possible, or document why unofficial is needed.",
135 "DL3027" => "Remove 'apt' and use 'apt-get' for scripting in Dockerfiles.",
136 "DL3028" => "Pin gem versions: 'gem install package:version'.",
137 "DL3029" => "Specify --platform explicitly for multi-arch builds.",
138 "DL3030" => "Pin yum/dnf package versions: 'yum install package-version'.",
139 "DL3032" => "Replace 'yum clean all' with 'dnf clean all' for newer distros.",
140 "DL3033" => "Add 'yum clean all' after yum install to reduce image size.",
141 "DL3034" => "Add '--setopt=install_weak_deps=False' to dnf install.",
142 "DL3035" => "Add 'dnf clean all' after dnf install to reduce image size.",
143 "DL3036" => "Pin zypper package versions: 'zypper install package=version'.",
144 "DL3037" => "Add 'zypper clean' after zypper install.",
145 "DL3038" => "Add '--no-recommends' to zypper install.",
146 "DL3039" => "Add 'zypper clean' after zypper install.",
147 "DL3040" => "Add 'dnf clean all && rm -rf /var/cache/dnf' after dnf install.",
148 "DL3041" => "Add 'microdnf clean all' after microdnf install.",
149 "DL3042" => "Avoid pip cache in builds. Use '--no-cache-dir' or set PIP_NO_CACHE_DIR=1.",
150 "DL3044" => "Only use 'HEALTHCHECK' once per Dockerfile, or it won't work correctly.",
151 "DL3045" => "Use COPY instead of ADD for local files.",
152 "DL3046" => "Use 'useradd' instead of 'adduser' for better compatibility.",
153 "DL3047" => "Add 'wget --progress=dot:giga' or 'curl --progress-bar' to show progress during download.",
154 "DL3048" => "Prefer setting flag with 'SHELL' instruction instead of inline in RUN.",
155 "DL3049" => "Add a 'LABEL maintainer=\"name\"' for documentation.",
156 "DL3050" => "Add 'LABEL version=\"x.y\"' for versioning.",
157 "DL3051" => "Add 'LABEL description=\"...\"' for documentation.",
158 "DL3052" => "Prefer relative paths with LABEL for better portability.",
159 "DL3053" => "Remove unused LABEL instructions.",
160 "DL3054" => "Use recommended labels from OCI spec (org.opencontainers.image.*).",
161 "DL3055" => "Add 'LABEL org.opencontainers.image.created' with ISO 8601 date.",
162 "DL3056" => "Add 'LABEL org.opencontainers.image.description'.",
163 "DL3057" => "Add a HEALTHCHECK instruction for container health monitoring.",
164 "DL3058" => "Add 'LABEL org.opencontainers.image.title'.",
165 "DL3059" => "Combine consecutive RUN instructions with '&&' to reduce layers.",
166 "DL3060" => "Pin package versions in yarn add: 'yarn add package@version'.",
167 "DL3061" => "Use specific image digest or tag instead of implicit latest.",
168 "DL3062" => "Prefer single RUN with '&&' over multiple RUN for related commands.",
169 "DL4000" => "Replace MAINTAINER with 'LABEL maintainer=\"name <email>\"'.",
170 "DL4001" => "Use wget or curl instead of ADD for downloading from URLs.",
171 "DL4003" => "Use 'ENTRYPOINT' and 'CMD' together properly for container startup.",
172 "DL4005" => "Prefer JSON notation for SHELL: SHELL [\"/bin/bash\", \"-c\"].",
173 "DL4006" => "Add 'SHELL [\"/bin/bash\", \"-o\", \"pipefail\", \"-c\"]' before RUN with pipes.",
174 _ if code.starts_with("SC") => "See ShellCheck wiki for shell scripting fix.",
175 _ => "Review the rule documentation for specific guidance.",
176 }
177 }
178
179 fn get_rule_url(code: &str) -> String {
181 if code.starts_with("DL") || code.starts_with("SC") {
182 if code.starts_with("SC") {
183 format!("https://www.shellcheck.net/wiki/{}", code)
184 } else {
185 format!("https://github.com/hadolint/hadolint/wiki/{}", code)
186 }
187 } else {
188 String::new()
189 }
190 }
191
192 fn format_result(result: &LintResult, filename: &str) -> String {
194 let enriched_failures: Vec<serde_json::Value> = result.failures.iter().map(|f| {
196 let code = f.code.as_str();
197 let category = Self::get_rule_category(code);
198 let priority = Self::get_priority(f.severity, category);
199
200 json!({
201 "code": code,
202 "severity": format!("{:?}", f.severity).to_lowercase(),
203 "priority": priority,
204 "category": category,
205 "message": f.message,
206 "line": f.line,
207 "column": f.column,
208 "fix": Self::get_fix_recommendation(code),
209 "docs": Self::get_rule_url(code),
210 })
211 }).collect();
212
213 let critical: Vec<_> = enriched_failures.iter()
215 .filter(|f| f["priority"] == "critical")
216 .cloned().collect();
217 let high: Vec<_> = enriched_failures.iter()
218 .filter(|f| f["priority"] == "high")
219 .cloned().collect();
220 let medium: Vec<_> = enriched_failures.iter()
221 .filter(|f| f["priority"] == "medium")
222 .cloned().collect();
223 let low: Vec<_> = enriched_failures.iter()
224 .filter(|f| f["priority"] == "low")
225 .cloned().collect();
226
227 let mut by_category: std::collections::HashMap<&str, Vec<_>> = std::collections::HashMap::new();
229 for f in &enriched_failures {
230 let cat = f["category"].as_str().unwrap_or("other");
231 by_category.entry(cat).or_default().push(f.clone());
232 }
233
234 let decision_context = if critical.is_empty() && high.is_empty() {
236 if medium.is_empty() && low.is_empty() {
237 "Dockerfile follows best practices. No issues found."
238 } else if medium.is_empty() {
239 "Minor improvements possible. Low priority issues only."
240 } else {
241 "Good baseline. Medium priority improvements recommended."
242 }
243 } else if !critical.is_empty() {
244 "Critical issues found. Address security/error issues first before deployment."
245 } else {
246 "High priority issues found. Review and fix before production use."
247 };
248
249 let mut output = json!({
251 "file": filename,
252 "success": !result.has_errors(),
253 "decision_context": decision_context,
254 "summary": {
255 "total": result.failures.len(),
256 "by_priority": {
257 "critical": critical.len(),
258 "high": high.len(),
259 "medium": medium.len(),
260 "low": low.len(),
261 },
262 "by_severity": {
263 "errors": result.failures.iter().filter(|f| f.severity == Severity::Error).count(),
264 "warnings": result.failures.iter().filter(|f| f.severity == Severity::Warning).count(),
265 "info": result.failures.iter().filter(|f| f.severity == Severity::Info).count(),
266 },
267 "by_category": by_category.iter().map(|(k, v)| (k.to_string(), v.len())).collect::<std::collections::HashMap<_, _>>(),
268 },
269 "action_plan": {
270 "critical": critical,
271 "high": high,
272 "medium": medium,
273 "low": low,
274 },
275 });
276
277 if !enriched_failures.is_empty() {
279 let quick_fixes: Vec<String> = enriched_failures.iter()
280 .filter(|f| f["priority"] == "critical" || f["priority"] == "high")
281 .take(5)
282 .map(|f| format!("Line {}: {} - {}",
283 f["line"],
284 f["code"].as_str().unwrap_or(""),
285 f["fix"].as_str().unwrap_or("")
286 ))
287 .collect();
288
289 if !quick_fixes.is_empty() {
290 output["quick_fixes"] = json!(quick_fixes);
291 }
292 }
293
294 if !result.parse_errors.is_empty() {
295 output["parse_errors"] = json!(result.parse_errors);
296 }
297
298 serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string())
299 }
300}
301
302impl Tool for HadolintTool {
303 const NAME: &'static str = "hadolint";
304
305 type Error = HadolintError;
306 type Args = HadolintArgs;
307 type Output = String;
308
309 async fn definition(&self, _prompt: String) -> ToolDefinition {
310 ToolDefinition {
311 name: Self::NAME.to_string(),
312 description: "Lint Dockerfiles for best practices, security issues, and common mistakes. \
313 Returns AI-optimized JSON with issues categorized by priority (critical/high/medium/low) \
314 and type (security/best-practice/maintainability/performance/deprecated). \
315 Each issue includes an actionable fix recommendation. Use this to analyze Dockerfiles \
316 before deployment or to improve existing ones. The 'decision_context' field provides \
317 a summary for quick assessment, and 'quick_fixes' lists the most important changes."
318 .to_string(),
319 parameters: json!({
320 "type": "object",
321 "properties": {
322 "dockerfile": {
323 "type": "string",
324 "description": "Path to Dockerfile relative to project root (e.g., 'Dockerfile', 'docker/Dockerfile.prod')"
325 },
326 "content": {
327 "type": "string",
328 "description": "Inline Dockerfile content to lint. Use this when you want to validate generated Dockerfile content before writing."
329 },
330 "ignore": {
331 "type": "array",
332 "items": { "type": "string" },
333 "description": "List of rule codes to ignore (e.g., ['DL3008', 'DL3013'])"
334 },
335 "threshold": {
336 "type": "string",
337 "enum": ["error", "warning", "info", "style"],
338 "description": "Minimum severity to report. Default is 'warning'."
339 }
340 }
341 }),
342 }
343 }
344
345 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
346 let mut config = HadolintConfig::default();
348
349 for rule in &args.ignore {
351 config = config.ignore(rule.as_str());
352 }
353
354 if let Some(threshold) = &args.threshold {
356 config = config.with_threshold(Self::parse_threshold(threshold));
357 }
358
359 let (result, filename) = if let Some(content) = &args.content {
361 (lint(content, &config), "<inline>".to_string())
363 } else if let Some(dockerfile) = &args.dockerfile {
364 let path = self.project_path.join(dockerfile);
366 (lint_file(&path, &config), dockerfile.clone())
367 } else {
368 let path = self.project_path.join("Dockerfile");
370 if path.exists() {
371 (lint_file(&path, &config), "Dockerfile".to_string())
372 } else {
373 return Err(HadolintError(
374 "No Dockerfile specified and no Dockerfile found in project root".to_string(),
375 ));
376 }
377 };
378
379 if !result.parse_errors.is_empty() {
381 log::warn!("Dockerfile parse errors: {:?}", result.parse_errors);
382 }
383
384 Ok(Self::format_result(&result, &filename))
385 }
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391 use std::env::temp_dir;
392 use std::fs;
393
394 fn collect_all_issues(parsed: &serde_json::Value) -> Vec<serde_json::Value> {
396 let mut all = Vec::new();
397 for priority in ["critical", "high", "medium", "low"] {
398 if let Some(arr) = parsed["action_plan"][priority].as_array() {
399 all.extend(arr.clone());
400 }
401 }
402 all
403 }
404
405 #[tokio::test]
406 async fn test_hadolint_inline_content() {
407 let tool = HadolintTool::new(temp_dir());
408 let args = HadolintArgs {
409 dockerfile: None,
410 content: Some("FROM ubuntu:latest\nRUN sudo apt-get update".to_string()),
411 ignore: vec![],
412 threshold: None,
413 };
414
415 let result = tool.call(args).await.unwrap();
416 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
417
418 assert!(!parsed["success"].as_bool().unwrap_or(true));
420 assert!(parsed["summary"]["total"].as_u64().unwrap_or(0) >= 2);
421
422 assert!(parsed["decision_context"].is_string());
424 assert!(parsed["action_plan"].is_object());
425
426 let issues = collect_all_issues(&parsed);
428 assert!(issues.iter().all(|i| i["fix"].is_string() && !i["fix"].as_str().unwrap().is_empty()));
429 }
430
431 #[tokio::test]
432 async fn test_hadolint_ignore_rules() {
433 let tool = HadolintTool::new(temp_dir());
434 let args = HadolintArgs {
435 dockerfile: None,
436 content: Some("FROM ubuntu:latest".to_string()),
437 ignore: vec!["DL3007".to_string()],
438 threshold: None,
439 };
440
441 let result = tool.call(args).await.unwrap();
442 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
443
444 let all_issues = collect_all_issues(&parsed);
446 assert!(!all_issues.iter().any(|f| f["code"] == "DL3007"));
447 }
448
449 #[tokio::test]
450 async fn test_hadolint_threshold() {
451 let tool = HadolintTool::new(temp_dir());
452 let args = HadolintArgs {
453 dockerfile: None,
454 content: Some("FROM ubuntu\nMAINTAINER test".to_string()),
455 ignore: vec![],
456 threshold: Some("error".to_string()),
457 };
458
459 let result = tool.call(args).await.unwrap();
460 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
461
462 let all_issues = collect_all_issues(&parsed);
465 assert!(all_issues.iter().all(|f| f["severity"] == "error"));
466 }
467
468 #[tokio::test]
469 async fn test_hadolint_file() {
470 let temp = temp_dir().join("hadolint_test");
471 fs::create_dir_all(&temp).unwrap();
472 let dockerfile = temp.join("Dockerfile");
473 fs::write(&dockerfile, "FROM node:18-alpine\nWORKDIR /app\nCOPY . .\nCMD [\"node\", \"app.js\"]").unwrap();
474
475 let tool = HadolintTool::new(temp.clone());
476 let args = HadolintArgs {
477 dockerfile: Some("Dockerfile".to_string()),
478 content: None,
479 ignore: vec![],
480 threshold: None,
481 };
482
483 let result = tool.call(args).await.unwrap();
484 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
485
486 assert!(parsed["success"].as_bool().unwrap_or(false));
488 assert_eq!(parsed["file"], "Dockerfile");
489
490 fs::remove_dir_all(&temp).ok();
492 }
493
494 #[tokio::test]
495 async fn test_hadolint_valid_dockerfile() {
496 let tool = HadolintTool::new(temp_dir());
497 let dockerfile = r#"
498FROM node:18-alpine AS builder
499WORKDIR /app
500COPY package*.json ./
501RUN npm ci --only=production
502COPY . .
503RUN npm run build
504
505FROM node:18-alpine
506WORKDIR /app
507COPY --from=builder /app/dist ./dist
508USER node
509EXPOSE 3000
510CMD ["node", "dist/index.js"]
511"#;
512
513 let args = HadolintArgs {
514 dockerfile: None,
515 content: Some(dockerfile.to_string()),
516 ignore: vec![],
517 threshold: None,
518 };
519
520 let result = tool.call(args).await.unwrap();
521 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
522
523 assert!(parsed["success"].as_bool().unwrap_or(false));
525 assert!(parsed["decision_context"].is_string());
527 assert_eq!(parsed["summary"]["by_priority"]["critical"].as_u64().unwrap_or(99), 0);
529 assert_eq!(parsed["summary"]["by_priority"]["high"].as_u64().unwrap_or(99), 0);
530 }
531
532 #[tokio::test]
533 async fn test_hadolint_priority_categorization() {
534 let tool = HadolintTool::new(temp_dir());
535 let args = HadolintArgs {
536 dockerfile: None,
537 content: Some("FROM ubuntu\nRUN sudo apt-get update\nMAINTAINER test".to_string()),
538 ignore: vec![],
539 threshold: None,
540 };
541
542 let result = tool.call(args).await.unwrap();
543 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
544
545 assert!(parsed["summary"]["by_priority"]["critical"].is_number());
547 assert!(parsed["summary"]["by_priority"]["high"].is_number());
548 assert!(parsed["summary"]["by_priority"]["medium"].is_number());
549
550 assert!(parsed["summary"]["by_category"].is_object());
552
553 let all_issues = collect_all_issues(&parsed);
555 let sudo_issue = all_issues.iter().find(|i| i["code"] == "DL3004");
556 assert!(sudo_issue.is_some());
557 assert_eq!(sudo_issue.unwrap()["category"], "security");
558 }
559
560 #[tokio::test]
561 async fn test_hadolint_quick_fixes() {
562 let tool = HadolintTool::new(temp_dir());
563 let args = HadolintArgs {
564 dockerfile: None,
565 content: Some("FROM ubuntu\nRUN sudo rm -rf /".to_string()),
566 ignore: vec![],
567 threshold: None,
568 };
569
570 let result = tool.call(args).await.unwrap();
571 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
572
573 if parsed["summary"]["by_priority"]["high"].as_u64().unwrap_or(0) > 0
575 || parsed["summary"]["by_priority"]["critical"].as_u64().unwrap_or(0) > 0 {
576 assert!(parsed["quick_fixes"].is_array());
577 }
578 }
579}