Files
prowler/tests/providers/azure/services/entra/entra_service_test.py
T

418 lines
14 KiB
Python

import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
from prowler.providers.azure.models import AzureIdentityInfo
from prowler.providers.azure.services.entra.entra_service import (
AuthorizationPolicy,
ConditionalAccessPolicy,
DirectoryRole,
Entra,
GroupSetting,
NamedLocation,
SecurityDefault,
User,
)
from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provider
async def mock_entra_get_users(_):
return {
DOMAIN: {
"user-1@tenant1.es": User(id="id-1", name="User 1"),
}
}
async def mock_entra_get_authorization_policy(_):
return {
DOMAIN: AuthorizationPolicy(
id="id-1",
name="Name 1",
description="Description 1",
guest_invite_settings="none",
guest_user_role_id=uuid4(),
)
}
async def mock_entra_get_group_settings(_):
return {
DOMAIN: {
"id-1": GroupSetting(
id="id-1",
name="Test",
template_id="id-group-setting",
settings=[],
)
}
}
async def mock_entra_get_security_default(_):
return {
DOMAIN: SecurityDefault(
id="id-security-default",
name="Test",
is_enabled=True,
)
}
async def mock_entra_get_named_locations(_):
return {
DOMAIN: {
"id-1": NamedLocation(
id="id-1",
name="Test",
ip_ranges_addresses=[],
is_trusted=False,
)
}
}
async def mock_entra_get_directory_roles(_):
return {
DOMAIN: {
"GlobalAdministrator": DirectoryRole(
id="id-directory-role",
members=[],
)
}
}
async def mock_entra_get_conditional_access_policy(_):
return {
DOMAIN: {
"id-1": ConditionalAccessPolicy(
id="id-1",
state="enabled",
name="Test",
users={"include": ["All"], "exclude": []},
target_resources={
"include": ["797f4846-ba00-4fd7-ba43-dac1f8f63013"],
"exclude": [],
},
access_controls={"grant": ["MFA", "compliantDevice"], "block": []},
)
}
}
@patch(
"prowler.providers.azure.services.entra.entra_service.Entra._get_users",
new=mock_entra_get_users,
)
@patch(
"prowler.providers.azure.services.entra.entra_service.Entra._get_authorization_policy",
new=mock_entra_get_authorization_policy,
)
@patch(
"prowler.providers.azure.services.entra.entra_service.Entra._get_group_settings",
new=mock_entra_get_group_settings,
)
@patch(
"prowler.providers.azure.services.entra.entra_service.Entra._get_security_default",
new=mock_entra_get_security_default,
)
@patch(
"prowler.providers.azure.services.entra.entra_service.Entra._get_named_locations",
new=mock_entra_get_named_locations,
)
@patch(
"prowler.providers.azure.services.entra.entra_service.Entra._get_directory_roles",
new=mock_entra_get_directory_roles,
)
@patch(
"prowler.providers.azure.services.entra.entra_service.Entra._get_conditional_access_policy",
new=mock_entra_get_conditional_access_policy,
)
class Test_Entra_Service:
def test_get_client(self):
entra_client = Entra(
set_mocked_azure_provider(identity=AzureIdentityInfo(tenant_domain=DOMAIN))
)
assert entra_client.clients[DOMAIN].__class__.__name__ == "GraphServiceClient"
def test__get_subscriptions__(self):
entra_client = Entra(set_mocked_azure_provider())
assert entra_client.subscriptions.__class__.__name__ == "dict"
def test_get_users(self):
entra_client = Entra(set_mocked_azure_provider())
assert len(entra_client.users) == 1
assert entra_client.users[DOMAIN]["user-1@tenant1.es"].id == "id-1"
assert entra_client.users[DOMAIN]["user-1@tenant1.es"].name == "User 1"
assert entra_client.users[DOMAIN]["user-1@tenant1.es"].is_mfa_capable is False
assert entra_client.users[DOMAIN]["user-1@tenant1.es"].account_enabled is True
def test_get_authorization_policy(self):
entra_client = Entra(set_mocked_azure_provider())
assert entra_client.authorization_policy[DOMAIN].id == "id-1"
assert entra_client.authorization_policy[DOMAIN].name == "Name 1"
assert entra_client.authorization_policy[DOMAIN].description == "Description 1"
assert not entra_client.authorization_policy[
DOMAIN
].default_user_role_permissions
def test_get_group_settings(self):
entra_client = Entra(set_mocked_azure_provider())
assert entra_client.group_settings[DOMAIN]["id-1"].name == "Test"
assert (
entra_client.group_settings[DOMAIN]["id-1"].template_id
== "id-group-setting"
)
assert len(entra_client.group_settings[DOMAIN]["id-1"].settings) == 0
def test_get_security_default(self):
entra_client = Entra(set_mocked_azure_provider())
assert entra_client.security_default[DOMAIN].id == "id-security-default"
assert entra_client.security_default[DOMAIN].name == "Test"
assert entra_client.security_default[DOMAIN].is_enabled
def test_get_named_locations(self):
entra_client = Entra(set_mocked_azure_provider())
assert entra_client.named_locations[DOMAIN]["id-1"].name == "Test"
assert (
len(entra_client.named_locations[DOMAIN]["id-1"].ip_ranges_addresses) == 0
)
assert not entra_client.named_locations[DOMAIN]["id-1"].is_trusted
def test_get_directory_roles(self):
entra_client = Entra(set_mocked_azure_provider())
assert (
entra_client.directory_roles[DOMAIN]["GlobalAdministrator"].id
== "id-directory-role"
)
assert (
len(entra_client.directory_roles[DOMAIN]["GlobalAdministrator"].members)
== 0
)
def test_get_conditional_access_policy(self):
entra_client = Entra(set_mocked_azure_provider())
assert len(entra_client.conditional_access_policy) == 1
assert len(entra_client.conditional_access_policy[DOMAIN]) == 1
assert entra_client.conditional_access_policy[DOMAIN]["id-1"]
assert entra_client.conditional_access_policy[DOMAIN]["id-1"].name == "Test"
assert entra_client.conditional_access_policy[DOMAIN]["id-1"].state == "enabled"
assert entra_client.conditional_access_policy[DOMAIN]["id-1"].users[
"include"
] == ["All"]
assert (
entra_client.conditional_access_policy[DOMAIN]["id-1"].users["exclude"]
== []
)
assert entra_client.conditional_access_policy[DOMAIN]["id-1"].target_resources[
"include"
] == ["797f4846-ba00-4fd7-ba43-dac1f8f63013"]
assert (
entra_client.conditional_access_policy[DOMAIN]["id-1"].target_resources[
"exclude"
]
== []
)
assert entra_client.conditional_access_policy[DOMAIN]["id-1"].access_controls[
"grant"
] == ["MFA", "compliantDevice"]
assert (
entra_client.conditional_access_policy[DOMAIN]["id-1"].access_controls[
"block"
]
== []
)
def test_azure_entra__get_users_handles_pagination():
entra_service = Entra.__new__(Entra)
users_page_one = [
SimpleNamespace(id="user-1", display_name="User 1", account_enabled=False),
SimpleNamespace(id="user-2", display_name="User 2", account_enabled=True),
]
users_page_two = [
SimpleNamespace(id="user-3", display_name="User 3"),
]
users_response_page_one = SimpleNamespace(
value=users_page_one,
odata_next_link="next-link",
)
users_response_page_two = SimpleNamespace(
value=users_page_two, odata_next_link=None
)
users_with_url_builder = SimpleNamespace(
get=AsyncMock(return_value=users_response_page_two)
)
with_url_mock = MagicMock(return_value=users_with_url_builder)
users_builder = SimpleNamespace(
get=AsyncMock(return_value=users_response_page_one),
with_url=with_url_mock,
)
registration_details_response = SimpleNamespace(
value=[
SimpleNamespace(
id="user-1",
is_mfa_capable=True,
),
SimpleNamespace(
id="user-2",
is_mfa_capable=True,
),
],
odata_next_link=None,
)
registration_details_builder = SimpleNamespace(
get=AsyncMock(return_value=registration_details_response),
with_url=MagicMock(),
)
entra_service.clients = {
"tenant-1": SimpleNamespace(
users=users_builder,
reports=SimpleNamespace(
authentication_methods=SimpleNamespace(
user_registration_details=registration_details_builder
)
),
)
}
users = asyncio.run(entra_service._get_users())
assert len(users["tenant-1"]) == 3
assert users_builder.get.await_count == 1
request_configuration = users_builder.get.await_args.kwargs["request_configuration"]
assert request_configuration.query_parameters.select == [
"id",
"displayName",
"accountEnabled",
"signInActivity",
]
with_url_mock.assert_called_once_with("next-link")
registration_details_builder.get.assert_awaited()
registration_details_builder.with_url.assert_not_called()
assert users["tenant-1"]["user-1"].is_mfa_capable is True
assert users["tenant-1"]["user-1"].account_enabled is False
assert users["tenant-1"]["user-2"].is_mfa_capable is True
assert users["tenant-1"]["user-2"].account_enabled is True
assert users["tenant-1"]["user-3"].is_mfa_capable is False
assert users["tenant-1"]["user-3"].account_enabled is True
class TestGetUsersSignInActivity:
"""Service-level coverage for the signInActivity 403 fallback."""
@staticmethod
def _graph_error(status):
error = Exception("graph error")
error.response_status_code = status
return error
@staticmethod
def _users_response(value=None, next_link=None):
from types import SimpleNamespace
return SimpleNamespace(value=value or [], odata_next_link=next_link)
def _service(self, side_effect):
# SimpleNamespace instead of MagicMock: several check tests assign
# attributes on the MagicMock *class*, which would shadow instance
# child mocks here.
from types import SimpleNamespace
from unittest.mock import AsyncMock
from prowler.providers.azure.services.entra.entra_service import Entra
service = Entra.__new__(Entra)
client = SimpleNamespace(
users=SimpleNamespace(get=AsyncMock(side_effect=side_effect))
)
service.clients = {"tenant.onmicrosoft.com": client}
service.sign_in_activity_errors = {}
service.users_retrieval_errors = {}
service._get_user_registration_details = AsyncMock(return_value={})
return service
def test_403_records_tenant_and_retries_without_sign_in_activity(self):
import asyncio
service = self._service(
side_effect=[self._graph_error(403), self._users_response()]
)
users = asyncio.run(service._get_users())
assert "tenant.onmicrosoft.com" in service.sign_in_activity_errors
assert "403" in service.sign_in_activity_errors["tenant.onmicrosoft.com"]
assert service.users_retrieval_errors == {}
assert users == {"tenant.onmicrosoft.com": {}}
assert service.clients["tenant.onmicrosoft.com"].users.get.await_count == 2
def test_transient_error_does_not_blame_licensing(self):
import asyncio
service = self._service(side_effect=[self._graph_error(503)])
users = asyncio.run(service._get_users())
# The failure is not attributed to licensing/permissions, but the
# empty inventory is not trusted either: the tenant is recorded so
# the user-based checks report MANUAL.
assert service.sign_in_activity_errors == {}
assert "tenant.onmicrosoft.com" in service.users_retrieval_errors
assert "503" in service.users_retrieval_errors["tenant.onmicrosoft.com"]
assert users == {"tenant.onmicrosoft.com": {}}
assert service.clients["tenant.onmicrosoft.com"].users.get.await_count == 1
def test_failing_second_page_records_users_retrieval_error(self):
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock
service = self._service(
side_effect=[
self._users_response(
value=[
SimpleNamespace(
id="user-1",
display_name="user-1",
account_enabled=True,
sign_in_activity=None,
)
],
next_link="https://graph.microsoft.com/v1.0/users?$skiptoken=page2",
)
]
)
service.clients["tenant.onmicrosoft.com"].users.with_url = lambda _: (
SimpleNamespace(get=AsyncMock(side_effect=self._graph_error(503)))
)
users = asyncio.run(service._get_users())
# The first page made it into the inventory, but the tenant is marked
# unavailable: a partial inventory must not be evaluated as complete.
assert "user-1" in users["tenant.onmicrosoft.com"]
assert "tenant.onmicrosoft.com" in service.users_retrieval_errors
assert "503" in service.users_retrieval_errors["tenant.onmicrosoft.com"]
assert service.sign_in_activity_errors == {}
def test_403_with_failing_retry_records_users_retrieval_error(self):
import asyncio
service = self._service(
side_effect=[self._graph_error(403), self._graph_error(503)]
)
users = asyncio.run(service._get_users())
assert "tenant.onmicrosoft.com" in service.sign_in_activity_errors
assert "tenant.onmicrosoft.com" in service.users_retrieval_errors
assert "503" in service.users_retrieval_errors["tenant.onmicrosoft.com"]
assert users == {"tenant.onmicrosoft.com": {}}
assert service.clients["tenant.onmicrosoft.com"].users.get.await_count == 2