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,566 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ import platform
6
+ import shutil
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ import typer
12
+ from loguru import logger
13
+ from rich.console import Console
14
+ from rich.table import Table
15
+
16
+ from dockerls.cli.dependencies import available_source_names, build_source_registry
17
+ from dockerls.cli.text import safe
18
+ from dockerls.domain.value_objects.scanner_db import (
19
+ DatabaseFreshness,
20
+ DatabaseState,
21
+ classify,
22
+ )
23
+ from dockerls.domain.value_objects.tool_release import (
24
+ DEFAULT_VERSIONS,
25
+ INSTALLABLE,
26
+ OS,
27
+ Arch,
28
+ ToolSpec,
29
+ detect_arch,
30
+ detect_os,
31
+ )
32
+ from dockerls.exit_codes import EXIT_ERROR, EXIT_OK
33
+ from dockerls.infrastructure.toolchain.db_metadata import (
34
+ read_grype_built_at,
35
+ read_trivy_built_at,
36
+ )
37
+ from dockerls.infrastructure.toolchain.installer import (
38
+ InstallError,
39
+ InstallOutcome,
40
+ InstallPlan,
41
+ ToolInstaller,
42
+ )
43
+
44
+ if TYPE_CHECKING:
45
+ from collections.abc import Sequence
46
+
47
+ from dockerls.infrastructure.network.host_guard import HostGuard
48
+
49
+ console = Console()
50
+
51
+ TRIVY_INSTALL_URL = "https://aquasecurity.github.io/trivy"
52
+ GRYPE_INSTALL_URL = "https://github.com/anchore/grype"
53
+
54
+
55
+ def doctor(
56
+ install: bool = typer.Option(
57
+ False,
58
+ "--install",
59
+ help=(
60
+ "Download and install the missing scanners from their projects' official "
61
+ "GitHub releases, verifying the published SHA-256 before installing. "
62
+ "Asks for confirmation first unless --yes is given"
63
+ ),
64
+ ),
65
+ yes: bool = typer.Option(
66
+ False,
67
+ "--yes",
68
+ "-y",
69
+ help="Skip the confirmation prompt for --install (for non-interactive CI)",
70
+ ),
71
+ install_dir: str = typer.Option(
72
+ "",
73
+ "--install-dir",
74
+ help="Where to install the binaries [default: ~/.local/bin, no privilege required]",
75
+ ),
76
+ trivy_version: str = typer.Option(
77
+ "",
78
+ "--trivy-version",
79
+ help="Install this Trivy version instead of resolving the latest one (no 'v' "
80
+ "prefix, e.g. 0.58.1). Skips the releases/latest API entirely -- useful when a "
81
+ "proxy or allowlist blocks api.github.com but github.com/.../releases/download "
82
+ "is reachable",
83
+ ),
84
+ grype_version: str = typer.Option(
85
+ "",
86
+ "--grype-version",
87
+ help="Install this Grype version instead of resolving the latest one (no 'v' "
88
+ "prefix, e.g. 0.85.0). Same rationale as --trivy-version",
89
+ ),
90
+ require_fresh_db: bool = typer.Option(
91
+ False,
92
+ "--require-fresh-db",
93
+ help="Also fail when a scanner's vulnerability database is stale, or when its "
94
+ "age cannot be determined. Off by default: the exit code has always meant "
95
+ "'the components are present', and changing that silently would break pipelines",
96
+ ),
97
+ ) -> None:
98
+ """Check system dependencies and configuration.
99
+
100
+ Diagnosis is read-only and is what runs by default. `--install` is the
101
+ only thing that writes anything, and it never runs without either an
102
+ interactive confirmation or an explicit `--yes`.
103
+
104
+ What `--install` downloads, and from where:
105
+
106
+ \b
107
+ trivy https://github.com/aquasecurity/trivy/releases
108
+ grype https://github.com/anchore/grype/releases
109
+
110
+ For each tool it fetches the release archive and the `checksums.txt`
111
+ published alongside it, verifies the SHA-256, and extracts only the
112
+ binary. Nothing downloaded is ever executed, and no install script is
113
+ fetched or run.
114
+
115
+ When the project publishes `checksums.txt.sig` and `checksums.txt.pem`
116
+ and cosign is installed, the signature on `checksums.txt` is verified
117
+ first -- constrained to an identity under that project's own GitHub
118
+ repository, so "someone signed" is never mistaken for "the publisher
119
+ signed". Only then is the archive's SHA-256 compared against it.
120
+
121
+ An invalid signature aborts the install. A project with no signature
122
+ known here, a missing cosign, or a check that could not conclude are
123
+ reported as *not verified*, never as unsigned, and the install proceeds
124
+ on the published SHA-256 alone.
125
+ """
126
+ # `doctor` is the documented way for a pipeline to check its
127
+ # prerequisites before a scan job, so it has to *gate*: it used to print
128
+ # "Some components are missing" and exit 0 anyway, which meant a runner
129
+ # with no Trivy installed sailed past the check and failed later, in the
130
+ # scan, where the cause is far less obvious. `health` already gates the
131
+ # same way for network dependencies.
132
+ if install:
133
+ pinned_versions = {
134
+ name: version
135
+ for name, version in (("trivy", trivy_version), ("grype", grype_version))
136
+ if version.strip()
137
+ }
138
+ raise typer.Exit(
139
+ asyncio.run(
140
+ _install_missing(
141
+ assume_yes=yes, install_dir=install_dir, pinned_versions=pinned_versions
142
+ )
143
+ )
144
+ )
145
+ raise typer.Exit(asyncio.run(_doctor(require_fresh_db=require_fresh_db)))
146
+
147
+
148
+ def _database_ages(*, has_trivy: bool, has_grype: bool) -> list[DatabaseFreshness]:
149
+ """A idade da base de cada scanner instalado.
150
+
151
+ Só dos instalados: dizer que a base do Grype tem idade desconhecida
152
+ numa máquina sem Grype seria ruído, e ruído é como um aviso de verdade
153
+ deixa de ser lido.
154
+ """
155
+ ages: list[DatabaseFreshness] = []
156
+ if has_trivy:
157
+ built, detail = read_trivy_built_at()
158
+ ages.append(classify("trivy", built, detail=detail))
159
+ if has_grype:
160
+ built, detail = read_grype_built_at()
161
+ ages.append(classify("grype", built, detail=detail))
162
+ return ages
163
+
164
+
165
+ def _print_database_ages(*, has_trivy: bool, has_grype: bool) -> list[DatabaseFreshness]:
166
+ """Conferir que o scanner existe não é conferir que ele mede.
167
+
168
+ Um Trivy com base de três semanas devolve um scan limpo, verde e sem
169
+ erro nenhum que simplesmente não conhece os CVEs do último mês. É a
170
+ falha de medição mais silenciosa que há aqui, e a mais perigosa por
171
+ isso: nada no relatório indica que a resposta está velha.
172
+ """
173
+ ages = _database_ages(has_trivy=has_trivy, has_grype=has_grype)
174
+ if not ages:
175
+ return ages
176
+
177
+ console.print("\n[bold]Vulnerability database[/bold] [dim](how recent the answers are)[/dim]")
178
+ table = Table(show_header=False, box=None, padding=(0, 2))
179
+ table.add_column("Scanner", style="bold")
180
+ table.add_column("State")
181
+ table.add_column("Detail")
182
+ for age in ages:
183
+ table.add_row(age.scanner, _DB_STATES[age.state], safe(age.explain()))
184
+ console.print(table)
185
+ return ages
186
+
187
+
188
+ #: Cor por estado. `UNKNOWN` é ciano e não verde de propósito: ele não é
189
+ #: uma aprovação, e pintá-lo como uma seria a mentira que este bloco existe
190
+ #: para não contar.
191
+ _DB_STATES: dict[DatabaseState, str] = {
192
+ DatabaseState.FRESH: "[green]Fresh[/green]",
193
+ DatabaseState.AGING: "[yellow]Aging[/yellow]",
194
+ DatabaseState.STALE: "[red]Stale[/red]",
195
+ DatabaseState.UNKNOWN: "[cyan]Unknown[/cyan]",
196
+ }
197
+
198
+
199
+ def _print_sources() -> None:
200
+ """List the catalogues this build can search, and their caveats.
201
+
202
+ `--source` accepts these names, and a source whose registry needs
203
+ credentials is called out here rather than discovered as a wall of
204
+ failed scans: an unauthenticated DHI run produces UNVERIFIED
205
+ candidates, which is correct behaviour but a confusing surprise.
206
+ """
207
+ console.print("\n[bold]Image sources[/bold] [dim](--source ...)[/dim]")
208
+ table = Table(show_header=False, box=None, padding=(0, 2))
209
+ table.add_column("Name", style="bold cyan")
210
+ table.add_column("Detail")
211
+ for spec in build_source_registry().specs:
212
+ notes = [spec.description]
213
+ notes.append("searched by default" if spec.default_enabled else "opt-in")
214
+ if spec.requires_auth:
215
+ notes.append("[yellow]registry requires credentials to scan[/yellow]")
216
+ table.add_row(spec.name, " -- ".join(n for n in notes if n))
217
+ console.print(table)
218
+ console.print(f" [dim]accepted: {', '.join(available_source_names())}, all[/dim]")
219
+
220
+
221
+ def _print_threat_sources() -> None:
222
+ """As fontes de explorabilidade que enriquecem cada achado.
223
+
224
+ Listadas aqui porque nenhuma delas é obrigatória e todas degradam para
225
+ "não consultado" em silêncio: sem esta linha, um usuário atrás de um
226
+ proxy vê a coluna Threat cheia de `-` sem nada dizendo por quê.
227
+ `dockerls health` é quem testa se respondem.
228
+ """
229
+ console.print("\n[bold]Threat intelligence[/bold] [dim](optional enrichment)[/dim]")
230
+ table = Table(show_header=False, box=None, padding=(0, 2))
231
+ table.add_column("Source", style="bold cyan")
232
+ table.add_column("Detail")
233
+ table.add_row("CISA KEV", "vulnerabilities observed being exploited in the wild")
234
+ table.add_row("EPSS (FIRST)", "predicted probability of exploitation")
235
+ table.add_row("Exploit-DB", "published exploit code, matched by CVE id")
236
+ console.print(table)
237
+ console.print(
238
+ " [dim]All three degrade to 'not consulted' when unreachable, never to "
239
+ "'no exploit known'. Run `dockerls health` to test them.[/dim]"
240
+ )
241
+
242
+
243
+ async def _doctor(*, require_fresh_db: bool = False) -> int:
244
+ console.print("[bold]DockerLs System Check[/bold]\n")
245
+
246
+ checks = Table(show_header=False, box=None, padding=(0, 2))
247
+ checks.add_column("Component", style="bold")
248
+ checks.add_column("Status")
249
+
250
+ has_trivy = shutil.which("trivy") is not None
251
+ has_grype = shutil.which("grype") is not None
252
+
253
+ for tool, desc, available in (
254
+ ("trivy", "Primary vulnerability scanner", has_trivy),
255
+ ("grype", "Fallback scanner / cross-validation", has_grype),
256
+ ):
257
+ status = "[green]Available[/green]" if available else "[yellow]Not found[/yellow]"
258
+ checks.add_row(f"{tool} ({desc})", status)
259
+
260
+ has_httpx = True
261
+ try:
262
+ import httpx # noqa: F401
263
+
264
+ checks.add_row("httpx", "[green]Available[/green]")
265
+ except ImportError: # pragma: no cover - httpx is a hard dependency
266
+ checks.add_row("httpx", "[red]Missing[/red]")
267
+ has_httpx = False
268
+
269
+ try:
270
+ import keyring # noqa: F401
271
+
272
+ checks.add_row("keyring", "[green]Available[/green]")
273
+ except ImportError:
274
+ checks.add_row("keyring", "[yellow]Not installed (optional)[/yellow]")
275
+
276
+ console.print(checks)
277
+ database_ages = _print_database_ages(has_trivy=has_trivy, has_grype=has_grype)
278
+ _print_sources()
279
+ _print_threat_sources()
280
+
281
+ # The requirement is *a* scanner, not Trivy specifically: `ScannerFactory`
282
+ # runs on Grype alone. Flagging a Grype-only machine as broken would have
283
+ # been a false alarm, and flagging a machine with neither as fine was the
284
+ # real failure.
285
+ stale = [age for age in database_ages if not age.is_usable_measurement]
286
+ if stale and require_fresh_db:
287
+ # Só com `--require-fresh-db`. O código de saída deste comando sempre
288
+ # significou "os componentes estão presentes", e mudar isso em
289
+ # silêncio quebraria pipelines que já dependem do contrato -- que é
290
+ # exatamente o tipo de mudança que este projeto cobra dos outros.
291
+ console.print(
292
+ "\n[red]A vulnerability database is not fit to measure with.[/red]\n"
293
+ + "\n".join(f" {safe(age.scanner)}: {safe(age.explain())}" for age in stale)
294
+ + "\n[dim]Refresh it (`trivy image --download-db-only`, `grype db update`) "
295
+ "or drop --require-fresh-db to keep this a warning.[/dim]"
296
+ )
297
+ return EXIT_ERROR
298
+ if stale:
299
+ console.print(
300
+ "\n[yellow]A scan run now would answer from a database that is not "
301
+ "current.[/yellow] [dim]The result would look exactly like a clean one. "
302
+ "Use --require-fresh-db to make this fail.[/dim]"
303
+ )
304
+
305
+ if has_httpx and (has_trivy or has_grype):
306
+ if not (has_trivy and has_grype):
307
+ console.print(
308
+ "\n[yellow]Only one scanner is installed.[/yellow] "
309
+ "[dim]Cross-validation needs both, so scores will be reported "
310
+ "unconfirmed by a second tool.[/dim]"
311
+ )
312
+ else:
313
+ console.print("\n[green]All required components are available.[/green]")
314
+ return EXIT_OK
315
+
316
+ console.print("\n[red]DockerLs cannot measure anything on this machine.[/red]")
317
+ console.print("\n[bold]Cause[/bold]")
318
+ if not (has_trivy or has_grype):
319
+ console.print(" No vulnerability scanner is installed (needs Trivy or Grype).")
320
+ if not has_httpx: # pragma: no cover - httpx is a hard dependency
321
+ console.print(" The httpx package is missing; reinstall dockerls.")
322
+
323
+ console.print("\n[bold]Suggested action[/bold]")
324
+ if not (has_trivy or has_grype):
325
+ console.print(f" Install Trivy: [cyan]{TRIVY_INSTALL_URL}[/cyan]")
326
+ console.print(f" or install Grype: [cyan]{GRYPE_INSTALL_URL}[/cyan]")
327
+ console.print(
328
+ "\n[dim]Without a scanner, `recommend`, `analyze` and `advisor` report "
329
+ "every tag as unverified rather than as safe.[/dim]"
330
+ )
331
+ return EXIT_ERROR
332
+
333
+
334
+ def default_install_dir() -> Path:
335
+ """Onde instalar sem pedir privilégio a ninguém.
336
+
337
+ `~/.local/bin` no Linux/macOS e o equivalente no Windows: são diretórios
338
+ do próprio usuário, então a instalação nunca precisa de sudo nem de
339
+ prompt de administrador. Um destino que exigisse privilégio teria de
340
+ aparecer na confirmação, e a forma de nunca precisar disso é não
341
+ escolher um.
342
+ """
343
+ # `detect_os`, e não uma comparação de string própria: a regra de qual
344
+ # SO é qual já mora em `tool_release`, e duas cópias dela divergem na
345
+ # primeira vez que uma for corrigida.
346
+ if detect_os(platform.system()) is OS.WINDOWS:
347
+ base = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local")
348
+ return Path(base) / "Programs" / "dockerls" / "bin"
349
+ return Path.home() / ".local" / "bin"
350
+
351
+
352
+ def _writable_without_privilege(target: Path) -> bool:
353
+ """Se dá para escrever ali com o usuário atual.
354
+
355
+ Sobe até o primeiro ancestral existente: um destino que ainda não existe
356
+ é criável se o pai deixar.
357
+ """
358
+ probe = target
359
+ while not probe.exists() and probe != probe.parent:
360
+ probe = probe.parent
361
+ return os.access(probe, os.W_OK)
362
+
363
+
364
+ def _plan_for(spec: ToolSpec, version: str, os_: OS, arch: Arch, destination: Path) -> InstallPlan:
365
+ asset = spec.asset_for(version, os_, arch)
366
+ if asset is None: # pragma: no cover - guarded by the caller
367
+ raise InstallError(f"{spec.name} publishes no build for {os_.value}/{arch.value}")
368
+ return InstallPlan(
369
+ tool=spec.name,
370
+ version=version,
371
+ asset=asset,
372
+ destination=destination,
373
+ needs_privilege=not _writable_without_privilege(destination),
374
+ )
375
+
376
+
377
+ def _print_plans(plans: list[InstallPlan]) -> None:
378
+ """Exatamente o que será baixado, antes de baixar qualquer coisa.
379
+
380
+ Consentimento só é informado se a pessoa vê a URL antes de dizer sim --
381
+ e isso inclui dizer, aqui, se algo vai exigir privilégio, em vez de
382
+ descobrir isso no meio da instalação.
383
+ """
384
+ console.print("\n[bold]The following will be downloaded and verified[/bold]\n")
385
+ for plan in plans:
386
+ console.print(f" [bold cyan]{plan.tool} {plan.version}[/bold cyan]")
387
+ console.print(f" archive [dim]{safe(plan.asset.archive_url)}[/dim]", soft_wrap=True)
388
+ console.print(f" checksums [dim]{safe(plan.asset.checksums_url)}[/dim]", soft_wrap=True)
389
+ console.print(f" install [dim]{safe(str(plan.destination))}[/dim]", soft_wrap=True)
390
+ console.print()
391
+
392
+ console.print(
393
+ "[dim]The SHA-256 published by each project is verified before anything is "
394
+ "installed, and only the binary is extracted. No install script is fetched "
395
+ "or run, and nothing downloaded is executed.[/dim]"
396
+ )
397
+ if any(p.needs_privilege for p in plans):
398
+ console.print(
399
+ "\n[yellow]This destination is not writable by the current user, so the "
400
+ "install will need elevated privileges.[/yellow]\n"
401
+ "[dim]Use --install-dir to pick a directory you own instead.[/dim]"
402
+ )
403
+
404
+
405
+ async def _install_missing(
406
+ *, assume_yes: bool, install_dir: str, pinned_versions: dict[str, str] | None = None
407
+ ) -> int:
408
+ """Instala os scanners que faltam, um independente do outro.
409
+
410
+ `pinned_versions` (de `--trivy-version`/`--grype-version`) evita
411
+ consultar `releases/latest` por completo: um proxy ou allowlist
412
+ restritivo devolve 403 para `api.github.com` em vários ambientes de CI
413
+ mesmo quando `github.com/.../releases/download/...` -- o único outro
414
+ host que este instalador toca -- segue liberado.
415
+ """
416
+ system, machine = platform.system(), platform.machine()
417
+ os_, arch = detect_os(system), detect_arch(machine)
418
+ if os_ is None or arch is None:
419
+ console.print(
420
+ f"[red]Unsupported platform:[/red] {safe(system)} / {safe(machine)}.\n"
421
+ "[dim]Install Trivy or Grype manually; see the links in `dockerls doctor`.[/dim]"
422
+ )
423
+ return EXIT_ERROR
424
+
425
+ missing = [spec for spec in INSTALLABLE if shutil.which(spec.name) is None]
426
+ if not missing:
427
+ console.print("[green]Nothing to install: every scanner is already available.[/green]")
428
+ return EXIT_OK
429
+
430
+ destination = Path(install_dir).expanduser() if install_dir else default_install_dir()
431
+ unsupported = [s for s in missing if not s.supports(os_, arch)]
432
+ for spec in unsupported:
433
+ console.print(
434
+ f"[yellow]{spec.name} publishes no build for {os_.value}/{arch.value}; "
435
+ f"skipping it.[/yellow] [dim]{spec.repo_url}/releases[/dim]"
436
+ )
437
+ installable = [s for s in missing if s.supports(os_, arch)]
438
+ if not installable:
439
+ return EXIT_ERROR
440
+
441
+ installer = ToolInstaller(guard=_install_guard())
442
+ pinned_versions = pinned_versions or {}
443
+ plans: list[InstallPlan] = []
444
+ for spec in installable:
445
+ pinned = pinned_versions.get(spec.name, "").strip()
446
+ if pinned:
447
+ # An explicit version skips releases/latest entirely -- there is
448
+ # nothing to resolve, and nothing to fail on a 403.
449
+ plans.append(_plan_for(spec, pinned, os_, arch, destination))
450
+ continue
451
+
452
+ # Uma ferramenta que não resolve versão não impede a outra.
453
+ try:
454
+ version = await installer.latest_version(spec)
455
+ plans.append(_plan_for(spec, version, os_, arch, destination))
456
+ except InstallError as e:
457
+ fallback = DEFAULT_VERSIONS.get(spec.name, "")
458
+ if not fallback:
459
+ console.print(f"[red]{spec.name}:[/red] {safe(str(e))}")
460
+ continue
461
+ console.print(
462
+ f"[yellow]{spec.name}:[/yellow] {safe(str(e))}\n"
463
+ f"[dim]Falling back to the built-in default version {fallback} -- pass "
464
+ f"--{spec.name}-version to pin a different one.[/dim]"
465
+ )
466
+ plans.append(_plan_for(spec, fallback, os_, arch, destination))
467
+
468
+ if not plans:
469
+ return EXIT_ERROR
470
+
471
+ _print_plans(plans)
472
+ if not _confirmed(assume_yes):
473
+ console.print("\n[yellow]Nothing was installed.[/yellow]")
474
+ return EXIT_ERROR
475
+
476
+ outcomes = []
477
+ for plan in plans:
478
+ console.print(f"\n[bold]{plan.tool}[/bold] downloading and verifying...")
479
+ outcome = await installer.install(plan, cosign=_cosign_if_available())
480
+ outcomes.append(outcome)
481
+ if outcome.installed:
482
+ signed = (
483
+ " [dim](cosign signature verified)[/dim]" if outcome.signature_verified else ""
484
+ )
485
+ console.print(f" [green]OK[/green] {safe(outcome.detail)}{signed}")
486
+ else:
487
+ console.print(f" [red]FAILED[/red] {safe(outcome.detail)}")
488
+
489
+ _warn_if_not_on_path(destination, outcomes)
490
+
491
+ console.print("\n[bold]Re-running the diagnosis[/bold]\n")
492
+ # O veredito final é o mesmo diagnóstico de sempre, sobre o estado real
493
+ # da máquina: dizer "instalado" sem reconferir seria reportar a intenção
494
+ # em vez do resultado.
495
+ return await _doctor(require_fresh_db=False)
496
+
497
+
498
+ def _confirmed(assume_yes: bool) -> bool:
499
+ """Consentimento explícito, e nunca presumido num terminal não-interativo.
500
+
501
+ Sem `--yes` e sem alguém do outro lado, a resposta é não: um pipeline
502
+ que não pediu instalação explicitamente não deve receber uma.
503
+ """
504
+ if assume_yes:
505
+ return True
506
+ if not sys.stdin.isatty():
507
+ console.print(
508
+ "\n[yellow]Not an interactive terminal, and --yes was not given.[/yellow]\n"
509
+ "[dim]Re-run with --yes to install without confirmation.[/dim]"
510
+ )
511
+ return False
512
+ return typer.confirm("Proceed with the download and install?", default=False)
513
+
514
+
515
+ def _install_guard() -> HostGuard | None:
516
+ """A mesma política de rede que governa registries e scanners."""
517
+ try:
518
+ from dockerls.cli.dependencies import build_host_guard
519
+
520
+ return build_host_guard()
521
+ except Exception as e: # pragma: no cover - a guard is best-effort here
522
+ logger.debug(f"Network policy unavailable for the installer: {e}")
523
+ return None
524
+
525
+
526
+ def _cosign_if_available() -> object | None:
527
+ """O verificador de assinatura, quando existir um que sirva.
528
+
529
+ `ToolInstaller` pede `verify_blob`: um release de scanner é um *blob*
530
+ assinado, não uma imagem de container. O `CosignClient` sabia verificar
531
+ só referência de imagem (`cosign verify`), então devolvê-lo aqui fazia
532
+ o instalador procurar `verify_blob`, não achar, e seguir em silêncio --
533
+ uma verificação anunciada que nunca acontecia. A checagem abaixo
534
+ sobrevive à implementação de `verify_blob` de propósito: ela é o que
535
+ impede a mesma armadilha de voltar por um caminho diferente.
536
+ """
537
+ if shutil.which("cosign") is None:
538
+ return None
539
+ try:
540
+ from dockerls.integrations.signing.cosign import CosignClient
541
+
542
+ client: object = CosignClient()
543
+ except Exception as e: # pragma: no cover
544
+ logger.debug(f"cosign client unavailable: {e}")
545
+ return None
546
+ if not callable(getattr(client, "verify_blob", None)):
547
+ logger.debug(
548
+ "cosign is on PATH but no blob verifier is implemented; "
549
+ "the published SHA-256 remains the only verification"
550
+ )
551
+ return None
552
+ return client
553
+
554
+
555
+ def _warn_if_not_on_path(destination: Path, outcomes: Sequence[InstallOutcome]) -> None:
556
+ """Instalar fora do PATH deixa a ferramenta invisível para o próximo
557
+ comando, e o diagnóstico abaixo diria "Not found" sem explicar por quê."""
558
+ if not any(o.installed for o in outcomes):
559
+ return
560
+ entries = {Path(p).expanduser() for p in os.environ.get("PATH", "").split(os.pathsep) if p}
561
+ if destination.expanduser() in entries:
562
+ return
563
+ console.print(
564
+ f"\n[yellow]{safe(str(destination))} is not on your PATH.[/yellow]\n"
565
+ f"[dim]Add it, or the scanners will not be found by the next command.[/dim]"
566
+ )
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+
6
+ import typer
7
+ from rich.console import Console
8
+
9
+ from dockerls.cli.dependencies import build_recommend_use_case, resolve_tag_limit
10
+ from dockerls.cli.image_names import reject_tagged_reference
11
+ from dockerls.cli.validators import check_limit, check_workers
12
+ from dockerls.exit_codes import EXIT_ERROR
13
+ from dockerls.exporters.factory import ExporterFactory
14
+
15
+ console = Console()
16
+
17
+
18
+ def export(
19
+ image: str = typer.Argument(
20
+ help=(
21
+ "Docker image name only, without a tag (e.g. 'node', not 'node:18'). "
22
+ "Use 'analyze' or 'advisor' for a specific tag."
23
+ )
24
+ ),
25
+ output_format: str = typer.Option(
26
+ "json", "--format", "-f", help="Export format: json, csv, html, markdown, sarif"
27
+ ),
28
+ output: str = typer.Option("", "--output", "-o", help="Output file path (default: stdout)"),
29
+ workers: int | None = typer.Option(
30
+ None, "--workers", "-w", help="Concurrent workers [config: workers, default 10]"
31
+ ),
32
+ limit: int | None = typer.Option(
33
+ None, "--limit", "-l", help="Max tags to discover [config: max_tags, default 100]"
34
+ ),
35
+ ) -> None:
36
+ """Export analysis results in various formats."""
37
+ # `None` means "not given", so the configured value applies; only an
38
+ # explicitly supplied value is range-checked here.
39
+ if workers is not None:
40
+ workers = check_workers(workers)
41
+ if limit is not None:
42
+ limit = check_limit(limit)
43
+ error = reject_tagged_reference(image, "export")
44
+ if error:
45
+ console.print(f"[red]{error}[/red]")
46
+ raise typer.Exit(EXIT_ERROR)
47
+ try:
48
+ asyncio.run(_export(image, output_format, output, workers, limit))
49
+ except ValueError as e:
50
+ console.print(f"[red]Invalid configuration:[/red] {e}")
51
+ raise typer.Exit(EXIT_ERROR) from e
52
+
53
+
54
+ async def _export(
55
+ image: str, fmt: str, output: str, workers: int | None, limit: int | None
56
+ ) -> None:
57
+ # Same fallback as `recommend`: omitting a flag means "use the
58
+ # configured value", rather than a hard-coded default shadowing it.
59
+ use_case = await build_recommend_use_case(workers=workers)
60
+ result = await use_case.execute(image, limit=resolve_tag_limit(limit))
61
+
62
+ try:
63
+ exporter = ExporterFactory.create(fmt)
64
+ except ValueError as e:
65
+ console.print(f"[red]{e}[/red]")
66
+ raise typer.Exit(EXIT_ERROR) from e
67
+
68
+ if output:
69
+ path = Path(output)
70
+ try:
71
+ path.parent.mkdir(parents=True, exist_ok=True)
72
+ exporter.export(result, path)
73
+ except OSError as e:
74
+ # An unwritable destination is user error, not a crash.
75
+ console.print(f"[red]Could not write {path}:[/red] {e}")
76
+ raise typer.Exit(EXIT_ERROR) from e
77
+ console.print(f"[green]Report exported to {path}[/green]")
78
+ else:
79
+ # soft_wrap avoids Rich reflowing/inserting newlines into
80
+ # machine-readable output (JSON, CSV, SARIF).
81
+ console.print(exporter.export_string(result), soft_wrap=True)