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,292 @@
1
+ """Ler os `FROM` de um Dockerfile e dizer o que fazer com cada um.
2
+
3
+ O `analyze-dockerfile` sabe ler o seu projeto mas não mede nada -- a sugestão
4
+ de base era uma string fixa, `"FROM node:22-alpine or FROM chainguard/node"`,
5
+ respondida igual para qualquer Dockerfile, inclusive um de Python. O
6
+ `recommend` mede de verdade, mas só funciona se alguém digitar a referência na
7
+ mão: ele nunca olha o seu Dockerfile. A metade que lê o projeto não media, e a
8
+ metade que media não lia o projeto.
9
+
10
+ Este módulo é a ponte, e é a parte pura dela: recebe o texto do Dockerfile e
11
+ os digests que alguém resolveu por fora, e responde o que cada `FROM` é hoje e
12
+ o que deveria ser. Sem rede, sem disco -- o que torna cada regra testável
13
+ contra o texto exato que a produziu.
14
+
15
+ O estado de uma base é um de quatro, e a distinção importa porque cada um pede
16
+ uma ação diferente:
17
+
18
+ * `PINNED_CURRENT` -- fixada num digest, e a tag ainda aponta para ele. Nada a
19
+ fazer.
20
+ * `PINNED_STALE` -- fixada num digest, mas a tag já aponta para outro. **Este
21
+ é o caso que apodrece em silêncio**: foi assim que uma base de 2024 ficou
22
+ parada nesta imagem carregando CVEs do `libexpat1` que já tinham correção.
23
+ * `UNPINNED` -- só uma tag. O que você testou e o que vai para produção podem
24
+ ser bytes diferentes sem nenhuma mudança da sua parte.
25
+ * `UNRESOLVED` -- ninguém conseguiu perguntar ao registry. Ausência de
26
+ resposta, e não confirmação de que está tudo bem: é reportado como tal e
27
+ nunca vira "atualizado".
28
+
29
+ A reescrita mexe **apenas** no token da imagem, preservando `--platform`,
30
+ `AS <estágio>` e todo o resto da linha -- um upgrade de base que reformata o
31
+ arquivo transforma uma revisão de uma linha numa revisão de trinta.
32
+
33
+ Referências montadas com `ARG` (`FROM python:3.12-alpine@${PYTHON_DIGEST}`)
34
+ recebem tratamento próprio, porque são a forma correta de escrever isto e
35
+ seria absurdo não suportá-las: o valor é resolvido a partir do padrão do
36
+ `ARG` para a comparação, e a atualização vai para **a linha do `ARG`**, que é
37
+ onde o digest realmente mora. Sobrescrever o token do `FROM` quebraria o
38
+ contrato do arquivo em vez de atualizá-lo.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import re
44
+ from dataclasses import dataclass
45
+ from enum import StrEnum
46
+
47
+ #: `FROM [--platform=...] <referência> [AS <estágio>]`, com os pedaços
48
+ #: preservados para que a reescrita não perca nada da linha original.
49
+ _FROM_LINE = re.compile(
50
+ r"^(?P<prefix>\s*FROM\s+(?:--\S+\s+)*)(?P<reference>\S+)(?P<suffix>.*)$",
51
+ re.IGNORECASE,
52
+ )
53
+
54
+ #: `ARG NOME=valor`, de onde saem os valores que os `FROM` interpolam.
55
+ _ARG_LINE = re.compile(
56
+ r"^(?P<prefix>\s*ARG\s+(?P<name>[A-Za-z_][A-Za-z0-9_]*)=)(?P<value>\S*)", re.IGNORECASE
57
+ )
58
+
59
+ #: `${NOME}` ou `$NOME` dentro de uma referência.
60
+ _VARIABLE = re.compile(
61
+ r"\$\{(?P<braced>[A-Za-z_][A-Za-z0-9_]*)\}|\$(?P<bare>[A-Za-z_][A-Za-z0-9_]*)"
62
+ )
63
+
64
+
65
+ class BaseStatus(StrEnum):
66
+ """O que se sabe sobre uma base declarada."""
67
+
68
+ PINNED_CURRENT = "PINNED_CURRENT"
69
+ PINNED_STALE = "PINNED_STALE"
70
+ UNPINNED = "UNPINNED"
71
+ UNRESOLVED = "UNRESOLVED"
72
+
73
+ @property
74
+ def needs_action(self) -> bool:
75
+ """Se há algo a corrigir. `UNRESOLVED` não entra: não se corrige o que
76
+ não se conseguiu medir -- reporta-se."""
77
+ return self in (BaseStatus.PINNED_STALE, BaseStatus.UNPINNED)
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class DeclaredBase:
82
+ """Uma base como o Dockerfile a declara, decomposta."""
83
+
84
+ #: Número da linha, 1-indexado, para a mensagem apontar onde.
85
+ line: int
86
+ #: O token exato que aparece no arquivo.
87
+ raw: str
88
+ name: str
89
+ tag: str
90
+ #: Digest fixado no arquivo, vazio quando a base é só uma tag.
91
+ digest: str = ""
92
+ #: Nome do estágio (`AS builder`), quando há.
93
+ stage: str = ""
94
+ #: Referência montada a partir de variável (`${VERSION}`). O valor é
95
+ #: resolvido a partir do padrão do `ARG` para a comparação.
96
+ templated: bool = False
97
+ #: Nome do `ARG` de onde o digest veio, quando veio de um. É a linha que a
98
+ #: atualização deve tocar -- o digest mora lá, não no `FROM`.
99
+ digest_arg: str = ""
100
+ #: Linha do `ARG` correspondente, 1-indexada.
101
+ digest_arg_line: int = 0
102
+
103
+ @property
104
+ def is_pinned(self) -> bool:
105
+ return bool(self.digest)
106
+
107
+ @property
108
+ def reference(self) -> str:
109
+ base = f"{self.name}:{self.tag}" if self.tag else self.name
110
+ return f"{base}@{self.digest}" if self.digest else base
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class BaseFinding:
115
+ """O veredito sobre uma base, e a linha que a substituiria."""
116
+
117
+ base: DeclaredBase
118
+ status: BaseStatus
119
+ #: O digest que a tag aponta hoje, quando alguém conseguiu perguntar.
120
+ current_digest: str = ""
121
+
122
+ @property
123
+ def proposed_reference(self) -> str:
124
+ """A referência a usar, ou "" quando não há nada a propor."""
125
+ if not self.current_digest:
126
+ return ""
127
+ # Uma referência interpolada só é reescrevível quando o digest vem de
128
+ # um `ARG` que existe no arquivo -- aí a troca vai para lá. Sem isso,
129
+ # não há onde escrever sem quebrar o contrato do Dockerfile.
130
+ if self.base.templated and not self.base.digest_arg_line:
131
+ return ""
132
+ if self.status is BaseStatus.PINNED_CURRENT:
133
+ return ""
134
+ tag = self.base.tag or "latest"
135
+ return f"{self.base.name}:{tag}@{self.current_digest}"
136
+
137
+ def explain(self) -> str:
138
+ if self.status is BaseStatus.PINNED_STALE:
139
+ return (
140
+ "pinned to a digest the tag no longer points at: the base was "
141
+ "republished and this image keeps building from the old version"
142
+ )
143
+ if self.status is BaseStatus.UNPINNED:
144
+ return (
145
+ "moving tag, no digest: what you tested and what goes to production "
146
+ "can be different bytes with no change on your part"
147
+ )
148
+ if self.status is BaseStatus.UNRESOLVED:
149
+ return "the registry could not be asked which digest this tag points at"
150
+ return "pinned to the digest the tag points at today"
151
+
152
+
153
+ def parse_bases(content: str) -> list[DeclaredBase]:
154
+ """Toda base declarada no Dockerfile, na ordem em que aparece.
155
+
156
+ Estágios internos entram junto: um `FROM golang:1.21 AS builder` velho
157
+ compila com um toolchain velho, e isso é problema de cadeia de
158
+ fornecimento mesmo que o binário vá para uma imagem final endurecida.
159
+ """
160
+ args, arg_lines = _arg_defaults(content)
161
+ bases: list[DeclaredBase] = []
162
+ for number, line in enumerate(content.splitlines(), 1):
163
+ match = _FROM_LINE.match(line)
164
+ if not match:
165
+ continue
166
+ reference = match.group("reference")
167
+ stage = ""
168
+ suffix = match.group("suffix").strip()
169
+ if suffix:
170
+ parts = suffix.split()
171
+ if len(parts) >= 2 and parts[0].upper() == "AS":
172
+ stage = parts[1]
173
+
174
+ expanded = _expand(reference, args)
175
+ name, tag, digest = _split_reference(expanded)
176
+ _, _, raw_digest = reference.partition("@")
177
+ digest_arg = _variable_name(raw_digest)
178
+ bases.append(
179
+ DeclaredBase(
180
+ line=number,
181
+ raw=reference,
182
+ name=name,
183
+ tag=tag,
184
+ digest=digest,
185
+ stage=stage,
186
+ templated="$" in reference,
187
+ digest_arg=digest_arg,
188
+ digest_arg_line=arg_lines.get(digest_arg, 0),
189
+ )
190
+ )
191
+ return bases
192
+
193
+
194
+ def _arg_defaults(content: str) -> tuple[dict[str, str], dict[str, int]]:
195
+ """Valores padrão dos `ARG` e a linha de cada um."""
196
+ values: dict[str, str] = {}
197
+ lines: dict[str, int] = {}
198
+ for number, line in enumerate(content.splitlines(), 1):
199
+ match = _ARG_LINE.match(line)
200
+ if match:
201
+ values[match.group("name")] = match.group("value")
202
+ lines[match.group("name")] = number
203
+ return values, lines
204
+
205
+
206
+ def _expand(reference: str, args: dict[str, str]) -> str:
207
+ """Substitui `${VAR}` pelos padrões declarados, deixando o resto como está."""
208
+
209
+ def replace(match: re.Match[str]) -> str:
210
+ name = match.group("braced") or match.group("bare")
211
+ return args.get(name, match.group(0))
212
+
213
+ return _VARIABLE.sub(replace, reference)
214
+
215
+
216
+ def _variable_name(value: str) -> str:
217
+ """O nome da variável, se o valor for exatamente uma interpolação."""
218
+ match = _VARIABLE.fullmatch(value.strip())
219
+ if not match:
220
+ return ""
221
+ return match.group("braced") or match.group("bare")
222
+
223
+
224
+ def classify(base: DeclaredBase, current_digest: str) -> BaseFinding:
225
+ """O estado de uma base, dado o digest que a tag aponta hoje.
226
+
227
+ `current_digest` vazio significa "ninguém conseguiu perguntar", e o
228
+ resultado é `UNRESOLVED` -- nunca `PINNED_CURRENT` por omissão.
229
+ """
230
+ if not current_digest:
231
+ return BaseFinding(base=base, status=BaseStatus.UNRESOLVED)
232
+ if not base.is_pinned:
233
+ return BaseFinding(base=base, status=BaseStatus.UNPINNED, current_digest=current_digest)
234
+ status = BaseStatus.PINNED_CURRENT if base.digest == current_digest else BaseStatus.PINNED_STALE
235
+ return BaseFinding(base=base, status=status, current_digest=current_digest)
236
+
237
+
238
+ def rewrite(content: str, findings: list[BaseFinding]) -> tuple[str, int]:
239
+ """Aplica as substituições, devolvendo o texto novo e quantas houve.
240
+
241
+ Só o token da imagem é trocado: `--platform`, `AS <estágio>`, comentários
242
+ e indentação sobrevivem intactos, porque um upgrade de base que reformata
243
+ o arquivo transforma uma revisão de uma linha numa revisão de trinta.
244
+ """
245
+ from_edits: dict[int, str] = {}
246
+ arg_edits: dict[int, str] = {}
247
+ for finding in findings:
248
+ if not finding.proposed_reference:
249
+ continue
250
+ base = finding.base
251
+ if base.digest_arg_line:
252
+ # O digest mora no `ARG`; é lá que a atualização vale, e assim o
253
+ # `FROM` continua legível e o contrato do arquivo intacto.
254
+ arg_edits[base.digest_arg_line] = finding.current_digest
255
+ else:
256
+ from_edits[base.line] = finding.proposed_reference
257
+
258
+ if not from_edits and not arg_edits:
259
+ return content, 0
260
+
261
+ applied = 0
262
+ lines = content.splitlines(keepends=True)
263
+ for index, line in enumerate(lines):
264
+ number = index + 1
265
+ stripped = line.rstrip("\r\n")
266
+ ending = line[len(stripped) :]
267
+
268
+ if number in from_edits:
269
+ match = _FROM_LINE.match(stripped)
270
+ if match:
271
+ lines[index] = (
272
+ f"{match.group('prefix')}{from_edits[number]}{match.group('suffix')}{ending}"
273
+ )
274
+ applied += 1
275
+ elif number in arg_edits:
276
+ match = _ARG_LINE.match(stripped)
277
+ if match:
278
+ tail = stripped[match.end() :]
279
+ lines[index] = f"{match.group('prefix')}{arg_edits[number]}{tail}{ending}"
280
+ applied += 1
281
+ return "".join(lines), applied
282
+
283
+
284
+ def _split_reference(reference: str) -> tuple[str, str, str]:
285
+ """`nome`, `tag`, `digest` de uma referência, com os vazios explícitos."""
286
+ remainder, _, digest = reference.partition("@")
287
+ # O host pode carregar porta (`registry:5000/app`), então o `:` da tag é
288
+ # procurado só no último componente do caminho.
289
+ head, slash, last = remainder.rpartition("/")
290
+ name_part, _, tag = last.partition(":")
291
+ name = f"{head}{slash}{name_part}"
292
+ return name, tag, digest
@@ -0,0 +1,99 @@
1
+ """Quem responde por esta imagem, gravado nela antes de ela existir.
2
+
3
+ A regra DF007 deste projeto cobra `maintainer` e `security.scanner` de todo
4
+ Dockerfile que ele analisa, e o `build` publicava imagens sem nenhum dos dois
5
+ -- cobrando dos outros o que não fazia. Pior: `--labels` aceitava qualquer
6
+ JSON e não exigia nada, então o campo existia e ficava vazio.
7
+
8
+ O que estes rótulos resolvem acontece meses depois, às três da manhã: uma
9
+ imagem aparece rodando em produção, alguém precisa saber de onde ela veio,
10
+ qual commit a gerou e para quem ligar. Sem isso, a resposta é arqueologia a
11
+ partir da tag. As chaves usadas são as anotações pré-definidas da
12
+ especificação OCI, e não invenções deste projeto, justamente para que
13
+ qualquer ferramenta de inventário leia sem adaptação.
14
+
15
+ Perguntar antes do build é deliberado: depois do build a imagem já existe, e
16
+ rotular passa a ser reconstruir.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass, field
22
+
23
+ #: Rótulos sem os quais uma imagem não deveria chegar a um registry de
24
+ #: empresa. São poucos de propósito: uma lista longa vira formulário que
25
+ #: ninguém preenche de verdade.
26
+ REQUIRED_FIELDS: tuple[tuple[str, str], ...] = (
27
+ ("owner", "Owning team or person for this image"),
28
+ ("security_contact", "Who to tell about a vulnerability in this image"),
29
+ ("source", "URL of the repository that produces this image"),
30
+ )
31
+
32
+
33
+ class MissingBuildMetadataError(ValueError):
34
+ """Faltam rótulos obrigatórios, nomeados na mensagem."""
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class BuildIdentity:
39
+ """A procedência de uma imagem, do jeito que a OCI espera lê-la."""
40
+
41
+ #: Time ou pessoa responsável. Vira `maintainer` e
42
+ #: `org.opencontainers.image.vendor`.
43
+ owner: str = ""
44
+ #: Canal de contato para vulnerabilidades.
45
+ security_contact: str = ""
46
+ #: Repositório de origem.
47
+ source: str = ""
48
+ title: str = ""
49
+ description: str = ""
50
+ version: str = ""
51
+ #: Commit que gerou a imagem. Opcional porque nem todo build sai de um
52
+ #: repositório limpo, mas é o campo que responde "qual código é esse".
53
+ revision: str = ""
54
+ #: Rótulos livres da equipe, aplicados por cima dos derivados.
55
+ extra: dict[str, str] = field(default_factory=dict)
56
+
57
+ def missing(self) -> list[str]:
58
+ """Campos obrigatórios ainda vazios, na ordem em que devem ser pedidos."""
59
+ return [name for name, _ in REQUIRED_FIELDS if not getattr(self, name, "").strip()]
60
+
61
+ def require_complete(self) -> None:
62
+ absent = self.missing()
63
+ if absent:
64
+ raise MissingBuildMetadataError(
65
+ "required labels are missing: "
66
+ + ", ".join(absent)
67
+ + ". Give them as build options, or answer the questions "
68
+ "(use --non-interactive to require them as options)."
69
+ )
70
+
71
+ def to_labels(self) -> dict[str, str]:
72
+ """As chaves OCI, mais as duas que a regra DF007 cobra.
73
+
74
+ Rótulos vazios são omitidos em vez de gravados em branco: uma chave
75
+ presente e vazia é pior do que ausente, porque um inventário a lê
76
+ como respondida.
77
+ """
78
+ labels: dict[str, str] = {}
79
+ if self.owner:
80
+ labels["maintainer"] = self.owner
81
+ labels["org.opencontainers.image.vendor"] = self.owner
82
+ if self.security_contact:
83
+ # Não é chave OCI; é o canal que a pessoa de plantão procura.
84
+ labels["security.contact"] = self.security_contact
85
+ if self.source:
86
+ labels["org.opencontainers.image.source"] = self.source
87
+ if self.title:
88
+ labels["org.opencontainers.image.title"] = self.title
89
+ if self.description:
90
+ labels["org.opencontainers.image.description"] = self.description
91
+ if self.version:
92
+ labels["org.opencontainers.image.version"] = self.version
93
+ if self.revision:
94
+ labels["org.opencontainers.image.revision"] = self.revision
95
+ # Quem mediu esta imagem. A regra DF007 cobra exatamente isto, e o
96
+ # `build` publicava sem.
97
+ labels["security.scanner"] = "dockerls"
98
+ labels.update({k: v for k, v in self.extra.items() if v})
99
+ return labels