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,49 @@
|
|
|
1
|
+
# Dockerfile.hardened.ubuntu
|
|
2
|
+
# Hardened template for Ubuntu Linux (24.04 LTS Noble Numbat)
|
|
3
|
+
|
|
4
|
+
ARG UBUNTU_VERSION=24.04
|
|
5
|
+
|
|
6
|
+
# Stage 1: Build / Setup
|
|
7
|
+
FROM ubuntu:${UBUNTU_VERSION} AS builder
|
|
8
|
+
|
|
9
|
+
ENV DEBIAN_FRONTEND=noninteractive
|
|
10
|
+
|
|
11
|
+
RUN apt-get update \
|
|
12
|
+
&& apt-get upgrade -y \
|
|
13
|
+
&& apt-get install -y --no-install-recommends ca-certificates tzdata \
|
|
14
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
15
|
+
|
|
16
|
+
# Stage 2: Runtime
|
|
17
|
+
FROM ubuntu:${UBUNTU_VERSION}
|
|
18
|
+
|
|
19
|
+
LABEL security.scanner="dockerls"
|
|
20
|
+
LABEL security.hardened="true"
|
|
21
|
+
LABEL maintainer="security@company.com"
|
|
22
|
+
LABEL security.cve-contact="security@company.com"
|
|
23
|
+
|
|
24
|
+
ENV DEBIAN_FRONTEND=noninteractive
|
|
25
|
+
|
|
26
|
+
# Update security packages and create a non-root user (UID 10001)
|
|
27
|
+
RUN apt-get update \
|
|
28
|
+
&& apt-get upgrade -y \
|
|
29
|
+
&& apt-get install -y --no-install-recommends ca-certificates tzdata \
|
|
30
|
+
&& groupadd -g 10001 appgroup \
|
|
31
|
+
&& useradd -u 10001 -g appgroup -s /usr/sbin/nologin -m appuser \
|
|
32
|
+
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
|
33
|
+
|
|
34
|
+
WORKDIR /app
|
|
35
|
+
|
|
36
|
+
COPY --chown=appuser:appgroup . .
|
|
37
|
+
|
|
38
|
+
ARG GIT_SHA=unknown
|
|
39
|
+
ARG BUILD_TIME=unknown
|
|
40
|
+
LABEL org.opencontainers.image.revision="${GIT_SHA}"
|
|
41
|
+
LABEL org.opencontainers.image.created="${BUILD_TIME}"
|
|
42
|
+
|
|
43
|
+
USER appuser
|
|
44
|
+
|
|
45
|
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
|
46
|
+
CMD [ -f /app/health.ok ] || exit 0
|
|
47
|
+
|
|
48
|
+
ENTRYPOINT ["sh", "-c"]
|
|
49
|
+
CMD ["echo 'DockerLs Ubuntu container ready'"]
|
|
File without changes
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Lê a data de construção da base de vulnerabilidades de cada scanner.
|
|
2
|
+
|
|
3
|
+
Os dois publicam um JSON de metadados ao lado da base, com nomes de campo
|
|
4
|
+
diferentes:
|
|
5
|
+
|
|
6
|
+
Trivy <cache>/db/metadata.json -> "UpdatedAt" (e "NextUpdate")
|
|
7
|
+
Grype <cache>/db/<schema>/metadata.json -> "built"
|
|
8
|
+
|
|
9
|
+
Nada aqui levanta. Um arquivo ausente, ilegível, com JSON quebrado ou com
|
|
10
|
+
um campo que este código não reconhece devolve `None` **com o motivo**, e
|
|
11
|
+
quem chama transforma isso em `UNKNOWN` -- que é diferente de "atualizada",
|
|
12
|
+
e é justamente a distinção que este módulo existe para preservar.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from loguru import logger
|
|
23
|
+
|
|
24
|
+
#: Teto do arquivo de metadados. Ele tem centenas de bytes; qualquer coisa
|
|
25
|
+
#: maior não é ele, e ler sem limite um arquivo que o scanner escreveu é
|
|
26
|
+
#: confiar demais num caminho de disco.
|
|
27
|
+
MAX_METADATA_BYTES = 64 * 1024
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def trivy_cache_dir() -> Path:
|
|
31
|
+
"""Onde o Trivy guarda a base, pela mesma resolução que ele usa."""
|
|
32
|
+
env = os.environ.get("TRIVY_CACHE_DIR")
|
|
33
|
+
if env:
|
|
34
|
+
return Path(env)
|
|
35
|
+
xdg = os.environ.get("XDG_CACHE_HOME")
|
|
36
|
+
if xdg:
|
|
37
|
+
return Path(xdg) / "trivy"
|
|
38
|
+
return Path.home() / ".cache" / "trivy"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def grype_cache_dir() -> Path:
|
|
42
|
+
"""Onde o Grype guarda a base."""
|
|
43
|
+
env = os.environ.get("GRYPE_DB_CACHE_DIR")
|
|
44
|
+
if env:
|
|
45
|
+
return Path(env)
|
|
46
|
+
xdg = os.environ.get("XDG_CACHE_HOME")
|
|
47
|
+
if xdg:
|
|
48
|
+
return Path(xdg) / "grype"
|
|
49
|
+
return Path.home() / ".cache" / "grype"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def read_trivy_built_at(cache_dir: Path | None = None) -> tuple[datetime | None, str]:
|
|
53
|
+
"""A data da base do Trivy, ou `(None, motivo)`."""
|
|
54
|
+
base = cache_dir or trivy_cache_dir()
|
|
55
|
+
return _read(base / "db" / "metadata.json", ("UpdatedAt", "updatedAt", "built"))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def read_grype_built_at(cache_dir: Path | None = None) -> tuple[datetime | None, str]:
|
|
59
|
+
"""A data da base do Grype, ou `(None, motivo)`.
|
|
60
|
+
|
|
61
|
+
O Grype versiona a base por esquema (`db/5/metadata.json`), e o número
|
|
62
|
+
muda entre versões da ferramenta. Procurar o mais recente é mais
|
|
63
|
+
robusto que fixar um número que envelhece junto com o código.
|
|
64
|
+
"""
|
|
65
|
+
base = (cache_dir or grype_cache_dir()) / "db"
|
|
66
|
+
candidates = sorted(base.glob("*/metadata.json"), key=lambda p: p.parent.name, reverse=True)
|
|
67
|
+
if not candidates:
|
|
68
|
+
return None, f"no metadata under {base}"
|
|
69
|
+
return _read(candidates[0], ("built", "Built", "UpdatedAt"))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _read(path: Path, fields: tuple[str, ...]) -> tuple[datetime | None, str]:
|
|
73
|
+
"""O primeiro campo de data que existir, ou o motivo de não haver um."""
|
|
74
|
+
try:
|
|
75
|
+
if not path.is_file():
|
|
76
|
+
return None, f"{path} is not there"
|
|
77
|
+
if path.stat().st_size > MAX_METADATA_BYTES:
|
|
78
|
+
return None, f"{path} is larger than a metadata file should be"
|
|
79
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
80
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
81
|
+
return None, f"could not read {path}: {e}"
|
|
82
|
+
except json.JSONDecodeError as e:
|
|
83
|
+
return None, f"{path} is not valid JSON: {e}"
|
|
84
|
+
|
|
85
|
+
if not isinstance(payload, dict):
|
|
86
|
+
return None, f"{path} does not contain an object"
|
|
87
|
+
|
|
88
|
+
for field in fields:
|
|
89
|
+
value = payload.get(field)
|
|
90
|
+
if isinstance(value, str) and value.strip():
|
|
91
|
+
parsed = _parse_timestamp(value)
|
|
92
|
+
if parsed is not None:
|
|
93
|
+
return parsed, ""
|
|
94
|
+
return None, f"{path}: {field} is not a timestamp this code understands"
|
|
95
|
+
return None, f"{path} carries none of {', '.join(fields)}"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _parse_timestamp(value: str) -> datetime | None:
|
|
99
|
+
"""Interpreta o carimbo, tolerando o `Z` que os dois escrevem."""
|
|
100
|
+
text = value.strip().replace("Z", "+00:00")
|
|
101
|
+
try:
|
|
102
|
+
return datetime.fromisoformat(text)
|
|
103
|
+
except ValueError:
|
|
104
|
+
# Alguns carimbos trazem mais de seis casas de fração de segundo,
|
|
105
|
+
# que o `fromisoformat` não aceita. Cortar é melhor que desistir.
|
|
106
|
+
if "." in text:
|
|
107
|
+
head, _, tail = text.partition(".")
|
|
108
|
+
digits = "".join(c for c in tail if c.isdigit())[:6]
|
|
109
|
+
offset = tail[len(digits) :] if len(tail) > len(digits) else ""
|
|
110
|
+
with_offset = f"{head}.{digits}{offset}" if digits else head + offset
|
|
111
|
+
try:
|
|
112
|
+
return datetime.fromisoformat(with_offset)
|
|
113
|
+
except ValueError:
|
|
114
|
+
logger.debug(f"Unparseable database timestamp: {value!r}")
|
|
115
|
+
return None
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""Baixar, verificar e instalar um scanner, sem executar nada de terceiro.
|
|
2
|
+
|
|
3
|
+
Isto é uma ferramenta de segurança instalando outra, então o padrão usual --
|
|
4
|
+
`curl ... | sh` -- está fora de questão: não há como verificar a integridade
|
|
5
|
+
de um script antes de executá-lo, e nem o Trivy nem o Grype publicam checksum
|
|
6
|
+
do próprio `install.sh` (o que eles publicam é o checksum dos binários).
|
|
7
|
+
|
|
8
|
+
O caminho aqui faz o que aquele script faria, verificando:
|
|
9
|
+
|
|
10
|
+
1. resolve a versão publicada mais recente pela API de releases do projeto;
|
|
11
|
+
2. baixa o arquivo compactado **e** o `checksums.txt` do mesmo release;
|
|
12
|
+
3. confere o SHA-256 do arquivo contra a linha correspondente;
|
|
13
|
+
4. confere a **assinatura cosign do `checksums.txt`**, quando o projeto
|
|
14
|
+
publica uma e o cosign está instalado. A cadeia é curta de propósito: a
|
|
15
|
+
assinatura prova que o `checksums.txt` é o que o projeto publicou, e o
|
|
16
|
+
passo 3 prova que o arquivo é o que aquele `checksums.txt` descreve.
|
|
17
|
+
Não há assinatura por artefato para conferir diretamente.
|
|
18
|
+
|
|
19
|
+
A ordem é assinatura **antes** do SHA-256: conferir o digest primeiro
|
|
20
|
+
compararia o arquivo com uma lista que ainda não se sabe de quem é, e
|
|
21
|
+
uma lista adulterada aprova um arquivo adulterado.
|
|
22
|
+
|
|
23
|
+
A identidade é restrita ao repositório do projeto e o emissor ao OIDC do
|
|
24
|
+
GitHub Actions -- sem isso, o cosign responderia "alguém assinou", que é
|
|
25
|
+
uma pergunta diferente da que importa.
|
|
26
|
+
|
|
27
|
+
Só uma assinatura **inválida** aborta. Projeto sem assinatura conhecida,
|
|
28
|
+
cosign ausente ou cosign inconclusivo devolvem `None`: ausência de
|
|
29
|
+
verificação, dita como ausência, e a instalação segue pelo checksum;
|
|
30
|
+
5. extrai **apenas** o binário, para um diretório do usuário.
|
|
31
|
+
|
|
32
|
+
Nada baixado é executado. A extração é feita pelo `tarfile`/`zipfile` do
|
|
33
|
+
Python, sem shell, e cada membro do arquivo é validado antes de ser escrito:
|
|
34
|
+
um `.tar.gz` pode conter caminhos como `../../.ssh/authorized_keys`, e é
|
|
35
|
+
assim que uma extração ingênua vira escrita arbitrária no sistema de
|
|
36
|
+
arquivos.
|
|
37
|
+
|
|
38
|
+
Tudo acontece num diretório temporário que é removido ao fim, com sucesso ou
|
|
39
|
+
sem: um download interrompido não pode deixar meio binário ocupando espaço,
|
|
40
|
+
nem meio binário no PATH.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import hashlib
|
|
46
|
+
import os
|
|
47
|
+
import shutil
|
|
48
|
+
import tarfile
|
|
49
|
+
import tempfile
|
|
50
|
+
import zipfile
|
|
51
|
+
from dataclasses import dataclass
|
|
52
|
+
from pathlib import Path
|
|
53
|
+
from typing import TYPE_CHECKING
|
|
54
|
+
|
|
55
|
+
import httpx
|
|
56
|
+
from loguru import logger
|
|
57
|
+
|
|
58
|
+
from dockerls.infrastructure.network.host_guard import host_of_url
|
|
59
|
+
|
|
60
|
+
if TYPE_CHECKING:
|
|
61
|
+
from dockerls.domain.value_objects.tool_release import ReleaseAsset, ToolSpec
|
|
62
|
+
from dockerls.infrastructure.network.host_guard import HostGuard
|
|
63
|
+
|
|
64
|
+
#: Teto do arquivo compactado. Um scanner tem dezenas de MB; o limite existe
|
|
65
|
+
#: para que uma resposta inesperada não decida quanto este processo escreve
|
|
66
|
+
#: em disco.
|
|
67
|
+
MAX_ARCHIVE_BYTES = 512 * 1024 * 1024
|
|
68
|
+
|
|
69
|
+
#: O `checksums.txt` tem alguns KB. Qualquer coisa maior não é ele.
|
|
70
|
+
MAX_CHECKSUMS_BYTES = 1024 * 1024
|
|
71
|
+
|
|
72
|
+
#: Permissão do binário instalado: executável pelo dono, legível pelos
|
|
73
|
+
#: demais. Nada de bit de escrita para grupo/outros -- um binário que
|
|
74
|
+
#: qualquer processo pode reescrever é um binário que qualquer processo
|
|
75
|
+
#: pode trocar.
|
|
76
|
+
BINARY_MODE = 0o755
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class InstallError(RuntimeError):
|
|
80
|
+
"""Uma falha que impede a instalação, com mensagem para o usuário."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class InstallPlan:
|
|
85
|
+
"""O que será baixado, de onde, e o que isso exige do usuário.
|
|
86
|
+
|
|
87
|
+
Existe para ser **impresso antes de qualquer download**: o consentimento
|
|
88
|
+
só é informado se a pessoa vê a URL exata antes de dar o sim.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
tool: str
|
|
92
|
+
version: str
|
|
93
|
+
asset: ReleaseAsset
|
|
94
|
+
destination: Path
|
|
95
|
+
#: True quando o destino exige privilégio que o usuário não tem. Aparece
|
|
96
|
+
#: na confirmação, nunca surge no meio da execução.
|
|
97
|
+
needs_privilege: bool
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def sources(self) -> tuple[str, ...]:
|
|
101
|
+
"""Tudo que este plano baixa, para o consentimento e para a política.
|
|
102
|
+
|
|
103
|
+
O par de assinatura entra aqui: uma URL que a confirmação não
|
|
104
|
+
mostrou é uma URL que o usuário não consentiu, e a política de rede
|
|
105
|
+
precisa julgar todas antes de qualquer download começar.
|
|
106
|
+
"""
|
|
107
|
+
urls = [self.asset.archive_url, self.asset.checksums_url]
|
|
108
|
+
if self.asset.checksums_signature_url:
|
|
109
|
+
urls.append(self.asset.checksums_signature_url)
|
|
110
|
+
if self.asset.checksums_certificate_url:
|
|
111
|
+
urls.append(self.asset.checksums_certificate_url)
|
|
112
|
+
return tuple(urls)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class InstallOutcome:
|
|
117
|
+
tool: str
|
|
118
|
+
installed: bool
|
|
119
|
+
detail: str
|
|
120
|
+
path: Path | None = None
|
|
121
|
+
#: Se a assinatura foi conferida além do checksum. `None` quando o
|
|
122
|
+
#: cosign não estava disponível -- ausência de verificação, que é
|
|
123
|
+
#: diferente de assinatura inválida (essa aborta).
|
|
124
|
+
signature_verified: bool | None = None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class ToolInstaller:
|
|
128
|
+
"""Instala um scanner a partir do release oficial do projeto."""
|
|
129
|
+
|
|
130
|
+
def __init__(
|
|
131
|
+
self,
|
|
132
|
+
timeout: int = 120,
|
|
133
|
+
guard: HostGuard | None = None,
|
|
134
|
+
client_factory: object | None = None,
|
|
135
|
+
):
|
|
136
|
+
self._timeout = timeout
|
|
137
|
+
self._guard = guard
|
|
138
|
+
# Injetável para que o teste nunca toque a rede.
|
|
139
|
+
self._client_factory = client_factory
|
|
140
|
+
|
|
141
|
+
def _client(self) -> httpx.AsyncClient:
|
|
142
|
+
if self._client_factory is not None:
|
|
143
|
+
return self._client_factory() # type: ignore[operator,no-any-return]
|
|
144
|
+
return httpx.AsyncClient(timeout=self._timeout, follow_redirects=True)
|
|
145
|
+
|
|
146
|
+
async def latest_version(self, spec: ToolSpec) -> str:
|
|
147
|
+
"""A última versão publicada, pela API de releases do projeto."""
|
|
148
|
+
url = f"https://api.github.com/repos/{spec.owner}/{spec.repo}/releases/latest"
|
|
149
|
+
self._check_policy(url)
|
|
150
|
+
try:
|
|
151
|
+
async with self._client() as client:
|
|
152
|
+
resp = await client.get(url, headers={"Accept": "application/vnd.github+json"})
|
|
153
|
+
resp.raise_for_status()
|
|
154
|
+
tag = str(resp.json().get("tag_name", "")).lstrip("v")
|
|
155
|
+
except (httpx.HTTPError, ValueError, KeyError, TypeError) as e:
|
|
156
|
+
raise InstallError(f"could not resolve the latest {spec.name} version: {e}") from e
|
|
157
|
+
if not tag:
|
|
158
|
+
raise InstallError(f"the {spec.name} release feed returned no version")
|
|
159
|
+
return tag
|
|
160
|
+
|
|
161
|
+
def _check_policy(self, url: str) -> None:
|
|
162
|
+
# O guard decide sobre `host[:porta]`, nunca sobre a URL inteira:
|
|
163
|
+
# `hostname_of` cortaria no dois-pontos do esquema e perguntaria ao
|
|
164
|
+
# DNS por `https`, recusando todo download como se fosse política.
|
|
165
|
+
if self._guard is not None and not self._guard.allows(host_of_url(url)):
|
|
166
|
+
raise InstallError(f"the network policy refuses {url}")
|
|
167
|
+
|
|
168
|
+
async def install(self, plan: InstallPlan, *, cosign: object | None = None) -> InstallOutcome:
|
|
169
|
+
"""Executa um plano já consentido.
|
|
170
|
+
|
|
171
|
+
Todo o trabalho acontece num diretório temporário; o binário só
|
|
172
|
+
chega ao destino depois de o checksum bater.
|
|
173
|
+
"""
|
|
174
|
+
with tempfile.TemporaryDirectory(prefix="dockerls-install-") as tmp:
|
|
175
|
+
workdir = Path(tmp)
|
|
176
|
+
archive = workdir / plan.asset.archive_name
|
|
177
|
+
try:
|
|
178
|
+
# Dentro do `try` junto com o resto: este método devolve um
|
|
179
|
+
# resultado, nunca levanta. Uma ferramenta recusada pela
|
|
180
|
+
# política não pode abortar a tentativa de instalar a outra.
|
|
181
|
+
for url in plan.sources:
|
|
182
|
+
self._check_policy(url)
|
|
183
|
+
await self._download(plan.asset.archive_url, archive, MAX_ARCHIVE_BYTES)
|
|
184
|
+
|
|
185
|
+
# O `checksums.txt` vai para o disco, e não só para a
|
|
186
|
+
# memória: é ele o blob que a assinatura cobre, e o cosign
|
|
187
|
+
# verifica arquivo.
|
|
188
|
+
checksums_path = workdir / "checksums.txt"
|
|
189
|
+
checksums = await self._fetch_text(plan.asset.checksums_url, MAX_CHECKSUMS_BYTES)
|
|
190
|
+
checksums_path.write_text(checksums, encoding="utf-8")
|
|
191
|
+
|
|
192
|
+
# A ordem importa: a assinatura é conferida **antes** do
|
|
193
|
+
# SHA-256. Conferir o digest primeiro compararia o arquivo
|
|
194
|
+
# com uma lista que ainda não se sabe de quem é -- e uma
|
|
195
|
+
# lista adulterada aprova um arquivo adulterado.
|
|
196
|
+
signature_verified = await self._verify_checksums(
|
|
197
|
+
cosign, checksums_path, plan, workdir
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
expected = self._expected_digest(checksums, plan.asset.archive_name)
|
|
201
|
+
actual = _sha256(archive)
|
|
202
|
+
if actual != expected:
|
|
203
|
+
# Nada é extraído, nada é escrito no destino, e o
|
|
204
|
+
# temporário some com o `with`.
|
|
205
|
+
raise InstallError(
|
|
206
|
+
f"checksum mismatch for {plan.asset.archive_name}: "
|
|
207
|
+
f"expected {expected}, got {actual}"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
binary = self._extract(archive, workdir, plan.asset.binary_name)
|
|
211
|
+
destination = self._place(binary, plan.destination, plan.asset.binary_name)
|
|
212
|
+
except InstallError as e:
|
|
213
|
+
return InstallOutcome(plan.tool, installed=False, detail=str(e))
|
|
214
|
+
except (OSError, httpx.HTTPError) as e:
|
|
215
|
+
return InstallOutcome(plan.tool, installed=False, detail=str(e))
|
|
216
|
+
except (tarfile.TarError, zipfile.BadZipFile, EOFError) as e:
|
|
217
|
+
# Um arquivo que o checksum aprovou mas que não abre como o
|
|
218
|
+
# formato que o nome promete. Raro, e ainda assim um
|
|
219
|
+
# resultado: sem isto a exceção escapava de um método cuja
|
|
220
|
+
# única promessa é não levantar, e `doctor --install`
|
|
221
|
+
# terminava em traceback em vez de "FAILED".
|
|
222
|
+
return InstallOutcome(
|
|
223
|
+
plan.tool,
|
|
224
|
+
installed=False,
|
|
225
|
+
detail=f"{plan.asset.archive_name} could not be opened: {e}",
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
return InstallOutcome(
|
|
229
|
+
plan.tool,
|
|
230
|
+
installed=True,
|
|
231
|
+
detail=(
|
|
232
|
+
(
|
|
233
|
+
"verified cosign signature and sha256, installed to "
|
|
234
|
+
if signature_verified
|
|
235
|
+
else "verified sha256 and installed to "
|
|
236
|
+
)
|
|
237
|
+
+ str(destination)
|
|
238
|
+
),
|
|
239
|
+
path=destination,
|
|
240
|
+
signature_verified=signature_verified,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
async def _verify_checksums(
|
|
244
|
+
self,
|
|
245
|
+
cosign: object | None,
|
|
246
|
+
checksums_path: Path,
|
|
247
|
+
plan: InstallPlan,
|
|
248
|
+
workdir: Path,
|
|
249
|
+
) -> bool | None:
|
|
250
|
+
"""Confere a assinatura do `checksums.txt`, quando há o que conferir.
|
|
251
|
+
|
|
252
|
+
A cadeia é esta e não é mais longa: a assinatura prova que o
|
|
253
|
+
`checksums.txt` é o que o projeto publicou, e o SHA-256 do arquivo
|
|
254
|
+
compactado contra a linha correspondente prova que o arquivo é o
|
|
255
|
+
que aquele `checksums.txt` descreve. Verificar o compactado
|
|
256
|
+
diretamente não é possível -- não há assinatura por artefato.
|
|
257
|
+
|
|
258
|
+
`None` significa **não verificado**, e é o valor honesto para três
|
|
259
|
+
situações diferentes: o projeto não publica assinatura conhecida,
|
|
260
|
+
o cosign não está instalado, ou o cosign não conseguiu concluir.
|
|
261
|
+
Nenhuma delas é "não assinado", e nenhuma delas impede a
|
|
262
|
+
instalação: o checksum publicado continua sendo verificado. Só uma
|
|
263
|
+
assinatura **inválida** aborta, porque essa é uma afirmação.
|
|
264
|
+
"""
|
|
265
|
+
asset = plan.asset
|
|
266
|
+
if not asset.checksums_signature_url or not asset.checksums_certificate_url:
|
|
267
|
+
return None
|
|
268
|
+
if cosign is None:
|
|
269
|
+
return None
|
|
270
|
+
verify = getattr(cosign, "verify_blob", None)
|
|
271
|
+
if not callable(verify):
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
signature = workdir / "checksums.txt.sig"
|
|
275
|
+
certificate = workdir / "checksums.txt.pem"
|
|
276
|
+
try:
|
|
277
|
+
for url in (asset.checksums_signature_url, asset.checksums_certificate_url):
|
|
278
|
+
self._check_policy(url)
|
|
279
|
+
await self._download(asset.checksums_signature_url, signature, MAX_CHECKSUMS_BYTES)
|
|
280
|
+
await self._download(asset.checksums_certificate_url, certificate, MAX_CHECKSUMS_BYTES)
|
|
281
|
+
except (OSError, httpx.HTTPError) as e:
|
|
282
|
+
# O par de assinatura não veio. É ausência de verificação, e a
|
|
283
|
+
# instalação segue pelo checksum -- que é exatamente o que
|
|
284
|
+
# acontecia antes de este caminho existir.
|
|
285
|
+
logger.debug(f"signature material unavailable for {plan.tool}: {e}")
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
ok = await verify(
|
|
290
|
+
str(checksums_path),
|
|
291
|
+
signature=str(signature),
|
|
292
|
+
certificate=str(certificate),
|
|
293
|
+
certificate_identity_regexp=asset.signer_identity_pattern,
|
|
294
|
+
certificate_oidc_issuer=asset.signer_oidc_issuer,
|
|
295
|
+
)
|
|
296
|
+
except Exception as e: # pragma: no cover - a verificação é best-effort
|
|
297
|
+
logger.debug(f"cosign verification unavailable for {plan.tool}: {e}")
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
if ok is False:
|
|
301
|
+
raise InstallError(
|
|
302
|
+
f"cosign reported an invalid signature for {plan.asset.archive_name} "
|
|
303
|
+
f"(the {asset.checksums_signature_url.rsplit('/', 1)[-1]} does not match "
|
|
304
|
+
f"an identity under {asset.signer_identity_pattern}); refusing to install"
|
|
305
|
+
)
|
|
306
|
+
if ok is True:
|
|
307
|
+
logger.info(f"cosign verified the checksums of {plan.tool}")
|
|
308
|
+
return True
|
|
309
|
+
# Qualquer coisa que não seja True nem False -- um duplo que
|
|
310
|
+
# devolveu outra coisa -- é tratada como não conclusiva, e nunca
|
|
311
|
+
# como aprovação.
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
async def _download(self, url: str, target: Path, limit: int) -> None:
|
|
315
|
+
async with self._client() as client, client.stream("GET", url) as resp:
|
|
316
|
+
resp.raise_for_status()
|
|
317
|
+
total = 0
|
|
318
|
+
with target.open("wb") as fh:
|
|
319
|
+
async for chunk in resp.aiter_bytes():
|
|
320
|
+
total += len(chunk)
|
|
321
|
+
if total > limit:
|
|
322
|
+
raise InstallError(f"{url} exceeded {limit} bytes; refusing it")
|
|
323
|
+
fh.write(chunk)
|
|
324
|
+
|
|
325
|
+
async def _fetch_text(self, url: str, limit: int) -> str:
|
|
326
|
+
"""O texto de uma resposta pequena, com o teto valendo durante.
|
|
327
|
+
|
|
328
|
+
`client.get` já teria bufferizado o corpo inteiro antes de qualquer
|
|
329
|
+
checagem, então cortar `resp.content` depois media um limite que já
|
|
330
|
+
havia sido excedido: o teto existe para bound o que uma resposta
|
|
331
|
+
inesperada faz este processo alocar, e só um stream o aplica.
|
|
332
|
+
"""
|
|
333
|
+
chunks: list[bytes] = []
|
|
334
|
+
total = 0
|
|
335
|
+
async with self._client() as client, client.stream("GET", url) as resp:
|
|
336
|
+
resp.raise_for_status()
|
|
337
|
+
async for chunk in resp.aiter_bytes():
|
|
338
|
+
total += len(chunk)
|
|
339
|
+
if total > limit:
|
|
340
|
+
raise InstallError(f"{url} exceeded {limit} bytes; refusing it")
|
|
341
|
+
chunks.append(chunk)
|
|
342
|
+
return b"".join(chunks).decode("utf-8", errors="replace")
|
|
343
|
+
|
|
344
|
+
@staticmethod
|
|
345
|
+
def _expected_digest(checksums: str, archive_name: str) -> str:
|
|
346
|
+
"""A linha do `checksums.txt` que cobre este arquivo.
|
|
347
|
+
|
|
348
|
+
Formato do goreleaser: `<sha256> <filename>`. O arquivo é casado
|
|
349
|
+
pelo nome exato -- um `endswith` casaria
|
|
350
|
+
`trivy_0.58.1_Linux-64bit.tar.gz` com uma linha de
|
|
351
|
+
`outro_Linux-64bit.tar.gz`.
|
|
352
|
+
"""
|
|
353
|
+
for line in checksums.splitlines():
|
|
354
|
+
parts = line.split()
|
|
355
|
+
if len(parts) != 2:
|
|
356
|
+
continue
|
|
357
|
+
digest, name = parts
|
|
358
|
+
if name.lstrip("*") == archive_name:
|
|
359
|
+
if len(digest) != 64 or not all(c in "0123456789abcdef" for c in digest.lower()):
|
|
360
|
+
raise InstallError(f"malformed sha256 for {archive_name} in the checksum file")
|
|
361
|
+
return digest.lower()
|
|
362
|
+
raise InstallError(f"{archive_name} is not listed in the published checksum file")
|
|
363
|
+
|
|
364
|
+
@staticmethod
|
|
365
|
+
def _extract(archive: Path, workdir: Path, binary_name: str) -> Path:
|
|
366
|
+
"""Extrai só o binário, recusando qualquer caminho que escape.
|
|
367
|
+
|
|
368
|
+
Um membro chamado `../../.ssh/authorized_keys` é como uma extração
|
|
369
|
+
ingênua vira escrita arbitrária (CVE-2007-4559). Aqui só um membro
|
|
370
|
+
é considerado -- o que tem exatamente o nome do binário, sem
|
|
371
|
+
diretório -- e nada mais é escrito.
|
|
372
|
+
"""
|
|
373
|
+
out = workdir / "extracted"
|
|
374
|
+
out.mkdir(exist_ok=True)
|
|
375
|
+
|
|
376
|
+
if archive.name.endswith(".zip"):
|
|
377
|
+
with zipfile.ZipFile(archive) as zf:
|
|
378
|
+
member = _safe_member(zf.namelist(), binary_name, archive.name)
|
|
379
|
+
with zf.open(member) as src, (out / binary_name).open("wb") as dst:
|
|
380
|
+
shutil.copyfileobj(src, dst)
|
|
381
|
+
else:
|
|
382
|
+
with tarfile.open(archive, "r:gz") as tf:
|
|
383
|
+
member = _safe_member(tf.getnames(), binary_name, archive.name)
|
|
384
|
+
info = tf.getmember(member)
|
|
385
|
+
if not info.isfile():
|
|
386
|
+
raise InstallError(f"{member} in {archive.name} is not a regular file")
|
|
387
|
+
extracted = tf.extractfile(info)
|
|
388
|
+
if extracted is None:
|
|
389
|
+
raise InstallError(f"could not read {member} from {archive.name}")
|
|
390
|
+
with extracted as src, (out / binary_name).open("wb") as dst:
|
|
391
|
+
shutil.copyfileobj(src, dst)
|
|
392
|
+
|
|
393
|
+
return out / binary_name
|
|
394
|
+
|
|
395
|
+
@staticmethod
|
|
396
|
+
def _place(binary: Path, destination: Path, name: str) -> Path:
|
|
397
|
+
"""Move o binário verificado para o destino, atomicamente.
|
|
398
|
+
|
|
399
|
+
`os.replace` dentro do mesmo sistema de arquivos é atômico, então
|
|
400
|
+
nunca existe um instante em que o destino contém meio binário. Se
|
|
401
|
+
cruzar sistemas de arquivos, cai para copiar e renomear -- ainda
|
|
402
|
+
sem expor um arquivo parcial com o nome final.
|
|
403
|
+
"""
|
|
404
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
405
|
+
final = destination / name
|
|
406
|
+
binary.chmod(BINARY_MODE)
|
|
407
|
+
staging = destination / f".{name}.dockerls-partial"
|
|
408
|
+
try:
|
|
409
|
+
shutil.copy2(binary, staging)
|
|
410
|
+
staging.chmod(BINARY_MODE)
|
|
411
|
+
os.replace(staging, final)
|
|
412
|
+
except OSError:
|
|
413
|
+
staging.unlink(missing_ok=True)
|
|
414
|
+
raise
|
|
415
|
+
return final
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _safe_member(names: list[str], binary_name: str, archive_name: str) -> str:
|
|
419
|
+
"""O membro que é exatamente o binário, na raiz do arquivo.
|
|
420
|
+
|
|
421
|
+
Comparar pelo nome completo, e não por sufixo, é o que impede que
|
|
422
|
+
`evil/../../trivy` ou `nested/trivy` sejam aceitos: o release publica o
|
|
423
|
+
binário na raiz, então qualquer outra forma não é o que se espera dele.
|
|
424
|
+
"""
|
|
425
|
+
if binary_name in names:
|
|
426
|
+
return binary_name
|
|
427
|
+
raise InstallError(f"{archive_name} does not contain {binary_name} at its root")
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _sha256(path: Path) -> str:
|
|
431
|
+
digest = hashlib.sha256()
|
|
432
|
+
with path.open("rb") as fh:
|
|
433
|
+
for block in iter(lambda: fh.read(1024 * 1024), b""):
|
|
434
|
+
digest.update(block)
|
|
435
|
+
return digest.hexdigest()
|
|
File without changes
|
|
File without changes
|