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,412 @@
|
|
|
1
|
+
"""A política da organização, escrita uma vez e conferida em todo build.
|
|
2
|
+
|
|
3
|
+
`--fail-on critical` é um portão, mas é um portão que mora na linha de comando
|
|
4
|
+
-- e uma regra que mora na linha de comando é uma regra que cada pipeline
|
|
5
|
+
reescreve à mão. Bastava um `--fail-on high` esquecido num repositório para
|
|
6
|
+
que a política da organização deixasse de valer ali, sem que nada acusasse.
|
|
7
|
+
|
|
8
|
+
Este módulo é a política como dado: um `.dockerls-policy.yaml` versionado junto
|
|
9
|
+
do código, conferido contra o que foi **medido** neste build. A parte aqui é
|
|
10
|
+
pura -- recebe os fatos e devolve as violações -- para que cada regra seja
|
|
11
|
+
testável contra o número exato que a produziu.
|
|
12
|
+
|
|
13
|
+
Três princípios moldam o que existe e o que não existe aqui:
|
|
14
|
+
|
|
15
|
+
* **Só entra o que é mensurável.** Não há regra de "não use pacotes inseguros"
|
|
16
|
+
ou "mantenha a imagem pequena": não há como decidir isso a partir de um
|
|
17
|
+
build, e uma regra que não pode ser avaliada é uma regra que reprova por
|
|
18
|
+
engano ou aprova por omissão. As duas corroem a confiança no portão.
|
|
19
|
+
* **Não medir nunca aprova.** Toda regra que dependa de uma medição que não
|
|
20
|
+
aconteceu vira violação, não silêncio. É o mesmo princípio que impede uma
|
|
21
|
+
imagem não escaneada de ser apresentada como segura.
|
|
22
|
+
* **A política nunca afrouxa o que a linha de comando apertou.** Quando as
|
|
23
|
+
duas discordam, vale a mais estrita. Um arquivo no repositório não pode
|
|
24
|
+
desligar um portão que o pipeline pediu -- senão bastaria commitar um YAML
|
|
25
|
+
para publicar o que não passaria.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from enum import StrEnum
|
|
32
|
+
|
|
33
|
+
from dockerls.domain.value_objects.gate import SEVERITY_THRESHOLDS, merge_gates
|
|
34
|
+
from dockerls.domain.value_objects.tristate import Tristate
|
|
35
|
+
|
|
36
|
+
#: Severidades que uma contagem pode nomear. `unknown` entra: um scanner que
|
|
37
|
+
#: reporta um achado sem severidade ainda reportou um achado, e um teto sobre
|
|
38
|
+
#: ele é legítimo.
|
|
39
|
+
SEVERITY_ORDER: tuple[str, ...] = ("critical", "high", "medium", "low", "unknown")
|
|
40
|
+
|
|
41
|
+
#: Limiares que o portão aceita, do mais brando para o mais estrito -- e a
|
|
42
|
+
#: ordem é essa mesmo. `--fail-on low` reprova em LOW *e em tudo acima dele*,
|
|
43
|
+
#: então é o limiar mais exigente que existe; `--fail-on critical` é o mais
|
|
44
|
+
#: permissivo. Confundir os dois foi um bug real aqui: `effective_fail_on`
|
|
45
|
+
#: escolhia por "gravidade da palavra" e devolvia `critical` quando um lado
|
|
46
|
+
#: pedia `high`, afrouxando em silêncio um portão que alguém tinha apertado.
|
|
47
|
+
#:
|
|
48
|
+
#: `unknown` fica de fora porque o portão não sabe avaliá-lo: aceitá-lo na
|
|
49
|
+
#: política produziria um build que morre com erro técnico no meio do
|
|
50
|
+
#: caminho, em vez de uma política recusada na leitura do arquivo.
|
|
51
|
+
#: Reexportado de `gate.py`, que é onde a escala mora agora: duas
|
|
52
|
+
#: definições de "mais estrito" divergiriam na primeira mudança.
|
|
53
|
+
GATE_THRESHOLDS: tuple[str, ...] = SEVERITY_THRESHOLDS
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class PolicyRule(StrEnum):
|
|
57
|
+
"""Cada regra que a política pode exigir, nomeada para o relatório."""
|
|
58
|
+
|
|
59
|
+
FAIL_ON = "fail_on"
|
|
60
|
+
MAX_VULNERABILITIES = "max_vulnerabilities"
|
|
61
|
+
REQUIRE_SCAN = "require_scan"
|
|
62
|
+
REQUIRE_PINNED_BASES = "require_pinned_bases"
|
|
63
|
+
REQUIRE_NONROOT = "require_nonroot"
|
|
64
|
+
REQUIRED_LABELS = "required_labels"
|
|
65
|
+
ALLOWED_BASE_REGISTRIES = "allowed_base_registries"
|
|
66
|
+
REQUIRE_PROVENANCE = "require_provenance"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class BaseFact:
|
|
71
|
+
"""Uma base declarada, reduzida ao que a política consegue avaliar."""
|
|
72
|
+
|
|
73
|
+
reference: str
|
|
74
|
+
registry: str
|
|
75
|
+
pinned: bool
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class PolicyFacts:
|
|
80
|
+
"""O que este build mediu. Tudo que a política tem para trabalhar."""
|
|
81
|
+
|
|
82
|
+
#: Se um scanner chegou a rodar. `False` não é "zero vulnerabilidades".
|
|
83
|
+
scan_ran: bool = False
|
|
84
|
+
#: Contagem por severidade, do scan que rodou.
|
|
85
|
+
severity_counts: dict[str, int] = field(default_factory=dict)
|
|
86
|
+
bases: tuple[BaseFact, ...] = ()
|
|
87
|
+
labels: dict[str, str] = field(default_factory=dict)
|
|
88
|
+
#: Se a imagem roda sem privilégio. `UNKNOWN` quando o Dockerfile não
|
|
89
|
+
#: permitiu decidir -- e a política trata isso como violação, não como sim.
|
|
90
|
+
nonroot: Tristate = Tristate.UNKNOWN
|
|
91
|
+
#: `VERIFIED`, `INCOMPLETE`, `INPUT_CHANGED`, ou "" quando não houve
|
|
92
|
+
#: registro de procedência neste build.
|
|
93
|
+
provenance_status: str = ""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class PolicyViolation:
|
|
98
|
+
"""Uma regra que este build não cumpriu, e por quê."""
|
|
99
|
+
|
|
100
|
+
rule: PolicyRule
|
|
101
|
+
message: str
|
|
102
|
+
|
|
103
|
+
def to_dict(self) -> dict[str, str]:
|
|
104
|
+
return {"rule": str(self.rule), "message": self.message}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class BuildPolicy:
|
|
109
|
+
"""As exigências declaradas em `.dockerls-policy.yaml`."""
|
|
110
|
+
|
|
111
|
+
#: O portão que reprova o build: uma severidade (`critical`), `kev`,
|
|
112
|
+
#: `epss>=N`, ou vários separados por vírgula. Vazio deixa a decisão
|
|
113
|
+
#: com a linha de comando.
|
|
114
|
+
fail_on: str = ""
|
|
115
|
+
#: Teto por severidade (`{"high": 5}`). Um teto de zero é diferente de
|
|
116
|
+
#: `fail_on`: permite tolerar 3 HIGH e nenhum CRITICAL no mesmo arquivo.
|
|
117
|
+
max_vulnerabilities: dict[str, int] = field(default_factory=dict)
|
|
118
|
+
require_scan: bool = False
|
|
119
|
+
require_pinned_bases: bool = False
|
|
120
|
+
require_nonroot: bool = False
|
|
121
|
+
required_labels: tuple[str, ...] = ()
|
|
122
|
+
#: Registries de onde as bases podem vir. Vazio não restringe; declarado,
|
|
123
|
+
#: restringe **todas** as bases, inclusive as de estágios intermediários.
|
|
124
|
+
allowed_base_registries: tuple[str, ...] = ()
|
|
125
|
+
require_provenance: bool = False
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def is_empty(self) -> bool:
|
|
129
|
+
"""Uma política que não exige nada. Vale saber: um arquivo presente e
|
|
130
|
+
vazio quase sempre significa um erro de digitação nas chaves."""
|
|
131
|
+
return not (
|
|
132
|
+
self.fail_on
|
|
133
|
+
or self.max_vulnerabilities
|
|
134
|
+
or self.require_scan
|
|
135
|
+
or self.require_pinned_bases
|
|
136
|
+
or self.require_nonroot
|
|
137
|
+
or self.required_labels
|
|
138
|
+
or self.allowed_base_registries
|
|
139
|
+
or self.require_provenance
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def effective_fail_on(self, requested: str) -> str:
|
|
143
|
+
"""O portão que vale, entre o da política e o da linha de comando.
|
|
144
|
+
|
|
145
|
+
Severidade contra severidade continua sendo a regra antiga: vence o
|
|
146
|
+
limiar **mais baixo na escala**, e não a palavra mais assustadora --
|
|
147
|
+
`--fail-on low` reprova em LOW e em tudo acima, enquanto `critical`
|
|
148
|
+
só olha CRITICAL. Nem o arquivo do repositório desliga um portão que
|
|
149
|
+
o pipeline pediu, nem uma flag afrouxa a política da organização.
|
|
150
|
+
|
|
151
|
+
Portões de tipos diferentes não competem: somam. `kev` e `high` são
|
|
152
|
+
perguntas diferentes sobre coisas diferentes, e escolher uma
|
|
153
|
+
descartaria a outra em silêncio. A regra vive em
|
|
154
|
+
`domain/value_objects/gate.py` para que a política e o portão do
|
|
155
|
+
build não possam divergir sobre o que "mais estrito" significa.
|
|
156
|
+
"""
|
|
157
|
+
return merge_gates(self.fail_on, requested)
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def production() -> BuildPolicy:
|
|
161
|
+
"""O perfil de produção: o conjunto que uma imagem publicada precisa.
|
|
162
|
+
|
|
163
|
+
Existe porque a alternativa é uma lista de sete flags que cada pipeline
|
|
164
|
+
digita de novo, esquecendo uma diferente por vez. Nomear o conjunto faz
|
|
165
|
+
a omissão virar uma decisão visível (`--no-policy`, `--fail-on low`) em
|
|
166
|
+
vez de um esquecimento invisível.
|
|
167
|
+
|
|
168
|
+
`fail_on` fica em `critical` e não em `high` de propósito. Um perfil que
|
|
169
|
+
ninguém consegue cumprir é um perfil que as pessoas desligam inteiro, e
|
|
170
|
+
`high` reprova praticamente toda base Debian num dia qualquer. O teto de
|
|
171
|
+
`high` fica declarado à parte, onde se enxerga e se discute.
|
|
172
|
+
"""
|
|
173
|
+
return BuildPolicy(
|
|
174
|
+
fail_on="critical",
|
|
175
|
+
require_scan=True,
|
|
176
|
+
require_pinned_bases=True,
|
|
177
|
+
require_nonroot=True,
|
|
178
|
+
require_provenance=True,
|
|
179
|
+
required_labels=(
|
|
180
|
+
"org.opencontainers.image.source",
|
|
181
|
+
"org.opencontainers.image.vendor",
|
|
182
|
+
"security.contact",
|
|
183
|
+
),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def merged_with(self, other: BuildPolicy | None) -> BuildPolicy:
|
|
187
|
+
"""Este perfil somado a outro, sempre pelo lado mais estrito.
|
|
188
|
+
|
|
189
|
+
Serve para `--production` conviver com um `.dockerls-policy.yaml`: o
|
|
190
|
+
arquivo do repositório pode **apertar** o perfil (exigir um rótulo a
|
|
191
|
+
mais, um registry específico), e não pode afrouxá-lo. Uma exigência
|
|
192
|
+
declarada em qualquer um dos dois vale nos dois.
|
|
193
|
+
"""
|
|
194
|
+
if other is None:
|
|
195
|
+
return self
|
|
196
|
+
tetos = {**other.max_vulnerabilities}
|
|
197
|
+
for severity, limit in self.max_vulnerabilities.items():
|
|
198
|
+
tetos[severity] = min(limit, tetos.get(severity, limit))
|
|
199
|
+
return BuildPolicy(
|
|
200
|
+
fail_on=self.effective_fail_on(other.fail_on),
|
|
201
|
+
max_vulnerabilities=tetos,
|
|
202
|
+
require_scan=self.require_scan or other.require_scan,
|
|
203
|
+
require_pinned_bases=self.require_pinned_bases or other.require_pinned_bases,
|
|
204
|
+
require_nonroot=self.require_nonroot or other.require_nonroot,
|
|
205
|
+
require_provenance=self.require_provenance or other.require_provenance,
|
|
206
|
+
required_labels=tuple(dict.fromkeys((*self.required_labels, *other.required_labels))),
|
|
207
|
+
allowed_base_registries=(
|
|
208
|
+
# Interseção não: duas listas disjuntas produziriam um conjunto
|
|
209
|
+
# vazio, que significa "não restringe" -- exatamente o oposto do
|
|
210
|
+
# que as duas pediram. A união mantém as duas restrições
|
|
211
|
+
# satisfazíveis e cada base ainda precisa estar em alguma delas.
|
|
212
|
+
tuple(
|
|
213
|
+
dict.fromkeys((*self.allowed_base_registries, *other.allowed_base_registries))
|
|
214
|
+
)
|
|
215
|
+
),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def static_subset(self) -> BuildPolicy:
|
|
219
|
+
"""A política reduzida ao que se decide sem construir nem escanear.
|
|
220
|
+
|
|
221
|
+
Uma varredura de frota lê Dockerfiles; ela não constrói imagem nem
|
|
222
|
+
chama scanner. Aplicar as regras que dependem de scan ali produziria
|
|
223
|
+
uma violação por arquivo, todas dizendo a mesma coisa ("não houve
|
|
224
|
+
scan") -- e uma lista em que tudo está vermelho não distingue nada.
|
|
225
|
+
|
|
226
|
+
As regras removidas não são consideradas cumpridas: elas continuam
|
|
227
|
+
valendo no `build`, que é onde há medição para conferi-las.
|
|
228
|
+
"""
|
|
229
|
+
return BuildPolicy(
|
|
230
|
+
require_pinned_bases=self.require_pinned_bases,
|
|
231
|
+
require_nonroot=self.require_nonroot,
|
|
232
|
+
required_labels=self.required_labels,
|
|
233
|
+
allowed_base_registries=self.allowed_base_registries,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def to_dict(self) -> dict[str, object]:
|
|
237
|
+
return {
|
|
238
|
+
"fail_on": self.fail_on,
|
|
239
|
+
"max_vulnerabilities": dict(self.max_vulnerabilities),
|
|
240
|
+
"require_scan": self.require_scan,
|
|
241
|
+
"require_pinned_bases": self.require_pinned_bases,
|
|
242
|
+
"require_nonroot": self.require_nonroot,
|
|
243
|
+
"required_labels": list(self.required_labels),
|
|
244
|
+
"allowed_base_registries": list(self.allowed_base_registries),
|
|
245
|
+
"require_provenance": self.require_provenance,
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def evaluate(policy: BuildPolicy, facts: PolicyFacts) -> list[PolicyViolation]:
|
|
250
|
+
"""As regras que este build não cumpriu, na ordem em que foram declaradas.
|
|
251
|
+
|
|
252
|
+
Uma lista vazia significa "nenhuma regra foi violada", e não "está tudo
|
|
253
|
+
bem": uma política vazia não viola nada e também não garante nada. Quem
|
|
254
|
+
consome precisa olhar a política junto do resultado, e é por isso que
|
|
255
|
+
`is_empty` existe.
|
|
256
|
+
"""
|
|
257
|
+
violations: list[PolicyViolation] = []
|
|
258
|
+
|
|
259
|
+
_check_scan(policy, facts, violations)
|
|
260
|
+
_check_ceilings(policy, facts, violations)
|
|
261
|
+
_check_bases(policy, facts, violations)
|
|
262
|
+
_check_nonroot(policy, facts, violations)
|
|
263
|
+
_check_labels(policy, facts, violations)
|
|
264
|
+
_check_provenance(policy, facts, violations)
|
|
265
|
+
return violations
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _check_scan(policy: BuildPolicy, facts: PolicyFacts, violations: list[PolicyViolation]) -> None:
|
|
269
|
+
if policy.require_scan and not facts.scan_ran:
|
|
270
|
+
violations.append(
|
|
271
|
+
PolicyViolation(
|
|
272
|
+
rule=PolicyRule.REQUIRE_SCAN,
|
|
273
|
+
message=(
|
|
274
|
+
"the policy requires a scan and no scanner ran: an image that "
|
|
275
|
+
"could not be measured is not an image without vulnerabilities"
|
|
276
|
+
),
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _check_ceilings(
|
|
282
|
+
policy: BuildPolicy, facts: PolicyFacts, violations: list[PolicyViolation]
|
|
283
|
+
) -> None:
|
|
284
|
+
if not policy.max_vulnerabilities:
|
|
285
|
+
return
|
|
286
|
+
if not facts.scan_ran:
|
|
287
|
+
# Sem scan não há contagem, e "contagem ausente" não é "contagem
|
|
288
|
+
# dentro do teto". Aprovar aqui esvaziaria toda regra de teto numa
|
|
289
|
+
# máquina sem scanner.
|
|
290
|
+
violations.append(
|
|
291
|
+
PolicyViolation(
|
|
292
|
+
rule=PolicyRule.MAX_VULNERABILITIES,
|
|
293
|
+
message=(
|
|
294
|
+
"the policy declares per-severity ceilings and no scanner ran: "
|
|
295
|
+
"there is no count to check them against"
|
|
296
|
+
),
|
|
297
|
+
)
|
|
298
|
+
)
|
|
299
|
+
return
|
|
300
|
+
for severity in SEVERITY_ORDER:
|
|
301
|
+
if severity not in policy.max_vulnerabilities:
|
|
302
|
+
continue
|
|
303
|
+
limit = policy.max_vulnerabilities[severity]
|
|
304
|
+
found = facts.severity_counts.get(severity, 0)
|
|
305
|
+
if found > limit:
|
|
306
|
+
violations.append(
|
|
307
|
+
PolicyViolation(
|
|
308
|
+
rule=PolicyRule.MAX_VULNERABILITIES,
|
|
309
|
+
message=(
|
|
310
|
+
f"{found} {severity.upper()} vulnerability(ies) against a "
|
|
311
|
+
f"policy ceiling of {limit}"
|
|
312
|
+
),
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _check_bases(
|
|
318
|
+
policy: BuildPolicy, facts: PolicyFacts, violations: list[PolicyViolation]
|
|
319
|
+
) -> None:
|
|
320
|
+
if policy.require_pinned_bases:
|
|
321
|
+
if not facts.bases:
|
|
322
|
+
violations.append(
|
|
323
|
+
PolicyViolation(
|
|
324
|
+
rule=PolicyRule.REQUIRE_PINNED_BASES,
|
|
325
|
+
message=(
|
|
326
|
+
"the policy requires digest-pinned bases and no base could "
|
|
327
|
+
"be read from the Dockerfile"
|
|
328
|
+
),
|
|
329
|
+
)
|
|
330
|
+
)
|
|
331
|
+
for base in facts.bases:
|
|
332
|
+
if not base.pinned:
|
|
333
|
+
violations.append(
|
|
334
|
+
PolicyViolation(
|
|
335
|
+
rule=PolicyRule.REQUIRE_PINNED_BASES,
|
|
336
|
+
message=(
|
|
337
|
+
f"{base.reference} is not pinned by digest: what was "
|
|
338
|
+
"tested and what ships to production can be different "
|
|
339
|
+
"bytes with no change of yours"
|
|
340
|
+
),
|
|
341
|
+
)
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
if not policy.allowed_base_registries:
|
|
345
|
+
return
|
|
346
|
+
permitidos = {r.lower() for r in policy.allowed_base_registries}
|
|
347
|
+
for base in facts.bases:
|
|
348
|
+
# Uma base sem host explícito vem do Docker Hub; tratá-la como "sem
|
|
349
|
+
# registry" faria a regra ignorar exatamente o caso mais comum.
|
|
350
|
+
registry = (base.registry or "docker.io").lower()
|
|
351
|
+
if registry not in permitidos:
|
|
352
|
+
violations.append(
|
|
353
|
+
PolicyViolation(
|
|
354
|
+
rule=PolicyRule.ALLOWED_BASE_REGISTRIES,
|
|
355
|
+
message=(
|
|
356
|
+
f"{base.reference} comes from {registry}, which is not among "
|
|
357
|
+
f"the allowed registries ({', '.join(sorted(permitidos))})"
|
|
358
|
+
),
|
|
359
|
+
)
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _check_nonroot(
|
|
364
|
+
policy: BuildPolicy, facts: PolicyFacts, violations: list[PolicyViolation]
|
|
365
|
+
) -> None:
|
|
366
|
+
if not policy.require_nonroot or facts.nonroot.is_true:
|
|
367
|
+
return
|
|
368
|
+
motivo = (
|
|
369
|
+
"the image runs as root"
|
|
370
|
+
if facts.nonroot.is_false
|
|
371
|
+
else "the user the image runs as could not be determined, and not "
|
|
372
|
+
"determining is not the same as being in order"
|
|
373
|
+
)
|
|
374
|
+
violations.append(
|
|
375
|
+
PolicyViolation(
|
|
376
|
+
rule=PolicyRule.REQUIRE_NONROOT,
|
|
377
|
+
message=f"the policy requires running without privilege: {motivo}",
|
|
378
|
+
)
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _check_labels(
|
|
383
|
+
policy: BuildPolicy, facts: PolicyFacts, violations: list[PolicyViolation]
|
|
384
|
+
) -> None:
|
|
385
|
+
for label in policy.required_labels:
|
|
386
|
+
if not facts.labels.get(label, "").strip():
|
|
387
|
+
violations.append(
|
|
388
|
+
PolicyViolation(
|
|
389
|
+
rule=PolicyRule.REQUIRED_LABELS,
|
|
390
|
+
message=(
|
|
391
|
+
f"required label missing or empty: {label} -- without it "
|
|
392
|
+
"nobody knows who to turn to when this image shows up in an "
|
|
393
|
+
"alert at three in the morning"
|
|
394
|
+
),
|
|
395
|
+
)
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _check_provenance(
|
|
400
|
+
policy: BuildPolicy, facts: PolicyFacts, violations: list[PolicyViolation]
|
|
401
|
+
) -> None:
|
|
402
|
+
if not policy.require_provenance:
|
|
403
|
+
return
|
|
404
|
+
if facts.provenance_status == "VERIFIED":
|
|
405
|
+
return
|
|
406
|
+
detalhe = facts.provenance_status or "no record was produced"
|
|
407
|
+
violations.append(
|
|
408
|
+
PolicyViolation(
|
|
409
|
+
rule=PolicyRule.REQUIRE_PROVENANCE,
|
|
410
|
+
message=(f"the policy requires verified provenance, and the record is: {detalhe}"),
|
|
411
|
+
)
|
|
412
|
+
)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""How much the evidence behind a verdict is worth.
|
|
2
|
+
|
|
3
|
+
Every number this tool prints is the output of a chain: discover a
|
|
4
|
+
candidate, resolve it to a digest, scan it, cross-check it with a second
|
|
5
|
+
scanner, read its configuration. Links in that chain break routinely -- a
|
|
6
|
+
registry rate-limits, a second scanner is not installed, a catalogue is
|
|
7
|
+
stale -- and the result is still a table full of numbers. Without a
|
|
8
|
+
confidence signal, a score produced from one scanner on an unresolved tag is
|
|
9
|
+
rendered identically to one produced from two agreeing scanners on a pinned
|
|
10
|
+
digest, and the reader has no way to tell them apart.
|
|
11
|
+
|
|
12
|
+
The rule the rest of this codebase is built on gets its final expression
|
|
13
|
+
here: **a technical failure never becomes a security statement.** A scan
|
|
14
|
+
that did not complete is UNVERIFIED, which is not a bad score -- it is the
|
|
15
|
+
absence of a score, and the ranking layer refuses to recommend it at all.
|
|
16
|
+
|
|
17
|
+
The four levels:
|
|
18
|
+
|
|
19
|
+
* `UNVERIFIED` -- no completed scan. Nothing may be concluded, in either
|
|
20
|
+
direction. This is a floor: no other signal can lift a candidate out of it.
|
|
21
|
+
* `LOW` -- scanned, but with a material problem: two scanners disagreed
|
|
22
|
+
substantially, or the reference could not be pinned to a digest and the
|
|
23
|
+
registry could not confirm it.
|
|
24
|
+
* `MEDIUM` -- scanned and consistent, with some evidence missing (no second
|
|
25
|
+
scanner, no digest, or thin hardening coverage).
|
|
26
|
+
* `HIGH` -- scanned, pinned to a digest, confirmed in its registry, and
|
|
27
|
+
corroborated by a second scanner that agreed.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from enum import StrEnum
|
|
33
|
+
from typing import NamedTuple
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Confidence(StrEnum):
|
|
37
|
+
UNVERIFIED = "UNVERIFIED"
|
|
38
|
+
LOW = "LOW"
|
|
39
|
+
MEDIUM = "MEDIUM"
|
|
40
|
+
HIGH = "HIGH"
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def is_recommendable(self) -> bool:
|
|
44
|
+
"""Whether a candidate at this confidence may be presented as a pick.
|
|
45
|
+
|
|
46
|
+
UNVERIFIED never is. LOW may be shown -- with its reasons -- but the
|
|
47
|
+
ranking layer will not put it above a comparable MEDIUM/HIGH result.
|
|
48
|
+
"""
|
|
49
|
+
return self is not Confidence.UNVERIFIED
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
#: Ordering for comparisons, worst first. Written out rather than relying on
|
|
53
|
+
#: declaration order, so a future level inserted in the middle cannot
|
|
54
|
+
#: silently reorder existing comparisons.
|
|
55
|
+
CONFIDENCE_ORDER: tuple[Confidence, ...] = (
|
|
56
|
+
Confidence.UNVERIFIED,
|
|
57
|
+
Confidence.LOW,
|
|
58
|
+
Confidence.MEDIUM,
|
|
59
|
+
Confidence.HIGH,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def confidence_rank(level: Confidence) -> int:
|
|
64
|
+
return CONFIDENCE_ORDER.index(level)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ConfidenceInputs(NamedTuple):
|
|
68
|
+
"""The facts that decide a confidence level.
|
|
69
|
+
|
|
70
|
+
A plain record rather than a reference to the analysis DTO: the domain
|
|
71
|
+
must not depend on the application layer, and stating the inputs
|
|
72
|
+
explicitly makes the rule below auditable in one screen.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
#: The primary scan completed and produced a parsed result.
|
|
76
|
+
scan_verified: bool
|
|
77
|
+
#: A second scanner ran and produced a comparable result.
|
|
78
|
+
cross_validated: bool = False
|
|
79
|
+
#: A second scanner ran and disagreed materially with the first.
|
|
80
|
+
scanners_disagree: bool = False
|
|
81
|
+
#: A second scanner ran and differed on individual findings without the
|
|
82
|
+
#: disagreement being material. Two vulnerability databases legitimately
|
|
83
|
+
#: differ at the margins, so this does not refute the result -- but it is
|
|
84
|
+
#: not the clean corroboration that HIGH is meant to represent either.
|
|
85
|
+
scanners_differ_slightly: bool = False
|
|
86
|
+
#: The candidate is pinned to a manifest digest.
|
|
87
|
+
digest_resolved: bool = False
|
|
88
|
+
#: The registry that owns the reference confirmed the tag exists.
|
|
89
|
+
#: Tri-state upstream (None = not checked); False is a real refutation.
|
|
90
|
+
registry_verified: bool | None = None
|
|
91
|
+
#: Share of the hardening model that could be determined, 0.0-1.0.
|
|
92
|
+
hardening_coverage: float = 0.0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
#: Hardening coverage below which the evidence is considered thin. Matches
|
|
96
|
+
#: the reporting threshold of the hardening model itself, so a score that is
|
|
97
|
+
#: not worth printing cannot silently prop up a HIGH confidence.
|
|
98
|
+
THIN_COVERAGE = 0.25
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ConfidenceAssessment:
|
|
102
|
+
"""Derives a confidence level and the reasons behind it."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, inputs: ConfidenceInputs):
|
|
105
|
+
self._inputs = inputs
|
|
106
|
+
self._reasons: list[str] = []
|
|
107
|
+
self._level = self._assess()
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def level(self) -> Confidence:
|
|
111
|
+
return self._level
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def reasons(self) -> list[str]:
|
|
115
|
+
"""Why this level, in the reader's terms. Never empty."""
|
|
116
|
+
return list(self._reasons)
|
|
117
|
+
|
|
118
|
+
def _assess(self) -> Confidence:
|
|
119
|
+
i = self._inputs
|
|
120
|
+
|
|
121
|
+
# The floor. Checked first and returned immediately: nothing below
|
|
122
|
+
# is allowed to reason its way past a missing measurement.
|
|
123
|
+
if not i.scan_verified:
|
|
124
|
+
self._reasons.append("no completed scan: nothing was measured")
|
|
125
|
+
return Confidence.UNVERIFIED
|
|
126
|
+
|
|
127
|
+
if i.registry_verified is False:
|
|
128
|
+
self._reasons.append("the registry that owns this reference does not have this tag")
|
|
129
|
+
return Confidence.LOW
|
|
130
|
+
|
|
131
|
+
if i.scanners_disagree:
|
|
132
|
+
self._reasons.append("two scanners disagreed materially on the vulnerability counts")
|
|
133
|
+
return Confidence.LOW
|
|
134
|
+
|
|
135
|
+
if not i.digest_resolved and i.registry_verified is not True:
|
|
136
|
+
self._reasons.append("reference is not pinned to a digest and was not confirmed")
|
|
137
|
+
return Confidence.LOW
|
|
138
|
+
|
|
139
|
+
gaps: list[str] = []
|
|
140
|
+
if not i.cross_validated:
|
|
141
|
+
gaps.append("only one scanner ran")
|
|
142
|
+
if i.scanners_differ_slightly:
|
|
143
|
+
gaps.append("the second scanner differed on individual findings")
|
|
144
|
+
if not i.digest_resolved:
|
|
145
|
+
gaps.append("no manifest digest resolved")
|
|
146
|
+
if i.hardening_coverage < THIN_COVERAGE:
|
|
147
|
+
gaps.append("little of the image configuration could be inspected")
|
|
148
|
+
|
|
149
|
+
if gaps:
|
|
150
|
+
self._reasons.extend(gaps)
|
|
151
|
+
return Confidence.MEDIUM
|
|
152
|
+
|
|
153
|
+
self._reasons.append("scanned, pinned to a digest, confirmed in its registry")
|
|
154
|
+
if i.cross_validated:
|
|
155
|
+
self._reasons.append("corroborated by a second scanner that agreed")
|
|
156
|
+
return Confidence.HIGH
|