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,1795 @@
1
+ """Use case para construir imagens Docker com segurança."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import hashlib
7
+ import json
8
+ import os
9
+
10
+ # subprocess é necessário para invocar docker/trivy/grype; todas as chamadas
11
+ # usam listas de argumentos, sem shell, com argv[0] resolvido por caminho.
12
+ import subprocess # nosec B404
13
+ from dataclasses import dataclass, field
14
+ from datetime import UTC, datetime
15
+ from pathlib import Path
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ from loguru import logger
19
+
20
+ from dockerls.domain.entities.dockerfile_analysis import (
21
+ DockerfileAnalysis,
22
+ DockerfileValidationResult,
23
+ HardeningRule,
24
+ ValidationStatus,
25
+ )
26
+ from dockerls.domain.entities.vulnerability import Severity, Vulnerability
27
+ from dockerls.domain.value_objects.base_upgrade import parse_bases
28
+ from dockerls.domain.value_objects.build_policy import (
29
+ BaseFact,
30
+ BuildPolicy,
31
+ PolicyFacts,
32
+ PolicyViolation,
33
+ evaluate,
34
+ )
35
+ from dockerls.domain.value_objects.gate import (
36
+ Finding,
37
+ GateOutcome,
38
+ GateSet,
39
+ GateVerdict,
40
+ )
41
+ from dockerls.domain.value_objects.image_reference import registry_host_of
42
+ from dockerls.domain.value_objects.inheritance import (
43
+ InheritanceReport,
44
+ attribute,
45
+ unavailable,
46
+ )
47
+ from dockerls.domain.value_objects.provenance import (
48
+ ArtifactDigests,
49
+ BuildProvenance,
50
+ SourceDigests,
51
+ )
52
+ from dockerls.domain.value_objects.tristate import Tristate
53
+ from dockerls.exit_codes import EXIT_ERROR, EXIT_OK, EXIT_POLICY
54
+ from dockerls.infrastructure.hashing import ContextTooLargeError, hash_context, hash_file
55
+ from dockerls.utils.executables import ExecutableNotFoundError, resolve_executable
56
+
57
+ if TYPE_CHECKING:
58
+ from dockerls.application.use_cases.analyze_dockerfile import AnalyzeDockerfileResponse
59
+ from dockerls.domain.interfaces.dockerfile_validator import (
60
+ DockerfileValidatorInterface,
61
+ HardeningTemplateProvider,
62
+ )
63
+ from dockerls.integrations.threat_intel.client import ThreatIntelClient
64
+
65
+
66
+ @dataclass
67
+ class BuildOptions:
68
+ """Opções de build."""
69
+
70
+ tag: str
71
+ dockerfile_path: str = "Dockerfile"
72
+ context_path: str = "."
73
+ no_cache: bool = False
74
+ build_args: dict[str, str] | None = None
75
+ labels: dict[str, str] | None = None
76
+ platform: str | None = None
77
+ target: str | None = None
78
+ pull: bool = True
79
+ buildkit: bool = True
80
+ secrets: dict[str, str] | None = None # id -> file_path
81
+ ssh: list[str] | None = None # SSH agents
82
+
83
+
84
+ @dataclass
85
+ class BuildResult:
86
+ """Resultado do build."""
87
+
88
+ success: bool
89
+ image_tag: str | None = None
90
+ image_id: str | None = None
91
+ image_sha256: str | None = None
92
+ build_time_seconds: float = 0.0
93
+ layers_count: int = 0
94
+ image_size_bytes: int = 0
95
+ error_message: str | None = None
96
+ logs: list[str] = field(default_factory=list)
97
+ warnings: list[str] = field(default_factory=list)
98
+
99
+
100
+ @dataclass
101
+ class ScanResult:
102
+ """Resultado do scan de segurança."""
103
+
104
+ critical: int = 0
105
+ high: int = 0
106
+ medium: int = 0
107
+ low: int = 0
108
+ unknown: int = 0
109
+ total_vulnerabilities: int = 0
110
+ fixable: int = 0
111
+ scan_tool: str = "trivy"
112
+ scan_time_seconds: float = 0.0
113
+ vulnerabilities: list[dict[str, Any]] = field(default_factory=list)
114
+ sbom_components_count: int = 0
115
+
116
+
117
+ @dataclass
118
+ class BuildReport:
119
+ """Relatório completo de build."""
120
+
121
+ build_id: str
122
+ timestamp: str
123
+ image: str
124
+ dockerfile_path: str
125
+ validation: dict[str, Any]
126
+ scan_results: dict[str, Any] | None = None
127
+ security_score: int = 0
128
+ security_tier: str = "F"
129
+ layers: list[dict[str, Any]] = field(default_factory=list)
130
+ recommendations: list[dict[str, Any]] = field(default_factory=list)
131
+ sbom: dict[str, Any] | None = None
132
+ build_metadata: dict[str, Any] | None = None
133
+ remediation_history: list[dict[str, Any]] = field(default_factory=list)
134
+ auto_remediated: bool = False
135
+
136
+
137
+ @dataclass
138
+ class BuildImageRequest:
139
+ """Request para build de imagem."""
140
+
141
+ context_path: str
142
+ tag: str
143
+ dockerfile_path: str = "Dockerfile"
144
+ hardened: bool = False
145
+ base_template: str | None = None
146
+ scan: bool = True
147
+ validate_only: bool = False
148
+ suggest_only: bool = False
149
+ no_cache: bool = False
150
+ build_args: dict[str, str] | None = None
151
+ labels: dict[str, str] | None = None
152
+ fail_on: str | None = None # "critical", "high"
153
+ ci_mode: bool = False
154
+ verbose: bool = False
155
+ force: bool = False
156
+ push: bool = False
157
+ auto_remediate: bool = False
158
+ max_remediation_rounds: int = 3
159
+ target_zero_vulns: bool = False
160
+ #: Onde arquivar o documento de procedência. Vazio desliga o arquivamento
161
+ #: mas não a medição: o registro continua na resposta.
162
+ provenance_path: str = ""
163
+ #: Destino completo da publicação (`meuacr.azurecr.io/apps/dockerls:1.5.0`).
164
+ #: Vazio significa publicar a própria tag local, que só funciona quando ela
165
+ #: já nomeia um registry -- e era o comportamento anterior, que falhava com
166
+ #: "denied" para toda tag sem host.
167
+ push_reference: str = ""
168
+ #: A política declarada em `.dockerls-policy.yaml`, já carregada pela
169
+ #: camada CLI. O caso de uso confere; ler o arquivo é trabalho da borda.
170
+ policy: BuildPolicy | None = None
171
+ #: Escanear também a base declarada, para dizer de quem é cada CVE. Custa
172
+ #: um segundo scan, e por isso é escolha e não padrão.
173
+ attribute_findings: bool = False
174
+
175
+
176
+ @dataclass
177
+ class BuildImageResponse:
178
+ """Resposta do build de imagem.
179
+
180
+ `validation` e `analysis` carregam o resultado bruto da validação para
181
+ que a camada CLI possa renderizar a tabela de checks. Sem eles o
182
+ comando só sabia dizer "falhou", sem qual regra falhou.
183
+ """
184
+
185
+ success: bool
186
+ image_tag: str | None = None
187
+ image_sha256: str | None = None
188
+ report: BuildReport | None = None
189
+ validation: DockerfileValidationResult | None = None
190
+ analysis: DockerfileAnalysis | None = None
191
+ recommendations: list[HardeningRule] = field(default_factory=list)
192
+ #: A cadeia entre o que entrou no build e o que saiu dele. Presente em
193
+ #: todo build que chegou a produzir imagem.
194
+ provenance: BuildProvenance | None = None
195
+ #: Regras de `.dockerls-policy.yaml` que este build não cumpriu.
196
+ policy_violations: list[PolicyViolation] = field(default_factory=list)
197
+ #: De quem é cada vulnerabilidade: da base declarada ou das suas camadas.
198
+ inheritance: InheritanceReport | None = None
199
+ error: str | None = None
200
+ exit_code: int = EXIT_OK
201
+
202
+
203
+ class BuildImageUseCase:
204
+ """Caso de uso para construção segura de imagens Docker."""
205
+
206
+ def __init__(
207
+ self,
208
+ validator: DockerfileValidatorInterface,
209
+ template_provider: HardeningTemplateProvider,
210
+ threat_intel: ThreatIntelClient | None = None,
211
+ ):
212
+ self.validator = validator
213
+ self.template_provider = template_provider
214
+ # Opcional: sem ele os portões `kev` e `epss` não têm o que
215
+ # consultar, e dizem isso em vez de aprovar por omissão.
216
+ self.threat_intel = threat_intel
217
+
218
+ def execute(self, request: BuildImageRequest) -> BuildImageResponse:
219
+ """Executa o build seguro da imagem."""
220
+ logger.debug(f"Iniciando build seguro: {request.context_path}")
221
+
222
+ try:
223
+ # 1. Validar Dockerfile. Uma falha aqui (Dockerfile ausente,
224
+ # ilegível) é erro de execução, não violação de política, e
225
+ # --force não cria um Dockerfile que não existe.
226
+ validation_result = self._validate_dockerfile(request)
227
+ if not validation_result.success:
228
+ return BuildImageResponse(
229
+ success=False,
230
+ error=validation_result.error or "Dockerfile validation could not run",
231
+ exit_code=EXIT_ERROR,
232
+ )
233
+
234
+ # 2. Modo validate-only. A política entra aqui pelo subconjunto
235
+ # estático: sem build não há scan, procedência nem imagem, e
236
+ # aplicar as regras que dependem deles produziria uma violação
237
+ # por execução dizendo sempre a mesma coisa. O que dá para
238
+ # conferir só lendo o Dockerfile é conferido -- e é justamente
239
+ # o que evita descobrir um rótulo faltando depois de dez
240
+ # minutos de build.
241
+ if request.validate_only:
242
+ response = self._format_validation_response(validation_result)
243
+ response.policy_violations = self._preflight(request, validation_result)
244
+ if response.policy_violations and response.exit_code == EXIT_OK:
245
+ response.success = False
246
+ response.error = (
247
+ f"{len(response.policy_violations)} policy rule(s) not met among what can "
248
+ "be checked without building"
249
+ )
250
+ response.exit_code = EXIT_POLICY
251
+ return response
252
+
253
+ # 3. Modo suggest-only
254
+ if request.suggest_only:
255
+ return self._format_suggestions_response(validation_result)
256
+
257
+ # 3b. Validação com erros barra o build (a menos que --force).
258
+ # A resposta carrega os checks para o CLI dizer o que falhou.
259
+ validation = validation_result.validation
260
+ if validation is not None and validation.errors > 0 and not request.force:
261
+ return self._format_validation_response(validation_result)
262
+
263
+ # 4. Gerar Dockerfile hardened se solicitado
264
+ dockerfile_path = request.dockerfile_path
265
+ if request.hardened or request.base_template:
266
+ dockerfile_path = self._generate_hardened_dockerfile(
267
+ request.context_path,
268
+ request.base_template or "node",
269
+ )
270
+
271
+ # 4b. Digerir a entrada **antes** de construir. Sem isto, dois
272
+ # builds do mesmo --tag produzem relatórios indistinguíveis
273
+ # mesmo partindo de Dockerfiles diferentes, e o scan não fica
274
+ # ligado a artefato nenhum.
275
+ started_at = datetime.now(tz=UTC).isoformat()
276
+ source_before = self._digest_source(request.context_path, dockerfile_path)
277
+
278
+ # 5. Construir imagem
279
+ build_result = self._build_image(
280
+ context_path=request.context_path,
281
+ dockerfile_path=dockerfile_path,
282
+ tag=request.tag,
283
+ options=BuildOptions(
284
+ tag=request.tag,
285
+ dockerfile_path=dockerfile_path,
286
+ context_path=request.context_path,
287
+ no_cache=request.no_cache,
288
+ build_args=request.build_args,
289
+ labels=request.labels,
290
+ buildkit=True,
291
+ ),
292
+ )
293
+
294
+ if not build_result.success:
295
+ return BuildImageResponse(
296
+ success=False,
297
+ error=build_result.error_message,
298
+ exit_code=EXIT_ERROR,
299
+ )
300
+
301
+ # 5b. Digerir a entrada de novo. A comparação é o que transforma
302
+ # o registro em controle: uma entrada que mudou durante o
303
+ # build não é a entrada que a imagem representa.
304
+ source_after = self._digest_source(request.context_path, dockerfile_path)
305
+
306
+ # 6. Scan pós-build
307
+ scan_result = None
308
+ if request.scan:
309
+ scan_result = self._enrich(self._scan_image(request.tag))
310
+
311
+ # 6b. Ciclo de Auto-Remediação Iterativo (Zero Vulnerabilidades)
312
+ remediation_history: list[dict[str, Any]] = []
313
+ if (
314
+ (request.auto_remediate or request.target_zero_vulns)
315
+ and request.scan
316
+ and scan_result
317
+ and scan_result.total_vulnerabilities > 0
318
+ ):
319
+ current_df_path = dockerfile_path
320
+ for round_num in range(1, request.max_remediation_rounds + 1):
321
+ remediated_path, applied_actions = self._derive_and_write_remediated_dockerfile(
322
+ context_path=request.context_path,
323
+ original_dockerfile_path=current_df_path,
324
+ scan_result=scan_result,
325
+ round_num=round_num,
326
+ )
327
+ if not applied_actions:
328
+ logger.info("No further automated remediation actions available.")
329
+ break
330
+
331
+ logger.info(
332
+ f"[Auto-Remediation Round {round_num}] "
333
+ f"Rebuilding with {len(applied_actions)} fix(es)..."
334
+ )
335
+ new_build = self._build_image(
336
+ context_path=request.context_path,
337
+ dockerfile_path=remediated_path,
338
+ tag=request.tag,
339
+ options=BuildOptions(
340
+ tag=request.tag,
341
+ dockerfile_path=remediated_path,
342
+ context_path=request.context_path,
343
+ no_cache=request.no_cache,
344
+ build_args=request.build_args,
345
+ labels=request.labels,
346
+ buildkit=True,
347
+ ),
348
+ )
349
+ if not new_build.success:
350
+ logger.warning(
351
+ f"Remediated build round {round_num} failed: {new_build.error_message}"
352
+ )
353
+ break
354
+
355
+ build_result = new_build
356
+ current_df_path = remediated_path
357
+ prev_scan = scan_result
358
+ new_scan = self._enrich(self._scan_image(request.tag))
359
+ if new_scan:
360
+ scan_result = new_scan
361
+ remediation_history.append(
362
+ {
363
+ "round": round_num,
364
+ "actions": applied_actions,
365
+ "critical_before": prev_scan.critical,
366
+ "critical_after": new_scan.critical,
367
+ "high_before": prev_scan.high,
368
+ "high_after": new_scan.high,
369
+ "total_before": prev_scan.total_vulnerabilities,
370
+ "total_after": new_scan.total_vulnerabilities,
371
+ }
372
+ )
373
+ if new_scan.total_vulnerabilities == 0:
374
+ logger.info(
375
+ "Success: image achieved ZERO "
376
+ f"vulnerabilities in round {round_num}!"
377
+ )
378
+ break
379
+
380
+ # 7. Verificar thresholds de falha. Entre o limiar da política e o
381
+ # da linha de comando vence o mais estrito: um arquivo no
382
+ # repositório não pode desligar um portão que o pipeline pediu.
383
+ threshold = (
384
+ request.policy.effective_fail_on(request.fail_on or "")
385
+ if request.policy
386
+ else (request.fail_on or "")
387
+ )
388
+ if threshold:
389
+ # Um portão que não pôde ser avaliado não é um portão
390
+ # aprovado. Sem scan, `--fail-on` deixava passar em silêncio
391
+ # qualquer imagem numa máquina sem scanner instalado.
392
+ if scan_result is None:
393
+ return BuildImageResponse(
394
+ success=False,
395
+ image_tag=request.tag,
396
+ image_sha256=build_result.image_sha256,
397
+ validation=validation,
398
+ analysis=validation_result.analysis,
399
+ error=(
400
+ f"--fail-on {threshold} requires a vulnerability scan, "
401
+ "and no scanner (trivy, grype) could be run"
402
+ ),
403
+ exit_code=EXIT_ERROR,
404
+ )
405
+ if self._should_fail(scan_result, threshold):
406
+ # A atribuição é calculada aqui e reaproveitada na
407
+ # mensagem: escanear a base duas vezes para dizer a mesma
408
+ # coisa duas vezes seria pagar minutos por nada.
409
+ gate_inheritance = self._attribute_findings(
410
+ request, validation_result, scan_result
411
+ )
412
+ return BuildImageResponse(
413
+ success=False,
414
+ image_tag=request.tag,
415
+ image_sha256=build_result.image_sha256,
416
+ validation=validation,
417
+ analysis=validation_result.analysis,
418
+ error=self._gate_failure_summary(scan_result, threshold, gate_inheritance),
419
+ inheritance=gate_inheritance,
420
+ exit_code=EXIT_POLICY,
421
+ )
422
+
423
+ # 7a. Cruzar os achados com os da base: a contagem sozinha diz
424
+ # "conserte", e não diz o quê. Roda antes dos portões porque
425
+ # precisa aparecer no relatório mesmo quando o build reprova --
426
+ # é justamente aí que a pergunta "de quem é isso?" é feita.
427
+ inheritance = self._attribute_findings(request, validation_result, scan_result)
428
+
429
+ # 7b. Conferir a política declarada. Antes do push, pelo mesmo
430
+ # motivo do portão de scan: uma imagem que viola a política da
431
+ # organização não é publicada por ter sido construída.
432
+ if request.policy is not None:
433
+ violations = evaluate(
434
+ request.policy,
435
+ self._policy_facts(
436
+ request=request,
437
+ analysis=validation_result.analysis,
438
+ scan_result=scan_result,
439
+ source_before=source_before,
440
+ source_after=source_after,
441
+ image_id=build_result.image_sha256 or "",
442
+ ),
443
+ )
444
+ if violations:
445
+ return BuildImageResponse(
446
+ success=False,
447
+ image_tag=request.tag,
448
+ image_sha256=build_result.image_sha256,
449
+ validation=validation,
450
+ analysis=validation_result.analysis,
451
+ policy_violations=violations,
452
+ inheritance=inheritance,
453
+ error=(f"{len(violations)} .dockerls-policy.yaml rule(s) not met"),
454
+ exit_code=EXIT_POLICY,
455
+ )
456
+
457
+ # 8. Push, se pedido. Só depois dos portões: publicar uma imagem
458
+ # que reprovou no scan derrota o propósito de ter o portão.
459
+ if request.push:
460
+ # A entrada mudou durante o build: a imagem existe, mas não é a
461
+ # que foi medida. Publicá-la seria distribuir um artefato cuja
462
+ # procedência esta ferramenta acabou de declarar quebrada --
463
+ # a mesma substituição que ela recusa em todo lugar.
464
+ if source_before.dockerfile and source_before != source_after:
465
+ return BuildImageResponse(
466
+ success=False,
467
+ image_tag=request.tag,
468
+ image_sha256=build_result.image_sha256,
469
+ validation=validation,
470
+ analysis=validation_result.analysis,
471
+ error=(
472
+ "publish refused: the Dockerfile or the context changed "
473
+ "during the build, so the image does not correspond to the "
474
+ "input that was measured. Rebuild from a stable tree."
475
+ ),
476
+ exit_code=EXIT_POLICY,
477
+ )
478
+ push_error = self._push_image(request.tag, request.push_reference)
479
+ if push_error is not None:
480
+ return BuildImageResponse(
481
+ success=False,
482
+ image_tag=request.tag,
483
+ image_sha256=build_result.image_sha256,
484
+ error=push_error,
485
+ exit_code=EXIT_ERROR,
486
+ )
487
+
488
+ # 8b. Fechar a cadeia: o digest do manifesto só existe depois do
489
+ # push, e é o único identificador que outra máquina consegue
490
+ # usar para puxar exatamente esta imagem.
491
+ provenance = BuildProvenance(
492
+ tag=request.tag,
493
+ source=source_before,
494
+ source_after=source_after,
495
+ artifact=ArtifactDigests(
496
+ image_id=build_result.image_sha256 or "",
497
+ repo_digest=self._repo_digest(request.push_reference or request.tag),
498
+ published_reference=request.push_reference,
499
+ scanner=scan_result.scan_tool if scan_result else "",
500
+ ),
501
+ started_at=started_at,
502
+ finished_at=datetime.now(tz=UTC).isoformat(),
503
+ )
504
+ self._archive_provenance(provenance, request.provenance_path)
505
+
506
+ # 9. Gerar relatório
507
+ report = self._generate_report(
508
+ validation=validation_result,
509
+ build=build_result,
510
+ scan=scan_result,
511
+ image_tag=request.tag,
512
+ dockerfile_path=request.dockerfile_path,
513
+ remediation_history=remediation_history,
514
+ )
515
+
516
+ return BuildImageResponse(
517
+ success=True,
518
+ image_tag=request.tag,
519
+ image_sha256=build_result.image_sha256,
520
+ provenance=provenance,
521
+ inheritance=inheritance,
522
+ report=report,
523
+ validation=validation,
524
+ analysis=validation_result.analysis,
525
+ recommendations=list(validation_result.suggestions or []),
526
+ exit_code=EXIT_OK,
527
+ )
528
+
529
+ except Exception as e:
530
+ logger.exception(f"Build error: {e}")
531
+ return BuildImageResponse(
532
+ success=False,
533
+ error=str(e),
534
+ exit_code=EXIT_ERROR,
535
+ )
536
+
537
+ def _validate_dockerfile(self, request: BuildImageRequest) -> AnalyzeDockerfileResponse:
538
+ """Valida o Dockerfile."""
539
+ from dockerls.application.use_cases.analyze_dockerfile import (
540
+ AnalyzeDockerfileRequest,
541
+ AnalyzeDockerfileUseCase,
542
+ )
543
+
544
+ analyze_request = AnalyzeDockerfileRequest(
545
+ dockerfile_path=Path(request.context_path) / request.dockerfile_path,
546
+ include_suggestions=True,
547
+ validate_only=False,
548
+ )
549
+
550
+ analyze_use_case = AnalyzeDockerfileUseCase(self.validator, self.template_provider)
551
+ return analyze_use_case.execute(analyze_request)
552
+
553
+ def _format_validation_response(
554
+ self, validation_result: AnalyzeDockerfileResponse
555
+ ) -> BuildImageResponse:
556
+ """Formata resposta apenas de validação.
557
+
558
+ Propaga o `DockerfileValidationResult` inteiro -- checks, contagens e
559
+ score -- porque é ele que a CLI renderiza. Devolver só `success` e
560
+ `exit_code` deixava o comando sem nada para imprimir.
561
+ """
562
+ validation = validation_result.validation
563
+ if validation is None:
564
+ return BuildImageResponse(
565
+ success=False,
566
+ error="Dockerfile validation produced no result",
567
+ exit_code=EXIT_ERROR,
568
+ )
569
+
570
+ analysis = validation_result.analysis
571
+ suggestions = list(validation_result.suggestions or [])
572
+ failed = validation.errors > 0
573
+
574
+ return BuildImageResponse(
575
+ success=not failed,
576
+ report=self._build_validation_report(validation_result, validation, analysis),
577
+ validation=validation,
578
+ analysis=analysis,
579
+ recommendations=suggestions,
580
+ error=self._validation_error_summary(validation) if failed else None,
581
+ exit_code=EXIT_POLICY if failed else EXIT_OK,
582
+ )
583
+
584
+ def _format_suggestions_response(
585
+ self, validation_result: AnalyzeDockerfileResponse
586
+ ) -> BuildImageResponse:
587
+ """Formata resposta apenas com sugestões.
588
+
589
+ Carrega também a validação: mostrar as sugestões sem dizer quais
590
+ checks as motivaram não é acionável.
591
+ """
592
+ validation = validation_result.validation
593
+ analysis = validation_result.analysis
594
+ return BuildImageResponse(
595
+ success=True,
596
+ report=self._build_validation_report(validation_result, validation, analysis)
597
+ if validation is not None
598
+ else None,
599
+ validation=validation,
600
+ analysis=analysis,
601
+ recommendations=list(validation_result.suggestions or []),
602
+ exit_code=EXIT_OK,
603
+ )
604
+
605
+ def _build_validation_report(
606
+ self,
607
+ validation_result: AnalyzeDockerfileResponse,
608
+ validation: DockerfileValidationResult,
609
+ analysis: DockerfileAnalysis | None,
610
+ ) -> BuildReport:
611
+ """Relatório de um run que só validou -- sem imagem, sem scan.
612
+
613
+ Existe para que `--ci-mode` emita o mesmo JSON estruturado nos dois
614
+ modos, em vez de um objeto vazio quando nada foi construído.
615
+ """
616
+ score = (
617
+ analysis.security_score
618
+ if analysis is not None
619
+ else self._calculate_security_score(validation_result, None)
620
+ )
621
+ tier = (
622
+ analysis.security_tier if analysis is not None else self._calculate_security_tier(score)
623
+ )
624
+ return BuildReport(
625
+ build_id=self._new_build_id(validation.dockerfile_path),
626
+ timestamp=datetime.now(tz=UTC).isoformat(),
627
+ image="",
628
+ dockerfile_path=validation.dockerfile_path,
629
+ validation=self._validation_dict(validation),
630
+ security_score=score,
631
+ security_tier=tier,
632
+ recommendations=self._recommendation_dicts(validation_result.suggestions or []),
633
+ )
634
+
635
+ @staticmethod
636
+ def _validation_error_summary(validation: DockerfileValidationResult) -> str:
637
+ """Resumo textual das regras violadas, para `error` e para logs de CI."""
638
+ failures = [c for c in validation.checks if c.status == ValidationStatus.FAIL]
639
+ header = f"Dockerfile validation failed: {validation.errors} error(s)"
640
+ if not failures:
641
+ return header
642
+ details = "; ".join(
643
+ f"{check.check}"
644
+ f"{f' (line {check.line})' if check.line is not None else ''}: {check.message}"
645
+ for check in failures
646
+ )
647
+ return f"{header} -- {details}"
648
+
649
+ @staticmethod
650
+ def _validation_dict(validation: DockerfileValidationResult) -> dict[str, Any]:
651
+ return {
652
+ "dockerfile_path": validation.dockerfile_path,
653
+ "passed": validation.passed,
654
+ "warnings": validation.warnings,
655
+ "errors": validation.errors,
656
+ # `rule_id`, `references` e `rationale` entram aqui porque este é o
657
+ # arquivo que vai para auditoria. O terminal citava o controle
658
+ # publicado (CIS 4.1, NIST 4.1.2) e o relatório perdia a citação --
659
+ # exatamente onde ela vale mais, que é diante de quem precisa
660
+ # mapear achado para programa de conformidade.
661
+ "checks": [
662
+ {
663
+ "check": check.check,
664
+ "rule_id": check.rule_id,
665
+ "status": check.status.value,
666
+ "message": check.message,
667
+ "severity": check.severity.value,
668
+ "line": check.line,
669
+ "references": check.references,
670
+ "rationale": check.rationale,
671
+ }
672
+ for check in validation.checks
673
+ ],
674
+ }
675
+
676
+ @staticmethod
677
+ def _recommendation_dicts(rules: list[HardeningRule]) -> list[dict[str, Any]]:
678
+ return [
679
+ {
680
+ "priority": rule.priority.value,
681
+ "title": rule.title,
682
+ "current": rule.current_state,
683
+ "suggested": rule.suggested_fix,
684
+ "reason": rule.reason,
685
+ }
686
+ for rule in rules
687
+ ]
688
+
689
+ @staticmethod
690
+ def _new_build_id(seed: str) -> str:
691
+ stamp = datetime.now(tz=UTC).isoformat()
692
+ return hashlib.sha256(f"{seed}{stamp}".encode()).hexdigest()[:16]
693
+
694
+ def _generate_hardened_dockerfile(self, context_path: str, template: str) -> str:
695
+ """Gera Dockerfile hardened delegando à infraestrutura.
696
+
697
+ Escrever arquivo é responsabilidade do provider, não do caso de uso:
698
+ aqui só decidimos onde ele deve sair.
699
+ """
700
+ output_path = Path(context_path) / "Dockerfile.hardened"
701
+ self.template_provider.generate_hardened_dockerfile(
702
+ dockerfile_path=Path(context_path),
703
+ base_image=template,
704
+ output_path=output_path,
705
+ )
706
+ logger.debug(f"Hardened Dockerfile generated: {output_path}")
707
+ return str(output_path)
708
+
709
+ def _derive_and_write_remediated_dockerfile(
710
+ self,
711
+ context_path: str,
712
+ original_dockerfile_path: str,
713
+ scan_result: ScanResult,
714
+ round_num: int,
715
+ ) -> tuple[str, list[str]]:
716
+ """Gera um Dockerfile com patches automáticos de segurança aplicados."""
717
+ full_orig = Path(original_dockerfile_path)
718
+ if not full_orig.is_absolute():
719
+ full_orig = Path(context_path) / original_dockerfile_path
720
+
721
+ if not full_orig.exists():
722
+ return original_dockerfile_path, []
723
+
724
+ content = full_orig.read_text(encoding="utf-8")
725
+ applied: list[str] = []
726
+ lower_content = content.lower()
727
+
728
+ # Identificar distro e tipo de pacote
729
+ is_alpine = "alpine" in lower_content
730
+ is_debian_ubuntu = any(d in lower_content for d in ("debian", "ubuntu", "slim"))
731
+
732
+ # 1. Patch de SO
733
+ os_vulns = [
734
+ v
735
+ for v in scan_result.vulnerabilities
736
+ if v.get("fixed_version")
737
+ and not any(
738
+ lang in (v.get("package") or "").lower() for lang in ("npm", "pip", "node_modules")
739
+ )
740
+ ]
741
+ if os_vulns:
742
+ if is_alpine and "apk upgrade" not in lower_content:
743
+ upgrade_cmd = "RUN apk upgrade --no-cache && rm -rf /var/cache/apk/*"
744
+ content = self._insert_instruction(content, upgrade_cmd)
745
+ applied.append(f"Applied Alpine OS security upgrade ({len(os_vulns)} fixable CVEs)")
746
+ elif is_debian_ubuntu and "apt-get upgrade" not in lower_content:
747
+ upgrade_cmd = (
748
+ "RUN apt-get update && apt-get upgrade -y && rm -rf /var/lib/apt/lists/*"
749
+ )
750
+ content = self._insert_instruction(content, upgrade_cmd)
751
+ applied.append(
752
+ f"Applied Debian/Ubuntu OS security upgrade ({len(os_vulns)} fixable CVEs)"
753
+ )
754
+
755
+ # 2. Patch de npm embutido
756
+ has_npm_vulns = any(
757
+ "npm" in (v.get("package") or "").lower() for v in scan_result.vulnerabilities
758
+ )
759
+ if has_npm_vulns and "npm install -g npm" not in lower_content:
760
+ npm_cmd = "RUN npm install -g npm@latest && npm cache clean --force"
761
+ content = self._insert_instruction(content, npm_cmd)
762
+ # "latest patched release" era uma afirmação sobre o resultado de um
763
+ # comando que ainda não rodou: `npm@latest` traz o que estiver
764
+ # publicado, que pode ou não corrigir o achado. A ação é descrita; o
765
+ # veredito fica com o scan da próxima rodada, que é quem mede.
766
+ applied.append("Inserted `npm install -g npm@latest` (next scan says if it helped)")
767
+
768
+ # 3. Patch de pip embutido
769
+ has_pip_vulns = any(
770
+ (v.get("package") or "").lower() in ("pip", "setuptools", "wheel")
771
+ for v in scan_result.vulnerabilities
772
+ )
773
+ if has_pip_vulns and "pip install --upgrade pip" not in lower_content:
774
+ pip_cmd = "RUN pip install --no-cache-dir --upgrade pip setuptools wheel"
775
+ content = self._insert_instruction(content, pip_cmd)
776
+ # Mesma correção da mensagem do npm: `--upgrade` não é sinônimo de
777
+ # "seguro". Além disso, numa imagem de execução **remover** costuma
778
+ # ser melhor que atualizar -- pip recebe advisory novo com
779
+ # regularidade, então atualizar é imposto recorrente. Isso não é
780
+ # automatizado aqui porque um estágio pode legitimamente precisar
781
+ # de pip depois; a alternativa é dita, e a decisão fica com quem lê.
782
+ applied.append(
783
+ "Inserted `pip install --upgrade pip setuptools wheel` (next scan says if "
784
+ "it helped; in a runtime stage, removing pip usually beats upgrading it)"
785
+ )
786
+
787
+ if not applied:
788
+ return original_dockerfile_path, []
789
+
790
+ remediated_filename = f"Dockerfile.remediated.{round_num}"
791
+ remediated_path = Path(context_path) / remediated_filename
792
+ remediated_path.write_text(content, encoding="utf-8")
793
+ return str(remediated_path), applied
794
+
795
+ @staticmethod
796
+ def _insert_instruction(dockerfile_content: str, instruction: str) -> str:
797
+ """Insere uma instrução RUN de forma segura antes do USER ou no final do primeiro stage."""
798
+ lines = dockerfile_content.splitlines()
799
+ insert_idx = -1
800
+ for i, line in enumerate(lines):
801
+ stripped = line.strip().upper()
802
+ if stripped.startswith("USER ") and insert_idx == -1:
803
+ insert_idx = i
804
+ break
805
+
806
+ if insert_idx != -1:
807
+ lines.insert(insert_idx, instruction)
808
+ else:
809
+ last_from_idx = 0
810
+ for i, line in enumerate(lines):
811
+ if line.strip().upper().startswith("FROM "):
812
+ last_from_idx = i
813
+ lines.insert(last_from_idx + 1, instruction)
814
+
815
+ return "\n".join(lines) + "\n"
816
+
817
+ def _build_image(
818
+ self,
819
+ context_path: str,
820
+ dockerfile_path: str,
821
+ tag: str,
822
+ options: BuildOptions,
823
+ ) -> BuildResult:
824
+ """Executa o build da imagem Docker."""
825
+ start_time = datetime.now()
826
+ logs: list[str] = []
827
+ warnings: list[str] = []
828
+
829
+ try:
830
+ # Comando docker build. O binário é resolvido para caminho
831
+ # absoluto: deixar a escolha para o $PATH é o próprio PATH
832
+ # hijacking que esta ferramenta reporta nas imagens dos outros.
833
+ # BuildKit é ativado via variável de ambiente, não por argumento.
834
+ cmd = [resolve_executable("docker"), "build"]
835
+
836
+ cmd.extend(["-t", tag])
837
+ cmd.extend(["-f", dockerfile_path])
838
+
839
+ if options.no_cache:
840
+ cmd.append("--no-cache")
841
+
842
+ if options.pull:
843
+ cmd.append("--pull")
844
+
845
+ if options.platform:
846
+ cmd.extend(["--platform", options.platform])
847
+
848
+ if options.target:
849
+ cmd.extend(["--target", options.target])
850
+
851
+ if options.build_args:
852
+ for key, value in options.build_args.items():
853
+ cmd.extend(["--build-arg", f"{key}={value}"])
854
+
855
+ if options.labels:
856
+ for key, value in options.labels.items():
857
+ cmd.extend(["--label", f"{key}={value}"])
858
+
859
+ # Adicionar contexto
860
+ cmd.append(context_path)
861
+
862
+ logger.debug(f"Executando comando: {' '.join(cmd)}")
863
+
864
+ # Executar build
865
+ env = {}
866
+ if options.buildkit:
867
+ env["DOCKER_BUILDKIT"] = "1"
868
+
869
+ result = subprocess.run( # nosec B603 # noqa: S603
870
+ cmd,
871
+ capture_output=True,
872
+ text=True,
873
+ timeout=3600, # 1 hora timeout
874
+ env={**os.environ, **env},
875
+ check=False,
876
+ )
877
+
878
+ logs.append(result.stdout)
879
+ if result.stderr:
880
+ logs.append(result.stderr)
881
+ warnings.append(result.stderr)
882
+
883
+ if result.returncode != 0:
884
+ return BuildResult(
885
+ success=False,
886
+ error_message=f"Build failed: {result.stderr}",
887
+ logs=logs,
888
+ warnings=warnings,
889
+ )
890
+
891
+ # Extrair informações da imagem
892
+ image_info = self._get_image_info(tag)
893
+
894
+ end_time = datetime.now()
895
+ build_time = (end_time - start_time).total_seconds()
896
+
897
+ return BuildResult(
898
+ success=True,
899
+ image_tag=tag,
900
+ image_id=image_info.get("Id"),
901
+ image_sha256=image_info.get("Id"),
902
+ build_time_seconds=build_time,
903
+ layers_count=len(image_info.get("RootFS", {}).get("Layers", [])),
904
+ image_size_bytes=image_info.get("Size", 0),
905
+ logs=logs,
906
+ warnings=warnings,
907
+ )
908
+
909
+ except ExecutableNotFoundError as e:
910
+ return BuildResult(success=False, error_message=str(e), logs=logs)
911
+ except subprocess.TimeoutExpired:
912
+ return BuildResult(
913
+ success=False,
914
+ error_message="Build timeout (1 hour)",
915
+ logs=logs,
916
+ )
917
+ except Exception as e:
918
+ logger.exception(f"Build error: {e}")
919
+ return BuildResult(
920
+ success=False,
921
+ error_message=str(e),
922
+ logs=logs,
923
+ )
924
+
925
+ def _push_image(self, tag: str, destination: str = "") -> str | None:
926
+ """Publica a imagem no destino. Devolve a mensagem de erro, ou None.
927
+
928
+ Antes disto o push usava a tag local como está. Numa tag sem host --
929
+ `dockerls:1.5.0`, que é a forma que todo mundo digita -- isso vira uma
930
+ tentativa de publicar em `docker.io/library/dockerls`, recusada com um
931
+ "denied" que não explica nada. Com um destino, a imagem é reetiquetada
932
+ antes: é o passo que faltava entre escolher o registry e publicar nele.
933
+ """
934
+ target = destination.strip() or tag
935
+ if target != tag:
936
+ retag_error = self._run_docker(
937
+ ["tag", tag, target], timeout=60, action=f"Retag to {target}"
938
+ )
939
+ if retag_error is not None:
940
+ return retag_error
941
+
942
+ logger.debug(f"Publishing image: {target}")
943
+ return self._run_docker(["push", target], timeout=1800, action=f"Push de {target}")
944
+
945
+ @staticmethod
946
+ def _run_docker(args: list[str], *, timeout: int, action: str) -> str | None:
947
+ """Um comando do docker, com o erro em texto em vez de exceção."""
948
+ try:
949
+ result = subprocess.run( # nosec B603 # noqa: S603
950
+ [resolve_executable("docker"), *args],
951
+ capture_output=True,
952
+ text=True,
953
+ timeout=timeout,
954
+ check=False,
955
+ )
956
+ except (ExecutableNotFoundError, OSError, subprocess.SubprocessError) as e:
957
+ return f"{action} falhou: {e}"
958
+
959
+ if result.returncode != 0:
960
+ return f"{action} falhou: {result.stderr.strip()[:500]}"
961
+ return None
962
+
963
+ def _get_image_info(self, tag: str) -> dict[str, Any]:
964
+ """Obtém informações da imagem construída."""
965
+ try:
966
+ result = subprocess.run( # nosec B603 # noqa: S603
967
+ [resolve_executable("docker"), "image", "inspect", tag],
968
+ capture_output=True,
969
+ text=True,
970
+ timeout=30,
971
+ check=False,
972
+ )
973
+
974
+ if result.returncode == 0:
975
+ images = json.loads(result.stdout)
976
+ if images:
977
+ info: dict[str, Any] = images[0]
978
+ return info
979
+
980
+ return {}
981
+ except Exception as e:
982
+ logger.warning(f"Could not read the image info: {e}")
983
+ return {}
984
+
985
+ def _scan_image(self, image_tag: str) -> ScanResult | None:
986
+ """Executa scan de segurança na imagem."""
987
+ logger.info(f"Starting the image scan: {image_tag}")
988
+ start_time = datetime.now()
989
+
990
+ try:
991
+ # Tentar usar Trivy
992
+ result = subprocess.run( # nosec B603 # noqa: S603
993
+ [
994
+ resolve_executable("trivy"),
995
+ "image",
996
+ "--format",
997
+ "json",
998
+ "--severity",
999
+ "CRITICAL,HIGH,MEDIUM,LOW,UNKNOWN",
1000
+ image_tag,
1001
+ ],
1002
+ capture_output=True,
1003
+ text=True,
1004
+ timeout=600, # 10 minutos
1005
+ check=False,
1006
+ )
1007
+
1008
+ if result.returncode == 0 and result.stdout:
1009
+ scan = self._parse_trivy_scan(json.loads(result.stdout))
1010
+ scan.scan_time_seconds = (datetime.now() - start_time).total_seconds()
1011
+ return scan
1012
+ logger.warning(f"Trivy falhou (exit {result.returncode}), tentando Grype...")
1013
+
1014
+ except ExecutableNotFoundError:
1015
+ logger.warning("Trivy not found, trying Grype...")
1016
+ except Exception as e:
1017
+ logger.warning(f"Trivy scan error: {e}")
1018
+
1019
+ # Fallback: tentar Grype
1020
+ try:
1021
+ result = subprocess.run( # nosec B603 # noqa: S603
1022
+ [
1023
+ resolve_executable("grype"),
1024
+ image_tag,
1025
+ "-o",
1026
+ "json",
1027
+ ],
1028
+ capture_output=True,
1029
+ text=True,
1030
+ timeout=600,
1031
+ check=False,
1032
+ )
1033
+
1034
+ if result.returncode == 0 and result.stdout:
1035
+ scan = self._parse_grype_scan(json.loads(result.stdout))
1036
+ scan.scan_time_seconds = (datetime.now() - start_time).total_seconds()
1037
+ return scan
1038
+
1039
+ except Exception as e:
1040
+ logger.warning(f"Grype failed as well: {e}")
1041
+
1042
+ logger.warning("No scanner available")
1043
+ return None
1044
+
1045
+ @staticmethod
1046
+ def _parse_trivy_scan(data: dict[str, Any]) -> ScanResult:
1047
+ """Converte o JSON do Trivy em contagens por severidade."""
1048
+ counts = dict.fromkeys(("CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN"), 0)
1049
+ fixable = 0
1050
+ vulnerabilities: list[dict[str, Any]] = []
1051
+
1052
+ for finding in data.get("Results", []):
1053
+ for vuln in finding.get("Vulnerabilities", []):
1054
+ severity = str(vuln.get("Severity", "UNKNOWN")).upper()
1055
+ counts[severity if severity in counts else "UNKNOWN"] += 1
1056
+ if vuln.get("FixedVersion"):
1057
+ fixable += 1
1058
+ vulnerabilities.append(
1059
+ {
1060
+ "cve_id": vuln.get("VulnerabilityID"),
1061
+ "package": vuln.get("PkgName"),
1062
+ "severity": severity,
1063
+ "installed_version": vuln.get("InstalledVersion"),
1064
+ "fixed_version": vuln.get("FixedVersion"),
1065
+ }
1066
+ )
1067
+
1068
+ return BuildImageUseCase._scan_result(counts, fixable, vulnerabilities, "trivy")
1069
+
1070
+ @staticmethod
1071
+ def _parse_grype_scan(data: dict[str, Any]) -> ScanResult:
1072
+ """Converte o JSON do Grype em contagens por severidade.
1073
+
1074
+ Este parser não existia: o fallback devolvia um `ScanResult()` zerado
1075
+ com um comentário "parse similar ao Trivy...". Numa máquina só com
1076
+ Grype, todo build era reportado com zero vulnerabilidades e
1077
+ `--fail-on critical` nunca reprovava nada.
1078
+ """
1079
+ counts = dict.fromkeys(("CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN"), 0)
1080
+ fixable = 0
1081
+ vulnerabilities: list[dict[str, Any]] = []
1082
+
1083
+ for match in data.get("matches", []):
1084
+ vuln = match.get("vulnerability", {})
1085
+ severity = str(vuln.get("severity", "UNKNOWN")).upper()
1086
+ # Grype tem uma faixa a mais que o Trivy; sem isso ela cairia em
1087
+ # UNKNOWN e sumiria da contagem de LOW.
1088
+ if severity == "NEGLIGIBLE":
1089
+ severity = "LOW"
1090
+ counts[severity if severity in counts else "UNKNOWN"] += 1
1091
+
1092
+ artifact = match.get("artifact", {})
1093
+ fixed_versions = vuln.get("fix", {}).get("versions", []) or []
1094
+ if fixed_versions:
1095
+ fixable += 1
1096
+ vulnerabilities.append(
1097
+ {
1098
+ "cve_id": vuln.get("id"),
1099
+ "package": artifact.get("name"),
1100
+ "severity": severity,
1101
+ "installed_version": artifact.get("version"),
1102
+ "fixed_version": fixed_versions[0] if fixed_versions else None,
1103
+ }
1104
+ )
1105
+
1106
+ return BuildImageUseCase._scan_result(counts, fixable, vulnerabilities, "grype")
1107
+
1108
+ @staticmethod
1109
+ def _scan_result(
1110
+ counts: dict[str, int],
1111
+ fixable: int,
1112
+ vulnerabilities: list[dict[str, Any]],
1113
+ tool: str,
1114
+ ) -> ScanResult:
1115
+ return ScanResult(
1116
+ critical=counts["CRITICAL"],
1117
+ high=counts["HIGH"],
1118
+ medium=counts["MEDIUM"],
1119
+ low=counts["LOW"],
1120
+ unknown=counts["UNKNOWN"],
1121
+ total_vulnerabilities=sum(counts.values()),
1122
+ fixable=fixable,
1123
+ scan_tool=tool,
1124
+ vulnerabilities=BuildImageUseCase._worst_first(vulnerabilities),
1125
+ )
1126
+
1127
+ #: Achados mantidos na amostra do relatório. As contagens acima são
1128
+ #: completas; esta lista existe para o relatório não carregar milhares de
1129
+ #: entradas.
1130
+ MAX_RETAINED_VULNERABILITIES = 100
1131
+
1132
+ @staticmethod
1133
+ def _worst_first(vulnerabilities: list[dict[str, Any]]) -> list[dict[str, Any]]:
1134
+ """Ordena por severidade antes de cortar a amostra.
1135
+
1136
+ O corte era `vulnerabilities[:100]` na ordem em que o scanner
1137
+ devolveu, que é ordem de pacote e não de gravidade. Numa imagem com
1138
+ mais de cem achados, as CRITICAL podiam cair inteiramente fora da
1139
+ amostra -- e foi o que aconteceu: o portão reprovava (as *contagens*
1140
+ estavam certas) enquanto o resumo, que lê a amostra, anunciava
1141
+ "0 finding(s) at or above CRITICAL". O leitor recebia uma reprovação
1142
+ que se contradizia e nenhum CVE para investigar.
1143
+
1144
+ Ordenar antes de cortar garante que o que decide o portão é o que
1145
+ sobrevive na amostra.
1146
+ """
1147
+ order = {level: index for index, level in enumerate(("CRITICAL", "HIGH", "MEDIUM", "LOW"))}
1148
+ ranked = sorted(
1149
+ vulnerabilities,
1150
+ key=lambda v: order.get(str(v.get("severity", "")).upper(), len(order)),
1151
+ )
1152
+ return ranked[: BuildImageUseCase.MAX_RETAINED_VULNERABILITIES]
1153
+
1154
+ #: Limiares aceitos por `--fail-on`, do mais severo para o mais brando.
1155
+ #: Cada um reprova também tudo que for pior que ele.
1156
+ FAIL_ON_THRESHOLDS = ("critical", "high", "medium", "low")
1157
+
1158
+ def _enrich(self, scan_result: ScanResult | None) -> ScanResult | None:
1159
+ """Anota os achados com CISA KEV e EPSS, quando há quem consultar.
1160
+
1161
+ O `build` escaneia direto com Trivy/Grype, fora do pipeline de
1162
+ `recommend` que faz esse enriquecimento -- então os portões `kev` e
1163
+ `epss` não tinham dado nenhum para olhar. Sem isto eles não
1164
+ reprovariam nunca, o que é a pior falha possível num portão de
1165
+ segurança: a que não aparece.
1166
+
1167
+ Como no `recommend`, só CRITICAL e HIGH são consultados. O que fica
1168
+ de fora permanece UNKNOWN por construção -- e isso é correto: não
1169
+ foi consultado.
1170
+
1171
+ O que **não** acontece aqui: marcar um achado como não explorado
1172
+ porque o catálogo não respondeu. Um feed fora do ar deixa tudo em
1173
+ UNKNOWN, e é o portão que decide o que fazer com isso.
1174
+ """
1175
+ if scan_result is None or self.threat_intel is None:
1176
+ return scan_result
1177
+ notable = [
1178
+ v
1179
+ for v in scan_result.vulnerabilities
1180
+ if str(v.get("severity") or "").upper() in ("CRITICAL", "HIGH") and v.get("cve_id")
1181
+ ]
1182
+ if not notable:
1183
+ return scan_result
1184
+
1185
+ ids = [str(v["cve_id"]) for v in notable]
1186
+ try:
1187
+ kev_ids, epss = asyncio.run(_lookup_threat_intel(self.threat_intel, ids))
1188
+ except (OSError, RuntimeError) as e:
1189
+ # Consulta que não aconteceu deixa tudo UNKNOWN, e o portão diz
1190
+ # que não pôde avaliar. Derrubar o build aqui trocaria uma
1191
+ # medição ausente por um erro técnico.
1192
+ logger.warning(f"Threat intelligence lookup failed: {e}")
1193
+ return scan_result
1194
+
1195
+ kev_answered = self.threat_intel.kev_available
1196
+ for v in notable:
1197
+ cve = str(v["cve_id"])
1198
+ if kev_answered is True:
1199
+ v["kev"] = str(Tristate.TRUE if cve in kev_ids else Tristate.FALSE)
1200
+ if cve in epss:
1201
+ v["epss"] = epss[cve]
1202
+ return scan_result
1203
+
1204
+ def _findings(self, scan_result: ScanResult) -> list[Finding]:
1205
+ """Os achados retidos, na forma que o portão entende.
1206
+
1207
+ A amostra, e não o scan inteiro: o relatório retém um número
1208
+ limitado de achados, e é dela que saem os nomes citados na
1209
+ mensagem. A **contagem** que reprova vem separada, do scan
1210
+ completo -- ver `Gate.evaluate`.
1211
+ """
1212
+ return [
1213
+ Finding(
1214
+ cve_id=str(v.get("cve_id") or ""),
1215
+ severity=str(v.get("severity") or ""),
1216
+ kev=_tristate(v.get("kev")),
1217
+ epss=_as_probability(v.get("epss")),
1218
+ package=str(v.get("package") or ""),
1219
+ fixed_version=str(v.get("fixed_version") or ""),
1220
+ )
1221
+ for v in scan_result.vulnerabilities
1222
+ ]
1223
+
1224
+ @staticmethod
1225
+ def _severity_counts(scan_result: ScanResult) -> dict[str, int]:
1226
+ return {
1227
+ "critical": scan_result.critical,
1228
+ "high": scan_result.high,
1229
+ "medium": scan_result.medium,
1230
+ "low": scan_result.low,
1231
+ }
1232
+
1233
+ def _gate_verdicts(self, scan_result: ScanResult, threshold: str) -> tuple[GateVerdict, ...]:
1234
+ """Os portões que **não** passaram. Vazio significa aprovado.
1235
+
1236
+ Um limiar desconhecido é recusado aqui, e não ignorado: um valor que
1237
+ caísse num `return False` seria um portão que nunca reprova, em
1238
+ silêncio -- e esse bug já existiu neste arquivo com
1239
+ `--fail-on medium`.
1240
+ """
1241
+ gates = GateSet.parse(threshold)
1242
+ return gates.evaluate(self._findings(scan_result), self._severity_counts(scan_result))
1243
+
1244
+ def _should_fail(self, scan_result: ScanResult, threshold: str) -> bool:
1245
+ """Se o build não deve prosseguir. Predicado sobre `_gate_verdicts`.
1246
+
1247
+ Um `UNMEASURED` conta como falha: quem pediu `--fail-on kev` pediu
1248
+ que a exploração fosse conferida, e não conseguir conferir deixa a
1249
+ pergunta sem resposta. Aprovar aí gastaria a ausência de medição
1250
+ como tranquilidade -- e, pior, desligaria um portão de segurança em
1251
+ silêncio numa oscilação de rede.
1252
+ """
1253
+ return bool(self._gate_verdicts(scan_result, threshold))
1254
+
1255
+ def _gate_failure_summary(
1256
+ self,
1257
+ scan_result: ScanResult,
1258
+ threshold: str,
1259
+ inheritance: InheritanceReport | None = None,
1260
+ ) -> str:
1261
+ """Nomeia o que disparou cada portão.
1262
+
1263
+ "Vulnerabilities exceed threshold (critical)" obrigava quem lê o log
1264
+ do CI a reabrir o relatório para descobrir *o quê*. Cada portão diz
1265
+ agora qual achado o disparou, com pacote e versão de correção.
1266
+
1267
+ Quando a atribuição rodou, a linha também diz **de onde** os achados
1268
+ vieram. É a informação mais cara de obter e a mais barata de mostrar
1269
+ aqui: quem lê o log está decidindo, naquele segundo, se mexe no
1270
+ Dockerfile ou na base -- e sem isso a decisão é um palpite.
1271
+
1272
+ Um portão `UNMEASURED` sai com palavras diferentes de propósito: ele
1273
+ não é um veredito sobre a imagem, é a constatação de que a pergunta
1274
+ ficou sem resposta, e confundir os dois faria o log do CI acusar uma
1275
+ imagem por uma falha de rede.
1276
+ """
1277
+ parts: list[str] = []
1278
+ for verdict in self._gate_verdicts(scan_result, threshold):
1279
+ if verdict.outcome is GateOutcome.UNMEASURED:
1280
+ parts.append(f"Gate not evaluated ({verdict.kind.value.lower()}): {verdict.reason}")
1281
+ continue
1282
+ header = f"Gate failed ({verdict.kind.value.lower()}): {verdict.reason}"
1283
+ if not verdict.offenders:
1284
+ parts.append(
1285
+ f"{header}{_origin_hint(inheritance)} "
1286
+ "(not kept in the report sample; run the scanner for the full list)"
1287
+ )
1288
+ continue
1289
+ listed = "; ".join(
1290
+ f"{f.cve_id or '?'} ({f.severity}) in {f.package or '?'}".strip()
1291
+ + (f" -> {f.fixed_version}" if f.fixed_version else " (no fix)")
1292
+ for f in verdict.offenders[:10]
1293
+ )
1294
+ more = (
1295
+ f"; ... and {len(verdict.offenders) - 10} more"
1296
+ if len(verdict.offenders) > 10
1297
+ else ""
1298
+ )
1299
+ parts.append(f"{header}{_origin_hint(inheritance)} -- {listed}{more}")
1300
+ return f"[--fail-on {threshold}] " + " | ".join(parts)
1301
+
1302
+ def _preflight(
1303
+ self, request: BuildImageRequest, validation: AnalyzeDockerfileResponse
1304
+ ) -> list[PolicyViolation]:
1305
+ """As regras que já dá para reprovar sem construir nada.
1306
+
1307
+ Descobrir um rótulo obrigatório faltando depois de dez minutos de build
1308
+ e um scan é o tipo de atrito que faz as pessoas pararem de rodar o
1309
+ portão. As regras que dependem de medição continuam para depois: elas
1310
+ não são consideradas cumpridas aqui, apenas não são conferíveis.
1311
+ """
1312
+ if request.policy is None:
1313
+ return []
1314
+ return evaluate(
1315
+ request.policy.static_subset(),
1316
+ PolicyFacts(
1317
+ bases=self._base_facts(request),
1318
+ labels=dict(request.labels or {}),
1319
+ nonroot=self._nonroot_state(validation.analysis),
1320
+ ),
1321
+ )
1322
+
1323
+ def _base_facts(self, request: BuildImageRequest) -> tuple[BaseFact, ...]:
1324
+ """As bases declaradas, lidas do arquivo com expansão de `ARG`."""
1325
+ path = Path(request.context_path) / request.dockerfile_path
1326
+ try:
1327
+ content = path.read_text(encoding="utf-8", errors="replace")
1328
+ except OSError as e:
1329
+ logger.debug(f"Could not re-read {path}: {e}")
1330
+ return ()
1331
+ return tuple(
1332
+ BaseFact(
1333
+ reference=base.reference,
1334
+ registry=registry_host_of(base.name),
1335
+ pinned=base.is_pinned,
1336
+ )
1337
+ for base in parse_bases(content)
1338
+ )
1339
+
1340
+ def _attribute_findings(
1341
+ self,
1342
+ request: BuildImageRequest,
1343
+ validation: AnalyzeDockerfileResponse,
1344
+ scan_result: ScanResult | None,
1345
+ ) -> InheritanceReport | None:
1346
+ """De quem é cada CVE: da base declarada, ou das camadas deste build.
1347
+
1348
+ Um relatório que diz "47 vulnerabilidades" manda consertar sem dizer o
1349
+ quê. A resposta exige um segundo scan -- o da base -- e por isso é
1350
+ escolha explícita: dobrar o tempo de portão por padrão faria as pessoas
1351
+ desligarem o portão.
1352
+
1353
+ Devolve `None` quando ninguém pediu, e um relatório `UNAVAILABLE` com o
1354
+ motivo quando pediram e não deu. As duas coisas são diferentes de "tudo
1355
+ é seu" e de "tudo é herdado", que seriam as duas formas de transformar
1356
+ ausência de medição em acusação.
1357
+ """
1358
+ if not request.attribute_findings:
1359
+ return None
1360
+ if scan_result is None:
1361
+ return unavailable("", "the built image could not be scanned")
1362
+
1363
+ analysis = validation.analysis
1364
+ base_reference = (analysis.info.final_base_image or "") if analysis else ""
1365
+ if not base_reference:
1366
+ return unavailable(
1367
+ "",
1368
+ "the final stage base could not be determined from the Dockerfile",
1369
+ )
1370
+ if base_reference.lower() == "scratch":
1371
+ # `scratch` não é uma imagem: não há o que escanear, e tudo que a
1372
+ # imagem carrega veio das camadas deste build. Dizer isso é
1373
+ # atribuição, não omissão.
1374
+ return attribute(
1375
+ _as_vulnerabilities(scan_result.vulnerabilities), [], base_reference=base_reference
1376
+ )
1377
+
1378
+ logger.info(f"Scanning the base {base_reference} to attribute the findings")
1379
+ base_scan = self._scan_image(base_reference)
1380
+ if base_scan is None:
1381
+ return unavailable(
1382
+ base_reference,
1383
+ f"the base {base_reference} could not be scanned",
1384
+ )
1385
+ return attribute(
1386
+ _as_vulnerabilities(scan_result.vulnerabilities),
1387
+ _as_vulnerabilities(base_scan.vulnerabilities),
1388
+ base_reference=base_reference,
1389
+ )
1390
+
1391
+ def _policy_facts(
1392
+ self,
1393
+ *,
1394
+ request: BuildImageRequest,
1395
+ analysis: DockerfileAnalysis | None,
1396
+ scan_result: ScanResult | None,
1397
+ source_before: SourceDigests,
1398
+ source_after: SourceDigests,
1399
+ image_id: str,
1400
+ ) -> PolicyFacts:
1401
+ """Os fatos medidos neste build, no formato que a política avalia.
1402
+
1403
+ Nada aqui infere: cada campo ou vem de uma medição que aconteceu ou
1404
+ fica no valor que significa "não medido". É o que faz a política
1405
+ reprovar por ausência de prova em vez de aprovar por falta de sinal.
1406
+ """
1407
+ counts: dict[str, int] = {}
1408
+ if scan_result is not None:
1409
+ counts = {
1410
+ "critical": scan_result.critical,
1411
+ "high": scan_result.high,
1412
+ "medium": scan_result.medium,
1413
+ "low": scan_result.low,
1414
+ }
1415
+
1416
+ bases = tuple(
1417
+ BaseFact(
1418
+ reference=reference,
1419
+ registry=registry_host_of(reference),
1420
+ pinned=bool(digest) or "@sha256:" in reference,
1421
+ )
1422
+ for reference, digest in source_before.base_images.items()
1423
+ )
1424
+
1425
+ # A procedência é recalculada aqui e não lida do documento: o registro
1426
+ # final só existe depois do push, e a política precisa decidir antes.
1427
+ parcial = BuildProvenance(
1428
+ tag=request.tag,
1429
+ source=source_before,
1430
+ source_after=source_after,
1431
+ artifact=ArtifactDigests(image_id=image_id),
1432
+ )
1433
+
1434
+ return PolicyFacts(
1435
+ scan_ran=scan_result is not None,
1436
+ severity_counts=counts,
1437
+ bases=bases,
1438
+ labels=dict(request.labels or {}),
1439
+ nonroot=self._nonroot_state(analysis),
1440
+ provenance_status=str(parcial.status),
1441
+ )
1442
+
1443
+ @staticmethod
1444
+ def _nonroot_state(analysis: DockerfileAnalysis | None) -> Tristate:
1445
+ """Se a imagem roda sem privilégio, segundo o que a validação mediu.
1446
+
1447
+ A ausência da checagem é `UNKNOWN`, e não `FALSE`: não ter medido não
1448
+ é ter medido e reprovado, e a política distingue os dois na mensagem.
1449
+ O veredito vem do DF002, que já sabe que `USER 0` e `USER 0:0` são root
1450
+ tanto quanto `USER root` -- reimplementar a leitura aqui seria manter
1451
+ duas definições de "sem privilégio" que divergiriam na primeira
1452
+ correção.
1453
+ """
1454
+ if analysis is None:
1455
+ return Tristate.UNKNOWN
1456
+ for check in analysis.validation.checks:
1457
+ if check.rule_id == "DF002":
1458
+ return Tristate.of(check.status is ValidationStatus.PASS)
1459
+ return Tristate.UNKNOWN
1460
+
1461
+ def _digest_source(self, context_path: str, dockerfile_path: str) -> SourceDigests:
1462
+ """Digere a entrada do build: Dockerfile, contexto, bases e revisão.
1463
+
1464
+ Nada aqui é fatal. Um contexto que não pôde ser digerido devolve
1465
+ campos vazios, e a procedência se declara `INCOMPLETE` -- que é a
1466
+ ausência de prova, não uma acusação, e é muito diferente de fingir
1467
+ que a cadeia fechou.
1468
+ """
1469
+ root = Path(context_path)
1470
+ dockerfile = (
1471
+ root / dockerfile_path
1472
+ if not Path(dockerfile_path).is_absolute()
1473
+ else Path(dockerfile_path)
1474
+ )
1475
+
1476
+ dockerfile_digest = ""
1477
+ base_images: dict[str, str] = {}
1478
+ if dockerfile.is_file():
1479
+ try:
1480
+ dockerfile_digest = hash_file(dockerfile)
1481
+ base_images = self._declared_bases(dockerfile)
1482
+ except OSError as e:
1483
+ logger.warning(f"Could not digest {dockerfile}: {e}")
1484
+
1485
+ context_digest, counted = "", 0
1486
+ try:
1487
+ context_digest, counted = hash_context(root)
1488
+ except (OSError, ContextTooLargeError) as e:
1489
+ logger.warning(f"Could not digest the context {root}: {e}")
1490
+
1491
+ revision, dirty = self._git_state(root)
1492
+ return SourceDigests(
1493
+ dockerfile=dockerfile_digest,
1494
+ context=context_digest,
1495
+ context_files=counted,
1496
+ base_images=base_images,
1497
+ git_revision=revision,
1498
+ git_dirty=dirty,
1499
+ )
1500
+
1501
+ @staticmethod
1502
+ def _declared_bases(dockerfile: Path) -> dict[str, str]:
1503
+ """Cada `FROM` e o digest que ele fixa, quando fixa.
1504
+
1505
+ Uma base sem digest é uma tag móvel, e registrar isso vale mais do
1506
+ que omitir: é exatamente a diferença entre um build reproduzível e um
1507
+ que depende do dia.
1508
+ """
1509
+ bases: dict[str, str] = {}
1510
+ try:
1511
+ content = dockerfile.read_text(encoding="utf-8", errors="replace")
1512
+ except OSError:
1513
+ return bases
1514
+ for line in content.splitlines():
1515
+ stripped = line.strip()
1516
+ if not stripped.upper().startswith("FROM "):
1517
+ continue
1518
+ reference = stripped.split()[1]
1519
+ _, separator, digest = reference.partition("@")
1520
+ bases[reference] = digest if separator else ""
1521
+ return bases
1522
+
1523
+ @staticmethod
1524
+ def _git_state(root: Path) -> tuple[str, bool]:
1525
+ """Commit e limpeza da árvore. Um commit sozinho mentiria sobre o que
1526
+ gerou a imagem se houvesse alteração não commitada."""
1527
+ try:
1528
+ revision = subprocess.run( # nosec B603 # noqa: S603
1529
+ [resolve_executable("git"), "-C", str(root), "rev-parse", "HEAD"],
1530
+ capture_output=True,
1531
+ text=True,
1532
+ timeout=15,
1533
+ check=False,
1534
+ )
1535
+ if revision.returncode != 0:
1536
+ return "", False
1537
+ status = subprocess.run( # nosec B603 # noqa: S603
1538
+ [resolve_executable("git"), "-C", str(root), "status", "--porcelain"],
1539
+ capture_output=True,
1540
+ text=True,
1541
+ timeout=15,
1542
+ check=False,
1543
+ )
1544
+ return revision.stdout.strip(), bool(status.stdout.strip())
1545
+ except (ExecutableNotFoundError, OSError, subprocess.SubprocessError):
1546
+ return "", False
1547
+
1548
+ def _repo_digest(self, reference: str) -> str:
1549
+ """O digest do manifesto, que só existe depois de um push."""
1550
+ info = self._get_image_info(reference)
1551
+ digests = info.get("RepoDigests") or []
1552
+ for entry in digests:
1553
+ _, separator, digest = str(entry).partition("@")
1554
+ if separator:
1555
+ return digest
1556
+ return ""
1557
+
1558
+ @staticmethod
1559
+ def _archive_provenance(provenance: BuildProvenance, destination: str) -> None:
1560
+ """Arquiva o documento ao lado do relatório. Falhar aqui não invalida
1561
+ o build: a procedência já está na resposta."""
1562
+ if not destination:
1563
+ return
1564
+ try:
1565
+ path = Path(destination)
1566
+ path.parent.mkdir(parents=True, exist_ok=True)
1567
+ path.write_text(
1568
+ json.dumps(provenance.to_dict(), indent=2, ensure_ascii=False), encoding="utf-8"
1569
+ )
1570
+ logger.info(f"Provenance archived at {path}")
1571
+ except OSError as e:
1572
+ logger.warning(f"Could not archive the provenance: {e}")
1573
+
1574
+ def _generate_report(
1575
+ self,
1576
+ validation: Any,
1577
+ build: BuildResult,
1578
+ scan: ScanResult | None,
1579
+ image_tag: str,
1580
+ dockerfile_path: str,
1581
+ remediation_history: list[dict[str, Any]] | None = None,
1582
+ ) -> BuildReport:
1583
+ """Gera relatório completo do build."""
1584
+ now = datetime.now(tz=UTC)
1585
+ build_id = self._new_build_id(image_tag)
1586
+
1587
+ # Calcular score de segurança
1588
+ security_score = self._calculate_security_score(validation, scan)
1589
+ security_tier = self._calculate_security_tier(security_score)
1590
+
1591
+ # Extrair checks de validação
1592
+ validation_dict = self._validation_dict(validation.validation)
1593
+
1594
+ # Resultados do scan
1595
+ scan_dict = None
1596
+ if scan:
1597
+ scan_dict = {
1598
+ "trivy" if scan.scan_tool == "trivy" else "grype": {
1599
+ "critical": scan.critical,
1600
+ "high": scan.high,
1601
+ "medium": scan.medium,
1602
+ "low": scan.low,
1603
+ },
1604
+ }
1605
+
1606
+ # Metadados do build
1607
+ git_sha = self._get_git_sha()
1608
+ metadata = {
1609
+ "timestamp": now.isoformat(),
1610
+ "git_sha": git_sha,
1611
+ "built_by": os.environ.get("USER", "unknown"),
1612
+ "docker_version": self._get_docker_version(),
1613
+ "buildkit": True,
1614
+ }
1615
+
1616
+ # Recomendações: vêm das sugestões de hardening. `DockerfileAnalysis`
1617
+ # nunca teve um atributo `recommendations` -- o acesso antigo só não
1618
+ # explodia porque `analysis` era sempre None nos testes.
1619
+ recommendations = self._recommendation_dicts(list(validation.suggestions or []))
1620
+
1621
+ history = remediation_history or []
1622
+ return BuildReport(
1623
+ build_id=build_id,
1624
+ timestamp=now.isoformat(),
1625
+ image=image_tag,
1626
+ dockerfile_path=dockerfile_path,
1627
+ validation=validation_dict,
1628
+ scan_results=scan_dict,
1629
+ security_score=security_score,
1630
+ security_tier=security_tier,
1631
+ recommendations=recommendations,
1632
+ build_metadata=metadata,
1633
+ remediation_history=history,
1634
+ auto_remediated=bool(history),
1635
+ )
1636
+
1637
+ def _calculate_security_score(self, validation: Any, scan: ScanResult | None) -> int:
1638
+ """Calcula score de segurança (0-100)."""
1639
+ score = 100
1640
+
1641
+ # Penalizar erros de validação
1642
+ if validation.validation:
1643
+ score -= validation.validation.errors * 10
1644
+ score -= validation.validation.warnings * 3
1645
+
1646
+ # Penalizar vulnerabilidades
1647
+ if scan:
1648
+ score -= scan.critical * 15
1649
+ score -= scan.high * 10
1650
+ score -= scan.medium * 3
1651
+ score -= scan.low * 1
1652
+
1653
+ return max(0, min(100, score))
1654
+
1655
+ def _calculate_security_tier(self, score: int) -> str:
1656
+ """Calcula tier de segurança baseado no score."""
1657
+ if score >= 90:
1658
+ return "A"
1659
+ elif score >= 75:
1660
+ return "B"
1661
+ elif score >= 60:
1662
+ return "C"
1663
+ elif score >= 40:
1664
+ return "D"
1665
+ else:
1666
+ return "F"
1667
+
1668
+ def _get_git_sha(self) -> str | None:
1669
+ """Obtém SHA do git atual.
1670
+
1671
+ Metadado opcional do relatório: fora de um repositório, ou sem git
1672
+ instalado, o relatório sai sem ele em vez de falhar o build.
1673
+ """
1674
+ return self._capture_output(["git", "rev-parse", "HEAD"], "git SHA")
1675
+
1676
+ def _get_docker_version(self) -> str:
1677
+ """Obtém versão do Docker."""
1678
+ return self._capture_output(["docker", "--version"], "the Docker version") or "unknown"
1679
+
1680
+ @staticmethod
1681
+ def _capture_output(argv: list[str], what: str) -> str | None:
1682
+ """Roda `argv` e devolve seu stdout, ou None se não der.
1683
+
1684
+ A falha é registrada em DEBUG em vez de engolida em silêncio: um
1685
+ `except: pass` esconde exatamente o caso que a gente quer investigar
1686
+ quando o metadado sai vazio.
1687
+ """
1688
+ try:
1689
+ resolved = [resolve_executable(argv[0]), *argv[1:]]
1690
+ result = subprocess.run( # nosec B603 # noqa: S603
1691
+ resolved,
1692
+ capture_output=True,
1693
+ text=True,
1694
+ timeout=10,
1695
+ check=False,
1696
+ )
1697
+ except (ExecutableNotFoundError, OSError, subprocess.SubprocessError) as e:
1698
+ logger.debug(f"Could not read {what}: {e}")
1699
+ return None
1700
+
1701
+ if result.returncode != 0:
1702
+ logger.debug(f"Could not read {what}: exit {result.returncode}")
1703
+ return None
1704
+ return result.stdout.strip()
1705
+
1706
+
1707
+ def _as_vulnerabilities(raw: list[dict[str, Any]]) -> list[Vulnerability]:
1708
+ """Converte os achados crus do scanner nas entidades do domínio.
1709
+
1710
+ O `ScanResult` deste módulo carrega dicionários porque o relatório de build
1711
+ os serializa direto. A atribuição, porém, é lógica de domínio e trabalha
1712
+ com a entidade -- converter aqui é o que evita duas definições de "o que é
1713
+ um achado" convivendo no mesmo processo.
1714
+
1715
+ Uma severidade que o scanner reporte com um nome que não conhecemos vira
1716
+ `UNKNOWN` em vez de derrubar o build: a atribuição usa CVE e pacote, e a
1717
+ severidade é só o que se mostra ao lado.
1718
+ """
1719
+ findings: list[Vulnerability] = []
1720
+ for item in raw:
1721
+ severity = str(item.get("severity") or "").strip().upper()
1722
+ findings.append(
1723
+ Vulnerability(
1724
+ cve_id=str(item.get("cve_id") or ""),
1725
+ package_name=str(item.get("package") or ""),
1726
+ severity=Severity(severity)
1727
+ if severity in Severity.__members__
1728
+ else Severity.UNKNOWN,
1729
+ installed_version=str(item.get("installed_version") or ""),
1730
+ fixed_version=str(item.get("fixed_version") or ""),
1731
+ )
1732
+ )
1733
+ return findings
1734
+
1735
+
1736
+ async def _lookup_threat_intel(
1737
+ client: ThreatIntelClient, cve_ids: list[str]
1738
+ ) -> tuple[set[str], dict[str, float]]:
1739
+ """As duas consultas, numa corrida só."""
1740
+ kev = await client.known_exploited(cve_ids)
1741
+ epss = await client.epss_scores(cve_ids)
1742
+ return kev, epss
1743
+
1744
+
1745
+ def _tristate(value: object) -> Tristate:
1746
+ """Lê o campo `kev` de um achado, com UNKNOWN como padrão honesto.
1747
+
1748
+ Ausente, ilegível ou de outro tipo é UNKNOWN: nada foi consultado. Só
1749
+ um `TRUE`/`FALSE` explícito -- escrito pelo enriquecimento, depois de o
1750
+ catálogo responder -- vira veredito.
1751
+ """
1752
+ if isinstance(value, Tristate):
1753
+ return value
1754
+ if isinstance(value, str):
1755
+ try:
1756
+ # `Tristate` é minúsculo por dentro (`"true"`), e o campo pode
1757
+ # chegar escrito de qualquer jeito de um JSON que não é nosso.
1758
+ return Tristate(value.strip().lower())
1759
+ except ValueError:
1760
+ return Tristate.UNKNOWN
1761
+ return Tristate.UNKNOWN
1762
+
1763
+
1764
+ def _as_probability(value: object) -> float | None:
1765
+ """O EPSS de um achado, ou None quando ninguém consultou.
1766
+
1767
+ `0.0` é uma resposta -- o FIRST pontuou em zero -- e `None` é a falta
1768
+ dela. Colapsar as duas faria o portão comparar uma ausência com um
1769
+ piso e concluir "abaixo do limiar", que é ausência de medição gasta
1770
+ como tranquilidade.
1771
+ """
1772
+ if isinstance(value, bool) or value is None:
1773
+ return None
1774
+ if isinstance(value, int | float):
1775
+ return float(value)
1776
+ return None
1777
+
1778
+
1779
+ def _origin_hint(inheritance: InheritanceReport | None) -> str:
1780
+ """Uma frase curta sobre de onde vieram os achados, quando se sabe.
1781
+
1782
+ Fica vazia quando ninguém pediu `--attribute` ou quando a atribuição não
1783
+ fechou: um portão que insinua uma origem que não mediu é pior do que um
1784
+ portão calado.
1785
+ """
1786
+ if inheritance is None or not inheritance.available:
1787
+ return ""
1788
+ herdadas, suas = len(inheritance.inherited), len(inheritance.introduced)
1789
+ if not herdadas and not suas:
1790
+ return ""
1791
+ corrigiveis = inheritance.fixable_inherited
1792
+ return (
1793
+ f" [{herdadas} from the base {inheritance.base_reference}"
1794
+ f" ({corrigiveis} with a published fix), {suas} from your layers]"
1795
+ )