mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
399 lines
17 KiB
Python
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]}",
|
|
)
|