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,1300 @@
1
+ """Comando CLI para build seguro de imagens Docker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from html import escape as html_escape
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ import typer
12
+ from rich.console import Console
13
+ from rich.panel import Panel
14
+
15
+ from dockerls.application.use_cases.build_image import (
16
+ BuildImageRequest,
17
+ BuildImageResponse,
18
+ BuildImageUseCase,
19
+ BuildReport,
20
+ )
21
+ from dockerls.cli.dependencies import enable_console_logging
22
+ from dockerls.cli.publish_prompt import resolve_destination, resolve_identity
23
+ from dockerls.cli.rendering import render_validation_report
24
+ from dockerls.cli.text import safe
25
+ from dockerls.domain.value_objects.build_labels import BuildIdentity, MissingBuildMetadataError
26
+ from dockerls.domain.value_objects.build_policy import BuildPolicy
27
+ from dockerls.domain.value_objects.gate import (
28
+ GateKind,
29
+ GateSet,
30
+ InvalidGateError,
31
+ merge_gates,
32
+ )
33
+ from dockerls.domain.value_objects.inheritance import ACTIONS, FindingOrigin
34
+ from dockerls.domain.value_objects.provenance import BuildProvenance, ProvenanceStatus
35
+ from dockerls.domain.value_objects.registry_target import InvalidRegistryTargetError
36
+ from dockerls.exit_codes import EXIT_ERROR, EXIT_OK
37
+ from dockerls.infrastructure.config.policy_file import (
38
+ PolicyFileError,
39
+ find_policy_file,
40
+ load_policy,
41
+ )
42
+ from dockerls.infrastructure.dockerfile_validator import DockerfileValidator, HardeningTemplates
43
+ from dockerls.integrations.signing.cosign import (
44
+ CosignClient,
45
+ SignatureResult,
46
+ SignatureStatus,
47
+ )
48
+
49
+ if TYPE_CHECKING:
50
+ from dockerls.domain.entities.dockerfile_analysis import DockerfileValidationResult
51
+ from dockerls.domain.value_objects.build_policy import PolicyViolation
52
+ from dockerls.domain.value_objects.inheritance import InheritanceReport
53
+ from dockerls.integrations.threat_intel.client import ThreatIntelClient
54
+
55
+ console = Console()
56
+
57
+
58
+ def build(
59
+ path: str = typer.Argument(".", help="Directory containing the Dockerfile"),
60
+ tag: str | None = typer.Option(None, "--tag", "-t", help="Image tag (required)"),
61
+ base: str | None = typer.Option(
62
+ None,
63
+ "--base",
64
+ help=(
65
+ "Hardened base template: alpine, debian, ubuntu, distroless, "
66
+ "node-alpine, python-alpine, maven-alpine, go-scratch, ... "
67
+ "(--list-templates shows all 39)"
68
+ ),
69
+ ),
70
+ hardened: bool = typer.Option(False, "--hardened", help="Use hardened Dockerfile templates"),
71
+ list_templates: bool = typer.Option(
72
+ False, "--list-templates", help="List the available hardened templates and exit"
73
+ ),
74
+ interactive: bool = typer.Option(
75
+ False, "--interactive", "-i", help="Step-by-step security wizard"
76
+ ),
77
+ scan: bool = typer.Option(True, "--scan/--no-scan", help="Run Trivy/Grype after the build"),
78
+ auto_remediate: bool = typer.Option(
79
+ False,
80
+ "--auto-fix",
81
+ "--auto-remediate",
82
+ help="Loop auto-remediation until zero vulnerabilities",
83
+ ),
84
+ zero_vulns: bool = typer.Option(
85
+ False, "--zero-vulns", help="Build and remediate until zero CVEs"
86
+ ),
87
+ max_iterations: int = typer.Option(
88
+ 3, "--max-iterations", help="Maximum number of remediation iterations"
89
+ ),
90
+ fail_on: str | None = typer.Option(
91
+ None,
92
+ "--fail-on",
93
+ help="Fail the build on: a severity (critical, high, medium, low), `kev` "
94
+ "(exploited in the wild, per CISA KEV), or `epss>=N` (probability of "
95
+ "exploitation). Combine with commas; all of them must pass",
96
+ ),
97
+ report: str | None = typer.Option(
98
+ None, "--report", "-r", help="Save the security report (JSON/HTML)"
99
+ ),
100
+ no_cache: bool = typer.Option(False, "--no-cache", help="Disable the Docker build cache"),
101
+ build_args: str | None = typer.Option(None, "--build-args", help="Build arguments (JSON)"),
102
+ labels: str | None = typer.Option(None, "--labels", help="Security labels (JSON)"),
103
+ ci_mode: bool = typer.Option(
104
+ False, "--ci-mode", help="CI/CD mode (JSON output, no interaction)"
105
+ ),
106
+ validate_only: bool = typer.Option(
107
+ False, "--validate-only", help="Only validate the Dockerfile"
108
+ ),
109
+ suggest_hardening: bool = typer.Option(
110
+ False, "--suggest-hardening", help="Suggest improvements without building"
111
+ ),
112
+ push: bool = typer.Option(False, "--push", help="docker push the tag after a successful build"),
113
+ registry: str | None = typer.Option(
114
+ None,
115
+ "--registry",
116
+ "--acr",
117
+ help=(
118
+ "Publish destination, without a tag: myacr.azurecr.io/apps/app, "
119
+ "us-central1-docker.pkg.dev/proj/repo/app, gcr.io/proj/app, myorg/app"
120
+ ),
121
+ ),
122
+ owner: str | None = typer.Option(
123
+ None, "--owner", help="Owning team or person (becomes maintainer and vendor)"
124
+ ),
125
+ security_contact: str | None = typer.Option(
126
+ None, "--security-contact", help="Contact for vulnerabilities in this image"
127
+ ),
128
+ source_url: str | None = typer.Option(
129
+ None, "--source", help="URL of the repository that produces this image"
130
+ ),
131
+ provenance: str | None = typer.Option(
132
+ None,
133
+ "--provenance",
134
+ help="Archive the supply-chain record (input and output hashes) as JSON",
135
+ ),
136
+ production: bool = typer.Option(
137
+ False,
138
+ "--production",
139
+ help=(
140
+ "Production profile: gate at critical, require a scan, pinned bases, an "
141
+ "unprivileged user, verified provenance, ownership labels and finding "
142
+ "attribution. Says in the output what it turned on"
143
+ ),
144
+ ),
145
+ attribute: bool = typer.Option(
146
+ False,
147
+ "--attribute",
148
+ help=(
149
+ "Also scan the declared base and say whose each CVE is: the base, or the "
150
+ "layers of this Dockerfile. Costs a second scan"
151
+ ),
152
+ ),
153
+ sign: bool = typer.Option(
154
+ False,
155
+ "--sign",
156
+ help=(
157
+ "Sign the published image with cosign (keyless/OIDC). Requires --push and "
158
+ "verified provenance"
159
+ ),
160
+ ),
161
+ policy: str | None = typer.Option(
162
+ None,
163
+ "--policy",
164
+ help=("Policy file to check (default: .dockerls-policy.yaml in the context, when present)"),
165
+ ),
166
+ no_policy: bool = typer.Option(
167
+ False,
168
+ "--no-policy",
169
+ help="Ignore the context .dockerls-policy.yaml. Recorded in the output",
170
+ ),
171
+ non_interactive: bool = typer.Option(
172
+ False,
173
+ "--non-interactive",
174
+ help="Ask nothing: anything missing becomes an error instead of stalling the pipeline",
175
+ ),
176
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose debug output"),
177
+ output: str | None = typer.Option(None, "--output", "-o", help="Report output file"),
178
+ force: bool = typer.Option(False, "--force", help="Build even when validation fails"),
179
+ compare_to_analysis: str | None = typer.Option(
180
+ None,
181
+ "--compare-to-analysis",
182
+ help="A report written by an earlier `analyze-dockerfile --output <file>` run "
183
+ "on this same Dockerfile. When given, the build report says which of those "
184
+ "findings are still present now and which were fixed",
185
+ ),
186
+ ) -> None:
187
+ """Build secure Docker images with validation, scanning and auto-remediation."""
188
+ if verbose:
189
+ enable_console_logging()
190
+
191
+ template_provider = HardeningTemplates()
192
+
193
+ if list_templates:
194
+ _print_templates(template_provider, ci_mode=ci_mode)
195
+ raise typer.Exit(EXIT_OK)
196
+
197
+ # Validar tag obrigatória (exceto em modos especiais)
198
+ if not tag and not validate_only and not suggest_hardening and not interactive:
199
+ console.print("[red]Error:[/red] --tag is required for build")
200
+ raise typer.Exit(EXIT_ERROR)
201
+
202
+ # Um limiar desconhecido não pode virar um portão que nunca reprova:
203
+ # rejeita antes de construir qualquer coisa.
204
+ if fail_on is not None:
205
+ try:
206
+ GateSet.parse(fail_on)
207
+ except InvalidGateError as e:
208
+ console.print(f"[red]Error:[/red] {e}")
209
+ raise typer.Exit(EXIT_ERROR) from e
210
+
211
+ # Uma base sem template não pode ser descoberta só depois do build ter
212
+ # começado -- o mesmo raciocínio do `--fail-on` acima.
213
+ if base is not None:
214
+ known = template_provider.list_templates()
215
+ # Nome exato. A comparação anterior perguntava se algum template era
216
+ # *substring* do que foi digitado, então `--base alpine-inexistente`
217
+ # passava aqui (por conter "alpine") e explodia lá dentro, na geração.
218
+ if base.strip().lower() not in known:
219
+ console.print(
220
+ f"[red]Error:[/red] invalid --base: {base!r}.\n"
221
+ f"[dim]Available: {', '.join(known)}[/dim]"
222
+ )
223
+ raise typer.Exit(EXIT_ERROR)
224
+
225
+ declared_policy = _load_policy(path, policy, no_policy=no_policy)
226
+ if production:
227
+ declared_policy = _announce_production(declared_policy)
228
+ attribute = True
229
+
230
+ # Parsear JSON args
231
+ build_args_dict = _parse_json_option(build_args, "--build-args")
232
+ labels_dict = _parse_json_option(labels, "--labels")
233
+
234
+ # Publicar sem veredito é a contradição que esta ferramenta existe para
235
+ # não cometer: o portão passa a ser obrigatório para quem publica, em vez
236
+ # de depender de alguém lembrar de passar --fail-on. `--no-scan` com push
237
+ # é recusado de saída -- sem medição não há veredito nenhum a dar.
238
+ publishing = push or bool(registry)
239
+ if publishing and not scan:
240
+ console.print(
241
+ "[red]Error:[/red] --push with --no-scan would publish an image nobody "
242
+ "measured. An unmeasured image is not a secure image; it is an unknown "
243
+ "one."
244
+ )
245
+ raise typer.Exit(EXIT_ERROR)
246
+ if publishing and fail_on is None:
247
+ fail_on = "critical"
248
+ console.print(
249
+ "[dim]Publishing: security gate at `critical` by default "
250
+ "(use --fail-on to change the threshold).[/dim]"
251
+ )
252
+
253
+ # Destino e responsabilidade são resolvidos **antes** do build: descobrir
254
+ # que o destino está errado depois de validar, construir e escanear
255
+ # desperdiça o trabalho inteiro, e rotular depois do build significa
256
+ # reconstruir.
257
+ quiet = non_interactive or ci_mode
258
+ identity = BuildIdentity(
259
+ owner=(owner or "").strip(),
260
+ security_contact=(security_contact or "").strip(),
261
+ source=(source_url or "").strip(),
262
+ version=(tag or "").rpartition(":")[2],
263
+ extra=labels_dict or {},
264
+ )
265
+ target = None
266
+ try:
267
+ if push or registry:
268
+ target = resolve_destination(registry, _tag_part(tag), non_interactive=quiet)
269
+ # Os rótulos só são exigidos de quem vai publicar: um build local para
270
+ # experimentar não precisa de dono, e transformar isso em obstáculo
271
+ # faria as pessoas desligarem a checagem inteira.
272
+ if target is not None:
273
+ identity = resolve_identity(identity, non_interactive=quiet)
274
+ except (InvalidRegistryTargetError, MissingBuildMetadataError) as e:
275
+ console.print(f"[red]Error:[/red] {e}")
276
+ raise typer.Exit(EXIT_ERROR) from e
277
+
278
+ labels_dict = {**identity.to_labels(), **(labels_dict or {})}
279
+
280
+ # Inicializar use case
281
+ validator = DockerfileValidator(template_provider)
282
+ # A inteligência de ameaça só é montada quando algum portão a pede:
283
+ # `--fail-on high` não deve sair para a rede consultar KEV, e um build
284
+ # sem portão de exploração não deve consultar nada.
285
+ use_case = BuildImageUseCase(
286
+ validator, template_provider, threat_intel=_threat_intel_for(fail_on, declared_policy)
287
+ )
288
+
289
+ # Criar request
290
+ request = BuildImageRequest(
291
+ context_path=path,
292
+ tag=tag or "temp:latest",
293
+ dockerfile_path="Dockerfile",
294
+ hardened=hardened,
295
+ base_template=base,
296
+ scan=scan,
297
+ validate_only=validate_only,
298
+ suggest_only=suggest_hardening,
299
+ no_cache=no_cache,
300
+ build_args=build_args_dict,
301
+ labels=labels_dict,
302
+ fail_on=fail_on,
303
+ ci_mode=ci_mode,
304
+ verbose=verbose,
305
+ force=force,
306
+ push=push or target is not None,
307
+ push_reference=target.reference if target else "",
308
+ provenance_path=(provenance or "").strip(),
309
+ auto_remediate=auto_remediate or zero_vulns,
310
+ max_remediation_rounds=max_iterations,
311
+ target_zero_vulns=zero_vulns,
312
+ policy=declared_policy,
313
+ attribute_findings=attribute,
314
+ )
315
+
316
+ # Executar
317
+ response = _run_interactive_wizard(use_case, path) if interactive else use_case.execute(request)
318
+
319
+ signature = _sign_if_requested(response, sign=sign, publishing=publishing)
320
+
321
+ if compare_to_analysis and response.validation is not None:
322
+ _print_analysis_comparison(compare_to_analysis, response.validation)
323
+
324
+ # Output
325
+ if ci_mode or output:
326
+ _print_json_output(response, output, signature=signature)
327
+ else:
328
+ _print_table_output(response, report)
329
+ if signature is not None:
330
+ _print_signature(signature)
331
+
332
+ # Assinar e falhar deixaria o pipeline verde com uma imagem publicada que
333
+ # ninguém atestou -- e o próximo `dockerls verify` seria a primeira notícia
334
+ # disso, tarde demais.
335
+ if signature is not None and not signature.trustworthy:
336
+ raise typer.Exit(EXIT_ERROR)
337
+ raise typer.Exit(response.exit_code)
338
+
339
+
340
+ def _sign_if_requested(
341
+ response: BuildImageResponse, *, sign: bool, publishing: bool
342
+ ) -> SignatureResult | None:
343
+ """Assina a imagem publicada, quando pedido e quando é legítimo assinar.
344
+
345
+ Duas recusas moram aqui, e as duas são sobre o mesmo erro: uma assinatura
346
+ aponta para bytes específicos e diz "eu publiquei isto". Emiti-la sobre um
347
+ artefato que não se sabe de onde veio transforma a assinatura em carimbo.
348
+ """
349
+ if not sign:
350
+ return None
351
+ if not publishing or not response.success:
352
+ console.print(
353
+ "[yellow]--sign ignored: only what was published can be signed, and this "
354
+ "build never published.[/yellow]"
355
+ )
356
+ return None
357
+
358
+ record = response.provenance
359
+ if record is None or not record.is_verified:
360
+ reason = record.explain() if record else "no provenance was recorded"
361
+ console.print(
362
+ f"[red]Signing refused:[/red] {safe(reason)}.\n"
363
+ "[dim]Signing asserts that you published these bytes; doing it over an "
364
+ "artifact whose inputs do not add up would be stamping the unknown.[/dim]"
365
+ )
366
+ return SignatureResult(
367
+ reference=response.image_tag or "",
368
+ status=SignatureStatus.FAILED,
369
+ detail="provenance not verified",
370
+ )
371
+
372
+ digest = record.artifact.repo_digest
373
+ reference = record.artifact.published_reference or response.image_tag or ""
374
+ if not digest:
375
+ console.print(
376
+ "[red]Signing refused:[/red] the registry did not return the manifest "
377
+ "digest.\n[dim]Signing the tag would sign what it points at now, and it "
378
+ "can move an instant later -- the signature would stay valid over "
379
+ "different bytes.[/dim]"
380
+ )
381
+ return SignatureResult(
382
+ reference=reference,
383
+ status=SignatureStatus.FAILED,
384
+ detail="no manifest digest",
385
+ )
386
+
387
+ alvo = _digest_reference(reference, digest)
388
+ console.print(f"[dim]Signing {safe(alvo)} with cosign (keyless).[/dim]")
389
+ return asyncio.run(CosignClient().sign(alvo))
390
+
391
+
392
+ def _announce_production(declared: BuildPolicy | None) -> BuildPolicy:
393
+ """Liga o perfil de produção e **diz o que ligou**.
394
+
395
+ Um perfil que muda o comportamento em silêncio é um perfil que a pessoa
396
+ descobre pelo build reprovando, e a primeira reação a um portão que
397
+ reprova sem explicar é desligá-lo.
398
+
399
+ Um `.dockerls-policy.yaml` no contexto continua valendo, e só pode
400
+ apertar: `--production` é um piso, não um teto.
401
+ """
402
+ perfil = BuildPolicy.production().merged_with(declared)
403
+ console.print("\n[bold]Production profile[/bold]")
404
+ for regra, valor in perfil.to_dict().items():
405
+ if valor:
406
+ console.print(f" [cyan]{regra}[/cyan] [dim]{safe(_describe_rule(valor))}[/dim]")
407
+ if declared is not None:
408
+ console.print(
409
+ " [dim]combined with the context .dockerls-policy.yaml, always by the "
410
+ "stricter side[/dim]"
411
+ )
412
+ console.print()
413
+ return perfil
414
+
415
+
416
+ def _describe_rule(value: object) -> str:
417
+ if isinstance(value, dict):
418
+ return ", ".join(f"{k}: {v}" for k, v in value.items())
419
+ if isinstance(value, list):
420
+ return ", ".join(str(v) for v in value)
421
+ return str(value)
422
+
423
+
424
+ def _print_inheritance(report: InheritanceReport | None) -> None:
425
+ """De quem é cada CVE -- a resposta para "consertar o quê?".
426
+
427
+ Uma contagem sozinha manda consertar sem dizer o quê, e quem lê passa a
428
+ tarde descobrindo que nada no Dockerfile dela resolve o problema.
429
+ """
430
+ if report is None:
431
+ return
432
+ if not report.available:
433
+ console.print(
434
+ f"\n[yellow]Attribution unavailable:[/yellow] [dim]{safe(report.explain())}[/dim]"
435
+ )
436
+ return
437
+
438
+ console.print("\n[bold]Where the vulnerabilities come from[/bold]")
439
+ console.print(f"[dim]{safe(report.explain())}[/dim]\n")
440
+
441
+ linhas = (
442
+ ("inherited from base", len(report.inherited), FindingOrigin.INHERITED, "yellow"),
443
+ ("from your layers", len(report.introduced), FindingOrigin.INTRODUCED, "red"),
444
+ ("removed in build", len(report.removed), FindingOrigin.REMOVED, "green"),
445
+ )
446
+ for rotulo, quantidade, origem, cor in linhas:
447
+ if not quantidade:
448
+ continue
449
+ console.print(f" [{cor}]{quantidade:>4}[/{cor}] {rotulo}")
450
+ console.print(f" [dim]{safe(ACTIONS[origem])}[/dim]")
451
+
452
+ _print_plan(report)
453
+
454
+ if report.inherited_share >= 0.5 and report.inherited:
455
+ console.print(
456
+ f"\n[yellow]{report.inherited_share:.0%} of this image vulnerabilities "
457
+ "came from the base.[/yellow]\n[dim]Changing your Dockerfile does not "
458
+ "address that part: run `dockerls base --alternatives` to measure another "
459
+ "base.[/dim]"
460
+ )
461
+
462
+
463
+ def _print_analysis_comparison(baseline_path: str, validation: DockerfileValidationResult) -> None:
464
+ """How this build's own validation compares to an earlier
465
+ `analyze-dockerfile --output` report on the same Dockerfile.
466
+
467
+ Both come from the exact same rule set -- `build` always runs its own
468
+ fresh validation as step one -- so this is a same-Dockerfile,
469
+ then-and-now comparison, not a guess about what changed.
470
+ """
471
+ from dockerls.cli.analysis_baseline import BaselineLoadError, compare, load_baseline_findings
472
+
473
+ try:
474
+ baseline = load_baseline_findings(baseline_path)
475
+ except BaselineLoadError as e:
476
+ console.print(f"\n[yellow]--compare-to-analysis:[/yellow] {safe(str(e))}")
477
+ return
478
+
479
+ result = compare(baseline, validation)
480
+ if not result.baseline_total and not result.newly_introduced:
481
+ return
482
+
483
+ console.print("\n[bold]Compared to the earlier analyze-dockerfile run[/bold]")
484
+ if result.baseline_total:
485
+ console.print(
486
+ f"[dim]{len(result.still_present)}/{result.baseline_total} of its "
487
+ "warnings/errors are still present in this build.[/dim]"
488
+ )
489
+ for name in result.resolved:
490
+ console.print(f" [green]fixed[/green] {safe(name)}")
491
+ for name in result.still_present:
492
+ console.print(f" [yellow]still open[/yellow] {safe(name)}")
493
+ for name in result.newly_introduced:
494
+ console.print(f" [red]new[/red] {safe(name)}")
495
+
496
+
497
+ def _print_plan(report: InheritanceReport) -> None:
498
+ """O plano de trabalho: origem cruzada com "existe correção?".
499
+
500
+ Origem sozinha diz de quem é o problema; correção diz se ele tem solução.
501
+ Só as duas juntas dizem o que fazer na segunda-feira -- e a diferença é
502
+ grande: se nenhuma das herdadas tem correção publicada, atualizar a base é
503
+ trabalho perdido.
504
+ """
505
+ plano = report.plan()
506
+ if not plano:
507
+ return
508
+
509
+ console.print("\n[bold]Work plan[/bold]")
510
+ for bucket in plano:
511
+ de_onde = "from the base" if bucket.origin is FindingOrigin.INHERITED else "yours"
512
+ com_correcao = "fixable" if bucket.fixable else "no fix"
513
+ criticas = f", {bucket.critical} CRITICAL" if bucket.critical else ""
514
+ cor = "red" if bucket.critical else "yellow"
515
+ console.print(f" [{cor}]{bucket.count:>4}[/{cor}] {de_onde}, {com_correcao}{criticas}")
516
+ console.print(f" [dim]{safe(bucket.action())}[/dim]")
517
+ # Os três primeiros por severidade: uma lista completa aqui viraria
518
+ # rolagem, e quem quer todos usa --format json.
519
+ amostra = ", ".join(f"{f.cve_id} ({f.package_name})" for f in bucket.findings[:3])
520
+ if amostra:
521
+ resto = f" and {bucket.count - 3} more" if bucket.count > 3 else ""
522
+ console.print(f" [dim]{safe(amostra)}{resto}[/dim]")
523
+
524
+
525
+ def _digest_reference(reference: str, digest: str) -> str:
526
+ """`reg.io/app:1.0` + digest -> `reg.io/app@sha256:...`.
527
+
528
+ A tag sai fora. `nome:tag@digest` é válido e o digest é quem manda, mas
529
+ manter os dois convida quem lê a achar que a tag importa -- e a assinatura
530
+ existe justamente porque ela não importa.
531
+ """
532
+ head = reference.split("@", 1)[0]
533
+ repositorio, separador, cauda = head.rpartition(":")
534
+ # `registry:5000/app` tem `:` no host, não na tag.
535
+ if separador and "/" not in cauda:
536
+ head = repositorio
537
+ return f"{head}@{digest}"
538
+
539
+
540
+ def _print_signature(signature: SignatureResult) -> None:
541
+ cor = "green" if signature.trustworthy or signature.status is SignatureStatus.SIGNED else "red"
542
+ console.print(f"\n[{cor}]{signature.status}[/{cor}] [dim]{safe(signature.explain())}[/dim]")
543
+ if signature.detail and not signature.trustworthy:
544
+ console.print(f"[dim]{safe(signature.detail)}[/dim]")
545
+
546
+
547
+ def _threat_intel_for(fail_on: str | None, policy: BuildPolicy | None) -> ThreatIntelClient | None:
548
+ """O cliente de inteligência de ameaça, quando algum portão o exige.
549
+
550
+ Montá-lo sempre faria todo `dockerls build` sair para a rede buscar o
551
+ catálogo KEV e as pontuações EPSS -- para um portão que ninguém pediu.
552
+ Montá-lo nunca faria `--fail-on kev` não ter o que consultar, e um
553
+ portão de segurança que não avalia é a pior falha possível: a que não
554
+ aparece.
555
+
556
+ A pergunta é feita sobre o portão **efetivo**, política somada à linha
557
+ de comando: um `.dockerls-policy.yaml` que exige `kev` tem de fazer o
558
+ cliente existir mesmo que a linha de comando não peça nada.
559
+ """
560
+ declared = policy.fail_on if policy is not None else ""
561
+ effective = merge_gates(declared, fail_on or "")
562
+ if not effective:
563
+ return None
564
+ try:
565
+ gates = GateSet.parse(effective)
566
+ except InvalidGateError:
567
+ # Valor inválido já foi recusado antes de chegar aqui; se escapou,
568
+ # não é este o lugar de reclamar.
569
+ return None
570
+ if not any(gate.kind in (GateKind.KEV, GateKind.EPSS) for gate in gates.gates):
571
+ return None
572
+
573
+ from dockerls.integrations.threat_intel.client import ThreatIntelClient
574
+
575
+ return ThreatIntelClient()
576
+
577
+
578
+ def _load_policy(context: str, explicit: str | None, *, no_policy: bool) -> BuildPolicy | None:
579
+ """A política a conferir neste build, ou `None` quando não há nenhuma.
580
+
581
+ Um arquivo de política ilegível **encerra o comando**, em vez de virar
582
+ "sem política". A direção da falha é o que decide: uma regra que não
583
+ carrega deixa de exigir alguma coisa, e o build passaria parecendo ter
584
+ sido conferido. Uma chave digitada errado seria um portão aberto com cara
585
+ de fechado, e ninguém descobre isso olhando a saída verde.
586
+ """
587
+ if no_policy:
588
+ console.print(
589
+ "[yellow]--no-policy: the context .dockerls-policy.yaml will not be "
590
+ "conferido neste build.[/yellow]"
591
+ )
592
+ return None
593
+
594
+ target = Path(explicit) if explicit else find_policy_file(Path(context))
595
+ if target is None:
596
+ return None
597
+ if explicit and not target.is_file():
598
+ console.print(f"[red]Error:[/red] policy file not found: {safe(explicit)}")
599
+ raise typer.Exit(EXIT_ERROR)
600
+
601
+ try:
602
+ declared = load_policy(target)
603
+ except PolicyFileError as e:
604
+ console.print(f"[red]Error:[/red] {safe(str(e))}")
605
+ raise typer.Exit(EXIT_ERROR) from e
606
+
607
+ console.print(f"[dim]Policy declared in {safe(str(target))} will be checked.[/dim]")
608
+ return declared
609
+
610
+
611
+ def _print_policy_violations(violations: list[PolicyViolation]) -> None:
612
+ if not violations:
613
+ return
614
+ console.print("\n[bold red]Policy not met[/bold red]")
615
+ for violation in violations:
616
+ console.print(f" [red]x[/red] [bold]{violation.rule}[/bold] {safe(violation.message)}")
617
+ console.print(
618
+ "\n[dim]These rules come from the `--production` profile and/or the context "
619
+ ".dockerls-policy.yaml. That file is versioned alongside the code: changing it "
620
+ "is a reviewable change, passing a different flag on the command line is "
621
+ "not.[/dim]"
622
+ )
623
+
624
+
625
+ def _parse_json_option(raw: str | None, flag: str) -> dict[str, str] | None:
626
+ """Parseia um argumento JSON de linha de comando, ou aborta com exit 1."""
627
+ if not raw:
628
+ return None
629
+ try:
630
+ parsed: dict[str, str] = json.loads(raw)
631
+ except json.JSONDecodeError as e:
632
+ console.print(f"[red]Error parsing {flag}:[/red] {e}")
633
+ raise typer.Exit(EXIT_ERROR) from e
634
+ return parsed
635
+
636
+
637
+ def _print_templates(template_provider: HardeningTemplates, ci_mode: bool = False) -> None:
638
+ """Lista os templates hardened que `--base`/`--hardened` aceitam."""
639
+ templates = template_provider.list_templates()
640
+ if ci_mode:
641
+ typer.echo(json.dumps({"templates": templates}, indent=2))
642
+ return
643
+
644
+ console.print(Panel("[bold cyan]Available hardened templates[/bold cyan]", expand=False))
645
+
646
+ # Agrupado por stack, com o sistema operacional visível. Uma lista plana de
647
+ # quase quarenta nomes não responde a pergunta que a pessoa tem, que é
648
+ # "qual serve para a MINHA aplicação, e sobre qual SO ela vai rodar".
649
+ grouped: dict[str, list[str]] = {}
650
+ for name in templates:
651
+ stack = name.split("-", 1)[0] if "-" in name else name
652
+ if name in _STANDALONE_OS:
653
+ stack = "so"
654
+ grouped.setdefault(stack, []).append(name)
655
+
656
+ for stack in sorted(grouped, key=lambda s: (s != "so", s)):
657
+ title = _STACK_TITLES.get(stack, stack.capitalize())
658
+ console.print(f"\n[bold]{title}[/bold]")
659
+ for name in grouped[stack]:
660
+ console.print(f" [cyan]{name:<18}[/cyan] [dim]{_TEMPLATE_HINTS.get(name, '')}[/dim]")
661
+
662
+ console.print("\n[bold]Examples[/bold]")
663
+ for example in _BUILD_EXAMPLES:
664
+ console.print(f" [dim]{example}[/dim]")
665
+ console.print(
666
+ "\n[dim]Without --base or --hardened, the build uses the Dockerfile already "
667
+ "in the directory -- templates only apply when you ask for one.[/dim]"
668
+ )
669
+
670
+
671
+ #: Templates que são só o sistema operacional, sem runtime de linguagem.
672
+ _STANDALONE_OS = frozenset({"alpine", "debian", "ubuntu", "distroless"})
673
+
674
+ _STACK_TITLES = {
675
+ "so": "The operating system alone (no runtime)",
676
+ "node": "Node.js",
677
+ "python": "Python",
678
+ "java": "Java (runtime)",
679
+ "maven": "Java, built with Maven (build + runtime)",
680
+ "gradle": "Java, built with Gradle (build + runtime)",
681
+ "go": "Go",
682
+ "rust": "Rust",
683
+ "php": "PHP",
684
+ "ruby": "Ruby",
685
+ }
686
+
687
+ #: O que distingue cada variante. Sem isto, escolher entre `node-alpine` e
688
+ #: `node-distroless` é adivinhação.
689
+ _TEMPLATE_HINTS = {
690
+ "alpine": "musl, ~5 MB, has a shell",
691
+ "debian": "glibc, stable, has a shell",
692
+ "ubuntu": "glibc, more packages available",
693
+ "distroless": "no shell and no package manager",
694
+ "node": "Debian slim",
695
+ "node-alpine": "musl -- watch out for native modules (sharp, bcrypt)",
696
+ "node-debian": "glibc",
697
+ "node-ubuntu": "glibc",
698
+ "node-distroless": "no shell; runtime only",
699
+ "python": "Debian slim",
700
+ "python-alpine": "musl -- wheels must be musllinux",
701
+ "python-debian": "glibc",
702
+ "python-ubuntu": "glibc",
703
+ "python-distroless": "no shell; interpreter only",
704
+ "java": "Temurin JRE",
705
+ "java-alpine": "Temurin JRE Alpine",
706
+ "java-debian": "Temurin JRE Debian",
707
+ "java-ubuntu": "Temurin JRE Ubuntu",
708
+ "java-distroless": "no shell; JVM only",
709
+ "maven": "builds with Maven, runs on the JRE alone",
710
+ "maven-alpine": "builds with Maven, runs on the JRE alone Alpine",
711
+ "gradle": "builds with Gradle, runs on the JRE alone",
712
+ "gradle-alpine": "builds with Gradle, runs on the JRE alone Alpine",
713
+ "go": "Debian slim",
714
+ "go-alpine": "static musl",
715
+ "go-debian": "glibc",
716
+ "go-distroless": "no shell",
717
+ "go-scratch": "the static binary alone -- the smallest surface there is",
718
+ "rust": "Debian slim",
719
+ "rust-alpine": "musl",
720
+ "rust-debian": "glibc",
721
+ "rust-scratch": "the static binary alone",
722
+ "php": "Debian slim",
723
+ "php-alpine": "musl",
724
+ "php-debian": "glibc",
725
+ "php-ubuntu": "glibc",
726
+ "ruby": "Debian slim",
727
+ "ruby-alpine": "musl",
728
+ "ruby-debian": "glibc",
729
+ }
730
+
731
+ #: Exemplos reais, um por forma de uso. A pergunta que eles respondem é "como
732
+ #: eu escrevo isso", que nenhuma lista de nomes responde sozinha.
733
+ _BUILD_EXAMPLES = (
734
+ "dockerls build -t minha-api:1.0 .",
735
+ " ^ uses the Dockerfile already in the directory",
736
+ "",
737
+ "dockerls build --hardened --base node-alpine -t minha-api:1.0 .",
738
+ " ^ generates a hardened Node-on-Alpine Dockerfile and builds with it",
739
+ "",
740
+ "dockerls build --hardened --base maven-alpine -t minha-api:1.0 --fail-on critical .",
741
+ " ^ Java with Maven: builds with the tool, runs on the JRE alone",
742
+ "",
743
+ "dockerls build --hardened --base go-scratch -t minha-api:1.0 .",
744
+ " ^ the static binary alone: the smallest attack surface there is",
745
+ "",
746
+ "dockerls build --hardened --base ubuntu -t minha-base:1.0 .",
747
+ " ^ the operating system only, with no language runtime",
748
+ )
749
+
750
+
751
+ def _run_interactive_wizard(use_case: BuildImageUseCase, path: str) -> BuildImageResponse:
752
+ """Executa wizard interativo completo com questionário aprofundado."""
753
+ console.print(
754
+ Panel(
755
+ "[bold cyan]DockerLs Interactive Build Wizard[/bold cyan]\n"
756
+ "[dim]Step-by-step setup aimed at security and zero vulnerabilities[/dim]",
757
+ expand=False,
758
+ )
759
+ )
760
+ console.print()
761
+
762
+ available = HardeningTemplates().list_templates() or [
763
+ "node",
764
+ "python",
765
+ "go",
766
+ "rust",
767
+ "java",
768
+ "php",
769
+ ]
770
+
771
+ # 1. Ecossistema / Linguagem
772
+ console.print("[bold yellow]? 1. Which ecosystem / language is your application?[/bold yellow]")
773
+ stacks = ["node", "python", "go", "java", "rust", "php", "other"]
774
+ for i, s in enumerate(stacks, 1):
775
+ console.print(f" {i}. {s}")
776
+ stack_choice = _prompt_choice(stacks, "1")
777
+
778
+ # 2. Versão recomendada e particularidades
779
+ version_options = {
780
+ "node": ["22.x LTS (Recommended)", "20.x LTS", "18.x", "custom"],
781
+ "python": ["3.12 (Recommended)", "3.13", "3.11", "custom"],
782
+ "go": ["1.23 (Recommended)", "1.24", "1.22", "custom"],
783
+ "java": ["21 LTS (Eclipse Temurin)", "17 LTS", "custom"],
784
+ "rust": ["1.82 (Alpine musl static)", "latest", "custom"],
785
+ "php": ["8.3 FPM/CLI", "8.2", "custom"],
786
+ }
787
+ opts = version_options.get(stack_choice, ["latest", "custom"])
788
+ console.print(f"\n[bold yellow]? 2. Which version of {stack_choice} do you want?[/bold yellow]")
789
+ for i, opt in enumerate(opts, 1):
790
+ console.print(f" {i}. {opt}")
791
+ _ = _prompt_choice(opts, "1")
792
+
793
+ # 3. Base distribution
794
+ console.print("\n[bold yellow]? 3. Which base distribution do you prefer?[/bold yellow]")
795
+ distros = [
796
+ "alpine (Alpine Linux - Ultra-lightweight musl)",
797
+ "debian (Debian Bookworm Slim - glibc)",
798
+ "ubuntu (Ubuntu 24.04 LTS - highest package compatibility)",
799
+ "distroless (Google Distroless - no shell, zero OS CVEs)",
800
+ "scratch (plain scratch, for static binaries)",
801
+ ]
802
+ for i, d in enumerate(distros, 1):
803
+ console.print(f" {i}. {d}")
804
+ distro_raw = _prompt_choice(distros, "1")
805
+ distro_key = distro_raw.split()[0].lower()
806
+
807
+ # 4. Usar template hardened
808
+ console.print(
809
+ "\n[bold yellow]? 4. Use a multi-stage template with a non-root user?[/bold yellow]"
810
+ )
811
+ console.print(" 1. yes (recommended - reduces the attack surface)")
812
+ console.print(" 2. no (use the directory default Dockerfile)")
813
+ use_hardened = _prompt_choice(["yes", "no"], "1") == "yes"
814
+
815
+ # 5. Dependências do SO / build nativo
816
+ console.print(
817
+ "\n[bold yellow]? 5. Does your application need native OS dependencies?[/bold yellow]"
818
+ )
819
+ deps_opts = [
820
+ "none (default runtime only)",
821
+ "build-essential / gcc / make",
822
+ "libpq (PostgreSQL client)",
823
+ "openssl / ca-certificates",
824
+ ]
825
+ for i, dep in enumerate(deps_opts, 1):
826
+ console.print(f" {i}. {dep}")
827
+ _ = _prompt_choice(deps_opts, "1")
828
+
829
+ # 6. Portas
830
+ default_port = (
831
+ "3000"
832
+ if stack_choice == "node"
833
+ else "8000"
834
+ if stack_choice in ("python", "php")
835
+ else "8080"
836
+ )
837
+ port_input = (
838
+ console.input(f"\n[bold yellow]? 6. Application port [{default_port}]: [/bold yellow]")
839
+ or default_port
840
+ )
841
+
842
+ # 7. Scan pós-build
843
+ console.print("\n[bold yellow]? 7. Run a vulnerability scan after the build?[/bold yellow]")
844
+ scan = _prompt_choice(["yes", "no"], "1") == "yes"
845
+
846
+ # 8. Ciclo de auto-remediação até zero vulnerabilidades
847
+ console.print("\n[bold yellow]? 8. Loop until ZERO vulnerabilities?[/bold yellow]")
848
+ console.print(" 1. yes (patches until the CVEs are gone)")
849
+ console.print(" 2. no (only report the vulnerabilities found)")
850
+ zero_vulns = _prompt_choice(["yes", "no"], "1") == "yes"
851
+
852
+ # 9. Tag da imagem
853
+ tag_input = (
854
+ console.input("\n[bold yellow]? 9. Docker image tag [app:latest]: [/bold yellow]")
855
+ or "app:latest"
856
+ )
857
+
858
+ # 10. Push para registro
859
+ console.print("\n[bold yellow]? 10. Publish (docker push) once it passes?[/bold yellow]")
860
+ push_choice = _prompt_choice(["no", "dockerhub", "ghcr", "harbor"], "1")
861
+
862
+ # Determinar melhor template com base na combinação Stack + Distro
863
+ candidate_key = f"{stack_choice}-{distro_key}"
864
+ if candidate_key in available:
865
+ base_template = candidate_key
866
+ elif stack_choice in available:
867
+ base_template = stack_choice
868
+ elif distro_key in available:
869
+ base_template = distro_key
870
+ else:
871
+ base_template = "node-alpine"
872
+
873
+ request = BuildImageRequest(
874
+ context_path=path,
875
+ tag=tag_input,
876
+ hardened=use_hardened,
877
+ base_template=base_template if use_hardened else None,
878
+ scan=scan,
879
+ auto_remediate=zero_vulns,
880
+ target_zero_vulns=zero_vulns,
881
+ push=push_choice != "no",
882
+ labels={"app.port": port_input, "dockerls.managed": "true"},
883
+ )
884
+
885
+ return use_case.execute(request)
886
+
887
+
888
+ def _prompt_choice(options: list[str], default: str = "1") -> str:
889
+ """Solicita a escolha do usuário."""
890
+ while True:
891
+ try:
892
+ choice = console.input(f"\nChoice [{default}]: ")
893
+ if not choice:
894
+ choice = default
895
+ idx = int(choice) - 1
896
+ if 0 <= idx < len(options):
897
+ return options[idx]
898
+ except ValueError:
899
+ continue
900
+
901
+
902
+ def _print_table_output(response: BuildImageResponse, report_file: str | None = None) -> None:
903
+ """Imprime resultado formatado em tabela."""
904
+ # Nenhuma imagem construída: o resultado é a validação, e é ela que
905
+ # precisa aparecer -- com os checks, não só com um veredito.
906
+ if response.image_tag is None:
907
+ _print_validation_output(response, report_file)
908
+ return
909
+
910
+ _print_build_output(response, report_file)
911
+
912
+
913
+ def _print_validation_output(response: BuildImageResponse, report_file: str | None) -> None:
914
+ if response.validation is not None:
915
+ render_validation_report(
916
+ console,
917
+ response.validation,
918
+ analysis=response.analysis,
919
+ suggestions=list(response.recommendations) or None,
920
+ title="Dockerfile Validation",
921
+ )
922
+
923
+ if response.success:
924
+ console.print(
925
+ Panel(
926
+ "[bold green]Validation Passed[/bold green]\n"
927
+ "[dim]No blocking policy violations found[/dim]",
928
+ expand=False,
929
+ )
930
+ )
931
+ else:
932
+ console.print(
933
+ Panel(
934
+ f"[bold red]Validation Failed[/bold red]\n\n"
935
+ f"[red]{response.error or 'Dockerfile validation failed'}[/red]",
936
+ expand=False,
937
+ )
938
+ )
939
+
940
+ _print_policy_violations(response.policy_violations)
941
+ _write_report_file(response.report, report_file)
942
+ console.print()
943
+
944
+
945
+ def _print_build_output(response: BuildImageResponse, report_file: str | None) -> None:
946
+ if not response.success:
947
+ console.print(
948
+ Panel(
949
+ f"[bold red]Build Failed[/bold red]\n\n"
950
+ f"[red]{response.error or 'Build failed'}[/red]",
951
+ expand=False,
952
+ )
953
+ )
954
+ _print_inheritance(response.inheritance)
955
+ _print_policy_violations(response.policy_violations)
956
+ _write_report_file(response.report, report_file)
957
+ return
958
+
959
+ console.print(
960
+ Panel(
961
+ f"[bold green]Build Successful[/bold green]\n[dim]{response.image_tag}[/dim]",
962
+ expand=False,
963
+ )
964
+ )
965
+ console.print()
966
+
967
+ report = response.report
968
+ if report is not None:
969
+ _print_report(report)
970
+ _write_report_file(report, report_file)
971
+
972
+ _print_inheritance(response.inheritance)
973
+
974
+ if response.provenance is not None:
975
+ _print_provenance(response.provenance)
976
+
977
+ if response.recommendations:
978
+ console.print(Panel("[bold yellow]Hardening Suggestions[/bold yellow]", expand=False))
979
+ for i, rec in enumerate(response.recommendations[:3], 1):
980
+ console.print(f"\n{i}. [bold]{rec.title}[/bold]")
981
+ console.print(f" [dim]{rec.description}[/dim]")
982
+ console.print(f" Fix: [green]{rec.suggested_fix}[/green]")
983
+
984
+ console.print()
985
+
986
+
987
+ def _print_report(report: BuildReport) -> None:
988
+ tier_colors = {"A": "green", "B": "yellow", "C": "yellow", "D": "red", "F": "red"}
989
+ tier_color = tier_colors.get(report.security_tier, "white")
990
+
991
+ console.print(
992
+ Panel(
993
+ f"[bold]Security Score: {report.security_score}/100[/bold]\n"
994
+ f"Tier: [{tier_color} bold]{report.security_tier}[/{tier_color} bold]",
995
+ expand=False,
996
+ )
997
+ )
998
+ console.print()
999
+
1000
+ validation = report.validation
1001
+ console.print(
1002
+ f"Validation: [green]{validation.get('passed', 0)} passed[/green] | "
1003
+ f"[yellow]{validation.get('warnings', 0)} warnings[/yellow] | "
1004
+ f"[red]{validation.get('errors', 0)} errors[/red]"
1005
+ )
1006
+ console.print()
1007
+
1008
+ if report.scan_results:
1009
+ console.print(Panel("[bold magenta]Security Scan Results[/bold magenta]", expand=False))
1010
+ scan_data = next(iter(report.scan_results.values()))
1011
+ console.print(f" CRITICAL: [red]{scan_data.get('critical', 0)}[/red]")
1012
+ console.print(f" HIGH: [red]{scan_data.get('high', 0)}[/red]")
1013
+ console.print(f" MEDIUM: [yellow]{scan_data.get('medium', 0)}[/yellow]")
1014
+ console.print(f" LOW: [dim]{scan_data.get('low', 0)}[/dim]")
1015
+ console.print()
1016
+
1017
+ if report.remediation_history:
1018
+ console.print(Panel("[bold green]Auto-Remediation Summary[/bold green]", expand=False))
1019
+ for item in report.remediation_history:
1020
+ round_num = item.get("round", 1)
1021
+ actions = item.get("actions", [])
1022
+ crit_b = item.get("critical_before", 0)
1023
+ crit_a = item.get("critical_after", 0)
1024
+ total_b = item.get("total_before", 0)
1025
+ total_a = item.get("total_after", 0)
1026
+ console.print(
1027
+ f" [bold cyan]Round {round_num}:[/bold cyan] "
1028
+ f"Total Vulns: {total_b} -> [green]{total_a}[/green] | "
1029
+ f"Critical: {crit_b} -> [green]{crit_a}[/green]"
1030
+ )
1031
+ for action in actions:
1032
+ console.print(f" - [dim]{action}[/dim]")
1033
+ console.print()
1034
+
1035
+ if report.recommendations:
1036
+ console.print(Panel("[bold yellow]Recommendations[/bold yellow]", expand=False))
1037
+ priority_colors = {"CRITICAL": "red", "HIGH": "red", "MEDIUM": "yellow", "LOW": "dim"}
1038
+ for i, rec in enumerate(report.recommendations[:5], 1):
1039
+ priority = str(rec.get("priority", "MEDIUM"))
1040
+ priority_color = priority_colors.get(priority, "white")
1041
+ console.print(f"\n[{priority_color}]#{i}. {rec.get('title', '-')}[/{priority_color}]")
1042
+ console.print(f" [dim]{rec.get('reason', '-')}[/dim]")
1043
+ console.print(f" Fix: [green]{rec.get('suggested', '-')}[/green]")
1044
+ console.print()
1045
+
1046
+
1047
+ def _write_report_file(report: BuildReport | None, report_file: str | None) -> None:
1048
+ if report is None or not report_file:
1049
+ return
1050
+ try:
1051
+ _save_report(report, report_file)
1052
+ except OSError as e:
1053
+ # An unwritable report destination is user error (bad path, no
1054
+ # permission), not a crash -- and it must not mask the build result
1055
+ # that was already printed above.
1056
+ console.print(f"\n[red]Could not write report to {report_file}:[/red] {e}")
1057
+ return
1058
+ console.print(f"\nReport saved: [cyan]{report_file}[/cyan]")
1059
+
1060
+
1061
+ def _report_dict(report: BuildReport) -> dict[str, Any]:
1062
+ return {
1063
+ "build_id": report.build_id,
1064
+ "timestamp": report.timestamp,
1065
+ "image": report.image,
1066
+ "dockerfile_path": report.dockerfile_path,
1067
+ "security_score": report.security_score,
1068
+ "security_tier": report.security_tier,
1069
+ "validation": report.validation,
1070
+ "scan_results": report.scan_results,
1071
+ "recommendations": report.recommendations,
1072
+ "build_metadata": report.build_metadata,
1073
+ "remediation_history": report.remediation_history,
1074
+ "auto_remediated": report.auto_remediated,
1075
+ }
1076
+
1077
+
1078
+ def _print_json_output(
1079
+ response: BuildImageResponse,
1080
+ output_file: str | None = None,
1081
+ *,
1082
+ signature: SignatureResult | None = None,
1083
+ ) -> None:
1084
+ """Imprime saída JSON (CI mode).
1085
+
1086
+ Vai para stdout via `typer.echo`, não pelo console do Rich: em CI o
1087
+ consumidor é um parser, e cor ou quebra de linha por largura de terminal
1088
+ quebrariam o JSON.
1089
+ """
1090
+ output_data: dict[str, Any] = {
1091
+ "status": "SUCCESS" if response.success else "FAILED",
1092
+ "exit_code": response.exit_code,
1093
+ }
1094
+
1095
+ # O relatório entra sempre que existe -- inclusive numa validação
1096
+ # reprovada, que é justamente quando o CI precisa saber o que falhou.
1097
+ if response.report is not None:
1098
+ output_data["report"] = _report_dict(response.report)
1099
+ # A procedência entra no JSON sempre que existe: é o que um portão de
1100
+ # supply chain lê para decidir, e ele não lê tabela de terminal.
1101
+ if response.provenance is not None:
1102
+ output_data["provenance"] = response.provenance.to_dict()
1103
+ if signature is not None:
1104
+ output_data["signature"] = signature.to_dict()
1105
+ if response.inheritance is not None:
1106
+ output_data["inheritance"] = response.inheritance.to_dict()
1107
+ if response.policy_violations:
1108
+ output_data["policy_violations"] = [v.to_dict() for v in response.policy_violations]
1109
+ if response.error:
1110
+ output_data["error"] = response.error
1111
+
1112
+ json_output = json.dumps(output_data, indent=2)
1113
+
1114
+ if output_file:
1115
+ try:
1116
+ Path(output_file).write_text(json_output, encoding="utf-8")
1117
+ except OSError as e:
1118
+ console.print(f"[red]Could not write {output_file}:[/red] {e}")
1119
+ raise typer.Exit(EXIT_ERROR) from e
1120
+ console.print(f"Report saved to {output_file}", style="dim")
1121
+ else:
1122
+ typer.echo(json_output)
1123
+
1124
+
1125
+ def _save_report(report: BuildReport, filepath: str) -> None:
1126
+ """Salva relatório em arquivo."""
1127
+ path = Path(filepath)
1128
+
1129
+ if path.suffix.lower() in (".html", ".htm"):
1130
+ path.write_text(_render_html_report(report), encoding="utf-8")
1131
+ return
1132
+
1133
+ path.write_text(json.dumps(_report_dict(report), indent=2), encoding="utf-8")
1134
+
1135
+
1136
+ def _render_html_report(report: BuildReport) -> str:
1137
+ score_color = "#22c55e" if report.security_score >= 75 else "#ef4444"
1138
+ tier_color = "#22c55e" if report.security_tier == "A" else "#ef4444"
1139
+ validation = report.validation
1140
+ # Every value below originates outside this process -- `--tag`, the
1141
+ # Dockerfile path, the tier string. Interpolated raw, a tag like
1142
+ # `x"><script>...` turned the report into an execution vector for whoever
1143
+ # opens it. The `export --format html` path already escaped; this one did
1144
+ # not, which is exactly the kind of split a security tool cannot afford.
1145
+ image = _esc(report.image)
1146
+ dockerfile_path = _esc(report.dockerfile_path)
1147
+ timestamp = _esc(report.timestamp)
1148
+ tier = _esc(report.security_tier)
1149
+
1150
+ html = f"""<!DOCTYPE html>
1151
+ <html>
1152
+ <head>
1153
+ <title>DockerLs Build Report - {image or dockerfile_path}</title>
1154
+ <style>
1155
+ body {{ font-family: Arial, sans-serif; margin: 40px; }}
1156
+ .score {{ font-size: 48px; font-weight: bold; color: {score_color}; }}
1157
+ .tier {{ font-size: 24px; color: {tier_color}; }}
1158
+ table {{ border-collapse: collapse; width: 100%; margin: 20px 0; }}
1159
+ th, td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }}
1160
+ th {{ background-color: #f3f4f6; }}
1161
+ .critical {{ color: #dc2626; }}
1162
+ .high {{ color: #dc2626; }}
1163
+ .medium {{ color: #f59e0b; }}
1164
+ .low {{ color: #6b7280; }}
1165
+ </style>
1166
+ </head>
1167
+ <body>
1168
+ <h1>DockerLs Build Report</h1>
1169
+ <p><strong>Image:</strong> {image or "(not built)"}</p>
1170
+ <p><strong>Dockerfile:</strong> {dockerfile_path}</p>
1171
+ <p><strong>Timestamp:</strong> {timestamp}</p>
1172
+
1173
+ <h2>Security Assessment</h2>
1174
+ <div class="score">{_int(report.security_score)}/100</div>
1175
+ <div class="tier">Tier: {tier}</div>
1176
+
1177
+ <h2>Validation Results</h2>
1178
+ <table>
1179
+ <tr><th>Passed</th><td>{_int(validation.get("passed", 0))}</td></tr>
1180
+ <tr><th>Warnings</th><td>{_int(validation.get("warnings", 0))}</td></tr>
1181
+ <tr><th>Errors</th><td>{_int(validation.get("errors", 0))}</td></tr>
1182
+ </table>
1183
+
1184
+ <h2>Vulnerability Scan</h2>
1185
+ """
1186
+
1187
+ if report.scan_results:
1188
+ scan = next(iter(report.scan_results.values()))
1189
+ html += f"""
1190
+ <table>
1191
+ <tr><th>Severity</th><th>Count</th></tr>
1192
+ <tr><td class="critical">Critical</td><td>{_int(scan.get("critical", 0))}</td></tr>
1193
+ <tr><td class="high">High</td><td>{_int(scan.get("high", 0))}</td></tr>
1194
+ <tr><td class="medium">Medium</td><td>{_int(scan.get("medium", 0))}</td></tr>
1195
+ <tr><td class="low">Low</td><td>{_int(scan.get("low", 0))}</td></tr>
1196
+ </table>
1197
+ """
1198
+ else:
1199
+ html += " <p>No scan was run.</p>\n"
1200
+
1201
+ if report.remediation_history:
1202
+ html += """
1203
+ <h2>Auto-Remediation Summary</h2>
1204
+ <table>
1205
+ <tr>
1206
+ <th>Round</th>
1207
+ <th>Fixes Applied</th>
1208
+ <th>Critical (Before &rarr; After)</th>
1209
+ <th>Total (Before &rarr; After)</th>
1210
+ </tr>
1211
+ """
1212
+ for item in report.remediation_history:
1213
+ round_num = _int(item.get("round", 1))
1214
+ actions_str = _esc("<br>".join(item.get("actions", [])))
1215
+ cb = _int(item.get("critical_before", 0))
1216
+ ca = _int(item.get("critical_after", 0))
1217
+ tb = _int(item.get("total_before", 0))
1218
+ ta = _int(item.get("total_after", 0))
1219
+ html += (
1220
+ f" <tr><td>{round_num}</td><td>{actions_str}</td>"
1221
+ f"<td>{cb} &rarr; {ca}</td><td>{tb} &rarr; {ta}</td></tr>\n"
1222
+ )
1223
+ html += " </table>\n"
1224
+
1225
+ return (
1226
+ html
1227
+ + """
1228
+ </body>
1229
+ </html>"""
1230
+ )
1231
+
1232
+
1233
+ def _esc(value: object) -> str:
1234
+ """HTML-escape a report value, quotes included, for attribute safety."""
1235
+ return html_escape(str(value), quote=True)
1236
+
1237
+
1238
+ def _int(value: object) -> int:
1239
+ """Counts come from scanner JSON, so they are numbers by convention, not
1240
+ by guarantee. Coercing keeps a non-numeric value from reaching the page
1241
+ as markup."""
1242
+ if isinstance(value, bool) or not isinstance(value, int | float | str):
1243
+ return 0
1244
+ try:
1245
+ return int(value)
1246
+ except (TypeError, ValueError):
1247
+ return 0
1248
+
1249
+
1250
+ def _tag_part(tag: str | None) -> str:
1251
+ """A tag de `nome:tag`, ou `latest` quando não há uma.
1252
+
1253
+ O destino recebe host e caminho; a tag vem daqui, de um lugar só, para
1254
+ não haver duas fontes discordando sobre qual versão está sendo publicada.
1255
+ """
1256
+ value = (tag or "").strip()
1257
+ if ":" in value:
1258
+ return value.rpartition(":")[2] or "latest"
1259
+ return "latest"
1260
+
1261
+
1262
+ def _print_provenance(provenance: BuildProvenance) -> None:
1263
+ """Os hashes de antes e depois, e o que a comparação entre eles diz.
1264
+
1265
+ Impresso mesmo quando tudo bate: o valor de uma cadeia de fornecimento
1266
+ está em ser vista rotineiramente, não só quando quebra -- quem nunca leu
1267
+ o registro íntegro não reconhece o rompido.
1268
+ """
1269
+ status = provenance.status
1270
+ colors = {
1271
+ ProvenanceStatus.VERIFIED: "green",
1272
+ ProvenanceStatus.INCOMPLETE: "yellow",
1273
+ ProvenanceStatus.INPUT_CHANGED: "red",
1274
+ }
1275
+ color = colors.get(status, "white")
1276
+ console.print(Panel(f"[bold {color}]Supply chain: {status}[/bold {color}]", expand=False))
1277
+ console.print(f" [dim]{safe(provenance.explain())}[/dim]\n")
1278
+
1279
+ source = provenance.source
1280
+ console.print("[bold]INPUT[/bold] [dim](measured before the build)[/dim]")
1281
+ console.print(f" Dockerfile {safe(source.dockerfile) or '[dim]not digested[/dim]'}")
1282
+ console.print(
1283
+ f" Contexto {safe(source.context) or '[dim]not digested[/dim]'}"
1284
+ f" [dim]({source.context_files} files)[/dim]"
1285
+ )
1286
+ if source.git_revision:
1287
+ dirty = " [yellow](dirty tree)[/yellow]" if source.git_dirty else ""
1288
+ console.print(f" Commit {safe(source.git_revision)}{dirty}")
1289
+ for reference, digest in source.base_images.items():
1290
+ pinned = safe(digest) if digest else "[yellow]moving tag, no digest[/yellow]"
1291
+ console.print(f" Base {safe(reference)} -> {pinned}")
1292
+
1293
+ artifact = provenance.artifact
1294
+ console.print("\n[bold]OUTPUT[/bold] [dim](measured after the build)[/dim]")
1295
+ console.print(f" Image {safe(artifact.image_id) or '[dim]unknown[/dim]'}")
1296
+ if artifact.repo_digest:
1297
+ console.print(f" Manifesto {safe(artifact.repo_digest)}")
1298
+ if artifact.published_reference:
1299
+ console.print(f" Publicada {safe(artifact.published_reference)}")
1300
+ console.print()