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,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