dockerls 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dockerls/__init__.py +31 -0
- dockerls/application/__init__.py +0 -0
- dockerls/application/dto/__init__.py +3 -0
- dockerls/application/dto/analysis.py +257 -0
- dockerls/application/services/__init__.py +0 -0
- dockerls/application/services/alternatives_lookup.py +167 -0
- dockerls/application/services/composite_repository.py +106 -0
- dockerls/application/services/cross_validation.py +233 -0
- dockerls/application/services/ecosystems.py +350 -0
- dockerls/application/services/fallback_scanner.py +97 -0
- dockerls/application/services/hardening_analysis.py +174 -0
- dockerls/application/services/migration.py +297 -0
- dockerls/application/services/progress.py +56 -0
- dockerls/application/services/remediation.py +321 -0
- dockerls/application/services/scan_history_store.py +88 -0
- dockerls/application/services/scanner_factory.py +88 -0
- dockerls/application/services/source_registry.py +151 -0
- dockerls/application/services/tag_history_store.py +76 -0
- dockerls/application/services/teardown.py +50 -0
- dockerls/application/services/verdict.py +298 -0
- dockerls/application/services/version_discovery.py +108 -0
- dockerls/application/use_cases/__init__.py +0 -0
- dockerls/application/use_cases/analyze_dockerfile.py +103 -0
- dockerls/application/use_cases/analyze_image.py +173 -0
- dockerls/application/use_cases/build_image.py +1795 -0
- dockerls/application/use_cases/compare_images.py +91 -0
- dockerls/application/use_cases/fleet_scan.py +240 -0
- dockerls/application/use_cases/recommend_images.py +1078 -0
- dockerls/application/use_cases/registry_audit.py +133 -0
- dockerls/application/use_cases/search_images.py +23 -0
- dockerls/application/use_cases/upgrade_base.py +167 -0
- dockerls/cache/__init__.py +0 -0
- dockerls/cache/sqlite_cache.py +184 -0
- dockerls/cli/__init__.py +0 -0
- dockerls/cli/analysis_baseline.py +98 -0
- dockerls/cli/app.py +294 -0
- dockerls/cli/commands/__init__.py +0 -0
- dockerls/cli/commands/advisor.py +262 -0
- dockerls/cli/commands/alternatives.py +291 -0
- dockerls/cli/commands/analyze.py +429 -0
- dockerls/cli/commands/analyze_dockerfile.py +104 -0
- dockerls/cli/commands/base_cmd.py +244 -0
- dockerls/cli/commands/base_image.py +551 -0
- dockerls/cli/commands/build.py +1300 -0
- dockerls/cli/commands/cache_cmd.py +104 -0
- dockerls/cli/commands/compare.py +177 -0
- dockerls/cli/commands/controls.py +110 -0
- dockerls/cli/commands/doctor.py +566 -0
- dockerls/cli/commands/export.py +81 -0
- dockerls/cli/commands/fleet.py +159 -0
- dockerls/cli/commands/health.py +84 -0
- dockerls/cli/commands/login.py +53 -0
- dockerls/cli/commands/policy_cmd.py +111 -0
- dockerls/cli/commands/provenance_cmd.py +162 -0
- dockerls/cli/commands/recommend.py +761 -0
- dockerls/cli/commands/registry_audit_cmd.py +103 -0
- dockerls/cli/commands/sbom.py +144 -0
- dockerls/cli/commands/search.py +86 -0
- dockerls/cli/commands/verify.py +115 -0
- dockerls/cli/commands/version.py +12 -0
- dockerls/cli/commands/vex_cmd.py +117 -0
- dockerls/cli/dependencies.py +530 -0
- dockerls/cli/image_names.py +79 -0
- dockerls/cli/options.py +42 -0
- dockerls/cli/progress.py +145 -0
- dockerls/cli/publish_prompt.py +123 -0
- dockerls/cli/rendering.py +214 -0
- dockerls/cli/runtime.py +65 -0
- dockerls/cli/scan_failure.py +71 -0
- dockerls/cli/text.py +39 -0
- dockerls/cli/validators.py +35 -0
- dockerls/cli/vulnerability_view.py +154 -0
- dockerls/domain/__init__.py +0 -0
- dockerls/domain/entities/__init__.py +75 -0
- dockerls/domain/entities/declared_metadata.py +147 -0
- dockerls/domain/entities/dockerfile_analysis.py +318 -0
- dockerls/domain/entities/image.py +109 -0
- dockerls/domain/entities/image_facts.py +137 -0
- dockerls/domain/entities/recommendation.py +33 -0
- dockerls/domain/entities/scan_result.py +129 -0
- dockerls/domain/entities/vulnerability.py +232 -0
- dockerls/domain/interfaces/__init__.py +17 -0
- dockerls/domain/interfaces/cache_store.py +18 -0
- dockerls/domain/interfaces/dockerfile_validator.py +99 -0
- dockerls/domain/interfaces/eol_checker.py +11 -0
- dockerls/domain/interfaces/image_repository.py +15 -0
- dockerls/domain/interfaces/scanner.py +15 -0
- dockerls/domain/security_controls.py +362 -0
- dockerls/domain/value_objects/__init__.py +49 -0
- dockerls/domain/value_objects/attack_surface.py +198 -0
- dockerls/domain/value_objects/base_recipe.py +600 -0
- dockerls/domain/value_objects/base_upgrade.py +292 -0
- dockerls/domain/value_objects/build_labels.py +99 -0
- dockerls/domain/value_objects/build_policy.py +412 -0
- dockerls/domain/value_objects/confidence.py +156 -0
- dockerls/domain/value_objects/fleet.py +174 -0
- dockerls/domain/value_objects/gate.py +327 -0
- dockerls/domain/value_objects/hardening.py +303 -0
- dockerls/domain/value_objects/image_reference.py +90 -0
- dockerls/domain/value_objects/inheritance.py +352 -0
- dockerls/domain/value_objects/network_policy.py +280 -0
- dockerls/domain/value_objects/production_readiness.py +145 -0
- dockerls/domain/value_objects/provenance.py +211 -0
- dockerls/domain/value_objects/recipe_diff.py +188 -0
- dockerls/domain/value_objects/registry_audit.py +195 -0
- dockerls/domain/value_objects/registry_target.py +225 -0
- dockerls/domain/value_objects/remediation_score.py +62 -0
- dockerls/domain/value_objects/scan_history.py +183 -0
- dockerls/domain/value_objects/scan_plan.py +193 -0
- dockerls/domain/value_objects/scanner_db.py +143 -0
- dockerls/domain/value_objects/security_score.py +160 -0
- dockerls/domain/value_objects/security_tier.py +122 -0
- dockerls/domain/value_objects/tag_history.py +180 -0
- dockerls/domain/value_objects/tool_release.py +253 -0
- dockerls/domain/value_objects/tristate.py +47 -0
- dockerls/domain/value_objects/vex.py +249 -0
- dockerls/exit_codes.py +23 -0
- dockerls/exporters/__init__.py +0 -0
- dockerls/exporters/base.py +17 -0
- dockerls/exporters/csv_exporter.py +85 -0
- dockerls/exporters/factory.py +31 -0
- dockerls/exporters/html_exporter.py +105 -0
- dockerls/exporters/json_exporter.py +19 -0
- dockerls/exporters/markdown_exporter.py +84 -0
- dockerls/exporters/sarif_exporter.py +245 -0
- dockerls/infrastructure/__init__.py +0 -0
- dockerls/infrastructure/config/__init__.py +0 -0
- dockerls/infrastructure/config/policy_file.py +165 -0
- dockerls/infrastructure/config/settings.py +197 -0
- dockerls/infrastructure/database/__init__.py +0 -0
- dockerls/infrastructure/database/models.py +78 -0
- dockerls/infrastructure/dockerfile_validator.py +1899 -0
- dockerls/infrastructure/evidence.py +99 -0
- dockerls/infrastructure/hashing.py +165 -0
- dockerls/infrastructure/logging/__init__.py +0 -0
- dockerls/infrastructure/logging/setup.py +98 -0
- dockerls/infrastructure/network/__init__.py +0 -0
- dockerls/infrastructure/network/guarded_client.py +107 -0
- dockerls/infrastructure/network/host_guard.py +117 -0
- dockerls/infrastructure/redaction.py +135 -0
- dockerls/infrastructure/templates/hardening/alpine.dockerfile +44 -0
- dockerls/infrastructure/templates/hardening/debian.dockerfile +45 -0
- dockerls/infrastructure/templates/hardening/distroless.dockerfile +34 -0
- dockerls/infrastructure/templates/hardening/go-alpine.dockerfile +52 -0
- dockerls/infrastructure/templates/hardening/go-debian.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/go-distroless.dockerfile +43 -0
- dockerls/infrastructure/templates/hardening/go-scratch.dockerfile +48 -0
- dockerls/infrastructure/templates/hardening/go.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/gradle-alpine.dockerfile +51 -0
- dockerls/infrastructure/templates/hardening/gradle.dockerfile +52 -0
- dockerls/infrastructure/templates/hardening/java-alpine.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/java-debian.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/java-distroless.dockerfile +39 -0
- dockerls/infrastructure/templates/hardening/java-ubuntu.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/java.dockerfile +60 -0
- dockerls/infrastructure/templates/hardening/maven-alpine.dockerfile +56 -0
- dockerls/infrastructure/templates/hardening/maven.dockerfile +57 -0
- dockerls/infrastructure/templates/hardening/node-alpine.dockerfile +47 -0
- dockerls/infrastructure/templates/hardening/node-debian.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/node-distroless.dockerfile +46 -0
- dockerls/infrastructure/templates/hardening/node-ubuntu.dockerfile +63 -0
- dockerls/infrastructure/templates/hardening/node.dockerfile +61 -0
- dockerls/infrastructure/templates/hardening/php-alpine.dockerfile +45 -0
- dockerls/infrastructure/templates/hardening/php-debian.dockerfile +45 -0
- dockerls/infrastructure/templates/hardening/php-ubuntu.dockerfile +49 -0
- dockerls/infrastructure/templates/hardening/php.dockerfile +44 -0
- dockerls/infrastructure/templates/hardening/python-alpine.dockerfile +51 -0
- dockerls/infrastructure/templates/hardening/python-debian.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/python-distroless.dockerfile +51 -0
- dockerls/infrastructure/templates/hardening/python-ubuntu.dockerfile +60 -0
- dockerls/infrastructure/templates/hardening/python.dockerfile +58 -0
- dockerls/infrastructure/templates/hardening/ruby-alpine.dockerfile +48 -0
- dockerls/infrastructure/templates/hardening/ruby-debian.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/rust-alpine.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/rust-debian.dockerfile +48 -0
- dockerls/infrastructure/templates/hardening/rust-scratch.dockerfile +44 -0
- dockerls/infrastructure/templates/hardening/rust.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/ubuntu.dockerfile +49 -0
- dockerls/infrastructure/toolchain/__init__.py +0 -0
- dockerls/infrastructure/toolchain/db_metadata.py +115 -0
- dockerls/infrastructure/toolchain/installer.py +435 -0
- dockerls/integrations/__init__.py +0 -0
- dockerls/integrations/dhi/__init__.py +0 -0
- dockerls/integrations/dhi/catalog.py +457 -0
- dockerls/integrations/dhi/definition.py +151 -0
- dockerls/integrations/dhi/repository.py +238 -0
- dockerls/integrations/dockerhub/__init__.py +0 -0
- dockerls/integrations/dockerhub/client.py +318 -0
- dockerls/integrations/dockerhub/urls.py +75 -0
- dockerls/integrations/endoflife/__init__.py +0 -0
- dockerls/integrations/endoflife/checker.py +216 -0
- dockerls/integrations/engine/__init__.py +0 -0
- dockerls/integrations/engine/batch.py +197 -0
- dockerls/integrations/engine/client.py +330 -0
- dockerls/integrations/engine/locator.py +96 -0
- dockerls/integrations/exploitdb/__init__.py +0 -0
- dockerls/integrations/exploitdb/client.py +271 -0
- dockerls/integrations/grype/__init__.py +0 -0
- dockerls/integrations/grype/scanner.py +336 -0
- dockerls/integrations/registry/__init__.py +0 -0
- dockerls/integrations/registry/hardened.py +284 -0
- dockerls/integrations/registry/inspector.py +420 -0
- dockerls/integrations/registry/oci.py +259 -0
- dockerls/integrations/registry/private.py +79 -0
- dockerls/integrations/registry/urls.py +36 -0
- dockerls/integrations/scan_errors.py +67 -0
- dockerls/integrations/scan_target.py +57 -0
- dockerls/integrations/signing/__init__.py +0 -0
- dockerls/integrations/signing/cosign.py +484 -0
- dockerls/integrations/threat_intel/__init__.py +0 -0
- dockerls/integrations/threat_intel/client.py +290 -0
- dockerls/integrations/trivy/__init__.py +0 -0
- dockerls/integrations/trivy/cache_pool.py +176 -0
- dockerls/integrations/trivy/scanner.py +447 -0
- dockerls/utils/__init__.py +0 -0
- dockerls/utils/auth.py +108 -0
- dockerls/utils/executables.py +39 -0
- dockerls/utils/ignore_file.py +130 -0
- dockerls/utils/rate_limit.py +130 -0
- dockerls/utils/resources.py +183 -0
- dockerls/utils/retry.py +31 -0
- dockerls/utils/safe_yaml.py +166 -0
- dockerls/utils/subprocess_runner.py +216 -0
- dockerls/utils/validation.py +72 -0
- dockerls-1.0.0.dist-info/METADATA +563 -0
- dockerls-1.0.0.dist-info/RECORD +230 -0
- dockerls-1.0.0.dist-info/WHEEL +5 -0
- dockerls-1.0.0.dist-info/entry_points.txt +2 -0
- dockerls-1.0.0.dist-info/licenses/LICENSE +21 -0
- dockerls-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|