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,1899 @@
1
+ """Implementação do validador de Dockerfiles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ import re
7
+ import shlex
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ from dockerls.domain.entities.dockerfile_analysis import (
12
+ DockerfileAnalysis,
13
+ DockerfileInfo,
14
+ DockerfileValidationResult,
15
+ HardeningRule,
16
+ SeverityLevel,
17
+ TemplateOriginMatch,
18
+ ValidationCheck,
19
+ ValidationStatus,
20
+ )
21
+ from dockerls.domain.interfaces.dockerfile_validator import (
22
+ DockerfileValidatorInterface,
23
+ HardeningTemplateProvider,
24
+ )
25
+ from dockerls.domain.value_objects.image_reference import split_repository_and_tag
26
+
27
+ #: `sudo` como comando, e não como substring de `pseudo` ou de uma frase
28
+ #: dentro de um `echo`.
29
+ _SUDO = re.compile(r"(?:^|[\s;&|(])sudo(?:$|[\s;&|)])")
30
+
31
+ #: Cada gerenciador de pacotes com uma borda de palavra em volta. Sem ela,
32
+ #: `apt` casava com `adapt` e `pip` com `pipeline`.
33
+ _PACKAGE_MANAGERS = {
34
+ name: re.compile(rf"(?:^|[\s;&|(]){re.escape(name)}(?:$|[\s;&|)])")
35
+ for name in ("apt-get", "apt", "apk", "yum", "dnf", "pip", "pip3", "npm", "yarn")
36
+ }
37
+
38
+ #: Texto entre aspas dentro de um `RUN`. Removido antes de procurar por
39
+ #: comandos, porque `RUN echo 'no sudo here'` não usa sudo -- fala dele.
40
+ _QUOTED = re.compile(r"'[^']*'|\"[^\"]*\"")
41
+
42
+ #: Quem baixa da rede, à esquerda de um pipe.
43
+ _DOWNLOADER = re.compile(r"(?:^|[\s;&(]) *(?:curl|wget|fetch)\b", re.IGNORECASE)
44
+
45
+ #: Um interpretador **no começo** do segmento à direita do pipe. A posição é
46
+ #: a regra inteira: `curl ... | sh` executa o que veio da rede, enquanto
47
+ #: `curl -o f && echo ... | sha256sum -c && sh f` também tem um `sh` depois
48
+ #: de um pipe e é exatamente a forma correta. Só o primeiro é acusado.
49
+ #: `[^\s=]+=\S+` rather than `\S+=\S+`: the key half excludes `=`, so there
50
+ #: is exactly one place the assignment can split. Two unbounded `\S+`
51
+ #: sharing an unbounded search space around one `=` is the textbook
52
+ #: catastrophic-backtracking shape (CodeQL flagged it); a real env var name
53
+ #: never contains `=` or whitespace, so this loses no matches.
54
+ _SHELL_AT_HEAD = re.compile(
55
+ r"^\s*(?:sudo\s+|env\s+[^\s=]+=\S+\s+)*(?:ba|z|k|da)?sh\b", re.IGNORECASE
56
+ )
57
+
58
+ #: Bit setuid/setgid posto na imagem, simbólico (`chmod u+s`) ou octal
59
+ #: (`chmod 4755`). O primeiro dígito de um modo de quatro é o que carrega
60
+ #: setuid(4), setgid(2) e sticky(1); só os dois primeiros interessam.
61
+ _SETUID_BIT = re.compile(
62
+ r"\bchmod\b[^;&|]*?(?:\b[4267]\d{3}\b|[ugo]*[+=][a-z]*s\b)",
63
+ re.IGNORECASE,
64
+ )
65
+
66
+ #: Fontes que um `ADD` busca na rede. O Docker as baixa sem verificar
67
+ #: assinatura nem checksum, e sem seguir o proxy configurado para o build.
68
+ _ADD_REMOTE = re.compile(r"^(?:https?|ftp|git)://|^git@", re.IGNORECASE)
69
+
70
+ #: Arquivos que o `ADD` **descompacta sozinho** ao copiar. É o
71
+ #: comportamento que separa `ADD` de `COPY`, e o que transforma um tarball
72
+ #: hostil em escrita de arquivo arbitrária dentro da imagem.
73
+ _ADD_ARCHIVE = re.compile(
74
+ r"\.(?:tar|tar\.gz|tgz|tar\.bz2|tbz2|tar\.xz|txz|tar\.zst|gz|bz2|xz)$",
75
+ re.IGNORECASE,
76
+ )
77
+
78
+ #: `RUN --mount=type=secret,id=token ...`: a forma BuildKit de passar um
79
+ #: segredo sem que ele sobre em ENV, ARG, nem em nenhuma camada da imagem.
80
+ _SECRET_MOUNT = re.compile(r"--mount=type=secret(?:,[^\s]*?id=([\w.\-]+))?", re.IGNORECASE)
81
+
82
+ #: Um comando de instalação de pacote, isolado de tudo que vem depois de um
83
+ #: `&&`/`;`/`|` -- o resto da linha é outro comando, com seus próprios
84
+ #: argumentos, e não faz parte da lista de pacotes deste.
85
+ _APT_INSTALL = re.compile(r"(?:apt-get|apt)\s+install\b([^&;|]*)", re.IGNORECASE)
86
+ _APK_ADD = re.compile(r"\bapk\s+add\b([^&;|]*)", re.IGNORECASE)
87
+ _PIP_INSTALL = re.compile(r"\bpip3?\s+install\b([^&;|]*)", re.IGNORECASE)
88
+ _NPM_INSTALL = re.compile(r"\bnpm\s+(?:install|i|ci|add)\b([^&;|]*)", re.IGNORECASE)
89
+
90
+ #: Flags que carregam um valor colado sem espaço (`-i requirements.txt` já
91
+ #: cai como token separado; `--index-url=https://...` não). Tratadas à
92
+ #: parte de "começa com -" porque `-r requirements.txt` é uma referência a
93
+ #: arquivo, não um nome de pacote, e não deve virar um falso "não pinado".
94
+ _PIP_FILE_FLAGS = ("-r", "--requirement", "-c", "--constraint")
95
+
96
+ #: Nomes de ARG que o Docker/BuildKit preenche sozinho num build
97
+ #: `--platform`. Declará-los -- com ou sem uso -- é o sinal de que o autor
98
+ #: sabe que a imagem pode ser construída para mais de uma arquitetura.
99
+ _PLATFORM_ARG_NAMES = frozenset(
100
+ {
101
+ "targetplatform",
102
+ "targetarch",
103
+ "targetos",
104
+ "targetvariant",
105
+ "buildplatform",
106
+ "buildarch",
107
+ "buildos",
108
+ }
109
+ )
110
+
111
+
112
+ #: Os dois inteiros que este parser lê de um Dockerfile: uma porta TCP e um
113
+ #: UID. Ambos são grandezas limitadas, e é o limite que fecha o buraco
114
+ #: abaixo.
115
+ _MAX_PORT = 65535
116
+ _MAX_UID = 2**32 - 1
117
+
118
+
119
+ def _bounded_int(digits: str, maximum: int) -> int | None:
120
+ """Um dígito-a-dígito vindo do arquivo, ou `None` se não for plausível.
121
+
122
+ A partir do Python 3.11 `int()` **recusa** uma string com mais de 4300
123
+ dígitos, e a recusa é um `ValueError` que este parser não capturava. Uma
124
+ única linha `EXPOSE <4301 dígitos>` -- ou `USER app:<4301 dígitos>` --
125
+ abortava `validate()` inteiro: o Dockerfile se retirava de *todas* as
126
+ regras de uma vez, e quem lê o relatório vê um erro de uso, não um
127
+ Dockerfile por verificar. Encontrado por fuzzing (Hypothesis).
128
+
129
+ Os zeros à esquerda caem antes da medição, para que `USER app:000...0`
130
+ continue sendo lido como uid 0 -- isto é, como root -- em vez de virar
131
+ "sem uid" e passar na DF002.
132
+ """
133
+ normalized = digits.lstrip("0") or "0"
134
+ if len(normalized) > len(str(maximum)):
135
+ return None
136
+ value = int(normalized)
137
+ return value if value <= maximum else None
138
+
139
+
140
+ def _strip_quoted(command: str) -> str:
141
+ """O comando sem o que está entre aspas.
142
+
143
+ `RUN echo 'no sudo needed here'` não usa sudo: fala dele. Sem esta
144
+ remoção, toda menção a uma ferramenta dentro de uma mensagem vira uma
145
+ acusação, e uma regra que acusa texto perde a confiança de quem a lê.
146
+ """
147
+ return _QUOTED.sub(" ", command)
148
+
149
+
150
+ def _pipes_remote_script_to_shell(command: str) -> bool:
151
+ """Se um download é canalizado direto para um interpretador.
152
+
153
+ Olha o **par**, e não cada metade: um baixador em algum segmento à
154
+ esquerda de um pipe, e um interpretador *no começo* do segmento à
155
+ direita. É essa segunda condição que separa
156
+
157
+ curl -fsSL https://x/i.sh | sh (acusado)
158
+
159
+ de
160
+
161
+ curl -o /tmp/i.sh https://x/i.sh \\
162
+ && echo '<sha> /tmp/i.sh' | sha256sum -c \\
163
+ && sh /tmp/i.sh (a forma certa)
164
+
165
+ -- que também tem um `sh` depois de um pipe, e é exatamente o que a
166
+ regra quer que as pessoas façam.
167
+ """
168
+ text = _strip_quoted(command)
169
+ # `||` é um operador, não um pipe.
170
+ segments = re.split(r"(?<!\|)\|(?!\|)", text)
171
+ for left, right in zip(segments, segments[1:], strict=False):
172
+ if _DOWNLOADER.search(left) and _SHELL_AT_HEAD.match(right):
173
+ return True
174
+ return False
175
+
176
+
177
+ def _unpinned_from(
178
+ args: str, *, manager: str, pin_marker: str, file_flags: tuple[str, ...] = ()
179
+ ) -> list[str]:
180
+ """Nomes de pacote em `args` que não carregam `pin_marker`.
181
+
182
+ `args` é só a cauda de um comando de instalação, já isolada de
183
+ `&&`/`;`/`|` por quem chamou. Um token é candidato a pacote quando não
184
+ começa com `-` (é uma flag, `-y`, `--no-cache`, `--upgrade`) e não é o
185
+ valor de uma flag que espera um caminho de arquivo (`-r
186
+ requirements.txt`, que nomeia um arquivo, não um pacote).
187
+ """
188
+ tokens = args.split()
189
+ packages = []
190
+ skip_next = False
191
+ for token in tokens:
192
+ if skip_next:
193
+ skip_next = False
194
+ continue
195
+ if token in file_flags:
196
+ skip_next = True
197
+ continue
198
+ if token.startswith("-"):
199
+ continue
200
+ if pin_marker in token:
201
+ continue
202
+ packages.append(token)
203
+ return packages
204
+
205
+
206
+ def _npm_targets(args: str) -> list[str]:
207
+ """Nomes de pacote sem `@versão` num `npm install`/`npm add`.
208
+
209
+ `npm install` sozinho (sem alvo nenhum) instala a partir do
210
+ `package.json` -- não é um pacote sem versão, é o projeto inteiro, e
211
+ isso é o `npm_lockfile_missing` que responde, não este.
212
+ """
213
+ unpinned = []
214
+ for token in args.split():
215
+ if token.startswith("-"):
216
+ continue
217
+ # `@scope/pacote@versão` -- o `@` do escopo não é o separador de
218
+ # versão; só o **último** `@` é, e só quando não está na posição 0.
219
+ at = token.rfind("@")
220
+ if at <= 0:
221
+ unpinned.append(token)
222
+ return unpinned
223
+
224
+
225
+ class UnknownHardeningTemplateError(ValueError):
226
+ """Pediu-se um template hardened que esta instalação não tem.
227
+
228
+ `ValueError` de propósito: a CLI já trata `ValueError` como erro de uso
229
+ (mensagem, sem stack trace), e o caso de uso de build o converte numa
230
+ resposta com `EXIT_ERROR`.
231
+ """
232
+
233
+
234
+ @dataclass
235
+ class _Stage:
236
+ """Um estágio (`FROM ... [AS alias]`) e o que foi declarado dentro dele.
237
+
238
+ O parser precisa disso porque quase toda propriedade de runtime -- quem
239
+ é o usuário, qual é a base -- só importa no estágio final.
240
+ """
241
+
242
+ base: str
243
+ alias: str | None = None
244
+ user: str | None = None
245
+ uid: int | None = None
246
+
247
+
248
+ class DockerfileParser:
249
+ """Parser simples para Dockerfiles.
250
+
251
+ Extrai informações estruturais de um Dockerfile sem depender
252
+ de bibliotecas externas complexas.
253
+ """
254
+
255
+ # Padrões regex para diretivas Dockerfile
256
+ FROM_PATTERN = re.compile(r"^FROM\s+(.+?)(?:\s+AS\s+(\S+))?$", re.IGNORECASE)
257
+ RUN_PATTERN = re.compile(r"^RUN\s+(.+)$", re.IGNORECASE | re.DOTALL)
258
+ COPY_PATTERN = re.compile(
259
+ r"^COPY\s+(?:--chown=(\S+:\S+)\s+)?(?:--from=(\S+)\s+)?(\S+)\s+(\S+)$",
260
+ re.IGNORECASE,
261
+ )
262
+ #: `ADD` inteiro, argumentos incluídos. Ao contrário do `COPY_PATTERN`
263
+ #: acima, não exige exatamente dois operandos: `ADD a b c/` é legal, e
264
+ #: uma regex que não casasse com ele deixaria a diretiva invisível --
265
+ #: que é o modo de falhar que esta regra existe para não ter.
266
+ ADD_PATTERN = re.compile(r"^ADD\s+(.+)$", re.IGNORECASE)
267
+ ENV_PREFIX = re.compile(r"^ENV\s+(.+)$", re.IGNORECASE)
268
+ # Pares chave=valor de uma linha ENV, com valor opcionalmente entre aspas.
269
+ ENV_KV = re.compile(r"""([\w.\-]+)=(?:"[^"]*"|'[^']*'|\S*)""")
270
+ LABEL_PREFIX = re.compile(r"^LABEL\s+(.+)$", re.IGNORECASE | re.DOTALL)
271
+ EXPOSE_PATTERN = re.compile(r"^EXPOSE\s+(\d+)", re.IGNORECASE)
272
+ USER_PATTERN = re.compile(r"^USER\s+(\S+)(?::(\d+))?$", re.IGNORECASE)
273
+ HEALTHCHECK_PATTERN = re.compile(r"^HEALTHCHECK\s+", re.IGNORECASE)
274
+ ENTRYPOINT_PATTERN = re.compile(r"^ENTRYPOINT\s+(.+)$", re.IGNORECASE)
275
+ CMD_PATTERN = re.compile(r"^CMD\s+(.+)$", re.IGNORECASE)
276
+ #: `(\S+)` era ganancioso e engolia o `=valor` inteiro: em
277
+ #: `ARG TOKEN=abc` o nome saía como `TOKEN=abc` e o grupo do valor vinha
278
+ #: vazio, então nada nesta classe conseguia distinguir um ARG com valor
279
+ #: -- que é um segredo escrito no Dockerfile -- de um sem.
280
+ ARG_PATTERN = re.compile(r"^ARG\s+([A-Za-z_][\w.\-]*)(?:=(.*))?$", re.IGNORECASE)
281
+ WORKDIR_PATTERN = re.compile(r"^WORKDIR\s+(\S+)$", re.IGNORECASE)
282
+
283
+ # Secret patterns - variáveis que podem conter segredos
284
+ SECRET_ENV_PATTERNS = [
285
+ r"(?i)password",
286
+ r"(?i)passwd",
287
+ r"(?i)secret",
288
+ r"(?i)token",
289
+ r"(?i)api[_-]?key",
290
+ r"(?i)auth",
291
+ r"(?i)credential",
292
+ r"(?i)private[_-]?key",
293
+ r"(?i)access[_-]?key",
294
+ ]
295
+
296
+ def __init__(self) -> None:
297
+ self._lines: list[str] = []
298
+ self._info = DockerfileInfo()
299
+ self._stages: list[_Stage] = []
300
+
301
+ def parse(self, content: str) -> DockerfileInfo:
302
+ """Parseia o conteúdo de um Dockerfile.
303
+
304
+ Args:
305
+ content: Conteúdo do Dockerfile como string.
306
+
307
+ Returns:
308
+ DockerfileInfo com informações extraídas.
309
+ """
310
+ self._lines = content.splitlines()
311
+ self._info = DockerfileInfo(raw_lines=self._lines.copy())
312
+ self._stages = []
313
+
314
+ line_continuation = ""
315
+ continuation_start = 0
316
+
317
+ for line_num, line in enumerate(self._lines, 1):
318
+ # Handle line continuations with backslash
319
+ if line.rstrip().endswith("\\"):
320
+ if not line_continuation:
321
+ continuation_start = line_num
322
+ line_continuation += line.rstrip()[:-1] + " "
323
+ continue
324
+ elif line_continuation:
325
+ line = line_continuation + line.lstrip()
326
+ line_num = continuation_start
327
+ line_continuation = ""
328
+
329
+ line = line.strip()
330
+
331
+ # Skip comments and empty lines
332
+ if not line or line.startswith("#"):
333
+ continue
334
+
335
+ self._parse_line(line, line_num)
336
+
337
+ # Um arquivo que termina numa barra invertida deixava a diretiva
338
+ # pendente no buffer e ela sumia: um `RUN ... \` final -- com sudo,
339
+ # com segredo, com o que fosse -- nunca era verificado.
340
+ if line_continuation.strip():
341
+ self._parse_line(line_continuation.strip(), continuation_start)
342
+
343
+ self._info.stages = max(1, len(self._stages))
344
+ self._resolve_final_stage()
345
+ return self._info
346
+
347
+ def _resolve_final_stage(self) -> None:
348
+ """Projeta no `DockerfileInfo` o que vale no estágio final.
349
+
350
+ `FROM builder` herda o USER do estágio referenciado, então a
351
+ resolução caminha pela cadeia de aliases até achar um USER ou uma
352
+ base externa. Sem isso, um `USER node` num estágio de build fazia a
353
+ imagem final -- que sobe como root -- passar na validação.
354
+ """
355
+ if not self._stages:
356
+ return
357
+
358
+ final = self._stages[-1]
359
+ by_alias = {s.alias.lower(): s for s in self._stages if s.alias}
360
+
361
+ stage: _Stage | None = final
362
+ visited: set[int] = set()
363
+ while stage is not None and id(stage) not in visited:
364
+ visited.add(id(stage))
365
+ if stage.user is not None:
366
+ self._info.has_user_directive = True
367
+ self._info.user_name = stage.user
368
+ self._info.user_uid = stage.uid
369
+ break
370
+ stage = by_alias.get(stage.base.lower())
371
+
372
+ # Base efetiva do estágio final, resolvida pela mesma cadeia.
373
+ stage = final
374
+ visited = set()
375
+ while stage is not None and id(stage) not in visited:
376
+ visited.add(id(stage))
377
+ parent = by_alias.get(stage.base.lower())
378
+ if parent is None:
379
+ self._info.final_base_image = stage.base
380
+ break
381
+ stage = parent
382
+
383
+ def _parse_line(self, line: str, line_num: int) -> None:
384
+ """Parseia uma linha específica do Dockerfile."""
385
+
386
+ # FROM
387
+ if match := self.FROM_PATTERN.match(line):
388
+ image = match.group(1).strip()
389
+ alias = match.group(2)
390
+ if "--platform=" in image:
391
+ self._info.declares_multi_arch = True
392
+ flag = next((p for p in image.split() if p.startswith("--platform=")), "")
393
+ if flag and flag not in self._info.platform_args:
394
+ self._info.platform_args.append(flag)
395
+ self._info.base_images.append(image)
396
+ self._stages.append(_Stage(base=image, alias=alias))
397
+ # `FROM builder` referencia um estágio anterior, não um registry:
398
+ # a ausência de tag ali não é um :latest implícito.
399
+ is_stage_reference = any(
400
+ s.alias and s.alias.lower() == image.lower() for s in self._stages[:-1]
401
+ )
402
+ if (
403
+ not is_stage_reference
404
+ and not self._is_scratch(image)
405
+ and self._is_moving_reference(image)
406
+ ):
407
+ self._info.uses_latest_tag = True
408
+
409
+ # RUN
410
+ elif match := self.RUN_PATTERN.match(line):
411
+ cmd = match.group(1)
412
+ self._info.run_commands.append({"line": line_num, "command": cmd})
413
+
414
+ # `"sudo" in cmd` casava com `pseudo`, `sudoku` e com a palavra
415
+ # dentro de um `echo`. A borda de palavra é o que separa a
416
+ # ferramenta da string.
417
+ unquoted = _strip_quoted(cmd)
418
+ if _SUDO.search(unquoted):
419
+ self._info.uses_sudo = True
420
+
421
+ # Idem: `"apt" in cmd` casava com `adapt` e `captured`, `"pip"`
422
+ # com `pipeline` e `pipx`. Um gerenciador de pacotes detectado
423
+ # onde não há nenhum acusa a DF005 de cache não limpo num
424
+ # Dockerfile que não instalou nada.
425
+ for pm, pattern in _PACKAGE_MANAGERS.items():
426
+ if pattern.search(unquoted) and pm not in self._info.package_managers_used:
427
+ self._info.package_managers_used.append(pm)
428
+
429
+ if _pipes_remote_script_to_shell(cmd):
430
+ self._info.pipes_remote_script_to_shell = True
431
+ self._info.remote_script_lines.append(line_num)
432
+
433
+ if _SETUID_BIT.search(unquoted):
434
+ self._info.sets_setuid_bit = True
435
+ self._info.setuid_lines.append(line_num)
436
+
437
+ for mount_match in _SECRET_MOUNT.finditer(unquoted):
438
+ self._info.uses_secret_mount = True
439
+ secret_id = mount_match.group(1)
440
+ if secret_id and secret_id not in self._info.secret_mount_ids:
441
+ self._info.secret_mount_ids.append(secret_id)
442
+
443
+ self._record_unpinned_packages(unquoted, line_num)
444
+
445
+ # Check cache cleaning
446
+ cache_clean_patterns = [
447
+ "rm -rf /var/cache/apk/*",
448
+ "rm -rf /var/cache/apt/archives",
449
+ "apt-get clean",
450
+ "rm -rf ~/.cache/pip",
451
+ "npm cache clean",
452
+ "--no-cache-dir",
453
+ "--no-install-recommends",
454
+ ]
455
+ if any(pattern in cmd for pattern in cache_clean_patterns):
456
+ self._info.cache_cleaned = True
457
+
458
+ # COPY
459
+ elif match := self.COPY_PATTERN.match(line):
460
+ chown = match.group(1)
461
+ from_stage = match.group(2)
462
+ src = match.group(3)
463
+ dest = match.group(4)
464
+ self._info.copy_commands.append(
465
+ {
466
+ "line": line_num,
467
+ "chown": chown,
468
+ "from_stage": from_stage,
469
+ "source": src,
470
+ "destination": dest,
471
+ }
472
+ )
473
+
474
+ # ADD -- não é um COPY com outro nome, e a diferença é a regra.
475
+ elif match := self.ADD_PATTERN.match(line):
476
+ self._record_add(match.group(1), line_num)
477
+
478
+ # ENV
479
+ elif self.ENV_PREFIX.match(line):
480
+ for env_name in self._env_names(line):
481
+ if self._is_secret_name(env_name):
482
+ self._info.has_secrets_in_env = True
483
+ if env_name not in self._info.secret_env_vars:
484
+ self._info.secret_env_vars.append(env_name)
485
+
486
+ # LABEL
487
+ elif match := self.LABEL_PREFIX.match(line):
488
+ for label_key, label_value in _parse_label_pairs(match.group(1)).items():
489
+ self._info.labels[label_key] = label_value
490
+ self._info.has_labels = True
491
+
492
+ # EXPOSE
493
+ elif match := self.EXPOSE_PATTERN.match(line):
494
+ port = _bounded_int(match.group(1), _MAX_PORT)
495
+ if port is not None and port not in self._info.exposes_ports:
496
+ self._info.exposes_ports.append(port)
497
+
498
+ # USER -- registrado no estágio corrente; qual deles vale é decidido
499
+ # em _resolve_final_stage().
500
+ elif match := self.USER_PATTERN.match(line):
501
+ if self._stages:
502
+ name, _, group = match.group(1).partition(":")
503
+ self._stages[-1].user = name
504
+ uid = match.group(2) or (group if group.isdigit() else None)
505
+ self._stages[-1].uid = _bounded_int(uid, _MAX_UID) if uid else None
506
+
507
+ # HEALTHCHECK
508
+ elif self.HEALTHCHECK_PATTERN.match(line):
509
+ self._info.has_healthcheck = True
510
+
511
+ # ENTRYPOINT
512
+ elif match := self.ENTRYPOINT_PATTERN.match(line):
513
+ self._info.entrypoint = match.group(1).strip()
514
+
515
+ # CMD
516
+ elif match := self.CMD_PATTERN.match(line):
517
+ self._info.cmd = match.group(1).strip()
518
+
519
+ # ARG
520
+ elif match := self.ARG_PATTERN.match(line):
521
+ arg_name = match.group(1)
522
+ if arg_name in ("BUILDKIT_INLINE_CACHE", "DOCKER_BUILDKIT"):
523
+ self._info.uses_buildkit = True
524
+ if arg_name.lower() in _PLATFORM_ARG_NAMES:
525
+ self._info.declares_multi_arch = True
526
+ if arg_name not in self._info.platform_args:
527
+ self._info.platform_args.append(arg_name)
528
+ # Um ARG **com valor** é um segredo escrito no Dockerfile e no
529
+ # histórico de camadas; um ARG sem valor é um parâmetro de build,
530
+ # e acusá-lo transformaria a forma correta de passar um segredo
531
+ # numa infração. A DF004 já dizia no seu texto que cobria "ENV e
532
+ # ARG" -- e não olhava ARG nenhum. Uma regra que afirma cobrir o
533
+ # que não cobre é pior do que não existir.
534
+ if match.group(2) and self._is_secret_name(arg_name):
535
+ self._info.has_secrets_in_build_args = True
536
+ if arg_name not in self._info.secret_build_args:
537
+ self._info.secret_build_args.append(arg_name)
538
+
539
+ @staticmethod
540
+ def _is_scratch(image: str) -> bool:
541
+ """`FROM scratch` é a imagem vazia embutida no Docker -- não é um
542
+ repositório e não tem tag nenhuma para pinar.
543
+
544
+ Tratá-la como "sem tag, logo :latest" reprovava com severidade HIGH
545
+ exatamente os Dockerfiles mais enxutos que existem (binário estático
546
+ sobre scratch), inclusive o template Go desta própria ferramenta.
547
+ """
548
+ # `FROM --platform=$BUILDPLATFORM scratch` também precisa casar.
549
+ parts = [p for p in image.split() if not p.startswith("--")]
550
+ return bool(parts) and parts[-1].lower() == "scratch"
551
+
552
+ @staticmethod
553
+ def _is_moving_reference(image: str) -> bool:
554
+ """Se este `FROM` aponta para algo que pode mudar debaixo do build.
555
+
556
+ A checagem anterior era `":latest" in image or (":" not in image and
557
+ "@" not in image)`, e errava nos dois sentidos:
558
+
559
+ * **Falso negativo, e é o grave.** `FROM registry.local:5000/app` não
560
+ tem tag -- o Docker resolve para `:latest` --, mas *tem* dois
561
+ pontos, no host. A regra concluía "está com tag" e dava PASS na
562
+ DF001, com severidade HIGH, exatamente para a base mais móvel que
563
+ existe. Toda organização com registry interno por porta escapava
564
+ da regra. `split_repository_and_tag` já sabia separar host de tag
565
+ e é a mesma pergunta sobre a mesma string, então é ela que
566
+ responde aqui -- duas leituras diferentes de "isto está fixado?"
567
+ no mesmo binário é como um dos dois lados fica errado sem que
568
+ ninguém note.
569
+ * **Falso positivo.** `:latest` como substring casa com
570
+ `FROM ghcr.io/org/app:latest-stable`, que é uma tag comum e não é
571
+ `latest`. A comparação agora é com a tag inteira.
572
+
573
+ Um digest (`@sha256:...`) nunca move, tenha tag ou não.
574
+ """
575
+ reference = next((p for p in image.split() if not p.startswith("--")), "")
576
+ if not reference or "$" in reference:
577
+ # `FROM $BASE_IMAGE` é resolvido por um ARG que este parser não
578
+ # avalia. Chamar isso de fixado ou de móvel seria inventar; a
579
+ # DF001 não fala sobre o que não leu.
580
+ return False
581
+ if "@sha256:" in reference:
582
+ return False
583
+ _, tag = split_repository_and_tag(reference)
584
+ return not tag or tag.lower() == "latest"
585
+
586
+ def _env_names(self, line: str) -> list[str]:
587
+ """Nomes de variáveis declarados numa linha ENV.
588
+
589
+ Cobre as duas formas que o Docker aceita, e a antiga regex cobria
590
+ meia: `ENV A=1 B=2` (só via `A`, então um segredo em `B` passava
591
+ batido) e `ENV KEY value` (não casava, então nunca era verificada).
592
+ A regra do Docker para distinguir: se o primeiro token tem `=`, é a
593
+ forma de múltiplos pares.
594
+ """
595
+ match = self.ENV_PREFIX.match(line)
596
+ if not match:
597
+ return []
598
+
599
+ body = match.group(1).strip()
600
+ first = body.split(maxsplit=1)[0] if body else ""
601
+ if "=" not in first:
602
+ return [first] if first else []
603
+ return self.ENV_KV.findall(body)
604
+
605
+ def _record_add(self, body: str, line_num: int) -> None:
606
+ """Registra um `ADD` e o que nele é diferente de um `COPY`.
607
+
608
+ `ADD` faz duas coisas que `COPY` não faz, e as duas são a regra:
609
+
610
+ * **busca uma URL**, sem conferir assinatura nem checksum, e o
611
+ resultado vira uma camada da imagem. Quem controlar aquele host,
612
+ ou o caminho até ele, escolhe o que roda dentro do container;
613
+ * **descompacta um arquivo local sozinho**. Um tarball com
614
+ `../../etc/passwd` dentro escreve fora do destino, e o Dockerfile
615
+ não diz em lugar nenhum que uma extração vai acontecer.
616
+
617
+ Um `ADD --checksum=sha256:...` de uma URL é a forma que o BuildKit
618
+ oferece para fazer isso com integridade, e não é acusada: o que a
619
+ regra combate é o download não verificado, não a diretiva.
620
+ """
621
+ try:
622
+ parts = shlex.split(body)
623
+ except ValueError:
624
+ # Aspas desbalanceadas: a linha é registrada mesmo assim, com os
625
+ # tokens crus. Deixar de registrar seria a diretiva sumindo por
626
+ # causa de um erro de digitação.
627
+ parts = body.split()
628
+
629
+ flags = [p for p in parts if p.startswith("--")]
630
+ operands = [p for p in parts if not p.startswith("--")]
631
+ checksummed = any(f.lower().startswith("--checksum=") for f in flags)
632
+ sources = operands[:-1] if len(operands) > 1 else operands
633
+ self._info.add_commands.append(
634
+ {
635
+ "line": line_num,
636
+ "sources": sources,
637
+ "destination": operands[-1] if len(operands) > 1 else "",
638
+ "remote": [s for s in sources if _ADD_REMOTE.match(s)],
639
+ "archives": [
640
+ s for s in sources if not _ADD_REMOTE.match(s) and _ADD_ARCHIVE.search(s)
641
+ ],
642
+ "checksummed": checksummed,
643
+ }
644
+ )
645
+
646
+ def _is_secret_name(self, name: str) -> bool:
647
+ """Verifica se um nome de variável parece ser um segredo."""
648
+ return any(re.search(pattern, name) for pattern in self.SECRET_ENV_PATTERNS)
649
+
650
+ def _record_unpinned_packages(self, unquoted_cmd: str, line_num: int) -> None:
651
+ """Pacotes instalados sem versão fixada, um `RUN` de cada vez.
652
+
653
+ Reprodutibilidade, não confidencialidade: `apt-get install curl` sem
654
+ `=<versão>` instala o que quer que esteja no índice hoje, e a mesma
655
+ linha pode instalar bytes diferentes amanhã -- inclusive uma versão
656
+ com uma CVE que a de ontem não tinha.
657
+ """
658
+ for match in _APT_INSTALL.finditer(unquoted_cmd):
659
+ for pkg in _unpinned_from(match.group(1), manager="apt", pin_marker="="):
660
+ self._info.unpinned_packages.append(
661
+ {"line": line_num, "manager": "apt", "package": pkg}
662
+ )
663
+ for match in _APK_ADD.finditer(unquoted_cmd):
664
+ for pkg in _unpinned_from(match.group(1), manager="apk", pin_marker="="):
665
+ self._info.unpinned_packages.append(
666
+ {"line": line_num, "manager": "apk", "package": pkg}
667
+ )
668
+ for match in _PIP_INSTALL.finditer(unquoted_cmd):
669
+ for pkg in _unpinned_from(
670
+ match.group(1), manager="pip", pin_marker="==", file_flags=_PIP_FILE_FLAGS
671
+ ):
672
+ self._info.unpinned_packages.append(
673
+ {"line": line_num, "manager": "pip", "package": pkg}
674
+ )
675
+ for match in _NPM_INSTALL.finditer(unquoted_cmd):
676
+ for pkg in _npm_targets(match.group(1)):
677
+ self._info.unpinned_packages.append(
678
+ {"line": line_num, "manager": "npm", "package": pkg}
679
+ )
680
+
681
+
682
+ class DockerfileValidator(DockerfileValidatorInterface):
683
+ """Validador de Dockerfiles baseado em regras OWASP."""
684
+
685
+ def __init__(self, template_provider: HardeningTemplateProvider | None = None):
686
+ self._parser = DockerfileParser()
687
+ self._template_provider = template_provider
688
+
689
+ def validate(self, dockerfile_path: str | Path) -> DockerfileValidationResult:
690
+ """Valida um Dockerfile contra regras OWASP."""
691
+ path = Path(dockerfile_path)
692
+ if path.is_dir():
693
+ path = path / "Dockerfile"
694
+
695
+ if not path.exists():
696
+ raise FileNotFoundError(f"Dockerfile not found at {path}")
697
+
698
+ content = path.read_text(encoding="utf-8")
699
+ info = self._parser.parse(content)
700
+
701
+ result = DockerfileValidationResult(
702
+ dockerfile_path=str(path),
703
+ metadata={
704
+ "base_images": info.base_images,
705
+ "stages": info.stages,
706
+ },
707
+ )
708
+
709
+ # Executar todas as verificações
710
+ self._check_base_image(info, result)
711
+ self._check_non_root_user(info, result)
712
+ self._check_multi_stage(info, result)
713
+ self._check_secrets_in_env(info, result)
714
+ self._check_package_cache(info, result)
715
+ self._check_healthcheck(info, result)
716
+ self._check_security_labels(info, result)
717
+ self._check_minimal_base(info, result)
718
+ self._check_no_sudo(info, result)
719
+ self._check_entrypoint_form(info, result)
720
+ self._check_shell_usage(info, result)
721
+ self._check_dockerignore(info, result, path.parent)
722
+ self._check_add_vs_copy(info, result)
723
+ self._check_remote_script_execution(info, result)
724
+ self._check_setuid(info, result)
725
+ self._check_pinned_package_versions(info, result, path.parent)
726
+ self._check_multi_arch_awareness(info, result)
727
+
728
+ return result
729
+
730
+ def analyze(self, dockerfile_path: str | Path) -> DockerfileAnalysis:
731
+ """Analisa um Dockerfile e retorna análise completa."""
732
+ path = Path(dockerfile_path)
733
+ if path.is_dir():
734
+ path = path / "Dockerfile"
735
+
736
+ if not path.exists():
737
+ raise FileNotFoundError(f"Dockerfile not found at {path}")
738
+
739
+ content = path.read_text(encoding="utf-8")
740
+ info = self._parser.parse(content)
741
+ validation = self.validate(path)
742
+
743
+ # Calcular score de segurança
744
+ security_score = self._calculate_security_score(validation)
745
+ security_tier = self._calculate_security_tier(security_score, validation)
746
+
747
+ return DockerfileAnalysis(
748
+ info=info,
749
+ validation=validation,
750
+ security_score=security_score,
751
+ security_tier=security_tier,
752
+ template_origin=self._detect_template_origin(content),
753
+ )
754
+
755
+ #: Abaixo disto, dois Dockerfiles quaisquer se parecem por acidente --
756
+ #: mesmas diretivas comuns (`FROM`, `USER`, `WORKDIR`), não parentesco.
757
+ #: Escolhido por inspeção: comparar o `alpine.dockerfile` hardened contra
758
+ #: o `debian.dockerfile` hardened (mesma família de regras, SO
759
+ #: diferente) fica bem abaixo disso.
760
+ _TEMPLATE_MATCH_THRESHOLD = 0.55
761
+
762
+ def _detect_template_origin(self, content: str) -> TemplateOriginMatch | None:
763
+ """Se este Dockerfile parece um dos 39 templates hardened, editado.
764
+
765
+ Só roda quando o validador foi construído com um `template_provider`
766
+ -- sem ele, não há com o que comparar, e ficar calado é a resposta
767
+ certa, não um erro. Compara contra os 39 de uma vez: os arquivos são
768
+ pequenos (algumas dezenas de linhas) e a comparação é local, sem
769
+ rede nem disco fora dos templates já embutidos no pacote.
770
+ """
771
+ if self._template_provider is None:
772
+ return None
773
+
774
+ best_name = ""
775
+ best_ratio = 0.0
776
+ best_lines: list[str] = []
777
+ our_lines = content.splitlines()
778
+
779
+ for name in self._template_provider.list_templates():
780
+ try:
781
+ template_text = self._template_provider.get_template(name)
782
+ except (UnknownHardeningTemplateError, OSError):
783
+ # Um template ilegível não pode abortar analyze() inteiro --
784
+ # ele só fica fora da comparação.
785
+ continue
786
+ template_lines = template_text.splitlines()
787
+ ratio = difflib.SequenceMatcher(a=template_lines, b=our_lines).ratio()
788
+ if ratio > best_ratio:
789
+ best_ratio = ratio
790
+ best_name = name
791
+ best_lines = template_lines
792
+
793
+ if best_ratio < self._TEMPLATE_MATCH_THRESHOLD:
794
+ return None
795
+
796
+ diff = list(
797
+ difflib.unified_diff(
798
+ best_lines,
799
+ our_lines,
800
+ fromfile=f"{best_name} (template)",
801
+ tofile="this Dockerfile",
802
+ lineterm="",
803
+ )
804
+ )
805
+ return TemplateOriginMatch(
806
+ template_name=best_name, similarity=round(best_ratio, 3), diff=diff
807
+ )
808
+
809
+ def suggest_hardening(self, dockerfile_path: str | Path) -> list[HardeningRule]:
810
+ """Sugere melhorias de hardening para um Dockerfile."""
811
+ analysis = self.analyze(dockerfile_path)
812
+ suggestions = []
813
+
814
+ # Base image upgrade
815
+ if analysis.info.uses_latest_tag or not self._is_minimal_base(analysis.info):
816
+ base = analysis.info.base_images[0] if analysis.info.base_images else "unknown"
817
+ suggestions.append(
818
+ HardeningRule(
819
+ priority=SeverityLevel.HIGH,
820
+ title="Upgrade base image",
821
+ description="Use a pinned, minimal base image",
822
+ current_state=base,
823
+ # Esta sugestão era a string fixa
824
+ # `"FROM node:22-alpine or FROM chainguard/node:latest-dev"`,
825
+ # devolvida igual para qualquer Dockerfile -- inclusive um
826
+ # de Python, onde nomear uma imagem Node é simplesmente
827
+ # errado. Nomear uma imagem que ninguém mediu é o oposto do
828
+ # que esta ferramenta faz em todo o resto, então ela agora
829
+ # aponta para os comandos que medem de verdade.
830
+ suggested_fix=(
831
+ "dockerls base --dry-run # pins the base by digest\n"
832
+ "dockerls base --alternatives # measures safer alternatives"
833
+ ),
834
+ reason=(
835
+ "Pinned versions ensure reproducibility; minimal bases reduce "
836
+ "attack surface. Which base is actually safer for this project "
837
+ "is a measurement, not a name -- `dockerls base --alternatives` "
838
+ "scans the current base alongside the candidates"
839
+ ),
840
+ )
841
+ )
842
+
843
+ # Non-root user
844
+ if not analysis.info.has_user_directive:
845
+ suggestions.append(
846
+ HardeningRule(
847
+ priority=SeverityLevel.HIGH,
848
+ title="Add non-root user",
849
+ description="Container should not run as root",
850
+ current_state="No USER directive",
851
+ suggested_fix="RUN adduser -D appuser && USER appuser",
852
+ reason="Running as root increases impact of container breakout",
853
+ )
854
+ )
855
+
856
+ # Secrets in ENV
857
+ if analysis.info.has_secrets_in_env:
858
+ suggestions.append(
859
+ HardeningRule(
860
+ priority=SeverityLevel.CRITICAL,
861
+ title="Remove secrets from ENV",
862
+ description="Secrets in ENV are visible in image history",
863
+ current_state=f"Secrets: {', '.join(analysis.info.secret_env_vars)}",
864
+ suggested_fix="Use BuildKit secrets: RUN --mount=type=secret,id=token",
865
+ reason="ENV values persist in all layers and can be extracted",
866
+ )
867
+ )
868
+
869
+ # Healthcheck
870
+ if not analysis.info.has_healthcheck:
871
+ suggestions.append(
872
+ HardeningRule(
873
+ priority=SeverityLevel.LOW,
874
+ title="Add HEALTHCHECK",
875
+ description="Containers should have health checks",
876
+ current_state="No HEALTHCHECK directive",
877
+ suggested_fix="HEALTHCHECK --interval=30s --timeout=5s CMD curl http://localhost/health",
878
+ reason="Health checks enable orchestration platforms to detect failures",
879
+ )
880
+ )
881
+
882
+ # Security labels
883
+ if not analysis.info.has_labels or "security.scanner" not in analysis.info.labels:
884
+ suggestions.append(
885
+ HardeningRule(
886
+ priority=SeverityLevel.LOW,
887
+ title="Add security labels",
888
+ description="Labels improve traceability and incident response",
889
+ current_state="Missing security labels",
890
+ suggested_fix=(
891
+ 'LABEL security.scanner="dockerls"\n'
892
+ 'LABEL security.cve-contact="security@company.com"'
893
+ ),
894
+ reason=(
895
+ "Labels enable automated policy enforcement and contact during incidents"
896
+ ),
897
+ )
898
+ )
899
+
900
+ # Package cache
901
+ if analysis.info.package_managers_used and not analysis.info.cache_cleaned:
902
+ suggestions.append(
903
+ HardeningRule(
904
+ priority=SeverityLevel.MEDIUM,
905
+ title="Clean package manager cache",
906
+ description="Package caches increase image size unnecessarily",
907
+ current_state="Cache not cleaned",
908
+ suggested_fix=(
909
+ "Add && rm -rf /var/cache/apk/* || rm -rf /var/cache/apt/archives"
910
+ ),
911
+ reason="Smaller images have smaller attack surface and faster pulls",
912
+ )
913
+ )
914
+
915
+ # Multi-stage
916
+ if analysis.info.stages < 2 and len(analysis.info.package_managers_used) > 0:
917
+ suggestions.append(
918
+ HardeningRule(
919
+ priority=SeverityLevel.MEDIUM,
920
+ title="Use multi-stage build",
921
+ description="Multi-stage builds reduce final image size",
922
+ current_state=f"Single stage ({analysis.info.stages} stage(s))",
923
+ suggested_fix="Create separate builder and runtime stages",
924
+ reason="Build tools and intermediate files don't belong in production images",
925
+ )
926
+ )
927
+
928
+ return suggestions
929
+
930
+ def _check_base_image(self, info: DockerfileInfo, result: DockerfileValidationResult) -> None:
931
+ """Verifica se a base image usa tag pinned."""
932
+ if info.uses_latest_tag:
933
+ result.add_check(
934
+ ValidationCheck(
935
+ check="base_image_pinned",
936
+ status=ValidationStatus.FAIL,
937
+ message="Base image uses 'latest' tag or no tag (implies latest)",
938
+ severity=SeverityLevel.HIGH,
939
+ rule_id="DF001",
940
+ fix_suggestion="Use specific version: FROM node:22-alpine (not :latest)",
941
+ details={"base_images": info.base_images},
942
+ )
943
+ )
944
+ else:
945
+ # PASS aqui significa apenas "não é `latest`". Dizer "pinned" sem
946
+ # mais nada punha um ✅ ao lado de `node:22` na mesma tela em que a
947
+ # política reprovava a mesma linha por "não está fixada por
948
+ # digest" -- duas frases verdadeiras que, lidas juntas, pareciam
949
+ # contradição. A distinção agora está na própria mensagem.
950
+ por_digest = all("@sha256:" in reference for reference in info.base_images)
951
+ message = (
952
+ "Base image pinned by digest"
953
+ if por_digest
954
+ else "Base image tag is not 'latest' (still a moving tag -- "
955
+ "`dockerls base` pins it by digest)"
956
+ )
957
+ result.add_check(
958
+ ValidationCheck(
959
+ check="base_image_pinned",
960
+ status=ValidationStatus.PASS,
961
+ message=message,
962
+ severity=SeverityLevel.INFO,
963
+ rule_id="DF001",
964
+ details={"base_images": info.base_images, "pinned_by_digest": por_digest},
965
+ )
966
+ )
967
+
968
+ def _check_non_root_user(
969
+ self, info: DockerfileInfo, result: DockerfileValidationResult
970
+ ) -> None:
971
+ """Verifica se o container roda como usuário não-root.
972
+
973
+ `USER 0` e `USER 0:0` são root tanto quanto `USER root` -- e passavam,
974
+ porque a checagem só comparava com a string "root". Um Dockerfile
975
+ podia rodar como uid 0 e ainda assim receber PASS nesta regra.
976
+ """
977
+ if info.has_user_directive and info.user_name and not self._is_root_user(info):
978
+ result.add_check(
979
+ ValidationCheck(
980
+ check="non_root_user",
981
+ status=ValidationStatus.PASS,
982
+ message=f"Container runs as non-root user: {info.user_name}",
983
+ severity=SeverityLevel.INFO,
984
+ rule_id="DF002",
985
+ details={"user": info.user_name, "uid": info.user_uid},
986
+ )
987
+ )
988
+ else:
989
+ result.add_check(
990
+ ValidationCheck(
991
+ check="non_root_user",
992
+ status=ValidationStatus.FAIL,
993
+ message="Container runs as root (no USER directive or USER root)",
994
+ severity=SeverityLevel.HIGH,
995
+ rule_id="DF002",
996
+ fix_suggestion="ADD USER appuser\nUSER appuser",
997
+ )
998
+ )
999
+
1000
+ @staticmethod
1001
+ def _is_root_user(info: DockerfileInfo) -> bool:
1002
+ name = (info.user_name or "").strip().lower()
1003
+ return name in ("root", "0") or info.user_uid == 0
1004
+
1005
+ def _check_multi_stage(self, info: DockerfileInfo, result: DockerfileValidationResult) -> None:
1006
+ """Verifica se usa multi-stage build."""
1007
+ if info.stages > 1:
1008
+ result.add_check(
1009
+ ValidationCheck(
1010
+ check="multi_stage_build",
1011
+ status=ValidationStatus.PASS,
1012
+ message=f"Multi-stage build detected ({info.stages} stages)",
1013
+ severity=SeverityLevel.INFO,
1014
+ rule_id="DF003",
1015
+ details={"stages": info.stages},
1016
+ )
1017
+ )
1018
+ else:
1019
+ result.add_check(
1020
+ ValidationCheck(
1021
+ check="multi_stage_build",
1022
+ status=ValidationStatus.WARN,
1023
+ message="Single-stage build detected",
1024
+ severity=SeverityLevel.MEDIUM,
1025
+ rule_id="DF003",
1026
+ fix_suggestion="Create builder stage separate from runtime",
1027
+ )
1028
+ )
1029
+
1030
+ def _check_secrets_in_env(
1031
+ self, info: DockerfileInfo, result: DockerfileValidationResult
1032
+ ) -> None:
1033
+ """Verifica se há segredos em ENV **ou em ARG**.
1034
+
1035
+ A DF004 se chama "Keep secrets out of ENV and ARG" desde sempre, no
1036
+ catálogo de controles, e não olhava ARG nenhum: um
1037
+ `ARG GITHUB_TOKEN=ghp_...` passava com PASS e a mensagem "No obvious
1038
+ secrets". Uma regra que afirma cobrir o que não cobre é pior que
1039
+ regra nenhuma, porque o PASS é lido como uma verificação que
1040
+ aconteceu.
1041
+
1042
+ Um ARG **com valor** é o caso: o valor fica escrito no Dockerfile e
1043
+ no histórico da camada. Um `ARG TOKEN` sem valor é a forma correta
1044
+ de parametrizar um build e não é acusado.
1045
+ """
1046
+ env_vars = list(info.secret_env_vars)
1047
+ arg_vars = list(info.secret_build_args)
1048
+ if env_vars or arg_vars:
1049
+ partes = []
1050
+ if env_vars:
1051
+ partes.append(f"ENV: {', '.join(env_vars)}")
1052
+ if arg_vars:
1053
+ partes.append(f"ARG with a default value: {', '.join(arg_vars)}")
1054
+ result.add_check(
1055
+ ValidationCheck(
1056
+ check="secrets_not_in_env",
1057
+ status=ValidationStatus.FAIL,
1058
+ message=f"Potential secrets in {'; '.join(partes)}",
1059
+ severity=SeverityLevel.CRITICAL,
1060
+ rule_id="DF004",
1061
+ fix_suggestion=(
1062
+ "Use BuildKit: RUN --mount=type=secret,id=token\n"
1063
+ "An ARG default is written into the Dockerfile and the layer "
1064
+ "history; declare `ARG TOKEN` with no value and pass it at "
1065
+ "build time if it has to be an ARG at all."
1066
+ ),
1067
+ details={"secret_vars": env_vars, "secret_build_args": arg_vars},
1068
+ )
1069
+ )
1070
+ else:
1071
+ result.add_check(
1072
+ ValidationCheck(
1073
+ check="secrets_not_in_env",
1074
+ status=ValidationStatus.PASS,
1075
+ message="No obvious secrets in ENV variables or ARG defaults",
1076
+ severity=SeverityLevel.INFO,
1077
+ rule_id="DF004",
1078
+ )
1079
+ )
1080
+
1081
+ # Usar `--mount=type=secret` é a resposta correta a esta regra, e
1082
+ # merece dizer isso -- sem virar desculpa para um segredo que ainda
1083
+ # esteja em ENV/ARG: o check acima continua reprovando esse caso
1084
+ # mesmo quando este aqui também aparece com PASS.
1085
+ if info.uses_secret_mount:
1086
+ ids = ", ".join(info.secret_mount_ids) if info.secret_mount_ids else "unnamed"
1087
+ result.add_check(
1088
+ ValidationCheck(
1089
+ check="buildkit_secret_mount_used",
1090
+ status=ValidationStatus.PASS,
1091
+ message=f"Uses BuildKit --mount=type=secret ({ids}) instead of ENV/ARG",
1092
+ severity=SeverityLevel.INFO,
1093
+ rule_id="DF004",
1094
+ )
1095
+ )
1096
+
1097
+ def _check_package_cache(
1098
+ self, info: DockerfileInfo, result: DockerfileValidationResult
1099
+ ) -> None:
1100
+ """Verifica se o cache do package manager foi limpo."""
1101
+ if info.package_managers_used:
1102
+ if info.cache_cleaned:
1103
+ result.add_check(
1104
+ ValidationCheck(
1105
+ check="package_cache_clean",
1106
+ status=ValidationStatus.PASS,
1107
+ message="Package manager cache is cleaned",
1108
+ severity=SeverityLevel.INFO,
1109
+ rule_id="DF005",
1110
+ )
1111
+ )
1112
+ else:
1113
+ result.add_check(
1114
+ ValidationCheck(
1115
+ check="package_cache_clean",
1116
+ status=ValidationStatus.WARN,
1117
+ message="Package manager cache not cleaned",
1118
+ severity=SeverityLevel.MEDIUM,
1119
+ rule_id="DF005",
1120
+ fix_suggestion=(
1121
+ "Add: && rm -rf /var/cache/apk/* || rm -rf /var/cache/apt/archives"
1122
+ ),
1123
+ )
1124
+ )
1125
+
1126
+ def _check_healthcheck(self, info: DockerfileInfo, result: DockerfileValidationResult) -> None:
1127
+ """Verifica se existe HEALTHCHECK."""
1128
+ if info.has_healthcheck:
1129
+ result.add_check(
1130
+ ValidationCheck(
1131
+ check="healthcheck_present",
1132
+ status=ValidationStatus.PASS,
1133
+ message="HEALTHCHECK directive present",
1134
+ severity=SeverityLevel.INFO,
1135
+ rule_id="DF006",
1136
+ )
1137
+ )
1138
+ else:
1139
+ result.add_check(
1140
+ ValidationCheck(
1141
+ check="healthcheck_present",
1142
+ status=ValidationStatus.WARN,
1143
+ message="No HEALTHCHECK directive",
1144
+ severity=SeverityLevel.LOW,
1145
+ rule_id="DF006",
1146
+ fix_suggestion="HEALTHCHECK --interval=30s --timeout=5s CMD curl http://localhost/health",
1147
+ )
1148
+ )
1149
+
1150
+ def _check_security_labels(
1151
+ self, info: DockerfileInfo, result: DockerfileValidationResult
1152
+ ) -> None:
1153
+ """Verifica se existem labels de segurança."""
1154
+ required_labels = ["security.scanner", "maintainer"]
1155
+ missing = [lbl for lbl in required_labels if lbl not in info.labels]
1156
+
1157
+ if not missing:
1158
+ result.add_check(
1159
+ ValidationCheck(
1160
+ check="security_labels",
1161
+ status=ValidationStatus.PASS,
1162
+ message="Security labels present",
1163
+ severity=SeverityLevel.INFO,
1164
+ rule_id="DF007",
1165
+ )
1166
+ )
1167
+ else:
1168
+ result.add_check(
1169
+ ValidationCheck(
1170
+ check="security_labels",
1171
+ status=ValidationStatus.WARN,
1172
+ message=f"Missing security labels: {', '.join(missing)}",
1173
+ severity=SeverityLevel.LOW,
1174
+ rule_id="DF007",
1175
+ fix_suggestion=(
1176
+ 'LABEL security.scanner="dockerls"\nLABEL maintainer="team@company.com"'
1177
+ ),
1178
+ )
1179
+ )
1180
+
1181
+ def _check_minimal_base(self, info: DockerfileInfo, result: DockerfileValidationResult) -> None:
1182
+ """Verifica se a base image é minimal."""
1183
+ if self._is_minimal_base(info):
1184
+ result.add_check(
1185
+ ValidationCheck(
1186
+ check="minimal_base",
1187
+ status=ValidationStatus.PASS,
1188
+ message="Using minimal base image",
1189
+ severity=SeverityLevel.INFO,
1190
+ rule_id="DF008",
1191
+ )
1192
+ )
1193
+ else:
1194
+ result.add_check(
1195
+ ValidationCheck(
1196
+ check="minimal_base",
1197
+ status=ValidationStatus.WARN,
1198
+ message="Base image may not be minimal (consider Alpine or Distroless)",
1199
+ severity=SeverityLevel.MEDIUM,
1200
+ rule_id="DF008",
1201
+ fix_suggestion="FROM alpine:latest or FROM gcr.io/distroless/nodejs",
1202
+ )
1203
+ )
1204
+
1205
+ def _is_minimal_base(self, info: DockerfileInfo) -> bool:
1206
+ """Verifica se a base do estágio **final** é minimal.
1207
+
1208
+ Antes bastava qualquer estágio ser minimal: um builder em Alpine
1209
+ fazia um runtime em Ubuntu passar. O que vai para produção é só o
1210
+ último estágio.
1211
+ """
1212
+ minimal_markers = ["alpine", "distroless", "slim", "chainguard", "wolfi"]
1213
+ base = info.final_base_image or (info.base_images[-1] if info.base_images else "")
1214
+ return any(marker in base.lower() for marker in minimal_markers)
1215
+
1216
+ def _check_no_sudo(self, info: DockerfileInfo, result: DockerfileValidationResult) -> None:
1217
+ """Verifica se usa sudo."""
1218
+ if info.uses_sudo:
1219
+ result.add_check(
1220
+ ValidationCheck(
1221
+ check="no_sudo",
1222
+ status=ValidationStatus.FAIL,
1223
+ message="sudo usage detected",
1224
+ severity=SeverityLevel.HIGH,
1225
+ rule_id="DF009",
1226
+ fix_suggestion="Remove sudo dependency",
1227
+ )
1228
+ )
1229
+ else:
1230
+ result.add_check(
1231
+ ValidationCheck(
1232
+ check="no_sudo",
1233
+ status=ValidationStatus.PASS,
1234
+ message="No sudo usage detected",
1235
+ severity=SeverityLevel.INFO,
1236
+ rule_id="DF009",
1237
+ )
1238
+ )
1239
+
1240
+ def _check_entrypoint_form(
1241
+ self, info: DockerfileInfo, result: DockerfileValidationResult
1242
+ ) -> None:
1243
+ """Verifica se ENTRYPOINT usa forma exec (não shell)."""
1244
+ if not info.entrypoint:
1245
+ # SKIP, não silêncio: um check que simplesmente some da tabela é
1246
+ # indistinguível de um check que passou.
1247
+ result.add_check(
1248
+ ValidationCheck(
1249
+ check="entrypoint_exec_form",
1250
+ status=ValidationStatus.SKIP,
1251
+ message="No ENTRYPOINT directive to check",
1252
+ severity=SeverityLevel.INFO,
1253
+ rule_id="DF010",
1254
+ )
1255
+ )
1256
+ else:
1257
+ # Exec form starts with [
1258
+ if info.entrypoint.startswith("["):
1259
+ result.add_check(
1260
+ ValidationCheck(
1261
+ check="entrypoint_exec_form",
1262
+ status=ValidationStatus.PASS,
1263
+ message="ENTRYPOINT uses exec form",
1264
+ severity=SeverityLevel.INFO,
1265
+ rule_id="DF010",
1266
+ )
1267
+ )
1268
+ else:
1269
+ result.add_check(
1270
+ ValidationCheck(
1271
+ check="entrypoint_exec_form",
1272
+ status=ValidationStatus.WARN,
1273
+ message="ENTRYPOINT uses shell form (should use exec form)",
1274
+ severity=SeverityLevel.MEDIUM,
1275
+ rule_id="DF010",
1276
+ fix_suggestion='ENTRYPOINT ["node"] instead of ENTRYPOINT node',
1277
+ )
1278
+ )
1279
+
1280
+ def _check_shell_usage(self, info: DockerfileInfo, result: DockerfileValidationResult) -> None:
1281
+ """Verifica se CMD usa forma exec (não shell).
1282
+
1283
+ Este check devolvia PASS incondicionalmente -- não olhava nada. Uma
1284
+ regra que sempre passa é pior que regra nenhuma: ela afirma ao
1285
+ usuário que o ponto foi verificado, e ainda infla o score.
1286
+
1287
+ Na forma shell o processo vira filho de `/bin/sh -c`, que não repassa
1288
+ sinais: o container ignora SIGTERM e morre no SIGKILL do timeout.
1289
+ """
1290
+ if not info.cmd:
1291
+ result.add_check(
1292
+ ValidationCheck(
1293
+ check="shell_usage",
1294
+ status=ValidationStatus.SKIP,
1295
+ message="No CMD directive to check",
1296
+ severity=SeverityLevel.INFO,
1297
+ rule_id="DF011",
1298
+ )
1299
+ )
1300
+ elif info.cmd.startswith("["):
1301
+ result.add_check(
1302
+ ValidationCheck(
1303
+ check="shell_usage",
1304
+ status=ValidationStatus.PASS,
1305
+ message="CMD uses exec form",
1306
+ severity=SeverityLevel.INFO,
1307
+ rule_id="DF011",
1308
+ )
1309
+ )
1310
+ else:
1311
+ result.add_check(
1312
+ ValidationCheck(
1313
+ check="shell_usage",
1314
+ status=ValidationStatus.WARN,
1315
+ message="CMD uses shell form (signals are not forwarded to the process)",
1316
+ severity=SeverityLevel.MEDIUM,
1317
+ rule_id="DF011",
1318
+ fix_suggestion='CMD ["npm", "start"] instead of CMD npm start',
1319
+ )
1320
+ )
1321
+
1322
+ def _check_dockerignore(
1323
+ self, info: DockerfileInfo, result: DockerfileValidationResult, context_path: Path
1324
+ ) -> None:
1325
+ """Verifica se .dockerignore existe e é adequado."""
1326
+ dockerignore_path = context_path / ".dockerignore"
1327
+
1328
+ if dockerignore_path.exists():
1329
+ try:
1330
+ content = dockerignore_path.read_text(encoding="utf-8", errors="replace").lower()
1331
+ except OSError as e:
1332
+ # Present but unreadable is its own answer -- reporting it as
1333
+ # SKIP is honest, where letting the OSError escape aborted
1334
+ # the entire validation over one optional check.
1335
+ result.add_check(
1336
+ ValidationCheck(
1337
+ check="dockerignore_complete",
1338
+ status=ValidationStatus.SKIP,
1339
+ message=f".dockerignore could not be read: {e}",
1340
+ severity=SeverityLevel.INFO,
1341
+ rule_id="DF012",
1342
+ )
1343
+ )
1344
+ return
1345
+ recommended = [".git", ".env", "node_modules", "__pycache__", "*.log"]
1346
+ missing = [item for item in recommended if item not in content]
1347
+
1348
+ if missing:
1349
+ result.add_check(
1350
+ ValidationCheck(
1351
+ check="dockerignore_complete",
1352
+ status=ValidationStatus.WARN,
1353
+ message=f".dockerignore missing recommended entries: {', '.join(missing)}",
1354
+ severity=SeverityLevel.LOW,
1355
+ rule_id="DF012",
1356
+ )
1357
+ )
1358
+ else:
1359
+ result.add_check(
1360
+ ValidationCheck(
1361
+ check="dockerignore_complete",
1362
+ status=ValidationStatus.PASS,
1363
+ message=".dockerignore contains recommended entries",
1364
+ severity=SeverityLevel.INFO,
1365
+ rule_id="DF012",
1366
+ )
1367
+ )
1368
+ else:
1369
+ result.add_check(
1370
+ ValidationCheck(
1371
+ check="dockerignore_exists",
1372
+ status=ValidationStatus.WARN,
1373
+ message=".dockerignore not found",
1374
+ severity=SeverityLevel.LOW,
1375
+ rule_id="DF012",
1376
+ fix_suggestion="Create .dockerignore with .git, .env, node_modules, etc.",
1377
+ )
1378
+ )
1379
+
1380
+ def _check_add_vs_copy(self, info: DockerfileInfo, result: DockerfileValidationResult) -> None:
1381
+ """DF013 -- `ADD` faz duas coisas que `COPY` não faz.
1382
+
1383
+ Não é estilo. `ADD` de uma URL baixa e assa na imagem bytes que nada
1384
+ conferiu, e `ADD` de um tarball local o **descompacta sozinho**, o
1385
+ que transforma um arquivo com `../../etc/` dentro em escrita fora do
1386
+ destino. Nenhuma das duas está escrita na linha; quem lê o
1387
+ Dockerfile vê o que parece um `COPY`.
1388
+
1389
+ `ADD --checksum=sha256:...` de uma URL é a forma que o BuildKit
1390
+ oferece para baixar com integridade, e passa: o alvo é o download
1391
+ não verificado, não a diretiva.
1392
+ """
1393
+ remote = [a for a in info.add_commands if a["remote"] and not a["checksummed"]]
1394
+ archives = [a for a in info.add_commands if a["archives"]]
1395
+
1396
+ if not info.add_commands:
1397
+ result.add_check(
1398
+ ValidationCheck(
1399
+ check="add_not_used_for_copy",
1400
+ status=ValidationStatus.PASS,
1401
+ message="No ADD directives (COPY is used to bring files in)",
1402
+ severity=SeverityLevel.INFO,
1403
+ rule_id="DF013",
1404
+ )
1405
+ )
1406
+ return
1407
+
1408
+ if remote:
1409
+ sources = ", ".join(s for a in remote for s in a["remote"])
1410
+ result.add_check(
1411
+ ValidationCheck(
1412
+ check="add_not_used_for_copy",
1413
+ status=ValidationStatus.FAIL,
1414
+ message=(
1415
+ f"ADD fetches over the network without verifying what it got: {sources}"
1416
+ ),
1417
+ severity=SeverityLevel.HIGH,
1418
+ rule_id="DF013",
1419
+ line=remote[0]["line"],
1420
+ fix_suggestion=(
1421
+ "RUN curl -fsSL <url> -o /tmp/f && echo '<sha256> /tmp/f' | sha256sum -c\n"
1422
+ "or, on BuildKit: ADD --checksum=sha256:<digest> <url> <dest>"
1423
+ ),
1424
+ details={"sources": sources},
1425
+ )
1426
+ )
1427
+ return
1428
+
1429
+ if archives:
1430
+ sources = ", ".join(s for a in archives for s in a["archives"])
1431
+ result.add_check(
1432
+ ValidationCheck(
1433
+ check="add_not_used_for_copy",
1434
+ status=ValidationStatus.WARN,
1435
+ message=(
1436
+ f"ADD auto-extracts these archives, which the line does not say: {sources}"
1437
+ ),
1438
+ severity=SeverityLevel.MEDIUM,
1439
+ rule_id="DF013",
1440
+ line=archives[0]["line"],
1441
+ fix_suggestion=(
1442
+ "COPY the archive and extract it explicitly:\n"
1443
+ "COPY app.tar.gz /tmp/\nRUN tar -xzf /tmp/app.tar.gz -C /app"
1444
+ ),
1445
+ details={"sources": sources},
1446
+ )
1447
+ )
1448
+ return
1449
+
1450
+ result.add_check(
1451
+ ValidationCheck(
1452
+ check="add_not_used_for_copy",
1453
+ status=ValidationStatus.WARN,
1454
+ message="ADD used where COPY would do the same thing with fewer behaviours",
1455
+ severity=SeverityLevel.LOW,
1456
+ rule_id="DF013",
1457
+ line=info.add_commands[0]["line"],
1458
+ fix_suggestion="Replace ADD with COPY for plain files and directories",
1459
+ )
1460
+ )
1461
+
1462
+ def _check_remote_script_execution(
1463
+ self, info: DockerfileInfo, result: DockerfileValidationResult
1464
+ ) -> None:
1465
+ """DF014 -- um script baixado da rede e executado sem ser conferido.
1466
+
1467
+ `curl ... | sh` não deixa o script em lugar nenhum: nada o assina,
1468
+ nada compara um checksum, nada o registra numa camada onde alguém
1469
+ pudesse lê-lo depois. Quem controlar aquele host -- ou o caminho até
1470
+ ele, ou o DNS no meio -- escolhe o que roda como root dentro do
1471
+ build, e o Dockerfile continua parecendo o mesmo.
1472
+
1473
+ `curl -o arquivo` seguido de um `sha256sum -c` é a forma correta, e
1474
+ não casa aqui: a regra olha o pipe entre o baixador e o
1475
+ interpretador, não cada metade sozinha.
1476
+ """
1477
+ if not info.pipes_remote_script_to_shell:
1478
+ result.add_check(
1479
+ ValidationCheck(
1480
+ check="no_unverified_remote_script",
1481
+ status=ValidationStatus.PASS,
1482
+ message="No remote script is piped straight into a shell",
1483
+ severity=SeverityLevel.INFO,
1484
+ rule_id="DF014",
1485
+ )
1486
+ )
1487
+ return
1488
+
1489
+ result.add_check(
1490
+ ValidationCheck(
1491
+ check="no_unverified_remote_script",
1492
+ status=ValidationStatus.FAIL,
1493
+ message=(
1494
+ "A script is downloaded and piped into a shell: nothing verifies "
1495
+ "what runs, and it runs as root during the build"
1496
+ ),
1497
+ severity=SeverityLevel.HIGH,
1498
+ rule_id="DF014",
1499
+ line=info.remote_script_lines[0] if info.remote_script_lines else None,
1500
+ fix_suggestion=(
1501
+ "Download, verify, then run:\n"
1502
+ "RUN curl -fsSL <url> -o /tmp/install.sh \\\n"
1503
+ " && echo '<sha256> /tmp/install.sh' | sha256sum -c \\\n"
1504
+ " && sh /tmp/install.sh && rm /tmp/install.sh"
1505
+ ),
1506
+ details={"lines": list(info.remote_script_lines)},
1507
+ )
1508
+ )
1509
+
1510
+ def _check_setuid(self, info: DockerfileInfo, result: DockerfileValidationResult) -> None:
1511
+ """DF015 -- bit setuid/setgid posto num binário da imagem.
1512
+
1513
+ Um binário setuid roda com o dono do arquivo, e o dono é root. Num
1514
+ container que faz a coisa certa e roda como usuário sem privilégio,
1515
+ é justamente o caminho pronto de volta para o uid 0: a única peça
1516
+ que faltava para transformar uma execução de comando limitada numa
1517
+ completa.
1518
+ """
1519
+ if not info.sets_setuid_bit:
1520
+ result.add_check(
1521
+ ValidationCheck(
1522
+ check="no_setuid_binaries_added",
1523
+ status=ValidationStatus.PASS,
1524
+ message="No setuid or setgid bit is set in the build",
1525
+ severity=SeverityLevel.INFO,
1526
+ rule_id="DF015",
1527
+ )
1528
+ )
1529
+ return
1530
+
1531
+ result.add_check(
1532
+ ValidationCheck(
1533
+ check="no_setuid_binaries_added",
1534
+ status=ValidationStatus.FAIL,
1535
+ message=(
1536
+ "A setuid/setgid bit is set: the binary runs as its owner, and its "
1537
+ "owner is root -- a ready-made way back to uid 0"
1538
+ ),
1539
+ severity=SeverityLevel.HIGH,
1540
+ rule_id="DF015",
1541
+ line=info.setuid_lines[0] if info.setuid_lines else None,
1542
+ fix_suggestion=(
1543
+ "Drop the setuid bit and give the process what it needs directly:\n"
1544
+ "RUN chmod 0755 /usr/local/bin/tool\n"
1545
+ "For port binding below 1024, listen high and map the port instead."
1546
+ ),
1547
+ details={"lines": list(info.setuid_lines)},
1548
+ )
1549
+ )
1550
+
1551
+ def _check_pinned_package_versions(
1552
+ self, info: DockerfileInfo, result: DockerfileValidationResult, context_path: Path
1553
+ ) -> None:
1554
+ """DF016 -- pacote instalado sem versão fixada.
1555
+
1556
+ Reprodutibilidade, não confidencialidade: `apt-get install curl` sem
1557
+ `=<versão>` instala o que o índice tiver hoje, e a mesma linha pode
1558
+ instalar bytes -- e CVEs -- diferentes amanhã. `WARN`, não `FAIL`:
1559
+ isto não é o mesmo tipo de risco que um segredo em ENV, e reprovar o
1560
+ build por padrão quebraria toda imagem que hoje só confia no
1561
+ `latest` do gerenciador de pacotes.
1562
+ """
1563
+ unpinned = info.unpinned_packages
1564
+ npm_used = any(pkg["manager"] == "npm" for pkg in unpinned) or any(
1565
+ "npm install" in cmd["command"].lower()
1566
+ or "npm ci" in cmd["command"].lower()
1567
+ or "npm add" in cmd["command"].lower()
1568
+ for cmd in info.run_commands
1569
+ )
1570
+ lockfile_missing = npm_used and not (context_path / "package-lock.json").exists()
1571
+ info.npm_lockfile_missing = lockfile_missing
1572
+
1573
+ if not unpinned and not lockfile_missing:
1574
+ result.add_check(
1575
+ ValidationCheck(
1576
+ check="package_versions_pinned",
1577
+ status=ValidationStatus.PASS,
1578
+ message="Every installed package carries a pinned version",
1579
+ severity=SeverityLevel.INFO,
1580
+ rule_id="DF016",
1581
+ )
1582
+ )
1583
+ return
1584
+
1585
+ by_manager: dict[str, list[str]] = {}
1586
+ for pkg in unpinned:
1587
+ by_manager.setdefault(pkg["manager"], []).append(pkg["package"])
1588
+ parts = [f"{mgr}: {', '.join(names)}" for mgr, names in by_manager.items()]
1589
+ if lockfile_missing:
1590
+ parts.append("npm: no package-lock.json committed alongside the Dockerfile")
1591
+
1592
+ result.add_check(
1593
+ ValidationCheck(
1594
+ check="package_versions_pinned",
1595
+ status=ValidationStatus.WARN,
1596
+ message=f"Unpinned package install(s): {'; '.join(parts)}",
1597
+ severity=SeverityLevel.MEDIUM,
1598
+ rule_id="DF016",
1599
+ fix_suggestion=(
1600
+ "Pin every installed package: apt-get install curl=8.5.0-2, "
1601
+ "apk add curl=8.9.1-r2, pip install requests==2.32.3, "
1602
+ "npm install lodash@4.17.21 -- and commit package-lock.json "
1603
+ "when the image installs from package.json"
1604
+ ),
1605
+ details={"unpinned_packages": unpinned, "npm_lockfile_missing": lockfile_missing},
1606
+ )
1607
+ )
1608
+
1609
+ def _check_multi_arch_awareness(
1610
+ self, info: DockerfileInfo, result: DockerfileValidationResult
1611
+ ) -> None:
1612
+ """DF017 -- o Dockerfile declara ciência de build `--platform`.
1613
+
1614
+ Não é um pass/fail sobre segurança: `TARGETPLATFORM`/`--platform`
1615
+ não têm um estado correto, só presente ou ausente. Quando presente,
1616
+ o achado é informativo e lembra que um pacote pinado numa
1617
+ arquitetura (DF016) pode não existir, ou pinar em outra versão, na
1618
+ próxima -- então só aparece quando há algo a dizer.
1619
+ """
1620
+ if not info.declares_multi_arch:
1621
+ return
1622
+ result.add_check(
1623
+ ValidationCheck(
1624
+ check="multi_arch_build_declared",
1625
+ status=ValidationStatus.PASS,
1626
+ message=f"Multi-architecture build declared: {', '.join(info.platform_args)}",
1627
+ severity=SeverityLevel.INFO,
1628
+ rule_id="DF017",
1629
+ details={"platform_args": list(info.platform_args)},
1630
+ )
1631
+ )
1632
+ if info.unpinned_packages:
1633
+ result.add_check(
1634
+ ValidationCheck(
1635
+ check="multi_arch_pinned_packages",
1636
+ status=ValidationStatus.WARN,
1637
+ message=(
1638
+ "Multi-architecture build with unpinned package versions: a "
1639
+ "version pin resolved for one architecture is not guaranteed to "
1640
+ "exist, or to mean the same package, on another"
1641
+ ),
1642
+ severity=SeverityLevel.LOW,
1643
+ rule_id="DF017",
1644
+ )
1645
+ )
1646
+
1647
+ def _calculate_security_score(self, validation: DockerfileValidationResult) -> int:
1648
+ """Calcula score de segurança baseado nos resultados."""
1649
+ score = 100
1650
+
1651
+ # Pesos por severidade
1652
+ severity_weights = {
1653
+ SeverityLevel.CRITICAL: 25,
1654
+ SeverityLevel.HIGH: 15,
1655
+ SeverityLevel.MEDIUM: 8,
1656
+ SeverityLevel.LOW: 3,
1657
+ }
1658
+
1659
+ for check in validation.checks:
1660
+ if check.status == ValidationStatus.FAIL:
1661
+ score -= severity_weights.get(check.severity, 0)
1662
+ elif check.status == ValidationStatus.WARN:
1663
+ score -= severity_weights.get(check.severity, 0) // 2
1664
+
1665
+ return max(0, min(100, score))
1666
+
1667
+ def _calculate_security_tier(self, score: int, validation: DockerfileValidationResult) -> str:
1668
+ """Calcula tier de segurança baseado no score."""
1669
+ if validation.errors > 0:
1670
+ return "C" # Não pronto para produção
1671
+
1672
+ if score >= 90:
1673
+ return "A" # Production-ready
1674
+ elif score >= 70:
1675
+ return "B" # Requires review
1676
+ else:
1677
+ return "C" # Not recommended
1678
+
1679
+
1680
+ class HardeningTemplates(HardeningTemplateProvider):
1681
+ """Provedor de templates hardened para diferentes linguagens."""
1682
+
1683
+ # `Path(__file__).parent` já é `dockerls/infrastructure`. Subir mais dois
1684
+ # níveis e reentrar em "infrastructure/templates" apontava para
1685
+ # `<raiz-do-repo>/infrastructure/templates`, que não existe -- e em uma
1686
+ # instalação por wheel apontava para dentro de site-packages/. Resultado:
1687
+ # `exists()` era sempre False, os três templates versionados no repositório
1688
+ # nunca eram lidos, e todo `--hardened` caía no template básico. Um
1689
+ # gerador de Dockerfile "hardened" que na prática emitia outra coisa é
1690
+ # exatamente o tipo de silêncio que esta ferramenta existe para denunciar.
1691
+ TEMPLATES_DIR = Path(__file__).parent / "templates"
1692
+
1693
+ #: Chave aceita por `--base` -> arquivo de template versionado.
1694
+ #: Só entra aqui o que existe de fato: anunciar um template inexistente
1695
+ #: fazia `--base java` cair calado no template básico, com uma base
1696
+ #: destoante da que o usuário pediu.
1697
+ TEMPLATE_FILES = {
1698
+ # Standalone Operating Systems
1699
+ "alpine": "alpine.dockerfile",
1700
+ "debian": "debian.dockerfile",
1701
+ "ubuntu": "ubuntu.dockerfile",
1702
+ "distroless": "distroless.dockerfile",
1703
+ # Node.js
1704
+ "node": "node.dockerfile",
1705
+ "node-alpine": "node-alpine.dockerfile",
1706
+ "node-debian": "node-debian.dockerfile",
1707
+ "node-ubuntu": "node-ubuntu.dockerfile",
1708
+ "node-distroless": "node-distroless.dockerfile",
1709
+ # Python
1710
+ "python": "python.dockerfile",
1711
+ "python-alpine": "python-alpine.dockerfile",
1712
+ "python-debian": "python-debian.dockerfile",
1713
+ "python-ubuntu": "python-ubuntu.dockerfile",
1714
+ "python-distroless": "python-distroless.dockerfile",
1715
+ # Go
1716
+ "go": "go.dockerfile",
1717
+ "go-scratch": "go-scratch.dockerfile",
1718
+ "go-alpine": "go-alpine.dockerfile",
1719
+ "go-debian": "go-debian.dockerfile",
1720
+ "go-distroless": "go-distroless.dockerfile",
1721
+ # Java
1722
+ "java": "java.dockerfile",
1723
+ "java-alpine": "java-alpine.dockerfile",
1724
+ "java-debian": "java-debian.dockerfile",
1725
+ "java-ubuntu": "java-ubuntu.dockerfile",
1726
+ "java-distroless": "java-distroless.dockerfile",
1727
+ # Ferramentas de build Java. São onde um projeto Java de verdade
1728
+ # começa o Dockerfile, e não existiam: `--base maven` respondia que o
1729
+ # template não existe, mandando a pessoa escrever o multi-stage na mão.
1730
+ "maven": "maven.dockerfile",
1731
+ "maven-alpine": "maven-alpine.dockerfile",
1732
+ "gradle": "gradle.dockerfile",
1733
+ "gradle-alpine": "gradle-alpine.dockerfile",
1734
+ # Rust
1735
+ "rust": "rust.dockerfile",
1736
+ "rust-scratch": "rust-scratch.dockerfile",
1737
+ "rust-alpine": "rust-alpine.dockerfile",
1738
+ "rust-debian": "rust-debian.dockerfile",
1739
+ # PHP
1740
+ "php": "php.dockerfile",
1741
+ "php-alpine": "php-alpine.dockerfile",
1742
+ "php-debian": "php-debian.dockerfile",
1743
+ "php-ubuntu": "php-ubuntu.dockerfile",
1744
+ # Ruby
1745
+ "ruby": "ruby-alpine.dockerfile",
1746
+ "ruby-alpine": "ruby-alpine.dockerfile",
1747
+ "ruby-debian": "ruby-debian.dockerfile",
1748
+ }
1749
+
1750
+ def _template_path(self, name: str) -> Path:
1751
+ return self.TEMPLATES_DIR / "hardening" / name
1752
+
1753
+ def get_template(self, base_image: str) -> str:
1754
+ """Retorna o template hardened correspondente a `base_image` e distribuição do SO."""
1755
+ base_lower = base_image.lower().strip()
1756
+
1757
+ # 1. Correspondência exata de chave
1758
+ template_file = self.TEMPLATE_FILES.get(base_lower)
1759
+
1760
+ # 2. Resolução inteligente de Ecossistema + SO
1761
+ if template_file is None:
1762
+ # Identificar ecossistema
1763
+ detected_eco = None
1764
+ for eco in ("node", "python", "golang", "go", "java", "rust", "php", "ruby"):
1765
+ if eco in base_lower:
1766
+ detected_eco = "go" if eco == "golang" else eco
1767
+ break
1768
+
1769
+ # Identificar SO
1770
+ detected_os = None
1771
+ if "alpine" in base_lower or "musl" in base_lower:
1772
+ detected_os = "alpine"
1773
+ elif any(d in base_lower for d in ("debian", "slim", "bookworm", "bullseye")):
1774
+ detected_os = "debian"
1775
+ elif any(u in base_lower for u in ("ubuntu", "noble", "jammy", "focal")):
1776
+ detected_os = "ubuntu"
1777
+ elif "distroless" in base_lower:
1778
+ detected_os = "distroless"
1779
+ elif "scratch" in base_lower:
1780
+ detected_os = "scratch"
1781
+
1782
+ if detected_eco and detected_os:
1783
+ compound_key = f"{detected_eco}-{detected_os}"
1784
+ template_file = self.TEMPLATE_FILES.get(compound_key)
1785
+
1786
+ if template_file is None and detected_eco:
1787
+ template_file = self.TEMPLATE_FILES.get(detected_eco)
1788
+
1789
+ if template_file is None and detected_os:
1790
+ template_file = self.TEMPLATE_FILES.get(detected_os)
1791
+
1792
+ # 3. Fallback para correspondência parcial de chave
1793
+ if template_file is None:
1794
+ for key, filename in self.TEMPLATE_FILES.items():
1795
+ if key in base_lower:
1796
+ template_file = filename
1797
+ break
1798
+
1799
+ if template_file is not None:
1800
+ path = self._template_path(template_file)
1801
+ if path.exists():
1802
+ return path.read_text(encoding="utf-8")
1803
+ raise UnknownHardeningTemplateError(
1804
+ f"Hardened template file is missing from the installation: {path}"
1805
+ )
1806
+
1807
+ available = ", ".join(self.list_templates()) or "none"
1808
+ raise UnknownHardeningTemplateError(
1809
+ f"No hardened template for base image {base_image!r}. Available: {available}"
1810
+ )
1811
+
1812
+ def list_templates(self) -> list[str]:
1813
+ """Lista os templates hardened realmente disponíveis em disco."""
1814
+ return sorted(
1815
+ name
1816
+ for name, filename in self.TEMPLATE_FILES.items()
1817
+ if self._template_path(filename).exists()
1818
+ )
1819
+
1820
+ def generate_hardened_dockerfile(
1821
+ self,
1822
+ dockerfile_path: str | Path,
1823
+ base_image: str | None = None,
1824
+ output_path: str | Path | None = None,
1825
+ ) -> str:
1826
+ """Gera um Dockerfile hardened baseado no original ou template."""
1827
+ path = Path(dockerfile_path)
1828
+ if path.is_dir():
1829
+ path = path / "Dockerfile"
1830
+
1831
+ # Se base_image fornecida, usar template
1832
+ if base_image:
1833
+ content = self.get_template(base_image)
1834
+ else:
1835
+ # Analisar Dockerfile existente e sugerir melhorias
1836
+ validator = DockerfileValidator(self)
1837
+ suggestions = validator.suggest_hardening(path)
1838
+
1839
+ # Gerar conteúdo baseado nas sugestões
1840
+ content = self._apply_suggestions(path, suggestions)
1841
+
1842
+ # Salvar se output_path fornecido
1843
+ if output_path:
1844
+ output = Path(output_path)
1845
+ output.parent.mkdir(parents=True, exist_ok=True)
1846
+ output.write_text(content, encoding="utf-8")
1847
+
1848
+ return content
1849
+
1850
+ def _apply_suggestions(self, path: Path, suggestions: list[HardeningRule]) -> str:
1851
+ """Aplica sugestões ao Dockerfile existente."""
1852
+ content = path.read_text(encoding="utf-8")
1853
+
1854
+ # Aplicar cada sugestão
1855
+ for suggestion in suggestions:
1856
+ # Lógica simples de aplicação - em produção seria mais sofisticado
1857
+ if "non-root user" in suggestion.title.lower() and "USER" not in content:
1858
+ content += "\nUSER appuser\n"
1859
+
1860
+ return content
1861
+
1862
+
1863
+ def _parse_label_pairs(text: str) -> dict[str, str]:
1864
+ """Todos os pares de uma instrução LABEL, não só o primeiro.
1865
+
1866
+ O padrão anterior era `^LABEL\\s+([^=]+)=(.*)$`: ele casava até o primeiro
1867
+ `=` e engolia o resto da linha como *valor*. Numa instrução idiomática --
1868
+
1869
+ LABEL maintainer="Ivomsantiago" \\
1870
+ security.scanner="dockerls"
1871
+
1872
+ -- as continuações já vêm unidas numa linha lógica, então o resultado era a
1873
+ chave `maintainer` com valor `"Ivomsantiago" security.scanner="dockerls"`, e
1874
+ `security.scanner` simplesmente não existia. A regra DF007 então reprovava
1875
+ um Dockerfile que declara o rótulo que ela cobra. Um falso positivo é pior
1876
+ que nenhuma checagem: ele ensina o leitor a ignorar o aviso.
1877
+
1878
+ `shlex` faz o trabalho de aspas, que é onde um split ingênuo erra: o valor
1879
+ pode conter espaços e é isso que exige o parser em vez de um regex.
1880
+ """
1881
+ try:
1882
+ tokens = shlex.split(text)
1883
+ except ValueError:
1884
+ # Aspas desbalanceadas: o Docker também recusaria. Cai para o split
1885
+ # simples em vez de descartar a instrução inteira.
1886
+ tokens = text.split()
1887
+ if not tokens:
1888
+ return {}
1889
+
1890
+ # Forma legada `LABEL chave valor com espaços`, sem `=` nenhum.
1891
+ if "=" not in tokens[0]:
1892
+ return {tokens[0]: " ".join(tokens[1:])}
1893
+
1894
+ pairs: dict[str, str] = {}
1895
+ for token in tokens:
1896
+ key, separator, value = token.partition("=")
1897
+ if separator and key:
1898
+ pairs[key.strip()] = value.strip()
1899
+ return pairs