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,291 @@
1
+ """`dockerls alternatives <image:tag>` -- safer replacements for what you run today.
2
+
3
+ `recommend` answers "what should I use for node"; this answers the question
4
+ people actually have, which is "I am running *this*, is there something
5
+ better, and what does switching cost me". The difference is the baseline: a
6
+ concrete image the user already depends on, scanned under the same pipeline
7
+ as the candidates so the comparison is between two measurements rather than
8
+ between a measurement and a reputation.
9
+
10
+ Three properties make the output honest:
11
+
12
+ * the current image is scanned, not assumed. If it cannot be scanned, the
13
+ command says so and refuses to claim an improvement it cannot compute;
14
+ * every alternative is ranked by the same multi-source policy as
15
+ `recommend`, so a hardened-catalogue image wins on evidence rather than
16
+ on the name of its vendor;
17
+ * trade-offs are printed next to gains. A libc change, a missing shell or a
18
+ different package manager will break somebody's build, and a migration
19
+ suggestion that hides that is worse than no suggestion.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import json
26
+ from typing import TYPE_CHECKING
27
+
28
+ import typer
29
+ from rich.console import Console
30
+ from rich.panel import Panel
31
+ from rich.table import Table
32
+
33
+ from dockerls.application.services.migration import MigrationPlan, plan_migration
34
+ from dockerls.application.services.source_registry import UnknownSourceError
35
+ from dockerls.cli.dependencies import build_analyze_use_case, build_recommend_use_case
36
+ from dockerls.cli.image_names import display_reference, split_repository_and_tag
37
+ from dockerls.cli.options import OutputFormat, parse_output_format
38
+ from dockerls.cli.progress import RichScanObserver
39
+ from dockerls.cli.scan_failure import describe_scan_failure
40
+ from dockerls.cli.text import safe
41
+ from dockerls.cli.validators import check_workers
42
+ from dockerls.exit_codes import EXIT_ERROR, EXIT_OK
43
+
44
+ if TYPE_CHECKING:
45
+ from dockerls.application.dto.analysis import ImageAnalysis
46
+
47
+ console = Console()
48
+ # Diagnostics go to stderr, results to stdout. Printing a warning to stdout
49
+ # put a human sentence in front of the JSON document and made `--format json`
50
+ # unparseable -- a machine-readable format is only machine-readable if
51
+ # nothing else can land in the stream.
52
+ diagnostics = Console(stderr=True)
53
+
54
+ #: Exit codes, consistent with `recommend`:
55
+ #: 0 = a safer alternative was found (or the current image is already best)
56
+ #: 1 = a technical failure: the current image could not be measured
57
+ #: 2 = alternatives exist but none of them clears the baseline
58
+ EXIT_FOUND = EXIT_OK
59
+ EXIT_BELOW_BASELINE = 2
60
+
61
+ #: How many alternatives to present. More than this and the output stops
62
+ #: being a decision and becomes a catalogue.
63
+ TOP_ALTERNATIVES = 4
64
+
65
+
66
+ def alternatives(
67
+ image: str = typer.Argument(help="The image you run today (e.g. node:22, python:3.12-slim)"),
68
+ workers: int | None = typer.Option(
69
+ None,
70
+ "--workers",
71
+ "-w",
72
+ help="Concurrent scanner processes; 0 sizes it to this machine [config: workers]",
73
+ ),
74
+ source: list[str] = typer.Option(
75
+ [],
76
+ "--source",
77
+ "-s",
78
+ help="Image source to search; repeatable. dockerhub, chainguard, distroless, dhi, "
79
+ "private (if configured), all",
80
+ ),
81
+ all_sources: bool = typer.Option(
82
+ False, "--all-sources", help="Search every configured source, including opt-in ones"
83
+ ),
84
+ output_format: str = typer.Option(
85
+ OutputFormat.TABLE.value, "--format", "-f", help="Output format: table or json"
86
+ ),
87
+ no_color: bool = typer.Option(False, "--no-color", help="Disable colored output"),
88
+ no_progress: bool = typer.Option(False, "--no-progress", help="Disable the progress display"),
89
+ ) -> None:
90
+ """Find safer alternatives to an image you already run, with trade-offs."""
91
+ if no_color:
92
+ console.no_color = True
93
+ fmt = parse_output_format(output_format)
94
+ # `0` means "size it to this machine", so it is passed through rather
95
+ # than validated: the resolver, not the flag, decides what it becomes.
96
+ if workers:
97
+ workers = check_workers(workers)
98
+ try:
99
+ asyncio.run(
100
+ _alternatives(
101
+ image,
102
+ workers,
103
+ fmt,
104
+ sources=list(source) or None,
105
+ all_sources=all_sources,
106
+ show_progress=not no_progress and fmt != OutputFormat.JSON,
107
+ )
108
+ )
109
+ except UnknownSourceError as e:
110
+ console.print(f"[red]Error:[/red] {e}")
111
+ raise typer.Exit(EXIT_ERROR) from e
112
+ except ValueError as e:
113
+ console.print(f"[red]Invalid configuration:[/red] {e}")
114
+ raise typer.Exit(EXIT_ERROR) from e
115
+
116
+
117
+ async def _alternatives(
118
+ reference: str,
119
+ workers: int | None,
120
+ output_format: OutputFormat,
121
+ *,
122
+ sources: list[str] | None = None,
123
+ all_sources: bool = False,
124
+ show_progress: bool = True,
125
+ ) -> None:
126
+ # A regra compartilhada, e não um `rsplit(":", 1)`: a porta de um
127
+ # registry (`registry.internal:5000/app`) não é tag, e procurar o
128
+ # repositório "registry.internal" não devolve nada.
129
+ repository, tag = split_repository_and_tag(reference)
130
+
131
+ current = await _analyze_current(reference)
132
+ if current is None:
133
+ # No measurement of the current image means no honest claim about
134
+ # an improvement over it. The command fails rather than presenting
135
+ # candidates against an unknown baseline.
136
+ _report_unmeasurable(reference, output_format)
137
+ raise typer.Exit(EXIT_ERROR)
138
+
139
+ with RichScanObserver(enabled=show_progress) as observer:
140
+ use_case = await build_recommend_use_case(
141
+ workers=workers,
142
+ observer=observer,
143
+ sources=sources,
144
+ all_sources=all_sources,
145
+ )
146
+ result = await use_case.execute(repository)
147
+
148
+ candidates = [
149
+ a
150
+ for a in (result.recommendations or result.alternatives)
151
+ if a.image.full_reference != current.image.full_reference and a.confidence.is_recommendable
152
+ ][:TOP_ALTERNATIVES]
153
+
154
+ plans = [plan_migration(current, candidate) for candidate in candidates]
155
+
156
+ if output_format == OutputFormat.JSON:
157
+ console.print(
158
+ json.dumps(
159
+ {
160
+ "current": current.model_dump(),
161
+ "alternatives": [
162
+ {"image": c.model_dump(), "migration": p.model_dump()}
163
+ for c, p in zip(candidates, plans, strict=True)
164
+ ],
165
+ "baseline_met": result.baseline_met,
166
+ "sources_searched": result.sources_searched,
167
+ "metrics": result.metrics.model_dump(),
168
+ },
169
+ indent=2,
170
+ default=str,
171
+ ),
172
+ soft_wrap=True,
173
+ )
174
+ raise typer.Exit(EXIT_FOUND if result.baseline_met else EXIT_BELOW_BASELINE)
175
+
176
+ _render(reference, tag, current, candidates, plans)
177
+ raise typer.Exit(EXIT_FOUND if result.baseline_met else EXIT_BELOW_BASELINE)
178
+
179
+
180
+ async def _analyze_current(reference: str) -> ImageAnalysis | None:
181
+ """Scan the image the user runs today, or return None if it cannot be.
182
+
183
+ A falha do scan chega de duas formas, e as duas significam a mesma
184
+ coisa aqui. Uma exceção é o caso antigo; o caso comum é um
185
+ `ImageAnalysis` cujo scan não completou, que carrega score 0.0 e tier F
186
+ por construção. Tratar o segundo como medição faria a baseline desta
187
+ comparação valer zero, e toda alternativa apareceria como uma melhoria
188
+ enorme sobre uma imagem que ninguém mediu.
189
+ """
190
+ use_case = await build_analyze_use_case()
191
+ try:
192
+ analysis = await use_case.execute(reference)
193
+ except (ValueError, RuntimeError) as e:
194
+ diagnostics.print(f"[yellow]Could not analyze {reference}: {e}[/yellow]")
195
+ return None
196
+ finally:
197
+ # O scanner e o pool de conexões do repositório ficam abertos até
198
+ # alguém fechá-los, e este comando abre um segundo conjunto logo
199
+ # abaixo para a busca. `analyze` fecha no mesmo ponto.
200
+ await use_case.close()
201
+
202
+ if not analysis.scan.is_verified:
203
+ cause = describe_scan_failure(analysis.scan.error_kind, analysis.scan.error_message)
204
+ diagnostics.print(f"[yellow]Could not analyze {safe(reference)}: {safe(cause)}[/yellow]")
205
+ return None
206
+ return analysis
207
+
208
+
209
+ def _report_unmeasurable(reference: str, output_format: OutputFormat) -> None:
210
+ message = (
211
+ f"{reference} could not be scanned, so no improvement over it can be measured. "
212
+ "This is a technical failure, not a verdict about the image."
213
+ )
214
+ if output_format == OutputFormat.JSON:
215
+ console.print(json.dumps({"error": message, "current": reference}), soft_wrap=True)
216
+ else:
217
+ console.print(f"[red]{message}[/red]")
218
+
219
+
220
+ def _render(
221
+ reference: str,
222
+ tag: str,
223
+ current: ImageAnalysis,
224
+ candidates: list[ImageAnalysis],
225
+ plans: list[MigrationPlan],
226
+ ) -> None:
227
+ console.print(Panel(f"[bold cyan]Alternatives to {reference}[/bold cyan]", expand=False))
228
+
229
+ console.print("\n[bold]CURRENT[/bold]")
230
+ console.print(
231
+ f" {safe(current.image.full_reference)} [dim]{safe(current.image.source)}[/dim]"
232
+ )
233
+ console.print(
234
+ f" score {current.security_score} tier {current.tier} "
235
+ f"C/H/M {current.scan.critical_count}/{current.scan.high_count}/"
236
+ f"{current.scan.medium_count}" + (f" [dim](tag {tag})[/dim]" if tag else "")
237
+ )
238
+
239
+ if not candidates:
240
+ console.print(
241
+ "\n[yellow]No alternative scored better than what you already run.[/yellow]\n"
242
+ "[dim]That is a result, not a failure: staying put is the right call "
243
+ "when nothing measured better.[/dim]"
244
+ )
245
+ return
246
+
247
+ console.print("\n[bold]RECOMMENDED ALTERNATIVES[/bold]")
248
+ table = Table()
249
+ table.add_column("#", justify="right", style="dim")
250
+ table.add_column("Image", style="cyan bold", overflow="fold")
251
+ table.add_column("Source", style="magenta")
252
+ table.add_column("Score", justify="right")
253
+ table.add_column("Delta", justify="right")
254
+ table.add_column("C/H/M", justify="center", no_wrap=True)
255
+ table.add_column("Conf", justify="center")
256
+
257
+ for i, (candidate, plan) in enumerate(zip(candidates, plans, strict=True), 1):
258
+ delta = plan.score_delta
259
+ delta_text = f"[green]+{delta:.1f}[/green]" if delta > 0 else f"[red]{delta:.1f}[/red]"
260
+ table.add_row(
261
+ str(i),
262
+ safe(display_reference(candidate.image.name, candidate.image.tag)),
263
+ safe(candidate.image.source),
264
+ f"{candidate.security_score:.1f}",
265
+ delta_text,
266
+ f"{candidate.scan.critical_count}/{candidate.scan.high_count}/"
267
+ f"{candidate.scan.medium_count}",
268
+ candidate.confidence.value[:4],
269
+ )
270
+ console.print(table)
271
+
272
+ best, best_plan = candidates[0], plans[0]
273
+ console.print(f"\n[bold]WHY {safe(best.image.full_reference)}[/bold]")
274
+ for reason in best_plan.improvements[:8]:
275
+ console.print(f" [green]+[/green] {safe(reason)}")
276
+
277
+ if best_plan.trade_offs:
278
+ console.print("\n[bold]TRADE-OFFS[/bold]")
279
+ for cost in best_plan.trade_offs[:8]:
280
+ console.print(f" [yellow]![/yellow] {safe(cost)}")
281
+
282
+ console.print("\n[bold]MIGRATION CHECKLIST[/bold]")
283
+ for step_number, step in enumerate(best_plan.checklist, 1):
284
+ console.print(f" {step_number}. {safe(step)}")
285
+
286
+ if best.image.digest_known:
287
+ console.print(f"\n[dim]Pin to: {best.pinned_reference}[/dim]", soft_wrap=True)
288
+ console.print(
289
+ "\n[dim]Compatibility is never assumed: the checklist above exists because "
290
+ "no scan can tell you whether your application still runs.[/dim]"
291
+ )