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.
Files changed (230) hide show
  1. dockerls/__init__.py +31 -0
  2. dockerls/application/__init__.py +0 -0
  3. dockerls/application/dto/__init__.py +3 -0
  4. dockerls/application/dto/analysis.py +257 -0
  5. dockerls/application/services/__init__.py +0 -0
  6. dockerls/application/services/alternatives_lookup.py +167 -0
  7. dockerls/application/services/composite_repository.py +106 -0
  8. dockerls/application/services/cross_validation.py +233 -0
  9. dockerls/application/services/ecosystems.py +350 -0
  10. dockerls/application/services/fallback_scanner.py +97 -0
  11. dockerls/application/services/hardening_analysis.py +174 -0
  12. dockerls/application/services/migration.py +297 -0
  13. dockerls/application/services/progress.py +56 -0
  14. dockerls/application/services/remediation.py +321 -0
  15. dockerls/application/services/scan_history_store.py +88 -0
  16. dockerls/application/services/scanner_factory.py +88 -0
  17. dockerls/application/services/source_registry.py +151 -0
  18. dockerls/application/services/tag_history_store.py +76 -0
  19. dockerls/application/services/teardown.py +50 -0
  20. dockerls/application/services/verdict.py +298 -0
  21. dockerls/application/services/version_discovery.py +108 -0
  22. dockerls/application/use_cases/__init__.py +0 -0
  23. dockerls/application/use_cases/analyze_dockerfile.py +103 -0
  24. dockerls/application/use_cases/analyze_image.py +173 -0
  25. dockerls/application/use_cases/build_image.py +1795 -0
  26. dockerls/application/use_cases/compare_images.py +91 -0
  27. dockerls/application/use_cases/fleet_scan.py +240 -0
  28. dockerls/application/use_cases/recommend_images.py +1078 -0
  29. dockerls/application/use_cases/registry_audit.py +133 -0
  30. dockerls/application/use_cases/search_images.py +23 -0
  31. dockerls/application/use_cases/upgrade_base.py +167 -0
  32. dockerls/cache/__init__.py +0 -0
  33. dockerls/cache/sqlite_cache.py +184 -0
  34. dockerls/cli/__init__.py +0 -0
  35. dockerls/cli/analysis_baseline.py +98 -0
  36. dockerls/cli/app.py +294 -0
  37. dockerls/cli/commands/__init__.py +0 -0
  38. dockerls/cli/commands/advisor.py +262 -0
  39. dockerls/cli/commands/alternatives.py +291 -0
  40. dockerls/cli/commands/analyze.py +429 -0
  41. dockerls/cli/commands/analyze_dockerfile.py +104 -0
  42. dockerls/cli/commands/base_cmd.py +244 -0
  43. dockerls/cli/commands/base_image.py +551 -0
  44. dockerls/cli/commands/build.py +1300 -0
  45. dockerls/cli/commands/cache_cmd.py +104 -0
  46. dockerls/cli/commands/compare.py +177 -0
  47. dockerls/cli/commands/controls.py +110 -0
  48. dockerls/cli/commands/doctor.py +566 -0
  49. dockerls/cli/commands/export.py +81 -0
  50. dockerls/cli/commands/fleet.py +159 -0
  51. dockerls/cli/commands/health.py +84 -0
  52. dockerls/cli/commands/login.py +53 -0
  53. dockerls/cli/commands/policy_cmd.py +111 -0
  54. dockerls/cli/commands/provenance_cmd.py +162 -0
  55. dockerls/cli/commands/recommend.py +761 -0
  56. dockerls/cli/commands/registry_audit_cmd.py +103 -0
  57. dockerls/cli/commands/sbom.py +144 -0
  58. dockerls/cli/commands/search.py +86 -0
  59. dockerls/cli/commands/verify.py +115 -0
  60. dockerls/cli/commands/version.py +12 -0
  61. dockerls/cli/commands/vex_cmd.py +117 -0
  62. dockerls/cli/dependencies.py +530 -0
  63. dockerls/cli/image_names.py +79 -0
  64. dockerls/cli/options.py +42 -0
  65. dockerls/cli/progress.py +145 -0
  66. dockerls/cli/publish_prompt.py +123 -0
  67. dockerls/cli/rendering.py +214 -0
  68. dockerls/cli/runtime.py +65 -0
  69. dockerls/cli/scan_failure.py +71 -0
  70. dockerls/cli/text.py +39 -0
  71. dockerls/cli/validators.py +35 -0
  72. dockerls/cli/vulnerability_view.py +154 -0
  73. dockerls/domain/__init__.py +0 -0
  74. dockerls/domain/entities/__init__.py +75 -0
  75. dockerls/domain/entities/declared_metadata.py +147 -0
  76. dockerls/domain/entities/dockerfile_analysis.py +318 -0
  77. dockerls/domain/entities/image.py +109 -0
  78. dockerls/domain/entities/image_facts.py +137 -0
  79. dockerls/domain/entities/recommendation.py +33 -0
  80. dockerls/domain/entities/scan_result.py +129 -0
  81. dockerls/domain/entities/vulnerability.py +232 -0
  82. dockerls/domain/interfaces/__init__.py +17 -0
  83. dockerls/domain/interfaces/cache_store.py +18 -0
  84. dockerls/domain/interfaces/dockerfile_validator.py +99 -0
  85. dockerls/domain/interfaces/eol_checker.py +11 -0
  86. dockerls/domain/interfaces/image_repository.py +15 -0
  87. dockerls/domain/interfaces/scanner.py +15 -0
  88. dockerls/domain/security_controls.py +362 -0
  89. dockerls/domain/value_objects/__init__.py +49 -0
  90. dockerls/domain/value_objects/attack_surface.py +198 -0
  91. dockerls/domain/value_objects/base_recipe.py +600 -0
  92. dockerls/domain/value_objects/base_upgrade.py +292 -0
  93. dockerls/domain/value_objects/build_labels.py +99 -0
  94. dockerls/domain/value_objects/build_policy.py +412 -0
  95. dockerls/domain/value_objects/confidence.py +156 -0
  96. dockerls/domain/value_objects/fleet.py +174 -0
  97. dockerls/domain/value_objects/gate.py +327 -0
  98. dockerls/domain/value_objects/hardening.py +303 -0
  99. dockerls/domain/value_objects/image_reference.py +90 -0
  100. dockerls/domain/value_objects/inheritance.py +352 -0
  101. dockerls/domain/value_objects/network_policy.py +280 -0
  102. dockerls/domain/value_objects/production_readiness.py +145 -0
  103. dockerls/domain/value_objects/provenance.py +211 -0
  104. dockerls/domain/value_objects/recipe_diff.py +188 -0
  105. dockerls/domain/value_objects/registry_audit.py +195 -0
  106. dockerls/domain/value_objects/registry_target.py +225 -0
  107. dockerls/domain/value_objects/remediation_score.py +62 -0
  108. dockerls/domain/value_objects/scan_history.py +183 -0
  109. dockerls/domain/value_objects/scan_plan.py +193 -0
  110. dockerls/domain/value_objects/scanner_db.py +143 -0
  111. dockerls/domain/value_objects/security_score.py +160 -0
  112. dockerls/domain/value_objects/security_tier.py +122 -0
  113. dockerls/domain/value_objects/tag_history.py +180 -0
  114. dockerls/domain/value_objects/tool_release.py +253 -0
  115. dockerls/domain/value_objects/tristate.py +47 -0
  116. dockerls/domain/value_objects/vex.py +249 -0
  117. dockerls/exit_codes.py +23 -0
  118. dockerls/exporters/__init__.py +0 -0
  119. dockerls/exporters/base.py +17 -0
  120. dockerls/exporters/csv_exporter.py +85 -0
  121. dockerls/exporters/factory.py +31 -0
  122. dockerls/exporters/html_exporter.py +105 -0
  123. dockerls/exporters/json_exporter.py +19 -0
  124. dockerls/exporters/markdown_exporter.py +84 -0
  125. dockerls/exporters/sarif_exporter.py +245 -0
  126. dockerls/infrastructure/__init__.py +0 -0
  127. dockerls/infrastructure/config/__init__.py +0 -0
  128. dockerls/infrastructure/config/policy_file.py +165 -0
  129. dockerls/infrastructure/config/settings.py +197 -0
  130. dockerls/infrastructure/database/__init__.py +0 -0
  131. dockerls/infrastructure/database/models.py +78 -0
  132. dockerls/infrastructure/dockerfile_validator.py +1899 -0
  133. dockerls/infrastructure/evidence.py +99 -0
  134. dockerls/infrastructure/hashing.py +165 -0
  135. dockerls/infrastructure/logging/__init__.py +0 -0
  136. dockerls/infrastructure/logging/setup.py +98 -0
  137. dockerls/infrastructure/network/__init__.py +0 -0
  138. dockerls/infrastructure/network/guarded_client.py +107 -0
  139. dockerls/infrastructure/network/host_guard.py +117 -0
  140. dockerls/infrastructure/redaction.py +135 -0
  141. dockerls/infrastructure/templates/hardening/alpine.dockerfile +44 -0
  142. dockerls/infrastructure/templates/hardening/debian.dockerfile +45 -0
  143. dockerls/infrastructure/templates/hardening/distroless.dockerfile +34 -0
  144. dockerls/infrastructure/templates/hardening/go-alpine.dockerfile +52 -0
  145. dockerls/infrastructure/templates/hardening/go-debian.dockerfile +54 -0
  146. dockerls/infrastructure/templates/hardening/go-distroless.dockerfile +43 -0
  147. dockerls/infrastructure/templates/hardening/go-scratch.dockerfile +48 -0
  148. dockerls/infrastructure/templates/hardening/go.dockerfile +50 -0
  149. dockerls/infrastructure/templates/hardening/gradle-alpine.dockerfile +51 -0
  150. dockerls/infrastructure/templates/hardening/gradle.dockerfile +52 -0
  151. dockerls/infrastructure/templates/hardening/java-alpine.dockerfile +50 -0
  152. dockerls/infrastructure/templates/hardening/java-debian.dockerfile +50 -0
  153. dockerls/infrastructure/templates/hardening/java-distroless.dockerfile +39 -0
  154. dockerls/infrastructure/templates/hardening/java-ubuntu.dockerfile +54 -0
  155. dockerls/infrastructure/templates/hardening/java.dockerfile +60 -0
  156. dockerls/infrastructure/templates/hardening/maven-alpine.dockerfile +56 -0
  157. dockerls/infrastructure/templates/hardening/maven.dockerfile +57 -0
  158. dockerls/infrastructure/templates/hardening/node-alpine.dockerfile +47 -0
  159. dockerls/infrastructure/templates/hardening/node-debian.dockerfile +54 -0
  160. dockerls/infrastructure/templates/hardening/node-distroless.dockerfile +46 -0
  161. dockerls/infrastructure/templates/hardening/node-ubuntu.dockerfile +63 -0
  162. dockerls/infrastructure/templates/hardening/node.dockerfile +61 -0
  163. dockerls/infrastructure/templates/hardening/php-alpine.dockerfile +45 -0
  164. dockerls/infrastructure/templates/hardening/php-debian.dockerfile +45 -0
  165. dockerls/infrastructure/templates/hardening/php-ubuntu.dockerfile +49 -0
  166. dockerls/infrastructure/templates/hardening/php.dockerfile +44 -0
  167. dockerls/infrastructure/templates/hardening/python-alpine.dockerfile +51 -0
  168. dockerls/infrastructure/templates/hardening/python-debian.dockerfile +54 -0
  169. dockerls/infrastructure/templates/hardening/python-distroless.dockerfile +51 -0
  170. dockerls/infrastructure/templates/hardening/python-ubuntu.dockerfile +60 -0
  171. dockerls/infrastructure/templates/hardening/python.dockerfile +58 -0
  172. dockerls/infrastructure/templates/hardening/ruby-alpine.dockerfile +48 -0
  173. dockerls/infrastructure/templates/hardening/ruby-debian.dockerfile +50 -0
  174. dockerls/infrastructure/templates/hardening/rust-alpine.dockerfile +50 -0
  175. dockerls/infrastructure/templates/hardening/rust-debian.dockerfile +48 -0
  176. dockerls/infrastructure/templates/hardening/rust-scratch.dockerfile +44 -0
  177. dockerls/infrastructure/templates/hardening/rust.dockerfile +54 -0
  178. dockerls/infrastructure/templates/hardening/ubuntu.dockerfile +49 -0
  179. dockerls/infrastructure/toolchain/__init__.py +0 -0
  180. dockerls/infrastructure/toolchain/db_metadata.py +115 -0
  181. dockerls/infrastructure/toolchain/installer.py +435 -0
  182. dockerls/integrations/__init__.py +0 -0
  183. dockerls/integrations/dhi/__init__.py +0 -0
  184. dockerls/integrations/dhi/catalog.py +457 -0
  185. dockerls/integrations/dhi/definition.py +151 -0
  186. dockerls/integrations/dhi/repository.py +238 -0
  187. dockerls/integrations/dockerhub/__init__.py +0 -0
  188. dockerls/integrations/dockerhub/client.py +318 -0
  189. dockerls/integrations/dockerhub/urls.py +75 -0
  190. dockerls/integrations/endoflife/__init__.py +0 -0
  191. dockerls/integrations/endoflife/checker.py +216 -0
  192. dockerls/integrations/engine/__init__.py +0 -0
  193. dockerls/integrations/engine/batch.py +197 -0
  194. dockerls/integrations/engine/client.py +330 -0
  195. dockerls/integrations/engine/locator.py +96 -0
  196. dockerls/integrations/exploitdb/__init__.py +0 -0
  197. dockerls/integrations/exploitdb/client.py +271 -0
  198. dockerls/integrations/grype/__init__.py +0 -0
  199. dockerls/integrations/grype/scanner.py +336 -0
  200. dockerls/integrations/registry/__init__.py +0 -0
  201. dockerls/integrations/registry/hardened.py +284 -0
  202. dockerls/integrations/registry/inspector.py +420 -0
  203. dockerls/integrations/registry/oci.py +259 -0
  204. dockerls/integrations/registry/private.py +79 -0
  205. dockerls/integrations/registry/urls.py +36 -0
  206. dockerls/integrations/scan_errors.py +67 -0
  207. dockerls/integrations/scan_target.py +57 -0
  208. dockerls/integrations/signing/__init__.py +0 -0
  209. dockerls/integrations/signing/cosign.py +484 -0
  210. dockerls/integrations/threat_intel/__init__.py +0 -0
  211. dockerls/integrations/threat_intel/client.py +290 -0
  212. dockerls/integrations/trivy/__init__.py +0 -0
  213. dockerls/integrations/trivy/cache_pool.py +176 -0
  214. dockerls/integrations/trivy/scanner.py +447 -0
  215. dockerls/utils/__init__.py +0 -0
  216. dockerls/utils/auth.py +108 -0
  217. dockerls/utils/executables.py +39 -0
  218. dockerls/utils/ignore_file.py +130 -0
  219. dockerls/utils/rate_limit.py +130 -0
  220. dockerls/utils/resources.py +183 -0
  221. dockerls/utils/retry.py +31 -0
  222. dockerls/utils/safe_yaml.py +166 -0
  223. dockerls/utils/subprocess_runner.py +216 -0
  224. dockerls/utils/validation.py +72 -0
  225. dockerls-1.0.0.dist-info/METADATA +563 -0
  226. dockerls-1.0.0.dist-info/RECORD +230 -0
  227. dockerls-1.0.0.dist-info/WHEEL +5 -0
  228. dockerls-1.0.0.dist-info/entry_points.txt +2 -0
  229. dockerls-1.0.0.dist-info/licenses/LICENSE +21 -0
  230. 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