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,457 @@
1
+ """Read the Docker Hardened Images catalogue without downloading it.
2
+
3
+ The catalogue is a GitHub repository with roughly eleven thousand files. The
4
+ naive integration -- clone it, or walk `contents/` directory by directory --
5
+ is unusable twice over: an anonymous client gets 60 GitHub API requests per
6
+ hour, which a single `recommend node` would spend in one go, and a clone
7
+ costs tens of megabytes for the handful of definitions a query actually
8
+ needs.
9
+
10
+ So this client does two different things at two different rhythms:
11
+
12
+ * **once per TTL**, it fetches the repository's *tree* in a single API call
13
+ and reduces it to a compact index (image -> variant -> definition files).
14
+ The index is a few hundred kilobytes and answers "does DHI have node, and
15
+ in which variants" with no network at all;
16
+ * **per query**, it fetches only the definition files the index names, from
17
+ `raw.githubusercontent.com`, which is a CDN and does not consume the API
18
+ budget.
19
+
20
+ Everything is cached through the same store the rest of the tool uses, with
21
+ the commit SHA carried alongside so a cached index can state which revision
22
+ of the catalogue it describes. Conditional requests (`If-None-Match`) let a
23
+ refresh cost nothing when the catalogue has not moved.
24
+
25
+ Trust boundary: every byte here arrives from the network. Paths that come
26
+ back from the tree API are validated against a strict pattern before being
27
+ turned into URLs, and definition bodies go through the bounded YAML loader.
28
+ A catalogue that answers with something unexpected yields *no candidates*,
29
+ never a candidate with invented properties.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import asyncio
35
+ import json
36
+ import re
37
+ from enum import StrEnum
38
+ from typing import TYPE_CHECKING, Any
39
+
40
+ import httpx
41
+ from loguru import logger
42
+
43
+ from dockerls.integrations.dhi.definition import parse_definition
44
+ from dockerls.utils.rate_limit import CircuitBreaker, CircuitOpenError, RateLimiter
45
+ from dockerls.utils.safe_yaml import UnsafeYAMLError, safe_load_yaml
46
+
47
+ if TYPE_CHECKING:
48
+ from dockerls.domain.entities.declared_metadata import DeclaredImageMetadata
49
+ from dockerls.domain.interfaces.cache_store import CacheStoreInterface
50
+
51
+ CATALOG_OWNER = "docker-hardened-images"
52
+ CATALOG_REPO = "catalog"
53
+ CATALOG_BRANCH = "main"
54
+
55
+ #: The two hosts this client is allowed to reach. Paths and URLs derived
56
+ #: from a network response are checked against these before use, so a
57
+ #: compromised or spoofed catalogue response cannot redirect the client at
58
+ #: an internal address (SSRF) or an attacker-controlled host.
59
+ API_HOST = "api.github.com"
60
+ RAW_HOST = "raw.githubusercontent.com"
61
+
62
+ #: Definition paths are `image/<name>/<variant>/<file>.yaml` and nothing
63
+ #: else. Anchored, no dots in components, so `..` cannot appear and a
64
+ #: response cannot walk out of the `image/` subtree.
65
+ _DEFINITION_PATH = re.compile(
66
+ r"^image/(?P<image>[a-z0-9][a-z0-9_-]{0,63})/"
67
+ r"(?P<variant>[a-z0-9][a-z0-9._-]{0,63})/"
68
+ r"(?P<file>[a-z0-9][a-z0-9._-]{0,63})\.ya?ml$"
69
+ )
70
+
71
+ #: Queries arrive from the command line; only names that could name a
72
+ #: catalogue directory are looked up, and the lookup is a dict hit either
73
+ #: way, so this is a cheap guard against a query being used to build a URL.
74
+ _IMAGE_QUERY = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
75
+
76
+ #: The tree response is bounded before parsing: an unbounded body from a
77
+ #: hostile endpoint must not become unbounded memory. The real catalogue
78
+ #: tree is around 2 MB.
79
+ MAX_TREE_BYTES = 32 * 1024 * 1024
80
+
81
+ #: GitHub's anonymous budget is 60 requests/hour. This client makes at most
82
+ #: one API request per TTL in the normal case; the limiter exists so a cold
83
+ #: cache with several concurrent queries cannot burst past the budget.
84
+ API_RATE = 10
85
+ API_PERIOD = 60.0
86
+
87
+
88
+ class IndexState(StrEnum):
89
+ """How much of the catalogue the current index actually describes.
90
+
91
+ Without this, `variants()` returning `{}` meant three different things
92
+ with one shape: the catalogue has no such image, the catalogue could not
93
+ be reached, and the catalogue answered with a tree GitHub had truncated.
94
+ Only the first is a fact about the image. The other two are absences of
95
+ an answer, and a caller that cannot tell them apart reports "DHI has no
96
+ hardened build of this" when what happened was that nobody asked.
97
+
98
+ Nothing here changes what the client returns -- discovery still degrades
99
+ to no candidates rather than failing the run. What it changes is that
100
+ the *reason* survives the degradation instead of being flattened into
101
+ an empty dict.
102
+ """
103
+
104
+ #: Not loaded yet. No query has been answered from it.
105
+ NOT_LOADED = "NOT_LOADED"
106
+ #: Loaded, and it describes the whole catalogue.
107
+ COMPLETE = "COMPLETE"
108
+ #: GitHub truncated the tree. The index is real and it is short: images
109
+ #: it does not name may still exist in the catalogue.
110
+ TRUNCATED = "TRUNCATED"
111
+ #: The catalogue could not be read at all. Every answer from it is an
112
+ #: absence, and none of them is a statement about any image.
113
+ UNAVAILABLE = "UNAVAILABLE"
114
+
115
+ @property
116
+ def is_conclusive(self) -> bool:
117
+ """Whether "no variants" from this index means "the catalogue has none"."""
118
+ return self is IndexState.COMPLETE
119
+
120
+
121
+ class DHICatalogClient:
122
+ """Fetches and caches the DHI catalogue index and its definition files."""
123
+
124
+ def __init__(
125
+ self,
126
+ timeout: int = 30,
127
+ cache: CacheStoreInterface | None = None,
128
+ ttl_seconds: int = 21600,
129
+ token: str = "", # nosec B107 - empty means "anonymous", not a password
130
+ max_definition_bytes: int = 1024 * 1024,
131
+ ):
132
+ self._timeout = timeout
133
+ self._cache = cache
134
+ self._ttl = ttl_seconds
135
+ self._token = token
136
+ self._max_definition_bytes = max_definition_bytes
137
+ self._client: httpx.AsyncClient | None = None
138
+ self._client_lock = asyncio.Lock()
139
+ self._index: dict[str, dict[str, list[str]]] | None = None
140
+ self._index_lock = asyncio.Lock()
141
+ self._index_state = IndexState.NOT_LOADED
142
+ self._revision: str = ""
143
+ self._limiter = RateLimiter(rate=API_RATE, period=API_PERIOD, burst=API_RATE)
144
+ self._breaker = CircuitBreaker()
145
+ # Definitions fetched in this run, so ten candidates from one image
146
+ # never re-download the same YAML.
147
+ self._definitions: dict[str, DeclaredImageMetadata | None] = {}
148
+
149
+ @property
150
+ def revision(self) -> str:
151
+ """Catalogue commit the current index describes, if known.
152
+
153
+ Carried into the evidence trail: "DHI said X" is only auditable if
154
+ the reader can tell *which* version of the catalogue said it.
155
+ """
156
+ return self._revision
157
+
158
+ @property
159
+ def index_state(self) -> IndexState:
160
+ """Whether an empty answer from this client is a fact or an absence.
161
+
162
+ `variants()` returns `{}` for "the catalogue has no such image" and
163
+ for "the catalogue could not be read", and the caller has to be able
164
+ to tell those apart before it says anything about the image.
165
+ """
166
+ return self._index_state
167
+
168
+ async def _get_client(self) -> httpx.AsyncClient:
169
+ if self._client is None:
170
+ async with self._client_lock:
171
+ if self._client is None:
172
+ headers = {
173
+ "Accept": "application/vnd.github+json",
174
+ "X-GitHub-Api-Version": "2022-11-28",
175
+ }
176
+ if self._token:
177
+ # Never logged: the header is set on the client and
178
+ # the token itself is not interpolated anywhere else.
179
+ headers["Authorization"] = f"Bearer {self._token}"
180
+ self._client = httpx.AsyncClient(
181
+ timeout=self._timeout,
182
+ headers=headers,
183
+ # Redirects are not followed: a redirect is exactly
184
+ # how a response would move this client off the two
185
+ # hosts it is allowed to reach.
186
+ follow_redirects=False,
187
+ )
188
+ return self._client
189
+
190
+ async def close(self) -> None:
191
+ client, self._client = self._client, None
192
+ if client is not None:
193
+ await client.aclose()
194
+
195
+ async def variants(self, image: str) -> dict[str, list[str]]:
196
+ """Variant directories and definition files the catalogue has for `image`.
197
+
198
+ Returns an empty mapping when the catalogue has no such image, when
199
+ the index could not be loaded, or when the query is not a plausible
200
+ catalogue name. An empty mapping means "no DHI candidates", which is
201
+ a fact about discovery -- never a security statement.
202
+ """
203
+ name = image.strip().lower()
204
+ if not _IMAGE_QUERY.match(name):
205
+ return {}
206
+ index = await self._load_index()
207
+ return dict(index.get(name, {}))
208
+
209
+ async def definition(self, path: str) -> DeclaredImageMetadata | None:
210
+ """Fetch and parse one definition file by its catalogue path.
211
+
212
+ The path is re-validated here rather than trusted from the index,
213
+ because this is the method that turns a string into a URL.
214
+ """
215
+ if not _DEFINITION_PATH.match(path):
216
+ logger.warning(f"Refusing to fetch DHI definition at unexpected path: {path!r}")
217
+ return None
218
+ if path in self._definitions:
219
+ return self._definitions[path]
220
+
221
+ parsed = await self._fetch_definition(path)
222
+ self._definitions[path] = parsed
223
+ return parsed
224
+
225
+ async def _fetch_definition(self, path: str) -> DeclaredImageMetadata | None:
226
+ cache_key = f"dhi:def:{self._revision or 'head'}:{path}"
227
+ raw = await self._cached_text(cache_key)
228
+ if raw is None:
229
+ url = f"https://{RAW_HOST}/{CATALOG_OWNER}/{CATALOG_REPO}/{CATALOG_BRANCH}/{path}"
230
+ raw = await self._get_text(url, max_bytes=self._max_definition_bytes)
231
+ if raw is None:
232
+ return None
233
+ await self._store_text(cache_key, raw)
234
+
235
+ try:
236
+ data = safe_load_yaml(raw, origin=path)
237
+ except UnsafeYAMLError as e:
238
+ # A definition that cannot be read safely yields no candidate.
239
+ # It must never yield a candidate with default values, which
240
+ # would read downstream as a fully-specified hardened image.
241
+ logger.warning(f"Discarding DHI definition {path}: {e}")
242
+ return None
243
+ return parse_definition(data, definition_url=self._definition_url(path))
244
+
245
+ @staticmethod
246
+ def _definition_url(path: str) -> str:
247
+ return f"https://github.com/{CATALOG_OWNER}/{CATALOG_REPO}/blob/{CATALOG_BRANCH}/{path}"
248
+
249
+ async def _load_index(self) -> dict[str, dict[str, list[str]]]:
250
+ if self._index is not None:
251
+ return self._index
252
+ async with self._index_lock:
253
+ if self._index is not None:
254
+ return self._index
255
+ self._index = await self._resolve_index()
256
+ return self._index
257
+
258
+ async def _resolve_index(self) -> dict[str, dict[str, list[str]]]:
259
+ cached = await self._cached_index()
260
+ if cached is not None:
261
+ return cached
262
+ fetched = await self._fetch_index()
263
+ if fetched is None:
264
+ # An unreachable catalogue contributes nothing. Memoised as an
265
+ # empty index so a run with ten queries makes one failed attempt
266
+ # rather than ten -- but memoised *as unavailable*, so the empty
267
+ # dict is never read back as "the catalogue has no such image".
268
+ self._index_state = IndexState.UNAVAILABLE
269
+ logger.warning(
270
+ "DHI catalogue could not be read: this run reports no hardened "
271
+ "candidates from it, which is an absence of an answer and not a "
272
+ "statement that none exist"
273
+ )
274
+ return {}
275
+ index, revision, truncated = fetched
276
+ self._index_state = IndexState.TRUNCATED if truncated else IndexState.COMPLETE
277
+ self._revision = revision
278
+ await self._store_index(index, revision)
279
+ return index
280
+
281
+ async def _cached_index(self) -> dict[str, dict[str, list[str]]] | None:
282
+ if self._cache is None:
283
+ return None
284
+ try:
285
+ payload = await self._cache.get("dhi:index")
286
+ except Exception as e:
287
+ logger.warning(f"Could not read the cached DHI catalogue index: {e}")
288
+ return None
289
+ if not isinstance(payload, dict):
290
+ return None
291
+ index = payload.get("index")
292
+ revision = payload.get("revision")
293
+ if not isinstance(index, dict) or not isinstance(revision, str):
294
+ # A corrupted or older-shaped entry is a miss, never a partial
295
+ # index that would silently narrow discovery.
296
+ logger.warning("Discarding malformed DHI catalogue index from cache")
297
+ return None
298
+ cleaned = _validated_index(index)
299
+ if cleaned is None:
300
+ logger.warning("Discarding DHI catalogue index from cache: unexpected paths")
301
+ return None
302
+ self._revision = revision
303
+ self._index_state = IndexState.COMPLETE
304
+ logger.info(f"DHI catalogue index served from cache ({len(cleaned)} images, @{revision})")
305
+ return cleaned
306
+
307
+ async def _store_index(self, index: dict[str, dict[str, list[str]]], revision: str) -> None:
308
+ if self._cache is None:
309
+ return
310
+ try:
311
+ await self._cache.set(
312
+ "dhi:index", {"index": index, "revision": revision}, ttl_seconds=self._ttl
313
+ )
314
+ except Exception as e:
315
+ logger.warning(f"Could not cache the DHI catalogue index: {e}")
316
+
317
+ async def _fetch_index(self) -> tuple[dict[str, dict[str, list[str]]], str, bool] | None:
318
+ """One API call: the recursive tree, reduced to definition paths.
319
+
320
+ Returns `(index, revision, truncated)`, or None when the catalogue
321
+ could not be read. `truncated` travels with the index because a
322
+ short index and a complete one are not the same answer, and the
323
+ difference used to end at a log line.
324
+ """
325
+ url = (
326
+ f"https://{API_HOST}/repos/{CATALOG_OWNER}/{CATALOG_REPO}"
327
+ f"/git/trees/{CATALOG_BRANCH}?recursive=1"
328
+ )
329
+ raw = await self._get_text(url, max_bytes=MAX_TREE_BYTES, api=True)
330
+ if raw is None:
331
+ return None
332
+ try:
333
+ payload: Any = json.loads(raw)
334
+ except ValueError as e:
335
+ logger.warning(f"DHI catalogue tree was not valid JSON: {e}")
336
+ return None
337
+ if not isinstance(payload, dict):
338
+ return None
339
+ truncated = payload.get("truncated") is True
340
+ if truncated:
341
+ # GitHub truncates very large trees. A truncated tree silently
342
+ # hides images, so the fact travels with the index (see
343
+ # `IndexState.TRUNCATED`) rather than only into a log line that
344
+ # nothing downstream reads.
345
+ logger.warning("DHI catalogue tree came back truncated; discovery may be incomplete")
346
+
347
+ index: dict[str, dict[str, list[str]]] = {}
348
+ entries = payload.get("tree")
349
+ if not isinstance(entries, list):
350
+ return None
351
+ for entry in entries:
352
+ if not isinstance(entry, dict) or entry.get("type") != "blob":
353
+ continue
354
+ path = entry.get("path")
355
+ if not isinstance(path, str):
356
+ continue
357
+ match = _DEFINITION_PATH.match(path)
358
+ if not match:
359
+ continue
360
+ index.setdefault(match["image"], {}).setdefault(match["variant"], []).append(path)
361
+
362
+ revision = str(payload.get("sha") or "")[:40]
363
+ logger.info(f"DHI catalogue index built: {len(index)} images (@{revision or 'unknown'})")
364
+ return index, revision, truncated
365
+
366
+ async def _get_text(self, url: str, *, max_bytes: int, api: bool = False) -> str | None:
367
+ """GET `url` under the rate limiter, breaker and a size bound.
368
+
369
+ Returns None for every failure mode -- unreachable, refused,
370
+ oversized, non-2xx. The caller turns that into "no candidates from
371
+ this source", which is the only honest reading.
372
+ """
373
+ provider = "GitHub API" if api else "GitHub raw content"
374
+ try:
375
+ self._breaker.check(provider)
376
+ except CircuitOpenError as e:
377
+ logger.warning(str(e))
378
+ return None
379
+
380
+ if api:
381
+ await self._limiter.acquire()
382
+
383
+ try:
384
+ client = await self._get_client()
385
+ resp = await client.get(url)
386
+ except httpx.HTTPError as e:
387
+ self._breaker.record_failure()
388
+ logger.warning(f"{provider} request failed: {e}")
389
+ return None
390
+
391
+ if resp.status_code in (403, 429):
392
+ # Rate limited or forbidden. Both are budget problems, and both
393
+ # get worse if the client keeps trying.
394
+ self._breaker.record_failure()
395
+ logger.warning(
396
+ f"{provider} refused the request ({resp.status_code}); "
397
+ "set DOCKERLS_GITHUB_TOKEN to raise the anonymous rate limit"
398
+ )
399
+ return None
400
+ if resp.status_code == 404:
401
+ self._breaker.record_success()
402
+ return None
403
+ if not resp.is_success:
404
+ self._breaker.record_failure()
405
+ logger.warning(f"{provider} answered {resp.status_code} for a catalogue request")
406
+ return None
407
+
408
+ content = resp.content
409
+ if len(content) > max_bytes:
410
+ self._breaker.record_failure()
411
+ logger.warning(f"{provider} response exceeded {max_bytes} bytes; discarded")
412
+ return None
413
+
414
+ self._breaker.record_success()
415
+ return content.decode("utf-8", errors="replace")
416
+
417
+ async def _cached_text(self, key: str) -> str | None:
418
+ if self._cache is None:
419
+ return None
420
+ try:
421
+ value = await self._cache.get(key)
422
+ except Exception as e:
423
+ logger.warning(f"Could not read cached DHI definition: {e}")
424
+ return None
425
+ return value if isinstance(value, str) else None
426
+
427
+ async def _store_text(self, key: str, value: str) -> None:
428
+ if self._cache is None:
429
+ return
430
+ try:
431
+ await self._cache.set(key, value, ttl_seconds=self._ttl)
432
+ except Exception as e:
433
+ logger.warning(f"Could not cache DHI definition: {e}")
434
+
435
+
436
+ def _validated_index(index: dict[str, Any]) -> dict[str, dict[str, list[str]]] | None:
437
+ """Re-validate an index loaded from the cache.
438
+
439
+ The cache is a file on disk that other processes can write. An entry
440
+ whose paths do not match the expected shape is rejected outright rather
441
+ than filtered, because a partially-valid index is indistinguishable
442
+ from a tampered one and would quietly change what gets recommended.
443
+ """
444
+ cleaned: dict[str, dict[str, list[str]]] = {}
445
+ for image, variants in index.items():
446
+ if not isinstance(image, str) or not _IMAGE_QUERY.match(image):
447
+ return None
448
+ if not isinstance(variants, dict):
449
+ return None
450
+ for variant, paths in variants.items():
451
+ if not isinstance(variant, str) or not isinstance(paths, list):
452
+ return None
453
+ for path in paths:
454
+ if not isinstance(path, str) or not _DEFINITION_PATH.match(path):
455
+ return None
456
+ cleaned.setdefault(image, {})[variant] = list(paths)
457
+ return cleaned
@@ -0,0 +1,151 @@
1
+ """Parse one Docker Hardened Images definition file into declared metadata.
2
+
3
+ A definition is a YAML document describing how an image is built: which
4
+ packages go in, which account it runs as, which tags it publishes, when the
5
+ release goes end-of-life. The parsing here is deliberately total and
6
+ defensive -- every field is optional, every type is checked, and a document
7
+ that does not look like a definition yields `None` rather than a
8
+ half-populated object that later code would read as fact.
9
+
10
+ Nothing in this module decides anything about security. It converts a
11
+ vendor's claim into a typed claim; the verdict is made much further down,
12
+ after the tag has been resolved to a digest and scanned.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any
18
+
19
+ from dockerls.domain.entities.declared_metadata import (
20
+ DEBUG_TOOL_PACKAGES,
21
+ PACKAGE_MANAGER_PACKAGES,
22
+ SHELL_PACKAGES,
23
+ DeclaredImageMetadata,
24
+ )
25
+
26
+ DHI_CATALOG = "Docker Hardened Images"
27
+
28
+ #: Definitions list packages as `name`, `name=version`, or `!name` for an
29
+ #: explicit exclusion. An exclusion means the package is *removed* from the
30
+ #: base -- counting it as installed would invert its meaning.
31
+ _EXCLUSION_PREFIX = "!"
32
+
33
+ #: A definition that names more packages than this is either malformed or
34
+ #: hostile; the count is kept but the name matching stops, so a crafted
35
+ #: document cannot turn parsing into an O(n) walk of an unbounded list.
36
+ MAX_PACKAGES = 5000
37
+
38
+ #: Tags a definition may publish. DHI definitions list around a dozen
39
+ #: aliases per image; the cap bounds what a single document can inject into
40
+ #: the candidate list.
41
+ MAX_TAGS = 64
42
+
43
+
44
+ def parse_definition(data: Any, *, definition_url: str = "") -> DeclaredImageMetadata | None:
45
+ """Convert a parsed definition document into `DeclaredImageMetadata`.
46
+
47
+ Returns `None` when the document is not a mapping, or names neither an
48
+ image nor any tag -- there is nothing to resolve against a registry, so
49
+ there is no candidate.
50
+ """
51
+ if not isinstance(data, dict):
52
+ return None
53
+
54
+ tags = _string_tuple(data.get("tags"), limit=MAX_TAGS)
55
+ image = _as_str(data.get("image"))
56
+ if not image and not tags:
57
+ return None
58
+
59
+ packages = _package_names(data.get("contents"))
60
+ os_release = _as_mapping(data.get("os-release"))
61
+ dates = _as_mapping(data.get("dates"))
62
+ accounts = _as_mapping(data.get("accounts"))
63
+
64
+ return DeclaredImageMetadata(
65
+ catalog=DHI_CATALOG,
66
+ definition_url=definition_url,
67
+ display_name=_as_str(data.get("name")),
68
+ registry_repository=image,
69
+ variant=_as_str(data.get("variant")),
70
+ run_as_user=_as_str(accounts.get("run-as")),
71
+ platforms=_string_tuple(data.get("platforms"), limit=32),
72
+ release_date=_as_str(dates.get("release")),
73
+ end_of_life=_as_str(dates.get("end-of-life")),
74
+ os_id=_as_str(os_release.get("id")),
75
+ os_version=_as_str(os_release.get("version-id")),
76
+ declared_package_count=len(packages) if packages else None,
77
+ entrypoint=_string_tuple(data.get("entrypoint"), limit=32),
78
+ cmd=_string_tuple(data.get("cmd"), limit=32),
79
+ tags=tags,
80
+ shell_packages=_matching(packages, SHELL_PACKAGES),
81
+ package_manager_packages=_matching(packages, PACKAGE_MANAGER_PACKAGES),
82
+ debug_tool_packages=_matching(packages, DEBUG_TOOL_PACKAGES),
83
+ )
84
+
85
+
86
+ def _package_names(contents: Any) -> list[str]:
87
+ """Installed package names, with versions stripped and exclusions dropped.
88
+
89
+ `nodejs-22=22.23.2-0` is the package `nodejs-22`; `!gawk` is gawk being
90
+ *removed* from the base image and must not be counted as present.
91
+ """
92
+ if not isinstance(contents, dict):
93
+ return []
94
+ raw = contents.get("packages")
95
+ if not isinstance(raw, list):
96
+ return []
97
+
98
+ names: list[str] = []
99
+ for entry in raw[:MAX_PACKAGES]:
100
+ if not isinstance(entry, str):
101
+ continue
102
+ name = entry.strip()
103
+ if not name or name.startswith(_EXCLUSION_PREFIX):
104
+ continue
105
+ names.append(name.split("=", 1)[0].strip())
106
+ return names
107
+
108
+
109
+ def _matching(packages: list[str], known: frozenset[str]) -> tuple[str, ...]:
110
+ """Package names present in `known`, deduplicated and ordered.
111
+
112
+ Matching is exact rather than substring: `libcurl4` is not `curl`, and
113
+ treating it as one would report a fetch capability the image does not
114
+ have. Debian's `-dev`/major-version suffixes are handled by listing the
115
+ real package names in the sets themselves.
116
+ """
117
+ seen = {name for name in packages if name in known}
118
+ return tuple(sorted(seen))
119
+
120
+
121
+ def _as_mapping(value: Any) -> dict[str, Any]:
122
+ """A nested block, or an empty one when the key is absent or wrong-typed.
123
+
124
+ Definitions are hand-written YAML: a block can be missing, be `null`
125
+ (which parses to `None`, not `{}`), or be a list where a map is
126
+ expected. All three must read as "not stated".
127
+ """
128
+ return value if isinstance(value, dict) else {}
129
+
130
+
131
+ def _as_str(value: Any) -> str:
132
+ """Coerce a scalar to a string, refusing structures.
133
+
134
+ YAML happily produces ints, dates and nested maps where a string is
135
+ expected (`version-id: 13` parses as an int). Anything that is not a
136
+ scalar is dropped rather than stringified into `{'a': 1}`.
137
+ """
138
+ if isinstance(value, str):
139
+ return value.strip()
140
+ if isinstance(value, bool):
141
+ return "true" if value else "false"
142
+ if isinstance(value, int | float):
143
+ return str(value)
144
+ return ""
145
+
146
+
147
+ def _string_tuple(value: Any, *, limit: int) -> tuple[str, ...]:
148
+ if not isinstance(value, list):
149
+ return ()
150
+ items = [_as_str(entry) for entry in value[:limit]]
151
+ return tuple(item for item in items if item)