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,238 @@
1
+ """Docker Hardened Images as an image source.
2
+
3
+ DHI is different from every other source this tool searches, in a way that
4
+ shapes the whole integration: its *catalogue* is public (a GitHub
5
+ repository of build definitions) while its *registry* is not (dhi.io refuses
6
+ anonymous pulls). Discovery therefore works for everyone; scanning works
7
+ only for a machine holding DHI credentials.
8
+
9
+ That split is not a limitation to work around -- it is the exact case the
10
+ rest of this codebase was built to handle honestly. A DHI candidate is
11
+ discovered from the catalogue and enters the same pipeline as everything
12
+ else. If the registry will not serve it, the scan fails, the candidate is
13
+ reported as UNVERIFIED, and it is never ranked, never scored, and never
14
+ called production ready. What a vendor says about its own hardening does not
15
+ substitute for a measurement, and this provider is where that rule is at its
16
+ most tempting to break.
17
+
18
+ The candidate a definition produces carries the definition's declared
19
+ metadata alongside it, so the CLI can explain *why* a DHI image is worth
20
+ considering (non-root by declaration, a supported lifecycle, a small package
21
+ set) while keeping every one of those claims labelled as a claim.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import re
27
+ from typing import TYPE_CHECKING
28
+
29
+ from loguru import logger
30
+
31
+ from dockerls.domain.entities.image import DockerImage
32
+ from dockerls.domain.interfaces.image_repository import ImageRepositoryInterface
33
+
34
+ if TYPE_CHECKING:
35
+ from dockerls.domain.entities.declared_metadata import DeclaredImageMetadata
36
+ from dockerls.integrations.dhi.catalog import DHICatalogClient
37
+
38
+ DHI = "Docker Hardened Images"
39
+ DHI_REGISTRY = "dhi.io"
40
+
41
+ #: Query name -> catalogue directory, where DHI names an image differently
42
+ #: from the Docker Hub convention a user types.
43
+ ALIASES = {
44
+ "nodejs": "node",
45
+ "python3": "python",
46
+ "golang": "go",
47
+ "openjdk": "jdk",
48
+ "postgresql": "postgres",
49
+ }
50
+
51
+ #: Variant suffixes that are not the image most users want. `-dev` ships a
52
+ #: shell, a package manager and a compiler by design; `sfw`/`ent` are the
53
+ #: FIPS and enterprise builds, which are not pullable at all without the
54
+ #: corresponding entitlement. They are still discoverable by name -- they are
55
+ #: just not what a bare `recommend node` should fan out to.
56
+ DEPRIORITISED_MARKERS = ("dev", "sfw", "swf", "ent")
57
+
58
+
59
+ class DHIRepository(ImageRepositoryInterface):
60
+ """Discovers candidates from the DHI catalogue's build definitions."""
61
+
62
+ source = DHI
63
+ host = DHI_REGISTRY
64
+
65
+ def __init__(self, catalog: DHICatalogClient, definition_limit: int = 12):
66
+ self._catalog = catalog
67
+ self._definition_limit = max(1, definition_limit)
68
+
69
+ def repository_for(self, image_name: str) -> str | None:
70
+ """Map a user's query onto a catalogue directory name.
71
+
72
+ Returns None for anything that already names a registry or a
73
+ namespace: `recommend ghcr.io/org/app` must not fan out to DHI, and
74
+ a slash in the query means the user has already been specific.
75
+ """
76
+ name = image_name.strip().strip("/").lower()
77
+ if not name or "/" in name or ":" in name:
78
+ return None
79
+ return ALIASES.get(name, name)
80
+
81
+ async def search_tags(self, image_name: str, limit: int = 100) -> list[DockerImage]:
82
+ catalogue_name = self.repository_for(image_name)
83
+ if catalogue_name is None:
84
+ return []
85
+
86
+ variants = await self._catalog.variants(catalogue_name)
87
+ if not variants:
88
+ # Empty has three causes and only one of them is about the
89
+ # image. Saying which one happened is the difference between
90
+ # "DHI publishes no hardened build of this" and "nobody asked",
91
+ # and the second must not be reported as the first.
92
+ state = self._catalog.index_state
93
+ if state.is_conclusive:
94
+ logger.info(f"{DHI}: the catalogue has no {catalogue_name}")
95
+ else:
96
+ logger.warning(
97
+ f"{DHI}: no candidates for {catalogue_name}, and the catalogue index "
98
+ f"is {state} -- this is an absence of an answer, not a finding that "
99
+ "no hardened build exists"
100
+ )
101
+ return []
102
+
103
+ candidates: list[DockerImage] = []
104
+ for path in self._selected_paths(variants):
105
+ declared = await self._catalog.definition(path)
106
+ if declared is None:
107
+ continue
108
+ image = self._build_image(declared)
109
+ if image is None:
110
+ continue
111
+ candidates.append(image)
112
+ if len(candidates) >= limit:
113
+ break
114
+
115
+ logger.info(
116
+ f"{DHI}: {len(candidates)} candidate(s) for {catalogue_name} "
117
+ f"(catalogue @{self._catalog.revision or 'unknown'})"
118
+ )
119
+ return candidates
120
+
121
+ def _selected_paths(self, variants: dict[str, list[str]]) -> list[str]:
122
+ """Bound and order the definitions this query will read.
123
+
124
+ A popular image has dozens of definitions across OS variants and
125
+ build flavours. Fetching all of them would be a request each, so the
126
+ list is ordered -- runtime flavours before `-dev`, newest OS variant
127
+ first, newest version first -- and cut at `definition_limit`. The
128
+ ordering is total and deterministic, so two runs against the same
129
+ catalogue revision read the same files.
130
+ """
131
+ paths = [path for variant_paths in variants.values() for path in variant_paths]
132
+ paths.sort(key=_definition_rank)
133
+ return paths[: self._definition_limit]
134
+
135
+ def _build_image(self, declared: DeclaredImageMetadata) -> DockerImage | None:
136
+ """Turn a definition into a candidate pinned to its primary tag.
137
+
138
+ A definition publishes a dozen aliases of the same image (`22`,
139
+ `22.23`, `22.23.2-debian13`, ...). They are the same bytes, so
140
+ emitting one candidate per alias would multiply the scan work by
141
+ twelve for no additional information. The most specific tag is
142
+ chosen, because it is the one that stays meaningful over time.
143
+ """
144
+ tag = _primary_tag(declared.tags)
145
+ repository = _repository_name(declared)
146
+ if not tag or not repository:
147
+ return None
148
+ return DockerImage(
149
+ name=repository,
150
+ tag=tag,
151
+ source=DHI,
152
+ os=declared.os_id or "linux",
153
+ available_architectures=[p.split("/")[-1] for p in declared.platforms],
154
+ declared=declared,
155
+ )
156
+
157
+ async def get_image_metadata(self, image_name: str, tag: str) -> DockerImage | None:
158
+ for image in await self.search_tags(image_name, limit=self._definition_limit):
159
+ if image.tag == tag:
160
+ return image
161
+ return None
162
+
163
+ async def tag_exists(self, image_name: str, tag: str) -> bool | None:
164
+ """Whether the *catalogue* declares this tag.
165
+
166
+ Deliberately not a registry check: dhi.io refuses anonymous
167
+ requests, so this provider cannot confirm that a tag is actually
168
+ published. Returning True here would mean "the definition says this
169
+ tag exists", which is not what the caller is asking, so a catalogue
170
+ hit answers None -- unknown -- and the tag stands or falls on
171
+ whether the scanner could pull it.
172
+ """
173
+ del image_name, tag
174
+ return None
175
+
176
+ async def close(self) -> None:
177
+ await self._catalog.close()
178
+
179
+
180
+ def _repository_name(declared: DeclaredImageMetadata) -> str:
181
+ """The repository a definition publishes to, if it is a DHI repository.
182
+
183
+ The definition states it as `image: dhi.io/node`. It is *checked* here
184
+ rather than trusted: a definition that names some other host would
185
+ otherwise let catalogue content redirect a scan at an arbitrary
186
+ registry. Anything that is not a `dhi.io/...` path yields no candidate.
187
+ """
188
+ repository = declared.registry_repository.strip().lower()
189
+ prefix = f"{DHI_REGISTRY}/"
190
+ if not repository.startswith(prefix):
191
+ return ""
192
+ path = repository[len(prefix) :]
193
+ if not path or not _REPOSITORY_PATH.match(path):
194
+ return ""
195
+ return f"{DHI_REGISTRY}/{path}"
196
+
197
+
198
+ _REPOSITORY_PATH = re.compile(r"^[a-z0-9][a-z0-9._/-]{0,127}$")
199
+
200
+
201
+ def _primary_tag(tags: tuple[str, ...]) -> str:
202
+ """The most specific tag a definition publishes.
203
+
204
+ Specificity is measured by dotted components then by length, so
205
+ `22.23.2-debian13` beats `22`. A pinned tag is the one worth recording
206
+ in a recommendation: `22` moves with every patch release, and a
207
+ recommendation that moves is a recommendation that was never verified.
208
+ """
209
+ if not tags:
210
+ return ""
211
+ return max(tags, key=lambda tag: (tag.count("."), len(tag), tag))
212
+
213
+
214
+ def _definition_rank(path: str) -> tuple[int, str]:
215
+ """Order definitions: plain runtime flavours first, newest variant first.
216
+
217
+ The variant directory (`debian-13`, `alpine-3.24`) is reversed so the
218
+ highest version sorts first, and any definition whose file name carries
219
+ a de-prioritised marker sinks below the plain ones.
220
+ """
221
+ parts = path.split("/")
222
+ variant = parts[2] if len(parts) > 3 else ""
223
+ filename = parts[-1].rsplit(".", 1)[0]
224
+ markers = set(filename.lower().replace(".", "-").split("-"))
225
+ deprioritised = 1 if markers & set(DEPRIORITISED_MARKERS) else 0
226
+ # Reverse-lexicographic on variant and file name, achieved by negating
227
+ # the ordering with a descending key built from the strings themselves.
228
+ return (deprioritised, _descending(f"{variant}/{filename}"))
229
+
230
+
231
+ def _descending(value: str) -> str:
232
+ """Key that sorts `value` in reverse order under an ascending sort.
233
+
234
+ Inverting each code point keeps the comparison total and stable without
235
+ needing a second sort pass or a reverse=True that would also flip the
236
+ de-prioritisation flag.
237
+ """
238
+ return "".join(chr(0x10FFFF - ord(ch)) for ch in value)
File without changes
@@ -0,0 +1,318 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ from datetime import datetime
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ import httpx
9
+ from loguru import logger
10
+
11
+ from dockerls.domain.entities.image import DockerImage
12
+ from dockerls.domain.interfaces.image_repository import ImageRepositoryInterface
13
+ from dockerls.infrastructure.network.guarded_client import guarded_async_client
14
+ from dockerls.integrations.dockerhub.urls import build_tag_api_url
15
+ from dockerls.utils.retry import (
16
+ DEFAULT_BACKOFF_BASE,
17
+ DEFAULT_MAX_ATTEMPTS,
18
+ retry_policy,
19
+ )
20
+ from dockerls.utils.validation import sanitize_image_name
21
+
22
+ if TYPE_CHECKING:
23
+ from dockerls.domain.interfaces.cache_store import CacheStoreInterface
24
+ from dockerls.infrastructure.network.host_guard import HostGuard
25
+
26
+ # Anonymous Docker Hub requests are rate limited, and a `recommend` run
27
+ # checks one tag per candidate. Existence of a tag changes rarely, so a
28
+ # local TTL cache keeps repeated runs well under the limit. Kept shorter
29
+ # than the analysis cache: a tag disappearing matters sooner than a score
30
+ # going slightly stale.
31
+ TAG_EXISTS_TTL_SECONDS = 6 * 3600
32
+
33
+
34
+ class DockerHubClient(ImageRepositoryInterface):
35
+ BASE_URL = "https://hub.docker.com/v2"
36
+
37
+ def __init__(
38
+ self,
39
+ # Defaults vazios: "sem credencial", não uma senha embutida.
40
+ username: str = "", # nosec B107
41
+ token: str = "", # nosec B107
42
+ timeout: int = 30,
43
+ cache: CacheStoreInterface | None = None,
44
+ max_attempts: int = DEFAULT_MAX_ATTEMPTS,
45
+ backoff_base: float = DEFAULT_BACKOFF_BASE,
46
+ tag_ttl_seconds: int = TAG_EXISTS_TTL_SECONDS,
47
+ guard: HostGuard | None = None,
48
+ ):
49
+ # hub.docker.com is a fixed host, but it answers with redirects and
50
+ # this client follows them. A redirect target is chosen by the far
51
+ # end, so it is policed per hop rather than trusted because the
52
+ # first hop was fine.
53
+ self._guard = guard
54
+ self._username = username
55
+ self._token = token
56
+ self._timeout = timeout
57
+ self._auth_token: str = ""
58
+ self._cache = cache
59
+ self._max_attempts = max_attempts
60
+ self._backoff_base = backoff_base
61
+ self._tag_ttl_seconds = tag_ttl_seconds
62
+ self._client: httpx.AsyncClient | None = None
63
+ self._client_lock = asyncio.Lock()
64
+ # In-flight `tag_exists` checks, keyed by cache key. `_verify_tags`
65
+ # runs up to ten of them concurrently and several candidates can
66
+ # share a tag, so without this a cold cache means every one of them
67
+ # goes to the network before the first result is written back --
68
+ # a textbook stampede against a rate-limited API.
69
+ self._tag_checks: dict[str, asyncio.Future[bool | None]] = {}
70
+
71
+ async def _get_client(self) -> httpx.AsyncClient:
72
+ """One client for this repository's lifetime.
73
+
74
+ A fresh `AsyncClient` per call meant a new connection pool and a new
75
+ TLS handshake for every page of a listing and every tag check, with
76
+ nothing kept alive between them. Sharing one gives HTTP keep-alive
77
+ across the whole run.
78
+ """
79
+ if self._client is None:
80
+ async with self._client_lock:
81
+ if self._client is None:
82
+ self._client = guarded_async_client(
83
+ self._guard,
84
+ timeout=self._timeout,
85
+ headers={"Accept": "application/json"},
86
+ follow_redirects=True,
87
+ )
88
+ return self._client
89
+
90
+ def _auth_headers(self) -> dict[str, str]:
91
+ """Per-request auth, so a token obtained by a later `authenticate()`
92
+ applies to a client that was already created."""
93
+ return {"Authorization": f"Bearer {self._auth_token}"} if self._auth_token else {}
94
+
95
+ async def close(self) -> None:
96
+ """Release the shared connection pool."""
97
+ client, self._client = self._client, None
98
+ if client is not None:
99
+ await client.aclose()
100
+
101
+ async def authenticate(self) -> bool:
102
+ if not self._username or not self._token:
103
+ return False
104
+ try:
105
+ async with httpx.AsyncClient(timeout=self._timeout) as client:
106
+ resp = await client.post(
107
+ f"{self.BASE_URL}/users/login/",
108
+ json={"username": self._username, "password": self._token},
109
+ )
110
+ if resp.status_code == 200:
111
+ self._auth_token = resp.json().get("token", "")
112
+ return bool(self._auth_token)
113
+ except (httpx.HTTPError, ValueError) as e:
114
+ # ValueError covers a 200 whose body is not JSON -- a captive
115
+ # portal or proxy error page. Failing to authenticate degrades to
116
+ # anonymous access; it must never abort the command.
117
+ logger.warning(f"Docker Hub auth failed: {e}")
118
+ return False
119
+
120
+ async def _get_json(self, client: httpx.AsyncClient, url: str) -> httpx.Response:
121
+ """Perform a single GET with retry scoped to *this* request only, so
122
+ a transient failure deep into a paginated fetch doesn't force the
123
+ whole listing to restart from page one. Honors Retry-After on 429.
124
+
125
+ The policy is built per call so `retry_max_attempts` and
126
+ `retry_backoff_base` reach it; as a decorator it was fixed at
127
+ import time and the settings could never apply.
128
+ """
129
+ policy = retry_policy(self._max_attempts, self._backoff_base)
130
+ resp: httpx.Response = await policy(self._get_once, client, url)
131
+ return resp
132
+
133
+ async def _get_once(self, client: httpx.AsyncClient, url: str) -> httpx.Response:
134
+ resp = await client.get(url, headers=self._auth_headers())
135
+ if resp.status_code == 429:
136
+ retry_after = resp.headers.get("Retry-After")
137
+ wait_s = float(retry_after) if retry_after and retry_after.isdigit() else 2.0
138
+ logger.warning(f"Rate limited by Docker Hub, waiting {wait_s}s")
139
+ await asyncio.sleep(wait_s)
140
+ resp.raise_for_status()
141
+ return resp
142
+
143
+ @staticmethod
144
+ def _parse_images(images: list[dict[str, Any]]) -> tuple[int, str, str, list[str]]:
145
+ """Return (size, digest, primary_architecture, all_architectures)."""
146
+ archs = [img.get("architecture", "unknown") for img in images]
147
+ for img in images:
148
+ if img.get("architecture") == "amd64":
149
+ return img.get("size", 0), img.get("digest", ""), "amd64", archs
150
+ if images:
151
+ first = images[0]
152
+ return (
153
+ first.get("size", 0),
154
+ first.get("digest", ""),
155
+ first.get("architecture", "unknown"),
156
+ archs,
157
+ )
158
+ return 0, "", "amd64", archs
159
+
160
+ async def search_tags(self, image_name: str, limit: int = 100) -> list[DockerImage]:
161
+ safe_name = sanitize_image_name(image_name)
162
+ namespace = "library" if "/" not in safe_name else safe_name.split("/")[0]
163
+ repo = safe_name if "/" not in safe_name else safe_name.split("/", 1)[1]
164
+
165
+ tags: list[DockerImage] = []
166
+ page_size = min(limit, 100)
167
+ url: str | None = (
168
+ f"{self.BASE_URL}/repositories/{namespace}/{repo}/tags/"
169
+ f"?page_size={page_size}&ordering=last_updated"
170
+ )
171
+
172
+ client = await self._get_client()
173
+ while url and len(tags) < limit:
174
+ try:
175
+ resp = await self._get_json(client, url)
176
+ if resp.status_code == 404:
177
+ logger.warning(f"Image not found: {safe_name}")
178
+ return []
179
+ resp.raise_for_status()
180
+ data = resp.json()
181
+
182
+ for tag_data in data.get("results", []):
183
+ tag_name = tag_data.get("name", "")
184
+ if not tag_name:
185
+ continue
186
+
187
+ last_updated = None
188
+ lu_str = tag_data.get("last_updated")
189
+ if lu_str:
190
+ with contextlib.suppress(ValueError):
191
+ last_updated = datetime.fromisoformat(lu_str.replace("Z", "+00:00"))
192
+
193
+ size, digest, arch, archs = self._parse_images(tag_data.get("images", []))
194
+
195
+ tags.append(
196
+ DockerImage(
197
+ name=safe_name,
198
+ tag=tag_name,
199
+ digest=digest,
200
+ size_bytes=size,
201
+ architecture=arch,
202
+ available_architectures=archs,
203
+ last_updated=last_updated,
204
+ is_official=namespace == "library",
205
+ )
206
+ )
207
+
208
+ url = data.get("next")
209
+ except httpx.HTTPError as e:
210
+ # Network blips or non-429 API errors degrade to a
211
+ # partial result (whatever pages already fetched)
212
+ # instead of crashing the whole search.
213
+ logger.error(f"Docker Hub API error, returning partial results: {e}")
214
+ break
215
+
216
+ return tags[:limit]
217
+
218
+ async def tag_exists(self, image_name: str, tag: str) -> bool | None:
219
+ """Confirm `tag` really exists on Docker Hub.
220
+
221
+ Returns True/False on a definitive API answer, and None when the
222
+ answer is unknown -- the image is not hosted on Docker Hub, or the
223
+ network call failed. `None` must not be reported to the user as
224
+ "tag missing"; it means "not verified".
225
+
226
+ Concurrent checks for the same tag are coalesced onto one request:
227
+ `_verify_tags` fires up to ten of these at once, and on a cold cache
228
+ every one of them would otherwise reach the network before the first
229
+ answer was written back.
230
+ """
231
+ url = build_tag_api_url(image_name, tag)
232
+ if not url:
233
+ return None
234
+
235
+ cache_key = f"hubtag:{image_name}:{tag}"
236
+ if self._cache:
237
+ cached = await self._cache.get(cache_key)
238
+ if isinstance(cached, bool):
239
+ return cached
240
+
241
+ in_flight = self._tag_checks.get(cache_key)
242
+ if in_flight is not None:
243
+ return await asyncio.shield(in_flight)
244
+
245
+ future: asyncio.Future[bool | None] = asyncio.get_running_loop().create_future()
246
+ self._tag_checks[cache_key] = future
247
+ try:
248
+ result = await self._check_tag(image_name, tag, url, cache_key)
249
+ except BaseException as e:
250
+ future.set_exception(e)
251
+ # Nobody may be awaiting this future; without retrieving the
252
+ # exception asyncio logs a spurious "never retrieved" warning.
253
+ future.exception()
254
+ raise
255
+ else:
256
+ future.set_result(result)
257
+ return result
258
+ finally:
259
+ self._tag_checks.pop(cache_key, None)
260
+
261
+ async def _check_tag(self, image_name: str, tag: str, url: str, cache_key: str) -> bool | None:
262
+ try:
263
+ client = await self._get_client()
264
+ resp = await self._get_json(client, url)
265
+ except httpx.HTTPError as e:
266
+ logger.warning(f"Could not verify tag {image_name}:{tag} on Docker Hub: {e}")
267
+ return None
268
+
269
+ if resp.status_code == 404:
270
+ exists = False
271
+ elif resp.is_success:
272
+ exists = True
273
+ else:
274
+ logger.warning(
275
+ f"Unexpected status {resp.status_code} verifying {image_name}:{tag} on Docker Hub"
276
+ )
277
+ return None
278
+
279
+ if self._cache:
280
+ await self._cache.set(cache_key, exists, ttl_seconds=self._tag_ttl_seconds)
281
+ return exists
282
+
283
+ async def get_image_metadata(self, image_name: str, tag: str) -> DockerImage | None:
284
+ safe_name = sanitize_image_name(image_name)
285
+ namespace = "library" if "/" not in safe_name else safe_name.split("/")[0]
286
+ repo = safe_name if "/" not in safe_name else safe_name.split("/", 1)[1]
287
+
288
+ url = f"{self.BASE_URL}/repositories/{namespace}/{repo}/tags/{tag}"
289
+
290
+ client = await self._get_client()
291
+ try:
292
+ resp = await self._get_json(client, url)
293
+ if resp.status_code == 404:
294
+ return None
295
+ resp.raise_for_status()
296
+ data = resp.json()
297
+
298
+ last_updated = None
299
+ lu_str = data.get("last_updated")
300
+ if lu_str:
301
+ with contextlib.suppress(ValueError):
302
+ last_updated = datetime.fromisoformat(lu_str.replace("Z", "+00:00"))
303
+
304
+ size, digest, arch, archs = self._parse_images(data.get("images", []))
305
+
306
+ return DockerImage(
307
+ name=safe_name,
308
+ tag=tag,
309
+ digest=digest,
310
+ size_bytes=size,
311
+ architecture=arch,
312
+ available_architectures=archs,
313
+ last_updated=last_updated,
314
+ is_official=namespace == "library",
315
+ )
316
+ except httpx.HTTPError as e:
317
+ logger.error(f"Failed to get metadata for {safe_name}:{tag}: {e}")
318
+ return None
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ from urllib.parse import quote
4
+
5
+ HUB_WEB_BASE = "https://hub.docker.com"
6
+ HUB_API_BASE = "https://hub.docker.com/v2"
7
+
8
+
9
+ def _is_registry_host(segment: str) -> bool:
10
+ """A leading path segment is a registry host (ghcr.io, cgr.dev,
11
+ registry.internal:5000) rather than a Docker Hub namespace when it
12
+ carries a dot or a port."""
13
+ return "." in segment or ":" in segment
14
+
15
+
16
+ def split_repository(image: str) -> tuple[str, str] | None:
17
+ """Split a Docker Hub image name into (namespace, repository).
18
+
19
+ Official images ("node") map to the implicit "library" namespace, which
20
+ is what the Hub API expects. Returns None for references that are not
21
+ hosted on Docker Hub (e.g. "ghcr.io/org/app", "cgr.dev/chainguard/node")
22
+ so callers never build a link that would 404.
23
+ """
24
+ name = image.strip().strip("/")
25
+ if not name:
26
+ return None
27
+
28
+ # Strip any tag/digest suffix that leaked into the name.
29
+ if "@" in name:
30
+ name = name.split("@", 1)[0]
31
+
32
+ parts = name.split("/")
33
+ if _is_registry_host(parts[0]) and parts[0] not in ("docker.io", "index.docker.io"):
34
+ return None
35
+ if parts[0] in ("docker.io", "index.docker.io"):
36
+ parts = parts[1:]
37
+
38
+ if not parts or not parts[0]:
39
+ return None
40
+ if len(parts) == 1:
41
+ return "library", parts[0]
42
+ if len(parts) == 2:
43
+ return parts[0], parts[1]
44
+ return None
45
+
46
+
47
+ def build_dockerhub_url(image: str, tag: str) -> str:
48
+ """Return the canonical Docker Hub web URL for `image`:`tag`.
49
+
50
+ Official images live under the `_/<repo>` path and expose their tags via
51
+ a query parameter; everything else lives under `r/<ns>/<repo>/tags`.
52
+ Returns "" when the image is not on Docker Hub.
53
+ """
54
+ split = split_repository(image)
55
+ if split is None:
56
+ return ""
57
+ namespace, repo = split
58
+ safe_tag = quote(tag, safe="")
59
+
60
+ if namespace == "library":
61
+ return f"{HUB_WEB_BASE}/_/{quote(repo, safe='')}?tab=tags&name={safe_tag}"
62
+ ns = quote(namespace, safe="")
63
+ return f"{HUB_WEB_BASE}/r/{ns}/{quote(repo, safe='')}/tags?name={safe_tag}"
64
+
65
+
66
+ def build_tag_api_url(image: str, tag: str) -> str:
67
+ """Return the Hub API endpoint that confirms a tag actually exists."""
68
+ split = split_repository(image)
69
+ if split is None:
70
+ return ""
71
+ namespace, repo = split
72
+ return (
73
+ f"{HUB_API_BASE}/repositories/{quote(namespace, safe='')}/"
74
+ f"{quote(repo, safe='')}/tags/{quote(tag, safe='')}"
75
+ )
File without changes