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
dockerls/cli/app.py ADDED
@@ -0,0 +1,294 @@
1
+ """O ponto de entrada da CLI, com os subcomandos carregados sob demanda.
2
+
3
+ Este módulo importava os 24 comandos no topo, e cada um deles puxa a
4
+ árvore inteira da aplicação: o pipeline de scan, os clientes HTTP, o
5
+ pydantic-settings, o SQLAlchemy. Como o `import` é o que registra o
6
+ comando no Typer, `dockerls version` -- que só imprime uma string --
7
+ pagava os mesmos ~390ms de arranque que `dockerls advisor`.
8
+
9
+ Aqui o registro deixa de depender do import. `_LazyGroup` conhece os
10
+ comandos por uma tabela estática (nome, módulo, resumo) e só importa o
11
+ módulo quando o comando é de fato invocado. O `--help` do grupo é
12
+ desenhado a partir da tabela, sem importar nada.
13
+
14
+ O preço é a tabela precisar acompanhar as docstrings, e é por isso que
15
+ `tests/unit/cli/test_lazy_command_table.py` compara as duas: um comando
16
+ novo, renomeado ou com o resumo alterado quebra o teste antes de chegar
17
+ na CLI.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import sys
23
+ from dataclasses import dataclass
24
+ from importlib import import_module
25
+ from typing import TYPE_CHECKING, Any
26
+
27
+ import typer
28
+ from typer.core import TyperCommand, TyperGroup
29
+ from typer.main import get_command_from_info, get_group
30
+ from typer.models import CommandInfo
31
+
32
+ if TYPE_CHECKING:
33
+ from collections.abc import MutableMapping
34
+
35
+ from typer import _click
36
+
37
+ #: O click que o Typer usa. A partir do Typer 0.27 ele é vendorizado em
38
+ #: `typer._click`, e não há mais um `click` de topo garantido no ambiente;
39
+ #: `typer.core.TyperCommand`/`TyperGroup` são as classes públicas
40
+ #: correspondentes, e é delas que herdamos.
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class _Lazy:
45
+ """Um subcomando e onde encontrá-lo, sem importá-lo."""
46
+
47
+ #: Nome na linha de comando.
48
+ name: str
49
+ #: Módulo em `dockerls.cli.commands`.
50
+ module: str
51
+ #: Nome do objeto dentro do módulo.
52
+ attr: str
53
+ #: Primeiro parágrafo da docstring, que é o que o `--help` do grupo
54
+ #: mostra. Duplicado aqui de propósito: é o único jeito de listar os
55
+ #: comandos sem importar todos eles.
56
+ summary: str
57
+ #: `True` quando o objeto é um `typer.Typer` (um subgrupo) em vez de
58
+ #: uma função de comando.
59
+ is_group: bool = False
60
+
61
+ def load(self, *, rich_markup_mode: Any) -> _click.Command:
62
+ """Importa o módulo e monta o comando click de verdade."""
63
+ target = getattr(import_module(f"dockerls.cli.commands.{self.module}"), self.attr)
64
+ if self.is_group:
65
+ group = get_group(target)
66
+ group.name = self.name
67
+ group.help = self.summary
68
+ return group
69
+ command = get_command_from_info(
70
+ CommandInfo(name=self.name, callback=target),
71
+ pretty_exceptions_short=False,
72
+ rich_markup_mode=rich_markup_mode,
73
+ )
74
+ return command
75
+
76
+
77
+ #: Os subcomandos, na ordem em que aparecem no `--help`.
78
+ COMMANDS: tuple[_Lazy, ...] = (
79
+ _Lazy(
80
+ "search",
81
+ "search",
82
+ "search",
83
+ "Search for available tags of an image, on Docker Hub or any configured source.",
84
+ ),
85
+ _Lazy("recommend", "recommend", "recommend", "Recommend the most secure Docker image tags."),
86
+ _Lazy(
87
+ "advisor",
88
+ "advisor",
89
+ "advisor",
90
+ "Security advisor: analyze and provide actionable remediation plan.",
91
+ ),
92
+ _Lazy(
93
+ "alternatives",
94
+ "alternatives",
95
+ "alternatives",
96
+ "Find safer alternatives to an image you already run, with trade-offs.",
97
+ ),
98
+ _Lazy("analyze", "analyze", "analyze", "Deep-analyze a specific Docker image tag."),
99
+ _Lazy(
100
+ "analyze-dockerfile",
101
+ "analyze_dockerfile",
102
+ "analyze",
103
+ "Analyze a Dockerfile for security problems.",
104
+ ),
105
+ _Lazy(
106
+ "base",
107
+ "base_cmd",
108
+ "base",
109
+ "Check the Dockerfile bases against the registry and refresh their digests.",
110
+ ),
111
+ _Lazy(
112
+ "base-image",
113
+ "base_image",
114
+ "base_image",
115
+ "Generate the Dockerfile for a base image from a menu of choices.",
116
+ ),
117
+ _Lazy(
118
+ "build",
119
+ "build",
120
+ "build",
121
+ "Build secure Docker images with validation, scanning and auto-remediation.",
122
+ ),
123
+ _Lazy("compare", "compare", "compare", "Compare security posture of multiple Docker images."),
124
+ _Lazy(
125
+ "controls",
126
+ "controls",
127
+ "controls",
128
+ "Show the security controls each Dockerfile rule implements.",
129
+ ),
130
+ _Lazy(
131
+ "fleet",
132
+ "fleet",
133
+ "fleet",
134
+ "Scan a tree of repositories and summarise the state of its Dockerfiles.",
135
+ ),
136
+ _Lazy(
137
+ "policy",
138
+ "policy_cmd",
139
+ "policy",
140
+ "Show and validate the policy declared in `.dockerls-policy.yaml`.",
141
+ ),
142
+ _Lazy(
143
+ "provenance",
144
+ "provenance_cmd",
145
+ "provenance",
146
+ "Check a provenance document and prepare the attestation.",
147
+ ),
148
+ _Lazy(
149
+ "registry-audit",
150
+ "registry_audit_cmd",
151
+ "registry_audit",
152
+ "Establish, through the registry, what is known about a published image.",
153
+ ),
154
+ _Lazy("verify", "verify", "verify", "Check an image signature with cosign."),
155
+ _Lazy("export", "export", "export", "Export analysis results in various formats."),
156
+ _Lazy(
157
+ "login",
158
+ "login",
159
+ "login",
160
+ "Authenticate with Docker Hub. Credentials are stored in your system keyring.",
161
+ ),
162
+ _Lazy("logout", "login", "logout", "Remove stored Docker Hub credentials."),
163
+ _Lazy("version", "version", "version", "Show DockerLs version."),
164
+ _Lazy("doctor", "doctor", "doctor", "Check system dependencies and configuration."),
165
+ _Lazy("health", "health", "health", "Check connectivity to external services."),
166
+ _Lazy(
167
+ "sbom",
168
+ "sbom",
169
+ "sbom",
170
+ "Generate a Software Bill of Materials (SBOM) for an image via Trivy.",
171
+ ),
172
+ _Lazy(
173
+ "vex",
174
+ "vex_cmd",
175
+ "vex",
176
+ "Emit this repository's exemptions as an OpenVEX document.",
177
+ ),
178
+ _Lazy("cache", "cache_cmd", "cache_app", "Manage scan cache", is_group=True),
179
+ )
180
+
181
+ _BY_NAME: dict[str, _Lazy] = {spec.name: spec for spec in COMMANDS}
182
+
183
+
184
+ class _Stub(TyperCommand):
185
+ """O comando como o `--help` do grupo o vê: nome e resumo, mais nada.
186
+
187
+ Existe para que listar os comandos não importe nenhum deles. Tudo que
188
+ precisa do comando de verdade -- executar, mostrar o próprio `--help`,
189
+ completar argumentos -- passa por `make_context`/`shell_complete`, e
190
+ esses resolvem o módulo antes de responder.
191
+ """
192
+
193
+ def __init__(self, spec: _Lazy, rich_markup_mode: Any) -> None:
194
+ super().__init__(name=spec.name, short_help=spec.summary)
195
+ self._spec = spec
196
+ self._rich_markup_mode = rich_markup_mode
197
+
198
+ def _real(self) -> _click.Command:
199
+ return self._spec.load(rich_markup_mode=self._rich_markup_mode)
200
+
201
+ def make_context(
202
+ self,
203
+ info_name: str | None,
204
+ args: list[str],
205
+ parent: _click.Context | None = None,
206
+ **extra: Any,
207
+ ) -> _click.Context:
208
+ # O contexto devolvido carrega o comando de verdade, e é dele que
209
+ # o click tira o `invoke`: o stub some da execução aqui.
210
+ return self._real().make_context(info_name, args, parent=parent, **extra)
211
+
212
+ def shell_complete(self, ctx: _click.Context, incomplete: str) -> list[Any]:
213
+ return self._real().shell_complete(ctx, incomplete)
214
+
215
+
216
+ class _LazyGroup(TyperGroup):
217
+ """O grupo raiz: conhece os nomes sempre, os módulos só quando preciso."""
218
+
219
+ def list_commands(self, ctx: _click.Context) -> list[str]:
220
+ return [spec.name for spec in COMMANDS]
221
+
222
+ def get_command(self, ctx: _click.Context, cmd_name: str) -> _click.Command | None:
223
+ spec = _BY_NAME.get(cmd_name)
224
+ if spec is None:
225
+ return None
226
+ return _Stub(spec, self.rich_markup_mode)
227
+
228
+ @property
229
+ def commands(self) -> MutableMapping[str, _click.Command]:
230
+ """Compatibilidade: click e typer leem `.commands` em alguns
231
+ caminhos (completação, `to_info_dict`). Devolver os stubs mantém
232
+ esses caminhos funcionando sem importar nada."""
233
+ return {spec.name: _Stub(spec, self.rich_markup_mode) for spec in COMMANDS}
234
+
235
+ @commands.setter
236
+ def commands(self, value: MutableMapping[str, _click.Command]) -> None:
237
+ # `TyperGroup.__init__` atribui o dicionário (vazio) de comandos
238
+ # registrados; a tabela estática é a fonte da verdade, então a
239
+ # atribuição é descartada.
240
+ return
241
+
242
+
243
+ app = typer.Typer(
244
+ name="dockerls",
245
+ cls=_LazyGroup,
246
+ help="DockerLs -- Enterprise Docker Image Security Advisor. "
247
+ "Discover the most secure Docker images available on Docker Hub.",
248
+ no_args_is_help=True,
249
+ pretty_exceptions_enable=False,
250
+ )
251
+
252
+
253
+ def configure_logging() -> None:
254
+ """Detach loguru's default sink, importing the container only now.
255
+
256
+ Indireção de propósito: nem `cli.runtime` pode ser importado no topo,
257
+ ou `dockerls --help` -- que não configura sink nenhum, porque nem chega
258
+ a invocar um subcomando -- pagaria o pydantic-settings junto. Manter o
259
+ nome aqui, em vez de esconder o import dentro de `_bootstrap`, preserva
260
+ o ponto de substituição que os testes de bootstrap usam.
261
+ """
262
+ from dockerls.cli import runtime
263
+
264
+ runtime.configure_logging()
265
+
266
+
267
+ @app.callback()
268
+ def _bootstrap() -> None:
269
+ """Runs before every subcommand, so no command can start with loguru's
270
+ default DEBUG-to-stderr sink still attached."""
271
+ configure_logging()
272
+
273
+
274
+ def main() -> None:
275
+ try:
276
+ app()
277
+ finally:
278
+ _dispose_cache_if_loaded()
279
+
280
+
281
+ def _dispose_cache_if_loaded() -> None:
282
+ """Close the shared SQLite engine, without paying to import the module
283
+ that owns it when the command never touched it.
284
+
285
+ `cli.dependencies` pulls in SQLAlchemy and the rest of the application
286
+ stack, which is exactly what the lazy command table above exists to
287
+ keep `dockerls --help`/`version` from paying for. Checking
288
+ `sys.modules` instead of importing unconditionally means a command that
289
+ never built a cache -- which never imports `cli.dependencies` at all --
290
+ still doesn't, even after it finishes.
291
+ """
292
+ dependencies = sys.modules.get("dockerls.cli.dependencies")
293
+ if dependencies is not None:
294
+ dependencies.close_cache()
File without changes
@@ -0,0 +1,262 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from typing import TYPE_CHECKING
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.panel import Panel
10
+ from rich.table import Table
11
+
12
+ from dockerls.application.services.ecosystems import get_ecosystem_insights
13
+ from dockerls.application.services.migration import MigrationPlan, plan_migration
14
+ from dockerls.application.use_cases.recommend_images import build_recommendation
15
+ from dockerls.cli.dependencies import build_analyze_use_case, build_recommend_use_case
16
+ from dockerls.cli.image_names import split_repository_and_tag
17
+ from dockerls.cli.options import OutputFormat, parse_output_format
18
+ from dockerls.cli.scan_failure import describe_scan_failure
19
+ from dockerls.cli.text import safe
20
+ from dockerls.cli.validators import check_workers
21
+ from dockerls.exit_codes import EXIT_ERROR
22
+
23
+ if TYPE_CHECKING:
24
+ from dockerls.application.dto.analysis import ImageAnalysis
25
+
26
+ console = Console()
27
+ # Diagnostics go to stderr, results to stdout. Printing a warning to stdout
28
+ # put a human sentence in front of the JSON document and made `--format json`
29
+ # unparseable -- a machine-readable format is only machine-readable if
30
+ # nothing else can land in the stream.
31
+ diagnostics = Console(stderr=True)
32
+
33
+
34
+ def advisor(
35
+ image: str = typer.Argument(help="Docker image name (e.g., node, python, nginx)"),
36
+ workers: int | None = typer.Option(
37
+ None,
38
+ "--workers",
39
+ "-w",
40
+ help="Concurrent scanner processes; 0 sizes it to this machine [config: workers]",
41
+ ),
42
+ output_format: str = typer.Option(
43
+ OutputFormat.TABLE.value, "--format", "-f", help="Output format: table or json"
44
+ ),
45
+ no_color: bool = typer.Option(False, "--no-color", help="Disable colored output"),
46
+ ) -> None:
47
+ """Security advisor: analyze and provide actionable remediation plan."""
48
+ if no_color:
49
+ console.no_color = True
50
+ fmt = parse_output_format(output_format)
51
+ # `0` means "size it to this machine", so it is passed through rather
52
+ # than validated: the resolver, not the flag, decides what it becomes.
53
+ if workers:
54
+ workers = check_workers(workers)
55
+ try:
56
+ asyncio.run(_advisor(image, workers, fmt))
57
+ except ValueError as e:
58
+ console.print(f"[red]Invalid configuration:[/red] {e}")
59
+ raise typer.Exit(EXIT_ERROR) from e
60
+
61
+
62
+ async def _advisor(image: str, workers: int | None, output_format: OutputFormat) -> None:
63
+ # A tagged argument names an image the user runs *today*, so the advice
64
+ # can be a migration rather than a standalone suggestion. A bare name
65
+ # ("node") has no current image to move away from, and the command
66
+ # behaves exactly as it always has.
67
+ # O `rsplit(":", 1)` que morava aqui lia a porta do registry como tag:
68
+ # `advisor registry.internal:5000/app` procurava o repositório
69
+ # "registry.internal". A regra compartilhada só aceita dois-pontos no
70
+ # último segmento do caminho.
71
+ repository, current_tag = split_repository_and_tag(image)
72
+ current = await _analyze_current(image) if current_tag else None
73
+
74
+ use_case = await build_recommend_use_case(workers=workers)
75
+ result = await use_case.execute(repository)
76
+
77
+ items = result.recommendations or result.alternatives
78
+ if not items:
79
+ if output_format == OutputFormat.JSON:
80
+ error_payload = {"error": "No images found to advise on", "errors": result.errors}
81
+ console.print(json.dumps(error_payload), soft_wrap=True)
82
+ else:
83
+ console.print("[red]No images found to advise on.[/red]")
84
+ raise typer.Exit(EXIT_ERROR)
85
+
86
+ best = items[0]
87
+ # O plano de remediação é sobre a imagem que o usuário roda **hoje**,
88
+ # sempre que ele nomeou uma. Montá-lo sobre `best` -- a candidata que a
89
+ # busca elegeu -- descrevia as CVEs de outra imagem sob o título da que
90
+ # foi pedida: `advisor eclipse-temurin:21-jre-alpine` respondia com
91
+ # "Update stdlib (go1.26.5 -> 1.25.13)" e IDs `GO-...`, que não existem
92
+ # dentro de um JRE. Corrigir a saída não bastaria: os passos precisam
93
+ # nascer das vulnerabilidades que o scanner devolveu para *aquela*
94
+ # imagem, e é isso que a escolha do alvo aqui garante.
95
+ #
96
+ # Sem tag na linha de comando não há imagem atual, e o plano sobre a
97
+ # melhor candidata é o comportamento correto (e o original).
98
+ target = current if current is not None else best
99
+ rec = target.recommendation or build_recommendation(target)
100
+ insights = get_ecosystem_insights(target.image.full_reference or image)
101
+ # Only when the target is genuinely a different image: a "migration"
102
+ # from an image to itself is noise, and printing a checklist for it
103
+ # would suggest work that does not exist.
104
+ plan = (
105
+ plan_migration(current, best)
106
+ if current is not None and current.image.full_reference != best.image.full_reference
107
+ else None
108
+ )
109
+
110
+ if output_format == OutputFormat.JSON:
111
+ payload = best.model_dump()
112
+ payload["remediation"] = rec.model_dump()
113
+ # Qual imagem o plano endereça, dito explicitamente: o documento
114
+ # carrega `best` na raiz, e sem este campo um consumidor não teria
115
+ # como saber que a remediação é sobre outra imagem.
116
+ payload["remediation_target"] = target.image.full_reference
117
+ payload["ecosystem_insights"] = {
118
+ # As particularidades seguem o mesmo alvo do plano, e não a raiz
119
+ # do documento (que carrega `best`). Sem este campo um consumidor
120
+ # leria conselho sobre Debian ao lado de uma candidata Alpine sem
121
+ # nada dizendo que são imagens diferentes.
122
+ "for": target.image.full_reference,
123
+ "ecosystem": insights.ecosystem,
124
+ "version": insights.version,
125
+ "runtime_features": insights.runtime_features,
126
+ "base_distro_advice": insights.base_distro_advice,
127
+ "security_guidelines": insights.security_guidelines,
128
+ "common_pitfalls": insights.common_pitfalls,
129
+ "snippets": insights.recommended_dockerfile_snippets,
130
+ }
131
+ if plan is not None:
132
+ payload["migration"] = plan.model_dump()
133
+ if current is not None:
134
+ payload["current"] = current.model_dump()
135
+ console.print(json.dumps(payload, indent=2, default=str), soft_wrap=True)
136
+ return
137
+
138
+ console.print(Panel(f"[bold cyan]DockerLs Security Advisor: {image}[/bold cyan]", expand=False))
139
+ console.print()
140
+
141
+ info = Table(show_header=False, box=None, padding=(0, 2))
142
+ info.add_column("Key", style="bold")
143
+ info.add_column("Value")
144
+ info.add_row("Current Best Image", f"[cyan]{safe(best.image.full_reference)}[/cyan]")
145
+ info.add_row("Ecosystem / Runtime", f"{insights.ecosystem} ({insights.version})")
146
+ info.add_row("Security Score", f"[green]{best.security_score}[/green]")
147
+ info.add_row("Tier", best.tier)
148
+ info.add_row("Critical", f"[red]{best.scan.critical_count}[/red]")
149
+ info.add_row("High", f"[yellow]{best.scan.high_count}[/yellow]")
150
+ info.add_row("Medium", str(best.scan.medium_count))
151
+ info.add_row("Fixable High", str(best.scan.fixable_high_count))
152
+ info.add_row("Remediation Score", f"{best.remediation_score}%")
153
+ info.add_row("EOL", "Yes" if best.is_eol else "No")
154
+ info.add_row("LTS", "Yes" if best.is_lts else "No")
155
+ console.print(info)
156
+
157
+ if insights.base_distro_advice or insights.security_guidelines:
158
+ console.print()
159
+ console.print(
160
+ Panel(
161
+ "[bold magenta]Ecosystem Particularities & Hardening[/bold magenta]",
162
+ expand=False,
163
+ )
164
+ )
165
+ if insights.base_distro_advice:
166
+ console.print("\n[bold]Base Image & Distribution Notes:[/bold]")
167
+ for advice in insights.base_distro_advice:
168
+ console.print(f" - {advice}")
169
+ if insights.security_guidelines:
170
+ console.print("\n[bold]Production & Security Guidelines:[/bold]")
171
+ for item in insights.security_guidelines:
172
+ console.print(f" - {item}")
173
+ if insights.common_pitfalls:
174
+ console.print("\n[bold red]Common Pitfalls to Avoid:[/bold red]")
175
+ for pit in insights.common_pitfalls:
176
+ console.print(f" [yellow]![/yellow] {pit}")
177
+
178
+ if plan is not None:
179
+ _print_migration(plan)
180
+
181
+ if rec.steps:
182
+ console.print()
183
+ # A imagem nomeada no cabeçalho: um plano cujos passos citam versões
184
+ # de pacote precisa dizer de qual imagem essas versões vieram.
185
+ console.print(
186
+ f"[bold]Remediation Plan[/bold] [dim]for {safe(target.image.full_reference)}[/dim]"
187
+ )
188
+ console.print()
189
+ for step in rec.steps:
190
+ desc = step.description
191
+ if step.from_value and step.to_value:
192
+ desc += f" [dim]({step.from_value} -> {step.to_value})[/dim]"
193
+ console.print(f" STEP {step.step_number}: {desc}")
194
+ if step.expected_impact:
195
+ console.print(f" [dim]{step.expected_impact}[/dim]")
196
+
197
+ if rec.summary:
198
+ console.print()
199
+ console.print(f"[bold]Summary:[/bold] {rec.summary}")
200
+
201
+
202
+ async def _analyze_current(reference: str) -> ImageAnalysis | None:
203
+ """Scan the image named on the command line, or give up quietly.
204
+
205
+ Failing to measure the current image costs the migration section, not
206
+ the command: the advice about the best available image is still valid,
207
+ and claiming an improvement over something never measured would not be.
208
+ """
209
+ use_case = await build_analyze_use_case()
210
+ try:
211
+ analysis = await use_case.execute(reference)
212
+ except (ValueError, RuntimeError) as e:
213
+ diagnostics.print(f"[yellow]Could not analyze {reference} for comparison: {e}[/yellow]")
214
+ return None
215
+ finally:
216
+ # O scanner e o pool de conexões do repositório ficam abertos até
217
+ # alguém fechá-los, e este comando abre um segundo conjunto logo
218
+ # abaixo para a busca. `analyze` fecha no mesmo ponto; não fechar
219
+ # aqui era o único caminho que vazava.
220
+ await use_case.close()
221
+
222
+ # Um scan que não completou devolve score 0.0 e tier F por construção.
223
+ # Aceitá-lo como medição faria o plano de migração afirmar uma melhora
224
+ # sobre um número que não mede nada.
225
+ if not analysis.scan.is_verified:
226
+ cause = describe_scan_failure(analysis.scan.error_kind, analysis.scan.error_message)
227
+ diagnostics.print(
228
+ f"[yellow]Could not analyze {safe(reference)} for comparison: {safe(cause)}[/yellow]"
229
+ )
230
+ return None
231
+ return analysis
232
+
233
+
234
+ def _print_migration(plan: MigrationPlan) -> None:
235
+ """The move from what the user runs to what was measured as better."""
236
+ console.print()
237
+ console.print(Panel("[bold green]Migration[/bold green]", expand=False))
238
+ console.print(f" CURRENT [cyan]{plan.from_reference}[/cyan]")
239
+ console.print(f" RECOMMENDED [green]{plan.to_reference}[/green]")
240
+ if plan.to_pinned_reference != plan.to_reference:
241
+ console.print(f" PIN TO [dim]{plan.to_pinned_reference}[/dim]")
242
+
243
+ delta = plan.score_delta
244
+ colour = "green" if delta > 0 else "red"
245
+ console.print(f"\n SECURITY IMPROVEMENT [{colour}]{delta:+.1f} points[/{colour}]")
246
+
247
+ if plan.improvements:
248
+ console.print("\n[bold]WHY[/bold]")
249
+ for reason in plan.improvements:
250
+ console.print(f" [green]OK[/green] {safe(reason)}")
251
+ if plan.trade_offs:
252
+ console.print("\n[bold]TRADE-OFFS[/bold]")
253
+ for cost in plan.trade_offs:
254
+ console.print(f" [yellow]![/yellow] {safe(cost)}")
255
+ if plan.checklist:
256
+ console.print("\n[bold]MIGRATION CHECKLIST[/bold]")
257
+ for i, step in enumerate(plan.checklist, 1):
258
+ console.print(f" {i}. {safe(step)}")
259
+ console.print(
260
+ "\n[dim]Compatibility is never assumed: nothing here can tell you your "
261
+ "application still runs. That is what the checklist is for.[/dim]"
262
+ )