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

1"""Hadolint tool definition. 

2 

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""" 

7 

8from __future__ import annotations 

9 

10import subprocess # nosec B404 - used safely with shell disabled 

11from dataclasses import dataclass 

12from typing import Any 

13 

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) 

35 

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 

43 

44 

45@register_tool 

46@dataclass 

47class HadolintPlugin(BaseToolPlugin): 

48 """Hadolint Dockerfile linter plugin. 

49 

50 This plugin integrates Hadolint with Lintro for checking Dockerfiles 

51 against best practices. 

52 """ 

53 

54 @property 

55 def definition(self) -> ToolDefinition: 

56 """Return the tool definition. 

57 

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 ) 

87 

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. 

101 

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() 

117 

118 if failure_threshold is not None: 

119 thr_enum = normalize_hadolint_threshold(failure_threshold) 

120 failure_threshold = thr_enum.name.lower() 

121 

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") 

128 

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) 

140 

141 def _build_command(self) -> list[str]: 

142 """Build the hadolint command. 

143 

144 Returns: 

145 List of command arguments. 

146 """ 

147 cmd: list[str] = ["hadolint"] 

148 

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]) 

155 

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]) 

167 

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)]) 

173 

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)]) 

179 

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)]) 

185 

186 # Add strict labels 

187 if self.options.get("strict_labels", False): 

188 cmd.append("--strict-labels") 

189 

190 # Add no-fail option 

191 if self.options.get("no_fail", False): 

192 cmd.append("--no-fail") 

193 

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") 

197 

198 return cmd 

199 

200 def _process_single_file( 

201 self, 

202 file_path: str, 

203 timeout: int, 

204 ) -> FileProcessingResult: 

205 """Process a single Dockerfile with hadolint. 

206 

207 Args: 

208 file_path: Path to the Dockerfile to process. 

209 timeout: Timeout in seconds for the hadolint command. 

210 

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 ) 

237 

238 def doc_url(self, code: str) -> str | None: 

239 """Return documentation URL for the given code. 

240 

241 Hadolint emits both native DL rules and ShellCheck SC rules. 

242 Routes each prefix to the appropriate documentation site. 

243 

244 Args: 

245 code: Rule code (e.g., "DL3008", "SC2046"). 

246 

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 

258 

259 def check(self, paths: list[str], options: dict[str, object]) -> ToolResult: 

260 """Check files with Hadolint. 

261 

262 Args: 

263 paths: List of file or directory paths to check. 

264 options: Runtime options that override defaults. 

265 

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] 

272 

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 ) 

279 

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 ) 

287 

288 def fix(self, paths: list[str], options: dict[str, object]) -> ToolResult: 

289 """Hadolint cannot fix issues, only report them. 

290 

291 Args: 

292 paths: List of file or directory paths to fix. 

293 options: Tool-specific options. 

294 

295 Returns: 

296 ToolResult: Never returns, always raises NotImplementedError. 

297 

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 )