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,290 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import math
5
+ from typing import TYPE_CHECKING
6
+
7
+ import httpx
8
+ from loguru import logger
9
+
10
+ if TYPE_CHECKING:
11
+ from dockerls.domain.interfaces.cache_store import CacheStoreInterface
12
+
13
+ #: Smallest catalogue that could plausibly be the real KEV feed. It has
14
+ #: carried more than a thousand entries since 2023 and only grows, so a
15
+ #: floor an order of magnitude below that discriminates against proxy error
16
+ #: pages and truncated transfers, not against the feed.
17
+ MIN_PLAUSIBLE_KEV_ENTRIES = 100
18
+
19
+
20
+ def _probability(value: object) -> float | None:
21
+ """A finite 0.0-1.0 float, or None when the value is not one."""
22
+ try:
23
+ number = float(value) # type: ignore[arg-type]
24
+ except (TypeError, ValueError):
25
+ return None
26
+ if not math.isfinite(number) or not (0.0 <= number <= 1.0):
27
+ return None
28
+ return number
29
+
30
+
31
+ class ThreatIntelClient:
32
+ """Best-effort CISA KEV + FIRST EPSS lookups. Both sources are treated
33
+ as optional enrichment: any network/parse failure degrades to "no
34
+ signal" (empty set / 0.0 score) instead of breaking the scan."""
35
+
36
+ KEV_URL = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json"
37
+ EPSS_URL = "https://api.first.org/data/v1/epss"
38
+
39
+ # Both feeds move roughly daily -- KEV gets a handful of new entries a
40
+ # day, EPSS republishes once a day -- so a fresher copy buys nothing and
41
+ # costs a full re-download (KEV) or one HTTP round trip per CVE (EPSS)
42
+ # on every single invocation. Without this, `recommend` re-fetched the
43
+ # whole KEV catalogue and re-queried EPSS for every CRITICAL/HIGH CVE on
44
+ # every run, even back-to-back runs against the same image.
45
+ CACHE_TTL_SECONDS = 24 * 60 * 60
46
+ _KEV_CACHE_KEY = "threat-intel:kev:v1"
47
+ _EPSS_CACHE_PREFIX = "threat-intel:epss:v1:"
48
+
49
+ def __init__(
50
+ self,
51
+ timeout: int = 15,
52
+ cache: CacheStoreInterface | None = None,
53
+ min_kev_entries: int = MIN_PLAUSIBLE_KEV_ENTRIES,
54
+ ):
55
+ self._timeout = timeout
56
+ self._cache = cache
57
+ # Injectable so a test can exercise the parsing path with a small
58
+ # fixture without lowering the floor that protects real runs.
59
+ self._min_kev_entries = min_kev_entries
60
+ self._kev_ids: set[str] | None = None
61
+ # Whether each feed actually answered during this run. Without this,
62
+ # "no KEV hits" and "the KEV catalogue was unreachable" are the same
63
+ # empty set, and the caller cannot tell a negative finding from a
64
+ # missing lookup.
65
+ self._kev_available: bool | None = None
66
+ self._epss_available: bool | None = None
67
+ # EPSS percentiles, kept alongside the probabilities. A probability
68
+ # of 0.42 means little on its own; the percentile says where that
69
+ # sits among everything FIRST scored, which is what makes it
70
+ # comparable between runs on different days.
71
+ self._percentiles: dict[str, float] = {}
72
+ # `recommend` enriches every tag concurrently, and the memo below is
73
+ # only populated *after* the first download finishes. Without this
74
+ # lock, a 100-tag run started 100 simultaneous downloads of the same
75
+ # multi-megabyte KEV catalogue -- a self-inflicted burst against
76
+ # cisa.gov that the memo was written to prevent.
77
+ self._kev_lock = asyncio.Lock()
78
+
79
+ async def _load_kev(self) -> set[str]:
80
+ if self._kev_ids is not None:
81
+ return self._kev_ids
82
+ async with self._kev_lock:
83
+ if self._kev_ids is not None:
84
+ return self._kev_ids
85
+ cached = await self._kev_from_cache()
86
+ if cached is not None:
87
+ self._kev_available = True
88
+ self._kev_ids = cached
89
+ return self._kev_ids
90
+ self._kev_ids = await self._fetch_kev()
91
+ if self._kev_ids:
92
+ await self._store_kev_cache(self._kev_ids)
93
+ return self._kev_ids
94
+
95
+ async def _kev_from_cache(self) -> set[str] | None:
96
+ if self._cache is None:
97
+ return None
98
+ try:
99
+ data = await self._cache.get(self._KEV_CACHE_KEY)
100
+ except Exception as e: # pragma: no cover - an unreadable cache is a miss
101
+ logger.debug(f"Could not read the cached KEV catalogue: {e}")
102
+ return None
103
+ if not isinstance(data, list) or not data:
104
+ return None
105
+ return {str(cve).upper() for cve in data}
106
+
107
+ async def _store_kev_cache(self, ids: set[str]) -> None:
108
+ if self._cache is None:
109
+ return
110
+ try:
111
+ await self._cache.set(
112
+ self._KEV_CACHE_KEY, sorted(ids), ttl_seconds=self.CACHE_TTL_SECONDS
113
+ )
114
+ except Exception as e: # pragma: no cover - a cache that will not write is not fatal
115
+ logger.debug(f"Could not cache the KEV catalogue: {e}")
116
+
117
+ @property
118
+ def kev_available(self) -> bool | None:
119
+ """True once the KEV catalogue answered, False after it failed,
120
+ None before anything was asked."""
121
+ return self._kev_available
122
+
123
+ @property
124
+ def epss_available(self) -> bool | None:
125
+ """True once at least one EPSS batch answered; see `kev_available`."""
126
+ return self._epss_available
127
+
128
+ async def _fetch_kev(self) -> set[str]:
129
+ try:
130
+ async with httpx.AsyncClient(timeout=self._timeout) as client:
131
+ resp = await client.get(self.KEV_URL)
132
+ resp.raise_for_status()
133
+ data = resp.json()
134
+ entries = data.get("vulnerabilities", [])
135
+ if not isinstance(entries, list):
136
+ raise ValueError(f"KEV payload 'vulnerabilities' was {type(entries).__name__}")
137
+ ids = {
138
+ str(v.get("cveID", "")).upper()
139
+ for v in entries
140
+ if isinstance(v, dict) and v.get("cveID")
141
+ }
142
+ # A short catalogue is not a successful lookup either. The
143
+ # real feed carries thousands of entries, so a handful means
144
+ # a proxy error page, a truncated transfer or a captive
145
+ # portal parsed as JSON. Accepting it would mark every CVE
146
+ # not in that handful as `kev_status = FALSE` -- an
147
+ # affirmative "not known to be exploited" derived from a
148
+ # response that was never the catalogue.
149
+ self._kev_available = len(ids) >= self._min_kev_entries
150
+ if ids and not self._kev_available:
151
+ logger.warning(
152
+ f"CISA KEV answered with only {len(ids)} entries, far below the "
153
+ f"{self._min_kev_entries} a real catalogue carries; treating "
154
+ f"exploitation status as UNKNOWN rather than trusting it"
155
+ )
156
+ return set()
157
+ return ids
158
+ except (httpx.HTTPError, ValueError) as e:
159
+ logger.warning(
160
+ f"CISA KEV catalog unavailable: exploitation status will be UNKNOWN ({e})"
161
+ )
162
+ self._kev_available = False
163
+ return set()
164
+
165
+ async def known_exploited(self, cve_ids: list[str]) -> set[str]:
166
+ """Return the subset of `cve_ids` present in the CISA KEV catalog."""
167
+ if not cve_ids:
168
+ return set()
169
+ kev = await self._load_kev()
170
+ return {cve.upper() for cve in cve_ids if cve.upper() in kev}
171
+
172
+ # A API do FIRST pagina o resultado e a query vai na URL. Pedir 200 CVEs
173
+ # de uma vez devolvia calado só a primeira página -- e o restante perdia
174
+ # o sinal de EPSS justamente nas imagens que mais têm CRITICAL/HIGH, que
175
+ # são as que mais precisam dele. O lote é pedido com `limit` explícito
176
+ # em vez de confiar no default do serviço.
177
+ EPSS_BATCH_SIZE = 100
178
+
179
+ async def epss_scores(self, cve_ids: list[str]) -> dict[str, float]:
180
+ """Return {cve_id: epss_probability} for whatever FIRST.org returns;
181
+ missing/unreachable CVEs are simply absent from the result.
182
+
183
+ Checked against the cache first, one CVE at a time: `recommend`
184
+ enriches dozens of tags of the same image family, and their
185
+ CRITICAL/HIGH findings overlap heavily (the same OS package CVE
186
+ shows up in `node:20`, `node:22` and every variant of each). Without
187
+ this, every one of those tags re-queried FIRST.org for CVEs this
188
+ process, or an earlier run today, had already scored.
189
+ """
190
+ if not cve_ids:
191
+ return {}
192
+
193
+ wanted = sorted({cve.upper() for cve in cve_ids})
194
+ scores: dict[str, float] = {}
195
+ if self._cache is not None:
196
+ cached = await asyncio.gather(*[self._epss_from_cache(cve) for cve in wanted])
197
+ for cve, hit in zip(wanted, cached, strict=True):
198
+ if hit is not None:
199
+ scores[cve] = hit[0]
200
+ self._percentiles[cve] = hit[1]
201
+ missing = [cve for cve in wanted if cve not in scores]
202
+ if cached_hit := bool(scores):
203
+ self._epss_available = True
204
+
205
+ if missing:
206
+ async with httpx.AsyncClient(timeout=self._timeout) as client:
207
+ for start in range(0, len(missing), self.EPSS_BATCH_SIZE):
208
+ batch = missing[start : start + self.EPSS_BATCH_SIZE]
209
+ # Um lote que falha não pode descartar os que já vieram: o
210
+ # sinal parcial ainda é melhor que nenhum.
211
+ batch_scores = await self._epss_batch(client, batch)
212
+ if batch_scores:
213
+ self._epss_available = True
214
+ scores.update(batch_scores)
215
+ await self._store_epss_cache(batch_scores)
216
+ if self._epss_available is None and not cached_hit:
217
+ # Every batch came back empty and nothing was cached: either the
218
+ # service is down or it knows none of these CVEs. Neither
219
+ # supports a claim of low exploitation probability.
220
+ self._epss_available = False
221
+ return scores
222
+
223
+ async def _epss_from_cache(self, cve: str) -> tuple[float, float] | None:
224
+ if self._cache is None:
225
+ return None
226
+ try:
227
+ data = await self._cache.get(self._EPSS_CACHE_PREFIX + cve)
228
+ except Exception as e: # pragma: no cover - an unreadable cache is a miss
229
+ logger.debug(f"Could not read the cached EPSS score for {cve}: {e}")
230
+ return None
231
+ if not isinstance(data, dict) or "score" not in data:
232
+ return None
233
+ score = _probability(data["score"])
234
+ if score is None:
235
+ # A row written before this check existed may still carry a
236
+ # NaN/out-of-range value on disk; treat it the same as a miss
237
+ # rather than serving it back as a probability.
238
+ return None
239
+ percentile = _probability(data.get("percentile", 0.0)) or 0.0
240
+ return score, percentile
241
+
242
+ async def _store_epss_cache(self, batch_scores: dict[str, float]) -> None:
243
+ if self._cache is None or not batch_scores:
244
+ return
245
+ for cve, score in batch_scores.items():
246
+ payload = {"score": score, "percentile": self._percentiles.get(cve, 0.0)}
247
+ try:
248
+ await self._cache.set(
249
+ self._EPSS_CACHE_PREFIX + cve, payload, ttl_seconds=self.CACHE_TTL_SECONDS
250
+ )
251
+ except Exception as e: # pragma: no cover - a cache that will not write is not fatal
252
+ logger.debug(f"Could not cache the EPSS score for {cve}: {e}")
253
+
254
+ def percentile_of(self, cve_id: str) -> float:
255
+ """EPSS percentile for `cve_id`, or 0.0 when the source did not
256
+ provide one. Only meaningful when `epss_available` is True."""
257
+ return self._percentiles.get(cve_id.upper(), 0.0)
258
+
259
+ async def _epss_batch(self, client: httpx.AsyncClient, batch: list[str]) -> dict[str, float]:
260
+ try:
261
+ resp = await client.get(
262
+ self.EPSS_URL,
263
+ params={"cve": ",".join(batch), "limit": str(len(batch))},
264
+ )
265
+ resp.raise_for_status()
266
+ data = resp.json()
267
+ scores: dict[str, float] = {}
268
+ for entry in data.get("data", []):
269
+ if "cve" not in entry or "epss" not in entry:
270
+ continue
271
+ cve = entry["cve"].upper()
272
+ probability = _probability(entry["epss"])
273
+ if probability is None:
274
+ # `float()` accepts "nan", "inf" and "-1"; none of them
275
+ # is a probability, and all of them reached the scoring
276
+ # engine. Dropping the CVE from the result leaves it
277
+ # `epss_known = False` downstream, which is the honest
278
+ # reading: the feed answered, but not with a number.
279
+ logger.warning(
280
+ f"Discarding implausible EPSS value for {cve}: {entry['epss']!r}"
281
+ )
282
+ continue
283
+ scores[cve] = probability
284
+ percentile = _probability(entry.get("percentile"))
285
+ if percentile is not None:
286
+ self._percentiles[cve] = percentile
287
+ return scores
288
+ except (httpx.HTTPError, ValueError, KeyError, TypeError) as e:
289
+ logger.debug(f"EPSS lookup unavailable for {len(batch)} CVEs, continuing without: {e}")
290
+ return {}
File without changes
@@ -0,0 +1,176 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import os
6
+ import shutil
7
+ import tempfile
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ from loguru import logger
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import AsyncIterator
15
+
16
+
17
+ def default_trivy_cache_dir() -> Path:
18
+ """Mirror Trivy's own default cache location resolution."""
19
+ env = os.environ.get("TRIVY_CACHE_DIR")
20
+ if env:
21
+ return Path(env)
22
+ xdg = os.environ.get("XDG_CACHE_HOME")
23
+ if xdg:
24
+ return Path(xdg) / "trivy"
25
+ return Path.home() / ".cache" / "trivy"
26
+
27
+
28
+ class TrivyCachePool:
29
+ """Hands out an isolated ``--cache-dir`` to each concurrent Trivy scan.
30
+
31
+ Root cause of the "cache may be in use by another process: timeout"
32
+ errors: Trivy takes an exclusive BoltDB lock on its cache directory, so
33
+ N parallel `trivy image` calls sharing one cache dir serialize on that
34
+ lock and the losers time out and exit non-zero.
35
+
36
+ The fix is to give every in-flight scan its own cache directory. The
37
+ vulnerability DB itself is downloaded once into the shared cache and
38
+ then **hard-linked** into each slot -- a copy would multiply a
39
+ multi-hundred-MB DB by the worker count.
40
+
41
+ If hard-linking is unavailable (different filesystem, restricted
42
+ permissions) the pool degrades to a single slot pointing at the shared
43
+ cache dir. That serializes scans, which is slower but still correct --
44
+ only one process touches the lock at a time, so nothing times out.
45
+ """
46
+
47
+ _DB_FILES = ("trivy.db", "metadata.json")
48
+
49
+ def __init__(self, base_cache_dir: Path, size: int):
50
+ self._base = base_cache_dir
51
+ self._size = max(1, size)
52
+ self._slots: asyncio.Queue[Path] | None = None
53
+ # A mesma lista que alimenta a fila, guardada para quem faz o
54
+ # próprio rodízio -- a engine Go recebe todos os slots de uma vez e
55
+ # os reveza por dentro, em vez de pedir um por scan.
56
+ self._slot_paths: list[Path] = []
57
+ self._temp_dirs: list[Path] = []
58
+ self._isolated = False
59
+ # prepare() awaits before assigning _slots, so concurrent first
60
+ # scans could otherwise each build a full pool -- leaking temp dirs
61
+ # and handing out more slots than there are workers.
62
+ self._prepare_lock = asyncio.Lock()
63
+
64
+ @property
65
+ def isolated(self) -> bool:
66
+ """True when each concurrent scan got its own cache directory."""
67
+ return self._isolated
68
+
69
+ @property
70
+ def base_dir(self) -> Path:
71
+ return self._base
72
+
73
+ async def prepare(self) -> bool:
74
+ """Build the slot pool. Returns True when isolation was achieved."""
75
+ await self._ensure_slots()
76
+ return self._isolated
77
+
78
+ async def _ensure_slots(self) -> asyncio.Queue[Path]:
79
+ if self._slots is not None:
80
+ return self._slots
81
+
82
+ async with self._prepare_lock:
83
+ if self._slots is not None:
84
+ return self._slots
85
+
86
+ slots: list[Path] = []
87
+ if self._size > 1:
88
+ slots = await asyncio.to_thread(self._build_isolated_slots)
89
+
90
+ if slots:
91
+ self._isolated = True
92
+ else:
93
+ if self._size > 1:
94
+ logger.warning(
95
+ "Falling back to a single shared Trivy cache dir; scans will be "
96
+ "serialized to avoid cache lock contention"
97
+ )
98
+ slots = [self._base]
99
+ self._isolated = False
100
+
101
+ queue: asyncio.Queue[Path] = asyncio.Queue()
102
+ for slot in slots:
103
+ queue.put_nowait(slot)
104
+ self._slot_paths = list(slots)
105
+ self._slots = queue
106
+ return queue
107
+
108
+ def _build_isolated_slots(self) -> list[Path]:
109
+ db_dir = self._base / "db"
110
+ sources = [db_dir / name for name in self._DB_FILES]
111
+ if not all(src.exists() for src in sources):
112
+ logger.warning(f"Trivy DB not found under {db_dir}; cannot build isolated cache dirs")
113
+ return []
114
+
115
+ parent = self._base.parent
116
+ try:
117
+ parent.mkdir(parents=True, exist_ok=True)
118
+ except OSError as e:
119
+ logger.warning(f"Cannot create Trivy cache pool parent {parent}: {e}")
120
+ return []
121
+
122
+ slots: list[Path] = []
123
+ for _ in range(self._size):
124
+ try:
125
+ slot = Path(tempfile.mkdtemp(prefix="dockerls-trivy-", dir=parent))
126
+ except OSError as e:
127
+ logger.warning(f"Cannot create isolated Trivy cache dir: {e}")
128
+ break
129
+ self._temp_dirs.append(slot)
130
+ try:
131
+ (slot / "db").mkdir(parents=True, exist_ok=True)
132
+ for src in sources:
133
+ os.link(src, slot / "db" / src.name)
134
+ except OSError as e:
135
+ logger.warning(f"Cannot hard-link Trivy DB into {slot}: {e}")
136
+ break
137
+ slots.append(slot)
138
+
139
+ if len(slots) < self._size:
140
+ # Partial pools are still safe, but an empty one is not usable.
141
+ logger.warning(f"Prepared {len(slots)}/{self._size} isolated Trivy cache dirs")
142
+ return slots
143
+
144
+ async def slot_paths(self) -> list[Path]:
145
+ """Todos os slots do pool, para um chamador que faz o próprio rodízio.
146
+
147
+ `acquire()` empresta um slot por vez, que é o que o pipeline Python
148
+ precisa. A engine Go recebe o lote inteiro e reveza por dentro, e
149
+ para isso precisa da lista.
150
+ """
151
+ await self._ensure_slots()
152
+ return list(self._slot_paths)
153
+
154
+ @contextlib.asynccontextmanager
155
+ async def acquire(self) -> AsyncIterator[Path]:
156
+ slots = await self._ensure_slots()
157
+ slot = await slots.get()
158
+ try:
159
+ yield slot
160
+ finally:
161
+ slots.put_nowait(slot)
162
+
163
+ async def cleanup(self) -> None:
164
+ """Remove every temporary cache dir created by this pool."""
165
+ dirs, self._temp_dirs = self._temp_dirs, []
166
+ if not dirs:
167
+ return
168
+ await asyncio.to_thread(self._cleanup_sync, dirs)
169
+
170
+ @staticmethod
171
+ def _cleanup_sync(dirs: list[Path]) -> None:
172
+ for path in dirs:
173
+ try:
174
+ shutil.rmtree(path, ignore_errors=True)
175
+ except OSError as e: # pragma: no cover - rmtree already swallows
176
+ logger.warning(f"Could not remove temporary Trivy cache dir {path}: {e}")