fix(az-m365): asyncio.run() in Azure/M365 Celery worker event (#11360)

This commit is contained in:
Pedro Martín
2026-05-26 11:26:39 +02:00
committed by GitHub
parent d560020592
commit 723d161c63
5 changed files with 199 additions and 32 deletions
+1
View File
@@ -17,6 +17,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)
---
+1 -1
View File
@@ -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:
+3 -7
View File
@@ -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__),
+87 -1
View File
@@ -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()
+107 -23
View File
@@ -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()