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.
- dockerls/__init__.py +31 -0
- dockerls/application/__init__.py +0 -0
- dockerls/application/dto/__init__.py +3 -0
- dockerls/application/dto/analysis.py +257 -0
- dockerls/application/services/__init__.py +0 -0
- dockerls/application/services/alternatives_lookup.py +167 -0
- dockerls/application/services/composite_repository.py +106 -0
- dockerls/application/services/cross_validation.py +233 -0
- dockerls/application/services/ecosystems.py +350 -0
- dockerls/application/services/fallback_scanner.py +97 -0
- dockerls/application/services/hardening_analysis.py +174 -0
- dockerls/application/services/migration.py +297 -0
- dockerls/application/services/progress.py +56 -0
- dockerls/application/services/remediation.py +321 -0
- dockerls/application/services/scan_history_store.py +88 -0
- dockerls/application/services/scanner_factory.py +88 -0
- dockerls/application/services/source_registry.py +151 -0
- dockerls/application/services/tag_history_store.py +76 -0
- dockerls/application/services/teardown.py +50 -0
- dockerls/application/services/verdict.py +298 -0
- dockerls/application/services/version_discovery.py +108 -0
- dockerls/application/use_cases/__init__.py +0 -0
- dockerls/application/use_cases/analyze_dockerfile.py +103 -0
- dockerls/application/use_cases/analyze_image.py +173 -0
- dockerls/application/use_cases/build_image.py +1795 -0
- dockerls/application/use_cases/compare_images.py +91 -0
- dockerls/application/use_cases/fleet_scan.py +240 -0
- dockerls/application/use_cases/recommend_images.py +1078 -0
- dockerls/application/use_cases/registry_audit.py +133 -0
- dockerls/application/use_cases/search_images.py +23 -0
- dockerls/application/use_cases/upgrade_base.py +167 -0
- dockerls/cache/__init__.py +0 -0
- dockerls/cache/sqlite_cache.py +184 -0
- dockerls/cli/__init__.py +0 -0
- dockerls/cli/analysis_baseline.py +98 -0
- dockerls/cli/app.py +294 -0
- dockerls/cli/commands/__init__.py +0 -0
- dockerls/cli/commands/advisor.py +262 -0
- dockerls/cli/commands/alternatives.py +291 -0
- dockerls/cli/commands/analyze.py +429 -0
- dockerls/cli/commands/analyze_dockerfile.py +104 -0
- dockerls/cli/commands/base_cmd.py +244 -0
- dockerls/cli/commands/base_image.py +551 -0
- dockerls/cli/commands/build.py +1300 -0
- dockerls/cli/commands/cache_cmd.py +104 -0
- dockerls/cli/commands/compare.py +177 -0
- dockerls/cli/commands/controls.py +110 -0
- dockerls/cli/commands/doctor.py +566 -0
- dockerls/cli/commands/export.py +81 -0
- dockerls/cli/commands/fleet.py +159 -0
- dockerls/cli/commands/health.py +84 -0
- dockerls/cli/commands/login.py +53 -0
- dockerls/cli/commands/policy_cmd.py +111 -0
- dockerls/cli/commands/provenance_cmd.py +162 -0
- dockerls/cli/commands/recommend.py +761 -0
- dockerls/cli/commands/registry_audit_cmd.py +103 -0
- dockerls/cli/commands/sbom.py +144 -0
- dockerls/cli/commands/search.py +86 -0
- dockerls/cli/commands/verify.py +115 -0
- dockerls/cli/commands/version.py +12 -0
- dockerls/cli/commands/vex_cmd.py +117 -0
- dockerls/cli/dependencies.py +530 -0
- dockerls/cli/image_names.py +79 -0
- dockerls/cli/options.py +42 -0
- dockerls/cli/progress.py +145 -0
- dockerls/cli/publish_prompt.py +123 -0
- dockerls/cli/rendering.py +214 -0
- dockerls/cli/runtime.py +65 -0
- dockerls/cli/scan_failure.py +71 -0
- dockerls/cli/text.py +39 -0
- dockerls/cli/validators.py +35 -0
- dockerls/cli/vulnerability_view.py +154 -0
- dockerls/domain/__init__.py +0 -0
- dockerls/domain/entities/__init__.py +75 -0
- dockerls/domain/entities/declared_metadata.py +147 -0
- dockerls/domain/entities/dockerfile_analysis.py +318 -0
- dockerls/domain/entities/image.py +109 -0
- dockerls/domain/entities/image_facts.py +137 -0
- dockerls/domain/entities/recommendation.py +33 -0
- dockerls/domain/entities/scan_result.py +129 -0
- dockerls/domain/entities/vulnerability.py +232 -0
- dockerls/domain/interfaces/__init__.py +17 -0
- dockerls/domain/interfaces/cache_store.py +18 -0
- dockerls/domain/interfaces/dockerfile_validator.py +99 -0
- dockerls/domain/interfaces/eol_checker.py +11 -0
- dockerls/domain/interfaces/image_repository.py +15 -0
- dockerls/domain/interfaces/scanner.py +15 -0
- dockerls/domain/security_controls.py +362 -0
- dockerls/domain/value_objects/__init__.py +49 -0
- dockerls/domain/value_objects/attack_surface.py +198 -0
- dockerls/domain/value_objects/base_recipe.py +600 -0
- dockerls/domain/value_objects/base_upgrade.py +292 -0
- dockerls/domain/value_objects/build_labels.py +99 -0
- dockerls/domain/value_objects/build_policy.py +412 -0
- dockerls/domain/value_objects/confidence.py +156 -0
- dockerls/domain/value_objects/fleet.py +174 -0
- dockerls/domain/value_objects/gate.py +327 -0
- dockerls/domain/value_objects/hardening.py +303 -0
- dockerls/domain/value_objects/image_reference.py +90 -0
- dockerls/domain/value_objects/inheritance.py +352 -0
- dockerls/domain/value_objects/network_policy.py +280 -0
- dockerls/domain/value_objects/production_readiness.py +145 -0
- dockerls/domain/value_objects/provenance.py +211 -0
- dockerls/domain/value_objects/recipe_diff.py +188 -0
- dockerls/domain/value_objects/registry_audit.py +195 -0
- dockerls/domain/value_objects/registry_target.py +225 -0
- dockerls/domain/value_objects/remediation_score.py +62 -0
- dockerls/domain/value_objects/scan_history.py +183 -0
- dockerls/domain/value_objects/scan_plan.py +193 -0
- dockerls/domain/value_objects/scanner_db.py +143 -0
- dockerls/domain/value_objects/security_score.py +160 -0
- dockerls/domain/value_objects/security_tier.py +122 -0
- dockerls/domain/value_objects/tag_history.py +180 -0
- dockerls/domain/value_objects/tool_release.py +253 -0
- dockerls/domain/value_objects/tristate.py +47 -0
- dockerls/domain/value_objects/vex.py +249 -0
- dockerls/exit_codes.py +23 -0
- dockerls/exporters/__init__.py +0 -0
- dockerls/exporters/base.py +17 -0
- dockerls/exporters/csv_exporter.py +85 -0
- dockerls/exporters/factory.py +31 -0
- dockerls/exporters/html_exporter.py +105 -0
- dockerls/exporters/json_exporter.py +19 -0
- dockerls/exporters/markdown_exporter.py +84 -0
- dockerls/exporters/sarif_exporter.py +245 -0
- dockerls/infrastructure/__init__.py +0 -0
- dockerls/infrastructure/config/__init__.py +0 -0
- dockerls/infrastructure/config/policy_file.py +165 -0
- dockerls/infrastructure/config/settings.py +197 -0
- dockerls/infrastructure/database/__init__.py +0 -0
- dockerls/infrastructure/database/models.py +78 -0
- dockerls/infrastructure/dockerfile_validator.py +1899 -0
- dockerls/infrastructure/evidence.py +99 -0
- dockerls/infrastructure/hashing.py +165 -0
- dockerls/infrastructure/logging/__init__.py +0 -0
- dockerls/infrastructure/logging/setup.py +98 -0
- dockerls/infrastructure/network/__init__.py +0 -0
- dockerls/infrastructure/network/guarded_client.py +107 -0
- dockerls/infrastructure/network/host_guard.py +117 -0
- dockerls/infrastructure/redaction.py +135 -0
- dockerls/infrastructure/templates/hardening/alpine.dockerfile +44 -0
- dockerls/infrastructure/templates/hardening/debian.dockerfile +45 -0
- dockerls/infrastructure/templates/hardening/distroless.dockerfile +34 -0
- dockerls/infrastructure/templates/hardening/go-alpine.dockerfile +52 -0
- dockerls/infrastructure/templates/hardening/go-debian.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/go-distroless.dockerfile +43 -0
- dockerls/infrastructure/templates/hardening/go-scratch.dockerfile +48 -0
- dockerls/infrastructure/templates/hardening/go.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/gradle-alpine.dockerfile +51 -0
- dockerls/infrastructure/templates/hardening/gradle.dockerfile +52 -0
- dockerls/infrastructure/templates/hardening/java-alpine.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/java-debian.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/java-distroless.dockerfile +39 -0
- dockerls/infrastructure/templates/hardening/java-ubuntu.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/java.dockerfile +60 -0
- dockerls/infrastructure/templates/hardening/maven-alpine.dockerfile +56 -0
- dockerls/infrastructure/templates/hardening/maven.dockerfile +57 -0
- dockerls/infrastructure/templates/hardening/node-alpine.dockerfile +47 -0
- dockerls/infrastructure/templates/hardening/node-debian.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/node-distroless.dockerfile +46 -0
- dockerls/infrastructure/templates/hardening/node-ubuntu.dockerfile +63 -0
- dockerls/infrastructure/templates/hardening/node.dockerfile +61 -0
- dockerls/infrastructure/templates/hardening/php-alpine.dockerfile +45 -0
- dockerls/infrastructure/templates/hardening/php-debian.dockerfile +45 -0
- dockerls/infrastructure/templates/hardening/php-ubuntu.dockerfile +49 -0
- dockerls/infrastructure/templates/hardening/php.dockerfile +44 -0
- dockerls/infrastructure/templates/hardening/python-alpine.dockerfile +51 -0
- dockerls/infrastructure/templates/hardening/python-debian.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/python-distroless.dockerfile +51 -0
- dockerls/infrastructure/templates/hardening/python-ubuntu.dockerfile +60 -0
- dockerls/infrastructure/templates/hardening/python.dockerfile +58 -0
- dockerls/infrastructure/templates/hardening/ruby-alpine.dockerfile +48 -0
- dockerls/infrastructure/templates/hardening/ruby-debian.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/rust-alpine.dockerfile +50 -0
- dockerls/infrastructure/templates/hardening/rust-debian.dockerfile +48 -0
- dockerls/infrastructure/templates/hardening/rust-scratch.dockerfile +44 -0
- dockerls/infrastructure/templates/hardening/rust.dockerfile +54 -0
- dockerls/infrastructure/templates/hardening/ubuntu.dockerfile +49 -0
- dockerls/infrastructure/toolchain/__init__.py +0 -0
- dockerls/infrastructure/toolchain/db_metadata.py +115 -0
- dockerls/infrastructure/toolchain/installer.py +435 -0
- dockerls/integrations/__init__.py +0 -0
- dockerls/integrations/dhi/__init__.py +0 -0
- dockerls/integrations/dhi/catalog.py +457 -0
- dockerls/integrations/dhi/definition.py +151 -0
- dockerls/integrations/dhi/repository.py +238 -0
- dockerls/integrations/dockerhub/__init__.py +0 -0
- dockerls/integrations/dockerhub/client.py +318 -0
- dockerls/integrations/dockerhub/urls.py +75 -0
- dockerls/integrations/endoflife/__init__.py +0 -0
- dockerls/integrations/endoflife/checker.py +216 -0
- dockerls/integrations/engine/__init__.py +0 -0
- dockerls/integrations/engine/batch.py +197 -0
- dockerls/integrations/engine/client.py +330 -0
- dockerls/integrations/engine/locator.py +96 -0
- dockerls/integrations/exploitdb/__init__.py +0 -0
- dockerls/integrations/exploitdb/client.py +271 -0
- dockerls/integrations/grype/__init__.py +0 -0
- dockerls/integrations/grype/scanner.py +336 -0
- dockerls/integrations/registry/__init__.py +0 -0
- dockerls/integrations/registry/hardened.py +284 -0
- dockerls/integrations/registry/inspector.py +420 -0
- dockerls/integrations/registry/oci.py +259 -0
- dockerls/integrations/registry/private.py +79 -0
- dockerls/integrations/registry/urls.py +36 -0
- dockerls/integrations/scan_errors.py +67 -0
- dockerls/integrations/scan_target.py +57 -0
- dockerls/integrations/signing/__init__.py +0 -0
- dockerls/integrations/signing/cosign.py +484 -0
- dockerls/integrations/threat_intel/__init__.py +0 -0
- dockerls/integrations/threat_intel/client.py +290 -0
- dockerls/integrations/trivy/__init__.py +0 -0
- dockerls/integrations/trivy/cache_pool.py +176 -0
- dockerls/integrations/trivy/scanner.py +447 -0
- dockerls/utils/__init__.py +0 -0
- dockerls/utils/auth.py +108 -0
- dockerls/utils/executables.py +39 -0
- dockerls/utils/ignore_file.py +130 -0
- dockerls/utils/rate_limit.py +130 -0
- dockerls/utils/resources.py +183 -0
- dockerls/utils/retry.py +31 -0
- dockerls/utils/safe_yaml.py +166 -0
- dockerls/utils/subprocess_runner.py +216 -0
- dockerls/utils/validation.py +72 -0
- dockerls-1.0.0.dist-info/METADATA +563 -0
- dockerls-1.0.0.dist-info/RECORD +230 -0
- dockerls-1.0.0.dist-info/WHEEL +5 -0
- dockerls-1.0.0.dist-info/entry_points.txt +2 -0
- dockerls-1.0.0.dist-info/licenses/LICENSE +21 -0
- 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
|