feat(m365): add CIS M365 v7.0.0 entra device registration checks (#12152)

Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
This commit is contained in:
Pedro Martín
2026-08-05 13:51:29 +02:00
committed by GitHub
co-authored by Daniel Barranquero
parent 06799dcaa8
commit aaa29d3528
28 changed files with 1178 additions and 6 deletions
@@ -0,0 +1,55 @@
from unittest import mock
from prowler.providers.m365.services.entra.entra_service import (
DeviceRegistrationPolicy,
)
from tests.providers.m365.m365_fixtures import set_mocked_m365_provider
CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_device_registration_global_admins_not_local_admins.entra_device_registration_global_admins_not_local_admins"
class Test_entra_device_registration_global_admins_not_local_admins:
def _run(self, policy):
entra_client = mock.MagicMock
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client),
):
from prowler.providers.m365.services.entra.entra_device_registration_global_admins_not_local_admins.entra_device_registration_global_admins_not_local_admins import (
entra_device_registration_global_admins_not_local_admins,
)
entra_client.device_registration_policy = policy
return entra_device_registration_global_admins_not_local_admins().execute()
def test_no_policy(self):
assert self._run(None) == []
def test_global_admins_enabled(self):
result = self._run(
DeviceRegistrationPolicy(azure_ad_join_global_admins_enabled=True)
)
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Global Administrators are added as local administrators on devices during Microsoft Entra join."
)
assert result[0].resource_id == "deviceRegistrationPolicy"
assert result[0].resource_name == "Device Registration Policy"
def test_global_admins_disabled(self):
result = self._run(
DeviceRegistrationPolicy(azure_ad_join_global_admins_enabled=False)
)
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Global Administrators are not added as local administrators on devices during Microsoft Entra join."
)
assert result[0].resource_id == "deviceRegistrationPolicy"
assert result[0].resource_name == "Device Registration Policy"
@@ -0,0 +1,84 @@
from unittest import mock
from prowler.providers.m365.services.entra.entra_service import (
DeviceRegistrationMembershipType,
DeviceRegistrationPolicy,
)
from tests.providers.m365.m365_fixtures import set_mocked_m365_provider
CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_device_registration_join_restricted.entra_device_registration_join_restricted"
class Test_entra_device_registration_join_restricted:
def _run(self, policy):
entra_client = mock.MagicMock
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client),
):
from prowler.providers.m365.services.entra.entra_device_registration_join_restricted.entra_device_registration_join_restricted import (
entra_device_registration_join_restricted,
)
entra_client.device_registration_policy = policy
return entra_device_registration_join_restricted().execute()
def test_no_policy(self):
assert self._run(None) == []
def test_all_users(self):
result = self._run(
DeviceRegistrationPolicy(
azure_ad_join_allowed_to_join_type=DeviceRegistrationMembershipType.ALL.value
)
)
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "The users allowed to join devices to Microsoft Entra are not restricted to selected users or none."
)
assert result[0].resource_id == "deviceRegistrationPolicy"
assert result[0].resource_name == "Device Registration Policy"
def test_unknown_membership_type(self):
result = self._run(
DeviceRegistrationPolicy(azure_ad_join_allowed_to_join_type=None)
)
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "The users allowed to join devices to Microsoft Entra are not restricted to selected users or none."
)
def test_selected_users(self):
result = self._run(
DeviceRegistrationPolicy(
azure_ad_join_allowed_to_join_type=DeviceRegistrationMembershipType.ENUMERATED.value
)
)
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Only selected users or no users are allowed to join devices to Microsoft Entra."
)
assert result[0].resource_id == "deviceRegistrationPolicy"
assert result[0].resource_name == "Device Registration Policy"
def test_none(self):
result = self._run(
DeviceRegistrationPolicy(
azure_ad_join_allowed_to_join_type=DeviceRegistrationMembershipType.NONE.value
)
)
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Only selected users or no users are allowed to join devices to Microsoft Entra."
)
@@ -0,0 +1,51 @@
from unittest import mock
from prowler.providers.m365.services.entra.entra_service import (
DeviceRegistrationPolicy,
)
from tests.providers.m365.m365_fixtures import set_mocked_m365_provider
CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_device_registration_laps_enabled.entra_device_registration_laps_enabled"
class Test_entra_device_registration_laps_enabled:
def _run(self, policy):
entra_client = mock.MagicMock
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client),
):
from prowler.providers.m365.services.entra.entra_device_registration_laps_enabled.entra_device_registration_laps_enabled import (
entra_device_registration_laps_enabled,
)
entra_client.device_registration_policy = policy
return entra_device_registration_laps_enabled().execute()
def test_no_policy(self):
assert self._run(None) == []
def test_laps_enabled(self):
result = self._run(DeviceRegistrationPolicy(local_admin_password_enabled=True))
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Microsoft Entra Local Administrator Password Solution (LAPS) is enabled."
)
assert result[0].resource_id == "deviceRegistrationPolicy"
assert result[0].resource_name == "Device Registration Policy"
def test_laps_disabled(self):
result = self._run(DeviceRegistrationPolicy(local_admin_password_enabled=False))
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Microsoft Entra Local Administrator Password Solution (LAPS) is disabled."
)
assert result[0].resource_id == "deviceRegistrationPolicy"
assert result[0].resource_name == "Device Registration Policy"
@@ -0,0 +1,69 @@
from unittest import mock
from prowler.providers.m365.services.entra.entra_service import (
DeviceRegistrationPolicy,
)
from tests.providers.m365.m365_fixtures import set_mocked_m365_provider
CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_device_registration_max_devices_per_user_limited.entra_device_registration_max_devices_per_user_limited"
class Test_entra_device_registration_max_devices_per_user_limited:
def _run(self, policy):
entra_client = mock.MagicMock
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client),
):
from prowler.providers.m365.services.entra.entra_device_registration_max_devices_per_user_limited.entra_device_registration_max_devices_per_user_limited import (
entra_device_registration_max_devices_per_user_limited,
)
entra_client.device_registration_policy = policy
return entra_device_registration_max_devices_per_user_limited().execute()
def test_no_policy(self):
assert self._run(None) == []
def test_within_limit(self):
result = self._run(DeviceRegistrationPolicy(user_device_quota=10))
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "The maximum number of devices per user is 10, within the recommended limit of 10."
)
assert result[0].resource_id == "deviceRegistrationPolicy"
assert result[0].resource_name == "Device Registration Policy"
def test_zero_quota(self):
result = self._run(DeviceRegistrationPolicy(user_device_quota=0))
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "The maximum number of devices per user is 0, within the recommended limit of 10."
)
def test_exceeds_limit(self):
result = self._run(DeviceRegistrationPolicy(user_device_quota=50))
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "The maximum number of devices per user is 50, which exceeds the recommended limit of 10."
)
assert result[0].resource_id == "deviceRegistrationPolicy"
assert result[0].resource_name == "Device Registration Policy"
def test_none_quota(self):
result = self._run(DeviceRegistrationPolicy(user_device_quota=None))
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "The maximum number of devices per user is not limited, exceeding the recommended limit of 10."
)
@@ -0,0 +1,86 @@
from unittest import mock
from prowler.providers.m365.services.entra.entra_service import (
DeviceRegistrationMembershipType,
DeviceRegistrationPolicy,
)
from tests.providers.m365.m365_fixtures import set_mocked_m365_provider
CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_device_registration_registering_user_not_local_admin.entra_device_registration_registering_user_not_local_admin"
class Test_entra_device_registration_registering_user_not_local_admin:
def _run(self, policy):
entra_client = mock.MagicMock
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client),
):
from prowler.providers.m365.services.entra.entra_device_registration_registering_user_not_local_admin.entra_device_registration_registering_user_not_local_admin import (
entra_device_registration_registering_user_not_local_admin,
)
entra_client.device_registration_policy = policy
return (
entra_device_registration_registering_user_not_local_admin().execute()
)
def test_no_policy(self):
assert self._run(None) == []
def test_all_registering_users(self):
result = self._run(
DeviceRegistrationPolicy(
azure_ad_join_registering_users_type=DeviceRegistrationMembershipType.ALL.value
)
)
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Registering users are not restricted from being added as local administrators on devices during Microsoft Entra join."
)
assert result[0].resource_id == "deviceRegistrationPolicy"
assert result[0].resource_name == "Device Registration Policy"
def test_unknown_membership_type(self):
result = self._run(
DeviceRegistrationPolicy(azure_ad_join_registering_users_type=None)
)
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Registering users are not restricted from being added as local administrators on devices during Microsoft Entra join."
)
def test_selected_registering_users(self):
result = self._run(
DeviceRegistrationPolicy(
azure_ad_join_registering_users_type=DeviceRegistrationMembershipType.ENUMERATED.value
)
)
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Registering users are restricted from being added as local administrators on devices during Microsoft Entra join."
)
assert result[0].resource_id == "deviceRegistrationPolicy"
assert result[0].resource_name == "Device Registration Policy"
def test_none_registering_users(self):
result = self._run(
DeviceRegistrationPolicy(
azure_ad_join_registering_users_type=DeviceRegistrationMembershipType.NONE.value
)
)
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Registering users are restricted from being added as local administrators on devices during Microsoft Entra join."
)
@@ -0,0 +1,137 @@
from unittest import mock
from prowler.providers.m365.services.entra.entra_service import (
AuthorizationPolicy,
DefaultUserRolePermissions,
)
from tests.providers.m365.m365_fixtures import set_mocked_m365_provider
class Test_entra_policy_default_user_cannot_read_bitlocker_keys:
def test_users_can_read_bitlocker_keys(self):
entra_client = mock.MagicMock
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_policy_default_user_cannot_read_bitlocker_keys.entra_policy_default_user_cannot_read_bitlocker_keys.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_policy_default_user_cannot_read_bitlocker_keys.entra_policy_default_user_cannot_read_bitlocker_keys import (
entra_policy_default_user_cannot_read_bitlocker_keys,
)
entra_client.authorization_policy = AuthorizationPolicy(
id="authorizationPolicy",
name="Authorization Policy",
description="",
default_user_role_permissions=DefaultUserRolePermissions(
allowed_to_read_bitlocker_keys_for_owned_device=True,
),
)
check = entra_policy_default_user_cannot_read_bitlocker_keys()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Non-admin users are allowed to read BitLocker keys for their owned devices."
)
def test_authorization_policy_none(self):
entra_client = mock.MagicMock
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_policy_default_user_cannot_read_bitlocker_keys.entra_policy_default_user_cannot_read_bitlocker_keys.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_policy_default_user_cannot_read_bitlocker_keys.entra_policy_default_user_cannot_read_bitlocker_keys import (
entra_policy_default_user_cannot_read_bitlocker_keys,
)
entra_client.authorization_policy = None
result = entra_policy_default_user_cannot_read_bitlocker_keys().execute()
assert len(result) == 1
assert result[0].status == "FAIL"
def test_users_cannot_read_bitlocker_keys(self):
entra_client = mock.MagicMock
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_policy_default_user_cannot_read_bitlocker_keys.entra_policy_default_user_cannot_read_bitlocker_keys.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_policy_default_user_cannot_read_bitlocker_keys.entra_policy_default_user_cannot_read_bitlocker_keys import (
entra_policy_default_user_cannot_read_bitlocker_keys,
)
entra_client.authorization_policy = AuthorizationPolicy(
id="authorizationPolicy",
name="Authorization Policy",
description="",
default_user_role_permissions=DefaultUserRolePermissions(
allowed_to_read_bitlocker_keys_for_owned_device=False,
),
)
check = entra_policy_default_user_cannot_read_bitlocker_keys()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Non-admin users are not allowed to read BitLocker keys for their owned devices."
)
def test_bitlocker_permission_unknown(self):
"""A missing permission value must fail closed, not report PASS."""
entra_client = mock.MagicMock
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_policy_default_user_cannot_read_bitlocker_keys.entra_policy_default_user_cannot_read_bitlocker_keys.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_policy_default_user_cannot_read_bitlocker_keys.entra_policy_default_user_cannot_read_bitlocker_keys import (
entra_policy_default_user_cannot_read_bitlocker_keys,
)
entra_client.authorization_policy = AuthorizationPolicy(
id="authorizationPolicy",
name="Authorization Policy",
description="",
default_user_role_permissions=DefaultUserRolePermissions(
allowed_to_read_bitlocker_keys_for_owned_device=None,
),
)
result = entra_policy_default_user_cannot_read_bitlocker_keys().execute()
assert len(result) == 1
assert result[0].status == "FAIL"
@@ -22,6 +22,8 @@ Conditions = entra_service.Conditions
CredentialRestriction = entra_service.CredentialRestriction
DefaultAppManagementPolicy = entra_service.DefaultAppManagementPolicy
DefaultUserRolePermissions = entra_service.DefaultUserRolePermissions
DeviceRegistrationMembershipType = entra_service.DeviceRegistrationMembershipType
DeviceRegistrationPolicy = entra_service.DeviceRegistrationPolicy
Entra = entra_service.Entra
GrantControlOperator = entra_service.GrantControlOperator
GrantControls = entra_service.GrantControls
@@ -727,6 +729,71 @@ class Test_Entra_Service:
assert "AuditLog.Read.All" in error_message
assert "user registration details" in error_message
def _mocked_device_registration_entra(self, send_primitive):
entra_service = Entra.__new__(Entra)
entra_service.client = SimpleNamespace(
policies=SimpleNamespace(
device_registration_policy=SimpleNamespace(
to_get_request_information=MagicMock(return_value="request-info")
)
),
request_adapter=SimpleNamespace(send_primitive_async=send_primitive),
)
return entra_service
def test__get_device_registration_policy(self):
payload = b"""
{
"id": "deviceRegistrationPolicy",
"userDeviceQuota": 50,
"azureADJoin": {
"allowedToJoin": {
"@odata.type": "#microsoft.graph.allDeviceRegistrationMembership"
},
"localAdmins": {
"enableGlobalAdmins": true,
"registeringUsers": {
"@odata.type": "#microsoft.graph.enumeratedDeviceRegistrationMembership"
}
}
},
"localAdminPassword": {"isEnabled": false}
}
"""
send_primitive = AsyncMock(return_value=payload)
entra_service = self._mocked_device_registration_entra(send_primitive)
policy = asyncio.run(entra_service._get_device_registration_policy())
assert policy == DeviceRegistrationPolicy(
user_device_quota=50,
azure_ad_join_allowed_to_join_type=DeviceRegistrationMembershipType.ALL.value,
azure_ad_join_global_admins_enabled=True,
azure_ad_join_registering_users_type=DeviceRegistrationMembershipType.ENUMERATED.value,
local_admin_password_enabled=False,
)
send_primitive.assert_awaited_once_with("request-info", "bytes", {})
def test__get_device_registration_policy_missing_fields(self):
send_primitive = AsyncMock(return_value=b'{"id": "deviceRegistrationPolicy"}')
entra_service = self._mocked_device_registration_entra(send_primitive)
policy = asyncio.run(entra_service._get_device_registration_policy())
assert policy == DeviceRegistrationPolicy(
user_device_quota=None,
azure_ad_join_allowed_to_join_type=None,
azure_ad_join_global_admins_enabled=None,
azure_ad_join_registering_users_type=None,
local_admin_password_enabled=None,
)
def test__get_device_registration_policy_returns_none_on_error(self):
send_primitive = AsyncMock(side_effect=Exception("Graph error"))
entra_service = self._mocked_device_registration_entra(send_primitive)
assert asyncio.run(entra_service._get_device_registration_policy()) is None
def test__get_service_principals_filters_third_party_owners(self):
"""Service principals owned by another tenant must not be returned."""
# Mixed-case input to verify the service normalizes both sides before