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,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from loguru import logger
|
|
10
|
+
|
|
11
|
+
from dockerls.infrastructure.redaction import redact
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
_UNSAFE_PATH_CHARS = re.compile(r"[^A-Za-z0-9._-]+")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def slugify_reference(reference: str) -> str:
|
|
20
|
+
"""Flatten an image reference into a single safe path segment.
|
|
21
|
+
|
|
22
|
+
Deliberately collapses "/" and ":" instead of preserving them: the
|
|
23
|
+
evidence directory must stay flat and inside its root, so a reference
|
|
24
|
+
can never steer a write outside `.dockerls/scans/`.
|
|
25
|
+
"""
|
|
26
|
+
slug = _UNSAFE_PATH_CHARS.sub("_", reference).strip("._-")
|
|
27
|
+
return slug[:120] or "image"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Backwards-compatible private alias.
|
|
31
|
+
_slugify = slugify_reference
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class EvidenceStore:
|
|
35
|
+
"""Persists the raw JSON emitted by each scanner so any displayed score
|
|
36
|
+
can be traced back to the exact scanner output it was derived from."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, root: Path):
|
|
39
|
+
self._root = root
|
|
40
|
+
|
|
41
|
+
async def record_scan(self, image_reference: str, scanner: str, raw: str) -> str:
|
|
42
|
+
"""Write one scanner's raw JSON output. Returns the file path, or an
|
|
43
|
+
empty string when the evidence could not be persisted (evidence is
|
|
44
|
+
an audit aid -- never a reason to fail a scan)."""
|
|
45
|
+
return await asyncio.to_thread(self._record_scan_sync, image_reference, scanner, raw)
|
|
46
|
+
|
|
47
|
+
def _record_scan_sync(self, image_reference: str, scanner: str, raw: str) -> str:
|
|
48
|
+
stamp = datetime.now(tz=UTC).strftime("%Y%m%dT%H%M%S%f")
|
|
49
|
+
name = f"{slugify_reference(image_reference)}__{slugify_reference(scanner)}__{stamp}.json"
|
|
50
|
+
path = self._root / name
|
|
51
|
+
try:
|
|
52
|
+
self._root.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
# Redacted on the way to disk, with the same patterns the log
|
|
54
|
+
# sink uses. A raw scan artifact is the file people attach to
|
|
55
|
+
# tickets and paste into chats, and a scanner that fails an
|
|
56
|
+
# authenticated pull echoes the request it attempted -- headers
|
|
57
|
+
# included. Masking logs but not evidence covered the door
|
|
58
|
+
# nobody walks through.
|
|
59
|
+
path.write_text(redact(raw), encoding="utf-8")
|
|
60
|
+
except OSError as e:
|
|
61
|
+
logger.warning(f"Could not persist scan evidence for {image_reference}: {e}")
|
|
62
|
+
return ""
|
|
63
|
+
return str(path)
|
|
64
|
+
|
|
65
|
+
async def record_manifest(
|
|
66
|
+
self,
|
|
67
|
+
query: str,
|
|
68
|
+
entries: list[dict[str, Any]],
|
|
69
|
+
provenance: dict[str, Any] | None = None,
|
|
70
|
+
) -> str:
|
|
71
|
+
"""Write the score <-> evidence linkage for one `recommend` run."""
|
|
72
|
+
return await asyncio.to_thread(self._record_manifest_sync, query, entries, provenance)
|
|
73
|
+
|
|
74
|
+
def _record_manifest_sync(
|
|
75
|
+
self,
|
|
76
|
+
query: str,
|
|
77
|
+
entries: list[dict[str, Any]],
|
|
78
|
+
provenance: dict[str, Any] | None = None,
|
|
79
|
+
) -> str:
|
|
80
|
+
stamp = datetime.now(tz=UTC).strftime("%Y%m%dT%H%M%S")
|
|
81
|
+
path = self._root / f"{slugify_reference(query)}__manifest__{stamp}.json"
|
|
82
|
+
payload = {
|
|
83
|
+
"query": query,
|
|
84
|
+
"generated_at": datetime.now(tz=UTC).isoformat(),
|
|
85
|
+
# What produced these numbers: which DockerLs, which scanner at
|
|
86
|
+
# which version, and the fingerprint the cache was keyed on. An
|
|
87
|
+
# analysis nobody can reconstruct is an assertion, not evidence,
|
|
88
|
+
# and until this block existed the manifest recorded the verdict
|
|
89
|
+
# without recording what reached it.
|
|
90
|
+
"provenance": provenance or {},
|
|
91
|
+
"images": entries,
|
|
92
|
+
}
|
|
93
|
+
try:
|
|
94
|
+
self._root.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
path.write_text(redact(json.dumps(payload, indent=2, default=str)), encoding="utf-8")
|
|
96
|
+
except OSError as e:
|
|
97
|
+
logger.warning(f"Could not persist evidence manifest for {query}: {e}")
|
|
98
|
+
return ""
|
|
99
|
+
return str(path)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Digerir a entrada do build antes de ela virar imagem.
|
|
2
|
+
|
|
3
|
+
Um relatório de segurança afirma coisas sobre uma imagem. Para a afirmação
|
|
4
|
+
valer, é preciso poder responder *qual* imagem, e a partir de *qual* entrada
|
|
5
|
+
-- e é aí que a cadeia arrebentava: o `build` media o resultado sem registrar
|
|
6
|
+
nada sobre o que entrou. Dois builds do mesmo `--tag` produziam relatórios
|
|
7
|
+
indistinguíveis mesmo partindo de Dockerfiles diferentes.
|
|
8
|
+
|
|
9
|
+
O que este módulo produz é o começo dessa cadeia: um digest determinístico do
|
|
10
|
+
Dockerfile e do contexto de build. Determinístico importa mais do que parece:
|
|
11
|
+
o mesmo conteúdo tem de dar o mesmo hash em qualquer máquina, em qualquer
|
|
12
|
+
ordem de sistema de arquivos, ou a comparação entre dois builds não significa
|
|
13
|
+
nada. Por isso os caminhos entram ordenados e relativos, e o nome de cada
|
|
14
|
+
arquivo entra no digest junto do conteúdo -- renomear um arquivo muda o
|
|
15
|
+
contexto tanto quanto editá-lo.
|
|
16
|
+
|
|
17
|
+
O `.dockerignore` é respeitado porque ele decide o que o daemon realmente
|
|
18
|
+
recebe: hashear o que fica de fora produziria um digest que muda sem que a
|
|
19
|
+
imagem mude, e um controle que dispara sem motivo é um controle que as
|
|
20
|
+
pessoas desligam.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import fnmatch
|
|
26
|
+
import hashlib
|
|
27
|
+
from typing import TYPE_CHECKING
|
|
28
|
+
|
|
29
|
+
from loguru import logger
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
#: Lido em blocos para que um contexto com artefatos grandes não seja
|
|
35
|
+
#: carregado inteiro na memória só para ser digerido.
|
|
36
|
+
_CHUNK_BYTES = 1024 * 1024
|
|
37
|
+
|
|
38
|
+
#: Teto de arquivos digeridos. Um contexto acima disto quase sempre significa
|
|
39
|
+
#: um `.dockerignore` ausente -- e é melhor recusar a medir do que devolver um
|
|
40
|
+
#: digest que levou minutos e não descreve o que foi enviado.
|
|
41
|
+
MAX_CONTEXT_FILES = 50_000
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ContextTooLargeError(RuntimeError):
|
|
45
|
+
"""O contexto tem mais arquivos do que se pode digerir com sentido."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def hash_file(path: Path) -> str:
|
|
49
|
+
"""SHA-256 do conteúdo de um arquivo, em minúsculas e com prefixo."""
|
|
50
|
+
digest = hashlib.sha256()
|
|
51
|
+
with path.open("rb") as handle:
|
|
52
|
+
while chunk := handle.read(_CHUNK_BYTES):
|
|
53
|
+
digest.update(chunk)
|
|
54
|
+
return f"sha256:{digest.hexdigest()}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def hash_context(root: Path, *, dockerignore: Path | None = None) -> tuple[str, int]:
|
|
58
|
+
"""Digest determinístico do contexto de build, e quantos arquivos entraram.
|
|
59
|
+
|
|
60
|
+
Devolve o par para que o relatório possa dizer *sobre o que* o digest
|
|
61
|
+
fala: um hash sozinho não distingue "contexto vazio" de "contexto que
|
|
62
|
+
ninguém conseguiu ler".
|
|
63
|
+
"""
|
|
64
|
+
patterns = _ignore_patterns(dockerignore or root / ".dockerignore")
|
|
65
|
+
digest = hashlib.sha256()
|
|
66
|
+
counted = 0
|
|
67
|
+
|
|
68
|
+
for path in _walk(root, patterns):
|
|
69
|
+
counted += 1
|
|
70
|
+
if counted > MAX_CONTEXT_FILES:
|
|
71
|
+
raise ContextTooLargeError(
|
|
72
|
+
f"the build context exceeds {MAX_CONTEXT_FILES} files; "
|
|
73
|
+
"almost always that means a missing .dockerignore"
|
|
74
|
+
)
|
|
75
|
+
# O caminho entra no digest junto do conteúdo: renomear um arquivo
|
|
76
|
+
# muda o contexto tanto quanto editá-lo, e um digest que não visse o
|
|
77
|
+
# nome trataria as duas coisas como iguais.
|
|
78
|
+
relative = path.relative_to(root).as_posix()
|
|
79
|
+
digest.update(relative.encode("utf-8"))
|
|
80
|
+
digest.update(b"\0")
|
|
81
|
+
digest.update(hash_file(path).encode("ascii"))
|
|
82
|
+
digest.update(b"\n")
|
|
83
|
+
|
|
84
|
+
return f"sha256:{digest.hexdigest()}", counted
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _walk(root: Path, patterns: list[str]) -> list[Path]:
|
|
88
|
+
"""Arquivos do contexto, ordenados, sem os ignorados.
|
|
89
|
+
|
|
90
|
+
A ordenação é o que torna o digest reprodutível: a ordem em que o sistema
|
|
91
|
+
de arquivos devolve entradas não é estável entre máquinas nem entre
|
|
92
|
+
execuções, e um digest que dependesse dela seria diferente a cada vez. Por
|
|
93
|
+
isso a lista é ordenada no fim, sobre os caminhos completos -- e não a cada
|
|
94
|
+
nível durante a descida, que produziria uma ordem diferente e portanto um
|
|
95
|
+
digest diferente para o mesmo conteúdo.
|
|
96
|
+
|
|
97
|
+
**A poda acontece na descida, não depois dela.** A versão anterior
|
|
98
|
+
percorria a árvore inteira com `rglob("*")` e descartava o que o
|
|
99
|
+
`.dockerignore` excluía, o que significa abrir `.git` e `node_modules`
|
|
100
|
+
arquivo por arquivo para jogar fora cada um. Num repositório real isso é a
|
|
101
|
+
quase totalidade do trabalho: num contexto de 52.400 arquivos em que 401
|
|
102
|
+
são enviados ao daemon, 98% do tempo era gasto lendo entradas que o build
|
|
103
|
+
nunca veria. Um diretório ignorado tem todos os seus arquivos ignorados
|
|
104
|
+
pela mesma regra, então não descer nele produz exatamente o mesmo conjunto.
|
|
105
|
+
"""
|
|
106
|
+
files: list[Path] = []
|
|
107
|
+
stack: list[Path] = [root]
|
|
108
|
+
|
|
109
|
+
while stack:
|
|
110
|
+
directory = stack.pop()
|
|
111
|
+
try:
|
|
112
|
+
children = list(directory.iterdir())
|
|
113
|
+
except OSError as e:
|
|
114
|
+
# Um diretório ilegível não interrompe a digestão do resto: o
|
|
115
|
+
# `.dockerignore` pode muito bem excluí-lo, e o daemon não teria
|
|
116
|
+
# recebido nada dali de qualquer forma.
|
|
117
|
+
logger.debug(f"Could not list {directory}: {e}")
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
for child in children:
|
|
121
|
+
# Symlink não é seguido nem digerido, como antes: seguir um daria
|
|
122
|
+
# ao digest um conteúdo de fora do contexto.
|
|
123
|
+
if child.is_symlink():
|
|
124
|
+
continue
|
|
125
|
+
relative = child.relative_to(root).as_posix()
|
|
126
|
+
if child.is_dir():
|
|
127
|
+
if not _is_ignored(relative, patterns):
|
|
128
|
+
stack.append(child)
|
|
129
|
+
elif child.is_file() and not _is_ignored(relative, patterns):
|
|
130
|
+
files.append(child)
|
|
131
|
+
|
|
132
|
+
# Sobre os caminhos completos, exatamente como o `sorted(rglob(...))`
|
|
133
|
+
# anterior: o digest de um contexto inalterado continua o mesmo, e um
|
|
134
|
+
# documento de procedência antigo segue comparável com um novo.
|
|
135
|
+
files.sort()
|
|
136
|
+
return files
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _ignore_patterns(dockerignore: Path) -> list[str]:
|
|
140
|
+
if not dockerignore.is_file():
|
|
141
|
+
return []
|
|
142
|
+
patterns: list[str] = []
|
|
143
|
+
for raw in dockerignore.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
144
|
+
line = raw.strip()
|
|
145
|
+
# Negações (`!arquivo`) reincluem o que um padrão anterior tirou.
|
|
146
|
+
# Tratá-las como padrão comum excluiria exatamente o que elas mandam
|
|
147
|
+
# incluir, então ficam de fora da lista de exclusão.
|
|
148
|
+
if not line or line.startswith(("#", "!")):
|
|
149
|
+
continue
|
|
150
|
+
patterns.append(line.rstrip("/"))
|
|
151
|
+
return patterns
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _is_ignored(relative: str, patterns: list[str]) -> bool:
|
|
155
|
+
for pattern in patterns:
|
|
156
|
+
if fnmatch.fnmatch(relative, pattern):
|
|
157
|
+
return True
|
|
158
|
+
# Um padrão de diretório cobre tudo abaixo dele: `.git` no
|
|
159
|
+
# .dockerignore significa `.git/**`, e comparar só o caminho completo
|
|
160
|
+
# deixaria passar cada arquivo lá dentro.
|
|
161
|
+
if relative.startswith(f"{pattern}/") or fnmatch.fnmatch(relative, f"{pattern}/*"):
|
|
162
|
+
return True
|
|
163
|
+
if any(fnmatch.fnmatch(part, pattern) for part in relative.split("/")[:-1]):
|
|
164
|
+
return True
|
|
165
|
+
return False
|
|
File without changes
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from loguru import logger
|
|
9
|
+
|
|
10
|
+
from dockerls.infrastructure.redaction import redact
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from loguru import Record
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Key names that introduce a credential, in any casing or word shape
|
|
17
|
+
# ("apiKey", "api_key", "API-KEY", "dockerhub_token", "senha"). Named for
|
|
18
|
+
_FILE_FORMAT = "{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{function}:{line} - {message}"
|
|
19
|
+
_CONSOLE_FORMAT = (
|
|
20
|
+
"<level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan> - {message}"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _log_filter(record: Record) -> bool:
|
|
25
|
+
record["message"] = redact(record["message"])
|
|
26
|
+
return True
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _resolve_log_file(log_dir: Path) -> Path | None:
|
|
30
|
+
"""Return a writable `<log_dir>/dockerls_<timestamp>.log`, or None when
|
|
31
|
+
no directory in the fallback chain can be created."""
|
|
32
|
+
stamp = datetime.now(tz=UTC).strftime("%Y-%m-%d_%H-%M-%S")
|
|
33
|
+
candidates = [log_dir]
|
|
34
|
+
fallback = Path.home() / ".cache" / "dockerls" / "logs"
|
|
35
|
+
if fallback != log_dir:
|
|
36
|
+
candidates.append(fallback)
|
|
37
|
+
|
|
38
|
+
for candidate in candidates:
|
|
39
|
+
try:
|
|
40
|
+
candidate.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
path = candidate / f"dockerls_{stamp}.log"
|
|
42
|
+
path.touch()
|
|
43
|
+
except OSError:
|
|
44
|
+
continue
|
|
45
|
+
return path
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Nothing below this level ever reaches the terminal on its own. Normal CLI
|
|
50
|
+
# use is not a debugging session: an INFO line on stderr is noise a pipeline
|
|
51
|
+
# has to filter out, and it corrupts the Rich progress display.
|
|
52
|
+
DEFAULT_CONSOLE_LEVEL = "WARNING"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def setup_logging(
|
|
56
|
+
level: str = "INFO",
|
|
57
|
+
log_dir: Path | None = None,
|
|
58
|
+
console: bool = False,
|
|
59
|
+
console_level: str | None = None,
|
|
60
|
+
) -> Path | None:
|
|
61
|
+
"""Route diagnostics to a rotating log file instead of the terminal.
|
|
62
|
+
|
|
63
|
+
The CLI owns the terminal (Rich progress bars and tables), so loguru's
|
|
64
|
+
default stderr sink is always removed: scanner failures, retries and
|
|
65
|
+
debug chatter would otherwise interleave with -- and corrupt -- the
|
|
66
|
+
progress display. Set `console=True` (``--verbose``) to opt back into
|
|
67
|
+
stderr logging on top of the file sink.
|
|
68
|
+
|
|
69
|
+
`level` is the file sink's level; `console_level` is the stderr sink's
|
|
70
|
+
and defaults to WARNING regardless of `level`, so raising
|
|
71
|
+
DOCKERLS_LOG_LEVEL to DEBUG for the log file never starts spraying the
|
|
72
|
+
terminal. ``--verbose`` passes `console_level` explicitly to raise it.
|
|
73
|
+
|
|
74
|
+
Returns the active log file path so callers can point the user at it.
|
|
75
|
+
"""
|
|
76
|
+
logger.remove()
|
|
77
|
+
|
|
78
|
+
log_file = _resolve_log_file(log_dir or Path("logs"))
|
|
79
|
+
if log_file is not None:
|
|
80
|
+
logger.add(
|
|
81
|
+
log_file,
|
|
82
|
+
level=level.upper(),
|
|
83
|
+
format=_FILE_FORMAT,
|
|
84
|
+
filter=_log_filter,
|
|
85
|
+
enqueue=True,
|
|
86
|
+
retention=20,
|
|
87
|
+
encoding="utf-8",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if console or log_file is None:
|
|
91
|
+
logger.add(
|
|
92
|
+
sys.stderr,
|
|
93
|
+
level=(console_level or DEFAULT_CONSOLE_LEVEL).upper(),
|
|
94
|
+
format=_CONSOLE_FORMAT,
|
|
95
|
+
filter=_log_filter,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
return log_file
|
|
File without changes
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Apply the network policy to *every* request, not just the first one.
|
|
2
|
+
|
|
3
|
+
Checking the host of a reference before opening a client is necessary and
|
|
4
|
+
not sufficient. Two things happen after that check that can move the
|
|
5
|
+
request somewhere else entirely, and both are controlled by the far end:
|
|
6
|
+
|
|
7
|
+
* **Redirects.** `follow_redirects=True` means a registry answering `302
|
|
8
|
+
Location: http://169.254.169.254/latest/meta-data/iam/security-credentials/`
|
|
9
|
+
gets that request issued from inside the client, with the guard's verdict
|
|
10
|
+
on the *original* host still standing. The reference looked innocuous
|
|
11
|
+
because it was; the destination was chosen afterwards.
|
|
12
|
+
* **`WWW-Authenticate`.** The OCI token dance takes the URL to authenticate
|
|
13
|
+
against from a header the registry sends back (`Bearer realm="..."`). That
|
|
14
|
+
is a remote party naming a URL this process will then fetch. It is the
|
|
15
|
+
same primitive as an open redirect, arriving through a different door.
|
|
16
|
+
|
|
17
|
+
An `httpx` request event hook fires once per hop -- the initial request and
|
|
18
|
+
every redirect -- which is exactly the granularity the policy needs. Raising
|
|
19
|
+
from the hook aborts the transfer before the socket for that hop is opened.
|
|
20
|
+
|
|
21
|
+
The scheme is checked in the same place. `Location: file:///etc/shadow` and
|
|
22
|
+
`Location: gopher://...` are not requests this tool has any reason to make,
|
|
23
|
+
and an allowlist of `http`/`https` is cheaper to reason about than an
|
|
24
|
+
exhaustive list of what to refuse.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from typing import TYPE_CHECKING
|
|
30
|
+
|
|
31
|
+
import httpx
|
|
32
|
+
from loguru import logger
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from collections.abc import Callable, Coroutine
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
from dockerls.infrastructure.network.host_guard import HostGuard
|
|
39
|
+
|
|
40
|
+
#: The only schemes a hop may use. Anything else is refused by name.
|
|
41
|
+
ALLOWED_SCHEMES = frozenset({"http", "https"})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class BlockedRequestError(httpx.HTTPError):
|
|
45
|
+
"""A hop the network policy refused.
|
|
46
|
+
|
|
47
|
+
Derived from `httpx.HTTPError` deliberately: every caller in this
|
|
48
|
+
codebase already treats an `HTTPError` as "could not determine", which
|
|
49
|
+
is the correct reading of a refused request. A new exception type would
|
|
50
|
+
have escaped those handlers and turned a policy decision into a crash --
|
|
51
|
+
and a crash is a worse answer than an explicit unknown.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, url: httpx.URL, reason: str):
|
|
55
|
+
self.url = url
|
|
56
|
+
self.reason = reason
|
|
57
|
+
super().__init__(reason)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def guard_request(guard: HostGuard, request: httpx.Request) -> None:
|
|
61
|
+
"""Raise `BlockedRequestError` unless `request` is within policy."""
|
|
62
|
+
url = request.url
|
|
63
|
+
if url.scheme not in ALLOWED_SCHEMES:
|
|
64
|
+
# httpx normalises an unsupported scheme to the empty string, so the
|
|
65
|
+
# message names what it required rather than what it got -- and the
|
|
66
|
+
# URL itself is never echoed, because a token can live in its query.
|
|
67
|
+
raise BlockedRequestError(
|
|
68
|
+
url,
|
|
69
|
+
f"Refusing a request whose scheme is not one of "
|
|
70
|
+
f"{'/'.join(sorted(ALLOWED_SCHEMES))} (got {url.scheme or 'none'}).",
|
|
71
|
+
)
|
|
72
|
+
# `url.netloc` carries any userinfo; `host` and `port` do not, and the
|
|
73
|
+
# guard must judge the name that will actually be resolved.
|
|
74
|
+
host = url.host if url.port is None else f"{url.host}:{url.port}"
|
|
75
|
+
if not host:
|
|
76
|
+
raise BlockedRequestError(url, "Refusing a request with no host.")
|
|
77
|
+
if not guard.allows(host):
|
|
78
|
+
raise BlockedRequestError(url, guard.explain(host))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def request_hook(guard: HostGuard) -> Callable[[httpx.Request], Coroutine[Any, Any, None]]:
|
|
82
|
+
"""An `httpx` async request event hook enforcing `guard` on every hop."""
|
|
83
|
+
|
|
84
|
+
async def _hook(request: httpx.Request) -> None:
|
|
85
|
+
try:
|
|
86
|
+
guard_request(guard, request)
|
|
87
|
+
except BlockedRequestError as e:
|
|
88
|
+
logger.warning(f"Blocked request to {e.url.host}: {e.reason}")
|
|
89
|
+
raise
|
|
90
|
+
|
|
91
|
+
return _hook
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def guarded_async_client(guard: HostGuard | None, **kwargs: Any) -> httpx.AsyncClient:
|
|
95
|
+
"""An `httpx.AsyncClient` whose every hop is checked against `guard`.
|
|
96
|
+
|
|
97
|
+
A `None` guard builds an ordinary client: the callers that reach fixed,
|
|
98
|
+
vendor-owned endpoints (and are constructed in places with no guard to
|
|
99
|
+
hand) keep working unchanged rather than silently losing their network.
|
|
100
|
+
Those callers pass `follow_redirects=False`, so there is no second hop
|
|
101
|
+
to police in the first place.
|
|
102
|
+
"""
|
|
103
|
+
if guard is None:
|
|
104
|
+
return httpx.AsyncClient(**kwargs)
|
|
105
|
+
hooks = dict(kwargs.pop("event_hooks", None) or {})
|
|
106
|
+
hooks["request"] = [*hooks.get("request", []), request_hook(guard)]
|
|
107
|
+
return httpx.AsyncClient(event_hooks=hooks, **kwargs)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Resolve a host and ask the network policy whether it may be contacted.
|
|
2
|
+
|
|
3
|
+
The rule lives in the domain (`NetworkPolicy`) and knows nothing about DNS.
|
|
4
|
+
This is the half that performs the lookup, which is I/O and therefore
|
|
5
|
+
belongs out here -- and keeping them apart is what lets the rule be tested
|
|
6
|
+
exhaustively against address literals with no network at all.
|
|
7
|
+
|
|
8
|
+
Answers are memoised for the lifetime of the guard: a `recommend` run asks
|
|
9
|
+
about the same handful of registries once per candidate, and a name that was
|
|
10
|
+
just refused does not become acceptable a millisecond later.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import ipaddress
|
|
16
|
+
import socket
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
from urllib.parse import urlsplit
|
|
19
|
+
|
|
20
|
+
from loguru import logger
|
|
21
|
+
|
|
22
|
+
from dockerls.domain.value_objects.network_policy import (
|
|
23
|
+
NetworkDecision,
|
|
24
|
+
NetworkPolicy,
|
|
25
|
+
hostname_of,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from collections.abc import Iterable
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class HostGuard:
|
|
33
|
+
"""Decides, with DNS, whether a host is within the configured policy."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, policy: NetworkPolicy | None = None):
|
|
36
|
+
self._policy = policy or NetworkPolicy()
|
|
37
|
+
self._decisions: dict[str, NetworkDecision] = {}
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def policy(self) -> NetworkPolicy:
|
|
41
|
+
return self._policy
|
|
42
|
+
|
|
43
|
+
def decide(self, host: str) -> NetworkDecision:
|
|
44
|
+
cached = self._decisions.get(host)
|
|
45
|
+
if cached is not None:
|
|
46
|
+
return cached
|
|
47
|
+
|
|
48
|
+
if self._policy.is_allowlisted(host):
|
|
49
|
+
decision = NetworkDecision.ALLOWED_BY_ALLOWLIST
|
|
50
|
+
else:
|
|
51
|
+
hostname = hostname_of(host)
|
|
52
|
+
decision = (
|
|
53
|
+
self._policy.decide_addresses(_resolve(hostname))
|
|
54
|
+
if hostname
|
|
55
|
+
else NetworkDecision.BLOCKED_UNRESOLVABLE
|
|
56
|
+
)
|
|
57
|
+
self._decisions[host] = decision
|
|
58
|
+
return decision
|
|
59
|
+
|
|
60
|
+
def allows(self, host: str) -> bool:
|
|
61
|
+
return self.decide(host) in (
|
|
62
|
+
NetworkDecision.ALLOWED,
|
|
63
|
+
NetworkDecision.ALLOWED_BY_ALLOWLIST,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def explain(self, host: str) -> str:
|
|
67
|
+
return self._policy.explain(host, self.decide(host))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def host_of_url(url: str) -> str:
|
|
71
|
+
"""O `host[:porta]` que uma URL contataria, para o guard julgar.
|
|
72
|
+
|
|
73
|
+
`HostGuard` decide sobre hosts, nunca sobre URLs, e a diferença não é
|
|
74
|
+
cosmética: entregue a URL inteira, `hostname_of` corta no dois-pontos do
|
|
75
|
+
esquema e pergunta ao DNS por `https`, que não resolve para endereço
|
|
76
|
+
nenhum -- `BLOCKED_UNRESOLVABLE` para *toda* requisição. O efeito é uma
|
|
77
|
+
recusa que se parece com decisão de política e é bug. Quem tem uma URL
|
|
78
|
+
na mão converte aqui, uma vez, em vez de cada chamador repetir a regra.
|
|
79
|
+
|
|
80
|
+
Vive na infraestrutura, e não ao lado de `hostname_of`: o domínio é
|
|
81
|
+
testado sem rede nem disco e não importa `urllib`.
|
|
82
|
+
"""
|
|
83
|
+
netloc = urlsplit(url).netloc
|
|
84
|
+
if not netloc:
|
|
85
|
+
# Já era um host: `gitlab.com`, `registry.internal:5000`.
|
|
86
|
+
return url.strip()
|
|
87
|
+
# Credenciais embutidas (`user:senha@host`) trariam um dois-pontos que
|
|
88
|
+
# não é porta; o host é o que vem depois do `@`.
|
|
89
|
+
return netloc.rpartition("@")[2]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _resolve(hostname: str) -> list[ipaddress.IPv4Address | ipaddress.IPv6Address]:
|
|
93
|
+
"""Every address `hostname` resolves to, literals included.
|
|
94
|
+
|
|
95
|
+
A failure returns an empty list, which the policy reads as
|
|
96
|
+
"unresolvable" and therefore refuses -- the safe direction: a name that
|
|
97
|
+
cannot be resolved cannot be verified either.
|
|
98
|
+
"""
|
|
99
|
+
try:
|
|
100
|
+
return [ipaddress.ip_address(hostname)]
|
|
101
|
+
except ValueError:
|
|
102
|
+
pass
|
|
103
|
+
try:
|
|
104
|
+
infos: Iterable[tuple[Any, ...]] = socket.getaddrinfo(
|
|
105
|
+
hostname, None, proto=socket.IPPROTO_TCP
|
|
106
|
+
)
|
|
107
|
+
except (OSError, UnicodeError) as e:
|
|
108
|
+
logger.debug(f"Could not resolve {hostname}: {e}")
|
|
109
|
+
return []
|
|
110
|
+
|
|
111
|
+
addresses: list[ipaddress.IPv4Address | ipaddress.IPv6Address] = []
|
|
112
|
+
for info in infos:
|
|
113
|
+
try:
|
|
114
|
+
addresses.append(ipaddress.ip_address(info[4][0]))
|
|
115
|
+
except (ValueError, IndexError):
|
|
116
|
+
continue
|
|
117
|
+
return addresses
|