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,447 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import shutil
6
+ from datetime import UTC, datetime
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from loguru import logger
10
+
11
+ from dockerls.domain.entities.scan_result import ScanErrorKind, ScanResult, ScanStatus
12
+ from dockerls.domain.entities.vulnerability import Severity, Vulnerability
13
+ from dockerls.domain.interfaces.scanner import ScannerInterface
14
+ from dockerls.integrations.engine.batch import EngineBatchScanner
15
+ from dockerls.integrations.scan_errors import classify_scanner_error
16
+ from dockerls.integrations.scan_target import blocked_scan_result, blocked_target_reason
17
+ from dockerls.integrations.trivy.cache_pool import TrivyCachePool, default_trivy_cache_dir
18
+ from dockerls.utils.executables import ExecutableNotFoundError, resolve_executable
19
+ from dockerls.utils.subprocess_runner import (
20
+ VERSION_TIMEOUT_SECONDS,
21
+ OutputTooLargeError,
22
+ run_capture,
23
+ )
24
+ from dockerls.utils.validation import sanitize_image_name
25
+
26
+ if TYPE_CHECKING:
27
+ from pathlib import Path
28
+
29
+ from dockerls.infrastructure.evidence import EvidenceStore
30
+ from dockerls.infrastructure.network.host_guard import HostGuard
31
+
32
+
33
+ class TrivyScanner(ScannerInterface):
34
+ def __init__(
35
+ self,
36
+ timeout: int = 300,
37
+ skip_db_update: bool = False,
38
+ cache_dir: Path | None = None,
39
+ workers: int = 1,
40
+ evidence: EvidenceStore | None = None,
41
+ guard: HostGuard | None = None,
42
+ ):
43
+ self._timeout = timeout
44
+ self._skip_db_update = skip_db_update
45
+ self._workers = max(1, workers)
46
+ self._cache_pool = TrivyCachePool(cache_dir or default_trivy_cache_dir(), workers)
47
+ self._evidence = evidence
48
+ self._version: str | None = None
49
+ self._batch: EngineBatchScanner | None = None
50
+ # Trivy performs its own pull, so the reference has to clear the
51
+ # network policy here or it never clears it at all.
52
+ self._guard = guard
53
+
54
+ @property
55
+ def cache_pool(self) -> TrivyCachePool:
56
+ return self._cache_pool
57
+
58
+ @property
59
+ def batch(self) -> EngineBatchScanner:
60
+ """O caminho em lote, quando a engine Go estiver disponível."""
61
+ if self._batch is None:
62
+ self._batch = EngineBatchScanner(
63
+ scanner="trivy",
64
+ timeout_seconds=float(self._timeout),
65
+ skip_db_update=self._skip_db_update,
66
+ workers=self._workers,
67
+ guard=self._guard,
68
+ evidence=self._evidence,
69
+ raw_dir=self._evidence_raw_dir(),
70
+ cache_dirs=self._cache_pool.slot_paths,
71
+ )
72
+ return self._batch
73
+
74
+ def _evidence_raw_dir(self) -> Path | None:
75
+ """Onde a engine deposita o JSON cru para este lado redigir.
76
+
77
+ `None` quando a evidência está desligada: sem ninguém para ler e
78
+ arquivar, gravar o documento **não redigido** em disco seria deixar
79
+ para trás exatamente o que a redação existe para não deixar.
80
+ """
81
+ if self._evidence is None:
82
+ return None
83
+ return self._cache_pool.base_dir / "engine-raw"
84
+
85
+ async def is_available(self) -> bool:
86
+ return shutil.which("trivy") is not None
87
+
88
+ async def version(self) -> str:
89
+ """The scanner's own version string, memoised for the run.
90
+
91
+ Recorded so an analysis can be reconstructed: a score produced by
92
+ trivy 0.48 and one produced by 0.58 are different measurements of
93
+ the same image, and until this existed nothing wrote down which one
94
+ happened. It is also what keeps a cached result from being served
95
+ across a scanner upgrade.
96
+
97
+ Returns "" when the binary is missing or does not answer -- an
98
+ unknown version, which the cache key treats as its own value rather
99
+ than as "same as before".
100
+ """
101
+ if self._version is not None:
102
+ return self._version
103
+ self._version = await self._read_version()
104
+ return self._version
105
+
106
+ async def _read_version(self) -> str:
107
+ try:
108
+ returncode, stdout, _ = await run_capture(
109
+ [resolve_executable("trivy"), "--version"], timeout=VERSION_TIMEOUT_SECONDS
110
+ )
111
+ except (TimeoutError, OSError, ExecutableNotFoundError) as e:
112
+ logger.debug(f"Could not read trivy version: {e}")
113
+ return ""
114
+ if returncode != 0 or not stdout:
115
+ return ""
116
+ # `<tool> --version` prints a banner; the first line carries the
117
+ # version and the rest is database metadata that changes on its own
118
+ # schedule and would churn the cache key for no reason.
119
+ return stdout.decode(errors="replace").strip().splitlines()[0].strip()
120
+
121
+ def _cache_args(self, cache_dir: Path) -> list[str]:
122
+ return ["--cache-dir", str(cache_dir)]
123
+
124
+ async def generate_sbom(self, image_reference: str, fmt: str = "cyclonedx") -> str | None:
125
+ """Generate an SBOM for `image_reference` using Trivy's built-in
126
+ generators. `fmt` is one of "cyclonedx" or "spdx-json"."""
127
+ if fmt not in ("cyclonedx", "spdx-json"):
128
+ raise ValueError(f"Unsupported SBOM format: {fmt}")
129
+
130
+ safe_ref = sanitize_image_name(image_reference)
131
+ blocked = blocked_target_reason(safe_ref, self._guard)
132
+ if blocked:
133
+ logger.warning(f"Refusing to generate an SBOM for {safe_ref}: {blocked}")
134
+ return None
135
+
136
+ async with self._cache_pool.acquire() as cache_dir:
137
+ try:
138
+ cmd = [
139
+ resolve_executable("trivy"),
140
+ "image",
141
+ "--format",
142
+ fmt,
143
+ "--quiet",
144
+ *self._cache_args(cache_dir),
145
+ ]
146
+ if self._skip_db_update:
147
+ cmd.append("--skip-db-update")
148
+ cmd.append(safe_ref)
149
+
150
+ returncode, stdout, stderr = await run_capture(cmd, timeout=self._timeout)
151
+ if returncode != 0 or not stdout:
152
+ logger.error(f"SBOM generation failed for {safe_ref}: {stderr.decode()[:300]}")
153
+ return None
154
+ return stdout.decode()
155
+ except (TimeoutError, OSError, ExecutableNotFoundError) as e:
156
+ logger.error(f"SBOM generation failed for {safe_ref}: {e}")
157
+ return None
158
+
159
+ #: Tentativas do download da DB antes de desistir. A baixa vem do GHCR e
160
+ #: falha de forma transitória com muito mais frequência que um scan: rate
161
+ #: limit, corte de conexão no meio de centenas de MB, 5xx do registry.
162
+ DB_DOWNLOAD_ATTEMPTS = 3
163
+ DB_BACKOFF_SECONDS = 2.0
164
+
165
+ async def refresh_db(self) -> bool:
166
+ """Download the vulnerability DB once, up front, then build the
167
+ per-worker cache dir pool.
168
+
169
+ Doing the download here (rather than letting the first scan trigger
170
+ it) is what makes `--skip-db-update` safe for every subsequent scan,
171
+ and it removes the single biggest source of cache lock contention.
172
+
173
+ Retorna False quando a DB não ficou pronta -- e isso **importa**: sem
174
+ ela, `_skip_db_update` continua False e cada worker sai baixando a
175
+ própria cópia em paralelo, que é precisamente a corrida que produz
176
+ `init error: DB error` em série. Quem chama precisa tratar o False.
177
+ """
178
+ base = self._cache_pool.base_dir
179
+ for attempt in range(1, self.DB_DOWNLOAD_ATTEMPTS + 1):
180
+ ok, detail = await self._download_db(base)
181
+ if ok:
182
+ break
183
+ if attempt == self.DB_DOWNLOAD_ATTEMPTS:
184
+ logger.warning(
185
+ f"Trivy DB refresh failed after {attempt} attempts: {detail}. "
186
+ "Scans will be unable to skip the DB update."
187
+ )
188
+ return False
189
+ wait = self.DB_BACKOFF_SECONDS * (2 ** (attempt - 1))
190
+ logger.warning(
191
+ f"Trivy DB refresh attempt {attempt}/{self.DB_DOWNLOAD_ATTEMPTS} "
192
+ f"failed ({detail}); retrying in {wait:.0f}s"
193
+ )
194
+ await asyncio.sleep(wait)
195
+
196
+ self._skip_db_update = True
197
+ isolated = await self._cache_pool.prepare()
198
+ logger.info(
199
+ f"Trivy DB ready at {base}; "
200
+ f"cache isolation {'enabled' if isolated else 'unavailable (scans serialized)'}"
201
+ )
202
+ return True
203
+
204
+ async def _download_db(self, base: Path) -> tuple[bool, str]:
205
+ """One `--download-db-only` attempt. Returns (ok, detail)."""
206
+ try:
207
+ returncode, _, stderr = await run_capture(
208
+ [
209
+ resolve_executable("trivy"),
210
+ "image",
211
+ "--download-db-only",
212
+ "--quiet",
213
+ *self._cache_args(base),
214
+ ],
215
+ timeout=self._timeout,
216
+ )
217
+ except ExecutableNotFoundError as e:
218
+ # Binário ausente não melhora com repetição.
219
+ return False, str(e)
220
+ except (TimeoutError, OSError) as e:
221
+ return False, str(e)
222
+ if returncode != 0:
223
+ return False, stderr.decode(errors="replace")[:200]
224
+ return True, ""
225
+
226
+ async def close(self) -> None:
227
+ await self._cache_pool.cleanup()
228
+
229
+ async def scan(self, image_reference: str) -> ScanResult:
230
+ safe_ref = sanitize_image_name(image_reference)
231
+ blocked = blocked_target_reason(safe_ref, self._guard)
232
+ if blocked:
233
+ return blocked_scan_result(safe_ref, "trivy", blocked)
234
+ logger.info(f"Scanning {safe_ref} with Trivy")
235
+ timestamp = datetime.now(tz=UTC).isoformat()
236
+
237
+ async with self._cache_pool.acquire() as cache_dir:
238
+ try:
239
+ cmd = [
240
+ resolve_executable("trivy"),
241
+ "image",
242
+ "--format",
243
+ "json",
244
+ "--severity",
245
+ "CRITICAL,HIGH,MEDIUM,LOW",
246
+ "--quiet",
247
+ *self._cache_args(cache_dir),
248
+ ]
249
+ if self._skip_db_update:
250
+ # A DB de Java é baixada separadamente da principal. Sem
251
+ # este par, o `--download-db-only` do warm-up cobria só
252
+ # metade: cada worker ainda saía para a rede buscar a
253
+ # java-db, que é a corrida que o pool de cache existe
254
+ # para eliminar.
255
+ cmd.extend(["--skip-db-update", "--skip-java-db-update"])
256
+ cmd.append(safe_ref)
257
+
258
+ returncode, stdout, stderr = await run_capture(cmd, timeout=self._timeout)
259
+
260
+ if returncode != 0:
261
+ # Trivy writes its own diagnostics to stderr; they are
262
+ # captured into the log file and folded into the run
263
+ # summary rather than dumped raw onto the terminal.
264
+ err = stderr.decode(errors="replace")[:500]
265
+ logger.error(f"Trivy returned code {returncode} for {safe_ref}: {err}")
266
+ return ScanResult(
267
+ image_reference=safe_ref,
268
+ scanner="trivy",
269
+ scan_timestamp=timestamp,
270
+ status=ScanStatus.ERROR,
271
+ error_message=err,
272
+ error_kind=classify_scanner_error(err),
273
+ )
274
+
275
+ if not stdout:
276
+ return ScanResult(
277
+ image_reference=safe_ref,
278
+ scanner="trivy",
279
+ scan_timestamp=timestamp,
280
+ status=ScanStatus.ERROR,
281
+ error_message="Trivy produced no output",
282
+ error_kind=ScanErrorKind.INVALID_OUTPUT,
283
+ )
284
+
285
+ raw = stdout.decode()
286
+ data = json.loads(raw)
287
+ result = self._parse_results(safe_ref, data)
288
+ if self._evidence is not None:
289
+ result.evidence_path = await self._evidence.record_scan(safe_ref, "trivy", raw)
290
+ return result
291
+
292
+ except TimeoutError:
293
+ logger.error(f"Trivy scan timed out for {safe_ref}")
294
+ return ScanResult(
295
+ image_reference=safe_ref,
296
+ scanner="trivy",
297
+ scan_timestamp=timestamp,
298
+ status=ScanStatus.TIMEOUT,
299
+ error_message=f"Scan exceeded {self._timeout}s timeout",
300
+ error_kind=ScanErrorKind.TIMEOUT,
301
+ )
302
+ except OutputTooLargeError as e:
303
+ # Unbounded output is not a measurement: the document was
304
+ # never read in full, so there is nothing to parse and
305
+ # nothing to conclude.
306
+ logger.error(f"{'trivy'} output exceeded the size limit for {safe_ref}: {e}")
307
+ return ScanResult(
308
+ image_reference=safe_ref,
309
+ scanner="trivy",
310
+ scan_timestamp=timestamp,
311
+ status=ScanStatus.ERROR,
312
+ error_message=str(e),
313
+ error_kind=ScanErrorKind.INVALID_OUTPUT,
314
+ )
315
+ except json.JSONDecodeError as e:
316
+ logger.error(f"Trivy produced unparseable JSON for {safe_ref}: {e}")
317
+ return ScanResult(
318
+ image_reference=safe_ref,
319
+ scanner="trivy",
320
+ scan_timestamp=timestamp,
321
+ status=ScanStatus.ERROR,
322
+ error_message=str(e),
323
+ error_kind=ScanErrorKind.INVALID_OUTPUT,
324
+ )
325
+ except ExecutableNotFoundError as e:
326
+ logger.error(f"Trivy scan failed for {safe_ref}: {e}")
327
+ return ScanResult(
328
+ image_reference=safe_ref,
329
+ scanner="trivy",
330
+ scan_timestamp=timestamp,
331
+ status=ScanStatus.ERROR,
332
+ error_message=str(e),
333
+ error_kind=ScanErrorKind.SCANNER_MISSING,
334
+ )
335
+ except OSError as e:
336
+ logger.error(f"Trivy scan failed for {safe_ref}: {e}")
337
+ return ScanResult(
338
+ image_reference=safe_ref,
339
+ scanner="trivy",
340
+ scan_timestamp=timestamp,
341
+ status=ScanStatus.ERROR,
342
+ error_message=str(e),
343
+ error_kind=classify_scanner_error(str(e)),
344
+ )
345
+
346
+ def _parse_results(self, image_ref: str, data: dict[str, Any]) -> ScanResult:
347
+ vulns: list[Vulnerability] = []
348
+ for result in data.get("Results", []):
349
+ # `Type` distingue pacote de SO ("alpine", "debian") de pacote de
350
+ # linguagem ("node-pkg", "python-pkg"); `Target` diz onde ele mora.
351
+ pkg_type = str(result.get("Class") or "") or str(result.get("Type") or "")
352
+ target = str(result.get("Target") or "")
353
+ for v in result.get("Vulnerabilities", []):
354
+ sev_str = v.get("Severity", "UNKNOWN").upper()
355
+ try:
356
+ severity = Severity(sev_str)
357
+ except ValueError:
358
+ severity = Severity.UNKNOWN
359
+
360
+ score, source = self._extract_cvss(v)
361
+ vulns.append(
362
+ Vulnerability(
363
+ cve_id=v.get("VulnerabilityID", ""),
364
+ severity=severity,
365
+ cvss_score=score,
366
+ cvss_source=source,
367
+ package_name=v.get("PkgName", ""),
368
+ installed_version=v.get("InstalledVersion", ""),
369
+ fixed_version=v.get("FixedVersion", ""),
370
+ description=v.get("Title", "")[:200],
371
+ published_date=v.get("PublishedDate", ""),
372
+ package_type=pkg_type,
373
+ target=target,
374
+ )
375
+ )
376
+
377
+ family, version = self._parse_os(data)
378
+ return ScanResult(
379
+ image_reference=image_ref,
380
+ scanner="trivy",
381
+ vulnerabilities=vulns,
382
+ scan_timestamp=datetime.now(tz=UTC).isoformat(),
383
+ os_family=family,
384
+ os_version=version,
385
+ )
386
+
387
+ @staticmethod
388
+ def _parse_os(data: dict[str, Any]) -> tuple[str, str]:
389
+ """The base distribution Trivy identified, from `Metadata.OS`.
390
+
391
+ Reported by the scanner rather than guessed from the tag: a tag
392
+ called `-alpine` is a naming convention, while this is what the
393
+ package database inside the image actually is.
394
+ """
395
+ metadata = data.get("Metadata")
396
+ os_block = metadata.get("OS") if isinstance(metadata, dict) else None
397
+ if not isinstance(os_block, dict):
398
+ return "", ""
399
+ return str(os_block.get("Family") or ""), str(os_block.get("Name") or "")
400
+
401
+ # Ordem de desempate quando a base que definiu a severidade não publica
402
+ # CVSS (Debian, Alpine e Ubuntu classificam sem pontuar). NVD primeiro por
403
+ # ser a canônica; o resto é determinístico só para que o mesmo achado
404
+ # produza sempre o mesmo número.
405
+ _CVSS_SOURCE_PRIORITY = ("nvd", "redhat", "ghsa", "amazon", "photon", "oracle-oval")
406
+
407
+ def _extract_cvss(self, vuln_data: dict[str, Any]) -> tuple[float, str]:
408
+ """Return (score, source), preferring the base that set the severity.
409
+
410
+ O Trivy define `Severity` pela fonte em `SeveritySource` -- em geral o
411
+ vendor da distro -- enquanto o bloco `CVSS` traz o score de várias
412
+ bases ao mesmo tempo. Pegar a severidade de uma e o número de outra
413
+ produzia linhas como `CRITICAL ... 7.5`, que pelo CVSS v3 é uma
414
+ contradição (CRITICAL começa em 9.0). Não era erro de conta: eram duas
415
+ bases diferentes exibidas como se fossem uma. Casar as duas pontas --
416
+ e dizer qual base respondeu -- é o que torna o número conferível.
417
+ """
418
+ cvss = vuln_data.get("CVSS")
419
+ if not isinstance(cvss, dict) or not cvss:
420
+ return 0.0, ""
421
+
422
+ severity_source = str(vuln_data.get("SeveritySource") or "").strip().lower()
423
+ candidates: list[str] = []
424
+ if severity_source:
425
+ candidates.append(severity_source)
426
+ candidates.extend(s for s in self._CVSS_SOURCE_PRIORITY if s != severity_source)
427
+ candidates.extend(k for k in cvss if k not in candidates)
428
+
429
+ for source in candidates:
430
+ score = self._score_from_entry(cvss.get(source))
431
+ if score is not None:
432
+ return score, source
433
+ return 0.0, ""
434
+
435
+ @staticmethod
436
+ def _score_from_entry(entry: dict[str, Any] | None) -> float | None:
437
+ if not isinstance(entry, dict):
438
+ return None
439
+ for key in ("V4Score", "V3Score"):
440
+ value = entry.get(key)
441
+ if value is None:
442
+ continue
443
+ try:
444
+ return float(value)
445
+ except (TypeError, ValueError):
446
+ continue
447
+ return None
File without changes
dockerls/utils/auth.py ADDED
@@ -0,0 +1,108 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import os
5
+ import sys
6
+ from typing import TYPE_CHECKING, TypeVar
7
+
8
+ from loguru import logger
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Callable, Iterator
12
+
13
+ # A keyring backend is arbitrary third-party code loaded at call time. It can
14
+ # fail in ways that are *not* `Exception`: the `cryptography` backend used by
15
+ # SecretService is a Rust extension, and a broken install raises
16
+ # `pyo3_runtime.PanicException`, which derives straight from `BaseException`.
17
+ # Catching only `Exception` there let a broken keyring take down every command
18
+ # that merely tries to *read* optional credentials. Credentials are optional
19
+ # enrichment, never a reason to abort, so the guard is widened -- while still
20
+ # letting the two BaseExceptions that must always propagate through.
21
+ _MUST_PROPAGATE = (KeyboardInterrupt, SystemExit, MemoryError)
22
+
23
+ T = TypeVar("T")
24
+
25
+
26
+ @contextlib.contextmanager
27
+ def _quiet_stderr() -> Iterator[None]:
28
+ """Silence writes to file descriptor 2 for the duration of the block.
29
+
30
+ Catching the failure is not enough to keep the terminal clean. Keyring's
31
+ backend discovery writes its diagnostics *below* Python: the missing
32
+ `_cffi_backend` import and pyo3's `thread '<unnamed>' panicked at ...`
33
+ banner are emitted straight to fd 2 by the C and Rust runtimes before
34
+ any Python exception exists to be caught. On a machine with no keyring
35
+ backend -- a container or a CI runner, which is where this tool mostly
36
+ runs -- that noise printed ahead of the results of *every* command and
37
+ read like a crash.
38
+
39
+ Only the raw fd is redirected, and only around the keyring call. The
40
+ reason for the failure is not lost: it is logged (through loguru's own
41
+ sinks) after the fd is restored.
42
+
43
+ Degrades to a no-op if stderr has no real file descriptor, which is the
44
+ case under pytest's capture and in some embedded runners.
45
+ """
46
+ try:
47
+ fd = sys.stderr.fileno()
48
+ except (AttributeError, OSError, ValueError):
49
+ yield
50
+ return
51
+
52
+ sys.stderr.flush()
53
+ saved = os.dup(fd)
54
+ devnull = os.open(os.devnull, os.O_WRONLY)
55
+ try:
56
+ os.dup2(devnull, fd)
57
+ yield
58
+ finally:
59
+ sys.stderr.flush()
60
+ os.dup2(saved, fd)
61
+ os.close(saved)
62
+ os.close(devnull)
63
+
64
+
65
+ def _guarded(operation: str, action: Callable[[], T], default: T, *, level: str = "DEBUG") -> T:
66
+ """Run a keyring `action`, returning `default` if the backend misbehaves."""
67
+ try:
68
+ with _quiet_stderr():
69
+ return action()
70
+ except _MUST_PROPAGATE:
71
+ raise
72
+ except BaseException as e:
73
+ logger.log(level, f"{operation}: {type(e).__name__}: {e}")
74
+ return default
75
+
76
+
77
+ def store_credentials(username: str, token: str) -> bool:
78
+ def action() -> bool:
79
+ import keyring
80
+
81
+ keyring.set_password("dockerls", "username", username)
82
+ keyring.set_password("dockerls", "token", token)
83
+ return True
84
+
85
+ # A failed *store* is worth a warning: the user asked for it explicitly.
86
+ return _guarded("Keyring storage failed", action, False, level="WARNING")
87
+
88
+
89
+ def load_credentials() -> tuple[str, str]:
90
+ def action() -> tuple[str, str]:
91
+ import keyring
92
+
93
+ username = keyring.get_password("dockerls", "username") or ""
94
+ token = keyring.get_password("dockerls", "token") or ""
95
+ return username, token
96
+
97
+ return _guarded("Keyring unavailable, continuing anonymously", action, ("", ""))
98
+
99
+
100
+ def clear_credentials() -> bool:
101
+ def action() -> bool:
102
+ import keyring
103
+
104
+ keyring.delete_password("dockerls", "username")
105
+ keyring.delete_password("dockerls", "token")
106
+ return True
107
+
108
+ return _guarded("Keyring deletion failed", action, False)
@@ -0,0 +1,39 @@
1
+ """Absolute-path resolution for the external tools DockerLs shells out to.
2
+
3
+ Invoking `docker`, `trivy` or `grype` by bare name hands the choice of binary
4
+ to `$PATH`, so any writable directory earlier in the search order decides what
5
+ runs. That is PATH hijacking -- the same class of finding this tool reports on
6
+ other people's images -- and a security scanner is a particularly good thing to
7
+ hijack, since its verdict is what a pipeline trusts.
8
+
9
+ Every subprocess call resolves the name here first and invokes the absolute
10
+ path that comes back, or fails with a message naming the tool that is missing.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import shutil
16
+
17
+
18
+ class ExecutableNotFoundError(RuntimeError):
19
+ """A required external tool is not installed, or not on `$PATH`."""
20
+
21
+ def __init__(self, name: str) -> None:
22
+ super().__init__(
23
+ f"'{name}' was not found on PATH. Install it (see `dockerls doctor`) "
24
+ f"and make sure it is on the PATH of the process running dockerls."
25
+ )
26
+ self.name = name
27
+
28
+
29
+ def resolve_executable(name: str) -> str:
30
+ """Return the absolute path of `name`, or raise `ExecutableNotFoundError`.
31
+
32
+ The absolute path is what gets executed: resolving and then invoking the
33
+ bare name again would reintroduce the lookup this function exists to pin
34
+ down.
35
+ """
36
+ path = shutil.which(name)
37
+ if path is None:
38
+ raise ExecutableNotFoundError(name)
39
+ return path