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,429 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+ from typing import TYPE_CHECKING
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.measure import Measurement
10
+ from rich.table import Table
11
+
12
+ from dockerls.application.dto.analysis import AnalysisResult
13
+ from dockerls.application.services.remediation import (
14
+ build_remediation_plan,
15
+ render_dockerfile_patch,
16
+ )
17
+ from dockerls.cli.dependencies import build_analyze_use_case
18
+ from dockerls.cli.scan_failure import describe_scan_failure
19
+ from dockerls.cli.text import safe
20
+ from dockerls.cli.vulnerability_view import (
21
+ count_by_origin,
22
+ exploit_urls,
23
+ npm_remediation_hint,
24
+ origin_label,
25
+ sort_by_severity,
26
+ threat_label,
27
+ threat_style,
28
+ )
29
+ from dockerls.domain.entities.vulnerability import PackageOrigin, Vulnerability
30
+ from dockerls.exit_codes import EXIT_ERROR, EXIT_OK, EXIT_POLICY
31
+ from dockerls.exporters.factory import ExporterFactory
32
+
33
+ if TYPE_CHECKING:
34
+ from dockerls.application.dto.analysis import ImageAnalysis
35
+ from dockerls.application.services.remediation import RemediationPlan
36
+ from dockerls.domain.entities.scan_result import ScanResult
37
+
38
+ console = Console()
39
+
40
+
41
+ _FORMATS = ("table", "json", "sarif")
42
+
43
+ #: Limiares de `--fail-on`, do mais severo para o mais brando. Cada um reprova
44
+ #: também tudo que for pior que ele -- mesma semântica de `build --fail-on`,
45
+ #: para que a mesma palavra não signifique duas coisas na mesma ferramenta.
46
+ FAIL_ON_THRESHOLDS = ("critical", "high", "medium", "low")
47
+
48
+
49
+ def analyze(
50
+ image: str = typer.Argument(help="Full image reference (e.g., node:22-alpine)"),
51
+ output_format: str = typer.Option(
52
+ "table", "--format", "-f", help="Output format: table, json or sarif"
53
+ ),
54
+ output: str = typer.Option(
55
+ "", "--output", "-o", help="Write the report (or, with --fix, the patch) to a file"
56
+ ),
57
+ fix: bool = typer.Option(
58
+ False, "--fix", help="Emit a Dockerfile patch that remediates the findings"
59
+ ),
60
+ fail_on: str | None = typer.Option(
61
+ None,
62
+ "--fail-on",
63
+ help="Exit with the policy code when findings at/above this severity exist",
64
+ ),
65
+ no_color: bool = typer.Option(False, "--no-color", help="Disable colored output"),
66
+ wide: bool = typer.Option(
67
+ False, "--wide", help="Render the table without truncating any column"
68
+ ),
69
+ ) -> None:
70
+ """Deep-analyze a specific Docker image tag."""
71
+ if no_color:
72
+ console.no_color = True
73
+ if output_format not in _FORMATS:
74
+ console.print(
75
+ f"[red]Error:[/red] unsupported --format {output_format!r}. "
76
+ f"Use one of: {', '.join(_FORMATS)}"
77
+ )
78
+ raise typer.Exit(EXIT_ERROR)
79
+ # `--fix` produz um Dockerfile; `json`/`sarif` produzem um relatório. Um
80
+ # `--output` só pode receber um dos dois, e adivinhar qual seria pior que
81
+ # recusar.
82
+ if fix and output_format != "table":
83
+ console.print(
84
+ f"[red]Error:[/red] --fix produces a Dockerfile patch and cannot be combined "
85
+ f"with --format {output_format}. Run it on its own, or with --output."
86
+ )
87
+ raise typer.Exit(EXIT_ERROR)
88
+ # Um limiar que a ferramenta não entende viraria um portão aberto com cara
89
+ # de fechado -- rejeitado antes de qualquer scan começar.
90
+ if fail_on is not None and fail_on.strip().lower() not in FAIL_ON_THRESHOLDS:
91
+ console.print(
92
+ f"[red]Error:[/red] invalid --fail-on {fail_on!r}. "
93
+ f"Use one of: {', '.join(FAIL_ON_THRESHOLDS)}"
94
+ )
95
+ raise typer.Exit(EXIT_ERROR)
96
+ asyncio.run(
97
+ _analyze(
98
+ image,
99
+ wide=wide,
100
+ output_format=output_format,
101
+ output=output,
102
+ fail_on=fail_on,
103
+ fix=fix,
104
+ )
105
+ )
106
+
107
+
108
+ # A CVE ID is the primary key of a finding: "CVE-2026…" identifies nothing and
109
+ # cannot be looked up. `CVE-YYYY-NNNNN` is 14 cells, so the column reserves
110
+ # that much and the flexible columns (package, versions) give up width first.
111
+ _CVE_MIN_WIDTH = 14
112
+
113
+ # Used to measure what the table would like to be, free of the terminal's
114
+ # width -- Measurement clamps to the console otherwise, which makes an
115
+ # overflowing table look like a table that fits.
116
+ _UNBOUNDED_WIDTH = 10_000
117
+
118
+
119
+ async def _analyze(
120
+ image: str,
121
+ wide: bool = False,
122
+ output_format: str = "table",
123
+ output: str = "",
124
+ fail_on: str | None = None,
125
+ fix: bool = False,
126
+ ) -> None:
127
+ use_case = await build_analyze_use_case()
128
+ try:
129
+ result = await use_case.execute(image)
130
+ except ValueError as e:
131
+ console.print(f"[red]Scan failed: {e}[/red]")
132
+ raise typer.Exit(EXIT_ERROR) from e
133
+ finally:
134
+ # The scanner and the repository's connection pool are held for the
135
+ # length of the run; releasing them is the caller's job. Rendering
136
+ # below needs only `result`, so this is the right moment.
137
+ await use_case.close()
138
+
139
+ if not result.scan.is_verified:
140
+ # Sem scan não há veredito. Sair 0 aqui deixaria um portão de CI
141
+ # passar uma imagem que ninguém mediu.
142
+ #
143
+ # A causa vai resumida: o stderr cru do Trivy para uma tag
144
+ # inexistente ocupa várias linhas e menciona o socket do Docker,
145
+ # que este modo de scan nem usa. O texto completo continua no
146
+ # arquivo de log e em `--format json`.
147
+ console.print(
148
+ f"[red]Scan did not complete for {safe(result.image.full_reference)}:[/red] "
149
+ f"{safe(describe_scan_failure(result.scan.error_kind, result.scan.error_message))}"
150
+ )
151
+ raise typer.Exit(EXIT_ERROR)
152
+
153
+ if output_format in ("json", "sarif"):
154
+ _emit_machine_readable(result, output_format, output)
155
+ raise typer.Exit(_fail_on_exit_code(result, fail_on))
156
+
157
+ if fix:
158
+ _emit_fix(result, output)
159
+ raise typer.Exit(_fail_on_exit_code(result, fail_on))
160
+
161
+ _render_table(result, wide)
162
+ raise typer.Exit(_fail_on_exit_code(result, fail_on))
163
+
164
+
165
+ def _emit_fix(result: ImageAnalysis, output: str) -> None:
166
+ """Emit the Dockerfile patch derived from this image's findings."""
167
+ plan = build_remediation_plan(result)
168
+ patch = render_dockerfile_patch(plan)
169
+
170
+ if output:
171
+ path = Path(output)
172
+ try:
173
+ path.parent.mkdir(parents=True, exist_ok=True)
174
+ path.write_text(patch, encoding="utf-8")
175
+ except OSError as e:
176
+ console.print(f"[red]Could not write {path}:[/red] {e}")
177
+ raise typer.Exit(EXIT_ERROR) from e
178
+ console.print(f"[green]Dockerfile patch written to {path}[/green]")
179
+ console.print(_fix_summary(plan))
180
+ return
181
+
182
+ # soft_wrap: o destino provável é um `>` ou um copiar-e-colar, e o Rich
183
+ # quebraria as linhas na largura do terminal.
184
+ console.print(patch, soft_wrap=True, highlight=False)
185
+
186
+
187
+ def _fix_summary(plan: RemediationPlan) -> str:
188
+ resolved = plan.resolved_count
189
+ parts = [f"[dim]{len(plan.actions)} layer(s), addressing {resolved} finding(s)"]
190
+ if plan.unresolved:
191
+ parts.append(f"{len(plan.unresolved)} with no published fix")
192
+ return " | ".join(parts) + "[/dim]"
193
+
194
+
195
+ def _fail_on_exit_code(result: ImageAnalysis, fail_on: str | None) -> int:
196
+ """Honours the tool-wide exit contract: 2 means "measured, and it fails".
197
+
198
+ Deliberadamente igual a `build --fail-on`: `1` continua sendo "não
199
+ consegui medir" e `2` "medi, e reprovou". Inverter os dois só aqui faria a
200
+ mesma flag significar coisas opostas em dois comandos da mesma ferramenta.
201
+ """
202
+ if not fail_on:
203
+ return EXIT_OK
204
+ counts = {
205
+ "critical": result.scan.critical_count,
206
+ "high": result.scan.high_count,
207
+ "medium": result.scan.medium_count,
208
+ "low": result.scan.low_count,
209
+ }
210
+ cutoff = FAIL_ON_THRESHOLDS.index(fail_on.strip().lower())
211
+ triggering = [level for level in FAIL_ON_THRESHOLDS[: cutoff + 1] if counts[level] > 0]
212
+ if not triggering:
213
+ return EXIT_OK
214
+
215
+ # Um portão que só diz "reprovou" obriga a reabrir o relatório. Ele nomeia
216
+ # os CVEs que o dispararam.
217
+ offenders = [
218
+ v
219
+ for v in sort_by_severity(result.scan.vulnerabilities)
220
+ if v.severity.value.lower() in triggering
221
+ ]
222
+ console.print(
223
+ f"\n[bold red]Gate failed (--fail-on {fail_on}):[/bold red] "
224
+ f"{len(offenders)} finding(s) at or above {fail_on.upper()}"
225
+ )
226
+ for v in offenders[:10]:
227
+ console.print(
228
+ f" {v.cve_id} {v.severity.value} {v.package_name} {v.installed_version}"
229
+ + (f" -> {v.fixed_version}" if v.fixed_version else " (no fix)")
230
+ )
231
+ if len(offenders) > 10:
232
+ console.print(f" ... and {len(offenders) - 10} more")
233
+ return EXIT_POLICY
234
+
235
+
236
+ def _emit_machine_readable(result: ImageAnalysis, fmt: str, output: str) -> None:
237
+ """Reuse the existing exporters by wrapping the single analysis in the
238
+ same `AnalysisResult` they already consume -- one report shape for the
239
+ whole tool rather than a second one that can drift."""
240
+ wrapped = AnalysisResult(
241
+ query=result.image.full_reference,
242
+ total_tags_scanned=1,
243
+ total_tags_analyzed=1,
244
+ baseline_met=result.production_ready,
245
+ recommendations=[result],
246
+ )
247
+ payload = ExporterFactory.create(fmt).export_string(wrapped)
248
+
249
+ if not output:
250
+ # soft_wrap: o Rich quebraria a linha na largura do terminal, e uma
251
+ # quebra no meio de uma string do JSON produz documento inválido.
252
+ console.print(payload, soft_wrap=True)
253
+ return
254
+
255
+ path = Path(output)
256
+ try:
257
+ path.parent.mkdir(parents=True, exist_ok=True)
258
+ path.write_text(payload, encoding="utf-8")
259
+ except OSError as e:
260
+ console.print(f"[red]Could not write {path}:[/red] {e}")
261
+ raise typer.Exit(EXIT_ERROR) from e
262
+ console.print(f"[green]Report written to {path}[/green]")
263
+
264
+
265
+ def _render_table(result: ImageAnalysis, wide: bool) -> None:
266
+ console.print(f"\n[bold]Analysis: {safe(result.image.full_reference)}[/bold]\n")
267
+
268
+ info = Table(show_header=False, box=None, padding=(0, 2))
269
+ info.add_column("Key", style="bold")
270
+ info.add_column("Value")
271
+ info.add_row("Score", f"[green]{result.security_score}[/green]")
272
+ info.add_row("Tier", result.tier)
273
+ info.add_row("Critical", f"[red]{result.scan.critical_count}[/red]")
274
+ info.add_row("High", f"[yellow]{result.scan.high_count}[/yellow]")
275
+ info.add_row("Medium", str(result.scan.medium_count))
276
+ info.add_row("Low", str(result.scan.low_count))
277
+ info.add_row("Total Vulns", str(result.scan.total_count))
278
+ # A proporção crua ao lado da contagem: é ela que o "Remediation Score"
279
+ # resume num degrau, e vê-las juntas é o que impede de ler o degrau 20
280
+ # como "20% corrigíveis".
281
+ info.add_row("Fixable", f"{result.scan.fixable_count} ({_fixable_pct(result.scan)})")
282
+ info.add_row("Remediation Score", f"{result.remediation_score}/100")
283
+ info.add_row("EOL", "Yes" if result.is_eol else "No")
284
+ info.add_row("LTS", "Yes" if result.is_lts else "No")
285
+ info.add_row("Scanner", result.scan.scanner)
286
+ console.print(info)
287
+
288
+ if result.tag_drift_note:
289
+ console.print(f"\n[dim]tag history: {safe(result.tag_drift_note)}[/dim]")
290
+ if result.vuln_trend_note:
291
+ console.print(f"[dim]vuln trend: {safe(result.vuln_trend_note)}[/dim]")
292
+
293
+ if result.scan.vulnerabilities:
294
+ console.print("\n[bold]Vulnerabilities[/bold]\n")
295
+ vtable = Table()
296
+ # `overflow="fold"` rather than the default ellipsis: a CVE ID that
297
+ # somehow exceeds the reserved width wraps to a second line, still
298
+ # readable, instead of being cut.
299
+ vtable.add_column("CVE", style="cyan", min_width=_CVE_MIN_WIDTH, overflow="fold")
300
+ vtable.add_column("Severity")
301
+ vtable.add_column("CVSS", justify="right")
302
+ # Qual base publicou o CVSS ao lado. Sem isso, um CRITICAL com score
303
+ # 7.5 -- severidade do vendor, número do NVD -- lê como erro de conta
304
+ # da ferramenta, e quem desconfia de um número desconfia do relatório.
305
+ vtable.add_column("Src", style="dim")
306
+ # `ratio` marks these three as the flexible ones: when the terminal is
307
+ # too narrow, they are what shrinks.
308
+ vtable.add_column("Package", overflow="ellipsis", ratio=1)
309
+ vtable.add_column("Origin", style="magenta", overflow="ellipsis")
310
+ # Explorabilidade: KEV e Exploit-DB numa coluna só. Ambos os sinais
311
+ # já existiam no modelo e nunca chegavam à tabela -- ver
312
+ # `threat_label` para o motivo de dividirem uma célula, e para o
313
+ # motivo de "não consultado" nunca virar "No".
314
+ vtable.add_column("Threat", justify="center", no_wrap=True)
315
+ vtable.add_column("Installed", overflow="ellipsis", ratio=1)
316
+ vtable.add_column("Fixed", overflow="ellipsis", ratio=1)
317
+ vtable.add_column("Status")
318
+
319
+ sev_styles = {
320
+ "CRITICAL": "bold red",
321
+ "HIGH": "yellow",
322
+ "MEDIUM": "white",
323
+ "LOW": "dim",
324
+ }
325
+ for v in sort_by_severity(result.scan.vulnerabilities)[:30]:
326
+ st = sev_styles.get(v.severity.value, "")
327
+ status = "FIX AVAILABLE" if v.is_fixable else "NO FIX"
328
+ status_style = "green" if v.is_fixable else "red"
329
+ # Every value below originates outside this process: the CVE id
330
+ # and versions come from an upstream advisory, the package name
331
+ # from inside the image being analysed. Rich would interpret
332
+ # bracket markup in any of them, so a crafted package name could
333
+ # style a CRITICAL row to look benign.
334
+ threat = threat_label(v)
335
+ tstyle = threat_style(v)
336
+ vtable.add_row(
337
+ safe(v.cve_id),
338
+ f"[{st}]{v.severity.value}[/{st}]" if st else v.severity.value,
339
+ f"{v.cvss_score:.1f}",
340
+ safe(v.cvss_source) if v.cvss_source else "-",
341
+ safe(v.package_name),
342
+ origin_label(v),
343
+ f"[{tstyle}]{threat}[/{tstyle}]",
344
+ safe(v.installed_version),
345
+ safe(v.fixed_version) if v.fixed_version else "-",
346
+ f"[{status_style}]{status}[/{status_style}]",
347
+ )
348
+ _print_vulnerabilities(vtable, wide)
349
+ _print_origin_summary(result.scan.vulnerabilities)
350
+ _print_exploit_links(result.scan.vulnerabilities)
351
+
352
+
353
+ def _fixable_pct(scan: ScanResult) -> str:
354
+ if scan.total_count == 0:
355
+ return "n/a"
356
+ return f"{scan.fixable_count / scan.total_count:.0%} of {scan.total_count}"
357
+
358
+
359
+ def _print_vulnerabilities(vtable: Table, wide: bool) -> None:
360
+ """Render the findings table, never at the cost of a CVE ID.
361
+
362
+ Rich only redistributes width among columns marked flexible, and only
363
+ when the table is told to fill the available width. Left alone, a table
364
+ wider than the terminal is cropped on the right instead -- which is how
365
+ the CVE column ended up truncated and the last column lost its border.
366
+ """
367
+ natural_width = Measurement.get(
368
+ console, console.options.update(max_width=_UNBOUNDED_WIDTH), vtable
369
+ ).maximum
370
+
371
+ if wide:
372
+ # Give the table exactly the width it asked for: nothing truncates.
373
+ Console(width=natural_width, no_color=console.no_color).print(vtable)
374
+ return
375
+
376
+ # Only fit-to-width when the terminal is too narrow. On a wide terminal
377
+ # the table keeps its natural layout rather than being stretched.
378
+ vtable.expand = natural_width > console.width
379
+ console.print(vtable)
380
+
381
+
382
+ def _print_origin_summary(vulns: list[Vulnerability]) -> None:
383
+ """Break the findings down by where the affected package lives.
384
+
385
+ Uma contagem única de "16 vulnerabilidades" não diz o que fazer com elas.
386
+ Saber que as 16 estão em pacotes de linguagem, e não no SO, é a diferença
387
+ entre `apk upgrade` (que não resolve nada) e remover o npm da imagem
388
+ final (que resolve todas).
389
+ """
390
+ if not vulns:
391
+ return
392
+ counts = count_by_origin(vulns)
393
+ parts = [
394
+ f"OS packages: {counts[PackageOrigin.OS]}",
395
+ f"language packages: {counts[PackageOrigin.LANGUAGE]}",
396
+ ]
397
+ if counts[PackageOrigin.UNKNOWN]:
398
+ parts.append(f"unclassified: {counts[PackageOrigin.UNKNOWN]}")
399
+ console.print(f"\n[bold]By origin:[/bold] {' | '.join(parts)}")
400
+
401
+ hint = npm_remediation_hint(vulns)
402
+ if hint:
403
+ console.print(f"\n[bold yellow]Remediation[/bold yellow]\n{hint}")
404
+
405
+
406
+ def _print_exploit_links(vulns: list[Vulnerability]) -> None:
407
+ """Onde ler o exploit, para quem quer julgar a fonte em vez de confiar
408
+ na coluna.
409
+
410
+ Abaixo da tabela, e não dentro dela: uma URL não cabe numa célula, e a
411
+ decisão que a coluna sustenta ("isto tem exploit publicado?") é anterior
412
+ à de abrir o link.
413
+ """
414
+ linked = [(v, exploit_urls(v)) for v in sort_by_severity(vulns)]
415
+ linked = [(v, urls) for v, urls in linked if urls]
416
+ if not linked:
417
+ return
418
+
419
+ console.print("\n[bold]Published exploits[/bold]")
420
+ for vuln, urls in linked[:10]:
421
+ mark = " [red](verified)[/red]" if vuln.exploitdb_verified else ""
422
+ console.print(f" {safe(vuln.cve_id)}{mark}")
423
+ for url in urls[:5]:
424
+ # soft_wrap: uma URL quebrada na largura do terminal deixa de ser
425
+ # copiável, que é a única coisa que se faz com ela.
426
+ console.print(f" [link={url}]{url}[/link]", soft_wrap=True)
427
+ remaining = len(linked) - 10
428
+ if remaining > 0:
429
+ console.print(f" [dim]... and {remaining} more (see --format json)[/dim]")
@@ -0,0 +1,104 @@
1
+ """Comando CLI para análise de Dockerfiles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ from rich.console import Console
10
+
11
+ from dockerls.application.use_cases.analyze_dockerfile import (
12
+ AnalyzeDockerfileRequest,
13
+ AnalyzeDockerfileResponse,
14
+ AnalyzeDockerfileUseCase,
15
+ )
16
+ from dockerls.cli.dependencies import enable_console_logging
17
+ from dockerls.cli.rendering import render_validation_report
18
+ from dockerls.cli.text import safe
19
+ from dockerls.exit_codes import EXIT_ERROR, EXIT_OK, EXIT_POLICY
20
+ from dockerls.infrastructure.dockerfile_validator import DockerfileValidator, HardeningTemplates
21
+
22
+ console = Console()
23
+
24
+
25
+ def analyze(
26
+ path: str = typer.Argument(".", help="Path to the Dockerfile or directory"),
27
+ validate_only: bool = typer.Option(
28
+ False, "--validate-only", help="Validate only, without suggesting improvements"
29
+ ),
30
+ suggestions: bool = typer.Option(
31
+ True, "--suggestions/--no-suggestions", help="Show hardening suggestions"
32
+ ),
33
+ output_format: str = typer.Option(
34
+ "table", "--format", "-f", help="Output format: table or json"
35
+ ),
36
+ output: str | None = typer.Option(
37
+ None,
38
+ "--output",
39
+ "-o",
40
+ help="Also write the full JSON report to this file. A later "
41
+ "`build --compare-to-analysis <file>` reads it to say which of "
42
+ "these findings are still present in the image `build` scans",
43
+ ),
44
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable verbose logging"),
45
+ ) -> None:
46
+ """Analyze a Dockerfile for security problems."""
47
+ if verbose:
48
+ enable_console_logging()
49
+
50
+ template_provider = HardeningTemplates()
51
+ validator = DockerfileValidator(template_provider)
52
+ use_case = AnalyzeDockerfileUseCase(validator, template_provider)
53
+
54
+ request = AnalyzeDockerfileRequest(
55
+ dockerfile_path=path,
56
+ include_suggestions=suggestions,
57
+ validate_only=validate_only,
58
+ )
59
+
60
+ response = use_case.execute(request)
61
+
62
+ if not response.success:
63
+ console.print(f"[red]Error:[/red] {response.error}")
64
+ raise typer.Exit(EXIT_ERROR)
65
+
66
+ if output:
67
+ try:
68
+ Path(output).write_text(json.dumps(response.model_dump(), indent=2), encoding="utf-8")
69
+ except OSError as e:
70
+ console.print(f"[red]Error:[/red] could not write {safe(output)}: {e}")
71
+ raise typer.Exit(EXIT_ERROR) from e
72
+ if output_format != "json":
73
+ console.print(f"[dim]Report written to {safe(output)}.[/dim]\n")
74
+
75
+ if output_format == "json":
76
+ # Via `typer.echo`, não pelo console do Rich: o consumidor é um
77
+ # parser. O Rich quebra a linha na largura do terminal, e uma quebra
78
+ # no meio de uma string do JSON produz um documento inválido -- num
79
+ # terminal de 80 colunas era exatamente o que saía daqui.
80
+ typer.echo(json.dumps(response.model_dump(), indent=2))
81
+ return
82
+
83
+ _print_table_output(response)
84
+
85
+
86
+ def _print_table_output(response: AnalyzeDockerfileResponse) -> None:
87
+ """Imprime resultado formatado em tabela."""
88
+ validation = response.validation
89
+ if validation is None:
90
+ console.print("[red]Error:[/red] validation produced no result")
91
+ raise typer.Exit(EXIT_ERROR)
92
+
93
+ render_validation_report(
94
+ console,
95
+ validation,
96
+ analysis=response.analysis,
97
+ suggestions=response.suggestions,
98
+ )
99
+
100
+ # Exit code: erro de validação é violação de política (2); warnings não
101
+ # reprovam o build.
102
+ if validation.errors > 0:
103
+ raise typer.Exit(EXIT_POLICY)
104
+ raise typer.Exit(EXIT_OK)