diff --git a/prowler/providers/image/lib/registry/base.py b/prowler/providers/image/lib/registry/base.py index 01a60bf619..6fb104d34a 100644 --- a/prowler/providers/image/lib/registry/base.py +++ b/prowler/providers/image/lib/registry/base.py @@ -82,6 +82,15 @@ class RegistryAdapter(ABC): ) time.sleep(wait) continue + if resp.status_code >= 500: + last_status = resp.status_code + wait = _BACKOFF_BASE * (2 ** (attempt - 1)) + logger.warning( + f"Server error from {context_label} (HTTP {resp.status_code}), " + f"retrying in {wait}s (attempt {attempt}/{_MAX_RETRIES})" + ) + time.sleep(wait) + continue return resp except requests.exceptions.ConnectionError as exc: last_exception = exc @@ -103,6 +112,11 @@ class RegistryAdapter(ABC): file=__file__, message=f"Rate limited by {context_label} after {_MAX_RETRIES} attempts.", ) + if last_status is not None and last_status >= 500: + raise ImageRegistryNetworkError( + file=__file__, + message=f"Server error from {context_label} (HTTP {last_status}) after {_MAX_RETRIES} attempts.", + ) raise ImageRegistryNetworkError( file=__file__, message=f"Failed to connect to {context_label} after {_MAX_RETRIES} attempts.", diff --git a/prowler/providers/image/lib/registry/dockerhub_adapter.py b/prowler/providers/image/lib/registry/dockerhub_adapter.py index 6b3df09818..949de2d9cb 100644 --- a/prowler/providers/image/lib/registry/dockerhub_adapter.py +++ b/prowler/providers/image/lib/registry/dockerhub_adapter.py @@ -115,6 +115,7 @@ class DockerHubAdapter(RegistryAdapter): return if not self.username or not self.password: return + logger.debug(f"Docker Hub login attempt for username: {self.username!r}") resp = self._request_with_retry( "POST", f"{_HUB_API}/v2/users/login", @@ -122,9 +123,14 @@ class DockerHubAdapter(RegistryAdapter): context_label="Docker Hub", ) if resp.status_code != 200: + body_preview = resp.text[:200] if resp.text else "(empty body)" raise ImageRegistryAuthError( file=__file__, - message=f"Docker Hub login failed (HTTP {resp.status_code}). Check REGISTRY_USERNAME and REGISTRY_PASSWORD.", + message=( + f"Docker Hub login failed (HTTP {resp.status_code}). " + f"Check REGISTRY_USERNAME and REGISTRY_PASSWORD. " + f"Response: {body_preview}" + ), ) self._hub_jwt = resp.json().get("token") if not self._hub_jwt: diff --git a/tests/providers/image/lib/registry/test_dockerhub_adapter.py b/tests/providers/image/lib/registry/test_dockerhub_adapter.py index 4f2d91d57c..d27ca536ba 100644 --- a/tests/providers/image/lib/registry/test_dockerhub_adapter.py +++ b/tests/providers/image/lib/registry/test_dockerhub_adapter.py @@ -99,7 +99,7 @@ class TestDockerHubListTags: class TestDockerHubLogin: @patch("prowler.providers.image.lib.registry.base.requests.request") def test_login_failure(self, mock_request): - resp = MagicMock(status_code=401) + resp = MagicMock(status_code=401, text="invalid credentials") mock_request.return_value = resp adapter = DockerHubAdapter("docker.io/myorg", username="bad", password="creds") with pytest.raises(ImageRegistryAuthError, match="login failed"): @@ -110,6 +110,29 @@ class TestDockerHubLogin: adapter._hub_login() # Should not raise assert adapter._hub_jwt is None + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_login_401_includes_response_body(self, mock_request): + resp = MagicMock( + status_code=401, text='{"detail":"Incorrect authentication credentials"}' + ) + mock_request.return_value = resp + adapter = DockerHubAdapter("docker.io/myorg", username="u", password="p") + with pytest.raises( + ImageRegistryAuthError, match="Incorrect authentication credentials" + ): + adapter._hub_login() + + @patch("prowler.providers.image.lib.registry.base.time.sleep") + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_login_500_retried_then_raises_network_error( + self, mock_request, mock_sleep + ): + mock_request.return_value = MagicMock(status_code=500) + adapter = DockerHubAdapter("docker.io/myorg", username="u", password="p") + with pytest.raises(ImageRegistryNetworkError, match="Server error"): + adapter._hub_login() + assert mock_request.call_count == 3 + class TestDockerHubRetry: @patch("prowler.providers.image.lib.registry.base.time.sleep") @@ -133,6 +156,41 @@ class TestDockerHubRetry: adapter._request_with_retry("GET", "https://hub.docker.com") assert mock_request.call_count == 3 + @patch("prowler.providers.image.lib.registry.base.time.sleep") + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_retry_on_500(self, mock_request, mock_sleep): + resp_500 = MagicMock(status_code=500) + resp_200 = MagicMock(status_code=200) + mock_request.side_effect = [resp_500, resp_200] + adapter = DockerHubAdapter("docker.io/myorg") + result = adapter._request_with_retry("GET", "https://hub.docker.com") + assert result.status_code == 200 + assert mock_request.call_count == 2 + mock_sleep.assert_called_once() + + @patch("prowler.providers.image.lib.registry.base.time.sleep") + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_retry_exhausted_on_500_raises_network_error( + self, mock_request, mock_sleep + ): + mock_request.return_value = MagicMock(status_code=500) + adapter = DockerHubAdapter("docker.io/myorg") + with pytest.raises( + ImageRegistryNetworkError, match="Server error.*HTTP 500.*3 attempts" + ): + adapter._request_with_retry("GET", "https://hub.docker.com") + assert mock_request.call_count == 3 + + @patch("prowler.providers.image.lib.registry.base.time.sleep") + @patch("prowler.providers.image.lib.registry.base.requests.request") + def test_4xx_not_retried(self, mock_request, mock_sleep): + mock_request.return_value = MagicMock(status_code=403) + adapter = DockerHubAdapter("docker.io/myorg") + result = adapter._request_with_retry("GET", "https://hub.docker.com") + assert result.status_code == 403 + assert mock_request.call_count == 1 + mock_sleep.assert_not_called() + class TestDockerHubEmptyTokens: @patch("prowler.providers.image.lib.registry.base.requests.request")