dockerls 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (230) hide show
  1. dockerls/__init__.py +31 -0
  2. dockerls/application/__init__.py +0 -0
  3. dockerls/application/dto/__init__.py +3 -0
  4. dockerls/application/dto/analysis.py +257 -0
  5. dockerls/application/services/__init__.py +0 -0
  6. dockerls/application/services/alternatives_lookup.py +167 -0
  7. dockerls/application/services/composite_repository.py +106 -0
  8. dockerls/application/services/cross_validation.py +233 -0
  9. dockerls/application/services/ecosystems.py +350 -0
  10. dockerls/application/services/fallback_scanner.py +97 -0
  11. dockerls/application/services/hardening_analysis.py +174 -0
  12. dockerls/application/services/migration.py +297 -0
  13. dockerls/application/services/progress.py +56 -0
  14. dockerls/application/services/remediation.py +321 -0
  15. dockerls/application/services/scan_history_store.py +88 -0
  16. dockerls/application/services/scanner_factory.py +88 -0
  17. dockerls/application/services/source_registry.py +151 -0
  18. dockerls/application/services/tag_history_store.py +76 -0
  19. dockerls/application/services/teardown.py +50 -0
  20. dockerls/application/services/verdict.py +298 -0
  21. dockerls/application/services/version_discovery.py +108 -0
  22. dockerls/application/use_cases/__init__.py +0 -0
  23. dockerls/application/use_cases/analyze_dockerfile.py +103 -0
  24. dockerls/application/use_cases/analyze_image.py +173 -0
  25. dockerls/application/use_cases/build_image.py +1795 -0
  26. dockerls/application/use_cases/compare_images.py +91 -0
  27. dockerls/application/use_cases/fleet_scan.py +240 -0
  28. dockerls/application/use_cases/recommend_images.py +1078 -0
  29. dockerls/application/use_cases/registry_audit.py +133 -0
  30. dockerls/application/use_cases/search_images.py +23 -0
  31. dockerls/application/use_cases/upgrade_base.py +167 -0
  32. dockerls/cache/__init__.py +0 -0
  33. dockerls/cache/sqlite_cache.py +184 -0
  34. dockerls/cli/__init__.py +0 -0
  35. dockerls/cli/analysis_baseline.py +98 -0
  36. dockerls/cli/app.py +294 -0
  37. dockerls/cli/commands/__init__.py +0 -0
  38. dockerls/cli/commands/advisor.py +262 -0
  39. dockerls/cli/commands/alternatives.py +291 -0
  40. dockerls/cli/commands/analyze.py +429 -0
  41. dockerls/cli/commands/analyze_dockerfile.py +104 -0
  42. dockerls/cli/commands/base_cmd.py +244 -0
  43. dockerls/cli/commands/base_image.py +551 -0
  44. dockerls/cli/commands/build.py +1300 -0
  45. dockerls/cli/commands/cache_cmd.py +104 -0
  46. dockerls/cli/commands/compare.py +177 -0
  47. dockerls/cli/commands/controls.py +110 -0
  48. dockerls/cli/commands/doctor.py +566 -0
  49. dockerls/cli/commands/export.py +81 -0
  50. dockerls/cli/commands/fleet.py +159 -0
  51. dockerls/cli/commands/health.py +84 -0
  52. dockerls/cli/commands/login.py +53 -0
  53. dockerls/cli/commands/policy_cmd.py +111 -0
  54. dockerls/cli/commands/provenance_cmd.py +162 -0
  55. dockerls/cli/commands/recommend.py +761 -0
  56. dockerls/cli/commands/registry_audit_cmd.py +103 -0
  57. dockerls/cli/commands/sbom.py +144 -0
  58. dockerls/cli/commands/search.py +86 -0
  59. dockerls/cli/commands/verify.py +115 -0
  60. dockerls/cli/commands/version.py +12 -0
  61. dockerls/cli/commands/vex_cmd.py +117 -0
  62. dockerls/cli/dependencies.py +530 -0
  63. dockerls/cli/image_names.py +79 -0
  64. dockerls/cli/options.py +42 -0
  65. dockerls/cli/progress.py +145 -0
  66. dockerls/cli/publish_prompt.py +123 -0
  67. dockerls/cli/rendering.py +214 -0
  68. dockerls/cli/runtime.py +65 -0
  69. dockerls/cli/scan_failure.py +71 -0
  70. dockerls/cli/text.py +39 -0
  71. dockerls/cli/validators.py +35 -0
  72. dockerls/cli/vulnerability_view.py +154 -0
  73. dockerls/domain/__init__.py +0 -0
  74. dockerls/domain/entities/__init__.py +75 -0
  75. dockerls/domain/entities/declared_metadata.py +147 -0
  76. dockerls/domain/entities/dockerfile_analysis.py +318 -0
  77. dockerls/domain/entities/image.py +109 -0
  78. dockerls/domain/entities/image_facts.py +137 -0
  79. dockerls/domain/entities/recommendation.py +33 -0
  80. dockerls/domain/entities/scan_result.py +129 -0
  81. dockerls/domain/entities/vulnerability.py +232 -0
  82. dockerls/domain/interfaces/__init__.py +17 -0
  83. dockerls/domain/interfaces/cache_store.py +18 -0
  84. dockerls/domain/interfaces/dockerfile_validator.py +99 -0
  85. dockerls/domain/interfaces/eol_checker.py +11 -0
  86. dockerls/domain/interfaces/image_repository.py +15 -0
  87. dockerls/domain/interfaces/scanner.py +15 -0
  88. dockerls/domain/security_controls.py +362 -0
  89. dockerls/domain/value_objects/__init__.py +49 -0
  90. dockerls/domain/value_objects/attack_surface.py +198 -0
  91. dockerls/domain/value_objects/base_recipe.py +600 -0
  92. dockerls/domain/value_objects/base_upgrade.py +292 -0
  93. dockerls/domain/value_objects/build_labels.py +99 -0
  94. dockerls/domain/value_objects/build_policy.py +412 -0
  95. dockerls/domain/value_objects/confidence.py +156 -0
  96. dockerls/domain/value_objects/fleet.py +174 -0
  97. dockerls/domain/value_objects/gate.py +327 -0
  98. dockerls/domain/value_objects/hardening.py +303 -0
  99. dockerls/domain/value_objects/image_reference.py +90 -0
  100. dockerls/domain/value_objects/inheritance.py +352 -0
  101. dockerls/domain/value_objects/network_policy.py +280 -0
  102. dockerls/domain/value_objects/production_readiness.py +145 -0
  103. dockerls/domain/value_objects/provenance.py +211 -0
  104. dockerls/domain/value_objects/recipe_diff.py +188 -0
  105. dockerls/domain/value_objects/registry_audit.py +195 -0
  106. dockerls/domain/value_objects/registry_target.py +225 -0
  107. dockerls/domain/value_objects/remediation_score.py +62 -0
  108. dockerls/domain/value_objects/scan_history.py +183 -0
  109. dockerls/domain/value_objects/scan_plan.py +193 -0
  110. dockerls/domain/value_objects/scanner_db.py +143 -0
  111. dockerls/domain/value_objects/security_score.py +160 -0
  112. dockerls/domain/value_objects/security_tier.py +122 -0
  113. dockerls/domain/value_objects/tag_history.py +180 -0
  114. dockerls/domain/value_objects/tool_release.py +253 -0
  115. dockerls/domain/value_objects/tristate.py +47 -0
  116. dockerls/domain/value_objects/vex.py +249 -0
  117. dockerls/exit_codes.py +23 -0
  118. dockerls/exporters/__init__.py +0 -0
  119. dockerls/exporters/base.py +17 -0
  120. dockerls/exporters/csv_exporter.py +85 -0
  121. dockerls/exporters/factory.py +31 -0
  122. dockerls/exporters/html_exporter.py +105 -0
  123. dockerls/exporters/json_exporter.py +19 -0
  124. dockerls/exporters/markdown_exporter.py +84 -0
  125. dockerls/exporters/sarif_exporter.py +245 -0
  126. dockerls/infrastructure/__init__.py +0 -0
  127. dockerls/infrastructure/config/__init__.py +0 -0
  128. dockerls/infrastructure/config/policy_file.py +165 -0
  129. dockerls/infrastructure/config/settings.py +197 -0
  130. dockerls/infrastructure/database/__init__.py +0 -0
  131. dockerls/infrastructure/database/models.py +78 -0
  132. dockerls/infrastructure/dockerfile_validator.py +1899 -0
  133. dockerls/infrastructure/evidence.py +99 -0
  134. dockerls/infrastructure/hashing.py +165 -0
  135. dockerls/infrastructure/logging/__init__.py +0 -0
  136. dockerls/infrastructure/logging/setup.py +98 -0
  137. dockerls/infrastructure/network/__init__.py +0 -0
  138. dockerls/infrastructure/network/guarded_client.py +107 -0
  139. dockerls/infrastructure/network/host_guard.py +117 -0
  140. dockerls/infrastructure/redaction.py +135 -0
  141. dockerls/infrastructure/templates/hardening/alpine.dockerfile +44 -0
  142. dockerls/infrastructure/templates/hardening/debian.dockerfile +45 -0
  143. dockerls/infrastructure/templates/hardening/distroless.dockerfile +34 -0
  144. dockerls/infrastructure/templates/hardening/go-alpine.dockerfile +52 -0
  145. dockerls/infrastructure/templates/hardening/go-debian.dockerfile +54 -0
  146. dockerls/infrastructure/templates/hardening/go-distroless.dockerfile +43 -0
  147. dockerls/infrastructure/templates/hardening/go-scratch.dockerfile +48 -0
  148. dockerls/infrastructure/templates/hardening/go.dockerfile +50 -0
  149. dockerls/infrastructure/templates/hardening/gradle-alpine.dockerfile +51 -0
  150. dockerls/infrastructure/templates/hardening/gradle.dockerfile +52 -0
  151. dockerls/infrastructure/templates/hardening/java-alpine.dockerfile +50 -0
  152. dockerls/infrastructure/templates/hardening/java-debian.dockerfile +50 -0
  153. dockerls/infrastructure/templates/hardening/java-distroless.dockerfile +39 -0
  154. dockerls/infrastructure/templates/hardening/java-ubuntu.dockerfile +54 -0
  155. dockerls/infrastructure/templates/hardening/java.dockerfile +60 -0
  156. dockerls/infrastructure/templates/hardening/maven-alpine.dockerfile +56 -0
  157. dockerls/infrastructure/templates/hardening/maven.dockerfile +57 -0
  158. dockerls/infrastructure/templates/hardening/node-alpine.dockerfile +47 -0
  159. dockerls/infrastructure/templates/hardening/node-debian.dockerfile +54 -0
  160. dockerls/infrastructure/templates/hardening/node-distroless.dockerfile +46 -0
  161. dockerls/infrastructure/templates/hardening/node-ubuntu.dockerfile +63 -0
  162. dockerls/infrastructure/templates/hardening/node.dockerfile +61 -0
  163. dockerls/infrastructure/templates/hardening/php-alpine.dockerfile +45 -0
  164. dockerls/infrastructure/templates/hardening/php-debian.dockerfile +45 -0
  165. dockerls/infrastructure/templates/hardening/php-ubuntu.dockerfile +49 -0
  166. dockerls/infrastructure/templates/hardening/php.dockerfile +44 -0
  167. dockerls/infrastructure/templates/hardening/python-alpine.dockerfile +51 -0
  168. dockerls/infrastructure/templates/hardening/python-debian.dockerfile +54 -0
  169. dockerls/infrastructure/templates/hardening/python-distroless.dockerfile +51 -0
  170. dockerls/infrastructure/templates/hardening/python-ubuntu.dockerfile +60 -0
  171. dockerls/infrastructure/templates/hardening/python.dockerfile +58 -0
  172. dockerls/infrastructure/templates/hardening/ruby-alpine.dockerfile +48 -0
  173. dockerls/infrastructure/templates/hardening/ruby-debian.dockerfile +50 -0
  174. dockerls/infrastructure/templates/hardening/rust-alpine.dockerfile +50 -0
  175. dockerls/infrastructure/templates/hardening/rust-debian.dockerfile +48 -0
  176. dockerls/infrastructure/templates/hardening/rust-scratch.dockerfile +44 -0
  177. dockerls/infrastructure/templates/hardening/rust.dockerfile +54 -0
  178. dockerls/infrastructure/templates/hardening/ubuntu.dockerfile +49 -0
  179. dockerls/infrastructure/toolchain/__init__.py +0 -0
  180. dockerls/infrastructure/toolchain/db_metadata.py +115 -0
  181. dockerls/infrastructure/toolchain/installer.py +435 -0
  182. dockerls/integrations/__init__.py +0 -0
  183. dockerls/integrations/dhi/__init__.py +0 -0
  184. dockerls/integrations/dhi/catalog.py +457 -0
  185. dockerls/integrations/dhi/definition.py +151 -0
  186. dockerls/integrations/dhi/repository.py +238 -0
  187. dockerls/integrations/dockerhub/__init__.py +0 -0
  188. dockerls/integrations/dockerhub/client.py +318 -0
  189. dockerls/integrations/dockerhub/urls.py +75 -0
  190. dockerls/integrations/endoflife/__init__.py +0 -0
  191. dockerls/integrations/endoflife/checker.py +216 -0
  192. dockerls/integrations/engine/__init__.py +0 -0
  193. dockerls/integrations/engine/batch.py +197 -0
  194. dockerls/integrations/engine/client.py +330 -0
  195. dockerls/integrations/engine/locator.py +96 -0
  196. dockerls/integrations/exploitdb/__init__.py +0 -0
  197. dockerls/integrations/exploitdb/client.py +271 -0
  198. dockerls/integrations/grype/__init__.py +0 -0
  199. dockerls/integrations/grype/scanner.py +336 -0
  200. dockerls/integrations/registry/__init__.py +0 -0
  201. dockerls/integrations/registry/hardened.py +284 -0
  202. dockerls/integrations/registry/inspector.py +420 -0
  203. dockerls/integrations/registry/oci.py +259 -0
  204. dockerls/integrations/registry/private.py +79 -0
  205. dockerls/integrations/registry/urls.py +36 -0
  206. dockerls/integrations/scan_errors.py +67 -0
  207. dockerls/integrations/scan_target.py +57 -0
  208. dockerls/integrations/signing/__init__.py +0 -0
  209. dockerls/integrations/signing/cosign.py +484 -0
  210. dockerls/integrations/threat_intel/__init__.py +0 -0
  211. dockerls/integrations/threat_intel/client.py +290 -0
  212. dockerls/integrations/trivy/__init__.py +0 -0
  213. dockerls/integrations/trivy/cache_pool.py +176 -0
  214. dockerls/integrations/trivy/scanner.py +447 -0
  215. dockerls/utils/__init__.py +0 -0
  216. dockerls/utils/auth.py +108 -0
  217. dockerls/utils/executables.py +39 -0
  218. dockerls/utils/ignore_file.py +130 -0
  219. dockerls/utils/rate_limit.py +130 -0
  220. dockerls/utils/resources.py +183 -0
  221. dockerls/utils/retry.py +31 -0
  222. dockerls/utils/safe_yaml.py +166 -0
  223. dockerls/utils/subprocess_runner.py +216 -0
  224. dockerls/utils/validation.py +72 -0
  225. dockerls-1.0.0.dist-info/METADATA +563 -0
  226. dockerls-1.0.0.dist-info/RECORD +230 -0
  227. dockerls-1.0.0.dist-info/WHEEL +5 -0
  228. dockerls-1.0.0.dist-info/entry_points.txt +2 -0
  229. dockerls-1.0.0.dist-info/licenses/LICENSE +21 -0
  230. dockerls-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,761 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from collections import Counter
6
+ from enum import StrEnum
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.panel import Panel
13
+ from rich.table import Table
14
+
15
+ from dockerls.application.services.source_registry import UnknownSourceError
16
+ from dockerls.cli.dependencies import (
17
+ build_recommend_use_case,
18
+ enable_console_logging,
19
+ resolve_tag_limit,
20
+ )
21
+ from dockerls.cli.image_names import display_reference, reject_tagged_reference
22
+ from dockerls.cli.options import OutputFormat, parse_output_format
23
+ from dockerls.cli.progress import RichScanObserver
24
+ from dockerls.cli.scan_failure import short_reason
25
+ from dockerls.cli.text import safe
26
+ from dockerls.cli.validators import check_limit, check_threshold, check_workers
27
+ from dockerls.domain.value_objects.confidence import Confidence
28
+ from dockerls.domain.value_objects.security_tier import SecurityTier, Tier
29
+ from dockerls.domain.value_objects.tristate import Tristate
30
+ from dockerls.exit_codes import EXIT_ERROR, EXIT_OK
31
+ from dockerls.infrastructure.evidence import slugify_reference
32
+
33
+ if TYPE_CHECKING:
34
+ from collections.abc import Callable
35
+
36
+ from dockerls.application.dto.analysis import (
37
+ AnalysisResult,
38
+ DimensionReport,
39
+ ImageAnalysis,
40
+ )
41
+
42
+ console = Console()
43
+
44
+ # Exit codes, in order of severity:
45
+ # 0 = an image meeting the baseline (Critical=0, High=0, Medium<=max) was found
46
+ # 1 = a hard error occurred (nothing could be scanned, or --fail-on was violated)
47
+ # 2 = no baseline image, but fallback alternatives were found
48
+ # 3 = nothing usable was found at all
49
+ # 0 e 1 vêm do contrato compartilhado; 2 e 3 são próprios de `recommend`,
50
+ # que escolhe entre candidatos em vez de avaliar um artefato do usuário.
51
+ EXIT_BASELINE_MET = EXIT_OK
52
+ EXIT_ERROR_CODE = EXIT_ERROR
53
+ EXIT_ALTERNATIVES_FOUND = 2
54
+ EXIT_NONE_FOUND = 3
55
+
56
+ DISPUTED_SCORE_LABEL = "[yellow]!disputed[/yellow]"
57
+
58
+
59
+ class FailOn(StrEnum):
60
+ NONE = "none"
61
+ CRITICAL = "critical"
62
+ HIGH = "high"
63
+ MEDIUM = "medium"
64
+
65
+
66
+ _FAIL_ON_COUNT: dict[FailOn, Callable[[ImageAnalysis], int]] = {
67
+ FailOn.CRITICAL: lambda a: a.scan.critical_count,
68
+ FailOn.HIGH: lambda a: a.scan.critical_count + a.scan.high_count,
69
+ FailOn.MEDIUM: lambda a: a.scan.critical_count + a.scan.high_count + a.scan.medium_count,
70
+ }
71
+
72
+
73
+ def recommend(
74
+ image: str = typer.Argument(
75
+ help=(
76
+ "Docker image name only, without a tag (e.g. 'node', not 'node:18'). "
77
+ "Use 'analyze' or 'advisor' for a specific tag."
78
+ )
79
+ ),
80
+ max_critical: int | None = typer.Option(
81
+ None, "--max-critical", help="Max critical vulns allowed [config: max_critical, default 0]"
82
+ ),
83
+ max_high: int | None = typer.Option(
84
+ None, "--max-high", help="Max high vulns allowed [config: max_high, default 0]"
85
+ ),
86
+ max_medium: int | None = typer.Option(
87
+ None, "--max-medium", help="Max medium vulns allowed [config: max_medium, default 5]"
88
+ ),
89
+ limit: int | None = typer.Option(
90
+ None, "--limit", "-l", help="Max tags to discover [config: max_tags, default 100]"
91
+ ),
92
+ budget: int | None = typer.Option(
93
+ None,
94
+ "--budget",
95
+ "-b",
96
+ help="Max tags to actually scan; 0 scans every tag found [config: scan_budget, default 25]",
97
+ ),
98
+ workers: int | None = typer.Option(
99
+ None,
100
+ "--workers",
101
+ "-w",
102
+ help="Concurrent scanner processes; 0 sizes it to this machine [config: workers]",
103
+ ),
104
+ fail_on: FailOn = typer.Option(
105
+ FailOn.NONE, "--fail-on", help="Exit non-zero if the top result has vulns at/above severity"
106
+ ),
107
+ output_format: str = typer.Option(
108
+ OutputFormat.TABLE.value, "--format", "-f", help="Output format: table or json"
109
+ ),
110
+ no_color: bool = typer.Option(False, "--no-color", help="Disable colored output"),
111
+ no_progress: bool = typer.Option(False, "--no-progress", help="Disable the progress display"),
112
+ no_cross_validate: bool = typer.Option(
113
+ False, "--no-cross-validate", help="Skip second-scanner validation of top candidates"
114
+ ),
115
+ no_hub_check: bool = typer.Option(
116
+ False, "--no-hub-check", help="Skip registry tag existence verification"
117
+ ),
118
+ no_hardened: bool = typer.Option(
119
+ False, "--no-hardened", help="Search Docker Hub only (skip Chainguard/Distroless)"
120
+ ),
121
+ source: list[str] = typer.Option(
122
+ [],
123
+ "--source",
124
+ "-s",
125
+ help=(
126
+ "Image source to search; repeatable. "
127
+ "One of: dockerhub, chainguard, distroless, dhi, private (if configured), all"
128
+ ),
129
+ ),
130
+ all_sources: bool = typer.Option(
131
+ False, "--all-sources", help="Search every configured source, including opt-in ones"
132
+ ),
133
+ no_cache: bool = typer.Option(
134
+ False, "--no-cache", help="Ignore cached analyses and re-scan every candidate"
135
+ ),
136
+ verbose: bool = typer.Option(
137
+ False, "--verbose", "-v", help="Also print logs to stderr (they always go to the log file)"
138
+ ),
139
+ ) -> None:
140
+ """Recommend the most secure Docker image tags."""
141
+ if no_color:
142
+ console.no_color = True
143
+ if verbose:
144
+ enable_console_logging()
145
+ fmt = parse_output_format(output_format)
146
+
147
+ # Validated here, before any dependency is built: an out-of-range value
148
+ # must produce a readable CLI error rather than a traceback from deep
149
+ # inside the use case (or, for `--workers 0`, a semaphore nobody can
150
+ # acquire). `None` means "not given", so the configured default applies.
151
+ if max_critical is not None:
152
+ max_critical = check_threshold(max_critical, "max_critical")
153
+ if max_high is not None:
154
+ max_high = check_threshold(max_high, "max_high")
155
+ if max_medium is not None:
156
+ max_medium = check_threshold(max_medium, "max_medium")
157
+ if limit is not None:
158
+ limit = check_limit(limit)
159
+ # `0` é um valor legítimo aqui -- significa "meça todas" -- e por isso
160
+ # não passa pelo validador de limite, que recusa zero.
161
+ if budget is not None and budget < 0:
162
+ raise typer.BadParameter("--budget cannot be negative")
163
+ # `0` means "size it to this machine", so it is passed through rather
164
+ # than validated: the resolver, not the flag, decides what it becomes.
165
+ if workers:
166
+ workers = check_workers(workers)
167
+
168
+ error = reject_tagged_reference(image, "recommend")
169
+ if error:
170
+ console.print(f"[red]{error}[/red]")
171
+ raise typer.Exit(EXIT_ERROR_CODE)
172
+
173
+ try:
174
+ asyncio.run(
175
+ _recommend(
176
+ image,
177
+ max_critical,
178
+ max_high,
179
+ max_medium,
180
+ limit,
181
+ budget,
182
+ workers,
183
+ fail_on,
184
+ fmt,
185
+ show_progress=not no_progress and fmt != OutputFormat.JSON,
186
+ cross_validate=not no_cross_validate,
187
+ verify_hub_tags=not no_hub_check,
188
+ include_hardened=not no_hardened,
189
+ use_cache=not no_cache,
190
+ sources=list(source) or None,
191
+ all_sources=all_sources,
192
+ )
193
+ )
194
+ except UnknownSourceError as e:
195
+ # Its own arm: a mistyped source names a fixable mistake, and the
196
+ # exception already carries the valid choices.
197
+ console.print(f"[red]Error:[/red] {e}")
198
+ raise typer.Exit(EXIT_ERROR_CODE) from e
199
+ except ValueError as e:
200
+ # Bad thresholds are user error, not a crash: show the message, not
201
+ # a stack trace (pretty_exceptions_enable is off app-wide).
202
+ console.print(f"[red]Invalid configuration:[/red] {e}")
203
+ raise typer.Exit(EXIT_ERROR_CODE) from e
204
+
205
+
206
+ async def _recommend(
207
+ image: str,
208
+ max_critical: int | None,
209
+ max_high: int | None,
210
+ max_medium: int | None,
211
+ limit: int | None,
212
+ budget: int | None,
213
+ workers: int | None,
214
+ fail_on: FailOn,
215
+ output_format: OutputFormat,
216
+ show_progress: bool = True,
217
+ cross_validate: bool = True,
218
+ verify_hub_tags: bool = True,
219
+ include_hardened: bool = True,
220
+ use_cache: bool = True,
221
+ sources: list[str] | None = None,
222
+ all_sources: bool = False,
223
+ ) -> None:
224
+ # The observer builds its own stderr console; `console` (stdout) is left
225
+ # exclusively for results so the two streams cannot interleave.
226
+ with RichScanObserver(enabled=show_progress) as observer:
227
+ use_case = await build_recommend_use_case(
228
+ max_critical=max_critical,
229
+ max_high=max_high,
230
+ max_medium=max_medium,
231
+ workers=workers,
232
+ observer=observer,
233
+ cross_validate=cross_validate,
234
+ verify_hub_tags=verify_hub_tags,
235
+ include_hardened=include_hardened,
236
+ use_cache=use_cache,
237
+ sources=sources,
238
+ all_sources=all_sources,
239
+ scan_budget=budget,
240
+ )
241
+ result = await use_case.execute(image, limit=resolve_tag_limit(limit))
242
+
243
+ if output_format == OutputFormat.JSON:
244
+ console.print(json.dumps(result.model_dump(), indent=2, default=str), soft_wrap=True)
245
+ raise typer.Exit(_exit_code(result, fail_on))
246
+
247
+ _print_summary(result)
248
+
249
+ if result.baseline_met and result.recommendations:
250
+ console.print(Panel("[bold green]Recommended Images[/bold green]", expand=False))
251
+ _print_table(result.recommendations)
252
+ _print_why(result.recommendations)
253
+ _print_details(result.recommendations)
254
+ _print_divergences(result.recommendations)
255
+ elif result.alternatives:
256
+ console.print(Panel(_baseline_miss_message(result), expand=False))
257
+ _print_table(result.alternatives)
258
+ _print_why(result.alternatives)
259
+ _print_details(result.alternatives)
260
+ _print_divergences(result.alternatives)
261
+ elif _nothing_could_be_measured(result):
262
+ console.print(_measurement_failure_message(result))
263
+ else:
264
+ console.print("[red]No suitable images found.[/red]")
265
+ console.print(f"[dim]{_baseline_line(result)}[/dim]")
266
+
267
+ _print_tier_warnings(result.recommendations or result.alternatives)
268
+ _print_unverified(result)
269
+ _print_deferred(result)
270
+
271
+ if result.evidence_manifest:
272
+ console.print(f"\n[dim]Evidence manifest: {result.evidence_manifest}[/dim]")
273
+
274
+ raise typer.Exit(_exit_code(result, fail_on))
275
+
276
+
277
+ def _baseline_line(result: AnalysisResult) -> str:
278
+ if result.baseline is None:
279
+ return ""
280
+ return f"Baseline: {result.baseline.describe()}."
281
+
282
+
283
+ def _baseline_miss_message(result: AnalysisResult) -> str:
284
+ """Name the exact threshold that was not met, so "no match" is a fact
285
+ the reader can check rather than an opaque verdict.
286
+
287
+ O ranking abaixo do alvo é apresentado como tal, e com todas as letras:
288
+ são as melhores candidatas encontradas, nenhuma delas aprovada. Antes o
289
+ caminho alternativo exigia `critical_count == 0` -- o mesmo critério que o
290
+ baseline acabara de rejeitar --, então quando toda tag tinha um CRITICAL o
291
+ usuário recebia "No suitable images found" e nada mais, depois de esperar
292
+ por uma centena de scans.
293
+ """
294
+ baseline = _baseline_line(result)
295
+ detail = f"{baseline}\n" if baseline else ""
296
+ return (
297
+ f"[bold yellow]No image meets the baseline.[/bold yellow]\n"
298
+ f"[yellow]{detail}"
299
+ f"Showing the best candidates found -- all of them below target.[/yellow]"
300
+ )
301
+
302
+
303
+ def _print_summary(result: AnalysisResult) -> None:
304
+ """One-line account of the run, so a clean table can never hide the
305
+ fact that half the candidates failed to scan."""
306
+ analyzed = result.total_tags_analyzed
307
+ total = result.total_tags_scanned
308
+ skipped = result.unverified_count
309
+
310
+ parts = [f"[green]OK {analyzed}/{total} analyzed[/green]"]
311
+ if skipped:
312
+ parts.append(f"[yellow]X {skipped} skipped (technical error)[/yellow]")
313
+ if result.deferred_count:
314
+ # Dito no mesmo lugar que os scans que falharam, e com palavras
315
+ # diferentes: um scan que falhou é uma medição que não deu certo,
316
+ # uma tag adiada é uma medição que nem foi tentada. As duas são
317
+ # ausência de medição, e nenhuma é um veredito sobre a imagem.
318
+ parts.append(f"[cyan]- {result.deferred_count} not measured[/cyan]")
319
+ if result.sources_searched:
320
+ parts.append(f"[magenta]sources: {', '.join(result.sources_searched)}[/magenta]")
321
+ console.print(" | ".join(parts))
322
+
323
+ work = _work_line(result)
324
+ if work:
325
+ # Second line rather than more fields on the first: the first line
326
+ # answers "what did it find", this one answers "what did it do",
327
+ # and cramming both together made neither readable at 80 columns.
328
+ console.print(work)
329
+ if result.log_file:
330
+ # Its own line: a wrapped path is a path the user cannot copy.
331
+ console.print(f"[dim]log: {result.log_file}[/dim]", soft_wrap=True)
332
+ console.print()
333
+
334
+
335
+ def _work_line(result: AnalysisResult) -> str:
336
+ """Account for the work the run did, not just what it found.
337
+
338
+ "Analyzed 84/100" says nothing about whether those 84 cost 84 scans or
339
+ 5, which is the difference between four minutes and twenty seconds. The
340
+ numbers were already being computed and thrown away.
341
+ """
342
+ m = result.metrics
343
+ if not m.tags_discovered:
344
+ return ""
345
+
346
+ parts = [f"scans: {m.scans_performed}"]
347
+ if m.cache_hits:
348
+ parts.append(f"cache: {m.cache_hits} hit ({m.cache_hit_rate:.0%})")
349
+ if m.duplicates_collapsed:
350
+ parts.append(f"deduped: {m.duplicates_collapsed}")
351
+ if m.cross_validations:
352
+ parts.append(f"cross-validated: {m.cross_validations}")
353
+ if m.workers:
354
+ parts.append(f"workers: {m.workers}")
355
+ return f"[dim]{' | '.join(parts)}[/dim]"
356
+
357
+
358
+ def _hub_status(analysis: ImageAnalysis) -> str:
359
+ if analysis.hub_tag_verified is True:
360
+ return "[green]OK[/green]"
361
+ if analysis.hub_tag_verified is False:
362
+ return "[red]missing[/red]"
363
+ return "[dim]n/a[/dim]"
364
+
365
+
366
+ def _print_table(analyses: list[ImageAnalysis]) -> None:
367
+ # Kept deliberately narrow so it survives an 80-column terminal without
368
+ # ellipsizing the image reference -- the one cell the reader must be
369
+ # able to copy verbatim. Severity counts collapse into a single
370
+ # Critical/High/Medium cell, and full Hub URLs are listed below.
371
+ table = Table()
372
+ table.add_column("#", justify="right", style="dim")
373
+ table.add_column("Image", style="cyan bold", overflow="fold")
374
+ table.add_column("Source", style="magenta")
375
+ table.add_column("Score", justify="right", style="green", no_wrap=True)
376
+ table.add_column("Tier", justify="center")
377
+ table.add_column("C/H/M", justify="center", no_wrap=True)
378
+ table.add_column("Hard", justify="right", no_wrap=True)
379
+ table.add_column("Surf", justify="right", no_wrap=True)
380
+ table.add_column("Conf", justify="center", no_wrap=True)
381
+ table.add_column("Fix", justify="right", style="green")
382
+ table.add_column("Rem", justify="right")
383
+ table.add_column("Tag", justify="center")
384
+ table.add_column("Threat", justify="center", no_wrap=True)
385
+
386
+ styles = {
387
+ "A": "bold green",
388
+ "B": "bold blue",
389
+ "C": "bold yellow",
390
+ "D": "bold red",
391
+ "E": "bold red",
392
+ "F": "bold white on red",
393
+ }
394
+ for i, a in enumerate(analyses, 1):
395
+ ts = styles.get(a.tier, "")
396
+ # A score two scanners disagree about is not shown as a number:
397
+ # displaying it would imply a confidence the data does not support.
398
+ score = DISPUTED_SCORE_LABEL if a.scan_divergence else str(a.security_score)
399
+ crit_style = "red" if a.scan.critical_count else "dim"
400
+ counts = (
401
+ f"[{crit_style}]{a.scan.critical_count}[/{crit_style}]/"
402
+ f"[yellow]{a.scan.high_count}[/yellow]/{a.scan.medium_count}"
403
+ )
404
+ table.add_row(
405
+ str(i),
406
+ safe(display_reference(a.image.name, a.image.tag)),
407
+ safe(a.image.source),
408
+ score,
409
+ f"[{ts}]{a.tier}[/{ts}]" if ts else a.tier,
410
+ counts,
411
+ _dimension(a.hardening),
412
+ _dimension(a.attack_surface),
413
+ _confidence_label(a.confidence),
414
+ str(a.scan.fixable_count),
415
+ f"{a.remediation_score}/100",
416
+ _hub_status(a),
417
+ _threat_cell(a),
418
+ )
419
+ console.print(table)
420
+ console.print(
421
+ "[dim]C/H/M = Critical/High/Medium | Hard = hardening (higher is better) | "
422
+ "Surf = attack surface (LOWER is better) | Conf = evidence confidence | "
423
+ "Fix = fixable | Rem = remediation | Tag = confirmed in source registry | "
424
+ "Threat = KEV (actively exploited) / EDB (public exploit) findings, from "
425
+ "CISA KEV, FIRST EPSS and Exploit-DB[/dim]"
426
+ )
427
+
428
+
429
+ def _threat_cell(analysis: ImageAnalysis) -> str:
430
+ """Worst-case KEV/Exploit-DB signal across the image's findings, in one cell.
431
+
432
+ The two exploitability signals -- CISA KEV (actively exploited) and
433
+ Exploit-DB (a public exploit exists) -- were already being fetched and
434
+ folded into the security score, but never shown in this table: a reader
435
+ had no way to tell a score reflected them at all. `-` means neither
436
+ catalogue was consulted for any finding in this image (nothing to show
437
+ yet); `none` means they were consulted and found nothing.
438
+ """
439
+ vulns = analysis.scan.vulnerabilities
440
+ if not vulns:
441
+ return "[dim]-[/dim]"
442
+ kev = sum(1 for v in vulns if v.kev_status.is_true)
443
+ edb = sum(1 for v in vulns if v.exploitdb_status.is_true)
444
+ if kev and edb:
445
+ return f"[red bold]KEV:{kev}+EDB:{edb}[/red bold]"
446
+ if kev:
447
+ return f"[red bold]KEV:{kev}[/red bold]"
448
+ if edb:
449
+ return f"[yellow]EDB:{edb}[/yellow]"
450
+ checked = any(
451
+ v.kev_status is not Tristate.UNKNOWN or v.exploitdb_status is not Tristate.UNKNOWN
452
+ for v in vulns
453
+ )
454
+ return "[dim]none[/dim]" if checked else "[dim]-[/dim]"
455
+
456
+
457
+ def _dimension(report: DimensionReport) -> str:
458
+ """Render a dimension, or say plainly that too little was determined.
459
+
460
+ A score computed from two facts out of nine is not a measurement, and
461
+ printing it as a number would let the reader treat it as one. Below the
462
+ reportable threshold the cell says `n/a` and the coverage is shown in
463
+ the details section instead.
464
+ """
465
+ if not report.reportable:
466
+ return "[dim]n/a[/dim]"
467
+ return f"{report.score:.0f}"
468
+
469
+
470
+ #: Colours track trust, not quality: UNVERIFIED is red because nothing was
471
+ #: measured, which is a stronger warning than a low score.
472
+ _CONFIDENCE_STYLES = {
473
+ Confidence.HIGH: "green",
474
+ Confidence.MEDIUM: "yellow",
475
+ Confidence.LOW: "red",
476
+ Confidence.UNVERIFIED: "bold white on red",
477
+ }
478
+
479
+
480
+ def _confidence_label(level: Confidence) -> str:
481
+ style = _CONFIDENCE_STYLES.get(level, "")
482
+ text = level.value[:4]
483
+ return f"[{style}]{text}[/{style}]" if style else text
484
+
485
+
486
+ def _print_why(analyses: list[ImageAnalysis]) -> None:
487
+ """State the case for the top pick, and what it costs.
488
+
489
+ A ranked table answers "which one"; it never answers "why that one".
490
+ Without this section the reader is asked to trust a number whose
491
+ derivation is invisible -- which is exactly the failure mode this tool
492
+ exists to avoid in other people's scanners.
493
+ """
494
+ if not analyses:
495
+ return
496
+ best = analyses[0]
497
+ console.print(f"\n[bold]Why {safe(best.image.full_reference)}?[/bold]")
498
+ for reason in best.why[:10]:
499
+ console.print(f" [green]+[/green] {safe(reason)}")
500
+ if best.trade_offs:
501
+ console.print("\n[bold]Trade-offs[/bold]")
502
+ for cost in best.trade_offs[:10]:
503
+ console.print(f" [yellow]![/yellow] {safe(cost)}")
504
+
505
+ _print_verdict(best)
506
+
507
+ facts = best.facts
508
+ console.print(
509
+ f"\n[dim]Evidence: {facts.determined_count} fact(s) determined | "
510
+ f"hardening coverage {best.hardening.coverage:.0%} | "
511
+ f"scanner {safe(best.scan.scanner)}[/dim]"
512
+ )
513
+ if best.image.digest_known:
514
+ console.print(
515
+ f"[dim]Deploy this exact image: {best.pinned_reference}[/dim]", soft_wrap=True
516
+ )
517
+
518
+
519
+ def _print_verdict(analysis: ImageAnalysis) -> None:
520
+ """State the verdict and the evidence behind it, in that order.
521
+
522
+ The failure this prevents is the one the whole project is organised
523
+ around: a reader glancing at a table and reading an empty findings
524
+ column as "clean". An image nobody could scan and an image with nothing
525
+ wrong occupy the same row shape, so the difference is spelled out --
526
+ the confidence level, what is missing, and whether the thing may go to
527
+ production at all.
528
+ """
529
+ level = analysis.confidence
530
+ style = _CONFIDENCE_STYLES.get(level, "")
531
+ console.print(f"\n[{style}]{level.value}[/{style}]" if style else f"\n{level.value}")
532
+
533
+ # At HIGH the reasons are what was verified; below it they are what is
534
+ # missing. Same list, opposite meaning, so the heading says which.
535
+ console.print(f" [dim]{'Evidence:' if level is Confidence.HIGH else 'Evidence gaps:'}[/dim]")
536
+ for reason in analysis.confidence_reasons:
537
+ console.print(f" - {safe(reason)}")
538
+
539
+ if analysis.production_ready:
540
+ console.print(" [green]Production ready[/green]")
541
+ return
542
+ console.print(" [red]Not production ready[/red]")
543
+ for reason in analysis.readiness_reasons:
544
+ console.print(f" [red]x[/red] {safe(reason)}")
545
+
546
+
547
+ def _print_details(analyses: list[ImageAnalysis]) -> None:
548
+ """Per-image registry link and the scan evidence backing its score.
549
+
550
+ Both are listed below the table rather than in it: URLs and file paths
551
+ are far too wide for a terminal column, and each image needs its *own*
552
+ evidence path, not just a pointer to the aggregate manifest.
553
+ """
554
+ if not analyses:
555
+ return
556
+ console.print("\n[bold]Details[/bold]")
557
+ for i, a in enumerate(analyses, 1):
558
+ console.print(
559
+ f" {i}. [cyan]{safe(a.image.full_reference)}[/cyan] [dim]{safe(a.image.source)}[/dim]"
560
+ )
561
+ if a.hub_url:
562
+ # soft_wrap keeps the URL on one line so it stays copy-pasteable.
563
+ console.print(f" link: [link={a.hub_url}]{a.hub_url}[/link]", soft_wrap=True)
564
+ if a.evidence_paths:
565
+ for scanner, path in sorted(a.evidence_paths.items()):
566
+ note = _shared_scan_note(a, path)
567
+ console.print(f" {scanner + ':':9} [dim]{path}{note}[/dim]", soft_wrap=True)
568
+ else:
569
+ console.print(" [dim]evidence: not recorded[/dim]")
570
+
571
+
572
+ def _shared_scan_note(analysis: ImageAnalysis, path: str) -> str:
573
+ """Flag evidence produced under a sibling tag's name.
574
+
575
+ Tags that share a manifest digest are scanned once and share the
576
+ result, so the file can be named for whichever tag triggered the scan.
577
+ That is correct -- they are the same image -- but without saying so the
578
+ path looks like it belongs to the wrong image.
579
+ """
580
+ if not path:
581
+ return ""
582
+ own_prefix = f"{slugify_reference(analysis.image.full_reference)}__"
583
+ return "" if Path(path).name.startswith(own_prefix) else " (shared digest)"
584
+
585
+
586
+ def _print_tier_warnings(analyses: list[ImageAnalysis]) -> None:
587
+ """Surface tiers that oblige the reader to act.
588
+
589
+ The advice comes from `SecurityTier`, so the terminal states the
590
+ domain's rule rather than a copy of it that can drift.
591
+ """
592
+ flagged = [
593
+ (a, SecurityTier.ADVICE.get(Tier(a.tier), ""))
594
+ for a in analyses
595
+ if not a.production_ready or Tier(a.tier) in SecurityTier.ADVICE
596
+ ]
597
+ flagged = [(a, advice) for a, advice in flagged if advice]
598
+ if not flagged:
599
+ return
600
+ console.print("\n[bold yellow]! Requires review[/bold yellow]")
601
+ for a, advice in flagged:
602
+ console.print(f" {safe(a.image.full_reference)} [dim]Tier {safe(a.tier)}: {advice}[/dim]")
603
+
604
+
605
+ def _print_divergences(analyses: list[ImageAnalysis]) -> None:
606
+ disputed = [a for a in analyses if a.scan_divergence]
607
+ if not disputed:
608
+ return
609
+ console.print("\n[bold yellow]! Scanner divergence[/bold yellow]")
610
+ for a in disputed:
611
+ console.print(f" {safe(a.image.full_reference)}: [dim]{safe(a.scan_divergence)}[/dim]")
612
+
613
+
614
+ def _print_unverified(result: AnalysisResult) -> None:
615
+ """List the tags that could not be scanned, grouped by classified cause.
616
+
617
+ Noventa e três linhas repetindo `ERROR: FATAL Fatal error run error: init
618
+ error: DB error: error in v...` não é diagnóstico: é o mesmo prefixo
619
+ cortado noventa e três vezes, sem nomear causa nenhuma. O resumo por
620
+ causa em cima diz de imediato que se trata de *um* problema -- o banco de
621
+ vulnerabilidades -- e não de noventa e três imagens ruins.
622
+ """
623
+ if not result.unverified:
624
+ return
625
+ console.print("\n[bold yellow]! Unverified (technical error)[/bold yellow]")
626
+ console.print(
627
+ "[dim] These tags were never scored -- no successful scan, no recommendation.[/dim]"
628
+ )
629
+
630
+ by_kind = Counter(item.kind for item in result.unverified)
631
+ console.print(
632
+ " [bold]Causes:[/bold] "
633
+ + ", ".join(f"{kind} x{count}" for kind, count in by_kind.most_common())
634
+ )
635
+
636
+ for item in result.unverified[:10]:
637
+ console.print(
638
+ f" {safe(item.image_reference)} "
639
+ f"[dim]{safe(item.kind)}: {safe(short_reason(item.reason))}[/dim]"
640
+ )
641
+ remaining = len(result.unverified) - 10
642
+ if remaining > 0:
643
+ console.print(f" [dim]... and {remaining} more (see log file)[/dim]")
644
+ console.print(" [dim]Run with --verbose for the full scanner output.[/dim]")
645
+
646
+
647
+ def _print_deferred(result: AnalysisResult) -> None:
648
+ """As tags que a busca achou e o run não mediu, com o motivo.
649
+
650
+ Existe pelo mesmo motivo que `_print_unverified`: uma tabela limpa não
651
+ pode esconder o que ficou de fora. A diferença entre os dois blocos é
652
+ a que importa -- lá estão scans que falharam, aqui estão scans que não
653
+ foram tentados, e o segundo é uma escolha desta ferramenta, não um
654
+ acidente. Por isso o bloco também diz como desfazê-la.
655
+ """
656
+ if not result.deferred:
657
+ return
658
+
659
+ console.print()
660
+ console.print(
661
+ Panel(
662
+ f"[bold cyan]Not Measured ({result.deferred_count} of "
663
+ f"{result.tags_discovered} tags found)[/bold cyan]",
664
+ expand=False,
665
+ )
666
+ )
667
+ console.print(
668
+ "[dim]These tags exist and were not scanned. That is not a verdict on "
669
+ "them: nothing here was measured, so nothing here is being claimed.[/dim]"
670
+ )
671
+ console.print()
672
+
673
+ by_reason = Counter(item.reason.value for item in result.deferred)
674
+ for reason, count in by_reason.most_common():
675
+ console.print(f" [cyan]{reason}[/cyan] {count}")
676
+ console.print()
677
+
678
+ for item in result.deferred[:10]:
679
+ console.print(f" [dim]{safe(item.reference)}[/dim] -- {safe(item.detail)}")
680
+ remaining = result.deferred_count - 10
681
+ if remaining > 0:
682
+ console.print(f" [dim]... and {remaining} more (see --format json)[/dim]")
683
+
684
+ console.print()
685
+ console.print(
686
+ "[dim]To measure every tag found: --budget 0, or set scan_budget = 0 "
687
+ "in the config file.[/dim]"
688
+ )
689
+
690
+
691
+ def _nothing_could_be_measured(result: AnalysisResult) -> bool:
692
+ """True when tags were found but not one of them could be scanned.
693
+
694
+ This is the difference between "we looked and nothing was good enough"
695
+ and "we never managed to look". Both end with an empty table, and only
696
+ the second is a technical failure.
697
+ """
698
+ return result.total_tags_analyzed == 0 and bool(result.unverified)
699
+
700
+
701
+ def _measurement_failure_message(result: AnalysisResult) -> str:
702
+ """Lead with the cause rather than with a verdict about the images.
703
+
704
+ `No suitable images found` on a machine with no scanner installed reads
705
+ as a statement about the tags -- that they were examined and rejected.
706
+ Nothing was examined. Naming the dominant cause up front turns the
707
+ output into something the reader can act on.
708
+ """
709
+ causes = Counter(item.kind for item in result.unverified)
710
+ dominant, count = causes.most_common(1)[0]
711
+ hint = _CAUSE_HINTS.get(
712
+ dominant, "Run with --verbose for the scanner output, or see the log file above."
713
+ )
714
+ return (
715
+ f"[bold red]No image could be scanned.[/bold red]\n"
716
+ f"[red]All {count} candidate(s) failed with: {dominant}[/red]\n\n"
717
+ f"[bold]Suggested action[/bold]\n {hint}\n\n"
718
+ f"[dim]This is a technical failure, not a security verdict: "
719
+ f"nothing was measured, so nothing can be said about these images.[/dim]"
720
+ )
721
+
722
+
723
+ #: What to do about each classified failure cause, in the reader's terms.
724
+ _CAUSE_HINTS = {
725
+ "SCANNER_MISSING": "Install Trivy or Grype, then re-run. `dockerls doctor` checks for both.",
726
+ "DB_INIT_FAILED": (
727
+ "The vulnerability database could not be prepared. Check network access to "
728
+ "ghcr.io, then re-run."
729
+ ),
730
+ "TIMEOUT": (
731
+ "Scans exceeded the timeout. Raise DOCKERLS_SCANNER_TIMEOUT, or lower --workers "
732
+ "to reduce contention."
733
+ ),
734
+ "RATE_LIMITED": "Rate limited by the registry. Run `dockerls login`, or retry later.",
735
+ "AUTH_REQUIRED": "The registry requires credentials. Run `dockerls login`.",
736
+ "NOT_FOUND": "None of the discovered tags could be pulled. Check the image name.",
737
+ }
738
+
739
+
740
+ def _exit_code(result: AnalysisResult, fail_on: FailOn) -> int:
741
+ items = result.recommendations or result.alternatives
742
+ if items and fail_on != FailOn.NONE:
743
+ counter = _FAIL_ON_COUNT[fail_on]
744
+ if counter(items[0]) > 0:
745
+ return EXIT_ERROR_CODE
746
+
747
+ if result.baseline_met and result.recommendations:
748
+ return EXIT_BASELINE_MET
749
+ if result.alternatives:
750
+ return EXIT_ALTERNATIVES_FOUND
751
+ if result.total_tags_scanned == 0:
752
+ return EXIT_ERROR_CODE
753
+ # Tags were discovered but not one could be scanned. Code 3 is published
754
+ # as "nothing usable was found" -- a statement about the *images*, which
755
+ # a CI gate is entitled to act on. With no scanner installed, or a
756
+ # vulnerability database that would not download, nothing was measured
757
+ # at all, and reporting that as a verdict is exactly the substitution
758
+ # this tool must never make. It is an operational failure: code 1.
759
+ if _nothing_could_be_measured(result):
760
+ return EXIT_ERROR_CODE
761
+ return EXIT_NONE_FOUND