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