mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 09:30:21 +00:00
fix(az-m365): asyncio.run() in Azure/M365 Celery worker event (#11360)
This commit is contained in:
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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__),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user