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,420 @@
1
+ """Resolve a tag to a digest and read the image's configuration.
2
+
3
+ This is where "digest-first" stops being a slogan. A tag is a mutable
4
+ pointer: `node:22` names different bytes this week than last, so a
5
+ recommendation that says only `node:22` cannot be checked against the scan
6
+ that produced it. Resolving the tag through the registry gives the manifest
7
+ digest -- the content-addressed identity of the image -- and everything
8
+ downstream (deduplication, caching, the evidence trail, the recommendation
9
+ itself) keys off that instead.
10
+
11
+ The same round-trip yields the OCI image **config**, which is the only
12
+ source of verified runtime facts available without pulling and unpacking
13
+ the image: the account it runs as, the ports it declares, its entrypoint,
14
+ its layer count. Those facts are what the hardening and attack-surface
15
+ models are built from, and they are labelled `REGISTRY` because they were
16
+ measured, not claimed.
17
+
18
+ Integrity is checked rather than assumed: a config blob is addressed by its
19
+ own SHA-256, so the bytes that come back are hashed and compared against the
20
+ digest that was requested. A registry, proxy or cache that returns different
21
+ content fails that comparison and the config is discarded. This is a cheap,
22
+ real supply-chain check, and skipping it would mean trusting a network path
23
+ to describe the image whose security we are about to certify.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import asyncio
29
+ import hashlib
30
+ import json
31
+ import re
32
+ from typing import TYPE_CHECKING, Any
33
+
34
+ from loguru import logger
35
+
36
+ from dockerls.domain.entities.image_facts import EvidenceSource, HardeningFacts
37
+ from dockerls.domain.value_objects.tristate import Tristate
38
+ from dockerls.infrastructure.network.host_guard import HostGuard
39
+ from dockerls.integrations.registry.oci import OCIRegistryClient
40
+
41
+ if TYPE_CHECKING:
42
+ from dockerls.domain.entities.image import DockerImage
43
+
44
+ #: Docker Hub's registry endpoint. Distinct from `hub.docker.com`, which is
45
+ #: the catalogue API the search client uses.
46
+ DOCKER_HUB_REGISTRY = "registry-1.docker.io"
47
+
48
+ #: Media types accepted when asking for a manifest. Both the OCI and the
49
+ #: Docker v2 spellings, indexes first, because a multi-arch tag answers with
50
+ #: an index and the per-architecture manifest has to be selected from it.
51
+ MANIFEST_ACCEPT = ", ".join(
52
+ (
53
+ "application/vnd.oci.image.index.v1+json",
54
+ "application/vnd.docker.distribution.manifest.list.v2+json",
55
+ "application/vnd.oci.image.manifest.v1+json",
56
+ "application/vnd.docker.distribution.manifest.v2+json",
57
+ )
58
+ )
59
+
60
+ #: Architecture preferred when a tag resolves to a multi-arch index.
61
+ DEFAULT_ARCHITECTURE = "amd64"
62
+ DEFAULT_OS = "linux"
63
+
64
+ _DIGEST = re.compile(r"^sha256:[a-f0-9]{64}$")
65
+ #: Registry host: a DNS name with an optional port. Anchored, and no
66
+ #: userinfo, scheme or path can survive it -- this string is interpolated
67
+ #: into a URL, so nothing that could redirect the request is allowed
68
+ #: through.
69
+ _HOST = re.compile(r"^[a-zA-Z0-9]([a-zA-Z0-9.-]{0,251}[a-zA-Z0-9])?(:\d{1,5})?$")
70
+ _REPOSITORY = re.compile(r"^[a-z0-9]+(?:[._/-][a-z0-9]+)*$")
71
+
72
+
73
+ class RegistryInspector:
74
+ """Resolves references to digests and reads image configs, per registry.
75
+
76
+ One `OCIRegistryClient` is kept per host so connections and anonymous
77
+ tokens are reused across every candidate from that registry, and each
78
+ (repository, reference) pair is resolved at most once per run.
79
+ """
80
+
81
+ def __init__(
82
+ self,
83
+ timeout: int = 30,
84
+ guard: HostGuard | None = None,
85
+ credentials: dict[str, tuple[str, str]] | None = None,
86
+ ):
87
+ self._timeout = timeout
88
+ # Where this inspector is permitted to send a request. A reference is
89
+ # user input carrying a hostname, so without a policy `dockerls
90
+ # analyze 169.254.169.254/x` is an outbound request to the cloud
91
+ # metadata endpoint chosen by whoever supplied the reference.
92
+ self._guard = guard or HostGuard()
93
+ # host -> (username, password), for a reference naming a registry
94
+ # this run has credentials for -- the configured private registry,
95
+ # today. `analyze`/`compare`/`alternatives` take a reference
96
+ # directly rather than going through `SourceRegistry`, so this is
97
+ # how they reach the same credentials `--source private` uses.
98
+ self._credentials = credentials or {}
99
+ self._clients: dict[str, OCIRegistryClient] = {}
100
+ self._clients_lock = asyncio.Lock()
101
+ self._resolved: dict[str, tuple[str, dict[str, Any] | None]] = {}
102
+ # Digests learned from a cheap HEAD, kept separately so a later full
103
+ # inspection of the same reference does not re-resolve them.
104
+ self._digests: dict[str, str] = {}
105
+ self._locks: dict[str, asyncio.Lock] = {}
106
+
107
+ async def _client(self, host: str) -> OCIRegistryClient | None:
108
+ if not _HOST.match(host):
109
+ logger.warning(f"Refusing to contact registry with unexpected host: {host!r}")
110
+ return None
111
+ # Checked here rather than at the call sites: this is the single
112
+ # place a host becomes a connection, so a future caller cannot
113
+ # forget the check.
114
+ if not self._guard.allows(host):
115
+ logger.warning(f"Refusing to contact {host}: {self._guard.explain(host)}")
116
+ return None
117
+ async with self._clients_lock:
118
+ client = self._clients.get(host)
119
+ if client is None:
120
+ username, password = self._credentials.get(host, ("", ""))
121
+ client = OCIRegistryClient(
122
+ host,
123
+ timeout=self._timeout,
124
+ guard=self._guard,
125
+ username=username,
126
+ password=password,
127
+ )
128
+ self._clients[host] = client
129
+ return client
130
+
131
+ async def close(self) -> None:
132
+ async with self._clients_lock:
133
+ clients, self._clients = list(self._clients.values()), {}
134
+ for client in clients:
135
+ await client.close()
136
+
137
+ async def resolve_digest(self, image: DockerImage) -> str:
138
+ """The manifest digest for `image`, from a single HEAD request.
139
+
140
+ This is the cheap half of `inspect`, and it runs over every
141
+ candidate rather than just the finalists: without a digest, two tags
142
+ that name the same bytes look like two different images and are
143
+ scanned twice. One HEAD per unresolved tag buys deduplication across
144
+ *every* source -- `node:22`, `node:22-bookworm` and a hardened
145
+ catalogue's alias of the same manifest collapse into one scan.
146
+
147
+ Returns "" when the registry cannot be asked or does not answer,
148
+ which leaves the candidate keyed by its reference as before.
149
+ """
150
+ target = _registry_target(image)
151
+ if target is None:
152
+ return ""
153
+ host, repository = target
154
+ key = f"{host}/{repository}:{image.tag}"
155
+
156
+ lock = self._locks.setdefault(key, asyncio.Lock())
157
+ async with lock:
158
+ if key in self._digests:
159
+ return self._digests[key]
160
+ client = await self._client(host)
161
+ digest = ""
162
+ if client is not None:
163
+ resp = await client.get(
164
+ f"{repository}/manifests/{image.tag}", accept=MANIFEST_ACCEPT, head=True
165
+ )
166
+ if resp is not None:
167
+ digest = _clean_digest(resp.headers.get("Docker-Content-Digest", ""))
168
+ self._digests[key] = digest
169
+ return digest
170
+
171
+ async def inspect(self, image: DockerImage) -> tuple[str, HardeningFacts]:
172
+ """Return (digest, facts) for `image`.
173
+
174
+ The digest is "" when the registry could not be asked or did not
175
+ answer, and the facts are empty in the same case. Both outcomes mean
176
+ "not determined": the caller keeps whatever it already had and the
177
+ candidate carries UNKNOWNs rather than fabricated defaults.
178
+ """
179
+ target = _registry_target(image)
180
+ if target is None:
181
+ return "", HardeningFacts()
182
+ host, repository = target
183
+ reference = image.digest if _DIGEST.match(image.digest) else image.tag
184
+ key = f"{host}/{repository}:{reference}"
185
+
186
+ lock = self._locks.setdefault(key, asyncio.Lock())
187
+ async with lock:
188
+ if key in self._resolved:
189
+ digest, config = self._resolved[key]
190
+ else:
191
+ digest, config = await self._resolve(host, repository, reference)
192
+ self._resolved[key] = (digest, config)
193
+ if digest:
194
+ self._digests.setdefault(f"{host}/{repository}:{image.tag}", digest)
195
+
196
+ return digest, _facts_from_config(config)
197
+
198
+ async def _resolve(
199
+ self, host: str, repository: str, reference: str
200
+ ) -> tuple[str, dict[str, Any] | None]:
201
+ client = await self._client(host)
202
+ if client is None:
203
+ return "", None
204
+
205
+ resp = await client.get(f"{repository}/manifests/{reference}", accept=MANIFEST_ACCEPT)
206
+ if resp is None:
207
+ return "", None
208
+
209
+ digest = _clean_digest(resp.headers.get("Docker-Content-Digest", ""))
210
+ try:
211
+ manifest: Any = resp.json()
212
+ except ValueError:
213
+ logger.warning(f"Registry returned an unparseable manifest for {host}/{repository}")
214
+ return digest, None
215
+ if not isinstance(manifest, dict):
216
+ return digest, None
217
+
218
+ manifests = manifest.get("manifests")
219
+ if isinstance(manifests, list):
220
+ # A multi-arch index. The index digest is the image's identity,
221
+ # so it is kept; the per-architecture manifest is followed only
222
+ # to reach the config.
223
+ child = _select_platform(manifests)
224
+ if child is None:
225
+ return digest, None
226
+ child_digest = _clean_digest(str(child.get("digest") or ""))
227
+ if not child_digest:
228
+ return digest, None
229
+ child_resp = await client.get(
230
+ f"{repository}/manifests/{child_digest}", accept=MANIFEST_ACCEPT
231
+ )
232
+ if child_resp is None:
233
+ return digest, None
234
+ try:
235
+ manifest = child_resp.json()
236
+ except ValueError:
237
+ return digest, None
238
+ if not isinstance(manifest, dict):
239
+ return digest, None
240
+
241
+ config = await self._fetch_config(client, host, repository, manifest)
242
+ return digest, config
243
+
244
+ async def _fetch_config(
245
+ self,
246
+ client: OCIRegistryClient,
247
+ host: str,
248
+ repository: str,
249
+ manifest: dict[str, Any],
250
+ ) -> dict[str, Any] | None:
251
+ descriptor = manifest.get("config")
252
+ if not isinstance(descriptor, dict):
253
+ return None
254
+ config_digest = _clean_digest(str(descriptor.get("digest") or ""))
255
+ if not config_digest:
256
+ return None
257
+
258
+ resp = await client.get(f"{repository}/blobs/{config_digest}")
259
+ if resp is None:
260
+ return None
261
+
262
+ # Content addressing, actually checked. A blob whose bytes do not
263
+ # hash to the digest that named it is not the config this manifest
264
+ # points at, whatever the registry says.
265
+ actual = f"sha256:{hashlib.sha256(resp.content).hexdigest()}"
266
+ if actual != config_digest:
267
+ logger.warning(
268
+ f"Config blob digest mismatch for {host}/{repository}: "
269
+ f"requested {config_digest}, received {actual}. Discarding."
270
+ )
271
+ return None
272
+
273
+ try:
274
+ config: Any = json.loads(resp.content)
275
+ except ValueError:
276
+ logger.warning(f"Config blob for {host}/{repository} was not valid JSON")
277
+ return None
278
+ if not isinstance(config, dict):
279
+ return None
280
+ # The layer list lives on the manifest, not the config, and it is
281
+ # what gives a verified layer count.
282
+ layers = manifest.get("layers")
283
+ if isinstance(layers, list):
284
+ config["__layers"] = layers
285
+ return config
286
+
287
+
288
+ def _registry_target(image: DockerImage) -> tuple[str, str] | None:
289
+ """Split an image name into (registry host, repository path).
290
+
291
+ Unqualified names are Docker Hub, where a single-component name lives
292
+ under `library/`. Returns None for anything whose host or repository
293
+ does not match the expected shape, so a crafted name cannot become a
294
+ request to an arbitrary URL.
295
+ """
296
+ name = image.name.strip()
297
+ host = image.registry_host
298
+ repository = name[len(host) + 1 :] if host else name
299
+ if not host:
300
+ host = DOCKER_HUB_REGISTRY
301
+ if "/" not in repository:
302
+ repository = f"library/{repository}"
303
+
304
+ if not _HOST.match(host) or not _REPOSITORY.match(repository):
305
+ logger.info(f"Not resolving {name!r}: unexpected registry or repository shape")
306
+ return None
307
+ return host, repository
308
+
309
+
310
+ def _select_platform(manifests: list[Any]) -> dict[str, Any] | None:
311
+ """Pick the linux/amd64 entry of an index, or the first usable one.
312
+
313
+ Attestation manifests (cosign, SLSA) appear alongside real images in
314
+ modern indexes and declare `platform.architecture: unknown`; selecting
315
+ one would produce a config describing a signature rather than an image.
316
+ """
317
+ usable: list[dict[str, Any]] = []
318
+ for entry in manifests:
319
+ if not isinstance(entry, dict):
320
+ continue
321
+ platform = entry.get("platform")
322
+ platform = platform if isinstance(platform, dict) else {}
323
+ architecture = str(platform.get("architecture") or "")
324
+ if architecture == "unknown":
325
+ continue
326
+ if architecture == DEFAULT_ARCHITECTURE and str(platform.get("os") or "") == DEFAULT_OS:
327
+ return entry
328
+ usable.append(entry)
329
+ return usable[0] if usable else None
330
+
331
+
332
+ def _clean_digest(value: str) -> str:
333
+ digest = value.strip().lower()
334
+ return digest if _DIGEST.match(digest) else ""
335
+
336
+
337
+ def _facts_from_config(config: dict[str, Any] | None) -> HardeningFacts:
338
+ """Turn an OCI image config into verified facts.
339
+
340
+ The one judgement call worth stating: an image whose config sets no
341
+ `User` runs as root. That is a determined FALSE for `runs_as_non_root`,
342
+ not an UNKNOWN -- the config was read and it says the default account is
343
+ root. The distinction matters, because "the image does not say" and
344
+ "nobody looked" have opposite consequences for a score.
345
+ """
346
+ if config is None:
347
+ return HardeningFacts()
348
+
349
+ section = config.get("config")
350
+ section = section if isinstance(section, dict) else {}
351
+
352
+ user = str(section.get("User") or "").strip()
353
+ ports = _ports(section.get("ExposedPorts"))
354
+ entrypoint = _string_list(section.get("Entrypoint"))
355
+ cmd = _string_list(section.get("Cmd"))
356
+ layers = config.get("__layers")
357
+ layer_count = len(layers) if isinstance(layers, list) else None
358
+ size = _total_size(layers) if isinstance(layers, list) else None
359
+
360
+ evidence = {
361
+ "runs_as_non_root": EvidenceSource.REGISTRY,
362
+ "exposed_ports": EvidenceSource.REGISTRY,
363
+ "has_healthcheck": EvidenceSource.REGISTRY,
364
+ "entrypoint": EvidenceSource.REGISTRY,
365
+ }
366
+ if layer_count is not None:
367
+ evidence["layer_count"] = EvidenceSource.REGISTRY
368
+
369
+ return HardeningFacts(
370
+ runs_as_non_root=Tristate.of(bool(user) and user.split(":", 1)[0] not in ("root", "0")),
371
+ user=user,
372
+ exposed_ports=ports,
373
+ # A config that declares no healthcheck was still read: Docker treats
374
+ # an absent Healthcheck as "none", which is a determined fact.
375
+ has_healthcheck=Tristate.of(bool(section.get("Healthcheck"))),
376
+ entrypoint=entrypoint,
377
+ cmd=cmd,
378
+ layer_count=layer_count,
379
+ size_bytes=size,
380
+ os_family=str(config.get("os") or ""),
381
+ config_verified=True,
382
+ evidence=evidence,
383
+ )
384
+
385
+
386
+ def _ports(value: Any) -> list[int]:
387
+ """Port numbers from an OCI `ExposedPorts` map ("8080/tcp": {})."""
388
+ if not isinstance(value, dict):
389
+ return []
390
+ ports: list[int] = []
391
+ for key in value:
392
+ if not isinstance(key, str):
393
+ continue
394
+ head = key.split("/", 1)[0]
395
+ try:
396
+ port = int(head)
397
+ except ValueError:
398
+ continue
399
+ if 0 < port <= 65535:
400
+ ports.append(port)
401
+ return sorted(set(ports))
402
+
403
+
404
+ def _string_list(value: Any) -> list[str]:
405
+ if not isinstance(value, list):
406
+ return []
407
+ return [item for item in value if isinstance(item, str)]
408
+
409
+
410
+ def _total_size(layers: list[Any]) -> int | None:
411
+ total = 0
412
+ seen = False
413
+ for layer in layers:
414
+ if not isinstance(layer, dict):
415
+ continue
416
+ size = layer.get("size")
417
+ if isinstance(size, int) and size >= 0:
418
+ total += size
419
+ seen = True
420
+ return total if seen else None
@@ -0,0 +1,259 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import re
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ import httpx
8
+ from loguru import logger
9
+
10
+ from dockerls.infrastructure.network.guarded_client import guarded_async_client
11
+
12
+ if TYPE_CHECKING:
13
+ from dockerls.infrastructure.network.host_guard import HostGuard
14
+
15
+ # Cosign and friends publish their signatures, attestations and SBOMs as
16
+ # ordinary tags in the same repository. They are not runnable images, so
17
+ # they must never reach the scan pipeline.
18
+ _ARTIFACT_TAG = re.compile(
19
+ r"""
20
+ ^sha256[-:] # cosign artifacts: sha256-<digest>.sig/.att/.sbom
21
+ | \.(sig|att|sbom)$
22
+ | ^deprecated-public-image-
23
+ """,
24
+ re.VERBOSE,
25
+ )
26
+
27
+ # Single-architecture aliases of a multi-arch tag ("16-amd64"). Scanning them
28
+ # adds duplicates of a tag we already have.
29
+ _ARCH_SUFFIX = re.compile(r"-(amd64|arm64|arm|armv[567]|386|ppc64le|s390x|riscv64|mips64le|wasm)$")
30
+
31
+ # Provenance-pinned variants -- either suffixed ("debug-nonroot-165b5d63...")
32
+ # or a bare commit hash -- point at the same image as their base tag.
33
+ # Distroless publishes dozens per release; unfiltered they crowd out every
34
+ # distinct image in the listing.
35
+ _COMMIT_TAG = re.compile(r"(-[0-9a-f]{32,}$|^[0-9a-f]{32,}$)")
36
+
37
+
38
+ #: Manifests and config blobs are kilobytes. A registry (or something
39
+ #: pretending to be one) answering with more than this is not serving
40
+ #: metadata, and the body is discarded rather than parsed.
41
+ MAX_BLOB_BYTES = 8 * 1024 * 1024
42
+
43
+
44
+ def is_runnable_tag(tag: str) -> bool:
45
+ """True for tags that name a distinct image a user would actually pull."""
46
+ if not tag or _ARTIFACT_TAG.search(tag):
47
+ return False
48
+ if _COMMIT_TAG.search(tag):
49
+ return False
50
+ return not _ARCH_SUFFIX.search(tag)
51
+
52
+
53
+ def parse_www_authenticate(header: str) -> tuple[str, dict[str, str]]:
54
+ """Split a `Bearer realm="...",service="...",scope="..."` challenge into
55
+ (realm, params)."""
56
+ if not header.lower().startswith("bearer"):
57
+ return "", {}
58
+ params = dict(re.findall(r'(\w+)="([^"]*)"', header))
59
+ return params.pop("realm", ""), params
60
+
61
+
62
+ def is_fetchable_realm(realm: str) -> bool:
63
+ """Whether a `WWW-Authenticate` realm is a URL we will actually fetch.
64
+
65
+ Requires an absolute http/https URL with a host. That rules out
66
+ `file://`, `gopher://` and the schemeless forms; *where* the host may be
67
+ is the network policy's business, enforced per hop by the guarded
68
+ client, and is deliberately not re-decided here.
69
+ """
70
+ try:
71
+ url = httpx.URL(realm)
72
+ except (httpx.InvalidURL, ValueError, TypeError, UnicodeError):
73
+ return False
74
+ return url.scheme in ("http", "https") and bool(url.host)
75
+
76
+
77
+ class OCIRegistryClient:
78
+ """Minimal OCI Distribution v2 client for listing tags.
79
+
80
+ Implements only the anonymous pull-scope token dance that public
81
+ registries use: request the endpoint, and if it answers 401 with a
82
+ Bearer challenge, fetch a token from the advertised realm and retry.
83
+
84
+ A listing is fetched **once per repository per run**. `recommend` asks
85
+ for the same listing many times over -- once during discovery, then once
86
+ more for every candidate whose tag `_verify_tags` confirms -- and each
87
+ call previously opened a fresh connection, ate a 401, fetched a token,
88
+ and re-downloaded a payload identical to the one it already had. For a
89
+ single repository with ten candidates that is 33 requests where 3 do the
90
+ job.
91
+
92
+ Three things close that gap, and they compose:
93
+
94
+ * One `httpx.AsyncClient` for the client's lifetime, so connections and
95
+ the TLS handshake are reused (HTTP keep-alive) instead of rebuilt.
96
+ * A per-repository result cache.
97
+ * A per-repository lock, so ten *concurrent* first calls collapse into
98
+ one request rather than ten -- a cache with no single-flight guard
99
+ would still stampede, because verification runs them in parallel.
100
+
101
+ The cache lives on the instance, so it lasts exactly one run and cannot
102
+ serve a listing from a previous invocation.
103
+ """
104
+
105
+ def __init__(
106
+ self,
107
+ host: str,
108
+ timeout: int = 30,
109
+ guard: HostGuard | None = None,
110
+ *,
111
+ username: str = "",
112
+ password: str = "",
113
+ ):
114
+ self._host = host
115
+ self._timeout = timeout
116
+ # Redirects are followed, and the token realm is a URL this registry
117
+ # chooses. Both are hops the caller's up-front check on `host` says
118
+ # nothing about, so the guard travels with the client.
119
+ self._guard = guard
120
+ # Basic credentials for the token endpoint -- the standard Docker
121
+ # Registry HTTP API V2 flow every private registry this client
122
+ # targets (ECR, Harbor, GHCR, a generic OCI registry) implements the
123
+ # same way: the 401 challenge names a realm, and that realm accepts
124
+ # HTTP Basic auth in exchange for a scoped bearer token. Empty
125
+ # strings mean anonymous, unchanged from before this parameter
126
+ # existed.
127
+ self._username = username
128
+ self._password = password
129
+ self._client: httpx.AsyncClient | None = None
130
+ self._client_lock = asyncio.Lock()
131
+ self._listings: dict[str, dict[str, Any] | None] = {}
132
+ self._listing_locks: dict[str, asyncio.Lock] = {}
133
+
134
+ @property
135
+ def host(self) -> str:
136
+ return self._host
137
+
138
+ async def _get_client(self) -> httpx.AsyncClient:
139
+ if self._client is None:
140
+ async with self._client_lock:
141
+ if self._client is None:
142
+ self._client = guarded_async_client(
143
+ self._guard, timeout=self._timeout, follow_redirects=True
144
+ )
145
+ return self._client
146
+
147
+ async def close(self) -> None:
148
+ """Release the shared connection pool."""
149
+ client, self._client = self._client, None
150
+ if client is not None:
151
+ await client.aclose()
152
+
153
+ async def _token(self, client: httpx.AsyncClient, challenge: str) -> str:
154
+ realm, params = parse_www_authenticate(challenge)
155
+ if not realm:
156
+ return ""
157
+ if not is_fetchable_realm(realm):
158
+ # The realm is a URL chosen by the far end. Fetching whatever it
159
+ # names turns any registry -- or anything on the path to one --
160
+ # into a redirector pointing this process at the host's internal
161
+ # network. An `https://` realm on a real registry is the norm;
162
+ # anything else is refused before a socket is opened.
163
+ logger.warning(f"Refusing token realm advertised by {self._host}: {realm!r}")
164
+ return ""
165
+ auth = (self._username, self._password) if self._username and self._password else None
166
+ resp = await client.get(realm, params=params, auth=auth)
167
+ resp.raise_for_status()
168
+ data = resp.json()
169
+ # Registries disagree on the field name; GCR/ECR use access_token.
170
+ token: str = data.get("token") or data.get("access_token") or ""
171
+ return token
172
+
173
+ async def list_tags(self, repository: str) -> dict[str, Any] | None:
174
+ """Return the raw `/v2/<repository>/tags/list` payload, or None when
175
+ the repository does not exist or cannot be reached.
176
+
177
+ Memoised per repository for the lifetime of this client, including
178
+ the `None` outcome: a repository that does not exist should be asked
179
+ about once, not once per candidate.
180
+ """
181
+ if repository in self._listings:
182
+ return self._listings[repository]
183
+
184
+ lock = self._listing_locks.setdefault(repository, asyncio.Lock())
185
+ async with lock:
186
+ # A concurrent caller may have filled it while we waited.
187
+ if repository in self._listings:
188
+ return self._listings[repository]
189
+ payload = await self._fetch_tags(repository)
190
+ self._listings[repository] = payload
191
+ return payload
192
+
193
+ async def get(
194
+ self,
195
+ path: str,
196
+ *,
197
+ accept: str = "",
198
+ head: bool = False,
199
+ max_bytes: int = MAX_BLOB_BYTES,
200
+ ) -> httpx.Response | None:
201
+ """One authenticated request against `/v2/<path>` on this registry.
202
+
203
+ Performs the same anonymous token dance `_fetch_tags` uses, and
204
+ bounds the response body: a manifest or config blob is a few
205
+ kilobytes, and a registry answering with megabytes is either broken
206
+ or hostile. Returns None on any failure, including an oversized
207
+ body -- callers treat that as "could not determine", never as an
208
+ empty result.
209
+ """
210
+ url = f"https://{self._host}/v2/{path}"
211
+ headers = {"Accept": accept} if accept else {}
212
+ try:
213
+ client = await self._get_client()
214
+ resp = await client.request("HEAD" if head else "GET", url, headers=headers)
215
+ if resp.status_code == 401:
216
+ token = await self._token(client, resp.headers.get("WWW-Authenticate", ""))
217
+ if not token:
218
+ logger.info(f"No anonymous token available for {self._host}/{path}")
219
+ return None
220
+ resp = await client.request(
221
+ "HEAD" if head else "GET",
222
+ url,
223
+ headers={**headers, "Authorization": f"Bearer {token}"},
224
+ )
225
+ except (httpx.HTTPError, ValueError) as e:
226
+ logger.warning(f"Registry request failed for {self._host}/{path}: {e}")
227
+ return None
228
+
229
+ if not resp.is_success:
230
+ logger.info(f"Registry answered {resp.status_code} for {self._host}/{path}")
231
+ return None
232
+ if not head and len(resp.content) > max_bytes:
233
+ logger.warning(
234
+ f"Registry response for {self._host}/{path} exceeded {max_bytes} bytes; discarded"
235
+ )
236
+ return None
237
+ return resp
238
+
239
+ async def _fetch_tags(self, repository: str) -> dict[str, Any] | None:
240
+ url = f"https://{self._host}/v2/{repository}/tags/list"
241
+ try:
242
+ client = await self._get_client()
243
+ resp = await client.get(url)
244
+ if resp.status_code == 401:
245
+ token = await self._token(client, resp.headers.get("WWW-Authenticate", ""))
246
+ if not token:
247
+ logger.warning(f"No anonymous token available for {self._host}")
248
+ return None
249
+ resp = await client.get(url, headers={"Authorization": f"Bearer {token}"})
250
+
251
+ if resp.status_code == 404:
252
+ logger.info(f"Repository not found: {self._host}/{repository}")
253
+ return None
254
+ resp.raise_for_status()
255
+ payload: dict[str, Any] = resp.json()
256
+ return payload
257
+ except (httpx.HTTPError, ValueError) as e:
258
+ logger.warning(f"Tag listing failed for {self._host}/{repository}: {e}")
259
+ return None