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,600 @@
1
+ """Montar uma imagem base a partir de escolhas, e dizer o que cada uma custa.
2
+
3
+ Uma imagem base é o piso de tudo que vem depois: cada pacote instalado aqui
4
+ existe em toda aplicação que a consome, e toda CVE dele vira trabalho de
5
+ triagem para times que nem sabem que ele está lá. É por isso que a escolha
6
+ merece uma tela em vez de um Dockerfile copiado de outro projeto -- e é por
7
+ isso que cada item deste catálogo carrega **para que serve** e **o que custa**,
8
+ lado a lado.
9
+
10
+ O catálogo não é uma lista de pacotes disponíveis; é a lista curta do que
11
+ aparece de verdade numa imagem base de produção. Oferecer tudo que existe no
12
+ repositório da distribuição transformaria a escolha em paralisia e faria as
13
+ pessoas marcarem tudo "por via das dúvidas", que é exatamente o resultado que
14
+ uma imagem base não pode ter.
15
+
16
+ Três recusas estão codificadas aqui, e todas vêm da mesma ideia -- conveniência
17
+ que se paga em superfície de ataque não é conveniência:
18
+
19
+ * **distroless não instala nada.** Não há gerenciador de pacotes nem shell na
20
+ imagem; pedir pacotes ali é um mal-entendido sobre o que distroless é, e a
21
+ resposta certa é explicar isso em vez de gerar um Dockerfile que falha.
22
+ * **`sudo` não está no catálogo.** Numa imagem que já roda sem privilégio, ele
23
+ existe para cruzar a fronteira que a imagem acabou de estabelecer.
24
+ * **o cache do gerenciador sai na mesma camada que o criou**, sempre, sem ser
25
+ uma opção. Removê-lo depois deixa os bytes na camada anterior e a imagem
26
+ carrega o peso e a superfície mesmo parecendo não carregar.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from dataclasses import dataclass, field, replace
32
+ from enum import StrEnum
33
+
34
+
35
+ class OsFamily(StrEnum):
36
+ """A distribuição base. Decide libc, gerenciador de pacotes e nomes."""
37
+
38
+ ALPINE = "alpine"
39
+ DEBIAN = "debian"
40
+ UBUNTU = "ubuntu"
41
+ DISTROLESS = "distroless"
42
+ #: Wolfi/Chainguard. Modelado como o Distroless: uma imagem pronta,
43
+ #: mínima, sem gerenciador de pacotes exposto neste menu -- não porque
44
+ #: o Wolfi careça de `apk` (ele tem, é um fork do apk-tools), mas
45
+ #: porque o catálogo gratuito da Chainguard não publica tag de versão
46
+ #: pinada nem imagem "builder + apk add" para customizar, só as tags
47
+ #: móveis (`latest`, `latest-dev`) que `hardened.py` já consome em
48
+ #: `recommend`. Tratar como apk-instalável aqui geraria um `apk add`
49
+ #: contra um índice que este projeto nunca verificou responder do
50
+ #: jeito que o Alpine responde.
51
+ WOLFI = "wolfi"
52
+
53
+ @property
54
+ def uses_apk(self) -> bool:
55
+ return self is OsFamily.ALPINE
56
+
57
+ @property
58
+ def installs_packages(self) -> bool:
59
+ """Distroless e Wolfi não oferecem pacote neste menu -- o primeiro
60
+ porque não tem gerenciador nenhum, o segundo porque só a tag móvel
61
+ pronta é servida pelo tier gratuito, sem uma imagem "builder" para
62
+ customizar sobre ela."""
63
+ return self not in (OsFamily.DISTROLESS, OsFamily.WOLFI)
64
+
65
+ @property
66
+ def libc(self) -> str:
67
+ return "musl" if self is OsFamily.ALPINE else "glibc"
68
+
69
+
70
+ class Runtime(StrEnum):
71
+ """O runtime de linguagem que a base carrega, se carregar algum."""
72
+
73
+ NONE = "none"
74
+ JAVA = "java"
75
+ NODE = "node"
76
+ PYTHON = "python"
77
+ GO = "go"
78
+ RUBY = "ruby"
79
+ PHP = "php"
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class RuntimeBase:
84
+ """A imagem oficial que serve de ponto de partida para um runtime."""
85
+
86
+ image: str
87
+ tag: str
88
+ #: Caminhos de um gerenciador de pacotes que a imagem oficial embute e que
89
+ #: uma base de *runtime* não precisa. O npm é o caso que motivou isto: ele
90
+ #: vem com a própria árvore de dependências em node_modules, fora do
91
+ #: alcance do apk, e é de onde saem quase todas as CVEs de uma imagem
92
+ #: `node:*-alpine` recém-construída.
93
+ bundled_manager: tuple[str, ...] = ()
94
+ bundled_manager_note: str = ""
95
+ #: Usuário não-root que a imagem oficial já traz, quando traz. Criar outro
96
+ #: por cima seria duplicar o que existe e confundir quem consome.
97
+ builtin_user: str = ""
98
+ note: str = ""
99
+
100
+ @property
101
+ def reference(self) -> str:
102
+ return f"{self.image}:{self.tag}"
103
+
104
+
105
+ #: (runtime, família) -> imagem base. Só combinações que existem de verdade:
106
+ #: oferecer `go` sobre distroless levaria a `gcr.io/distroless/static`, que não
107
+ #: tem runtime nenhum e é uma resposta diferente da que a pessoa pediu.
108
+ RUNTIME_BASES: dict[tuple[Runtime, OsFamily], RuntimeBase] = {
109
+ (Runtime.NONE, OsFamily.ALPINE): RuntimeBase("alpine", "3.21"),
110
+ (Runtime.NONE, OsFamily.DEBIAN): RuntimeBase("debian", "12-slim"),
111
+ (Runtime.NONE, OsFamily.UBUNTU): RuntimeBase("ubuntu", "24.04"),
112
+ (Runtime.NONE, OsFamily.DISTROLESS): RuntimeBase(
113
+ "gcr.io/distroless/base-debian12", "nonroot", builtin_user="nonroot"
114
+ ),
115
+ # `builtin_user="nonroot"`: convenção pública e estável das imagens
116
+ # Chainguard (a mesma que `gcr.io/distroless/*:nonroot` usa), não uma
117
+ # medição feita a partir deste ambiente -- este projeto não tem como
118
+ # confirmar contra `cgr.dev` daqui.
119
+ (Runtime.NONE, OsFamily.WOLFI): RuntimeBase(
120
+ "cgr.dev/chainguard/wolfi-base", "latest", builtin_user="nonroot"
121
+ ),
122
+ (Runtime.JAVA, OsFamily.ALPINE): RuntimeBase(
123
+ "eclipse-temurin",
124
+ "21-jre-alpine",
125
+ note="JRE, not JDK: a compiler and build tooling are not needed to run a jar",
126
+ ),
127
+ (Runtime.JAVA, OsFamily.DEBIAN): RuntimeBase("eclipse-temurin", "21-jre"),
128
+ (Runtime.JAVA, OsFamily.UBUNTU): RuntimeBase("eclipse-temurin", "21-jre-noble"),
129
+ (Runtime.JAVA, OsFamily.DISTROLESS): RuntimeBase(
130
+ "gcr.io/distroless/java21-debian12", "nonroot", builtin_user="nonroot"
131
+ ),
132
+ (Runtime.JAVA, OsFamily.WOLFI): RuntimeBase(
133
+ "cgr.dev/chainguard/jre", "latest", builtin_user="nonroot"
134
+ ),
135
+ (Runtime.NODE, OsFamily.ALPINE): RuntimeBase(
136
+ "node",
137
+ "22-alpine",
138
+ builtin_user="node",
139
+ note="the official image already ships the `node` user",
140
+ bundled_manager=(
141
+ "/usr/local/lib/node_modules/npm",
142
+ "/usr/local/bin/npm",
143
+ "/usr/local/bin/npx",
144
+ "/opt/yarn-*",
145
+ "/usr/local/bin/yarn",
146
+ "/usr/local/bin/yarnpkg",
147
+ ),
148
+ bundled_manager_note="npm and yarn",
149
+ ),
150
+ (Runtime.NODE, OsFamily.DEBIAN): RuntimeBase(
151
+ "node",
152
+ "22-slim",
153
+ builtin_user="node",
154
+ bundled_manager=(
155
+ "/usr/local/lib/node_modules/npm",
156
+ "/usr/local/bin/npm",
157
+ "/usr/local/bin/npx",
158
+ "/opt/yarn-*",
159
+ "/usr/local/bin/yarn",
160
+ "/usr/local/bin/yarnpkg",
161
+ ),
162
+ bundled_manager_note="npm and yarn",
163
+ ),
164
+ (Runtime.NODE, OsFamily.DISTROLESS): RuntimeBase(
165
+ "gcr.io/distroless/nodejs22-debian12", "nonroot", builtin_user="nonroot"
166
+ ),
167
+ (Runtime.NODE, OsFamily.WOLFI): RuntimeBase(
168
+ "cgr.dev/chainguard/node", "latest", builtin_user="nonroot"
169
+ ),
170
+ (Runtime.PYTHON, OsFamily.ALPINE): RuntimeBase(
171
+ "python",
172
+ "3.12-alpine",
173
+ note="musl: wheels must be musllinux or the package compiles at build time",
174
+ ),
175
+ (Runtime.PYTHON, OsFamily.DEBIAN): RuntimeBase("python", "3.12-slim-bookworm"),
176
+ (Runtime.PYTHON, OsFamily.DISTROLESS): RuntimeBase(
177
+ "gcr.io/distroless/python3-debian12", "nonroot", builtin_user="nonroot"
178
+ ),
179
+ (Runtime.PYTHON, OsFamily.WOLFI): RuntimeBase(
180
+ "cgr.dev/chainguard/python", "latest", builtin_user="nonroot"
181
+ ),
182
+ (Runtime.GO, OsFamily.ALPINE): RuntimeBase("golang", "1.23-alpine"),
183
+ (Runtime.GO, OsFamily.DEBIAN): RuntimeBase("golang", "1.23-bookworm"),
184
+ # Sem Ubuntu nem distroless para Ruby/PHP, pelo mesmo motivo que Node,
185
+ # Python e Go também não têm: nenhum dos dois publica uma imagem
186
+ # `ruby:*-ubuntu`/`php:*-ubuntu` oficial (o template `*-ubuntu` instala
187
+ # via apt sobre Ubuntu puro, um padrão multi-stage que este gerador de
188
+ # base única não modela), e o catálogo distroless do Google não publica
189
+ # runtime para nenhum dos dois.
190
+ (Runtime.RUBY, OsFamily.ALPINE): RuntimeBase("ruby", "3.3-alpine"),
191
+ (Runtime.RUBY, OsFamily.DEBIAN): RuntimeBase("ruby", "3.3-slim-bookworm"),
192
+ (Runtime.PHP, OsFamily.ALPINE): RuntimeBase("php", "8.3-cli-alpine"),
193
+ (Runtime.PHP, OsFamily.DEBIAN): RuntimeBase("php", "8.3-cli-bookworm"),
194
+ }
195
+
196
+ #: O molde da tag para cada combinação, com a versão de fora -- é o que
197
+ #: permite `--os-version`/`--runtime-version` trocar só o número e manter o
198
+ #: sufixo de família (`-alpine`, `-slim`, `-jre-noble`...) que `RUNTIME_BASES`
199
+ #: já usa. Não é uma segunda fonte de versão: as versões continuam vindo do
200
+ #: registry (`--runtime-version` explícito, ou a descoberta dinâmica), isto
201
+ #: só sabe *onde* o número entra na tag.
202
+ TAG_TEMPLATES: dict[tuple[Runtime, OsFamily], str] = {
203
+ (Runtime.NONE, OsFamily.ALPINE): "{version}",
204
+ (Runtime.NONE, OsFamily.DEBIAN): "{version}-slim",
205
+ (Runtime.NONE, OsFamily.UBUNTU): "{version}",
206
+ (Runtime.JAVA, OsFamily.ALPINE): "{version}-jre-alpine",
207
+ (Runtime.JAVA, OsFamily.DEBIAN): "{version}-jre",
208
+ (Runtime.JAVA, OsFamily.UBUNTU): "{version}-jre-noble",
209
+ (Runtime.NODE, OsFamily.ALPINE): "{version}-alpine",
210
+ (Runtime.NODE, OsFamily.DEBIAN): "{version}-slim",
211
+ (Runtime.PYTHON, OsFamily.ALPINE): "{version}-alpine",
212
+ (Runtime.PYTHON, OsFamily.DEBIAN): "{version}-slim-bookworm",
213
+ (Runtime.GO, OsFamily.ALPINE): "{version}-alpine",
214
+ (Runtime.GO, OsFamily.DEBIAN): "{version}-bookworm",
215
+ (Runtime.RUBY, OsFamily.ALPINE): "{version}-alpine",
216
+ (Runtime.RUBY, OsFamily.DEBIAN): "{version}-slim-bookworm",
217
+ (Runtime.PHP, OsFamily.ALPINE): "{version}-cli-alpine",
218
+ (Runtime.PHP, OsFamily.DEBIAN): "{version}-cli-bookworm",
219
+ }
220
+
221
+
222
+ def with_version(runtime: Runtime, family: OsFamily, version: str) -> RuntimeBase:
223
+ """O `RuntimeBase` padrão desta combinação, com a tag trocada para
224
+ `version`.
225
+
226
+ Levanta a mesma `UnsupportedCombinationError` da combinação em si
227
+ quando ela não existe, e diz que a família não tem molde de tag quando
228
+ a combinação existe mas ninguém ensinou o formato (hoje, distroless --
229
+ ele não versiona por número solto, versiona pelo runtime que embute).
230
+ """
231
+ key = (runtime, family)
232
+ if key not in RUNTIME_BASES:
233
+ raise UnsupportedCombinationError(f"no base image is published for {runtime} on {family}")
234
+ template = TAG_TEMPLATES.get(key)
235
+ if template is None:
236
+ raise UnsupportedCombinationError(
237
+ f"{family} does not take an explicit version override for {runtime}"
238
+ )
239
+ base = RUNTIME_BASES[key]
240
+ return replace(base, tag=template.format(version=version))
241
+
242
+
243
+ @dataclass(frozen=True)
244
+ class PackageChoice:
245
+ """Um pacote oferecido no menu, com o que ganha e o que custa."""
246
+
247
+ key: str
248
+ purpose: str
249
+ #: O preço em superfície de ataque, dito na hora da escolha e não depois.
250
+ cost: str
251
+ #: Nome no apk (Alpine) e no apt (Debian/Ubuntu). Divergem com frequência.
252
+ apk: str = ""
253
+ apt: str = ""
254
+ #: Já presente na maioria das bases; marcar não faz mal, mas não faz nada.
255
+ usually_present: bool = False
256
+
257
+ def package_for(self, family: OsFamily) -> str:
258
+ """O nome nesta família, ou "" quando o pacote não existe nela.
259
+
260
+ O fallback para `key` só vale quando *nenhum* nome específico foi
261
+ declarado. Aplicá-lo a um pacote que existe só numa família --
262
+ `libc6-compat`, que é do Alpine -- geraria um `apt-get install
263
+ libc6-compat` que quebra o build: o vazio aqui significa "não se
264
+ aplica", e é o que faz o menu não oferecê-lo onde não cabe.
265
+ """
266
+ if not self.apk and not self.apt:
267
+ return self.key
268
+ return self.apk if family.uses_apk else self.apt
269
+
270
+
271
+ #: O menu. Curto de propósito: uma lista longa faz as pessoas marcarem tudo
272
+ #: "por via das dúvidas", que é o pior resultado possível numa imagem base.
273
+ PACKAGE_CATALOG: tuple[PackageChoice, ...] = (
274
+ PackageChoice(
275
+ key="ca-certificates",
276
+ purpose="validating TLS when talking to any HTTPS service",
277
+ cost="practically none; without it every TLS connection fails verification",
278
+ apk="ca-certificates",
279
+ apt="ca-certificates",
280
+ usually_present=True,
281
+ ),
282
+ PackageChoice(
283
+ key="tzdata",
284
+ purpose="time zones; without it the container stays on UTC and local dates are wrong",
285
+ cost="a few MB of data, no new executable",
286
+ apk="tzdata",
287
+ apt="tzdata",
288
+ ),
289
+ PackageChoice(
290
+ key="curl",
291
+ purpose="HTTP HEALTHCHECK and network diagnostics",
292
+ cost="a full HTTP client inside the container -- what an attacker uses to "
293
+ "fetch the second stage",
294
+ apk="curl",
295
+ apt="curl",
296
+ ),
297
+ PackageChoice(
298
+ key="wget",
299
+ purpose="an alternative to curl for downloading files",
300
+ cost="the same cost as curl; having both doubles the surface, not the use",
301
+ apk="wget",
302
+ apt="wget",
303
+ ),
304
+ PackageChoice(
305
+ key="bash",
306
+ purpose="scripts relying on features the Alpine `sh` does not have",
307
+ cost="a more capable shell is a more useful shell for whoever breaks in",
308
+ apk="bash",
309
+ apt="bash",
310
+ ),
311
+ PackageChoice(
312
+ key="git",
313
+ purpose="cloning or inspecting repositories at runtime",
314
+ cost="rarely needed in production, and pulls a large dependency tree; it "
315
+ "almost always belongs in the build stage",
316
+ apk="git",
317
+ apt="git",
318
+ ),
319
+ PackageChoice(
320
+ key="jq",
321
+ purpose="parsing JSON in entrypoint scripts",
322
+ cost="small and self-contained",
323
+ apk="jq",
324
+ apt="jq",
325
+ ),
326
+ PackageChoice(
327
+ key="openssl",
328
+ purpose="generating certificates or debugging TLS from inside the container",
329
+ cost="the library is already there; this adds the command-line *tool*",
330
+ apk="openssl",
331
+ apt="openssl",
332
+ ),
333
+ PackageChoice(
334
+ key="tini",
335
+ purpose="a minimal init that forwards signals and reaps orphaned processes",
336
+ cost="almost nothing, and it fixes the pid 1 that ignores SIGTERM",
337
+ apk="tini",
338
+ apt="tini",
339
+ ),
340
+ PackageChoice(
341
+ key="libc6-compat",
342
+ purpose="a glibc compatibility layer on Alpine, for pre-compiled binaries",
343
+ cost="only makes sense on Alpine; a Debian already has real glibc",
344
+ apk="libc6-compat",
345
+ apt="",
346
+ ),
347
+ )
348
+
349
+ #: Pacotes que este catálogo recusa a oferecer, com o motivo. São recusas, não
350
+ #: omissões: alguém que procurar por eles merece a explicação.
351
+ REFUSED_PACKAGES: dict[str, str] = {
352
+ "sudo": (
353
+ "in an image that already runs unprivileged, `sudo` exists to cross the "
354
+ "boundary it just established -- and it is setuid in order to"
355
+ ),
356
+ "su-exec": (
357
+ "switching user at runtime undoes the image `USER`; if the process needs a "
358
+ "different user, declare it in `USER`"
359
+ ),
360
+ "docker": (
361
+ "the Docker client inside the container implies access to the daemon "
362
+ "socket, which is equivalent to root on the host"
363
+ ),
364
+ }
365
+
366
+
367
+ class UnsupportedCombinationError(ValueError):
368
+ """A combinação de runtime e sistema operacional não existe."""
369
+
370
+
371
+ @dataclass(frozen=True)
372
+ class BaseRecipe:
373
+ """Tudo que decide o conteúdo do Dockerfile de uma imagem base."""
374
+
375
+ family: OsFamily
376
+ runtime: Runtime = Runtime.NONE
377
+ packages: tuple[str, ...] = ()
378
+ #: Digest resolvido da base. Vazio deixa a tag móvel -- e o gerador diz
379
+ #: isso em voz alta em vez de fingir que está fixado.
380
+ digest: str = ""
381
+ title: str = "base"
382
+ description: str = ""
383
+ owner: str = ""
384
+ source: str = ""
385
+ uid: int = 10001
386
+ user_name: str = "appuser"
387
+ #: Remover o gerenciador de pacotes que a imagem oficial embute. Vale para
388
+ #: uma base de execução: as dependências que o npm carrega dentro de si
389
+ #: respondem por quase toda CVE de uma `node:*-alpine`, e nada delas é
390
+ #: necessário para *rodar* uma aplicação cujas dependências já foram
391
+ #: instaladas no estágio de build.
392
+ strip_bundled_manager: bool = False
393
+ extra: dict[str, str] = field(default_factory=dict)
394
+ #: Substitui o `RuntimeBase` do catálogo por um com outra tag -- de
395
+ #: `--os-version`/`--runtime-version`, resolvida contra o registry pelo
396
+ #: chamador. `None` é o padrão em todo lugar que não pediu isso, e
397
+ #: preserva exatamente o comportamento de sempre: o catálogo decide.
398
+ base_override: RuntimeBase | None = None
399
+
400
+ @property
401
+ def base(self) -> RuntimeBase:
402
+ if self.base_override is not None:
403
+ return self.base_override
404
+ try:
405
+ return RUNTIME_BASES[(self.runtime, self.family)]
406
+ except KeyError as e:
407
+ raise UnsupportedCombinationError(
408
+ f"no base image is published for {self.runtime} on {self.family}"
409
+ ) from e
410
+
411
+ def validate(self) -> None:
412
+ base = self.base # levanta se a combinação não existe
413
+ if self.packages and not self.family.installs_packages:
414
+ raise UnsupportedCombinationError(
415
+ f"{self.family.value} has no package manager exposed here: nothing "
416
+ "can be installed into it this way. Use alpine or debian if you need "
417
+ "packages, or no packages at all if what you want is the smallest "
418
+ "possible surface"
419
+ )
420
+ for package in self.packages:
421
+ if package in REFUSED_PACKAGES:
422
+ raise UnsupportedCombinationError(
423
+ f"{package} is not offered: {REFUSED_PACKAGES[package]}"
424
+ )
425
+ if package not in {choice.key for choice in PACKAGE_CATALOG}:
426
+ raise UnsupportedCombinationError(f"unknown package: {package}")
427
+ if base.builtin_user and self.user_name != base.builtin_user:
428
+ # Não é erro -- só não vale a pena criar um usuário quando a
429
+ # imagem oficial já traz um. O gerador reaproveita o existente.
430
+ pass
431
+
432
+
433
+ def render(recipe: BaseRecipe) -> str:
434
+ """O Dockerfile da imagem base, pronto para construir."""
435
+ recipe.validate()
436
+ base = recipe.base
437
+ lines: list[str] = ["# syntax=docker/dockerfile:1", ""]
438
+ lines += _header(recipe, base)
439
+
440
+ reference = base.reference
441
+ if recipe.digest:
442
+ lines += [
443
+ f"ARG BASE_DIGEST={recipe.digest}",
444
+ "",
445
+ f"FROM {reference}@${{BASE_DIGEST}}",
446
+ ]
447
+ else:
448
+ lines += [
449
+ "# WARNING: base not pinned by digest. What you test and what is built",
450
+ "# tomorrow can be different bytes, with no change of yours.",
451
+ f"FROM {reference}",
452
+ ]
453
+ lines.append("")
454
+ lines += _labels(recipe)
455
+ lines.append("")
456
+
457
+ if recipe.family.installs_packages:
458
+ lines += _packages(recipe)
459
+ lines.append("")
460
+
461
+ if recipe.strip_bundled_manager and base.bundled_manager:
462
+ lines += _strip_manager(base)
463
+ lines.append("")
464
+
465
+ user = base.builtin_user or recipe.user_name
466
+ if not base.builtin_user and recipe.family.installs_packages:
467
+ lines += _create_user(recipe)
468
+ lines.append("")
469
+
470
+ lines += [
471
+ "WORKDIR /app",
472
+ f"USER {user}",
473
+ "",
474
+ "# No ENTRYPOINT, EXPOSE or HEALTHCHECK: a base image does not know which",
475
+ "# port the application listens on, nor what 'healthy' means for it.",
476
+ "# Declaring them here would be inherited wrongly by every consumer.",
477
+ ]
478
+ return "\n".join(lines) + "\n"
479
+
480
+
481
+ def _header(recipe: BaseRecipe, base: RuntimeBase) -> list[str]:
482
+ lines = [
483
+ f"# BASE image generated by DockerLs: {recipe.family}"
484
+ + (f" + {recipe.runtime}" if recipe.runtime is not Runtime.NONE else ""),
485
+ "#",
486
+ "# Contains no application. Consumers do `FROM this-image` and bring",
487
+ "# their own artifact.",
488
+ ]
489
+ if base.note:
490
+ lines += ["#", f"# {base.note}."]
491
+ lines += ["#", f"# libc: {recipe.family.libc}.", ""]
492
+ return lines
493
+
494
+
495
+ def _labels(recipe: BaseRecipe) -> list[str]:
496
+ labels = {
497
+ "maintainer": recipe.owner,
498
+ "security.scanner": "dockerls",
499
+ "org.opencontainers.image.title": recipe.title,
500
+ "org.opencontainers.image.description": recipe.description,
501
+ "org.opencontainers.image.source": recipe.source,
502
+ **recipe.extra,
503
+ }
504
+ entries = [(key, value) for key, value in labels.items() if value]
505
+ if not entries:
506
+ return []
507
+ rendered = ["LABEL " + f'{entries[0][0]}="{entries[0][1]}"' + (" \\" if entries[1:] else "")]
508
+ for index, (key, value) in enumerate(entries[1:]):
509
+ suffix = " \\" if index < len(entries) - 2 else ""
510
+ rendered.append(f' {key}="{value}"{suffix}')
511
+ return rendered
512
+
513
+
514
+ def _packages(recipe: BaseRecipe) -> list[str]:
515
+ chosen = [choice for choice in PACKAGE_CATALOG if choice.key in recipe.packages]
516
+ names = sorted(
517
+ {
518
+ choice.package_for(recipe.family)
519
+ for choice in chosen
520
+ if choice.package_for(recipe.family)
521
+ }
522
+ )
523
+
524
+ comment = [
525
+ "# The digest freezes the base on the day it was published; without this line,",
526
+ "# a package fixed after that date would stay old here.",
527
+ ]
528
+ if recipe.family.uses_apk:
529
+ if not names:
530
+ return [*comment, "RUN apk upgrade --no-cache"]
531
+ pacotes = " \\\n ".join(names)
532
+ return [
533
+ *comment,
534
+ "# `--no-cache` leaves no index behind: there is nothing to clean up in",
535
+ "# a later layer, and removing the cache afterwards would still leave it",
536
+ "# behind in this layer.",
537
+ "RUN apk upgrade --no-cache && \\",
538
+ " apk add --no-cache \\",
539
+ f" {pacotes}",
540
+ ]
541
+
542
+ if not names:
543
+ return [
544
+ *comment,
545
+ "RUN apt-get update && apt-get upgrade -y --no-install-recommends && \\",
546
+ " rm -rf /var/lib/apt/lists/*",
547
+ ]
548
+ pacotes = " \\\n ".join(names)
549
+ return [
550
+ *comment,
551
+ "# The index lists go out in the layer that created them: removing them later",
552
+ "# would still leave the bytes behind in the earlier layer.",
553
+ "RUN apt-get update && apt-get upgrade -y --no-install-recommends && \\",
554
+ " apt-get install -y --no-install-recommends \\",
555
+ f" {pacotes} && \\",
556
+ " rm -rf /var/lib/apt/lists/*",
557
+ ]
558
+
559
+
560
+ def _create_user(recipe: BaseRecipe) -> list[str]:
561
+ comment = [
562
+ "# a high, fixed uid: consumers inherit the user without recreating it,",
563
+ "# and a high uid does not collide with host users on a bind mount.",
564
+ ]
565
+ if recipe.family.uses_apk:
566
+ return [
567
+ *comment,
568
+ f"RUN addgroup -g {recipe.uid} {recipe.user_name} && \\",
569
+ f" adduser -u {recipe.uid} -G {recipe.user_name} "
570
+ f"-h /home/{recipe.user_name} -s /sbin/nologin -D {recipe.user_name}",
571
+ ]
572
+ return [
573
+ *comment,
574
+ f"RUN groupadd -g {recipe.uid} {recipe.user_name} && \\",
575
+ f" useradd -u {recipe.uid} -g {recipe.user_name} "
576
+ f"-s /usr/sbin/nologin -m {recipe.user_name}",
577
+ ]
578
+
579
+
580
+ def _strip_manager(base: RuntimeBase) -> list[str]:
581
+ """Remove o gerenciador de pacotes que a imagem oficial embute.
582
+
583
+ O `apk upgrade` não alcança essas dependências: elas vivem em
584
+ `node_modules` dentro do próprio npm, não no banco de pacotes da
585
+ distribuição. Numa base de execução elas são superfície pura -- as
586
+ dependências da aplicação já foram instaladas no estágio de build de quem
587
+ consome, e nada aqui precisa instalar mais nada.
588
+
589
+ Quem *precisa* de npm em runtime (um `npm start` que resolve dependências
590
+ na subida, por exemplo) simplesmente não marca esta opção.
591
+ """
592
+ caminhos = " \\\n ".join(base.bundled_manager)
593
+ return [
594
+ f"# Removes {base.bundled_manager_note}: in a runtime base, the dependencies",
595
+ "# a package manager carries inside itself are pure surface -- and they sit",
596
+ "# outside the reach of a system upgrade, not being distribution packages.",
597
+ "USER root",
598
+ "RUN rm -rf \\",
599
+ f" {caminhos}",
600
+ ]