From d9c849bed0358869ab5ab6f3f7a9382ce1da7ad4 Mon Sep 17 00:00:00 2001 From: Prowler Bot Date: Tue, 26 May 2026 11:50:28 +0200 Subject: [PATCH] fix(az-m365): asyncio.run() in Azure/M365 Celery worker event (#11361) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Pedro Martín --- prowler/CHANGELOG.md | 1 + prowler/providers/azure/azure_provider.py | 2 +- prowler/providers/m365/m365_provider.py | 10 +- tests/providers/azure/azure_provider_test.py | 88 ++++++++++++- tests/providers/m365/m365_provider_test.py | 130 +++++++++++++++---- 5 files changed, 199 insertions(+), 32 deletions(-) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index bce64335b0..ac41b41e5c 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `compute_project_os_login_enabled` and `compute_project_os_login_2fa_enabled` checks for GCP provider no longer false-FAIL on projects where the `enable-oslogin` / `enable-oslogin-2fa` metadata is not set explicitly but is inherited automatically from the `constraints/compute.requireOsLogin` org policy. The policy controller writes the inherited value in lowercase (`"true"`), but the service-layer parser compared it to the uppercase string literal `"TRUE"`. Comparison is now case-insensitive [(#11341)](https://github.com/prowler-cloud/prowler/pull/11341) - `storage_smb_channel_encryption_with_secure_algorithm` check for Azure provider no longer passes when a storage account allows a weak SMB channel encryption algorithm (e.g. `AES-128-CCM`/`AES-128-GCM`) alongside `AES-256-GCM`; it now requires every enabled algorithm to be in the recommended list, configurable via `azure.recommended_smb_channel_encryption_algorithms` (defaults to `AES-256-GCM` only, as required by CIS) [(#11327)](https://github.com/prowler-cloud/prowler/pull/11327) +- Azure and M365 providers crashing with `RuntimeError: There is no current event loop` on Python 3.12 when called from threads without an active event loop (e.g. Celery workers) [(#11360)](https://github.com/prowler-cloud/prowler/pull/11360) --- diff --git a/prowler/providers/azure/azure_provider.py b/prowler/providers/azure/azure_provider.py index ce28d60bda..be8b7e60a3 100644 --- a/prowler/providers/azure/azure_provider.py +++ b/prowler/providers/azure/azure_provider.py @@ -949,7 +949,7 @@ class AzureProvider(Provider): f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" ) - asyncio.get_event_loop().run_until_complete(get_azure_identity()) + asyncio.run(get_azure_identity()) # Managed identities only can be assigned resource, resource group and subscription scope permissions elif managed_identity_auth: diff --git a/prowler/providers/m365/m365_provider.py b/prowler/providers/m365/m365_provider.py index 0e3a6c2b26..0454d85f3d 100644 --- a/prowler/providers/m365/m365_provider.py +++ b/prowler/providers/m365/m365_provider.py @@ -1073,7 +1073,7 @@ class M365Provider(Provider): organization_info = await client.organization.get() identity.tenant_id = organization_info.value[0].id - asyncio.get_event_loop().run_until_complete(get_m365_identity(identity)) + asyncio.run(get_m365_identity(identity)) return identity @staticmethod @@ -1261,9 +1261,7 @@ class M365Provider(Provider): result = await client.domains.get() return result.value - result = asyncio.get_event_loop().run_until_complete( - verify_certificate() - ) + result = asyncio.run(verify_certificate()) if not result: raise M365NotValidCertificateContentError( file=os.path.basename(__file__), @@ -1284,9 +1282,7 @@ class M365Provider(Provider): result = await client.domains.get() return result.value - result = asyncio.get_event_loop().run_until_complete( - verify_certificate() - ) + result = asyncio.run(verify_certificate()) if not result: raise M365NotValidCertificatePathError( file=os.path.basename(__file__), diff --git a/tests/providers/azure/azure_provider_test.py b/tests/providers/azure/azure_provider_test.py index 4aa09644e4..0846fdc540 100644 --- a/tests/providers/azure/azure_provider_test.py +++ b/tests/providers/azure/azure_provider_test.py @@ -1,4 +1,5 @@ -from unittest.mock import patch +import asyncio +from unittest.mock import AsyncMock, patch from uuid import uuid4 import pytest @@ -722,3 +723,88 @@ class TestAzureProviderSetupIdentitySubscriptions: first_id: shared_name, second_id: shared_name, } + + +class TestAzureProviderSetupIdentityEventLoop: + """Regression for the Celery worker scenario where + asyncio.get_event_loop() raised "There is no current event loop in + thread 'MainThread'." on Python 3.12. setup_identity now uses + asyncio.run(), which creates its own loop and must work without a + pre-existing one in the current thread.""" + + @staticmethod + def _mock_subscription(display_name, subscription_id): + mock_subscription = MagicMock() + mock_subscription.display_name = display_name + mock_subscription.subscription_id = subscription_id + return mock_subscription + + @staticmethod + def _build_subscriptions_client_mock(subscriptions): + subscriptions_operations = MagicMock() + subscriptions_operations.list = MagicMock(return_value=subscriptions) + subscriptions_operations.get = MagicMock() + + tenants_operations = MagicMock() + tenants_operations.list = MagicMock(return_value=[]) + + client_instance = MagicMock() + client_instance.subscriptions = subscriptions_operations + client_instance.tenants = tenants_operations + return MagicMock(return_value=client_instance) + + @staticmethod + def _build_provider(): + with patch.object(AzureProvider, "__init__", return_value=None): + azure_provider = AzureProvider() + azure_provider._session = MagicMock() + azure_provider._region_config = AzureRegionConfig( + name="AzureCloud", + authority=None, + base_url="https://management.azure.com", + credential_scopes=["https://management.azure.com/.default"], + ) + return azure_provider + + def test_setup_identity_succeeds_without_active_event_loop(self): + sub_id = str(uuid4()) + subscriptions_client = self._build_subscriptions_client_mock( + [self._mock_subscription("Sub", sub_id)] + ) + + graph_client = MagicMock() + graph_client.domains.get = AsyncMock(return_value=MagicMock(value=[])) + graph_client.me.get = AsyncMock(return_value=None) + + # Simulate the Celery worker state: no event loop registered for the + # current thread. Before the fix this combination triggered + # `RuntimeError: There is no current event loop in thread 'MainThread'.` + # on Python 3.12 from asyncio.get_event_loop(). + asyncio.set_event_loop(None) + try: + with ( + patch( + "prowler.providers.azure.azure_provider.GraphServiceClient", + return_value=graph_client, + ), + patch( + "prowler.providers.azure.azure_provider.SubscriptionClient", + subscriptions_client, + ), + ): + azure_provider = self._build_provider() + identity = azure_provider.setup_identity( + az_cli_auth=False, + sp_env_auth=True, + browser_auth=False, + managed_identity_auth=False, + subscription_ids=[], + client_id="00000000-0000-0000-0000-000000000000", + ) + finally: + # Re-arm a loop for sibling tests that may rely on the default. + asyncio.set_event_loop(asyncio.new_event_loop()) + + assert isinstance(identity, AzureIdentityInfo) + assert identity.subscriptions == {sub_id: "Sub"} + graph_client.domains.get.assert_awaited_once() diff --git a/tests/providers/m365/m365_provider_test.py b/tests/providers/m365/m365_provider_test.py index f2354ea7e7..9f4a151249 100644 --- a/tests/providers/m365/m365_provider_test.py +++ b/tests/providers/m365/m365_provider_test.py @@ -1,6 +1,7 @@ +import asyncio import base64 import os -from unittest.mock import MagicMock, mock_open, patch +from unittest.mock import AsyncMock, MagicMock, mock_open, patch from uuid import uuid4 import pytest @@ -1535,19 +1536,17 @@ class TestM365Provider: TENANT_ID, CLIENT_ID, None, b"fake_certificate_data", certificate_path ) - @patch("prowler.providers.m365.m365_provider.asyncio.get_event_loop") + @patch("prowler.providers.m365.m365_provider.asyncio.run") @patch("prowler.providers.m365.m365_provider.GraphServiceClient") @patch("prowler.providers.m365.m365_provider.CertificateCredential") def test_verify_client_certificate_content_success( - self, mock_cert_cred, mock_graph, mock_loop + self, mock_cert_cred, mock_graph, mock_asyncio_run ): """Test verify_client method with valid certificate content""" certificate_content = base64.b64encode(b"fake_certificate").decode("utf-8") - # Mock the async call - mock_loop_instance = MagicMock() - mock_loop.return_value = mock_loop_instance - mock_loop_instance.run_until_complete.return_value = [{"id": "domain.com"}] + # Mock the async call result + mock_asyncio_run.return_value = [{"id": "domain.com"}] # Mock credential and graph client mock_credential = MagicMock() @@ -1563,19 +1562,17 @@ class TestM365Provider: mock_cert_cred.assert_called_once() mock_graph.assert_called_once_with(credentials=mock_credential) - @patch("prowler.providers.m365.m365_provider.asyncio.get_event_loop") + @patch("prowler.providers.m365.m365_provider.asyncio.run") @patch("prowler.providers.m365.m365_provider.GraphServiceClient") @patch("prowler.providers.m365.m365_provider.CertificateCredential") def test_verify_client_certificate_content_failure( - self, mock_cert_cred, mock_graph, mock_loop + self, mock_cert_cred, mock_graph, mock_asyncio_run ): """Test verify_client method with certificate content that fails validation""" certificate_content = base64.b64encode(b"fake_certificate").decode("utf-8") # Mock the async call to return empty result (invalid certificate) - mock_loop_instance = MagicMock() - mock_loop.return_value = mock_loop_instance - mock_loop_instance.run_until_complete.return_value = None + mock_asyncio_run.return_value = None # Mock credential and graph client mock_credential = MagicMock() @@ -1591,19 +1588,17 @@ class TestM365Provider: assert "certificate content is not valid" in str(exception.value) @patch("builtins.open", mock_open(read_data=b"fake_certificate_data")) - @patch("prowler.providers.m365.m365_provider.asyncio.get_event_loop") + @patch("prowler.providers.m365.m365_provider.asyncio.run") @patch("prowler.providers.m365.m365_provider.GraphServiceClient") @patch("prowler.providers.m365.m365_provider.CertificateCredential") def test_verify_client_certificate_path_success( - self, mock_cert_cred, mock_graph, mock_loop + self, mock_cert_cred, mock_graph, mock_asyncio_run ): """Test verify_client method with valid certificate path""" certificate_path = "/path/to/cert.pem" - # Mock the async call - mock_loop_instance = MagicMock() - mock_loop.return_value = mock_loop_instance - mock_loop_instance.run_until_complete.return_value = [{"id": "domain.com"}] + # Mock the async call result + mock_asyncio_run.return_value = [{"id": "domain.com"}] # Mock credential and graph client mock_credential = MagicMock() @@ -1618,19 +1613,17 @@ class TestM365Provider: mock_graph.assert_called_once_with(credentials=mock_credential) @patch("builtins.open", mock_open(read_data=b"fake_certificate_data")) - @patch("prowler.providers.m365.m365_provider.asyncio.get_event_loop") + @patch("prowler.providers.m365.m365_provider.asyncio.run") @patch("prowler.providers.m365.m365_provider.GraphServiceClient") @patch("prowler.providers.m365.m365_provider.CertificateCredential") def test_verify_client_certificate_path_failure( - self, mock_cert_cred, mock_graph, mock_loop + self, mock_cert_cred, mock_graph, mock_asyncio_run ): """Test verify_client method with certificate path that fails validation""" certificate_path = "/path/to/cert.pem" # Mock the async call to return empty result (invalid certificate) - mock_loop_instance = MagicMock() - mock_loop.return_value = mock_loop_instance - mock_loop_instance.run_until_complete.return_value = None + mock_asyncio_run.return_value = None # Mock credential and graph client mock_credential = MagicMock() @@ -1804,3 +1797,94 @@ class TestM365Provider: assert "Missing environment variable M365_CERTIFICATE_CONTENT" in str( exception.value ) + + +class TestM365ProviderEventLoop: + """Regression for Celery workers on Python 3.12 where + asyncio.get_event_loop() raised + `RuntimeError: There is no current event loop in thread 'MainThread'.` + M365Provider.setup_identity and M365Provider.validate_static_credentials + must work without a pre-existing loop in the current thread.""" + + def _without_event_loop(self, callable_): + # Simulate the Celery worker state: no event loop registered for the + # current thread. + asyncio.set_event_loop(None) + try: + return callable_() + finally: + # Re-arm a loop so sibling tests that rely on the default don't + # bleed into each other. + asyncio.set_event_loop(asyncio.new_event_loop()) + + def test_setup_identity_succeeds_without_active_event_loop(self): + domain = MagicMock() + domain.id = "tenant.onmicrosoft.com" + domain.is_default = True + + org = MagicMock() + org.id = TENANT_ID + + graph_client = MagicMock() + graph_client.domains.get = AsyncMock(return_value=MagicMock(value=[domain])) + graph_client.organization.get = AsyncMock(return_value=MagicMock(value=[org])) + + session = MagicMock() + # `setup_identity` reads `session.credentials[0]._credential.client_id` + # when sp_env_auth is True to populate identity.identity_id. + session.credentials = [MagicMock()] + session.credentials[0]._credential.client_id = CLIENT_ID + + def call(): + with patch( + "prowler.providers.m365.m365_provider.GraphServiceClient", + return_value=graph_client, + ): + return M365Provider.setup_identity( + sp_env_auth=True, + browser_auth=False, + az_cli_auth=False, + certificate_auth=False, + session=session, + ) + + identity = self._without_event_loop(call) + + assert isinstance(identity, M365IdentityInfo) + assert identity.tenant_id == TENANT_ID + graph_client.domains.get.assert_awaited_once() + graph_client.organization.get.assert_awaited_once() + + def test_verify_client_certificate_content_without_active_event_loop(self): + # `verify_client` is the function the Sentry trace exercises through + # certificate-based credential validation; it must run an asyncio + # coroutine to call `client.domains.get()` and previously relied on + # `asyncio.get_event_loop()`. + graph_client = MagicMock() + graph_client.domains.get = AsyncMock( + return_value=MagicMock(value=[MagicMock()]) + ) + + def call(): + with ( + patch("prowler.providers.m365.m365_provider.CertificateCredential"), + patch( + "prowler.providers.m365.m365_provider.GraphServiceClient", + return_value=graph_client, + ), + patch( + "prowler.providers.m365.m365_provider.base64.b64decode", + return_value=b"cert-bytes", + ), + ): + M365Provider.verify_client( + tenant_id=TENANT_ID, + client_id=CLIENT_ID, + client_secret=None, + certificate_content="dGVzdA==", + certificate_path=None, + ) + + # Must not raise "There is no current event loop in thread 'MainThread'.". + self._without_event_loop(call) + graph_client.domains.get.assert_awaited_once()