From d9e451489d2357d67c215c19ebebda36049fb132 Mon Sep 17 00:00:00 2001 From: "Andoni A." <14891798+andoniaf@users.noreply.github.com> Date: Tue, 17 Feb 2026 10:01:35 +0100 Subject: [PATCH] fix(image): harden registry adapter layer - Fix shared dict mutation in ImageBaseException (thread-safety) - Handle unexpected status codes in OCI adapter auth flow - Force TLS verification for Docker Hub endpoints - Add credential-safe __repr__ to RegistryAdapter - Rename _next_page_url override to _next_tag_page_url in DockerHubAdapter - Distinguish rate-limit from connection errors in retry logic - Add type hints to all public adapter methods - Remove unused imports in test files (flake8) --- .../providers/image/exceptions/exceptions.py | 4 +- prowler/providers/image/lib/registry/base.py | 33 +++- .../image/lib/registry/dockerhub_adapter.py | 57 ++++--- .../providers/image/lib/registry/factory.py | 11 +- .../image/lib/registry/oci_adapter.py | 45 +++-- .../image/lib/registry/test_arguments.py | 154 ++++++++++++++++-- .../image/lib/registry/test_factory.py | 19 ++- .../lib/registry/test_provider_registry.py | 1 - 8 files changed, 251 insertions(+), 73 deletions(-) diff --git a/prowler/providers/image/exceptions/exceptions.py b/prowler/providers/image/exceptions/exceptions.py index fdf2445474..509a3f1c3e 100644 --- a/prowler/providers/image/exceptions/exceptions.py +++ b/prowler/providers/image/exceptions/exceptions.py @@ -82,8 +82,8 @@ class ImageBaseException(ProwlerException): def __init__(self, code, file=None, original_exception=None, message=None): error_info = self.IMAGE_ERROR_CODES.get((code, self.__class__.__name__)) - if message: - error_info["message"] = message + if error_info and message: + error_info = {**error_info, "message": message} super().__init__( code, source="Image", diff --git a/prowler/providers/image/lib/registry/base.py b/prowler/providers/image/lib/registry/base.py index 617235dc88..51eb1a1e62 100644 --- a/prowler/providers/image/lib/registry/base.py +++ b/prowler/providers/image/lib/registry/base.py @@ -9,9 +9,7 @@ from abc import ABC, abstractmethod import requests from prowler.lib.logger import logger -from prowler.providers.image.exceptions.exceptions import ( - ImageRegistryNetworkError, -) +from prowler.providers.image.exceptions.exceptions import ImageRegistryNetworkError _MAX_RETRIES = 3 _BACKOFF_BASE = 1 @@ -21,14 +19,28 @@ class RegistryAdapter(ABC): """Abstract base class for registry adapters.""" def __init__( - self, registry_url, username=None, password=None, token=None, verify_ssl=True - ): + self, + registry_url: str, + username: str | None = None, + password: str | None = None, + token: str | None = None, + verify_ssl: bool = True, + ) -> None: self.registry_url = registry_url self.username = username self.password = password self.token = token self.verify_ssl = verify_ssl + def __repr__(self) -> str: + return ( + f"{self.__class__.__name__}(" + f"registry_url={self.registry_url!r}, " + f"username={self.username!r}, " + f"password={'' if self.password else None}, " + f"token={'' if self.token else None})" + ) + @abstractmethod def list_repositories(self) -> list[str]: """Enumerate all repository names in the registry.""" @@ -39,15 +51,17 @@ class RegistryAdapter(ABC): """Enumerate all tags for a repository.""" ... - def _request_with_retry(self, method, url, **kwargs): + def _request_with_retry(self, method: str, url: str, **kwargs) -> requests.Response: context_label = kwargs.pop("context_label", None) or self.registry_url kwargs.setdefault("timeout", 30) kwargs.setdefault("verify", self.verify_ssl) last_exception = None + last_status = None for attempt in range(1, _MAX_RETRIES + 1): try: resp = requests.request(method, url, **kwargs) if resp.status_code == 429: + last_status = 429 wait = _BACKOFF_BASE * (2 ** (attempt - 1)) logger.warning( f"Rate limited by {context_label}, retrying in {wait}s (attempt {attempt}/{_MAX_RETRIES})" @@ -70,6 +84,11 @@ class RegistryAdapter(ABC): message=f"Connection timed out to {context_label}.", original_exception=exc, ) + if last_status == 429: + raise ImageRegistryNetworkError( + file=__file__, + message=f"Rate limited by {context_label} after {_MAX_RETRIES} attempts.", + ) raise ImageRegistryNetworkError( file=__file__, message=f"Failed to connect to {context_label} after {_MAX_RETRIES} attempts.", @@ -77,7 +96,7 @@ class RegistryAdapter(ABC): ) @staticmethod - def _next_page_url(resp): + def _next_page_url(resp: requests.Response) -> str | None: link_header = resp.headers.get("Link", "") if not link_header: return None diff --git a/prowler/providers/image/lib/registry/dockerhub_adapter.py b/prowler/providers/image/lib/registry/dockerhub_adapter.py index 984cd30114..154adc6331 100644 --- a/prowler/providers/image/lib/registry/dockerhub_adapter.py +++ b/prowler/providers/image/lib/registry/dockerhub_adapter.py @@ -4,6 +4,8 @@ from __future__ import annotations import re +import requests + from prowler.lib.logger import logger from prowler.providers.image.exceptions.exceptions import ( ImageRegistryAuthError, @@ -21,15 +23,24 @@ class DockerHubAdapter(RegistryAdapter): """Adapter for Docker Hub using the Hub REST API + OCI tag listing.""" def __init__( - self, registry_url, username=None, password=None, token=None, verify_ssl=True - ): - super().__init__(registry_url, username, password, token, verify_ssl) + self, + registry_url: str, + username: str | None = None, + password: str | None = None, + token: str | None = None, + verify_ssl: bool = True, + ) -> None: + if not verify_ssl: + logger.warning( + "Docker Hub always uses TLS verification; --registry-insecure is ignored for Docker Hub registries." + ) + super().__init__(registry_url, username, password, token, verify_ssl=True) self.namespace = self._extract_namespace(registry_url) - self._hub_jwt = None - self._registry_tokens = {} + self._hub_jwt: str | None = None + self._registry_tokens: dict[str, str] = {} @staticmethod - def _extract_namespace(registry_url): + def _extract_namespace(registry_url: str) -> str: url = registry_url.rstrip("/") for prefix in ( "https://registry-1.docker.io", @@ -49,19 +60,19 @@ class DockerHubAdapter(RegistryAdapter): namespace = parts[0] if parts and parts[0] else "" return namespace - def list_repositories(self): + def list_repositories(self) -> list[str]: if not self.namespace: raise ImageRegistryAuthError( file=__file__, message="Docker Hub requires a namespace. Use --registry docker.io/{org_or_user}.", ) self._hub_login() - repositories = [] + repositories: list[str] = [] if self._hub_jwt: url = f"{_HUB_API}/v2/namespaces/{self.namespace}/repositories" else: url = f"{_HUB_API}/v2/repositories/{self.namespace}/" - params = {"page_size": 100} + params: dict = {"page_size": 100} while url: resp = self._hub_request("GET", url, params=params) self._check_hub_response(resp, "repository listing") @@ -74,11 +85,11 @@ class DockerHubAdapter(RegistryAdapter): params = {} return repositories - def list_tags(self, repository): + def list_tags(self, repository: str) -> list[str]: token = self._get_registry_token(repository) - tags = [] + tags: list[str] = [] url = f"{_REGISTRY_HOST}/v2/{repository}/tags/list" - params = {"n": 100} + params: dict = {"n": 100} while url: resp = self._registry_request("GET", url, token, params=params) if resp.status_code in (401, 403): @@ -93,11 +104,11 @@ class DockerHubAdapter(RegistryAdapter): break data = resp.json() tags.extend(data.get("tags", []) or []) - url = self._next_page_url(resp) + url = self._next_tag_page_url(resp) params = {} return tags - def _hub_login(self): + def _hub_login(self) -> None: if self._hub_jwt: return if not self.username or not self.password: @@ -115,7 +126,7 @@ class DockerHubAdapter(RegistryAdapter): ) self._hub_jwt = resp.json().get("token") - def _get_registry_token(self, repository): + def _get_registry_token(self, repository: str) -> str: if repository in self._registry_tokens: return self._registry_tokens[repository] params = { @@ -126,7 +137,11 @@ class DockerHubAdapter(RegistryAdapter): if self.username and self.password: auth = (self.username, self.password) resp = self._request_with_retry( - "GET", _AUTH_URL, params=params, auth=auth, context_label="Docker Hub", + "GET", + _AUTH_URL, + params=params, + auth=auth, + context_label="Docker Hub", ) if resp.status_code != 200: raise ImageRegistryAuthError( @@ -137,7 +152,7 @@ class DockerHubAdapter(RegistryAdapter): self._registry_tokens[repository] = token return token - def _hub_request(self, method, url, **kwargs): + def _hub_request(self, method: str, url: str, **kwargs) -> requests.Response: headers = kwargs.pop("headers", {}) if self._hub_jwt: headers["Authorization"] = f"Bearer {self._hub_jwt}" @@ -146,7 +161,9 @@ class DockerHubAdapter(RegistryAdapter): method, url, context_label="Docker Hub", **kwargs ) - def _registry_request(self, method, url, token, **kwargs): + def _registry_request( + self, method: str, url: str, token: str, **kwargs + ) -> requests.Response: headers = kwargs.pop("headers", {}) headers["Authorization"] = f"Bearer {token}" kwargs["headers"] = headers @@ -154,7 +171,7 @@ class DockerHubAdapter(RegistryAdapter): method, url, context_label="Docker Hub", **kwargs ) - def _check_hub_response(self, resp, context): + def _check_hub_response(self, resp: requests.Response, context: str) -> None: if resp.status_code == 200: return if resp.status_code in (401, 403): @@ -173,7 +190,7 @@ class DockerHubAdapter(RegistryAdapter): ) @staticmethod - def _next_page_url(resp): + def _next_tag_page_url(resp: requests.Response) -> str | None: link_header = resp.headers.get("Link", "") if not link_header: return None diff --git a/prowler/providers/image/lib/registry/factory.py b/prowler/providers/image/lib/registry/factory.py index 4c351705ee..3ca380d4ad 100644 --- a/prowler/providers/image/lib/registry/factory.py +++ b/prowler/providers/image/lib/registry/factory.py @@ -4,6 +4,7 @@ from __future__ import annotations import re +from prowler.providers.image.lib.registry.base import RegistryAdapter from prowler.providers.image.lib.registry.dockerhub_adapter import DockerHubAdapter from prowler.providers.image.lib.registry.oci_adapter import OciRegistryAdapter @@ -16,8 +17,12 @@ _ECR_PATTERN = re.compile( def create_registry_adapter( - registry_url, username=None, password=None, token=None, verify_ssl=True -): + registry_url: str, + username: str | None = None, + password: str | None = None, + token: str | None = None, + verify_ssl: bool = True, +) -> RegistryAdapter: """Auto-detect registry type from URL and return the appropriate adapter.""" if _DOCKER_HUB_PATTERN.search(registry_url): return DockerHubAdapter( @@ -38,7 +43,7 @@ def create_registry_adapter( ) -def detect_registry_type(registry_url): +def detect_registry_type(registry_url: str) -> str: """Return a string identifying the detected registry type.""" if _DOCKER_HUB_PATTERN.search(registry_url): return "dockerhub" diff --git a/prowler/providers/image/lib/registry/oci_adapter.py b/prowler/providers/image/lib/registry/oci_adapter.py index c252ff6b9b..eb9e8dd546 100644 --- a/prowler/providers/image/lib/registry/oci_adapter.py +++ b/prowler/providers/image/lib/registry/oci_adapter.py @@ -5,6 +5,8 @@ from __future__ import annotations import base64 import re +import requests + from prowler.providers.image.exceptions.exceptions import ( ImageRegistryAuthError, ImageRegistryCatalogError, @@ -17,25 +19,30 @@ class OciRegistryAdapter(RegistryAdapter): """Adapter for registries implementing OCI Distribution Spec.""" def __init__( - self, registry_url, username=None, password=None, token=None, verify_ssl=True - ): + 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 = None + self._bearer_token: str | None = None self._basic_auth_verified = False @staticmethod - def _normalise_url(url): + def _normalise_url(url: str) -> str: url = url.rstrip("/") if not url.startswith(("http://", "https://")): url = f"https://{url}" return url - def list_repositories(self): + def list_repositories(self) -> list[str]: self._ensure_auth() - repositories = [] + repositories: list[str] = [] url = f"{self._base_url}/v2/_catalog" - params = {"n": 200} + params: dict = {"n": 200} while url: resp = self._authed_request("GET", url, params=params) if resp.status_code == 404: @@ -50,11 +57,11 @@ class OciRegistryAdapter(RegistryAdapter): params = {} return repositories - def list_tags(self, repository): + def list_tags(self, repository: str) -> list[str]: self._ensure_auth(repository=repository) - tags = [] + tags: list[str] = [] url = f"{self._base_url}/v2/{repository}/tags/list" - params = {"n": 200} + params: dict = {"n": 200} while url: resp = self._authed_request("GET", url, params=params) self._check_response(resp, f"tag listing for {repository}") @@ -64,7 +71,7 @@ class OciRegistryAdapter(RegistryAdapter): params = {} return tags - def _ensure_auth(self, repository=None): + def _ensure_auth(self, repository: str | None = None) -> None: if self._bearer_token: return if self._basic_auth_verified: @@ -101,8 +108,14 @@ class OciRegistryAdapter(RegistryAdapter): 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, repository=None): + 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( @@ -110,7 +123,7 @@ class OciRegistryAdapter(RegistryAdapter): message=f"Cannot parse token endpoint from registry {self.registry_url}. Www-Authenticate: {www_authenticate[:200]}", ) realm = match.group(1) - params = {} + params: dict = {} service_match = re.search(r'service="([^"]+)"', www_authenticate) if service_match: params["service"] = service_match.group(1) @@ -131,7 +144,7 @@ class OciRegistryAdapter(RegistryAdapter): data = resp.json() return data.get("token") or data.get("access_token", "") - def _resolve_basic_credentials(self): + 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 @@ -145,7 +158,7 @@ class OciRegistryAdapter(RegistryAdapter): pass return self.username, self.password - def _authed_request(self, method, url, **kwargs): + def _authed_request(self, method: str, url: str, **kwargs) -> requests.Response: headers = kwargs.pop("headers", {}) if self._bearer_token: headers["Authorization"] = f"Bearer {self._bearer_token}" @@ -155,7 +168,7 @@ class OciRegistryAdapter(RegistryAdapter): kwargs["headers"] = headers return self._request_with_retry(method, url, **kwargs) - def _check_response(self, resp, context): + def _check_response(self, resp: requests.Response, context: str) -> None: if resp.status_code == 200: return if resp.status_code in (401, 403): diff --git a/tests/providers/image/lib/registry/test_arguments.py b/tests/providers/image/lib/registry/test_arguments.py index 974a41e0d0..1a6567c9de 100644 --- a/tests/providers/image/lib/registry/test_arguments.py +++ b/tests/providers/image/lib/registry/test_arguments.py @@ -1,90 +1,210 @@ from argparse import Namespace -import pytest - from prowler.providers.image.lib.arguments.arguments import validate_arguments class TestValidateArguments: def test_no_source_fails(self): - args = Namespace(images=[], image_list_file=None, registry=None, image_filter=None, tag_filter=None, max_images=0, registry_insecure=False) + args = Namespace( + images=[], + image_list_file=None, + registry=None, + image_filter=None, + tag_filter=None, + max_images=0, + registry_insecure=False, + ) ok, msg = validate_arguments(args) assert not ok assert "--image" in msg def test_image_only_passes(self): - args = Namespace(images=["nginx:latest"], image_list_file=None, registry=None, image_filter=None, tag_filter=None, max_images=0, registry_insecure=False) + args = Namespace( + images=["nginx:latest"], + image_list_file=None, + registry=None, + image_filter=None, + tag_filter=None, + max_images=0, + registry_insecure=False, + ) ok, _ = validate_arguments(args) assert ok def test_image_list_only_passes(self): - args = Namespace(images=[], image_list_file="images.txt", registry=None, image_filter=None, tag_filter=None, max_images=0, registry_insecure=False) + args = Namespace( + images=[], + image_list_file="images.txt", + registry=None, + image_filter=None, + tag_filter=None, + max_images=0, + registry_insecure=False, + ) ok, _ = validate_arguments(args) assert ok def test_registry_only_passes(self): - args = Namespace(images=[], image_list_file=None, registry="myregistry.io", image_filter=None, tag_filter=None, max_images=0, registry_insecure=False) + args = Namespace( + images=[], + image_list_file=None, + registry="myregistry.io", + image_filter=None, + tag_filter=None, + max_images=0, + registry_insecure=False, + ) ok, _ = validate_arguments(args) assert ok def test_image_filter_without_registry_fails(self): - args = Namespace(images=["nginx:latest"], image_list_file=None, registry=None, image_filter="^prod", tag_filter=None, max_images=0, registry_insecure=False) + args = Namespace( + images=["nginx:latest"], + image_list_file=None, + registry=None, + image_filter="^prod", + tag_filter=None, + max_images=0, + registry_insecure=False, + ) ok, msg = validate_arguments(args) assert not ok assert "--image-filter requires --registry" in msg def test_tag_filter_without_registry_fails(self): - args = Namespace(images=["nginx:latest"], image_list_file=None, registry=None, image_filter=None, tag_filter="^v", max_images=0, registry_insecure=False) + args = Namespace( + images=["nginx:latest"], + image_list_file=None, + registry=None, + image_filter=None, + tag_filter="^v", + max_images=0, + registry_insecure=False, + ) ok, msg = validate_arguments(args) assert not ok assert "--tag-filter requires --registry" in msg def test_max_images_without_registry_fails(self): - args = Namespace(images=["nginx:latest"], image_list_file=None, registry=None, image_filter=None, tag_filter=None, max_images=50, registry_insecure=False) + args = Namespace( + images=["nginx:latest"], + image_list_file=None, + registry=None, + image_filter=None, + tag_filter=None, + max_images=50, + registry_insecure=False, + ) ok, msg = validate_arguments(args) assert not ok assert "--max-images requires --registry" in msg def test_registry_insecure_without_registry_fails(self): - args = Namespace(images=[], image_list_file="i.txt", registry=None, image_filter=None, tag_filter=None, max_images=0, registry_insecure=True) + args = Namespace( + images=[], + image_list_file="i.txt", + registry=None, + image_filter=None, + tag_filter=None, + max_images=0, + registry_insecure=True, + ) ok, msg = validate_arguments(args) assert not ok assert "--registry-insecure requires --registry" in msg def test_docker_hub_no_namespace_fails(self): - args = Namespace(images=[], image_list_file=None, registry="docker.io", image_filter=None, tag_filter=None, max_images=0, registry_insecure=False) + args = Namespace( + images=[], + image_list_file=None, + registry="docker.io", + image_filter=None, + tag_filter=None, + max_images=0, + registry_insecure=False, + ) ok, msg = validate_arguments(args) assert not ok assert "namespace" in msg.lower() def test_docker_hub_with_namespace_passes(self): - args = Namespace(images=[], image_list_file=None, registry="docker.io/myorg", image_filter=None, tag_filter=None, max_images=0, registry_insecure=False) + args = Namespace( + images=[], + image_list_file=None, + registry="docker.io/myorg", + image_filter=None, + tag_filter=None, + max_images=0, + registry_insecure=False, + ) ok, _ = validate_arguments(args) assert ok def test_docker_hub_https_no_namespace_fails(self): - args = Namespace(images=[], image_list_file=None, registry="https://docker.io", image_filter=None, tag_filter=None, max_images=0, registry_insecure=False) + args = Namespace( + images=[], + image_list_file=None, + registry="https://docker.io", + image_filter=None, + tag_filter=None, + max_images=0, + registry_insecure=False, + ) ok, msg = validate_arguments(args) assert not ok assert "namespace" in msg.lower() def test_registry_with_filters_passes(self): - args = Namespace(images=[], image_list_file=None, registry="myregistry.io", image_filter="^prod", tag_filter="^v", max_images=100, registry_insecure=True) + args = Namespace( + images=[], + image_list_file=None, + registry="myregistry.io", + image_filter="^prod", + tag_filter="^v", + max_images=100, + registry_insecure=True, + ) ok, _ = validate_arguments(args) assert ok def test_registry_list_without_registry_fails(self): - args = Namespace(images=["nginx:latest"], image_list_file=None, registry=None, image_filter=None, tag_filter=None, max_images=0, registry_insecure=False, registry_list_images=True) + args = Namespace( + images=["nginx:latest"], + image_list_file=None, + registry=None, + image_filter=None, + tag_filter=None, + max_images=0, + registry_insecure=False, + registry_list_images=True, + ) ok, msg = validate_arguments(args) assert not ok assert "--registry-list requires --registry" in msg def test_registry_list_with_registry_passes(self): - args = Namespace(images=[], image_list_file=None, registry="myregistry.io", image_filter=None, tag_filter=None, max_images=0, registry_insecure=False, registry_list_images=True) + args = Namespace( + images=[], + image_list_file=None, + registry="myregistry.io", + image_filter=None, + tag_filter=None, + max_images=0, + registry_insecure=False, + registry_list_images=True, + ) ok, _ = validate_arguments(args) assert ok def test_combined_registry_and_image_passes(self): - args = Namespace(images=["nginx:latest"], image_list_file=None, registry="myregistry.io", image_filter=None, tag_filter=None, max_images=0, registry_insecure=False) + args = Namespace( + images=["nginx:latest"], + image_list_file=None, + registry="myregistry.io", + image_filter=None, + tag_filter=None, + max_images=0, + registry_insecure=False, + ) ok, _ = validate_arguments(args) assert ok diff --git a/tests/providers/image/lib/registry/test_factory.py b/tests/providers/image/lib/registry/test_factory.py index 1e674ef087..2370f7f85a 100644 --- a/tests/providers/image/lib/registry/test_factory.py +++ b/tests/providers/image/lib/registry/test_factory.py @@ -1,7 +1,3 @@ -from unittest.mock import patch - -import pytest - from prowler.providers.image.lib.registry.dockerhub_adapter import DockerHubAdapter from prowler.providers.image.lib.registry.factory import ( create_registry_adapter, @@ -27,10 +23,15 @@ class TestDetectRegistryType: assert detect_registry_type("https://docker.io/myorg") == "dockerhub" def test_ecr(self): - assert detect_registry_type("123456789.dkr.ecr.us-east-1.amazonaws.com") == "ecr" + assert ( + detect_registry_type("123456789.dkr.ecr.us-east-1.amazonaws.com") == "ecr" + ) def test_ecr_with_https(self): - assert detect_registry_type("https://123456789.dkr.ecr.us-east-1.amazonaws.com") == "ecr" + assert ( + detect_registry_type("https://123456789.dkr.ecr.us-east-1.amazonaws.com") + == "ecr" + ) def test_harbor(self): assert detect_registry_type("harbor.example.com") == "oci" @@ -51,7 +52,11 @@ class TestCreateRegistryAdapter: def test_passes_credentials(self): adapter = create_registry_adapter( - "myregistry.io", username="user", password="pass", token="tok", verify_ssl=False + "myregistry.io", + username="user", + password="pass", + token="tok", + verify_ssl=False, ) assert adapter.username == "user" assert adapter.password == "pass" diff --git a/tests/providers/image/lib/registry/test_provider_registry.py b/tests/providers/image/lib/registry/test_provider_registry.py index eb5ec932b9..a4455821f6 100644 --- a/tests/providers/image/lib/registry/test_provider_registry.py +++ b/tests/providers/image/lib/registry/test_provider_registry.py @@ -1,5 +1,4 @@ import os -from argparse import Namespace from unittest.mock import MagicMock, patch import pytest