mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-03 17:54:05 +00:00
418 lines
14 KiB
Python
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
|