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 @@
`entra_device_registration_join_restricted`, `entra_device_registration_max_devices_per_user_limited`, `entra_device_registration_global_admins_not_local_admins`, `entra_device_registration_registering_user_not_local_admin`, `entra_device_registration_laps_enabled` and `entra_policy_default_user_cannot_read_bitlocker_keys` checks for M365 provider, covering CIS Microsoft 365 Foundations Benchmark v7.0.0 device registration controls (5.1.4.x)
+18 -6
View File
@@ -1250,7 +1250,9 @@
{
"Id": "5.1.4.1",
"Description": "This setting enables you to select the users who can register their devices as Microsoft Entra joined devices. The recommended state is Selected or None. Note: This setting is applicable only to Microsoft Entra join on Windows 10 or newer. This setting doesn't apply to Microsoft Entra hybrid joined devices, Microsoft Entra joined VMs in Azure, or Microsoft Entra joined devices that use Windows Autopilot self- deployment mode because these methods work in a userless context.",
"Checks": [],
"Checks": [
"entra_device_registration_join_restricted"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",
@@ -1271,7 +1273,9 @@
{
"Id": "5.1.4.2",
"Description": "This setting defines the maximum number of Microsoft Entra joined or registered devices that a user can have in Microsoft Entra ID. Once this limit is reached, no additional devices can be added until existing ones are removed. Values above 100 are automatically capped at 100. The recommended state is 10 or less.",
"Checks": [],
"Checks": [
"entra_device_registration_max_devices_per_user_limited"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",
@@ -1292,7 +1296,9 @@
{
"Id": "5.1.4.3",
"Description": "This setting controls whether the Global Administrator role is automatically added to the local administrators group on a device during the Microsoft Entra join process. The recommended state is No.",
"Checks": [],
"Checks": [
"entra_device_registration_global_admins_not_local_admins"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",
@@ -1313,7 +1319,9 @@
{
"Id": "5.1.4.4",
"Description": "This setting determines if the Microsoft Entra user registering their device as Microsoft Entra join will be added to the local administrators group. This setting applies only once during the actual registration of the device as Microsoft Entra join. The recommended state is Selected or None.",
"Checks": [],
"Checks": [
"entra_device_registration_registering_user_not_local_admin"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",
@@ -1334,7 +1342,9 @@
{
"Id": "5.1.4.5",
"Description": "Local Administrator Password Solution (LAPS) is the management of local account passwords on Windows devices. LAPS provides a solution to securely manage and retrieve the built-in local admin password. With cloud version of LAPS, customers can enable storing and rotation of local admin passwords for both Microsoft Entra and Microsoft Entra hybrid join devices The recommended state is Yes.",
"Checks": [],
"Checks": [
"entra_device_registration_laps_enabled"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",
@@ -1355,7 +1365,9 @@
{
"Id": "5.1.4.6",
"Description": "This setting determines if users can self-service recover their BitLocker key(s). 'Yes' restricts non-admin users from being able to see the BitLocker key(s) for their owned devices if there are any. 'No' allows all users to recover their BitLocker key(s). The recommended state is Yes.",
"Checks": [],
"Checks": [
"entra_policy_default_user_cannot_read_bitlocker_keys"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",
@@ -0,0 +1,37 @@
{
"Provider": "m365",
"CheckID": "entra_device_registration_global_admins_not_local_admins",
"CheckTitle": "Global Administrators are not added as local administrators during Entra join",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "The tenant device registration policy should not automatically add the **Global Administrator** role to the local administrators group of a device during the Microsoft Entra join process (**azureADJoin.localAdmins.enableGlobalAdmins** should be false).",
"Risk": "Automatically granting Global Administrators local admin rights on every Entra-joined device broadens the blast radius of a device compromise and violates least privilege, since local admin rights on endpoints are rarely required for directory administration.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/entra/identity/devices/assign-local-admin"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Identity** > **Devices** > **Device settings**\n3. Set **Global administrator role is added as local administrator on the device during Microsoft Entra join** to **No**\n4. Click **Save**",
"Terraform": ""
},
"Recommendation": {
"Text": "Disable the automatic addition of Global Administrators to the local administrators group during Microsoft Entra join and grant local admin rights only where required.",
"Url": "https://hub.prowler.com/check/entra_device_registration_global_admins_not_local_admins"
}
},
"Categories": [
"identity-access",
"e3"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,49 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.entra.entra_client import entra_client
class entra_device_registration_global_admins_not_local_admins(Check):
"""Check if Global Administrators are not added as local admins on Entra join.
The device registration policy should not automatically add the Global
Administrator role to the local administrators group of a device during the
Microsoft Entra join process.
- PASS: Global Administrators are not added as local administrators on Entra join.
- FAIL: Global Administrators are added as local administrators on Entra join.
"""
def execute(self) -> List[CheckReportM365]:
"""Execute the Global Administrators local-admin restriction check.
Returns:
List[CheckReportM365]: A list containing the result of the check.
"""
findings = []
policy = entra_client.device_registration_policy
if not policy:
return findings
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name="Device Registration Policy",
resource_id="deviceRegistrationPolicy",
)
report.status = "FAIL"
report.status_extended = (
"Global Administrators are added as local administrators on devices "
"during Microsoft Entra join."
)
if policy.azure_ad_join_global_admins_enabled is False:
report.status = "PASS"
report.status_extended = (
"Global Administrators are not added as local administrators on "
"devices during Microsoft Entra join."
)
findings.append(report)
return findings
@@ -0,0 +1,37 @@
{
"Provider": "m365",
"CheckID": "entra_device_registration_join_restricted",
"CheckTitle": "Users allowed to join devices to Microsoft Entra are restricted",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "The tenant device registration policy should restrict who can register devices as **Microsoft Entra joined** to **Selected** users or **None**. Allowing all users to join devices increases the number of devices that establish a trust relationship with the tenant.",
"Risk": "When all users can Entra-join devices, an attacker who compromises any account can register a device, potentially satisfying device-based **Conditional Access** controls and expanding their foothold in the tenant.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/entra/identity/devices/device-join-plan"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Identity** > **Devices** > **Device settings**\n3. Set **Users may join devices to Microsoft Entra** to **Selected** (and choose the allowed users/groups) or **None**\n4. Click **Save**",
"Terraform": ""
},
"Recommendation": {
"Text": "Restrict Entra device join to a selected set of users or disable it entirely unless there is a business need for all users to join devices.",
"Url": "https://hub.prowler.com/check/entra_device_registration_join_restricted"
}
},
"Categories": [
"identity-access",
"e3"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,57 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.entra.entra_client import entra_client
from prowler.providers.m365.services.entra.entra_service import (
DeviceRegistrationMembershipType,
)
RESTRICTED_MEMBERSHIP_TYPES = {
DeviceRegistrationMembershipType.ENUMERATED.value,
DeviceRegistrationMembershipType.NONE.value,
}
class entra_device_registration_join_restricted(Check):
"""Check if the users allowed to join devices to Entra are restricted.
The device registration policy should restrict who can register devices as
Microsoft Entra joined to Selected users or None, rather than allowing all
users.
- PASS: Only selected users or no users may join devices to Entra.
- FAIL: The users allowed to join devices are not restricted to Selected or None.
"""
def execute(self) -> List[CheckReportM365]:
"""Execute the Entra device join restriction check.
Returns:
List[CheckReportM365]: A list containing the result of the check.
"""
findings = []
policy = entra_client.device_registration_policy
if not policy:
return findings
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name="Device Registration Policy",
resource_id="deviceRegistrationPolicy",
)
report.status = "FAIL"
report.status_extended = (
"The users allowed to join devices to Microsoft Entra are not "
"restricted to selected users or none."
)
if policy.azure_ad_join_allowed_to_join_type in RESTRICTED_MEMBERSHIP_TYPES:
report.status = "PASS"
report.status_extended = (
"Only selected users or no users are allowed to join devices to "
"Microsoft Entra."
)
findings.append(report)
return findings
@@ -0,0 +1,37 @@
{
"Provider": "m365",
"CheckID": "entra_device_registration_laps_enabled",
"CheckTitle": "Microsoft Entra Local Administrator Password Solution (LAPS) is enabled",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "The tenant device registration policy should enable **Microsoft Entra Local Administrator Password Solution (LAPS)** (**localAdminPassword.isEnabled**). LAPS securely manages and rotates the built-in local administrator password on Windows devices and stores it for controlled retrieval.",
"Risk": "Without **LAPS**, local administrator passwords are often static and shared across devices, enabling **lateral movement**: an attacker who recovers one device's local admin password can reuse it across the fleet.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/entra/identity/devices/howto-manage-local-admin-passwords"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Identity** > **Devices** > **Device settings**\n3. Set **Enable Microsoft Entra Local Administrator Password Solution (LAPS)** to **Yes**\n4. Click **Save**",
"Terraform": ""
},
"Recommendation": {
"Text": "Enable Microsoft Entra LAPS and deploy a matching Intune policy so local administrator passwords are unique per device, rotated automatically, and retrievable only by authorized roles.",
"Url": "https://hub.prowler.com/check/entra_device_registration_laps_enabled"
}
},
"Categories": [
"identity-access",
"e3"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,47 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.entra.entra_client import entra_client
class entra_device_registration_laps_enabled(Check):
"""Check if Microsoft Entra Local Administrator Password Solution (LAPS) is enabled.
The device registration policy should enable LAPS so that the built-in local
administrator password on Windows devices is securely managed and rotated.
- PASS: LAPS is enabled.
- FAIL: LAPS is disabled.
"""
def execute(self) -> List[CheckReportM365]:
"""Execute the Microsoft Entra LAPS enablement check.
Returns:
List[CheckReportM365]: A list containing the result of the check.
"""
findings = []
policy = entra_client.device_registration_policy
if not policy:
return findings
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name="Device Registration Policy",
resource_id="deviceRegistrationPolicy",
)
report.status = "FAIL"
report.status_extended = (
"Microsoft Entra Local Administrator Password Solution (LAPS) is disabled."
)
if policy.local_admin_password_enabled:
report.status = "PASS"
report.status_extended = (
"Microsoft Entra Local Administrator Password Solution (LAPS) is "
"enabled."
)
findings.append(report)
return findings
@@ -0,0 +1,37 @@
{
"Provider": "m365",
"CheckID": "entra_device_registration_max_devices_per_user_limited",
"CheckTitle": "Maximum number of devices per user is limited",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "low",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "The tenant device registration policy should limit the maximum number of Microsoft Entra joined or registered devices per user to **10 or less** (**userDeviceQuota**). Once the limit is reached, no additional devices can be added until existing ones are removed.",
"Risk": "An unbounded or high per-user device quota lets a compromised account register many devices, increasing the number of trusted endpoints an attacker controls and complicating device lifecycle governance.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/entra/identity/devices/device-join-plan"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Identity** > **Devices** > **Device settings**\n3. Set **Maximum number of devices per user** to **10** or less\n4. Click **Save**",
"Terraform": ""
},
"Recommendation": {
"Text": "Set the maximum number of devices per user to 10 or less to bound the number of trusted endpoints each identity can register.",
"Url": "https://hub.prowler.com/check/entra_device_registration_max_devices_per_user_limited"
}
},
"Categories": [
"identity-access",
"e3"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,58 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.entra.entra_client import entra_client
# CIS recommends a maximum of 10 devices per user (or less).
MAX_DEVICES_PER_USER = 10
class entra_device_registration_max_devices_per_user_limited(Check):
"""Check if the maximum number of devices per user is limited.
The device registration policy should set the maximum number of Entra joined or
registered devices per user to 10 or less.
- PASS: The maximum number of devices per user is 10 or less.
- FAIL: The maximum number of devices per user is greater than 10 (or unlimited).
"""
def execute(self) -> List[CheckReportM365]:
"""Execute the per-user device quota limit check.
Returns:
List[CheckReportM365]: A list containing the result of the check.
"""
findings = []
policy = entra_client.device_registration_policy
if not policy:
return findings
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name="Device Registration Policy",
resource_id="deviceRegistrationPolicy",
)
quota = policy.user_device_quota
report.status = "FAIL"
if quota is None:
report.status_extended = (
"The maximum number of devices per user is not limited, exceeding "
f"the recommended limit of {MAX_DEVICES_PER_USER}."
)
else:
report.status_extended = (
f"The maximum number of devices per user is {quota}, which exceeds "
f"the recommended limit of {MAX_DEVICES_PER_USER}."
)
if quota is not None and quota <= MAX_DEVICES_PER_USER:
report.status = "PASS"
report.status_extended = (
f"The maximum number of devices per user is {quota}, within the "
f"recommended limit of {MAX_DEVICES_PER_USER}."
)
findings.append(report)
return findings
@@ -0,0 +1,37 @@
{
"Provider": "m365",
"CheckID": "entra_device_registration_registering_user_not_local_admin",
"CheckTitle": "Registering user is not added as local administrator during Entra join",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "The tenant device registration policy should restrict which registering users are added to the local administrators group during Microsoft Entra join to **Selected** users or **None** (**azureADJoin.localAdmins.registeringUsers**), rather than granting local admin to every user who registers a device.",
"Risk": "Automatically granting the registering user local administrator rights gives standard users elevated control over their devices, increasing the impact of endpoint compromise and enabling local privilege abuse.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/entra/identity/devices/assign-local-admin"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Identity** > **Devices** > **Device settings**\n3. Set **Registering user is added as local administrator on the device during Microsoft Entra join** to **Selected** or **None**\n4. Click **Save**",
"Terraform": ""
},
"Recommendation": {
"Text": "Restrict or disable the automatic addition of the registering user to the local administrators group during Microsoft Entra join, granting local admin rights only to selected users where required.",
"Url": "https://hub.prowler.com/check/entra_device_registration_registering_user_not_local_admin"
}
},
"Categories": [
"identity-access",
"e3"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,59 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.entra.entra_client import entra_client
from prowler.providers.m365.services.entra.entra_service import (
DeviceRegistrationMembershipType,
)
RESTRICTED_MEMBERSHIP_TYPES = {
DeviceRegistrationMembershipType.ENUMERATED.value,
DeviceRegistrationMembershipType.NONE.value,
}
class entra_device_registration_registering_user_not_local_admin(Check):
"""Check if the registering user is not added as local admin on Entra join.
The device registration policy should restrict which registering users are added
to the local administrators group during Microsoft Entra join to Selected users
or None, rather than all registering users.
- PASS: Registering users are restricted (Selected or None) from becoming local
administrators on Entra join.
- FAIL: The registering users added as local administrators are not restricted
to Selected or None.
"""
def execute(self) -> List[CheckReportM365]:
"""Execute the registering-user local-admin restriction check.
Returns:
List[CheckReportM365]: A list containing the result of the check.
"""
findings = []
policy = entra_client.device_registration_policy
if not policy:
return findings
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name="Device Registration Policy",
resource_id="deviceRegistrationPolicy",
)
report.status = "FAIL"
report.status_extended = (
"Registering users are not restricted from being added as local "
"administrators on devices during Microsoft Entra join."
)
if policy.azure_ad_join_registering_users_type in RESTRICTED_MEMBERSHIP_TYPES:
report.status = "PASS"
report.status_extended = (
"Registering users are restricted from being added as local "
"administrators on devices during Microsoft Entra join."
)
findings.append(report)
return findings
@@ -0,0 +1,38 @@
{
"Provider": "m365",
"CheckID": "entra_policy_default_user_cannot_read_bitlocker_keys",
"CheckTitle": "Non-admin users cannot read BitLocker keys for their owned devices",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "Microsoft Entra tenant's authorization policy should restrict **non-admin users** from reading (self-recovering) **BitLocker recovery keys** for devices they own. Restricting self-service recovery reduces the risk of an attacker who has compromised a user account from also recovering the disk-encryption key of that user's device.",
"Risk": "If a user can retrieve the **BitLocker recovery key** for their own device, an attacker who compromises the account can decrypt the device's disk, exposing data at rest. Recovery-key access should be limited to administrators and controlled recovery workflows.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/entra/identity/devices/device-management-azure-portal"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Identity** > **Devices** > **All devices** > **Device settings**\n3. Set **Users can recover BitLocker key(s) for their owned devices** to **No**\n4. Click **Save**",
"Terraform": ""
},
"Recommendation": {
"Text": "Disable self-service BitLocker recovery-key access for non-admin users so that recovery keys can only be retrieved by administrators through a controlled process.",
"Url": "https://hub.prowler.com/check/entra_policy_default_user_cannot_read_bitlocker_keys"
}
},
"Categories": [
"identity-access",
"encryption",
"e3"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,48 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.entra.entra_client import entra_client
class entra_policy_default_user_cannot_read_bitlocker_keys(Check):
"""Check if default users are restricted from reading BitLocker keys for their owned devices.
This check verifies whether the authorization policy prevents non-admin users
from self-recovering BitLocker keys for devices they own in Microsoft Entra ID.
- PASS: Non-admin users cannot read BitLocker keys for their owned devices.
- FAIL: Non-admin users are allowed to read BitLocker keys for their owned devices.
"""
def execute(self) -> List[CheckReportM365]:
"""Execute the check for BitLocker key self-recovery restrictions.
Returns:
List[CheckReportM365]: A list containing the result of the check.
"""
findings = []
auth_policy = entra_client.authorization_policy
report = CheckReportM365(
metadata=self.metadata(),
resource=auth_policy if auth_policy else {},
resource_name=auth_policy.name if auth_policy else "Authorization Policy",
resource_id=auth_policy.id if auth_policy else "authorizationPolicy",
)
report.status = "FAIL"
report.status_extended = "Non-admin users are allowed to read BitLocker keys for their owned devices."
if (
getattr(auth_policy, "default_user_role_permissions", None)
and getattr(
auth_policy.default_user_role_permissions,
"allowed_to_read_bitlocker_keys_for_owned_device",
None,
)
is False
):
report.status = "PASS"
report.status_extended = "Non-admin users are not allowed to read BitLocker keys for their owned devices."
findings.append(report)
return findings
@@ -102,6 +102,7 @@ class Entra(M365Service):
self._get_service_principals(),
self._get_app_registrations(),
self._get_exchange_mailbox_permission_service_principals(),
self._get_device_registration_policy(),
)
)
@@ -122,6 +123,9 @@ class Entra(M365Service):
self.exchange_mailbox_permission_service_principals: Dict[
str, "ServicePrincipal"
] = attributes[12]
self.device_registration_policy: Optional[DeviceRegistrationPolicy] = (
attributes[13]
)
self.user_accounts_status = {}
# Resolve directory-object identifiers referenced by Conditional Access
@@ -1192,6 +1196,53 @@ OAuthAppInfo
)
return authentication_method_configurations
async def _get_device_registration_policy(self):
"""Retrieve the tenant device registration policy from Microsoft Entra.
Fetches the ``policies/deviceRegistrationPolicy`` singleton from the v1.0
Graph endpoint. The response is parsed from raw JSON because the audited
settings (``azureADJoin.*`` membership objects) are polymorphic
``@odata.type`` values that are simpler to read from the raw payload than
through the typed SDK model.
Returns:
Optional[DeviceRegistrationPolicy]: The parsed policy, or None on error.
"""
logger.info("Entra - Getting device registration policy...")
device_registration_policy = None
try:
request_info = (
self.client.policies.device_registration_policy.to_get_request_information()
)
response = await self.client.request_adapter.send_primitive_async(
request_info, "bytes", {}
)
if response:
data = json.loads(response)
azure_ad_join = data.get("azureADJoin", {}) or {}
local_admins = azure_ad_join.get("localAdmins", {}) or {}
allowed_to_join = azure_ad_join.get("allowedToJoin", {}) or {}
registering_users = local_admins.get("registeringUsers", {}) or {}
local_admin_password = data.get("localAdminPassword", {}) or {}
device_registration_policy = DeviceRegistrationPolicy(
user_device_quota=data.get("userDeviceQuota"),
azure_ad_join_allowed_to_join_type=allowed_to_join.get(
"@odata.type"
),
azure_ad_join_global_admins_enabled=local_admins.get(
"enableGlobalAdmins"
),
azure_ad_join_registering_users_type=registering_users.get(
"@odata.type"
),
local_admin_password_enabled=local_admin_password.get("isEnabled"),
)
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return device_registration_policy
async def _get_service_principals(self):
"""Retrieve service principals owned by the audited tenant.
@@ -1925,6 +1976,24 @@ class AuthorizationPolicy(BaseModel):
guest_user_role_id: Optional[UUID]
class DeviceRegistrationMembershipType(str, Enum):
"""OData types for Entra device registration membership settings."""
ALL = "#microsoft.graph.allDeviceRegistrationMembership"
ENUMERATED = "#microsoft.graph.enumeratedDeviceRegistrationMembership"
NONE = "#microsoft.graph.noDeviceRegistrationMembership"
class DeviceRegistrationPolicy(BaseModel):
"""Tenant device registration policy (policies/deviceRegistrationPolicy)."""
user_device_quota: Optional[int] = None
azure_ad_join_allowed_to_join_type: Optional[str] = None
azure_ad_join_global_admins_enabled: Optional[bool] = None
azure_ad_join_registering_users_type: Optional[str] = None
local_admin_password_enabled: Optional[bool] = None
class Organization(BaseModel):
id: str
name: str
@@ -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