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,180 @@
|
|
|
1
|
+
"""Quantas vezes uma tag mudou de bytes debaixo de quem a usa.
|
|
2
|
+
|
|
3
|
+
`base` já sabe dizer que a tag `python:3.12-alpine` aponta hoje para um digest
|
|
4
|
+
diferente do que o Dockerfile fixou. O que ele não sabia dizer é o que essa
|
|
5
|
+
frase significa: uma tag que mudou uma vez em oito meses e uma que muda toda
|
|
6
|
+
semana pedem decisões opostas, e as duas produziam exatamente o mesmo
|
|
7
|
+
`PINNED_STALE`.
|
|
8
|
+
|
|
9
|
+
Este módulo guarda o histórico dessas observações -- digest e quando foi
|
|
10
|
+
visto -- para que a mensagem deixe de ser "mudou" e passe a ser "mudou 6 vezes
|
|
11
|
+
desde que começamos a olhar, a última há 3 dias". É a diferença entre um alerta
|
|
12
|
+
e uma medida.
|
|
13
|
+
|
|
14
|
+
Duas escolhas moldam o formato:
|
|
15
|
+
|
|
16
|
+
* **Só a mudança entra.** Observar a mesma tag no mesmo digest cem vezes não é
|
|
17
|
+
cem eventos; é um. Registrar cada consulta encheria o histórico de ruído e
|
|
18
|
+
faria "6 observações" parecer "6 mudanças".
|
|
19
|
+
* **A primeira observação nunca é descartada.** O histórico tem teto, mas
|
|
20
|
+
quando ele estoura são as do meio que caem. Perder a primeira apagaria
|
|
21
|
+
desde-quando -- e "mudou 6 vezes" sem o intervalo é um número sem unidade.
|
|
22
|
+
|
|
23
|
+
Nada aqui afirma que uma tag *não* mudou: o histórico começa na primeira vez
|
|
24
|
+
que esta ferramenta olhou, e o que aconteceu antes disso é desconhecido, não
|
|
25
|
+
ausente. `explain()` diz isso em vez de esconder.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from dataclasses import dataclass, field, replace
|
|
31
|
+
from typing import TYPE_CHECKING
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from datetime import datetime
|
|
35
|
+
|
|
36
|
+
#: Teto de observações guardadas por tag. Suficiente para enxergar uma cadência
|
|
37
|
+
#: e pequeno o bastante para o histórico de um Dockerfile inteiro caber no
|
|
38
|
+
#: cache sem virar um banco de dados por acidente.
|
|
39
|
+
MAX_OBSERVATIONS = 24
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class TagObservation:
|
|
44
|
+
"""Um digest, e quando esta ferramenta o viu pela primeira vez."""
|
|
45
|
+
|
|
46
|
+
digest: str
|
|
47
|
+
observed_at: str
|
|
48
|
+
|
|
49
|
+
def to_dict(self) -> dict[str, str]:
|
|
50
|
+
return {"digest": self.digest, "observed_at": self.observed_at}
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def from_dict(raw: object) -> TagObservation | None:
|
|
54
|
+
"""Uma observação vinda do cache, ou `None` se a linha não presta.
|
|
55
|
+
|
|
56
|
+
O cache é conteúdo de fora do processo: uma entrada corrompida ou
|
|
57
|
+
gravada por uma versão anterior não deve derrubar um `dockerls base`.
|
|
58
|
+
"""
|
|
59
|
+
if not isinstance(raw, dict):
|
|
60
|
+
return None
|
|
61
|
+
digest = raw.get("digest")
|
|
62
|
+
observed = raw.get("observed_at")
|
|
63
|
+
if not isinstance(digest, str) or not isinstance(observed, str):
|
|
64
|
+
return None
|
|
65
|
+
if not digest.strip():
|
|
66
|
+
return None
|
|
67
|
+
return TagObservation(digest=digest.strip(), observed_at=observed.strip())
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class TagHistory:
|
|
72
|
+
"""O que se sabe sobre os movimentos de uma tag, em ordem cronológica."""
|
|
73
|
+
|
|
74
|
+
reference: str
|
|
75
|
+
observations: tuple[TagObservation, ...] = field(default_factory=tuple)
|
|
76
|
+
#: Movimentos que já não têm observação guardada, por terem caído no teto.
|
|
77
|
+
#: Sem este contador a poda faria a contagem regredir, e uma tag que muda
|
|
78
|
+
#: toda semana -- justamente a que mais importa -- apareceria como a mais
|
|
79
|
+
#: estável de todas assim que passasse de `MAX_OBSERVATIONS`.
|
|
80
|
+
dropped: int = 0
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def moves(self) -> int:
|
|
84
|
+
"""Quantas vezes a tag trocou de digest desde a primeira observação.
|
|
85
|
+
|
|
86
|
+
Uma única observação é zero movimentos: é o ponto de partida, não um
|
|
87
|
+
evento.
|
|
88
|
+
"""
|
|
89
|
+
return max(len(self.observations) - 1, 0) + self.dropped
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def current_digest(self) -> str:
|
|
93
|
+
return self.observations[-1].digest if self.observations else ""
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def first_seen(self) -> str:
|
|
97
|
+
return self.observations[0].observed_at if self.observations else ""
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def last_moved_at(self) -> str:
|
|
101
|
+
"""Quando a tag mudou pela última vez, ou "" se ela nunca mudou aqui."""
|
|
102
|
+
return self.observations[-1].observed_at if self.moves else ""
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def is_empty(self) -> bool:
|
|
106
|
+
return not self.observations
|
|
107
|
+
|
|
108
|
+
def explain(self) -> str:
|
|
109
|
+
"""A frase que transforma o histórico numa decisão."""
|
|
110
|
+
if self.is_empty:
|
|
111
|
+
return "first time this tag is observed: there is no history to compare against"
|
|
112
|
+
if not self.moves:
|
|
113
|
+
return (
|
|
114
|
+
f"observed since {self.first_seen}, always on the same digest -- what "
|
|
115
|
+
"happened before that is unknown, not absent"
|
|
116
|
+
)
|
|
117
|
+
times = "time" if self.moves == 1 else "times"
|
|
118
|
+
return (
|
|
119
|
+
f"changed digest {self.moves} {times} since {self.first_seen}, "
|
|
120
|
+
f"the last on {self.last_moved_at}"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def to_dict(self) -> dict[str, object]:
|
|
124
|
+
return {
|
|
125
|
+
"reference": self.reference,
|
|
126
|
+
"moves": self.moves,
|
|
127
|
+
"dropped_observations": self.dropped,
|
|
128
|
+
"first_seen": self.first_seen,
|
|
129
|
+
"last_moved_at": self.last_moved_at,
|
|
130
|
+
"explanation": self.explain(),
|
|
131
|
+
"observations": [o.to_dict() for o in self.observations],
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
def from_dict(reference: str, raw: object) -> TagHistory:
|
|
136
|
+
"""Reconstrói do cache, descartando em silêncio o que não presta.
|
|
137
|
+
|
|
138
|
+
Um histórico ilegível vira um histórico vazio -- que é honesto: não se
|
|
139
|
+
sabe nada sobre esta tag. Nunca uma exceção: o histórico é um extra
|
|
140
|
+
sobre o diagnóstico, e não pode ser o que impede o diagnóstico.
|
|
141
|
+
"""
|
|
142
|
+
if not isinstance(raw, dict):
|
|
143
|
+
return TagHistory(reference=reference)
|
|
144
|
+
entries = raw.get("observations")
|
|
145
|
+
if not isinstance(entries, list):
|
|
146
|
+
return TagHistory(reference=reference)
|
|
147
|
+
parsed = [o for o in (TagObservation.from_dict(e) for e in entries) if o is not None]
|
|
148
|
+
dropped = raw.get("dropped_observations")
|
|
149
|
+
return TagHistory(
|
|
150
|
+
reference=reference,
|
|
151
|
+
observations=tuple(parsed),
|
|
152
|
+
dropped=dropped if isinstance(dropped, int) and dropped >= 0 else 0,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def record(history: TagHistory, digest: str, observed_at: datetime) -> TagHistory:
|
|
157
|
+
"""O histórico com esta observação incorporada.
|
|
158
|
+
|
|
159
|
+
Digest vazio não entra: "não foi possível perguntar ao registry" não é uma
|
|
160
|
+
observação de nada, e gravá-lo como se fosse inventaria um movimento que
|
|
161
|
+
nunca houve.
|
|
162
|
+
"""
|
|
163
|
+
digest = digest.strip()
|
|
164
|
+
if not digest:
|
|
165
|
+
return history
|
|
166
|
+
if history.observations and history.observations[-1].digest == digest:
|
|
167
|
+
return history
|
|
168
|
+
|
|
169
|
+
observation = TagObservation(
|
|
170
|
+
digest=digest, observed_at=observed_at.isoformat(timespec="seconds")
|
|
171
|
+
)
|
|
172
|
+
entries = (*history.observations, observation)
|
|
173
|
+
dropped = history.dropped
|
|
174
|
+
if len(entries) > MAX_OBSERVATIONS:
|
|
175
|
+
# A primeira sobrevive porque é ela que ancora o "desde quando"; quem
|
|
176
|
+
# cai é o meio. O que cai vira contagem em `dropped`, para que podar o
|
|
177
|
+
# histórico nunca faça a tag parecer mais estável do que ela é.
|
|
178
|
+
dropped += len(entries) - MAX_OBSERVATIONS
|
|
179
|
+
entries = (entries[0], *entries[-(MAX_OBSERVATIONS - 1) :])
|
|
180
|
+
return replace(history, observations=entries, dropped=dropped)
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Qual artefato de release baixar, para qual plataforma.
|
|
2
|
+
|
|
3
|
+
Puro de propósito: a escolha do asset é uma regra sobre nomes, e uma regra
|
|
4
|
+
sobre nomes se testa exaustivamente sem rede, sem disco e sem instalar nada.
|
|
5
|
+
O download, a verificação e a extração vivem em `infrastructure/toolchain`,
|
|
6
|
+
que é onde o I/O pertence.
|
|
7
|
+
|
|
8
|
+
Os nomes não são adivinhados, e foram confirmados por duas fontes
|
|
9
|
+
independentes: a configuração de release de cada projeto (`goreleaser.yml` no
|
|
10
|
+
Trivy, `.goreleaser.yaml` no Grype), que produz os arquivos, e o `install.sh`
|
|
11
|
+
oficial de cada um, que os consome.
|
|
12
|
+
|
|
13
|
+
Trivy contrib/install.sh:390
|
|
14
|
+
NAME=${PROJECT_NAME}_${VERSION}_${OS}-${ARCH}
|
|
15
|
+
CHECKSUM=${PROJECT_NAME}_${VERSION}_checksums.txt
|
|
16
|
+
adjust_os: linux -> Linux, darwin -> macOS, windows inalterado
|
|
17
|
+
adjust_arch: amd64 -> 64bit, arm64 -> ARM64
|
|
18
|
+
|
|
19
|
+
Grype install.sh:432 (search_for_asset)
|
|
20
|
+
asset_glob="${name}_.*_${os}_${arch}.${format}"
|
|
21
|
+
checksums: "${name}_${version}_checksums.txt"
|
|
22
|
+
os/arch vêm de `uname` em minúsculas, sem tradução
|
|
23
|
+
|
|
24
|
+
Os dois usam `zip` no Windows e `tar.gz` no resto.
|
|
25
|
+
|
|
26
|
+
Uma plataforma sem artefato conhecido devolve None, e o comando recusa com
|
|
27
|
+
uma mensagem em vez de tentar uma URL genérica que daria 404 depois do
|
|
28
|
+
consentimento do usuário.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from enum import StrEnum
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class OS(StrEnum):
|
|
38
|
+
LINUX = "linux"
|
|
39
|
+
WINDOWS = "windows"
|
|
40
|
+
MACOS = "macos"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Arch(StrEnum):
|
|
44
|
+
AMD64 = "amd64"
|
|
45
|
+
ARM64 = "arm64"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def detect_os(system: str) -> OS | None:
|
|
49
|
+
"""Traduz `platform.system()` para o enum, ou None se não suportado.
|
|
50
|
+
|
|
51
|
+
Recusar explicitamente é o ponto: um SO desconhecido tem que falhar com
|
|
52
|
+
mensagem, não escorregar para o caminho do Linux e baixar um binário
|
|
53
|
+
que não roda ali.
|
|
54
|
+
"""
|
|
55
|
+
match system.strip().lower():
|
|
56
|
+
case "linux":
|
|
57
|
+
return OS.LINUX
|
|
58
|
+
case "windows":
|
|
59
|
+
return OS.WINDOWS
|
|
60
|
+
case "darwin":
|
|
61
|
+
return OS.MACOS
|
|
62
|
+
case _:
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def detect_arch(machine: str) -> Arch | None:
|
|
67
|
+
"""Traduz `platform.machine()`, cujos rótulos variam por SO.
|
|
68
|
+
|
|
69
|
+
`x86_64` no Linux e `AMD64` no Windows são a mesma arquitetura, e
|
|
70
|
+
`aarch64`/`arm64` também.
|
|
71
|
+
"""
|
|
72
|
+
match machine.strip().lower():
|
|
73
|
+
case "x86_64" | "amd64" | "x64":
|
|
74
|
+
return Arch.AMD64
|
|
75
|
+
case "aarch64" | "arm64":
|
|
76
|
+
return Arch.ARM64
|
|
77
|
+
case _:
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class ReleaseAsset:
|
|
83
|
+
"""O que baixar e onde conferir, para uma versão e plataforma."""
|
|
84
|
+
|
|
85
|
+
#: URL do arquivo compactado com o binário.
|
|
86
|
+
archive_url: str
|
|
87
|
+
#: URL do arquivo de checksums que cobre esse arquivo.
|
|
88
|
+
checksums_url: str
|
|
89
|
+
#: Nome do arquivo dentro do `checksums.txt`, que é por onde a linha
|
|
90
|
+
#: correta é localizada.
|
|
91
|
+
archive_name: str
|
|
92
|
+
#: Nome do binário depois de extraído, com a extensão do SO.
|
|
93
|
+
binary_name: str
|
|
94
|
+
#: A página de release, para o usuário conferir de onde isto vem antes
|
|
95
|
+
#: de consentir.
|
|
96
|
+
release_url: str
|
|
97
|
+
#: Assinatura cosign do `checksums.txt`, quando o projeto publica uma.
|
|
98
|
+
#: Vazio quando não publica -- e vazio nunca significa "não assinado":
|
|
99
|
+
#: significa que este catálogo não sabe de nenhuma, que é ausência de
|
|
100
|
+
#: informação e não veredito.
|
|
101
|
+
checksums_signature_url: str = ""
|
|
102
|
+
#: Certificado keyless que acompanha a assinatura.
|
|
103
|
+
checksums_certificate_url: str = ""
|
|
104
|
+
#: Regex da identidade que precisa ter assinado, e emissor OIDC
|
|
105
|
+
#: esperado. Vazios quando não há assinatura a conferir.
|
|
106
|
+
signer_identity_pattern: str = ""
|
|
107
|
+
signer_oidc_issuer: str = ""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class ToolSpec:
|
|
112
|
+
"""Um scanner e como o projeto dele publica os releases."""
|
|
113
|
+
|
|
114
|
+
name: str
|
|
115
|
+
owner: str
|
|
116
|
+
repo: str
|
|
117
|
+
#: Rótulos de SO no nome do arquivo, por plataforma.
|
|
118
|
+
os_labels: dict[OS, str]
|
|
119
|
+
#: Rótulos de arquitetura no nome do arquivo.
|
|
120
|
+
arch_labels: dict[Arch, str]
|
|
121
|
+
#: Separador **entre SO e arquitetura**: o Trivy usa `-`
|
|
122
|
+
#: (`Linux-64bit`), o Grype `_` (`linux_amd64`). O separador entre
|
|
123
|
+
#: projeto, versão e plataforma é `_` nos dois -- é o que o goreleaser
|
|
124
|
+
#: emite -- e não é configurável aqui.
|
|
125
|
+
separator: str
|
|
126
|
+
#: Plataformas para as quais o projeto publica artefato.
|
|
127
|
+
supported: frozenset[tuple[OS, Arch]]
|
|
128
|
+
#: Se o projeto publica `checksums.txt.sig` e `checksums.txt.pem` ao
|
|
129
|
+
#: lado do `checksums.txt`, que é o par que o `cosign verify-blob`
|
|
130
|
+
#: consome.
|
|
131
|
+
#:
|
|
132
|
+
#: A cadeia inteira depende disto e de mais nada: a assinatura prova
|
|
133
|
+
#: que o `checksums.txt` é o que o projeto publicou, e o SHA-256 do
|
|
134
|
+
#: arquivo compactado contra a linha correspondente prova que o
|
|
135
|
+
#: arquivo é o que aquele `checksums.txt` descreve.
|
|
136
|
+
#:
|
|
137
|
+
#: `False` significa **não confirmado por este catálogo**, e não "não
|
|
138
|
+
#: assinado": ninguém aqui foi ao release ver. A instalação segue com
|
|
139
|
+
#: o checksum, e `signature_verified` fica `None` -- ausência de
|
|
140
|
+
#: verificação, dita como ausência.
|
|
141
|
+
signs_checksums: bool = False
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def repo_url(self) -> str:
|
|
145
|
+
return f"https://github.com/{self.owner}/{self.repo}"
|
|
146
|
+
|
|
147
|
+
def supports(self, os_: OS, arch: Arch) -> bool:
|
|
148
|
+
return (os_, arch) in self.supported
|
|
149
|
+
|
|
150
|
+
def asset_for(self, version: str, os_: OS, arch: Arch) -> ReleaseAsset | None:
|
|
151
|
+
"""O artefato desta versão para esta plataforma, ou None.
|
|
152
|
+
|
|
153
|
+
`version` vem sem o `v` inicial: as tags são `v0.58.1` e os arquivos
|
|
154
|
+
dentro do release são `trivy_0.58.1_...`.
|
|
155
|
+
"""
|
|
156
|
+
if not self.supports(os_, arch):
|
|
157
|
+
return None
|
|
158
|
+
clean = version.lstrip("v")
|
|
159
|
+
if not clean:
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
extension = "zip" if os_ is OS.WINDOWS else "tar.gz"
|
|
163
|
+
platform_label = f"{self.os_labels[os_]}{self.separator}{self.arch_labels[arch]}"
|
|
164
|
+
archive = f"{self.repo}_{clean}_{platform_label}.{extension}"
|
|
165
|
+
# O goreleaser sempre nomeia o checksum com `_`, mesmo quando o
|
|
166
|
+
# template do arquivo usa outro separador.
|
|
167
|
+
checksums = f"{self.repo}_{clean}_checksums.txt"
|
|
168
|
+
base = f"{self.repo_url}/releases/download/v{clean}"
|
|
169
|
+
return ReleaseAsset(
|
|
170
|
+
archive_url=f"{base}/{archive}",
|
|
171
|
+
checksums_url=f"{base}/{checksums}",
|
|
172
|
+
archive_name=archive,
|
|
173
|
+
binary_name=f"{self.name}.exe" if os_ is OS.WINDOWS else self.name,
|
|
174
|
+
release_url=f"{self.repo_url}/releases/tag/v{clean}",
|
|
175
|
+
checksums_signature_url=(f"{base}/{checksums}.sig" if self.signs_checksums else ""),
|
|
176
|
+
checksums_certificate_url=(f"{base}/{checksums}.pem" if self.signs_checksums else ""),
|
|
177
|
+
# A identidade tem de ser deste projeto: uma assinatura válida
|
|
178
|
+
# de *outra* workflow do GitHub prova que alguém assinou, e não
|
|
179
|
+
# que quem publica esta ferramenta assinou. O emissor é o do
|
|
180
|
+
# OIDC do GitHub Actions, que é como os dois projetos assinam.
|
|
181
|
+
signer_identity_pattern=(
|
|
182
|
+
rf"^https://github\.com/{self.owner}/{self.repo}/" if self.signs_checksums else ""
|
|
183
|
+
),
|
|
184
|
+
signer_oidc_issuer=(
|
|
185
|
+
"https://token.actions.githubusercontent.com" if self.signs_checksums else ""
|
|
186
|
+
),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
_EVERY_PLATFORM = frozenset(
|
|
191
|
+
{
|
|
192
|
+
(OS.LINUX, Arch.AMD64),
|
|
193
|
+
(OS.LINUX, Arch.ARM64),
|
|
194
|
+
(OS.WINDOWS, Arch.AMD64),
|
|
195
|
+
(OS.MACOS, Arch.AMD64),
|
|
196
|
+
(OS.MACOS, Arch.ARM64),
|
|
197
|
+
}
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
#: Rótulos conforme `goreleaser.yml` do Trivy: CamelCase para o SO,
|
|
201
|
+
#: `64bit`/`ARM64` para a arquitetura, separados por `-`. Windows não está
|
|
202
|
+
#: na lista de casos especiais do template, então cai no `.Os` cru, em
|
|
203
|
+
#: minúsculas.
|
|
204
|
+
TRIVY = ToolSpec(
|
|
205
|
+
name="trivy",
|
|
206
|
+
owner="aquasecurity",
|
|
207
|
+
repo="trivy",
|
|
208
|
+
os_labels={OS.LINUX: "Linux", OS.WINDOWS: "windows", OS.MACOS: "macOS"},
|
|
209
|
+
arch_labels={Arch.AMD64: "64bit", Arch.ARM64: "ARM64"},
|
|
210
|
+
separator="-",
|
|
211
|
+
supported=_EVERY_PLATFORM,
|
|
212
|
+
# Não confirmado: o release do Trivy não foi inspecionado a partir
|
|
213
|
+
# deste ambiente, e afirmar que ele assina (ou que não assina) seria
|
|
214
|
+
# inventar. Fica `False`, a instalação segue pelo checksum, e
|
|
215
|
+
# `signature_verified` volta `None` -- ausência de verificação, dita
|
|
216
|
+
# como ausência.
|
|
217
|
+
signs_checksums=False,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
#: O `.goreleaser.yaml` do Grype não declara `name_template`, então vale o
|
|
221
|
+
#: default do goreleaser: tudo minúsculo, separado por `_`.
|
|
222
|
+
GRYPE = ToolSpec(
|
|
223
|
+
name="grype",
|
|
224
|
+
owner="anchore",
|
|
225
|
+
repo="grype",
|
|
226
|
+
os_labels={OS.LINUX: "linux", OS.WINDOWS: "windows", OS.MACOS: "darwin"},
|
|
227
|
+
arch_labels={Arch.AMD64: "amd64", Arch.ARM64: "arm64"},
|
|
228
|
+
separator="_",
|
|
229
|
+
supported=_EVERY_PLATFORM,
|
|
230
|
+
# Confirmado no `install.sh` do próprio Grype, que baixa
|
|
231
|
+
# `checksums.txt.sig` e `checksums.txt.pem` ao lado do `checksums.txt`
|
|
232
|
+
# e chama `cosign verify-blob` com os três.
|
|
233
|
+
signs_checksums=True,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
#: Os scanners que `doctor --install` sabe instalar, na ordem em que são
|
|
237
|
+
#: oferecidos. Trivy primeiro porque é o scanner primário do projeto.
|
|
238
|
+
INSTALLABLE: tuple[ToolSpec, ...] = (TRIVY, GRYPE)
|
|
239
|
+
|
|
240
|
+
#: Versão usada quando `releases/latest` não pôde ser consultada -- um proxy
|
|
241
|
+
#: ou allowlist restritivo devolve 403 para `api.github.com` em muitos
|
|
242
|
+
#: ambientes de CI enquanto `github.com/.../releases/download/...` (o único
|
|
243
|
+
#: outro host que este instalador toca) segue liberado, porque é o mesmo
|
|
244
|
+
#: host que serve o próprio binário. Sem isto, `doctor --install` travava o
|
|
245
|
+
#: onboarding inteiro por causa de um endpoint que nem baixa nada.
|
|
246
|
+
#:
|
|
247
|
+
#: Atualizada manualmente de vez em quando; não precisa ser a última versão
|
|
248
|
+
#: publicada, só uma que existe e ainda recebe correção de CVE -- é um
|
|
249
|
+
#: piso, não a versão que `--install` busca quando a API está acessível.
|
|
250
|
+
DEFAULT_VERSIONS: dict[str, str] = {
|
|
251
|
+
"trivy": "0.58.1",
|
|
252
|
+
"grype": "0.85.0",
|
|
253
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Three-valued logic for facts about an image.
|
|
2
|
+
|
|
3
|
+
A security fact has three states, not two: true, false, and *not
|
|
4
|
+
determined*. Collapsing the third into `False` is the single most dangerous
|
|
5
|
+
simplification available here, because every consumer downstream reads
|
|
6
|
+
`has_shell = False` as "this image has no shell" -- a hardening claim -- when
|
|
7
|
+
all that happened is that nobody looked.
|
|
8
|
+
|
|
9
|
+
The whole hardening and attack-surface analysis is therefore built on this
|
|
10
|
+
type rather than on `bool`, and an unknown never earns credit: a fact that
|
|
11
|
+
could not be determined contributes nothing to a score in either direction,
|
|
12
|
+
and is reported as `unknown` in the table and in `--format json`.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from enum import StrEnum
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Tristate(StrEnum):
|
|
21
|
+
TRUE = "true"
|
|
22
|
+
FALSE = "false"
|
|
23
|
+
UNKNOWN = "unknown"
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def of(cls, value: bool | None) -> Tristate:
|
|
27
|
+
"""Lift an optional bool, mapping `None` to UNKNOWN.
|
|
28
|
+
|
|
29
|
+
Use this at every boundary where a source may simply not answer --
|
|
30
|
+
`dict.get()` on a registry payload, an absent catalogue key -- so the
|
|
31
|
+
absence stays visible instead of becoming a false.
|
|
32
|
+
"""
|
|
33
|
+
if value is None:
|
|
34
|
+
return cls.UNKNOWN
|
|
35
|
+
return cls.TRUE if value else cls.FALSE
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def is_known(self) -> bool:
|
|
39
|
+
return self is not Tristate.UNKNOWN
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def is_true(self) -> bool:
|
|
43
|
+
return self is Tristate.TRUE
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def is_false(self) -> bool:
|
|
47
|
+
return self is Tristate.FALSE
|