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,484 @@
1
+ """Assinar e verificar imagens com cosign.
2
+
3
+ Uma assinatura responde a uma pergunta que nada mais nesta ferramenta
4
+ responde: *quem* publicou estes bytes. O scan diz o que há dentro, a
5
+ procedência diz de onde veio, e nenhum dos dois impede alguém com acesso de
6
+ escrita ao registry de sobrescrever a tag com outra coisa. A assinatura é o
7
+ elo que fecha isso, e ela vale exatamente pelo cuidado com que é tratada --
8
+ uma assinatura conferida com displicência é pior que nenhuma, porque produz
9
+ confiança sem base.
10
+
11
+ Daí as três regras deste módulo:
12
+
13
+ * **Cosign ausente é `SIGNER_MISSING`, nunca "não assinado" nem "verificado".**
14
+ Não ter a ferramenta é ausência de resposta. Reportar isso como falta de
15
+ assinatura acusaria alguém injustamente; reportar como verificado seria a
16
+ falha silenciosa que este projeto inteiro existe para evitar.
17
+ * **Só se assina por digest.** Assinar `app:1.0` assina o que a tag aponta no
18
+ instante do comando, e a tag pode mover no instante seguinte -- a assinatura
19
+ continuaria válida, cobrindo bytes que ninguém mediu. O digest não move.
20
+ * **Nada de senha ou token em log.** A invocação passa por lista de argumentos,
21
+ sem shell, e a saída do cosign é repassada como está apenas quando não há
22
+ material sensível envolvido; a variável de ambiente com a senha da chave
23
+ nunca é ecoada.
24
+
25
+ A verificação keyless (OIDC) é o padrão porque é o que funciona em CI sem
26
+ segredo de longa duração no repositório -- e um segredo de longa duração num
27
+ repositório é a coisa que a assinatura deveria estar protegendo.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ from dataclasses import dataclass, field
34
+ from enum import StrEnum
35
+
36
+ from loguru import logger
37
+
38
+ from dockerls.utils.executables import ExecutableNotFoundError, resolve_executable
39
+ from dockerls.utils.subprocess_runner import OutputTooLargeError, run_capture
40
+
41
+ #: Tempo máximo de uma operação do cosign. Assinar e verificar falam com o
42
+ #: registry e com o log de transparência; minutos seriam patologia, não
43
+ #: lentidão.
44
+ COSIGN_TIMEOUT_SECONDS = 120.0
45
+
46
+ _COSIGN = "cosign"
47
+
48
+
49
+ class SignatureStatus(StrEnum):
50
+ """O resultado de uma operação de assinatura, com a ausência explícita."""
51
+
52
+ #: A assinatura existe e confere.
53
+ VERIFIED = "VERIFIED"
54
+ #: A assinatura foi produzida agora.
55
+ SIGNED = "SIGNED"
56
+ #: O cosign respondeu que não há assinatura nenhuma para esta imagem.
57
+ UNSIGNED = "UNSIGNED"
58
+ #: **Existe assinatura, e ela não confere** -- bytes adulterados, ou
59
+ #: assinados por alguém que não é quem se esperava. É veredito, e o mais
60
+ #: grave dos três: `UNSIGNED` diz que ninguém atestou nada, este diz que
61
+ #: alguém atestou e a atestação não bate.
62
+ VERIFICATION_FAILED = "VERIFICATION_FAILED"
63
+ #: O cosign não está instalado. Ausência de resposta, não veredito.
64
+ SIGNER_MISSING = "SIGNER_MISSING"
65
+ #: O cosign rodou e falhou por outro motivo (rede, permissão, timeout).
66
+ FAILED = "FAILED"
67
+
68
+ @property
69
+ def is_conclusive(self) -> bool:
70
+ """Se este estado permite afirmar alguma coisa sobre a imagem.
71
+
72
+ `SIGNER_MISSING` e `FAILED` não permitem: são falhas do medidor, e
73
+ tratá-las como veredito é a substituição que esta ferramenta recusa.
74
+ """
75
+ return self in (
76
+ SignatureStatus.VERIFIED,
77
+ SignatureStatus.SIGNED,
78
+ SignatureStatus.UNSIGNED,
79
+ SignatureStatus.VERIFICATION_FAILED,
80
+ )
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class SignatureResult:
85
+ """O que se conseguiu apurar sobre a assinatura de uma referência."""
86
+
87
+ reference: str
88
+ status: SignatureStatus
89
+ detail: str = ""
90
+ #: Identidades que assinaram, quando a verificação as revelou.
91
+ identities: tuple[str, ...] = field(default_factory=tuple)
92
+ #: Se a verificação restringiu identidade **e/ou** emissor. `False` num
93
+ #: `VERIFIED` quer dizer "alguém assinou", não "quem você espera assinou".
94
+ #: Sai no JSON porque um consumidor que só olha `trustworthy` não teria
95
+ #: como saber que a pergunta feita foi a fraca.
96
+ identity_constrained: bool = False
97
+
98
+ @property
99
+ def trustworthy(self) -> bool:
100
+ """Só `VERIFIED` autoriza confiar. Nem `SIGNER_MISSING`, nem `FAILED`."""
101
+ return self.status is SignatureStatus.VERIFIED
102
+
103
+ def explain(self) -> str:
104
+ if self.status is SignatureStatus.VERIFIED:
105
+ quem = ", ".join(self.identities) if self.identities else "identity not disclosed"
106
+ return f"valid signature ({quem})"
107
+ if self.status is SignatureStatus.SIGNED:
108
+ return "signature published in the registry"
109
+ if self.status is SignatureStatus.UNSIGNED:
110
+ return (
111
+ "cosign found no valid signature for this image: nobody publicly "
112
+ "attested to publishing it"
113
+ )
114
+ if self.status is SignatureStatus.VERIFICATION_FAILED:
115
+ return (
116
+ "SIGNATURE VERIFICATION FAILED: cosign found signing material for this "
117
+ "image and it did not check out -- the bytes do not match what was "
118
+ "signed, or the signer is not the identity you required. This is worse "
119
+ "than an unsigned image, not the same thing"
120
+ )
121
+ if self.status is SignatureStatus.SIGNER_MISSING:
122
+ return (
123
+ "cosign is not installed: this is an absence of an answer, not "
124
+ "confirmation that the image is unsigned"
125
+ )
126
+ return f"the check could not be completed: {self.detail or 'reason unknown'}"
127
+
128
+ def to_dict(self) -> dict[str, object]:
129
+ return {
130
+ "reference": self.reference,
131
+ "status": str(self.status),
132
+ "trustworthy": self.trustworthy,
133
+ "conclusive": self.status.is_conclusive,
134
+ "identity_constrained": self.identity_constrained,
135
+ "explanation": self.explain(),
136
+ "identities": list(self.identities),
137
+ "detail": self.detail,
138
+ }
139
+
140
+
141
+ class CosignClient:
142
+ """Invoca o cosign, sempre por lista de argumentos e sem shell."""
143
+
144
+ def __init__(self, timeout: float = COSIGN_TIMEOUT_SECONDS):
145
+ self._timeout = timeout
146
+
147
+ async def sign(self, reference: str, *, keyless: bool = True) -> SignatureResult:
148
+ """Assina uma referência **por digest**.
149
+
150
+ Assinar uma tag assinaria o que ela aponta agora, e ela pode mover no
151
+ instante seguinte: a assinatura seguiria válida cobrindo outros bytes.
152
+ """
153
+ if "@sha256:" not in reference:
154
+ return SignatureResult(
155
+ reference=reference,
156
+ status=SignatureStatus.FAILED,
157
+ detail=(
158
+ "only digests get signed: a tag can move, and the signature would "
159
+ "stay valid over bytes nobody measured"
160
+ ),
161
+ )
162
+
163
+ argv = ["sign", "--yes", reference]
164
+ if not keyless:
165
+ argv = ["sign", "--yes", "--key", "cosign.key", reference]
166
+
167
+ code, _, err = await self._run(argv)
168
+ if code is None:
169
+ return _missing(reference)
170
+ if code != 0:
171
+ return SignatureResult(
172
+ reference=reference, status=SignatureStatus.FAILED, detail=_tail(err)
173
+ )
174
+ return SignatureResult(reference=reference, status=SignatureStatus.SIGNED)
175
+
176
+ async def attest(
177
+ self,
178
+ reference: str,
179
+ *,
180
+ predicate: str,
181
+ predicate_type: str,
182
+ keyless: bool = True,
183
+ ) -> SignatureResult:
184
+ """Anexa um documento ao manifesto de uma imagem, assinado.
185
+
186
+ Uma atestação é uma afirmação sobre bytes específicos -- "este SBOM
187
+ descreve *esta* imagem" --, e por isso vale a mesma regra do
188
+ `sign`: **só por digest**. Atestar uma tag anexaria o documento ao
189
+ que ela aponta agora, e ela pode mover no instante seguinte; a
190
+ atestação seguiria válida, descrevendo outra imagem.
191
+
192
+ Isto é o que fecha o círculo com `registry-audit`, que já pergunta
193
+ se há atestação publicada e, até agora, respondia "não há" para
194
+ toda imagem que esta própria ferramenta construiu.
195
+ """
196
+ if "@sha256:" not in reference:
197
+ return SignatureResult(
198
+ reference=reference,
199
+ status=SignatureStatus.FAILED,
200
+ detail=(
201
+ "only digests get attested: a tag can move, and the attestation "
202
+ "would stay valid while describing a different image"
203
+ ),
204
+ )
205
+
206
+ argv = [
207
+ "attest",
208
+ "--yes",
209
+ "--predicate",
210
+ predicate,
211
+ "--type",
212
+ predicate_type,
213
+ reference,
214
+ ]
215
+ if not keyless:
216
+ argv[1:1] = ["--key", "cosign.key"]
217
+
218
+ code, _, err = await self._run(argv)
219
+ if code is None:
220
+ return _missing(reference)
221
+ if code != 0:
222
+ return SignatureResult(
223
+ reference=reference, status=SignatureStatus.FAILED, detail=_tail(err)
224
+ )
225
+ return SignatureResult(
226
+ reference=reference,
227
+ status=SignatureStatus.SIGNED,
228
+ detail=f"attested with a {predicate_type} predicate",
229
+ )
230
+
231
+ async def verify(
232
+ self,
233
+ reference: str,
234
+ *,
235
+ certificate_identity_regexp: str = "",
236
+ certificate_oidc_issuer: str = "",
237
+ ) -> SignatureResult:
238
+ """Confere a assinatura de uma referência.
239
+
240
+ Sem identidade e emissor declarados, o cosign aceita **qualquer**
241
+ assinante -- o que responde "está assinada" e não responde "por quem",
242
+ que é a única pergunta que importa. Este método passa os dois adiante
243
+ e diz na saída quando não recebeu nenhum.
244
+
245
+ Os três desfechos negativos são separados de propósito, e a ordem em
246
+ que são testados é a correção de um erro real:
247
+
248
+ * **`VERIFICATION_FAILED`** -- há material de assinatura e ele não
249
+ confere. Testado **primeiro**, porque o cosign anuncia uma
250
+ identidade que não bate como `no matching signatures: none of the
251
+ expected identities matched what was in the certificate`, e o
252
+ marcador `no matching signatures` também aparece numa imagem sem
253
+ assinatura nenhuma. Enquanto "não assinado" era testado antes, uma
254
+ imagem assinada **por outra pessoa** saía como `UNSIGNED`: a falha
255
+ exata que restringir a identidade existe para pegar era reportada
256
+ como se ninguém tivesse assinado, que é o desfecho *menos* grave.
257
+ * **`UNSIGNED`** -- o cosign procurou e não achou nada.
258
+ * **`FAILED`** -- o medidor não respondeu (rede, permissão, timeout).
259
+ """
260
+ constrained = bool(certificate_identity_regexp or certificate_oidc_issuer)
261
+ argv = ["verify", "--output", "json", reference]
262
+ if certificate_identity_regexp:
263
+ argv[1:1] = ["--certificate-identity-regexp", certificate_identity_regexp]
264
+ if certificate_oidc_issuer:
265
+ argv[1:1] = ["--certificate-oidc-issuer", certificate_oidc_issuer]
266
+
267
+ code, out, err = await self._run(argv)
268
+ if code is None:
269
+ return _missing(reference)
270
+ if code != 0:
271
+ # O cosign devolve o mesmo código para "não há assinatura", para
272
+ # "a assinatura não confere" e para "não consegui falar com o
273
+ # registry". Distinguir importa: dois são veredito sobre a
274
+ # imagem -- e não o mesmo veredito --, o terceiro é falha do
275
+ # medidor.
276
+ texto = _tail(err)
277
+ if _looks_like_a_bad_signature(texto):
278
+ return SignatureResult(
279
+ reference=reference,
280
+ status=SignatureStatus.VERIFICATION_FAILED,
281
+ detail=texto,
282
+ identity_constrained=constrained,
283
+ )
284
+ if _looks_unsigned(texto):
285
+ return SignatureResult(
286
+ reference=reference,
287
+ status=SignatureStatus.UNSIGNED,
288
+ detail=texto,
289
+ identity_constrained=constrained,
290
+ )
291
+ return SignatureResult(
292
+ reference=reference,
293
+ status=SignatureStatus.FAILED,
294
+ detail=texto,
295
+ identity_constrained=constrained,
296
+ )
297
+
298
+ identities = _identities(out)
299
+ detail = ""
300
+ if not constrained:
301
+ detail = (
302
+ "verified without constraining identity or issuer: this confirms that "
303
+ "*someone* signed, not that whoever you expect signed"
304
+ )
305
+ elif not _is_readable_output(out):
306
+ # Saída malformada num cosign que saiu com 0: a verificação
307
+ # aconteceu contra as restrições passadas, mas o documento que
308
+ # nomeia o assinante não pôde ser lido. Dizer isso é melhor do
309
+ # que uma lista de identidades vazia que se lê como "não havia".
310
+ detail = (
311
+ "cosign exited cleanly but its JSON output could not be parsed: "
312
+ "the signature was checked against the identity you required, "
313
+ "but the signer could not be read back to show you"
314
+ )
315
+ return SignatureResult(
316
+ reference=reference,
317
+ status=SignatureStatus.VERIFIED,
318
+ identities=identities,
319
+ detail=detail,
320
+ identity_constrained=constrained,
321
+ )
322
+
323
+ async def verify_blob(
324
+ self,
325
+ blob: str,
326
+ *,
327
+ signature: str,
328
+ certificate: str,
329
+ certificate_identity_regexp: str = "",
330
+ certificate_oidc_issuer: str = "",
331
+ ) -> bool | None:
332
+ """Confere a assinatura keyless de um arquivo local.
333
+
334
+ `None` quer dizer **não foi possível conferir** -- cosign ausente, ou
335
+ o cosign falhando por um motivo que não é "assinatura inválida". Só
336
+ `False` é veredito: os bytes não são os que aquela identidade
337
+ assinou. A distinção é a mesma de sempre neste projeto, e aqui ela
338
+ decide se uma instalação é abortada ou apenas não é atestada.
339
+
340
+ Sem identidade e emissor declarados o cosign aceita qualquer
341
+ assinante, o que responderia "está assinado" sem responder "por
342
+ quem". Quem chama passa os dois; este método não inventa um padrão,
343
+ porque um padrão errado seria pior que nenhum.
344
+ """
345
+ argv = [
346
+ "verify-blob",
347
+ "--signature",
348
+ signature,
349
+ "--certificate",
350
+ certificate,
351
+ blob,
352
+ ]
353
+ if certificate_identity_regexp:
354
+ argv[1:1] = ["--certificate-identity-regexp", certificate_identity_regexp]
355
+ if certificate_oidc_issuer:
356
+ argv[1:1] = ["--certificate-oidc-issuer", certificate_oidc_issuer]
357
+
358
+ code, _, err = await self._run(argv)
359
+ if code is None:
360
+ return None
361
+ if code == 0:
362
+ return True
363
+ texto = _tail(err)
364
+ if _looks_like_a_bad_signature(texto):
365
+ return False
366
+ # Rede fora do ar, Rekor indisponível, certificado que o cosign não
367
+ # soube ler: nada disso diz que os bytes estão errados.
368
+ logger.debug(f"cosign verify-blob could not conclude: {texto}")
369
+ return None
370
+
371
+ async def _run(self, argv: list[str]) -> tuple[int | None, bytes, bytes]:
372
+ """Executa o cosign. `None` como código significa "não está instalado"."""
373
+ try:
374
+ binary = resolve_executable(_COSIGN)
375
+ except ExecutableNotFoundError:
376
+ return None, b"", b""
377
+
378
+ try:
379
+ return await run_capture([binary, *argv], timeout=self._timeout)
380
+ except TimeoutError:
381
+ # Em inglês porque `detail` sai no JSON e na tabela do `verify`.
382
+ return 1, b"", b"cosign did not answer within the time limit"
383
+ except OutputTooLargeError: # pragma: no cover - saida do cosign e pequena
384
+ return 1, b"", b"cosign produced more output than will be read"
385
+ except OSError as e: # pragma: no cover - falha de processo e rara
386
+ logger.debug(f"Falha ao executar cosign: {e}")
387
+ return 1, b"", str(e).encode("utf-8", errors="replace")
388
+
389
+
390
+ def _missing(reference: str) -> SignatureResult:
391
+ return SignatureResult(reference=reference, status=SignatureStatus.SIGNER_MISSING)
392
+
393
+
394
+ def _tail(stream: bytes, limit: int = 400) -> str:
395
+ """As últimas linhas do erro, cortadas. Mensagem de erro do cosign pode
396
+ conter caminho local; o corte reduz o que vaza para um log de CI."""
397
+ text = stream.decode("utf-8", errors="replace").strip()
398
+ return text[-limit:] if len(text) > limit else text
399
+
400
+
401
+ def _is_readable_output(stdout: bytes) -> bool:
402
+ """Se a saída do cosign é um documento JSON que este módulo sabe ler.
403
+
404
+ Separado de `_identities` porque uma lista válida e vazia (`[]`) e uma
405
+ saída ilegível produzem as mesmas zero identidades, e só a segunda é uma
406
+ ausência de resposta que vale a pena declarar.
407
+ """
408
+ try:
409
+ payload = json.loads(stdout.decode("utf-8", errors="replace") or "[]")
410
+ except json.JSONDecodeError:
411
+ return False
412
+ return isinstance(payload, list)
413
+
414
+
415
+ def _looks_like_a_bad_signature(message: str) -> bool:
416
+ """Se o cosign disse que a assinatura **não confere**.
417
+
418
+ Existe para separar veredito de indisponibilidade: o cosign devolve o
419
+ mesmo código de saída para "estes bytes não são os assinados" e para
420
+ "não consegui falar com o Rekor", e tratar o segundo como o primeiro
421
+ abortaria instalações por causa de uma rede instável.
422
+
423
+ E, na outra direção, para separar veredito de veredito: uma imagem
424
+ assinada por outra identidade não é uma imagem sem assinatura, e é esta
425
+ função -- consultada **antes** de `_looks_unsigned` -- que impede a
426
+ primeira de ser reportada como a segunda.
427
+ """
428
+ texto = (message or "").lower()
429
+ return any(
430
+ marker in texto
431
+ for marker in (
432
+ "signature verification failed",
433
+ "invalid signature",
434
+ "failed to verify signature",
435
+ "error verifying blob",
436
+ "certificate identity",
437
+ "none of the expected identities matched",
438
+ # O cosign nomeia assim a assinatura cujo certificado é válido
439
+ # mas cujo emissor não é o exigido.
440
+ "none of the expected issuers matched",
441
+ # Adulteração do payload: a assinatura existe e cobre outros bytes.
442
+ "payload hash does not match",
443
+ # Cadeia de confiança recusada -- material presente, e rejeitado.
444
+ # Deliberadamente restrito a frases que o cosign só emite depois
445
+ # de ter o certificado em mãos: um marcador largo faria uma rede
446
+ # instável abortar instalações, que é o erro simétrico.
447
+ "certificate verification failed",
448
+ )
449
+ )
450
+
451
+
452
+ def _looks_unsigned(message: str) -> bool:
453
+ lowered = message.lower()
454
+ return any(
455
+ marker in lowered
456
+ for marker in (
457
+ "no signatures found",
458
+ "no matching signatures",
459
+ "manifest unknown",
460
+ "no such file or directory: signature",
461
+ )
462
+ )
463
+
464
+
465
+ def _identities(stdout: bytes) -> tuple[str, ...]:
466
+ """Os assinantes que o cosign revelou, quando o JSON os traz."""
467
+ try:
468
+ payload = json.loads(stdout.decode("utf-8", errors="replace") or "[]")
469
+ except json.JSONDecodeError:
470
+ return ()
471
+ if not isinstance(payload, list):
472
+ return ()
473
+
474
+ found: list[str] = []
475
+ for entry in payload:
476
+ if not isinstance(entry, dict):
477
+ continue
478
+ optional = entry.get("optional")
479
+ if not isinstance(optional, dict):
480
+ continue
481
+ subject = optional.get("Subject") or optional.get("subject")
482
+ if isinstance(subject, str) and subject.strip():
483
+ found.append(subject.strip())
484
+ return tuple(dict.fromkeys(found))
File without changes