Coverage for lintro / tools / definitions / hadolint.py: 96%
97 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Hadolint tool definition.
3Hadolint is a Dockerfile linter that helps you build best practice Docker images.
4It parses the Dockerfile into an AST and performs rules on top of the AST.
5It also uses ShellCheck to lint the Bash code inside RUN instructions.
6"""
8from __future__ import annotations
10import subprocess # nosec B404 - used safely with shell disabled
11from dataclasses import dataclass
12from typing import Any
14from lintro._tool_versions import get_min_version
15from lintro.enums.doc_url_template import DocUrlTemplate
16from lintro.enums.hadolint_enums import (
17 HadolintFailureThreshold,
18 HadolintFormat,
19 normalize_hadolint_format,
20 normalize_hadolint_threshold,
21)
22from lintro.enums.tool_name import ToolName
23from lintro.enums.tool_type import ToolType
24from lintro.models.core.tool_result import ToolResult
25from lintro.parsers.hadolint.hadolint_parser import parse_hadolint_output
26from lintro.plugins.base import BaseToolPlugin
27from lintro.plugins.file_processor import FileProcessingResult
28from lintro.plugins.protocol import ToolDefinition
29from lintro.plugins.registry import register_tool
30from lintro.tools.core.option_validators import (
31 filter_none_options,
32 validate_bool,
33 validate_list,
34)
36# Constants for Hadolint configuration
37HADOLINT_DEFAULT_TIMEOUT: int = 30
38HADOLINT_DEFAULT_PRIORITY: int = 50
39HADOLINT_FILE_PATTERNS: list[str] = ["Dockerfile", "Dockerfile.*"]
40HADOLINT_DEFAULT_FORMAT: str = "tty"
41HADOLINT_DEFAULT_FAILURE_THRESHOLD: str = "info"
42HADOLINT_DEFAULT_NO_COLOR: bool = True
45@register_tool
46@dataclass
47class HadolintPlugin(BaseToolPlugin):
48 """Hadolint Dockerfile linter plugin.
50 This plugin integrates Hadolint with Lintro for checking Dockerfiles
51 against best practices.
52 """
54 @property
55 def definition(self) -> ToolDefinition:
56 """Return the tool definition.
58 Returns:
59 ToolDefinition containing tool metadata.
60 """
61 return ToolDefinition(
62 name="hadolint",
63 description=(
64 "Dockerfile linter that helps you build best practice Docker images"
65 ),
66 can_fix=False,
67 tool_type=ToolType.LINTER | ToolType.INFRASTRUCTURE,
68 file_patterns=HADOLINT_FILE_PATTERNS,
69 priority=HADOLINT_DEFAULT_PRIORITY,
70 conflicts_with=[],
71 native_configs=[".hadolint.yaml", ".hadolint.yml"],
72 version_command=["hadolint", "--version"],
73 min_version=get_min_version(ToolName.HADOLINT),
74 default_options={
75 "timeout": HADOLINT_DEFAULT_TIMEOUT,
76 "format": HADOLINT_DEFAULT_FORMAT,
77 "failure_threshold": HADOLINT_DEFAULT_FAILURE_THRESHOLD,
78 "ignore": None,
79 "trusted_registries": None,
80 "require_labels": None,
81 "strict_labels": False,
82 "no_fail": False,
83 "no_color": HADOLINT_DEFAULT_NO_COLOR,
84 },
85 default_timeout=HADOLINT_DEFAULT_TIMEOUT,
86 )
88 def set_options(
89 self,
90 format: str | HadolintFormat | None = None,
91 failure_threshold: str | HadolintFailureThreshold | None = None,
92 ignore: list[str] | None = None,
93 trusted_registries: list[str] | None = None,
94 require_labels: list[str] | None = None,
95 strict_labels: bool | None = None,
96 no_fail: bool | None = None,
97 no_color: bool | None = None,
98 **kwargs: Any,
99 ) -> None:
100 """Set Hadolint-specific options.
102 Args:
103 format: Output format (tty, json, checkstyle, codeclimate, etc.).
104 failure_threshold: Exit with failure only when rules with
105 severity >= threshold.
106 ignore: List of rule codes to ignore (e.g., ['DL3006', 'SC2086']).
107 trusted_registries: List of trusted Docker registries.
108 require_labels: List of required labels with schemas.
109 strict_labels: Whether to use strict label checking.
110 no_fail: Whether to suppress exit codes.
111 no_color: Whether to disable color output.
112 **kwargs: Other tool options.
113 """
114 if format is not None:
115 fmt_enum = normalize_hadolint_format(format)
116 format = fmt_enum.name.lower()
118 if failure_threshold is not None:
119 thr_enum = normalize_hadolint_threshold(failure_threshold)
120 failure_threshold = thr_enum.name.lower()
122 validate_list(ignore, "ignore")
123 validate_list(trusted_registries, "trusted_registries")
124 validate_list(require_labels, "require_labels")
125 validate_bool(strict_labels, "strict_labels")
126 validate_bool(no_fail, "no_fail")
127 validate_bool(no_color, "no_color")
129 options = filter_none_options(
130 format=format,
131 failure_threshold=failure_threshold,
132 ignore=ignore,
133 trusted_registries=trusted_registries,
134 require_labels=require_labels,
135 strict_labels=strict_labels,
136 no_fail=no_fail,
137 no_color=no_color,
138 )
139 super().set_options(**options, **kwargs)
141 def _build_command(self) -> list[str]:
142 """Build the hadolint command.
144 Returns:
145 List of command arguments.
146 """
147 cmd: list[str] = ["hadolint"]
149 # Add format option
150 format_opt = self.options.get("format", HADOLINT_DEFAULT_FORMAT)
151 format_option = (
152 str(format_opt) if format_opt is not None else HADOLINT_DEFAULT_FORMAT
153 )
154 cmd.extend(["--format", format_option])
156 # Add failure threshold
157 threshold_opt = self.options.get(
158 "failure_threshold",
159 HADOLINT_DEFAULT_FAILURE_THRESHOLD,
160 )
161 failure_threshold = (
162 str(threshold_opt)
163 if threshold_opt is not None
164 else HADOLINT_DEFAULT_FAILURE_THRESHOLD
165 )
166 cmd.extend(["--failure-threshold", failure_threshold])
168 # Add ignore rules
169 ignore_opt = self.options.get("ignore")
170 if ignore_opt is not None and isinstance(ignore_opt, list):
171 for rule in ignore_opt:
172 cmd.extend(["--ignore", str(rule)])
174 # Add trusted registries
175 registries_opt = self.options.get("trusted_registries")
176 if registries_opt is not None and isinstance(registries_opt, list):
177 for registry in registries_opt:
178 cmd.extend(["--trusted-registry", str(registry)])
180 # Add required labels
181 labels_opt = self.options.get("require_labels")
182 if labels_opt is not None and isinstance(labels_opt, list):
183 for label in labels_opt:
184 cmd.extend(["--require-label", str(label)])
186 # Add strict labels
187 if self.options.get("strict_labels", False):
188 cmd.append("--strict-labels")
190 # Add no-fail option
191 if self.options.get("no_fail", False):
192 cmd.append("--no-fail")
194 # Add no-color option (default to True for better parsing)
195 if self.options.get("no_color", HADOLINT_DEFAULT_NO_COLOR):
196 cmd.append("--no-color")
198 return cmd
200 def _process_single_file(
201 self,
202 file_path: str,
203 timeout: int,
204 ) -> FileProcessingResult:
205 """Process a single Dockerfile with hadolint.
207 Args:
208 file_path: Path to the Dockerfile to process.
209 timeout: Timeout in seconds for the hadolint command.
211 Returns:
212 FileProcessingResult with processing outcome.
213 """
214 cmd = self._build_command() + [str(file_path)]
215 try:
216 success, output = self._run_subprocess(cmd=cmd, timeout=timeout)
217 issues = parse_hadolint_output(output=output)
218 return FileProcessingResult(
219 success=success,
220 output=output,
221 issues=issues,
222 )
223 except subprocess.TimeoutExpired:
224 return FileProcessingResult(
225 success=False,
226 output="",
227 issues=[],
228 skipped=True,
229 )
230 except (OSError, ValueError, RuntimeError) as e:
231 return FileProcessingResult(
232 success=False,
233 output="",
234 issues=[],
235 error=str(e),
236 )
238 def doc_url(self, code: str) -> str | None:
239 """Return documentation URL for the given code.
241 Hadolint emits both native DL rules and ShellCheck SC rules.
242 Routes each prefix to the appropriate documentation site.
244 Args:
245 code: Rule code (e.g., "DL3008", "SC2046").
247 Returns:
248 URL to the rule documentation, or None if code is empty.
249 """
250 if not code:
251 return None
252 upper = code.upper()
253 if upper.startswith("SC"):
254 return DocUrlTemplate.SHELLCHECK.format(code=upper)
255 if upper.startswith("DL"):
256 return DocUrlTemplate.HADOLINT.format(code=upper)
257 return None
259 def check(self, paths: list[str], options: dict[str, object]) -> ToolResult:
260 """Check files with Hadolint.
262 Args:
263 paths: List of file or directory paths to check.
264 options: Runtime options that override defaults.
266 Returns:
267 ToolResult with check results.
268 """
269 ctx = self._prepare_execution(paths, options)
270 if ctx.should_skip:
271 return ctx.early_result # type: ignore[return-value]
273 # Process files using the shared file processor
274 result = self._process_files_with_progress(
275 files=ctx.files,
276 processor=lambda f: self._process_single_file(f, ctx.timeout),
277 timeout=ctx.timeout,
278 )
280 return ToolResult(
281 name=self.definition.name,
282 success=result.all_success and result.total_issues == 0,
283 output=result.build_output(timeout=ctx.timeout),
284 issues_count=result.total_issues,
285 issues=result.all_issues,
286 )
288 def fix(self, paths: list[str], options: dict[str, object]) -> ToolResult:
289 """Hadolint cannot fix issues, only report them.
291 Args:
292 paths: List of file or directory paths to fix.
293 options: Tool-specific options.
295 Returns:
296 ToolResult: Never returns, always raises NotImplementedError.
298 Raises:
299 NotImplementedError: Hadolint does not support fixing issues.
300 """
301 raise NotImplementedError(
302 "Hadolint cannot automatically fix issues. Run 'lintro check' to see "
303 "issues.",
304 )