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.
- dockerls/__init__.py +31 -0
- dockerls/application/__init__.py +0 -0
- dockerls/application/dto/__init__.py +3 -0
- dockerls/application/dto/analysis.py +257 -0
- dockerls/application/services/__init__.py +0 -0
- dockerls/application/services/alternatives_lookup.py +167 -0
- dockerls/application/services/composite_repository.py +106 -0
- dockerls/application/services/cross_validation.py +233 -0
- dockerls/application/services/ecosystems.py +350 -0
- dockerls/application/services/fallback_scanner.py +97 -0
- dockerls/application/services/hardening_analysis.py +174 -0
- dockerls/application/services/migration.py +297 -0
- dockerls/application/services/progress.py +56 -0
- dockerls/application/services/remediation.py +321 -0
- dockerls/application/services/scan_history_store.py +88 -0
- dockerls/application/services/scanner_factory.py +88 -0
- dockerls/application/services/source_registry.py +151 -0
- dockerls/application/services/tag_history_store.py +76 -0
- dockerls/application/services/teardown.py +50 -0
- dockerls/application/services/verdict.py +298 -0
- dockerls/application/services/version_discovery.py +108 -0
- dockerls/application/use_cases/__init__.py +0 -0
- dockerls/application/use_cases/analyze_dockerfile.py +103 -0
- dockerls/application/use_cases/analyze_image.py +173 -0
- dockerls/application/use_cases/build_image.py +1795 -0
- dockerls/application/use_cases/compare_images.py +91 -0
- dockerls/application/use_cases/fleet_scan.py +240 -0
- dockerls/application/use_cases/recommend_images.py +1078 -0
- dockerls/application/use_cases/registry_audit.py +133 -0
- dockerls/application/use_cases/search_images.py +23 -0
- dockerls/application/use_cases/upgrade_base.py +167 -0
- dockerls/cache/__init__.py +0 -0
- dockerls/cache/sqlite_cache.py +184 -0
- dockerls/cli/__init__.py +0 -0
- dockerls/cli/analysis_baseline.py +98 -0
- dockerls/cli/app.py +294 -0
- dockerls/cli/commands/__init__.py +0 -0
- dockerls/cli/commands/advisor.py +262 -0
- dockerls/cli/commands/alternatives.py +291 -0
- dockerls/cli/commands/analyze.py +429 -0
- dockerls/cli/commands/analyze_dockerfile.py +104 -0
- dockerls/cli/commands/base_cmd.py +244 -0
- dockerls/cli/commands/base_image.py +551 -0
- dockerls/cli/commands/build.py +1300 -0
- dockerls/cli/commands/cache_cmd.py +104 -0
- dockerls/cli/commands/compare.py +177 -0
- dockerls/cli/commands/controls.py +110 -0
- dockerls/cli/commands/doctor.py +566 -0
- dockerls/cli/commands/export.py +81 -0
- dockerls/cli/commands/fleet.py +159 -0
- dockerls/cli/commands/health.py +84 -0
- dockerls/cli/commands/login.py +53 -0
- dockerls/cli/commands/policy_cmd.py +111 -0
- dockerls/cli/commands/provenance_cmd.py +162 -0
- dockerls/cli/commands/recommend.py +761 -0
- dockerls/cli/commands/registry_audit_cmd.py +103 -0
- dockerls/cli/commands/sbom.py +144 -0
- dockerls/cli/commands/search.py +86 -0
- dockerls/cli/commands/verify.py +115 -0
- dockerls/cli/commands/version.py +12 -0
- dockerls/cli/commands/vex_cmd.py +117 -0
- dockerls/cli/dependencies.py +530 -0
- dockerls/cli/image_names.py +79 -0
- dockerls/cli/options.py +42 -0
- dockerls/cli/progress.py +145 -0
- dockerls/cli/publish_prompt.py +123 -0
- dockerls/cli/rendering.py +214 -0
- dockerls/cli/runtime.py +65 -0
- dockerls/cli/scan_failure.py +71 -0
- dockerls/cli/text.py +39 -0
- dockerls/cli/validators.py +35 -0
- dockerls/cli/vulnerability_view.py +154 -0
- dockerls/domain/__init__.py +0 -0
- dockerls/domain/entities/__init__.py +75 -0
- dockerls/domain/entities/declared_metadata.py +147 -0
- dockerls/domain/entities/dockerfile_analysis.py +318 -0
- dockerls/domain/entities/image.py +109 -0
- dockerls/domain/entities/image_facts.py +137 -0
- dockerls/domain/entities/recommendation.py +33 -0
- dockerls/domain/entities/scan_result.py +129 -0
- dockerls/domain/entities/vulnerability.py +232 -0
- dockerls/domain/interfaces/__init__.py +17 -0
- dockerls/domain/interfaces/cache_store.py +18 -0
- dockerls/domain/interfaces/dockerfile_validator.py +99 -0
- dockerls/domain/interfaces/eol_checker.py +11 -0
- dockerls/domain/interfaces/image_repository.py +15 -0
- dockerls/domain/interfaces/scanner.py +15 -0
- dockerls/domain/security_controls.py +362 -0
- dockerls/domain/value_objects/__init__.py +49 -0
- dockerls/domain/value_objects/attack_surface.py +198 -0
- dockerls/domain/value_objects/base_recipe.py +600 -0
- dockerls/domain/value_objects/base_upgrade.py +292 -0
- dockerls/domain/value_objects/build_labels.py +99 -0
- dockerls/domain/value_objects/build_policy.py +412 -0
- dockerls/domain/value_objects/confidence.py +156 -0
- dockerls/domain/value_objects/fleet.py +174 -0
- dockerls/domain/value_objects/gate.py +327 -0
- dockerls/domain/value_objects/hardening.py +303 -0
- dockerls/domain/value_objects/image_reference.py +90 -0
- dockerls/domain/value_objects/inheritance.py +352 -0
- dockerls/domain/value_objects/network_policy.py +280 -0
- dockerls/domain/value_objects/production_readiness.py +145 -0
- dockerls/domain/value_objects/provenance.py +211 -0
- dockerls/domain/value_objects/recipe_diff.py +188 -0
- dockerls/domain/value_objects/registry_audit.py +195 -0
- dockerls/domain/value_objects/registry_target.py +225 -0
- dockerls/domain/value_objects/remediation_score.py +62 -0
- dockerls/domain/value_objects/scan_history.py +183 -0
- dockerls/domain/value_objects/scan_plan.py +193 -0
- dockerls/domain/value_objects/scanner_db.py +143 -0
- dockerls/domain/value_objects/security_score.py +160 -0
- dockerls/domain/value_objects/security_tier.py +122 -0
- dockerls/domain/value_objects/tag_history.py +180 -0
- dockerls/domain/value_objects/tool_release.py +253 -0
- dockerls/domain/value_objects/tristate.py +47 -0
- dockerls/domain/value_objects/vex.py +249 -0
- dockerls/exit_codes.py +23 -0
- dockerls/exporters/__init__.py +0 -0
- dockerls/exporters/base.py +17 -0
- dockerls/exporters/csv_exporter.py +85 -0
- dockerls/exporters/factory.py +31 -0
- dockerls/exporters/html_exporter.py +105 -0
- dockerls/exporters/json_exporter.py +19 -0
- dockerls/exporters/markdown_exporter.py +84 -0
- dockerls/exporters/sarif_exporter.py +245 -0
- dockerls/infrastructure/__init__.py +0 -0
- dockerls/infrastructure/config/__init__.py +0 -0
- dockerls/infrastructure/config/policy_file.py +165 -0
- dockerls/infrastructure/config/settings.py +197 -0
- dockerls/infrastructure/database/__init__.py +0 -0
- dockerls/infrastructure/database/models.py +78 -0
- dockerls/infrastructure/dockerfile_validator.py +1899 -0
- dockerls/infrastructure/evidence.py +99 -0
- dockerls/infrastructure/hashing.py +165 -0
- dockerls/infrastructure/logging/__init__.py +0 -0
- dockerls/infrastructure/logging/setup.py +98 -0
- dockerls/infrastructure/network/__init__.py +0 -0
- dockerls/infrastructure/network/guarded_client.py +107 -0
- dockerls/infrastructure/network/host_guard.py +117 -0
- dockerls/infrastructure/redaction.py +135 -0
- dockerls/infrastructure/templates/hardening/alpine.dockerfile +44 -0
- dockerls/infrastructure/templates/hardening/debian.dockerfile +45 -0
- dockerls/infrastructure/templates/hardening/distroless.dockerfile +34 -0
- dockerls/infrastructure/templates/hardening/go-alpine.dockerfile +52 -0
- dockerls/infrastructure/templates/hardening/go-debian.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/go-distroless.dockerfile +43 -0
- dockerls/infrastructure/templates/hardening/go-scratch.dockerfile +48 -0
- dockerls/infrastructure/templates/hardening/go.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/gradle-alpine.dockerfile +51 -0
- dockerls/infrastructure/templates/hardening/gradle.dockerfile +52 -0
- dockerls/infrastructure/templates/hardening/java-alpine.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/java-debian.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/java-distroless.dockerfile +39 -0
- dockerls/infrastructure/templates/hardening/java-ubuntu.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/java.dockerfile +60 -0
- dockerls/infrastructure/templates/hardening/maven-alpine.dockerfile +56 -0
- dockerls/infrastructure/templates/hardening/maven.dockerfile +57 -0
- dockerls/infrastructure/templates/hardening/node-alpine.dockerfile +47 -0
- dockerls/infrastructure/templates/hardening/node-debian.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/node-distroless.dockerfile +46 -0
- dockerls/infrastructure/templates/hardening/node-ubuntu.dockerfile +63 -0
- dockerls/infrastructure/templates/hardening/node.dockerfile +61 -0
- dockerls/infrastructure/templates/hardening/php-alpine.dockerfile +45 -0
- dockerls/infrastructure/templates/hardening/php-debian.dockerfile +45 -0
- dockerls/infrastructure/templates/hardening/php-ubuntu.dockerfile +49 -0
- dockerls/infrastructure/templates/hardening/php.dockerfile +44 -0
- dockerls/infrastructure/templates/hardening/python-alpine.dockerfile +51 -0
- dockerls/infrastructure/templates/hardening/python-debian.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/python-distroless.dockerfile +51 -0
- dockerls/infrastructure/templates/hardening/python-ubuntu.dockerfile +60 -0
- dockerls/infrastructure/templates/hardening/python.dockerfile +58 -0
- dockerls/infrastructure/templates/hardening/ruby-alpine.dockerfile +48 -0
- dockerls/infrastructure/templates/hardening/ruby-debian.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/rust-alpine.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/rust-debian.dockerfile +48 -0
- dockerls/infrastructure/templates/hardening/rust-scratch.dockerfile +44 -0
- dockerls/infrastructure/templates/hardening/rust.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/ubuntu.dockerfile +49 -0
- dockerls/infrastructure/toolchain/__init__.py +0 -0
- dockerls/infrastructure/toolchain/db_metadata.py +115 -0
- dockerls/infrastructure/toolchain/installer.py +435 -0
- dockerls/integrations/__init__.py +0 -0
- dockerls/integrations/dhi/__init__.py +0 -0
- dockerls/integrations/dhi/catalog.py +457 -0
- dockerls/integrations/dhi/definition.py +151 -0
- dockerls/integrations/dhi/repository.py +238 -0
- dockerls/integrations/dockerhub/__init__.py +0 -0
- dockerls/integrations/dockerhub/client.py +318 -0
- dockerls/integrations/dockerhub/urls.py +75 -0
- dockerls/integrations/endoflife/__init__.py +0 -0
- dockerls/integrations/endoflife/checker.py +216 -0
- dockerls/integrations/engine/__init__.py +0 -0
- dockerls/integrations/engine/batch.py +197 -0
- dockerls/integrations/engine/client.py +330 -0
- dockerls/integrations/engine/locator.py +96 -0
- dockerls/integrations/exploitdb/__init__.py +0 -0
- dockerls/integrations/exploitdb/client.py +271 -0
- dockerls/integrations/grype/__init__.py +0 -0
- dockerls/integrations/grype/scanner.py +336 -0
- dockerls/integrations/registry/__init__.py +0 -0
- dockerls/integrations/registry/hardened.py +284 -0
- dockerls/integrations/registry/inspector.py +420 -0
- dockerls/integrations/registry/oci.py +259 -0
- dockerls/integrations/registry/private.py +79 -0
- dockerls/integrations/registry/urls.py +36 -0
- dockerls/integrations/scan_errors.py +67 -0
- dockerls/integrations/scan_target.py +57 -0
- dockerls/integrations/signing/__init__.py +0 -0
- dockerls/integrations/signing/cosign.py +484 -0
- dockerls/integrations/threat_intel/__init__.py +0 -0
- dockerls/integrations/threat_intel/client.py +290 -0
- dockerls/integrations/trivy/__init__.py +0 -0
- dockerls/integrations/trivy/cache_pool.py +176 -0
- dockerls/integrations/trivy/scanner.py +447 -0
- dockerls/utils/__init__.py +0 -0
- dockerls/utils/auth.py +108 -0
- dockerls/utils/executables.py +39 -0
- dockerls/utils/ignore_file.py +130 -0
- dockerls/utils/rate_limit.py +130 -0
- dockerls/utils/resources.py +183 -0
- dockerls/utils/retry.py +31 -0
- dockerls/utils/safe_yaml.py +166 -0
- dockerls/utils/subprocess_runner.py +216 -0
- dockerls/utils/validation.py +72 -0
- dockerls-1.0.0.dist-info/METADATA +563 -0
- dockerls-1.0.0.dist-info/RECORD +230 -0
- dockerls-1.0.0.dist-info/WHEEL +5 -0
- dockerls-1.0.0.dist-info/entry_points.txt +2 -0
- dockerls-1.0.0.dist-info/licenses/LICENSE +21 -0
- 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
|