Files
prowler/prowler/providers/image/lib/registry/oci_adapter.py
T

399 lines
17 KiB
Python

"""Generic OCI Distribution Spec registry adapter."""
from __future__ import annotations
import base64
import re
import threading
from typing import TYPE_CHECKING
from urllib.parse import urlparse
from prowler.lib.logger import logger
from prowler.providers.image.exceptions.exceptions import (
ImageRegistryAuthError,
ImageRegistryCatalogError,
ImageRegistryNetworkError,
)
from prowler.providers.image.lib.registry.base import RegistryAdapter
if TYPE_CHECKING:
import requests
OCI_MANIFEST_MEDIA_TYPE = "application/vnd.oci.image.manifest.v1+json"
MANIFEST_ACCEPT_TYPES = (
"application/vnd.docker.distribution.manifest.v2+json",
"application/vnd.docker.distribution.manifest.list.v2+json",
OCI_MANIFEST_MEDIA_TYPE,
"application/vnd.oci.image.index.v1+json",
)
# Multi-arch indexes: their children are resolved by trivy itself.
INDEX_MEDIA_TYPES = (
"application/vnd.docker.distribution.manifest.list.v2+json",
"application/vnd.oci.image.index.v1+json",
)
IMAGE_CONFIG_MEDIA_TYPES = (
"application/vnd.oci.image.config.v1+json",
"application/vnd.docker.container.image.v1+json",
)
class OciRegistryAdapter(RegistryAdapter):
"""Adapter for registries implementing OCI Distribution Spec."""
def __init__(
self,
registry_url: str,
username: str | None = None,
password: str | None = None,
token: str | None = None,
verify_ssl: bool = True,
) -> None:
super().__init__(registry_url, username, password, token, verify_ssl)
self._base_url = self._normalise_url(registry_url)
self._bearer_token: str | None = None
self._basic_auth_verified = False
self._anonymous_verified = False
# Enumeration inspects manifests from a thread pool; serialise token
# exchanges so N concurrent 401s don't trigger N auth round-trips.
self._auth_lock = threading.Lock()
def __getstate__(self) -> dict:
state = super().__getstate__()
del state["_auth_lock"]
return state
def __setstate__(self, state: dict) -> None:
self.__dict__.update(state)
self._auth_lock = threading.Lock()
@staticmethod
def _normalise_url(url: str) -> str:
url = url.rstrip("/")
if not url.startswith(("http://", "https://")):
url = f"https://{url}"
return url
def _origin_url(self) -> str:
return self._base_url
@staticmethod
def _find_challenge(www_authenticate: str, scheme: str) -> str | None:
"""Extract one scheme's challenge from a (possibly multi-challenge) header.
RFC 7235 allows several comma-separated challenges in any order, e.g.
``Basic realm="registry", Bearer realm="...",service="..."``.
"""
match = re.search(
rf'(?:^|,)\s*{scheme}\b((?:\s*[\w-]+="[^"]*"\s*,?)*)',
www_authenticate,
re.IGNORECASE,
)
if not match:
return None
return f"{scheme} {match.group(1).strip().rstrip(',')}"
def list_repositories(self) -> list[str]:
self._ensure_auth()
repositories: list[str] = []
url = f"{self._base_url}/v2/_catalog"
params: dict = {"n": 200}
while url:
resp = self._authed_request("GET", url, params=params)
if resp.status_code == 404:
raise ImageRegistryCatalogError(
file=__file__,
message=f"Registry at {self.registry_url} does not support catalog listing (/_catalog returned 404). Use --image or --image-list instead.",
)
self._check_response(resp, "catalog listing")
data = resp.json()
repositories.extend(data.get("repositories", []))
url = self._next_page_url(resp)
params = {}
return repositories
def list_tags(self, repository: str) -> list[str]:
self._ensure_auth(repository=repository)
tags: list[str] = []
url = f"{self._base_url}/v2/{repository}/tags/list"
params: dict = {"n": 200}
while url:
resp = self._authed_request("GET", url, params=params)
self._check_response(resp, f"tag listing for {repository}")
data = resp.json()
tags.extend(data.get("tags", []) or [])
url = self._next_page_url(resp)
params = {}
return tags
def is_container_image(self, repository: str, tag: str) -> bool:
"""Inspect the manifest to tell container images apart from other OCI artifacts.
Uncertainty (network error, unparseable manifest) resolves to True so a
transient failure never silently drops a real image — trivy gives the
final verdict.
"""
url = f"{self._base_url}/v2/{repository}/manifests/{tag}"
try:
self._ensure_auth(repository=repository)
resp = self._authed_request(
"GET", url, headers={"Accept": ", ".join(MANIFEST_ACCEPT_TYPES)}
)
except Exception as error:
logger.warning(
f"Could not fetch manifest for {repository}:{tag}, assuming image: {error}"
)
return True
if resp.status_code != 200:
logger.warning(
f"Manifest request for {repository}:{tag} returned HTTP {resp.status_code}, assuming image"
)
return True
# RFC 9110: media type tokens are case-insensitive
content_type = (
resp.headers.get("Content-Type", "").split(";")[0].strip().lower()
)
if content_type == "application/vnd.docker.distribution.manifest.v2+json":
return True
if content_type in INDEX_MEDIA_TYPES or content_type == OCI_MANIFEST_MEDIA_TYPE:
# Helm charts, cosign signatures, SBOMs... reuse the OCI manifest
# media type, and since image-spec v1.1 an index can also represent
# a non-image artifact; only artifactType (or, for manifests,
# config.mediaType) tells them apart.
try:
manifest = resp.json()
except ValueError:
return True
artifact_type = (manifest.get("artifactType") or "").lower()
if artifact_type:
return artifact_type in IMAGE_CONFIG_MEDIA_TYPES
if content_type in INDEX_MEDIA_TYPES:
# Plain multi-arch index
return True
config_type = (manifest.get("config", {}).get("mediaType") or "").lower()
return config_type in IMAGE_CONFIG_MEDIA_TYPES
logger.info(
f"Skipping {repository}:{tag} — manifest media type {content_type or 'unknown'} is not a container image"
)
return False
def _ensure_auth(self, repository: str | None = None) -> None:
if self._bearer_token or self._basic_auth_verified or self._anonymous_verified:
return
with self._auth_lock:
if (
self._bearer_token
or self._basic_auth_verified
or self._anonymous_verified
):
return
self._authenticate(repository=repository)
def _authenticate(self, repository: str | None = None) -> None:
if self.token:
self._bearer_token = self.token
return
ping_url = f"{self._base_url}/v2/"
resp = self._request_with_retry("GET", ping_url)
if resp.status_code == 200:
self._anonymous_verified = True
return
if resp.status_code == 401:
www_auth = resp.headers.get("Www-Authenticate", "")
bearer_challenge = self._find_challenge(www_auth, "Bearer")
if not bearer_challenge:
# Basic auth challenge (e.g., AWS ECR)
if self.username and self.password:
self._basic_auth_verified = True
return
raise ImageRegistryAuthError(
file=__file__,
message=(
f"Registry {self.registry_url} requires authentication "
f"but no credentials provided. "
f"Set REGISTRY_USERNAME and REGISTRY_PASSWORD."
),
)
# Bearer token exchange (standard OCI flow)
self._bearer_token = self._obtain_bearer_token(bearer_challenge, repository)
return
if resp.status_code == 403:
raise ImageRegistryAuthError(
file=__file__,
message=f"Access denied to registry {self.registry_url} (HTTP 403). Check REGISTRY_USERNAME and REGISTRY_PASSWORD.",
)
raise ImageRegistryNetworkError(
file=__file__,
message=f"Unexpected HTTP {resp.status_code} from registry {self.registry_url} during auth check.",
)
def _obtain_bearer_token(
self, www_authenticate: str, repository: str | None = None
) -> str:
match = re.search(r'realm="([^"]+)"', www_authenticate)
if not match:
raise ImageRegistryAuthError(
file=__file__,
message=f"Cannot parse token endpoint from registry {self.registry_url}. Www-Authenticate: {www_authenticate[:200]}",
)
realm = self._validate_outbound_url(match.group(1))
realm_is_http = urlparse(realm).scheme == "http"
if realm_is_http and urlparse(self._base_url).scheme == "https":
# Transport downgrade: an on-path attacker could read or replace
# the token. An all-HTTP registry is an explicit operator choice.
raise ImageRegistryAuthError(
file=__file__,
message=(
f"Registry {self.registry_url} uses HTTPS but its token realm "
f"{realm} uses HTTP; refusing to exchange a token over cleartext."
),
)
if realm_is_http:
logger.warning(f"Bearer token realm uses HTTP (not HTTPS): {realm}")
params: dict = {}
service_match = re.search(r'service="([^"]+)"', www_authenticate)
if service_match:
params["service"] = service_match.group(1)
scope_match = re.search(r'scope="([^"]+)"', www_authenticate)
if scope_match:
params["scope"] = scope_match.group(1)
elif repository:
params["scope"] = f"repository:{repository}:pull"
auth = None
if self.username and self.password:
auth = (self.username, self.password)
resp = self._request_with_retry("GET", realm, params=params, auth=auth)
if resp.status_code != 200:
raise ImageRegistryAuthError(
file=__file__,
message=f"Failed to obtain bearer token from {realm} (HTTP {resp.status_code}). Check REGISTRY_USERNAME and REGISTRY_PASSWORD.",
)
data = resp.json()
token = data.get("token") or data.get("access_token", "")
if not token:
raise ImageRegistryAuthError(
file=__file__,
message=f"Token endpoint {realm} returned an empty token. Check REGISTRY_USERNAME and REGISTRY_PASSWORD.",
)
return token
def _resolve_basic_credentials(self) -> tuple[str | None, str | None]:
"""Decode pre-encoded base64 auth tokens (e.g., from aws ecr get-authorization-token).
Returns (username, password) — decoded if the password is a base64 token
containing 'username:real_password', otherwise returned as-is.
"""
if not self.password:
return self.username, self.password
try:
decoded = base64.b64decode(self.password).decode("utf-8")
if decoded.startswith(f"{self.username}:"):
return self.username, decoded[len(self.username) + 1 :]
except (ValueError, UnicodeDecodeError):
logger.debug("Password is not a base64-encoded auth token, using as-is")
return self.username, self.password
def _authed_request(self, method: str, url: str, **kwargs) -> requests.Response:
resp = self._do_authed_request(method, url, **kwargs)
if resp.status_code == 401 and self._bearer_token:
challenge = self._find_challenge(
resp.headers.get("Www-Authenticate", ""), "Bearer"
)
if challenge and self._is_same_origin_as_registry(url):
# The cached token may be scoped to another repository; the
# response challenge names the exact scope this endpoint needs.
logger.debug(
f"Bearer token rejected (HTTP 401), re-authenticating with response challenge scope for {url}"
)
fresh_token = self._obtain_bearer_token(challenge)
self._bearer_token = fresh_token
else:
logger.debug(
f"Bearer token rejected (HTTP 401), re-authenticating to {self.registry_url}"
)
self._bearer_token = None
self._ensure_auth()
fresh_token = self._bearer_token
# Retry with the token this request obtained: a concurrent worker
# may have already replaced the shared one with another scope.
resp = self._do_authed_request(
method, url, bearer_token=fresh_token, **kwargs
)
if (
resp.status_code == 401
and self._bearer_token
and self.username
and self.password
and self._is_same_origin_as_registry(url)
and self._find_challenge(resp.headers.get("Www-Authenticate", ""), "Basic")
):
# Registries like Harbor guard some endpoints (e.g. /_catalog) with
# Basic even when /v2/ negotiates Bearer.
logger.debug(
f"Bearer token not accepted for {url}, retrying with Basic auth"
)
user, pwd = self._resolve_basic_credentials()
resp = self._request_with_retry(method, url, auth=(user, pwd), **kwargs)
if resp.ok:
# Stay in Basic mode so later requests (e.g. catalog pages)
# skip the doomed Bearer round-trips.
self._basic_auth_verified = True
self._bearer_token = None
if resp.status_code == 401 and not self._bearer_token:
bearer_challenge = self._find_challenge(
resp.headers.get("Www-Authenticate", ""), "Bearer"
)
if bearer_challenge and self._is_same_origin_as_registry(url):
# The inverse switch: /v2/ negotiated Basic (or anonymous) but
# this endpoint demands Bearer. The challenge carries the right
# scope.
logger.debug(f"Basic auth not accepted for {url}, switching to Bearer")
fresh_token = self._obtain_bearer_token(bearer_challenge)
self._bearer_token = fresh_token
resp = self._do_authed_request(
method, url, bearer_token=fresh_token, **kwargs
)
return resp
def _do_authed_request(
self, method: str, url: str, bearer_token: str | None = None, **kwargs
) -> requests.Response:
headers = kwargs.pop("headers", {})
if self._is_same_origin_as_registry(url):
token = bearer_token or self._bearer_token
if token:
headers["Authorization"] = f"Bearer {token}"
elif self.username and self.password:
user, pwd = self._resolve_basic_credentials()
kwargs.setdefault("auth", (user, pwd))
kwargs["headers"] = headers
return self._request_with_retry(method, url, **kwargs)
def _is_same_origin_as_registry(self, url: str) -> bool:
target = urlparse(url)
origin = urlparse(self._base_url)
return (
target.scheme == origin.scheme
and (target.hostname or "") == (origin.hostname or "")
and target.port == origin.port
)
def _check_response(self, resp: requests.Response, context: str) -> None:
if resp.status_code == 200:
return
if resp.status_code in (401, 403):
raise ImageRegistryAuthError(
file=__file__,
message=f"Authentication failed for {context} on {self.registry_url} (HTTP {resp.status_code}). Check REGISTRY_USERNAME and REGISTRY_PASSWORD.",
)
raise ImageRegistryNetworkError(
file=__file__,
message=f"Unexpected error during {context} on {self.registry_url} (HTTP {resp.status_code}): {resp.text[:200]}",
)