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,352 @@
|
|
|
1
|
+
"""De quem é cada CVE: da base que você escolheu, ou das camadas que você escreveu.
|
|
2
|
+
|
|
3
|
+
Um relatório de build diz "47 vulnerabilidades" e manda a pessoa consertar. A
|
|
4
|
+
pergunta que ela faz em seguida -- toda vez -- é "consertar *o quê*?", e a
|
|
5
|
+
contagem não responde. Nesta ferramenta a resposta apareceu por acaso: uma
|
|
6
|
+
`base-node` recém-gerada reprovava com um CRITICAL que não vinha de nada que o
|
|
7
|
+
Dockerfile fazia, e sim do npm que a imagem oficial embute. Foi preciso um
|
|
8
|
+
terceiro produto para descobrir isso.
|
|
9
|
+
|
|
10
|
+
Este módulo faz esse cruzamento em casa. Escaneia-se a base declarada no `FROM`
|
|
11
|
+
e a imagem construída, e comparam-se os dois conjuntos de achados pela mesma
|
|
12
|
+
identidade que a validação cruzada entre scanners já usa (`CVE|pacote`). O
|
|
13
|
+
resultado divide as vulnerabilidades em três, e as três levam a ações
|
|
14
|
+
completamente diferentes:
|
|
15
|
+
|
|
16
|
+
* **`INHERITED`** -- está na base e continua na sua imagem. Nada no seu
|
|
17
|
+
Dockerfile causou, e nada no seu Dockerfile resolve: ou a base é atualizada,
|
|
18
|
+
ou é trocada. É aqui que costuma morar a grande maioria.
|
|
19
|
+
* **`INTRODUCED`** -- não está na base e está na sua imagem. Veio do que você
|
|
20
|
+
instalou, copiou ou construiu. É a única parte sobre a qual o seu código tem
|
|
21
|
+
poder direto.
|
|
22
|
+
* **`REMOVED`** -- estava na base e não está mais. O `apk upgrade` do seu
|
|
23
|
+
Dockerfile, ou a remoção de um pacote, resolveu. Aparece porque é a medida
|
|
24
|
+
do que o seu endurecimento efetivamente comprou.
|
|
25
|
+
|
|
26
|
+
A regra que sustenta o módulo é a de sempre: **sem os dois scans não há
|
|
27
|
+
atribuição.** Se a base não pôde ser escaneada, o resultado é `UNAVAILABLE` e
|
|
28
|
+
o relatório diz isso -- nunca "tudo é seu" nem "tudo é herdado", que seriam as
|
|
29
|
+
duas maneiras de transformar ausência de medição em acusação.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from enum import StrEnum
|
|
36
|
+
from typing import TYPE_CHECKING
|
|
37
|
+
|
|
38
|
+
from dockerls.domain.entities.vulnerability import finding_identity
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from collections.abc import Iterable
|
|
42
|
+
|
|
43
|
+
from dockerls.domain.entities.vulnerability import Vulnerability
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class FindingOrigin(StrEnum):
|
|
47
|
+
"""De onde cada achado veio, do ponto de vista de quem escreveu o build."""
|
|
48
|
+
|
|
49
|
+
INHERITED = "INHERITED"
|
|
50
|
+
INTRODUCED = "INTRODUCED"
|
|
51
|
+
REMOVED = "REMOVED"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
#: O que fazer com cada grupo, dito em uma linha.
|
|
55
|
+
ACTIONS: dict[FindingOrigin, str] = {
|
|
56
|
+
FindingOrigin.INHERITED: (
|
|
57
|
+
"came from the base and no line of your Dockerfile fixes it: update the base "
|
|
58
|
+
"(`dockerls base`) or swap it (`dockerls base --alternatives`)"
|
|
59
|
+
),
|
|
60
|
+
FindingOrigin.INTRODUCED: (
|
|
61
|
+
"came from what this Dockerfile installs, copies or builds: the part you have "
|
|
62
|
+
"direct power over"
|
|
63
|
+
),
|
|
64
|
+
FindingOrigin.REMOVED: (
|
|
65
|
+
"was in the base and is no longer in the final image: the measure of what your "
|
|
66
|
+
"hardening bought"
|
|
67
|
+
),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class AttributedFinding:
|
|
73
|
+
"""Um achado, e de quem ele é."""
|
|
74
|
+
|
|
75
|
+
identity: str
|
|
76
|
+
cve_id: str
|
|
77
|
+
package_name: str
|
|
78
|
+
severity: str
|
|
79
|
+
origin: FindingOrigin
|
|
80
|
+
#: Versão em que o upstream publicou a correção, quando publicou. Vazio é
|
|
81
|
+
#: "não há correção", e essa distinção é metade do plano de trabalho:
|
|
82
|
+
#: atualizar não resolve o que ninguém corrigiu.
|
|
83
|
+
fixed_version: str = ""
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def fixable(self) -> bool:
|
|
87
|
+
return bool(self.fixed_version.strip())
|
|
88
|
+
|
|
89
|
+
def to_dict(self) -> dict[str, object]:
|
|
90
|
+
return {
|
|
91
|
+
"cve": self.cve_id,
|
|
92
|
+
"package": self.package_name,
|
|
93
|
+
"severity": self.severity,
|
|
94
|
+
"origin": str(self.origin),
|
|
95
|
+
"fixed_version": self.fixed_version,
|
|
96
|
+
"fixable": self.fixable,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
#: Severidades da mais grave para a menos, para o plano abrir pelo que importa.
|
|
101
|
+
_SEVERITY_RANK = ("CRITICAL", "HIGH", "MEDIUM", "LOW", "UNKNOWN")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class RemediationBucket:
|
|
106
|
+
"""Um grupo do plano de trabalho: mesma origem, mesma situação de correção.
|
|
107
|
+
|
|
108
|
+
Existe porque origem sozinha não é plano. "41 vêm da base" ainda não diz se
|
|
109
|
+
atualizar a base adianta -- e a resposta muda tudo: se nenhuma delas tem
|
|
110
|
+
correção publicada, atualizar é trabalho perdido e trocar a base é o único
|
|
111
|
+
caminho.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
origin: FindingOrigin
|
|
115
|
+
fixable: bool
|
|
116
|
+
findings: tuple[AttributedFinding, ...]
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def count(self) -> int:
|
|
120
|
+
return len(self.findings)
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def critical(self) -> int:
|
|
124
|
+
return sum(1 for f in self.findings if f.severity.upper() == "CRITICAL")
|
|
125
|
+
|
|
126
|
+
def action(self) -> str:
|
|
127
|
+
"""O que fazer com este grupo, e o que essa ação não garante."""
|
|
128
|
+
if self.origin is FindingOrigin.INHERITED:
|
|
129
|
+
if self.fixable:
|
|
130
|
+
return (
|
|
131
|
+
"a fix is published upstream: updating the base may resolve it -- "
|
|
132
|
+
"may, because a fix existing does not mean whoever publishes the "
|
|
133
|
+
"base has rebuilt with it. `dockerls base` checks whether the tag "
|
|
134
|
+
"moved"
|
|
135
|
+
)
|
|
136
|
+
return (
|
|
137
|
+
"no fix is published: updating the base resolves nothing here. "
|
|
138
|
+
"Swapping the base is the only path -- `dockerls base --alternatives` "
|
|
139
|
+
"measures the candidates"
|
|
140
|
+
)
|
|
141
|
+
if self.fixable:
|
|
142
|
+
return "a fix is published: raise the dependency version in your manifest and rebuild"
|
|
143
|
+
return (
|
|
144
|
+
"no fix is published and the package is yours: consider removing, "
|
|
145
|
+
"replacing or isolating it. This is the group where a documented exemption "
|
|
146
|
+
"in `.dockerls-ignore.yaml` makes sense -- with an expiry date"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def to_dict(self) -> dict[str, object]:
|
|
150
|
+
return {
|
|
151
|
+
"origin": str(self.origin),
|
|
152
|
+
"fixable": self.fixable,
|
|
153
|
+
"count": self.count,
|
|
154
|
+
"critical": self.critical,
|
|
155
|
+
"action": self.action(),
|
|
156
|
+
"findings": [f.to_dict() for f in self.findings],
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass(frozen=True)
|
|
161
|
+
class InheritanceReport:
|
|
162
|
+
"""A divisão entre o que é da base e o que é seu."""
|
|
163
|
+
|
|
164
|
+
base_reference: str = ""
|
|
165
|
+
#: Vazio quando a atribuição foi possível. Preenchido com o motivo quando
|
|
166
|
+
#: não foi -- e aí nenhuma contagem abaixo significa coisa alguma.
|
|
167
|
+
unavailable_reason: str = ""
|
|
168
|
+
findings: tuple[AttributedFinding, ...] = field(default_factory=tuple)
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def available(self) -> bool:
|
|
172
|
+
return not self.unavailable_reason
|
|
173
|
+
|
|
174
|
+
def of(self, origin: FindingOrigin) -> tuple[AttributedFinding, ...]:
|
|
175
|
+
return tuple(f for f in self.findings if f.origin is origin)
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def inherited(self) -> tuple[AttributedFinding, ...]:
|
|
179
|
+
return self.of(FindingOrigin.INHERITED)
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def introduced(self) -> tuple[AttributedFinding, ...]:
|
|
183
|
+
return self.of(FindingOrigin.INTRODUCED)
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def removed(self) -> tuple[AttributedFinding, ...]:
|
|
187
|
+
return self.of(FindingOrigin.REMOVED)
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def inherited_share(self) -> float:
|
|
191
|
+
"""Fração das vulnerabilidades da imagem que vieram da base, 0.0--1.0.
|
|
192
|
+
|
|
193
|
+
Só conta o que está *na imagem*: `REMOVED` descreve o que não está lá,
|
|
194
|
+
e incluí-lo no denominador diluiria a conta com achados que ninguém
|
|
195
|
+
precisa tratar.
|
|
196
|
+
"""
|
|
197
|
+
presentes = len(self.inherited) + len(self.introduced)
|
|
198
|
+
return len(self.inherited) / presentes if presentes else 0.0
|
|
199
|
+
|
|
200
|
+
def plan(self) -> tuple[RemediationBucket, ...]:
|
|
201
|
+
"""O plano de trabalho: origem cruzada com "existe correção?".
|
|
202
|
+
|
|
203
|
+
Origem sozinha diz de quem é o problema; correção diz se ele tem
|
|
204
|
+
solução. Só as duas juntas dizem o que fazer na segunda-feira.
|
|
205
|
+
|
|
206
|
+
`REMOVED` fica de fora: não está na imagem, e um plano que lista o que
|
|
207
|
+
já não existe faz a lista parecer maior do que o trabalho é.
|
|
208
|
+
"""
|
|
209
|
+
buckets: list[RemediationBucket] = []
|
|
210
|
+
for origin in (FindingOrigin.INHERITED, FindingOrigin.INTRODUCED):
|
|
211
|
+
for fixable in (True, False):
|
|
212
|
+
achados = tuple(
|
|
213
|
+
sorted(
|
|
214
|
+
(f for f in self.of(origin) if f.fixable is fixable),
|
|
215
|
+
key=_by_severity,
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
if achados:
|
|
219
|
+
buckets.append(
|
|
220
|
+
RemediationBucket(origin=origin, fixable=fixable, findings=achados)
|
|
221
|
+
)
|
|
222
|
+
# O grupo com mais CRITICAL abre a lista: é onde a primeira hora de
|
|
223
|
+
# trabalho rende mais, e ordenar por total faria um monte de LOW passar
|
|
224
|
+
# à frente de dois CRITICAL sem correção.
|
|
225
|
+
return tuple(sorted(buckets, key=lambda b: (-b.critical, -b.count)))
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def fixable_inherited(self) -> int:
|
|
229
|
+
return sum(1 for f in self.inherited if f.fixable)
|
|
230
|
+
|
|
231
|
+
def explain(self) -> str:
|
|
232
|
+
"""A frase que responde "consertar o quê?"."""
|
|
233
|
+
if not self.available:
|
|
234
|
+
return (
|
|
235
|
+
f"the vulnerabilities could not be attributed: {self.unavailable_reason}. "
|
|
236
|
+
"Without both scans, calling them yours or the base's would be inventing"
|
|
237
|
+
)
|
|
238
|
+
herdadas, suas = len(self.inherited), len(self.introduced)
|
|
239
|
+
if not herdadas and not suas:
|
|
240
|
+
if self.removed:
|
|
241
|
+
# É o melhor resultado possível, e a versão anterior o
|
|
242
|
+
# escondia atrás de "nada a atribuir": a imagem está limpa
|
|
243
|
+
# *e* o build tirou coisa da base. Vale dizer.
|
|
244
|
+
quantas = len(self.removed)
|
|
245
|
+
if quantas == 1:
|
|
246
|
+
return (
|
|
247
|
+
"no vulnerabilities in this image, and the 1 the base had did "
|
|
248
|
+
"not survive the build"
|
|
249
|
+
)
|
|
250
|
+
return (
|
|
251
|
+
f"no vulnerabilities in this image, and the {quantas} the base had "
|
|
252
|
+
"did not survive the build"
|
|
253
|
+
)
|
|
254
|
+
return "no vulnerabilities to attribute in this image"
|
|
255
|
+
partes = [
|
|
256
|
+
f"{herdadas} of {herdadas + suas} {_come(herdadas)} from the base "
|
|
257
|
+
f"{self.base_reference}",
|
|
258
|
+
f"{suas} {_come(suas)} from the layers of this Dockerfile",
|
|
259
|
+
]
|
|
260
|
+
if self.removed:
|
|
261
|
+
quantas = len(self.removed)
|
|
262
|
+
partes.append(
|
|
263
|
+
f"{quantas} the base had {'was' if quantas == 1 else 'were'} removed by the build"
|
|
264
|
+
)
|
|
265
|
+
if herdadas:
|
|
266
|
+
# A pergunta imediatamente seguinte a "41 vêm da base" é "e
|
|
267
|
+
# atualizar a base resolve?". Responder junto poupa a viagem.
|
|
268
|
+
corrigiveis = self.fixable_inherited
|
|
269
|
+
partes.append(
|
|
270
|
+
f"{corrigiveis} of the inherited {_have(corrigiveis)} a fix published upstream"
|
|
271
|
+
)
|
|
272
|
+
return "; ".join(partes)
|
|
273
|
+
|
|
274
|
+
def to_dict(self) -> dict[str, object]:
|
|
275
|
+
return {
|
|
276
|
+
"base": self.base_reference,
|
|
277
|
+
"available": self.available,
|
|
278
|
+
"unavailable_reason": self.unavailable_reason,
|
|
279
|
+
"explanation": self.explain(),
|
|
280
|
+
"counts": {
|
|
281
|
+
"inherited": len(self.inherited),
|
|
282
|
+
"introduced": len(self.introduced),
|
|
283
|
+
"removed": len(self.removed),
|
|
284
|
+
},
|
|
285
|
+
"inherited_share": round(self.inherited_share, 3),
|
|
286
|
+
"actions": {str(origin): action for origin, action in ACTIONS.items()},
|
|
287
|
+
"plan": [b.to_dict() for b in self.plan()],
|
|
288
|
+
"findings": [f.to_dict() for f in self.findings],
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def attribute(
|
|
293
|
+
built: Iterable[Vulnerability],
|
|
294
|
+
base: Iterable[Vulnerability],
|
|
295
|
+
*,
|
|
296
|
+
base_reference: str,
|
|
297
|
+
) -> InheritanceReport:
|
|
298
|
+
"""Divide os achados entre herdados da base, introduzidos e removidos.
|
|
299
|
+
|
|
300
|
+
A identidade comparada é `CVE|pacote`, a mesma da validação cruzada entre
|
|
301
|
+
scanners: o mesmo CVE em dois pacotes são dois problemas a resolver, e o
|
|
302
|
+
mesmo pacote com dois CVEs também. A versão instalada fica de fora de
|
|
303
|
+
propósito -- a base e a imagem final frequentemente reportam o mesmo pacote
|
|
304
|
+
com strings de versão normalizadas de formas diferentes, e comparar isso
|
|
305
|
+
fabricaria diferença a partir de formatação.
|
|
306
|
+
"""
|
|
307
|
+
por_identidade_construida = {finding_identity(v): v for v in built}
|
|
308
|
+
identidades_base = {finding_identity(v): v for v in base}
|
|
309
|
+
|
|
310
|
+
achados: list[AttributedFinding] = []
|
|
311
|
+
for identity, vuln in por_identidade_construida.items():
|
|
312
|
+
origin = (
|
|
313
|
+
FindingOrigin.INHERITED if identity in identidades_base else FindingOrigin.INTRODUCED
|
|
314
|
+
)
|
|
315
|
+
achados.append(_attributed(identity, vuln, origin))
|
|
316
|
+
|
|
317
|
+
for identity, vuln in identidades_base.items():
|
|
318
|
+
if identity not in por_identidade_construida:
|
|
319
|
+
achados.append(_attributed(identity, vuln, FindingOrigin.REMOVED))
|
|
320
|
+
|
|
321
|
+
return InheritanceReport(base_reference=base_reference, findings=tuple(achados))
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def unavailable(base_reference: str, reason: str) -> InheritanceReport:
|
|
325
|
+
"""A atribuição que não pôde ser feita, com o motivo em vez de um palpite."""
|
|
326
|
+
return InheritanceReport(base_reference=base_reference, unavailable_reason=reason)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _attributed(identity: str, vuln: Vulnerability, origin: FindingOrigin) -> AttributedFinding:
|
|
330
|
+
return AttributedFinding(
|
|
331
|
+
identity=identity,
|
|
332
|
+
cve_id=vuln.cve_id,
|
|
333
|
+
package_name=vuln.package_name,
|
|
334
|
+
severity=str(vuln.severity),
|
|
335
|
+
origin=origin,
|
|
336
|
+
fixed_version=vuln.fixed_version,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _come(quantidade: int) -> str:
|
|
341
|
+
return "comes" if quantidade == 1 else "come"
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _have(quantidade: int) -> str:
|
|
345
|
+
return "has" if quantidade == 1 else "have"
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _by_severity(finding: AttributedFinding) -> tuple[int, str]:
|
|
349
|
+
"""Mais grave primeiro; CVE desempata para a ordem ser estável."""
|
|
350
|
+
severity = finding.severity.upper()
|
|
351
|
+
rank = _SEVERITY_RANK.index(severity) if severity in _SEVERITY_RANK else len(_SEVERITY_RANK)
|
|
352
|
+
return rank, finding.cve_id
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Where DockerLs is allowed to send a request, and why that needs a policy.
|
|
2
|
+
|
|
3
|
+
An image reference is user input, and it carries a hostname. `dockerls
|
|
4
|
+
analyze 169.254.169.254/latest` is a well-formed reference, and resolving it
|
|
5
|
+
means issuing `GET https://169.254.169.254/v2/latest/manifests/...` -- the
|
|
6
|
+
cloud metadata endpoint. On a CI runner, a reference arriving from a pull
|
|
7
|
+
request, a config file or an environment variable therefore turns this tool
|
|
8
|
+
into an SSRF primitive against the host's internal network. The response body
|
|
9
|
+
never reaches the requester, but the *reach* does, and blind is still SSRF.
|
|
10
|
+
|
|
11
|
+
The naive fix -- refuse every private address -- is wrong here, and the
|
|
12
|
+
project brief says so explicitly: internal registries on RFC1918 addresses
|
|
13
|
+
are ordinary, legitimate infrastructure, and a scanner that cannot look at
|
|
14
|
+
`registry.internal:5000` is a scanner nobody can use. So the policy
|
|
15
|
+
distinguishes two things that get lumped together:
|
|
16
|
+
|
|
17
|
+
* **loopback and link-local** are refused by default. No legitimate registry
|
|
18
|
+
is reachable at `127.0.0.1` *from the perspective of a reference someone
|
|
19
|
+
else supplied*, and `169.254.0.0/16` is where every cloud provider parks
|
|
20
|
+
its credential endpoint. This is the actual attack.
|
|
21
|
+
* **private ranges** (RFC1918, unique-local) are allowed by default, because
|
|
22
|
+
that is where real internal registries live -- and tightened with a single
|
|
23
|
+
setting when a deployment wants to.
|
|
24
|
+
|
|
25
|
+
Either default can be overridden, and an explicit host allowlist wins over
|
|
26
|
+
both: an operator who genuinely runs a registry on localhost says so once.
|
|
27
|
+
|
|
28
|
+
Hostnames are judged by where they *resolve*, not by how they are spelled:
|
|
29
|
+
`localhost` and an attacker-controlled name whose A record points at
|
|
30
|
+
127.0.0.1 are the same request, and only one of them looks suspicious in a
|
|
31
|
+
config file. Resolving a name is I/O, so it does not happen here -- this
|
|
32
|
+
module decides over addresses it is handed, and
|
|
33
|
+
`infrastructure/network/host_guard.py` is what performs the lookup. That
|
|
34
|
+
split is what keeps the rule testable without a network and the domain free
|
|
35
|
+
of sockets.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import ipaddress
|
|
41
|
+
from dataclasses import dataclass, field
|
|
42
|
+
from enum import StrEnum
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class NetworkDecision(StrEnum):
|
|
46
|
+
"""Why a host was allowed or refused. Reported, never silent."""
|
|
47
|
+
|
|
48
|
+
ALLOWED = "ALLOWED"
|
|
49
|
+
ALLOWED_BY_ALLOWLIST = "ALLOWED_BY_ALLOWLIST"
|
|
50
|
+
BLOCKED_LOOPBACK = "BLOCKED_LOOPBACK"
|
|
51
|
+
BLOCKED_LINK_LOCAL = "BLOCKED_LINK_LOCAL"
|
|
52
|
+
BLOCKED_PRIVATE = "BLOCKED_PRIVATE"
|
|
53
|
+
BLOCKED_UNSPECIFIED = "BLOCKED_UNSPECIFIED"
|
|
54
|
+
BLOCKED_UNRESOLVABLE = "BLOCKED_UNRESOLVABLE"
|
|
55
|
+
BLOCKED_SPECIAL = "BLOCKED_SPECIAL"
|
|
56
|
+
BLOCKED_SCHEME = "BLOCKED_SCHEME"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
#: Decisions that permit the request.
|
|
60
|
+
_ALLOWING = (NetworkDecision.ALLOWED, NetworkDecision.ALLOWED_BY_ALLOWLIST)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class NetworkPolicy:
|
|
65
|
+
"""What a reference is permitted to make this process talk to."""
|
|
66
|
+
|
|
67
|
+
#: RFC1918 / unique-local. Default True: internal registries are normal.
|
|
68
|
+
allow_private_networks: bool = True
|
|
69
|
+
#: 127.0.0.0/8, ::1. Default False -- this is the SSRF case, and a
|
|
70
|
+
#: registry on localhost is a deliberate, local choice that deserves to
|
|
71
|
+
#: be stated.
|
|
72
|
+
allow_loopback: bool = False
|
|
73
|
+
#: 169.254.0.0/16, fe80::/10. Default False: cloud metadata lives here
|
|
74
|
+
#: and nothing else legitimate does.
|
|
75
|
+
allow_link_local: bool = False
|
|
76
|
+
#: Hosts permitted regardless of where they resolve, exactly as written
|
|
77
|
+
#: in the reference (host or host:port).
|
|
78
|
+
allowed_hosts: frozenset[str] = field(default_factory=frozenset)
|
|
79
|
+
|
|
80
|
+
def is_allowlisted(self, host: str) -> bool:
|
|
81
|
+
"""Whether `host` is permitted outright, before any lookup.
|
|
82
|
+
|
|
83
|
+
Checked first by the caller so an operator who deliberately runs a
|
|
84
|
+
registry on localhost is never subjected to a DNS round-trip to be
|
|
85
|
+
told what they already declared.
|
|
86
|
+
"""
|
|
87
|
+
candidates = {host.strip().lower(), hostname_of(host).lower()}
|
|
88
|
+
return bool(candidates & {entry.strip().lower() for entry in self.allowed_hosts})
|
|
89
|
+
|
|
90
|
+
def decide_addresses(
|
|
91
|
+
self, addresses: list[ipaddress.IPv4Address | ipaddress.IPv6Address]
|
|
92
|
+
) -> NetworkDecision:
|
|
93
|
+
"""Classify a host from the addresses it resolves to.
|
|
94
|
+
|
|
95
|
+
**Every** address must pass. A name answering with one public and
|
|
96
|
+
one loopback address is the shape of a DNS-rebinding attack, and the
|
|
97
|
+
connection would be free to use either.
|
|
98
|
+
"""
|
|
99
|
+
if not addresses:
|
|
100
|
+
# A name that resolves to nothing cannot be reached anyway;
|
|
101
|
+
# refusing here keeps the failure on the policy's terms rather
|
|
102
|
+
# than as a connection error deep inside an HTTP client.
|
|
103
|
+
return NetworkDecision.BLOCKED_UNRESOLVABLE
|
|
104
|
+
for address in addresses:
|
|
105
|
+
decision = self._classify(address)
|
|
106
|
+
if decision not in _ALLOWING:
|
|
107
|
+
return decision
|
|
108
|
+
return NetworkDecision.ALLOWED
|
|
109
|
+
|
|
110
|
+
def explain(self, host: str, decision: NetworkDecision) -> str:
|
|
111
|
+
"""A refusal a reader can act on, naming the setting that changes it."""
|
|
112
|
+
return _EXPLANATIONS.get(decision, "").format(host=host)
|
|
113
|
+
|
|
114
|
+
def _classify(self, address: ipaddress.IPv4Address | ipaddress.IPv6Address) -> NetworkDecision:
|
|
115
|
+
# An IPv6 address may *carry* an IPv4 one. `::ffff:127.0.0.1`,
|
|
116
|
+
# `2002:7f00:1::` (6to4) and `64:ff9b::7f00:1` (NAT64) all reach
|
|
117
|
+
# 127.0.0.1 on a host with the matching translation configured, and
|
|
118
|
+
# only the first of the three is recognised by `is_loopback`. Judge
|
|
119
|
+
# the address that would actually be contacted, then fall through to
|
|
120
|
+
# judging the wrapper too -- a tunnel does not launder a destination.
|
|
121
|
+
embedded = _embedded_ipv4(address)
|
|
122
|
+
if embedded is not None:
|
|
123
|
+
decision = self._classify(embedded)
|
|
124
|
+
if decision not in _ALLOWING:
|
|
125
|
+
return decision
|
|
126
|
+
|
|
127
|
+
if address.is_unspecified or _in_any(address, _WILDCARD_SOURCE):
|
|
128
|
+
# 0.0.0.0/8 in full, not just the exact 0.0.0.0: Linux routes
|
|
129
|
+
# `0.x.y.z` to the local host, so the whole block is a spelling
|
|
130
|
+
# of "loopback" that `is_loopback` does not catch.
|
|
131
|
+
return NetworkDecision.BLOCKED_UNSPECIFIED
|
|
132
|
+
if address.is_loopback:
|
|
133
|
+
return (
|
|
134
|
+
NetworkDecision.ALLOWED if self.allow_loopback else NetworkDecision.BLOCKED_LOOPBACK
|
|
135
|
+
)
|
|
136
|
+
if address.is_link_local:
|
|
137
|
+
return (
|
|
138
|
+
NetworkDecision.ALLOWED
|
|
139
|
+
if self.allow_link_local
|
|
140
|
+
else NetworkDecision.BLOCKED_LINK_LOCAL
|
|
141
|
+
)
|
|
142
|
+
if _in_any(address, _SPECIAL) or address.is_multicast:
|
|
143
|
+
# Shared/benchmark/reserved space and the carrier-grade NAT block
|
|
144
|
+
# where Alibaba Cloud serves instance credentials
|
|
145
|
+
# (100.100.100.200). None of it hosts a registry, all of it is
|
|
146
|
+
# reachable from a CI runner, so it is refused outright rather
|
|
147
|
+
# than folded into `allow_private_networks` -- an operator who
|
|
148
|
+
# turns private networks on to reach 10.0.0.0/8 has not thereby
|
|
149
|
+
# asked for a route to a metadata service.
|
|
150
|
+
#
|
|
151
|
+
# `is_reserved` used to be part of this condition and was
|
|
152
|
+
# removed: for an IPv4-mapped IPv6 address (`::ffff:a.b.c.d`),
|
|
153
|
+
# its answer has changed between CPython patch releases (a
|
|
154
|
+
# private embedded address such as `::ffff:10.0.0.5` came back
|
|
155
|
+
# reserved on some 3.11.x builds and not on others), so the
|
|
156
|
+
# exact same instability the module comment above already
|
|
157
|
+
# calls out for `is_global` applied here too, just unnoticed.
|
|
158
|
+
# Every range this project actually intends to block through
|
|
159
|
+
# `is_reserved` is already named explicitly in `_SPECIAL`
|
|
160
|
+
# (TEST-NETs, the 240.0.0.0/4 block, NAT64, discard-only,
|
|
161
|
+
# documentation) or handled by the embedded-address recursion
|
|
162
|
+
# above -- a security boundary should not move with the
|
|
163
|
+
# runtime's patch version.
|
|
164
|
+
return NetworkDecision.BLOCKED_SPECIAL
|
|
165
|
+
if address.is_private:
|
|
166
|
+
return (
|
|
167
|
+
NetworkDecision.ALLOWED
|
|
168
|
+
if self.allow_private_networks
|
|
169
|
+
else NetworkDecision.BLOCKED_PRIVATE
|
|
170
|
+
)
|
|
171
|
+
return NetworkDecision.ALLOWED
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
#: 0.0.0.0/8. `is_unspecified` is only true for the single address 0.0.0.0,
|
|
175
|
+
#: but the whole block behaves as "this host" on Linux.
|
|
176
|
+
_WILDCARD_SOURCE = (ipaddress.ip_network("0.0.0.0/8"),)
|
|
177
|
+
|
|
178
|
+
#: Ranges that are neither public nor plausible registry homes. Enumerated
|
|
179
|
+
#: rather than derived from `is_global`, whose membership has changed between
|
|
180
|
+
#: Python releases -- a security boundary should not move with the runtime.
|
|
181
|
+
_SPECIAL = (
|
|
182
|
+
ipaddress.ip_network("100.64.0.0/10"), # RFC 6598 CGNAT; Alibaba metadata
|
|
183
|
+
ipaddress.ip_network("192.0.0.0/24"), # IETF protocol assignments
|
|
184
|
+
ipaddress.ip_network("192.0.2.0/24"), # TEST-NET-1
|
|
185
|
+
ipaddress.ip_network("192.88.99.0/24"), # deprecated 6to4 relay anycast
|
|
186
|
+
ipaddress.ip_network("198.18.0.0/15"), # benchmarking
|
|
187
|
+
ipaddress.ip_network("198.51.100.0/24"), # TEST-NET-2
|
|
188
|
+
ipaddress.ip_network("203.0.113.0/24"), # TEST-NET-3
|
|
189
|
+
ipaddress.ip_network("240.0.0.0/4"), # reserved, incl. 255.255.255.255
|
|
190
|
+
ipaddress.ip_network("64:ff9b::/96"), # NAT64
|
|
191
|
+
ipaddress.ip_network("64:ff9b:1::/48"), # local-use NAT64
|
|
192
|
+
ipaddress.ip_network("100::/64"), # discard-only
|
|
193
|
+
ipaddress.ip_network("2001:db8::/32"), # documentation
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
#: 6to4 (RFC 3056) and Teredo (RFC 4380) prefixes, whose payload is an IPv4
|
|
197
|
+
#: address that the host may actually route to.
|
|
198
|
+
_SIXTOFOUR = ipaddress.ip_network("2002::/16")
|
|
199
|
+
_TEREDO = ipaddress.ip_network("2001::/32")
|
|
200
|
+
_NAT64 = ipaddress.ip_network("64:ff9b::/96")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _in_any(
|
|
204
|
+
address: ipaddress.IPv4Address | ipaddress.IPv6Address,
|
|
205
|
+
networks: tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...],
|
|
206
|
+
) -> bool:
|
|
207
|
+
return any(address.version == net.version and address in net for net in networks)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _embedded_ipv4(
|
|
211
|
+
address: ipaddress.IPv4Address | ipaddress.IPv6Address,
|
|
212
|
+
) -> ipaddress.IPv4Address | None:
|
|
213
|
+
"""The IPv4 address an IPv6 address encodes, if it encodes one.
|
|
214
|
+
|
|
215
|
+
Covers the four encodings a host can be configured to translate:
|
|
216
|
+
IPv4-mapped and IPv4-compatible (`::ffff:a.b.c.d`, `::a.b.c.d`), 6to4,
|
|
217
|
+
Teredo and NAT64. Returns None for an ordinary IPv6 address.
|
|
218
|
+
"""
|
|
219
|
+
if not isinstance(address, ipaddress.IPv6Address):
|
|
220
|
+
return None
|
|
221
|
+
if address.ipv4_mapped is not None:
|
|
222
|
+
return address.ipv4_mapped
|
|
223
|
+
if address.sixtofour is not None and address in _SIXTOFOUR:
|
|
224
|
+
return address.sixtofour
|
|
225
|
+
if address.teredo is not None and address in _TEREDO:
|
|
226
|
+
# (server, client); the client is the address packets reach.
|
|
227
|
+
return address.teredo[1]
|
|
228
|
+
if address in _NAT64:
|
|
229
|
+
return ipaddress.IPv4Address(int(address) & 0xFFFFFFFF)
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
_EXPLANATIONS = {
|
|
234
|
+
NetworkDecision.BLOCKED_LOOPBACK: (
|
|
235
|
+
"{host} resolves to a loopback address. Refused by default: a reference that "
|
|
236
|
+
"reaches localhost is how an untrusted image name becomes a request to a "
|
|
237
|
+
"service on this machine. Set network_allow_loopback = true, or add the host "
|
|
238
|
+
"to network_allowed_hosts, if this is deliberate."
|
|
239
|
+
),
|
|
240
|
+
NetworkDecision.BLOCKED_LINK_LOCAL: (
|
|
241
|
+
"{host} resolves to a link-local address (169.254.0.0/16). Refused by default: "
|
|
242
|
+
"this is where cloud providers serve instance credentials. Set "
|
|
243
|
+
"network_allow_link_local = true only if you know why you need it."
|
|
244
|
+
),
|
|
245
|
+
NetworkDecision.BLOCKED_PRIVATE: (
|
|
246
|
+
"{host} resolves to a private address and network_allow_private_networks is "
|
|
247
|
+
"off. Turn it on, or add the host to network_allowed_hosts."
|
|
248
|
+
),
|
|
249
|
+
NetworkDecision.BLOCKED_UNSPECIFIED: (
|
|
250
|
+
"{host} resolves to an unspecified address (0.0.0.0/8 or ::), which names this "
|
|
251
|
+
"host rather than a registry."
|
|
252
|
+
),
|
|
253
|
+
NetworkDecision.BLOCKED_UNRESOLVABLE: "{host} could not be resolved to any address.",
|
|
254
|
+
NetworkDecision.BLOCKED_SPECIAL: (
|
|
255
|
+
"{host} resolves into reserved, shared or carrier-grade-NAT space (for example "
|
|
256
|
+
"100.64.0.0/10, where some clouds serve instance credentials). No registry is "
|
|
257
|
+
"published there; add the host to network_allowed_hosts if this is deliberate."
|
|
258
|
+
),
|
|
259
|
+
NetworkDecision.BLOCKED_SCHEME: (
|
|
260
|
+
"{host} was requested over a scheme other than http/https, which this tool never follows."
|
|
261
|
+
),
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def hostname_of(host: str) -> str:
|
|
266
|
+
"""Strip an optional `:port`, leaving the name or literal address.
|
|
267
|
+
|
|
268
|
+
IPv6 literals in a registry reference are bracketed (`[::1]:5000`), so
|
|
269
|
+
the bracket form is handled before the naive rsplit that would otherwise
|
|
270
|
+
cut a bare `::1` in half.
|
|
271
|
+
"""
|
|
272
|
+
value = host.strip()
|
|
273
|
+
if not value:
|
|
274
|
+
return ""
|
|
275
|
+
if value.startswith("["):
|
|
276
|
+
closing = value.find("]")
|
|
277
|
+
return value[1:closing] if closing > 1 else ""
|
|
278
|
+
if value.count(":") == 1:
|
|
279
|
+
return value.rsplit(":", 1)[0]
|
|
280
|
+
return value
|