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,271 @@
1
+ """Public exploits catalogued in Exploit-DB, matched strictly by CVE-ID.
2
+
3
+ Exploit-DB has no official public API, and this project does not scrape:
4
+ CISA KEV and EPSS are consumed from their published feeds, and a scraper
5
+ breaks silently every time somebody edits the site's HTML. What Exploit-DB
6
+ *does* publish is the same `files_exploits.csv` that `searchsploit` reads,
7
+ kept in the project's own repository. One HTTP request, one CSV parse, no
8
+ system dependency and nothing to install.
9
+
10
+ **Matching is CVE-ID to CVE-ID, and nothing else.** The catalogue records a
11
+ CVE only when the exploit's author declared one, so plenty of entries carry
12
+ none -- roughly 42% of them. The tempting fallback is to search by product
13
+ name and attach whatever comes back, and it manufactures false positives: a
14
+ 2015 exploit for an old release of curl says nothing about the curl CVE
15
+ Trivy found in this image today, and reporting it as "exploit available"
16
+ would be worse than reporting nothing. An unmatched CVE comes back FALSE
17
+ (the catalogue answered and does not list it), never a guess.
18
+
19
+ Failure degrades to UNKNOWN for every CVE in the scan, never to FALSE and
20
+ never to an exception: an unreachable catalogue is an absent measurement,
21
+ and the single rule this project is organised around is that an absent
22
+ measurement is never spent as reassurance.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import csv
29
+ import io
30
+ from typing import TYPE_CHECKING, Any
31
+
32
+ import httpx
33
+ from loguru import logger
34
+
35
+ from dockerls.infrastructure.network.host_guard import host_of_url
36
+
37
+ if TYPE_CHECKING:
38
+ from dockerls.domain.interfaces.cache_store import CacheStoreInterface
39
+ from dockerls.infrastructure.network.host_guard import HostGuard
40
+
41
+ #: The CSV the Exploit-DB project publishes and `searchsploit` consumes.
42
+ #: The repository moved from GitHub to GitLab; the old
43
+ #: `offensive-security/exploitdb` path is gone and answers 404.
44
+ EXPLOITDB_CSV_URL = "https://gitlab.com/exploit-database/exploitdb/-/raw/main/files_exploits.csv"
45
+
46
+ #: Where a reader goes to judge the exploit rather than trust our summary.
47
+ EXPLOIT_URL_TEMPLATE = "https://www.exploit-db.com/exploits/{edb_id}"
48
+
49
+ #: Refreshed daily. The catalogue grows by a handful of entries a day, so a
50
+ #: fresher copy would cost 10 MB per run to learn almost nothing.
51
+ CACHE_TTL_SECONDS = 24 * 60 * 60
52
+ CACHE_KEY = "exploitdb:cve-index:v1"
53
+
54
+ #: The file is ~10 MB and ~47k rows today. The ceiling is not a performance
55
+ #: tuning knob: it bounds what an unexpected response (a redirect to
56
+ #: something else, a compromised mirror, an error page) can make this
57
+ #: process allocate. Generous enough to absorb years of growth.
58
+ MAX_CSV_BYTES = 128 * 1024 * 1024
59
+
60
+ #: Defence against a malformed or hostile row claiming thousands of CVEs.
61
+ #: Real entries carry a handful.
62
+ MAX_CODES_PER_ROW = 64
63
+
64
+
65
+ class ExploitEntry:
66
+ """One catalogued exploit, reduced to what the report shows."""
67
+
68
+ __slots__ = ("edb_id", "verified")
69
+
70
+ def __init__(self, edb_id: str, verified: bool):
71
+ self.edb_id = edb_id
72
+ self.verified = verified
73
+
74
+ def __repr__(self) -> str: # pragma: no cover - debugging aid
75
+ return f"ExploitEntry({self.edb_id!r}, verified={self.verified})"
76
+
77
+
78
+ class ExploitDBClient:
79
+ """Best-effort Exploit-DB lookups over the published CSV.
80
+
81
+ Mirrors `ThreatIntelClient` in shape -- an `available` tri-state, a
82
+ memo guarded by a lock so a 100-tag `recommend` run does not start 100
83
+ simultaneous downloads -- and adds the persistent cache that one lacks,
84
+ because 10 MB per invocation is a different proposition from 2 MB.
85
+ """
86
+
87
+ def __init__(
88
+ self,
89
+ timeout: int = 30,
90
+ cache: CacheStoreInterface | None = None,
91
+ guard: HostGuard | None = None,
92
+ ):
93
+ self._timeout = timeout
94
+ self._cache = cache
95
+ self._guard = guard
96
+ self._index: dict[str, list[ExploitEntry]] | None = None
97
+ # None before anything was asked, True once the catalogue answered,
98
+ # False after it failed. The caller needs this to tell "not listed"
99
+ # from "never looked", which is the whole point of the tri-state.
100
+ self._available: bool | None = None
101
+ self._lock = asyncio.Lock()
102
+
103
+ @property
104
+ def available(self) -> bool | None:
105
+ """True once the catalogue answered, False after it failed, None
106
+ before anything was asked."""
107
+ return self._available
108
+
109
+ async def exploits_for(self, cve_ids: list[str]) -> dict[str, list[ExploitEntry]]:
110
+ """Map each of `cve_ids` that has catalogued exploits to them.
111
+
112
+ A CVE absent from the result was *not* found in the catalogue, which
113
+ is a finding -- but only when `available` is True. With `available`
114
+ False nothing was consulted and every CVE must stay UNKNOWN.
115
+ """
116
+ if not cve_ids:
117
+ return {}
118
+ index = await self._load()
119
+ if not index:
120
+ return {}
121
+ found: dict[str, list[ExploitEntry]] = {}
122
+ for cve in cve_ids:
123
+ entries = index.get(cve.upper())
124
+ if entries:
125
+ found[cve.upper()] = entries
126
+ return found
127
+
128
+ async def _load(self) -> dict[str, list[ExploitEntry]]:
129
+ if self._index is not None:
130
+ return self._index
131
+ async with self._lock:
132
+ # Re-checked inside the lock: everything that queued behind the
133
+ # first downloader is served the memo instead of repeating it.
134
+ if self._index is not None:
135
+ return self._index
136
+ self._index = await self._load_uncached()
137
+ return self._index
138
+
139
+ async def _load_uncached(self) -> dict[str, list[ExploitEntry]]:
140
+ cached = await self._from_cache()
141
+ if cached is not None:
142
+ self._available = True
143
+ return cached
144
+
145
+ raw = await self._download()
146
+ if raw is None:
147
+ self._available = False
148
+ return {}
149
+
150
+ index = self._parse(raw)
151
+ # An empty index is not a successful lookup: the real catalogue
152
+ # carries tens of thousands of rows, so parsing to nothing means the
153
+ # response was not the file we asked for.
154
+ self._available = bool(index)
155
+ if index:
156
+ await self._store(index)
157
+ else:
158
+ logger.warning(
159
+ "Exploit-DB catalogue parsed to zero entries: exploit status stays UNKNOWN"
160
+ )
161
+ return index
162
+
163
+ async def _download(self) -> str | None:
164
+ # The network policy decides who this process may contact, exactly
165
+ # as it does for registries and scanners. A guard that refuses is a
166
+ # deliberate configuration, not an error to route around.
167
+ # `allows` julga um host, nunca uma URL: entregar a URL inteira faz o
168
+ # guard perguntar ao DNS por `https`, que não resolve, e recusar todo
169
+ # download como se fosse decisão de política.
170
+ if self._guard is not None and not self._guard.allows(host_of_url(EXPLOITDB_CSV_URL)):
171
+ logger.warning(
172
+ "Exploit-DB catalogue refused by the network policy: exploit status will be UNKNOWN"
173
+ )
174
+ return None
175
+ try:
176
+ async with (
177
+ httpx.AsyncClient(timeout=self._timeout, follow_redirects=True) as client,
178
+ client.stream("GET", EXPLOITDB_CSV_URL) as resp,
179
+ ):
180
+ resp.raise_for_status()
181
+ chunks: list[bytes] = []
182
+ total = 0
183
+ # Streamed with a running ceiling rather than read whole:
184
+ # `resp.text` on an unexpected response would allocate
185
+ # whatever the other end decided to send.
186
+ async for chunk in resp.aiter_bytes():
187
+ total += len(chunk)
188
+ if total > MAX_CSV_BYTES:
189
+ logger.warning(
190
+ f"Exploit-DB catalogue exceeded {MAX_CSV_BYTES} bytes; "
191
+ "refusing it and leaving exploit status UNKNOWN"
192
+ )
193
+ return None
194
+ chunks.append(chunk)
195
+ return b"".join(chunks).decode("utf-8", errors="replace")
196
+ except (httpx.HTTPError, UnicodeDecodeError) as e:
197
+ logger.warning(f"Exploit-DB catalogue unavailable: exploit status stays UNKNOWN ({e})")
198
+ return None
199
+
200
+ @staticmethod
201
+ def _parse(raw: str) -> dict[str, list[ExploitEntry]]:
202
+ """Index the CSV by CVE-ID.
203
+
204
+ `codes` holds identifiers separated by `;` and mixes schemes --
205
+ `CVE-2009-3699;OSVDB-58726` -- so only the CVE-shaped ones are kept.
206
+ Everything here treats the file as untrusted input: a row missing the
207
+ fields this needs is skipped rather than raising.
208
+
209
+ A `csv.Error` -- a NUL byte, an unterminated quote, a field past the
210
+ module limit -- discards the **whole** index instead of keeping what
211
+ was read so far, and that is deliberate rather than lazy: a partial
212
+ catalogue would answer FALSE for every CVE whose row came after the
213
+ break, turning rows that were never read into "no published exploit".
214
+ Discarding leaves `available` False and every CVE UNKNOWN, which is
215
+ what actually happened.
216
+ """
217
+ index: dict[str, list[ExploitEntry]] = {}
218
+ try:
219
+ reader = csv.DictReader(io.StringIO(raw))
220
+ for row in reader:
221
+ edb_id = (row.get("id") or "").strip()
222
+ if not edb_id or not edb_id.isdigit():
223
+ # The EDB-ID becomes a URL in the report; anything but
224
+ # digits does not belong in one.
225
+ continue
226
+ codes = (row.get("codes") or "").strip()
227
+ if not codes:
228
+ continue
229
+ verified = (row.get("verified") or "").strip() == "1"
230
+ entry = ExploitEntry(edb_id, verified)
231
+ for code in codes.split(";")[:MAX_CODES_PER_ROW]:
232
+ cve = code.strip().upper()
233
+ if not cve.startswith("CVE-"):
234
+ continue
235
+ index.setdefault(cve, []).append(entry)
236
+ except csv.Error as e:
237
+ logger.warning(f"Exploit-DB catalogue could not be parsed: {e}")
238
+ return {}
239
+ return index
240
+
241
+ async def _from_cache(self) -> dict[str, list[ExploitEntry]] | None:
242
+ if self._cache is None:
243
+ return None
244
+ try:
245
+ data = await self._cache.get(CACHE_KEY)
246
+ except Exception as e: # pragma: no cover - an unreadable cache is a miss
247
+ logger.debug(f"Could not read the cached Exploit-DB index: {e}")
248
+ return None
249
+ if not isinstance(data, dict) or not data:
250
+ return None
251
+ try:
252
+ return {
253
+ str(cve): [ExploitEntry(str(e["id"]), bool(e["verified"])) for e in entries]
254
+ for cve, entries in data.items()
255
+ }
256
+ except (KeyError, TypeError, ValueError) as e:
257
+ # A cache written by another version is a miss, not a crash.
258
+ logger.debug(f"Discarding an unreadable Exploit-DB cache entry: {e}")
259
+ return None
260
+
261
+ async def _store(self, index: dict[str, list[ExploitEntry]]) -> None:
262
+ if self._cache is None:
263
+ return
264
+ payload: dict[str, Any] = {
265
+ cve: [{"id": e.edb_id, "verified": e.verified} for e in entries]
266
+ for cve, entries in index.items()
267
+ }
268
+ try:
269
+ await self._cache.set(CACHE_KEY, payload, ttl_seconds=CACHE_TTL_SECONDS)
270
+ except Exception as e: # pragma: no cover - a cache that will not write is not fatal
271
+ logger.debug(f"Could not cache the Exploit-DB index: {e}")
File without changes
@@ -0,0 +1,336 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
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.utils.executables import ExecutableNotFoundError, resolve_executable
18
+ from dockerls.utils.subprocess_runner import (
19
+ VERSION_TIMEOUT_SECONDS,
20
+ OutputTooLargeError,
21
+ run_capture,
22
+ )
23
+ from dockerls.utils.validation import sanitize_image_name
24
+
25
+ if TYPE_CHECKING:
26
+ from dockerls.infrastructure.evidence import EvidenceStore
27
+ from dockerls.infrastructure.network.host_guard import HostGuard
28
+
29
+
30
+ class GrypeScanner(ScannerInterface):
31
+ def __init__(
32
+ self,
33
+ timeout: int = 300,
34
+ evidence: EvidenceStore | None = None,
35
+ guard: HostGuard | None = None,
36
+ workers: int = 1,
37
+ ):
38
+ self._timeout = timeout
39
+ self._evidence = evidence
40
+ self._version: str | None = None
41
+ self._skip_db_update = False
42
+ self._batch: EngineBatchScanner | None = None
43
+ self._workers = max(1, workers)
44
+ # Grype pulls the image itself, so the reference has to clear the
45
+ # network policy here or it never clears it at all.
46
+ self._guard = guard
47
+
48
+ @property
49
+ def batch(self) -> EngineBatchScanner:
50
+ """O caminho em lote, quando a engine Go estiver disponível.
51
+
52
+ O Grype não tem `--cache-dir`: a base dele mora num diretório único
53
+ e o que desliga a atualização automática são variáveis de ambiente.
54
+ Por isso o lote vai sem diretórios de cache e com o mesmo par de
55
+ variáveis que o caminho individual usa -- e a engine sabe que, sem
56
+ o lock BoltDB do Trivy, não há motivo para serializar.
57
+ """
58
+ if self._batch is None:
59
+ self._batch = EngineBatchScanner(
60
+ scanner="grype",
61
+ timeout_seconds=float(self._timeout),
62
+ skip_db_update=self._skip_db_update,
63
+ workers=self._workers,
64
+ guard=self._guard,
65
+ evidence=self._evidence,
66
+ raw_dir=None,
67
+ # Só o par que interessa, e nunca `_scan_env()`: aquele
68
+ # devolve `os.environ.copy()` inteiro, e mandá-lo pela
69
+ # fronteira escreveria DOCKERHUB_TOKEN e companhia dentro
70
+ # de um documento JSON. A engine soma o que recebe ao
71
+ # ambiente que ela própria herdou.
72
+ env=self._batch_env(),
73
+ )
74
+ return self._batch
75
+
76
+ def _batch_env(self) -> dict[str, str]:
77
+ """As variáveis que o lote precisa, e só elas."""
78
+ if not self._skip_db_update:
79
+ return {}
80
+ return {"GRYPE_DB_AUTO_UPDATE": "false", "GRYPE_CHECK_FOR_APP_UPDATE": "false"}
81
+
82
+ async def is_available(self) -> bool:
83
+ return shutil.which("grype") is not None
84
+
85
+ async def version(self) -> str:
86
+ """The scanner's own version string, memoised for the run.
87
+
88
+ Recorded so an analysis can be reconstructed: a score produced by
89
+ grype 0.48 and one produced by 0.58 are different measurements of
90
+ the same image, and until this existed nothing wrote down which one
91
+ happened. It is also what keeps a cached result from being served
92
+ across a scanner upgrade.
93
+
94
+ Returns "" when the binary is missing or does not answer -- an
95
+ unknown version, which the cache key treats as its own value rather
96
+ than as "same as before".
97
+ """
98
+ if self._version is not None:
99
+ return self._version
100
+ self._version = await self._read_version()
101
+ return self._version
102
+
103
+ async def _read_version(self) -> str:
104
+ try:
105
+ returncode, stdout, _ = await run_capture(
106
+ [resolve_executable("grype"), "--version"], timeout=VERSION_TIMEOUT_SECONDS
107
+ )
108
+ except (TimeoutError, OSError, ExecutableNotFoundError) as e:
109
+ logger.debug(f"Could not read grype version: {e}")
110
+ return ""
111
+ if returncode != 0 or not stdout:
112
+ return ""
113
+ # `<tool> --version` prints a banner; the first line carries the
114
+ # version and the rest is database metadata that changes on its own
115
+ # schedule and would churn the cache key for no reason.
116
+ return stdout.decode(errors="replace").strip().splitlines()[0].strip()
117
+
118
+ def _scan_env(self) -> dict[str, str] | None:
119
+ """Environment for a scan invocation.
120
+
121
+ Left at None until the DB has been refreshed once, so a scanner used
122
+ without `refresh_db()` still behaves exactly as before.
123
+ """
124
+ if not self._skip_db_update:
125
+ return None
126
+ env = os.environ.copy()
127
+ env["GRYPE_DB_AUTO_UPDATE"] = "false"
128
+ env["GRYPE_CHECK_FOR_APP_UPDATE"] = "false"
129
+ return env
130
+
131
+ async def refresh_db(self) -> bool:
132
+ """Update the vulnerability DB once, up front.
133
+
134
+ Grype otherwise checks its DB freshness on *every* invocation, which
135
+ is a network round trip per scan -- the dominant cost when
136
+ cross-validating several images. After this succeeds, scans run with
137
+ GRYPE_DB_AUTO_UPDATE=false so they go straight to matching.
138
+ """
139
+ try:
140
+ returncode, _, stderr = await run_capture(
141
+ [resolve_executable("grype"), "db", "update"], timeout=self._timeout
142
+ )
143
+ if returncode != 0:
144
+ logger.warning(f"Grype DB refresh failed: {stderr.decode()[:200]}")
145
+ return False
146
+ except (TimeoutError, OSError, ExecutableNotFoundError) as e:
147
+ logger.warning(f"Grype DB refresh failed: {e}")
148
+ return False
149
+
150
+ self._skip_db_update = True
151
+ logger.info("Grype DB ready; per-scan auto-update disabled")
152
+ return True
153
+
154
+ async def scan(self, image_reference: str) -> ScanResult:
155
+ safe_ref = sanitize_image_name(image_reference)
156
+ blocked = blocked_target_reason(safe_ref, self._guard)
157
+ if blocked:
158
+ return blocked_scan_result(safe_ref, "grype", blocked)
159
+ logger.info(f"Scanning {safe_ref} with Grype")
160
+ timestamp = datetime.now(tz=UTC).isoformat()
161
+
162
+ try:
163
+ returncode, stdout, stderr = await run_capture(
164
+ [resolve_executable("grype"), safe_ref, "-o", "json", "--quiet"],
165
+ timeout=self._timeout,
166
+ env=self._scan_env(),
167
+ )
168
+
169
+ if returncode != 0:
170
+ err = stderr.decode(errors="replace")[:500]
171
+ logger.error(f"Grype returned code {returncode} for {safe_ref}: {err}")
172
+ return ScanResult(
173
+ image_reference=safe_ref,
174
+ scanner="grype",
175
+ scan_timestamp=timestamp,
176
+ status=ScanStatus.ERROR,
177
+ error_message=err,
178
+ error_kind=classify_scanner_error(err),
179
+ )
180
+
181
+ if not stdout:
182
+ return ScanResult(
183
+ image_reference=safe_ref,
184
+ scanner="grype",
185
+ scan_timestamp=timestamp,
186
+ status=ScanStatus.ERROR,
187
+ error_message="Grype produced no output",
188
+ error_kind=ScanErrorKind.INVALID_OUTPUT,
189
+ )
190
+
191
+ raw = stdout.decode()
192
+ data = json.loads(raw)
193
+ result = self._parse_results(safe_ref, data)
194
+ if self._evidence is not None:
195
+ result.evidence_path = await self._evidence.record_scan(safe_ref, "grype", raw)
196
+ return result
197
+
198
+ except OutputTooLargeError as e:
199
+ # Unbounded output is not a measurement: the document was never
200
+ # read in full, so there is nothing to parse and nothing to
201
+ # conclude. INVALID_OUTPUT is already a non-verified state.
202
+ logger.error(f"Grype output exceeded the size limit for {safe_ref}: {e}")
203
+ return ScanResult(
204
+ image_reference=safe_ref,
205
+ scanner="grype",
206
+ scan_timestamp=timestamp,
207
+ status=ScanStatus.ERROR,
208
+ error_message=str(e),
209
+ error_kind=ScanErrorKind.INVALID_OUTPUT,
210
+ )
211
+ except TimeoutError:
212
+ logger.error(f"Grype scan timed out for {safe_ref}")
213
+ return ScanResult(
214
+ image_reference=safe_ref,
215
+ scanner="grype",
216
+ scan_timestamp=timestamp,
217
+ status=ScanStatus.TIMEOUT,
218
+ error_message=f"Scan exceeded {self._timeout}s timeout",
219
+ error_kind=ScanErrorKind.TIMEOUT,
220
+ )
221
+ except json.JSONDecodeError as e:
222
+ logger.error(f"Grype produced unparseable JSON for {safe_ref}: {e}")
223
+ return ScanResult(
224
+ image_reference=safe_ref,
225
+ scanner="grype",
226
+ scan_timestamp=timestamp,
227
+ status=ScanStatus.ERROR,
228
+ error_message=str(e),
229
+ error_kind=ScanErrorKind.INVALID_OUTPUT,
230
+ )
231
+ except ExecutableNotFoundError as e:
232
+ logger.error(f"Grype scan failed for {safe_ref}: {e}")
233
+ return ScanResult(
234
+ image_reference=safe_ref,
235
+ scanner="grype",
236
+ scan_timestamp=timestamp,
237
+ status=ScanStatus.ERROR,
238
+ error_message=str(e),
239
+ error_kind=ScanErrorKind.SCANNER_MISSING,
240
+ )
241
+ except OSError as e:
242
+ logger.error(f"Grype scan failed for {safe_ref}: {e}")
243
+ return ScanResult(
244
+ image_reference=safe_ref,
245
+ scanner="grype",
246
+ scan_timestamp=timestamp,
247
+ status=ScanStatus.ERROR,
248
+ error_message=str(e),
249
+ error_kind=classify_scanner_error(str(e)),
250
+ )
251
+
252
+ def _parse_results(self, image_ref: str, data: dict[str, Any]) -> ScanResult:
253
+ vulns: list[Vulnerability] = []
254
+ for match in data.get("matches", []):
255
+ vd = match.get("vulnerability", {})
256
+ sev_str = vd.get("severity", "Unknown").upper()
257
+ if sev_str == "NEGLIGIBLE":
258
+ sev_str = "LOW"
259
+ try:
260
+ severity = Severity(sev_str)
261
+ except ValueError:
262
+ severity = Severity.UNKNOWN
263
+
264
+ artifact = match.get("artifact", {})
265
+ fixed_versions = vd.get("fix", {}).get("versions", [])
266
+ fixed_version = fixed_versions[0] if fixed_versions else ""
267
+
268
+ cvss_score, cvss_source = self._extract_cvss(vd.get("cvss", []))
269
+
270
+ vulns.append(
271
+ Vulnerability(
272
+ cve_id=vd.get("id", ""),
273
+ severity=severity,
274
+ cvss_score=cvss_score,
275
+ cvss_source=cvss_source,
276
+ package_name=artifact.get("name", ""),
277
+ installed_version=artifact.get("version", ""),
278
+ fixed_version=fixed_version,
279
+ description=vd.get("description", "")[:200],
280
+ package_type=str(artifact.get("type") or ""),
281
+ target=str(artifact.get("locations", [{}])[0].get("path", ""))
282
+ if artifact.get("locations")
283
+ else "",
284
+ )
285
+ )
286
+
287
+ family, version = self._parse_distro(data)
288
+ return ScanResult(
289
+ image_reference=image_ref,
290
+ scanner="grype",
291
+ vulnerabilities=vulns,
292
+ scan_timestamp=datetime.now(tz=UTC).isoformat(),
293
+ os_family=family,
294
+ os_version=version,
295
+ )
296
+
297
+ @staticmethod
298
+ def _parse_distro(data: dict[str, Any]) -> tuple[str, str]:
299
+ """The base distribution Grype identified, from its `distro` block.
300
+
301
+ Same fact Trivy reports under `Metadata.OS`, spelled differently.
302
+ Normalising it here means the migration analysis reads one field
303
+ regardless of which scanner produced the result.
304
+ """
305
+ distro = data.get("distro")
306
+ if not isinstance(distro, dict):
307
+ return "", ""
308
+ return str(distro.get("name") or ""), str(distro.get("version") or "")
309
+
310
+ @staticmethod
311
+ def _extract_cvss(entries: list[dict[str, Any]]) -> tuple[float, str]:
312
+ """Deterministic CVSS selection: NVD source > any other vendor
313
+ source > first available, instead of an arbitrary max() across
314
+ differently-scored advisories. Returns (score, source) so the report
315
+ can say which base produced the number."""
316
+ if not entries:
317
+ return 0.0, ""
318
+
319
+ def base_score(entry: dict[str, Any]) -> float:
320
+ # Grype emits `"metrics": null` for advisories with no CVSS
321
+ # vector, and a null/non-numeric score must read as "unscored",
322
+ # not blow up the whole parse of an otherwise good scan.
323
+ metrics = entry.get("metrics") or {}
324
+ if not isinstance(metrics, dict):
325
+ return 0.0
326
+ try:
327
+ return float(metrics.get("baseScore", 0.0))
328
+ except (TypeError, ValueError):
329
+ return 0.0
330
+
331
+ for entry in entries:
332
+ source = str(entry.get("source", ""))
333
+ if "nvd" in source.lower():
334
+ return base_score(entry), source or "nvd"
335
+
336
+ return base_score(entries[0]), str(entries[0].get("source", ""))
File without changes