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,216 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from datetime import UTC, datetime
5
+ from typing import Any, cast
6
+
7
+ import httpx
8
+ from loguru import logger
9
+
10
+ from dockerls.domain.interfaces.eol_checker import EOLCheckerInterface
11
+ from dockerls.domain.value_objects.tristate import Tristate
12
+ from dockerls.utils.retry import (
13
+ DEFAULT_BACKOFF_BASE,
14
+ DEFAULT_MAX_ATTEMPTS,
15
+ retry_policy,
16
+ )
17
+
18
+ # Docker Hub image name -> endoflife.date product slug. Docker Hub names and
19
+ # endoflife.date slugs frequently diverge (e.g. "node" vs "nodejs").
20
+ DOCKER_TO_ENDOFLIFE: dict[str, str] = {
21
+ "node": "nodejs",
22
+ "python": "python",
23
+ "postgres": "postgresql",
24
+ "postgresql": "postgresql",
25
+ "mysql": "mysql",
26
+ "mariadb": "mariadb",
27
+ "redis": "redis",
28
+ "nginx": "nginx",
29
+ "httpd": "apache",
30
+ "php": "php",
31
+ "ruby": "ruby",
32
+ "golang": "go",
33
+ "go": "go",
34
+ "django": "django",
35
+ "rails": "rails",
36
+ "ubuntu": "ubuntu",
37
+ "debian": "debian",
38
+ "alpine": "alpine",
39
+ "elasticsearch": "elasticsearch",
40
+ "rabbitmq": "rabbitmq",
41
+ "mongo": "mongodb",
42
+ "mongodb": "mongodb",
43
+ "dotnet": "dotnet",
44
+ "grafana": "grafana",
45
+ "prometheus": "prometheus",
46
+ "kafka": "kafka",
47
+ "cassandra": "cassandra",
48
+ "erlang": "erlang",
49
+ "haproxy": "haproxy",
50
+ "jenkins": "jenkins",
51
+ "traefik": "traefik",
52
+ }
53
+
54
+
55
+ def _version_parts(version: str) -> tuple[int, ...]:
56
+ """Parse the leading numeric dot-separated segments of a version string."""
57
+ parts: list[int] = []
58
+ for segment in version.split("."):
59
+ if segment.isdigit():
60
+ parts.append(int(segment))
61
+ else:
62
+ break
63
+ return tuple(parts)
64
+
65
+
66
+ def _cycle_matches(version: str, cycle: str) -> bool:
67
+ """True if `version` falls under endoflife.date `cycle`, using SemVer-aware
68
+ segment comparison (not naive substring prefix matching)."""
69
+ vparts = _version_parts(version)
70
+ cparts = _version_parts(cycle)
71
+ if not vparts or not cparts:
72
+ return version == cycle
73
+ if len(cparts) > len(vparts):
74
+ return False
75
+ return vparts[: len(cparts)] == cparts
76
+
77
+
78
+ class EndOfLifeChecker(EOLCheckerInterface):
79
+ BASE_URL = "https://endoflife.date/api"
80
+
81
+ def __init__(
82
+ self,
83
+ timeout: int = 15,
84
+ max_attempts: int = DEFAULT_MAX_ATTEMPTS,
85
+ backoff_base: float = DEFAULT_BACKOFF_BASE,
86
+ ):
87
+ self._timeout = timeout
88
+ self._max_attempts = max_attempts
89
+ self._backoff_base = backoff_base
90
+ self._cache: dict[str, list[dict[str, Any]]] = {}
91
+ # One lock per product slug. The memo below only closes the window
92
+ # *after* a response lands, and `recommend` asks `is_eol` + `is_lts`
93
+ # for ~100 tags concurrently -- so a single-product run fired up to
94
+ # 200 identical requests before the first one returned. That is the
95
+ # rate limiting (and the resulting inconsistent EOL verdicts within
96
+ # one run) that the memo was added to stop.
97
+ self._locks: dict[str, asyncio.Lock] = {}
98
+
99
+ def _resolve_product(self, product: str) -> str:
100
+ return DOCKER_TO_ENDOFLIFE.get(product.lower(), product.lower())
101
+
102
+ async def _fetch_product(self, product: str) -> list[dict[str, Any]]:
103
+ slug = self._resolve_product(product)
104
+ cached = self._cache.get(slug)
105
+ if cached is not None:
106
+ return cached
107
+
108
+ lock = self._locks.setdefault(slug, asyncio.Lock())
109
+ async with lock:
110
+ cached = self._cache.get(slug)
111
+ if cached is not None:
112
+ return cached
113
+ policy = retry_policy(self._max_attempts, self._backoff_base)
114
+ result: list[dict[str, Any]] = await policy(self._fetch_product_once, product)
115
+ return result
116
+
117
+ async def _fetch_product_once(self, product: str) -> list[dict[str, Any]]:
118
+ slug = self._resolve_product(product)
119
+ if slug in self._cache:
120
+ return self._cache[slug]
121
+ async with httpx.AsyncClient(timeout=self._timeout) as client:
122
+ try:
123
+ resp = await client.get(f"{self.BASE_URL}/{slug}.json")
124
+ if resp.status_code == 200:
125
+ data = cast("list[dict[str, Any]]", resp.json())
126
+ self._cache[slug] = data
127
+ return data
128
+ if resp.status_code == 404:
129
+ # O produto não existe no catálogo -- uma resposta
130
+ # definitiva, e cacheável. Sem isso, cada uma das ~100
131
+ # tags de uma execução repetia a mesma consulta perdida
132
+ # (duas, contando is_eol e is_lts). Além do desperdício,
133
+ # o volume provocava rate limiting: parte das tags então
134
+ # recebia dados e parte recebia lista vazia, e a mesma
135
+ # execução emitia vereditos de EOL inconsistentes entre
136
+ # tags do mesmo produto.
137
+ self._cache[slug] = []
138
+ return []
139
+ logger.debug(f"EOL check for {slug} returned HTTP {resp.status_code}")
140
+ return []
141
+ except httpx.HTTPError as e:
142
+ logger.debug(f"EOL check failed for {slug}: {e}")
143
+ return []
144
+
145
+ def _find_cycle(self, cycles: list[dict[str, Any]], version: str) -> dict[str, Any] | None:
146
+ best: dict[str, Any] | None = None
147
+ best_len = -1
148
+ for cycle in cycles:
149
+ cycle_ver = str(cycle.get("cycle", ""))
150
+ if _cycle_matches(version, cycle_ver):
151
+ clen = len(_version_parts(cycle_ver))
152
+ if clen > best_len:
153
+ best = cycle
154
+ best_len = clen
155
+ return best
156
+
157
+ async def is_eol(self, product: str, version: str) -> bool:
158
+ """Whether this release is past its end-of-life date.
159
+
160
+ Kept as the boolean the interface has always published. It answers
161
+ `False` for "supported" *and* for "nobody could tell", which is why
162
+ `eol_status` exists -- callers that can represent the difference
163
+ should ask that instead.
164
+ """
165
+ return (await self.eol_status(product, version)).is_true
166
+
167
+ async def eol_status(self, product: str, version: str) -> Tristate:
168
+ """Three-valued end-of-life: TRUE, FALSE, or UNKNOWN.
169
+
170
+ Every path that previously answered `False` had one of two very
171
+ different meanings behind it, and collapsing them meant an image
172
+ whose lifecycle nobody could look up was scored, and gated, exactly
173
+ like one confirmed to be inside its support window:
174
+
175
+ * no version could be extracted from the tag -- UNKNOWN;
176
+ * the product is not in the catalogue (404) -- UNKNOWN;
177
+ * the catalogue could not be reached -- UNKNOWN;
178
+ * no cycle matches this version -- UNKNOWN;
179
+ * the cycle exists and carries a date or a boolean -- TRUE/FALSE,
180
+ the only two answers actually supported by evidence.
181
+ """
182
+ if not version:
183
+ return Tristate.UNKNOWN
184
+ cycles = await self._fetch_product(product)
185
+ if not cycles:
186
+ # Empty covers "unknown product" and "request failed" alike;
187
+ # neither is a statement that the release is supported.
188
+ return Tristate.UNKNOWN
189
+ cycle = self._find_cycle(cycles, version)
190
+ if cycle is None:
191
+ return Tristate.UNKNOWN
192
+
193
+ eol = cycle.get("eol")
194
+ if isinstance(eol, bool):
195
+ return Tristate.of(eol)
196
+ if isinstance(eol, str):
197
+ try:
198
+ eol_date = datetime.strptime(eol, "%Y-%m-%d").replace(tzinfo=UTC)
199
+ except ValueError:
200
+ # A date the catalogue publishes but we cannot parse is a
201
+ # gap in our reading of it, not a supported release.
202
+ logger.debug(f"Unparseable EOL date for {product} {version}: {eol!r}")
203
+ return Tristate.UNKNOWN
204
+ return Tristate.of(datetime.now(tz=UTC) > eol_date)
205
+ # `eol: false` arrives as a bool; anything else (null, missing) means
206
+ # the catalogue has not set a date yet.
207
+ return Tristate.UNKNOWN
208
+
209
+ async def is_lts(self, product: str, version: str) -> bool:
210
+ if not version:
211
+ return False
212
+ cycles = await self._fetch_product(product)
213
+ cycle = self._find_cycle(cycles, version)
214
+ if cycle is None:
215
+ return False
216
+ return bool(cycle.get("lts", False))
File without changes
@@ -0,0 +1,197 @@
1
+ """Medir um lote com a engine Go, sem tirar nenhuma decisão do Python.
2
+
3
+ Este módulo é a fronteira, e ela é estreita de propósito. A engine recebe
4
+ referências **já sanitizadas e já aprovadas pela política de rede**, e
5
+ devolve resultados. Tudo que decide alguma coisa continua deste lado:
6
+
7
+ * `sanitize_image_name` e o `HostGuard` são aplicados aqui, antes de a
8
+ requisição existir. `trivy image X` e `grype X` abrem os próprios
9
+ sockets, e foi para cobrir esse buraco que `scan_target.py` existe -- um
10
+ lote que contornasse o guard abriria a mesma porta em nome de desempenho;
11
+ * `redact()` continua aqui. A engine guarda o JSON cru em arquivo `0600` e
12
+ devolve o caminho; quem decide o que vai para a evidência definitiva é o
13
+ `EvidenceStore`;
14
+ * nada de score, tier, EOL ou inteligência de ameaça atravessa.
15
+
16
+ Serve Trivy e Grype: o que difere entre os dois -- argv, forma do JSON,
17
+ diretório de cache versus variável de ambiente -- está inteiramente do lado
18
+ Go. Aqui os dois são o mesmo lote.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import contextlib
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+ from typing import TYPE_CHECKING
28
+
29
+ from loguru import logger
30
+
31
+ from dockerls.integrations.engine.client import EngineClient, EngineTarget
32
+ from dockerls.integrations.engine.locator import find_engine, probe
33
+ from dockerls.integrations.scan_target import blocked_scan_result, blocked_target_reason
34
+ from dockerls.utils.executables import ExecutableNotFoundError, resolve_executable
35
+ from dockerls.utils.validation import sanitize_image_name
36
+
37
+ if TYPE_CHECKING:
38
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
39
+
40
+ from dockerls.domain.entities.scan_result import ScanResult
41
+ from dockerls.infrastructure.evidence import EvidenceStore
42
+ from dockerls.infrastructure.network.host_guard import HostGuard
43
+ from dockerls.integrations.engine.client import EngineOutcome
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class BatchOutcome:
48
+ """O que um lote medido pela engine produziu."""
49
+
50
+ #: Um resultado por alvo, na ordem em que foram pedidos.
51
+ results: list[ScanResult]
52
+ #: Scans que de fato aconteceram -- irmãos do mesmo digest não contam.
53
+ scans_performed: int
54
+ #: Alvos servidos pelo digest de um irmão.
55
+ duplicates_collapsed: int
56
+ #: Quanto o lote levou, medido pela engine.
57
+ wall_seconds: float
58
+
59
+
60
+ class EngineBatchScanner:
61
+ """Mede um lote inteiro com uma travessia de processo, não N.
62
+
63
+ O ganho é bom ser exato sobre qual é: Trivy e Grype já são Go, e cada
64
+ scan continua custando os mesmos 1,2-2,5s. O que muda é o entorno --
65
+ criar e colher N processos, revezar o diretório de cache, coordenar o
66
+ dedup por digest --, que sai de N travessias Python<->processo para uma.
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ *,
72
+ scanner: str,
73
+ timeout_seconds: float,
74
+ skip_db_update: bool,
75
+ workers: int,
76
+ guard: HostGuard | None = None,
77
+ evidence: EvidenceStore | None = None,
78
+ raw_dir: Path | None = None,
79
+ cache_dirs: Callable[[], Awaitable[Sequence[Path]]] | None = None,
80
+ env: Mapping[str, str] | None = None,
81
+ ) -> None:
82
+ self._scanner = scanner
83
+ self._timeout_seconds = timeout_seconds
84
+ self._skip_db_update = skip_db_update
85
+ self._workers = workers
86
+ self._guard = guard
87
+ self._evidence = evidence
88
+ self._raw_dir = raw_dir
89
+ self._cache_dirs = cache_dirs
90
+ self._env = dict(env or {})
91
+ self._client: EngineClient | None = None
92
+ self._resolved = False
93
+
94
+ def _resolve(self) -> EngineClient | None:
95
+ """Localiza o binário uma vez por processo.
96
+
97
+ `probe()` custa milissegundos e `find_engine()` toca o disco; fazer
98
+ isso por lote seria pagar a descoberta repetidamente por uma
99
+ resposta que não muda dentro de um run.
100
+ """
101
+ if self._resolved:
102
+ return self._client
103
+ self._resolved = True
104
+
105
+ path = find_engine()
106
+ if not path or not probe(path):
107
+ return None
108
+ try:
109
+ scanner_path = resolve_executable(self._scanner)
110
+ except ExecutableNotFoundError:
111
+ # Sem o scanner não há o que a engine dirija, e o caminho Python
112
+ # dará a mesma resposta -- SCANNER_MISSING -- por si.
113
+ return None
114
+
115
+ logger.info(f"Using the Go engine at {path} for {self._scanner} batch scans")
116
+ self._client = EngineClient(
117
+ engine_path=path,
118
+ scanner=self._scanner,
119
+ scanner_path=scanner_path,
120
+ timeout_seconds=self._timeout_seconds,
121
+ skip_db_update=self._skip_db_update,
122
+ raw_dir=self._raw_dir,
123
+ env=self._env,
124
+ )
125
+ return self._client
126
+
127
+ async def scan_batch(self, targets: Sequence[tuple[str, str]]) -> BatchOutcome | None:
128
+ """Mede `(referência, chave_de_dedup)`, ou None para usar o Python."""
129
+ client = self._resolve()
130
+ if client is None:
131
+ return None
132
+
133
+ # A política primeiro, e do lado de cá. Um alvo recusado é um
134
+ # resultado ERROR/BLOCKED_BY_POLICY como sempre foi, e não entra na
135
+ # requisição: o binário Go nunca vê a referência que a política
136
+ # negou.
137
+ blocked: dict[int, ScanResult] = {}
138
+ allowed: list[tuple[int, EngineTarget]] = []
139
+ for index, (reference, dedup_key) in enumerate(targets):
140
+ safe_ref = sanitize_image_name(reference)
141
+ reason = blocked_target_reason(safe_ref, self._guard)
142
+ if reason:
143
+ blocked[index] = blocked_scan_result(safe_ref, self._scanner, reason)
144
+ continue
145
+ allowed.append((index, EngineTarget(reference=safe_ref, dedup_key=dedup_key)))
146
+
147
+ cache_dirs = list(await self._cache_dirs()) if self._cache_dirs is not None else []
148
+ outcome = await client.scan_batch(
149
+ [target for _, target in allowed],
150
+ workers=self._workers,
151
+ cache_dirs=cache_dirs,
152
+ )
153
+ if outcome is None:
154
+ return None
155
+
156
+ results: list[ScanResult] = [None] * len(targets) # type: ignore[list-item]
157
+ for position, (index, _) in enumerate(allowed):
158
+ results[index] = outcome.results[position]
159
+ for index, result in blocked.items():
160
+ results[index] = result
161
+
162
+ await self._archive_evidence(outcome)
163
+ return BatchOutcome(
164
+ results=results,
165
+ scans_performed=outcome.scans_performed,
166
+ duplicates_collapsed=outcome.duplicates_collapsed,
167
+ wall_seconds=outcome.wall_seconds,
168
+ )
169
+
170
+ async def _archive_evidence(self, outcome: EngineOutcome) -> None:
171
+ """Lê o JSON cru que a engine guardou, redige e arquiva.
172
+
173
+ A redação continua aqui de propósito: `redact()` é um controle de
174
+ segurança, e uma segunda implementação dele em Go seria uma segunda
175
+ a divergir desta. A engine só guarda o arquivo; quem decide o que
176
+ vai para o disco definitivo é o EvidenceStore.
177
+ """
178
+ evidence = self._evidence
179
+ if evidence is None:
180
+ return
181
+ for result, raw_path in zip(outcome.results, outcome.raw_paths, strict=True):
182
+ if not raw_path:
183
+ continue
184
+ try:
185
+ raw = await asyncio.to_thread(Path(raw_path).read_text, encoding="utf-8")
186
+ except OSError as e:
187
+ logger.warning(f"Could not read engine evidence at {raw_path}: {e}")
188
+ continue
189
+ finally:
190
+ # O temporário sai do disco em qualquer caminho: ele é o
191
+ # documento não redigido, e deixá-lo para trás desfaria a
192
+ # redação que acabou de acontecer.
193
+ with contextlib.suppress(OSError):
194
+ await asyncio.to_thread(Path(raw_path).unlink)
195
+ result.evidence_path = await evidence.record_scan(
196
+ result.image_reference, self._scanner, raw
197
+ )