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,174 @@
|
|
|
1
|
+
"""Um retrato de todos os Dockerfiles de uma vez.
|
|
2
|
+
|
|
3
|
+
Cada comando desta ferramenta olha para um artefato: um Dockerfile, uma
|
|
4
|
+
imagem, um build. Isso resolve a pergunta de quem está com o arquivo aberto e
|
|
5
|
+
não resolve nenhuma das perguntas de quem responde por trinta repositórios --
|
|
6
|
+
"quantos ainda rodam como root?", "quantos fixam a base?", "por onde eu
|
|
7
|
+
começo?". Sem resposta, a resposta na prática vira "por onde alguém reclamar".
|
|
8
|
+
|
|
9
|
+
Este módulo é a agregação, e é pura: recebe o que foi lido de cada arquivo e
|
|
10
|
+
devolve o retrato. O que ele **não** faz é tão importante quanto o que faz:
|
|
11
|
+
uma varredura de frota lê Dockerfiles, não constrói imagens nem chama scanner.
|
|
12
|
+
Portanto ela nunca diz que uma imagem está limpa -- ela diz o que o arquivo
|
|
13
|
+
declara, e nada além. Chamar isso de "auditoria de segurança da frota" seria
|
|
14
|
+
exatamente a promessa que esta ferramenta existe para não fazer.
|
|
15
|
+
|
|
16
|
+
A ordenação é por número de violações e depois por caminho. O empate resolvido
|
|
17
|
+
pelo caminho não é detalhe: sem ele, a mesma frota produziria uma ordem
|
|
18
|
+
diferente a cada varredura, e nenhum relatório seria comparável com o
|
|
19
|
+
anterior.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import TYPE_CHECKING
|
|
26
|
+
|
|
27
|
+
from dockerls.domain.value_objects.tristate import Tristate
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from dockerls.domain.value_objects.build_policy import PolicyViolation
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class FleetEntry:
|
|
35
|
+
"""Um Dockerfile da frota, com o que se conseguiu ler dele."""
|
|
36
|
+
|
|
37
|
+
path: str
|
|
38
|
+
#: Bases declaradas, na ordem dos `FROM`.
|
|
39
|
+
bases: tuple[str, ...] = ()
|
|
40
|
+
pinned_bases: int = 0
|
|
41
|
+
total_bases: int = 0
|
|
42
|
+
nonroot: Tristate = Tristate.UNKNOWN
|
|
43
|
+
stages: int = 1
|
|
44
|
+
violations: tuple[PolicyViolation, ...] = ()
|
|
45
|
+
#: Preenchido quando o arquivo não pôde ser lido ou analisado. Uma entrada
|
|
46
|
+
#: com erro nunca conta como conforme: é ausência de leitura.
|
|
47
|
+
error: str = ""
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def fully_pinned(self) -> bool:
|
|
51
|
+
return bool(self.total_bases) and self.pinned_bases == self.total_bases
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def readable(self) -> bool:
|
|
55
|
+
return not self.error
|
|
56
|
+
|
|
57
|
+
def to_dict(self) -> dict[str, object]:
|
|
58
|
+
return {
|
|
59
|
+
"path": self.path,
|
|
60
|
+
"bases": list(self.bases),
|
|
61
|
+
"pinned_bases": self.pinned_bases,
|
|
62
|
+
"total_bases": self.total_bases,
|
|
63
|
+
"fully_pinned": self.fully_pinned,
|
|
64
|
+
"nonroot": str(self.nonroot),
|
|
65
|
+
"stages": self.stages,
|
|
66
|
+
"violations": [v.to_dict() for v in self.violations],
|
|
67
|
+
"error": self.error,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class FleetReport:
|
|
73
|
+
"""O retrato agregado, e a fila de trabalho que sai dele."""
|
|
74
|
+
|
|
75
|
+
root: str
|
|
76
|
+
entries: tuple[FleetEntry, ...] = field(default_factory=tuple)
|
|
77
|
+
#: Diretórios que a varredura não conseguiu percorrer. Reportar é o que
|
|
78
|
+
#: impede um relatório de parecer completo quando não é.
|
|
79
|
+
unreadable_paths: tuple[str, ...] = ()
|
|
80
|
+
#: Se a varredura parou no teto de arquivos. Um retrato truncado que não
|
|
81
|
+
#: diz que foi truncado é pior do que nenhum retrato.
|
|
82
|
+
truncated: bool = False
|
|
83
|
+
#: Se havia política declarada. Sem ela, `violations` está sempre vazio --
|
|
84
|
+
#: e isso não significa conformidade.
|
|
85
|
+
policy_applied: bool = False
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def total(self) -> int:
|
|
89
|
+
return len(self.entries)
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def readable(self) -> tuple[FleetEntry, ...]:
|
|
93
|
+
return tuple(e for e in self.entries if e.readable)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def unreadable(self) -> tuple[FleetEntry, ...]:
|
|
97
|
+
return tuple(e for e in self.entries if not e.readable)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def fully_pinned(self) -> int:
|
|
101
|
+
return sum(1 for e in self.readable if e.fully_pinned)
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def running_as_root(self) -> int:
|
|
105
|
+
return sum(1 for e in self.readable if e.nonroot.is_false)
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def undetermined_user(self) -> int:
|
|
109
|
+
"""Arquivos em que não se conseguiu decidir o usuário.
|
|
110
|
+
|
|
111
|
+
Contado separado de `running_as_root` de propósito: juntar os dois
|
|
112
|
+
transformaria ausência de medida em acusação, e a fila de trabalho de
|
|
113
|
+
cada um é diferente."""
|
|
114
|
+
return sum(1 for e in self.readable if not e.nonroot.is_known)
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def with_violations(self) -> int:
|
|
118
|
+
return sum(1 for e in self.entries if e.violations)
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def total_violations(self) -> int:
|
|
122
|
+
return sum(len(e.violations) for e in self.entries)
|
|
123
|
+
|
|
124
|
+
def worst_first(self) -> tuple[FleetEntry, ...]:
|
|
125
|
+
"""A fila de trabalho: mais violações primeiro, caminho desempata.
|
|
126
|
+
|
|
127
|
+
O desempate por caminho é o que torna duas varreduras comparáveis --
|
|
128
|
+
sem ele a mesma frota sairia em ordem diferente a cada execução.
|
|
129
|
+
"""
|
|
130
|
+
return tuple(sorted(self.entries, key=lambda e: (-len(e.violations), e.path)))
|
|
131
|
+
|
|
132
|
+
def summary(self) -> str:
|
|
133
|
+
"""A frase que resume o retrato sem prometer o que não foi medido."""
|
|
134
|
+
if not self.total:
|
|
135
|
+
return "no Dockerfile found"
|
|
136
|
+
partes = [
|
|
137
|
+
f"{self.total} Dockerfile(s)",
|
|
138
|
+
f"{self.fully_pinned} with every base pinned",
|
|
139
|
+
]
|
|
140
|
+
if self.running_as_root:
|
|
141
|
+
partes.append(f"{self.running_as_root} running as root")
|
|
142
|
+
if self.undetermined_user:
|
|
143
|
+
partes.append(f"{self.undetermined_user} with an undetermined user")
|
|
144
|
+
if self.unreadable:
|
|
145
|
+
partes.append(f"{len(self.unreadable)} unreadable")
|
|
146
|
+
return ", ".join(partes)
|
|
147
|
+
|
|
148
|
+
def caveat(self) -> str:
|
|
149
|
+
"""O que este relatório deliberadamente não afirma."""
|
|
150
|
+
return (
|
|
151
|
+
"this scan reads Dockerfiles: it builds no image and calls no scanner. "
|
|
152
|
+
"It reports what the files declare, and nothing about the vulnerabilities "
|
|
153
|
+
"of the images they produce"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def to_dict(self) -> dict[str, object]:
|
|
157
|
+
return {
|
|
158
|
+
"root": self.root,
|
|
159
|
+
"summary": self.summary(),
|
|
160
|
+
"caveat": self.caveat(),
|
|
161
|
+
"policy_applied": self.policy_applied,
|
|
162
|
+
"truncated": self.truncated,
|
|
163
|
+
"unreadable_paths": list(self.unreadable_paths),
|
|
164
|
+
"totals": {
|
|
165
|
+
"dockerfiles": self.total,
|
|
166
|
+
"fully_pinned": self.fully_pinned,
|
|
167
|
+
"running_as_root": self.running_as_root,
|
|
168
|
+
"undetermined_user": self.undetermined_user,
|
|
169
|
+
"with_violations": self.with_violations,
|
|
170
|
+
"violations": self.total_violations,
|
|
171
|
+
"unreadable": len(self.unreadable),
|
|
172
|
+
},
|
|
173
|
+
"dockerfiles": [e.to_dict() for e in self.worst_first()],
|
|
174
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""O que faz um build reprovar, dito como um valor e não como um `if`.
|
|
2
|
+
|
|
3
|
+
O portão olhava só severidade -- `critical`, `high`, `medium`, `low` --, e
|
|
4
|
+
isso era uma incoerência do projeto inteiro: a ferramenta consulta o
|
|
5
|
+
catálogo CISA KEV, o EPSS e o Exploit-DB, usa os três para pontuar
|
|
6
|
+
(`SecurityScore`) e para redigir o veredito, e então reprovava o build por
|
|
7
|
+
um rótulo que o vendor da distro escolheu.
|
|
8
|
+
|
|
9
|
+
O caso concreto que isso deixava passar: um CVE **sendo explorado hoje**,
|
|
10
|
+
classificado MEDIUM pelo vendor, atravessava um `--fail-on high` sem um
|
|
11
|
+
pio. E o caso inverso: um CRITICAL teórico, sem exploit publicado e com
|
|
12
|
+
EPSS de 0,0003, reprovava o build. A ferramenta sabia a diferença e não a
|
|
13
|
+
usava onde ela decide alguma coisa.
|
|
14
|
+
|
|
15
|
+
`kev` e `epss>=N` são medições, não heurísticas -- é por isso que cabem num
|
|
16
|
+
portão, e é por isso que este módulo é puro: a regra que reprova um build
|
|
17
|
+
tem de ser testável sem rede, sem scanner e sem Docker.
|
|
18
|
+
|
|
19
|
+
**O tristate no portão.** `kev_status` UNKNOWN significa "ninguém
|
|
20
|
+
consultou", e há duas formas erradas de tratá-lo aqui. Reprovar por ele
|
|
21
|
+
diria que a imagem tem CVE explorado, o que é inventar. Aprovar por ele
|
|
22
|
+
gastaria a ausência de medição como tranquilidade, que é exatamente o que
|
|
23
|
+
este projeto existe para não fazer -- e pior, desligaria um portão de
|
|
24
|
+
segurança em silêncio numa oscilação de rede.
|
|
25
|
+
|
|
26
|
+
A saída é a terceira: quem pediu `--fail-on kev` pediu que a exploração
|
|
27
|
+
fosse *conferida*. Não conseguir conferir reprova o **run**, com uma
|
|
28
|
+
mensagem que nomeia a causa como falta de medição e não como veredito
|
|
29
|
+
sobre a imagem. São coisas diferentes e a mensagem diz qual das duas é.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import re
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from enum import StrEnum
|
|
37
|
+
from typing import TYPE_CHECKING
|
|
38
|
+
|
|
39
|
+
from dockerls.domain.value_objects.tristate import Tristate
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from collections.abc import Mapping, Sequence
|
|
43
|
+
|
|
44
|
+
#: Limiares de severidade, do mais severo para o mais brando. Cada um
|
|
45
|
+
#: reprova também tudo que for pior que ele.
|
|
46
|
+
SEVERITY_THRESHOLDS: tuple[str, ...] = ("critical", "high", "medium", "low")
|
|
47
|
+
|
|
48
|
+
#: `epss>=0.5`, `epss >= 0.5`, `epss>0.5`. O `>=` é o sentido pretendido; o
|
|
49
|
+
#: `>` é aceito porque alguém vai escrevê-lo e recusar seria pedantismo.
|
|
50
|
+
_EPSS = re.compile(r"^epss\s*>=?\s*(\d*\.?\d+)$", re.IGNORECASE)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class GateKind(StrEnum):
|
|
54
|
+
"""Sobre o que este portão pergunta."""
|
|
55
|
+
|
|
56
|
+
SEVERITY = "SEVERITY"
|
|
57
|
+
#: Listado no catálogo CISA KEV: exploração observada no mundo real.
|
|
58
|
+
KEV = "KEV"
|
|
59
|
+
#: Probabilidade EPSS de exploração nos próximos 30 dias.
|
|
60
|
+
EPSS = "EPSS"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class GateOutcome(StrEnum):
|
|
64
|
+
"""O que o portão concluiu."""
|
|
65
|
+
|
|
66
|
+
PASS = "PASS" # noqa: S105 -- veredito de portão, não senha
|
|
67
|
+
#: Achado que dispara o portão. É um veredito sobre a imagem.
|
|
68
|
+
FAIL = "FAIL"
|
|
69
|
+
#: O portão foi pedido e não pôde ser avaliado. **Não** é um veredito
|
|
70
|
+
#: sobre a imagem: é a constatação de que a pergunta ficou sem resposta.
|
|
71
|
+
UNMEASURED = "UNMEASURED"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class InvalidGateError(ValueError):
|
|
75
|
+
"""O valor de `--fail-on` não nomeia um portão que existe."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class Finding:
|
|
80
|
+
"""O mínimo que o portão precisa saber sobre um achado.
|
|
81
|
+
|
|
82
|
+
Deliberadamente não é a `Vulnerability` do domínio: o `build` carrega
|
|
83
|
+
seus achados como dicionários vindos do scanner, e obrigar uma
|
|
84
|
+
conversão só para consultar quatro campos acoplaria o portão à forma de
|
|
85
|
+
quem o chama.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
cve_id: str
|
|
89
|
+
severity: str
|
|
90
|
+
kev: Tristate = Tristate.UNKNOWN
|
|
91
|
+
#: Probabilidade EPSS, ou None quando ninguém consultou. `0.0` é uma
|
|
92
|
+
#: resposta ("o FIRST pontuou em zero") e `None` é a falta dela.
|
|
93
|
+
epss: float | None = None
|
|
94
|
+
package: str = ""
|
|
95
|
+
fixed_version: str = ""
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def severity_rank(self) -> int:
|
|
99
|
+
"""Posição na escala, ou -1 para uma severidade fora dela."""
|
|
100
|
+
try:
|
|
101
|
+
return SEVERITY_THRESHOLDS.index(self.severity.strip().lower())
|
|
102
|
+
except ValueError:
|
|
103
|
+
return -1
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class GateVerdict:
|
|
108
|
+
"""O que o portão decidiu, e por quê."""
|
|
109
|
+
|
|
110
|
+
outcome: GateOutcome
|
|
111
|
+
kind: GateKind
|
|
112
|
+
#: Os achados que dispararam, na ordem em que serão mostrados.
|
|
113
|
+
offenders: tuple[Finding, ...] = ()
|
|
114
|
+
#: Frase para o humano. Nomeia a causa, nunca só "reprovou".
|
|
115
|
+
reason: str = ""
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def failed(self) -> bool:
|
|
119
|
+
"""Se o build não deve prosseguir.
|
|
120
|
+
|
|
121
|
+
`UNMEASURED` conta: quem pediu o portão pediu que a pergunta fosse
|
|
122
|
+
respondida, e ela não foi.
|
|
123
|
+
"""
|
|
124
|
+
return self.outcome is not GateOutcome.PASS
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class Gate:
|
|
129
|
+
"""Um `--fail-on` já interpretado."""
|
|
130
|
+
|
|
131
|
+
kind: GateKind
|
|
132
|
+
#: Para SEVERITY, o limiar (`"high"`). Para EPSS, o piso (`0.5`).
|
|
133
|
+
severity: str = ""
|
|
134
|
+
epss_floor: float = 0.0
|
|
135
|
+
#: O texto original, para a mensagem citar o que o usuário escreveu.
|
|
136
|
+
raw: str = ""
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
def parse(cls, value: str) -> Gate:
|
|
140
|
+
"""Interpreta o valor de `--fail-on`, ou recusa nomeando as opções.
|
|
141
|
+
|
|
142
|
+
Recusar aqui é o ponto: um limiar desconhecido caindo num
|
|
143
|
+
`return False` seria um portão que nunca reprova, em silêncio -- e
|
|
144
|
+
esse bug já existiu neste arquivo com `--fail-on medium`.
|
|
145
|
+
"""
|
|
146
|
+
text = value.strip().lower()
|
|
147
|
+
if not text:
|
|
148
|
+
raise InvalidGateError("no gate was named")
|
|
149
|
+
|
|
150
|
+
if text in SEVERITY_THRESHOLDS:
|
|
151
|
+
return cls(kind=GateKind.SEVERITY, severity=text, raw=text)
|
|
152
|
+
if text == "kev":
|
|
153
|
+
return cls(kind=GateKind.KEV, raw=text)
|
|
154
|
+
|
|
155
|
+
match = _EPSS.match(text)
|
|
156
|
+
if match is not None:
|
|
157
|
+
floor = float(match.group(1))
|
|
158
|
+
if not 0.0 <= floor <= 1.0:
|
|
159
|
+
raise InvalidGateError(
|
|
160
|
+
f"EPSS is a probability between 0 and 1; {floor} is outside it"
|
|
161
|
+
)
|
|
162
|
+
return cls(kind=GateKind.EPSS, epss_floor=floor, raw=text)
|
|
163
|
+
|
|
164
|
+
raise InvalidGateError(
|
|
165
|
+
f"unknown --fail-on gate {value!r}; expected one of "
|
|
166
|
+
f"{', '.join(SEVERITY_THRESHOLDS)}, kev, or epss>=N (e.g. epss>=0.5)"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def evaluate(
|
|
170
|
+
self, findings: Sequence[Finding], counts: Mapping[str, int] | None = None
|
|
171
|
+
) -> GateVerdict:
|
|
172
|
+
"""Decide, e diz por quê.
|
|
173
|
+
|
|
174
|
+
`counts` é a contagem por severidade do scan **completo**. Ela
|
|
175
|
+
existe porque `findings` costuma ser uma amostra: o relatório retém
|
|
176
|
+
um número limitado de achados, e deixar o portão contar a amostra
|
|
177
|
+
faria o número que reprova ser menor que o número que existe. O que
|
|
178
|
+
reprova e o que se lê têm de ser o mesmo número.
|
|
179
|
+
"""
|
|
180
|
+
match self.kind:
|
|
181
|
+
case GateKind.SEVERITY:
|
|
182
|
+
return self._severity(findings, counts)
|
|
183
|
+
case GateKind.KEV:
|
|
184
|
+
return self._kev(findings)
|
|
185
|
+
case GateKind.EPSS:
|
|
186
|
+
return self._epss(findings)
|
|
187
|
+
|
|
188
|
+
def _severity(
|
|
189
|
+
self, findings: Sequence[Finding], counts: Mapping[str, int] | None
|
|
190
|
+
) -> GateVerdict:
|
|
191
|
+
cutoff = SEVERITY_THRESHOLDS.index(self.severity)
|
|
192
|
+
levels = SEVERITY_THRESHOLDS[: cutoff + 1]
|
|
193
|
+
offenders = tuple(f for f in findings if 0 <= f.severity_rank <= cutoff)
|
|
194
|
+
total = (
|
|
195
|
+
sum(counts.get(level, 0) for level in levels) if counts is not None else len(offenders)
|
|
196
|
+
)
|
|
197
|
+
if total == 0:
|
|
198
|
+
return GateVerdict(GateOutcome.PASS, self.kind)
|
|
199
|
+
return GateVerdict(
|
|
200
|
+
GateOutcome.FAIL,
|
|
201
|
+
self.kind,
|
|
202
|
+
offenders,
|
|
203
|
+
f"{total} finding(s) at or above {self.severity.upper()}",
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def _kev(self, findings: Sequence[Finding]) -> GateVerdict:
|
|
207
|
+
listed = tuple(f for f in findings if f.kev is Tristate.TRUE)
|
|
208
|
+
if listed:
|
|
209
|
+
return GateVerdict(
|
|
210
|
+
GateOutcome.FAIL,
|
|
211
|
+
self.kind,
|
|
212
|
+
listed,
|
|
213
|
+
f"{len(listed)} finding(s) are in the CISA KEV catalogue, which means "
|
|
214
|
+
"exploitation observed in the wild",
|
|
215
|
+
)
|
|
216
|
+
# Nada listado. Resta saber se a pergunta chegou a ser feita.
|
|
217
|
+
if findings and all(f.kev is Tristate.UNKNOWN for f in findings):
|
|
218
|
+
return GateVerdict(
|
|
219
|
+
GateOutcome.UNMEASURED,
|
|
220
|
+
self.kind,
|
|
221
|
+
(),
|
|
222
|
+
"the KEV gate was requested and the catalogue was never consulted, so "
|
|
223
|
+
"nothing here was checked against it. This is an absent measurement, "
|
|
224
|
+
"not a finding: the image may or may not carry an exploited CVE, and "
|
|
225
|
+
"this run cannot say which",
|
|
226
|
+
)
|
|
227
|
+
return GateVerdict(GateOutcome.PASS, self.kind)
|
|
228
|
+
|
|
229
|
+
def _epss(self, findings: Sequence[Finding]) -> GateVerdict:
|
|
230
|
+
scored = tuple(f for f in findings if f.epss is not None)
|
|
231
|
+
over = tuple(f for f in scored if f.epss is not None and f.epss >= self.epss_floor)
|
|
232
|
+
if over:
|
|
233
|
+
worst = max(f.epss or 0.0 for f in over)
|
|
234
|
+
return GateVerdict(
|
|
235
|
+
GateOutcome.FAIL,
|
|
236
|
+
self.kind,
|
|
237
|
+
over,
|
|
238
|
+
f"{len(over)} finding(s) at or above an EPSS of {self.epss_floor:g} "
|
|
239
|
+
f"(worst: {worst:.3f}), the probability of exploitation in the next "
|
|
240
|
+
"30 days",
|
|
241
|
+
)
|
|
242
|
+
if findings and not scored:
|
|
243
|
+
return GateVerdict(
|
|
244
|
+
GateOutcome.UNMEASURED,
|
|
245
|
+
self.kind,
|
|
246
|
+
(),
|
|
247
|
+
f"the EPSS gate was requested and no finding carries a score, so "
|
|
248
|
+
f"nothing here was compared against {self.epss_floor:g}. This is an "
|
|
249
|
+
"absent measurement, not a finding",
|
|
250
|
+
)
|
|
251
|
+
return GateVerdict(GateOutcome.PASS, self.kind)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@dataclass(frozen=True)
|
|
255
|
+
class GateSet:
|
|
256
|
+
"""Vários portões, todos obrigatórios.
|
|
257
|
+
|
|
258
|
+
Existe porque "mais estrito" deixou de ser uma comparação possível.
|
|
259
|
+
Entre `high` e `critical` dá para dizer qual reprova mais; entre
|
|
260
|
+
`critical` e `kev` não dá -- são perguntas diferentes sobre coisas
|
|
261
|
+
diferentes, e escolher uma seria descartar a outra em silêncio.
|
|
262
|
+
|
|
263
|
+
Quando a política do repositório pede um portão e a linha de comando
|
|
264
|
+
pede outro, os dois valem. É a única regra que não afrouxa nenhum dos
|
|
265
|
+
dois lados.
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
gates: tuple[Gate, ...]
|
|
269
|
+
|
|
270
|
+
@classmethod
|
|
271
|
+
def parse(cls, value: str) -> GateSet:
|
|
272
|
+
"""Interpreta `"critical"`, `"kev"` ou `"critical,kev,epss>=0.5"`."""
|
|
273
|
+
parts = [piece.strip() for piece in value.split(",") if piece.strip()]
|
|
274
|
+
if not parts:
|
|
275
|
+
raise InvalidGateError("no gate was named")
|
|
276
|
+
return cls(tuple(Gate.parse(part) for part in parts))
|
|
277
|
+
|
|
278
|
+
@property
|
|
279
|
+
def raw(self) -> str:
|
|
280
|
+
return ",".join(gate.raw for gate in self.gates)
|
|
281
|
+
|
|
282
|
+
def evaluate(
|
|
283
|
+
self, findings: Sequence[Finding], counts: Mapping[str, int] | None = None
|
|
284
|
+
) -> tuple[GateVerdict, ...]:
|
|
285
|
+
"""Os vereditos que **não** passaram, na ordem declarada.
|
|
286
|
+
|
|
287
|
+
Vazio significa aprovado. Devolver só as falhas é deliberado: o
|
|
288
|
+
chamador precisa listar o que reprovou, e um veredito PASS na
|
|
289
|
+
mensagem seria ruído no log do CI.
|
|
290
|
+
"""
|
|
291
|
+
return tuple(
|
|
292
|
+
verdict
|
|
293
|
+
for verdict in (gate.evaluate(findings, counts) for gate in self.gates)
|
|
294
|
+
if verdict.failed
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def merge_gates(declared: str, requested: str) -> str:
|
|
299
|
+
"""O portão que vale, entre a política do repositório e a linha de comando.
|
|
300
|
+
|
|
301
|
+
Severidade contra severidade continua sendo a regra antiga: vence a
|
|
302
|
+
**mais baixa na escala**, porque `--fail-on low` reprova em LOW e em
|
|
303
|
+
tudo acima enquanto `critical` só olha CRITICAL. Nem o arquivo desliga
|
|
304
|
+
o que o pipeline pediu, nem a flag afrouxa o que a organização exige.
|
|
305
|
+
|
|
306
|
+
Portões de tipos diferentes não competem: somam. Um `.dockerls-policy.yaml`
|
|
307
|
+
que exige `kev` e um `--fail-on high` na linha de comando produzem os
|
|
308
|
+
dois, porque descartar qualquer um seria afrouxar sem dizer.
|
|
309
|
+
"""
|
|
310
|
+
sides = [value.strip() for value in (declared, requested) if value.strip()]
|
|
311
|
+
if not sides:
|
|
312
|
+
return ""
|
|
313
|
+
if len(sides) == 1:
|
|
314
|
+
return sides[0]
|
|
315
|
+
|
|
316
|
+
kept: list[str] = []
|
|
317
|
+
severities: list[str] = []
|
|
318
|
+
for side in sides:
|
|
319
|
+
for gate in GateSet.parse(side).gates:
|
|
320
|
+
if gate.kind is GateKind.SEVERITY:
|
|
321
|
+
severities.append(gate.severity)
|
|
322
|
+
elif gate.raw not in kept:
|
|
323
|
+
kept.append(gate.raw)
|
|
324
|
+
if severities:
|
|
325
|
+
strictest = max(severities, key=SEVERITY_THRESHOLDS.index)
|
|
326
|
+
kept.insert(0, strictest)
|
|
327
|
+
return ",".join(kept)
|