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,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