feat(m365): add CIS M365 v7.0.0 intune enrollment check

This commit is contained in:
pedrooot committed 2026-07-28 13:36:33 +02:00
1 parent 1460f7b188
commit a9d9c5cdb6
8 files changed
+386 -2

No files matched your search

@@ -0,0 +1 @@
1 M365 intune check covering CIS Microsoft 365 Foundations Benchmark v7.0.0 control 4.2
+3 -1
View File
@@ -1005,7 +1005,9 @@
{
"Id": "4.2",
"Description": "Device enrollment restrictions let you restrict devices from enrolling in Intune based on certain device attributes such as device limit, device platform, OS Version, manufacturer or device ownership (Personally owned devices). The recommended state is to Block personally owned devices from enrollment.",
"Checks": [],
"Checks": [
"intune_device_enrollment_personal_device_restricted"
],
"Attributes": [
{
"Section": "4 Microsoft Intune admin center",
@@ -0,0 +1,37 @@
{
"Provider": "m365",
"CheckID": "intune_device_enrollment_personal_device_restricted",
"CheckTitle": "Default device enrollment restriction blocks personally owned devices",
"CheckType": [],
"ServiceName": "intune",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "The default (priority 0) Intune device platform restriction configuration should block **personally owned** devices for all platforms (**personalDeviceEnrollmentBlocked** set to true). This ensures only corporate-owned or otherwise managed devices can enroll into Intune.",
"Risk": "Allowing personally owned devices to enroll gives unmanaged, potentially non-compliant endpoints access to corporate resources, increasing the risk of data leakage and compromise from devices outside the organization's control.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/mem/intune/enrollment/enrollment-restrictions-set"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to the Microsoft Intune admin center at https://intune.microsoft.com/\n2. Go to **Devices** > **Device onboarding** > **Enrollment** > **Device platform restriction**\n3. Open the **Default** priority policy > **All Users** > **Properties**\n4. Set every platform to **Block** in the **Personally owned** column\n5. Save the changes",
"Terraform": ""
},
"Recommendation": {
"Text": "Block personally owned device enrollment for all platforms in the default enrollment platform restriction so only corporate-owned devices can enroll into Intune.",
"Url": "https://hub.prowler.com/check/intune_device_enrollment_personal_device_restricted"
}
},
"Categories": [
"identity-access",
"e3"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,62 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.intune.intune_client import intune_client
class intune_device_enrollment_personal_device_restricted(Check):
"""Check if the default device enrollment restriction blocks personal devices.
The default (priority 0) device platform restriction configuration should block
personally owned devices for all platforms
(``personalDeviceEnrollmentBlocked`` set to true).
- PASS: The default platform restriction blocks personal devices for all
platforms.
- FAIL: The default platform restriction allows personal devices for one or more
platforms.
"""
def execute(self) -> List[CheckReportM365]:
"""Evaluate whether the default enrollment restriction blocks personal devices.
Reads the default (priority 0) device platform restriction configuration and
reports PASS when personally owned devices are blocked for all platforms, and
FAIL when they are allowed for one or more platforms.
Returns:
List[CheckReportM365]: The findings for the check.
"""
findings = []
configurations = intune_client.device_enrollment_configurations
default_config = next(
(config for config in configurations if config.priority == 0),
None,
)
if not default_config:
return findings
report = CheckReportM365(
metadata=self.metadata(),
resource=default_config,
resource_name="Default Device Enrollment Platform Restrictions",
resource_id=default_config.id,
)
restrictions = default_config.platform_restrictions
if restrictions and all(restrictions.values()):
report.status = "PASS"
report.status_extended = (
"The default device enrollment restriction blocks personally owned "
"devices for all platforms."
)
else:
report.status = "FAIL"
report.status_extended = (
"The default device enrollment restriction allows personally owned "
"devices for one or more platforms."
)
findings.append(report)
return findings
@@ -1,5 +1,6 @@
import asyncio
from typing import Optional
import json
from typing import Dict, Optional
from kiota_abstractions.base_request_configuration import RequestConfiguration
from pydantic.v1 import BaseModel
@@ -29,6 +30,9 @@ class Intune(M365Service):
self.settings: Optional[IntuneSettings] = None
self.compliance_policies: Optional[list[IntuneCompliancePolicy]] = []
self.managed_devices: Optional[list[IntuneManagedDevice]] = []
self.device_enrollment_configurations: list["DeviceEnrollmentConfiguration"] = (
[]
)
self.verification_error: Optional[str] = None
loop = self._get_event_loop()
@@ -44,6 +48,9 @@ class Intune(M365Service):
self.settings = settings
self.compliance_policies = policies
self.managed_devices = managed_devices
self.device_enrollment_configurations = loop.run_until_complete(
self._get_device_enrollment_configurations()
)
self.verification_error = (
" ".join(
error
@@ -82,6 +89,72 @@ class Intune(M365Service):
except Exception as error:
logger.debug(f"Intune - Failed to clean up event loop: {error}")
async def _get_device_enrollment_configurations(
self,
) -> "list[DeviceEnrollmentConfiguration]":
"""Retrieve device enrollment configurations (platform restrictions).
Fetches ``deviceManagement/deviceEnrollmentConfigurations`` as raw JSON and
parses the platform-restriction configurations, capturing each platform's
``personalDeviceEnrollmentBlocked`` flag.
Returns:
list[DeviceEnrollmentConfiguration]: The parsed enrollment configurations.
"""
logger.info("M365 - Getting Intune device enrollment configurations...")
configurations: list[DeviceEnrollmentConfiguration] = []
try:
# The beta endpoint exposes the full set of per-platform restrictions
# (including macOS/Android for Work) that v1.0 may omit.
next_link = (
"https://graph.microsoft.com/beta/deviceManagement/"
"deviceEnrollmentConfigurations"
)
while next_link:
builder = self.client.device_management.device_enrollment_configurations.with_url(
next_link
)
request_info = builder.to_get_request_information()
response = await self.client.request_adapter.send_primitive_async(
request_info, "bytes", {}
)
if not response:
break
data = json.loads(response)
for config in data.get("value", []) or []:
odata_type = config.get("@odata.type", "")
if "PlatformRestrictionsConfiguration" not in odata_type:
continue
platform_restrictions = {}
for key, value in config.items():
if (
key.endswith("Restriction")
and isinstance(value, dict)
and "personalDeviceEnrollmentBlocked" in value
):
# A platform is compliant when personal enrollment is
# blocked OR the platform itself is fully blocked (CIS:
# a blocked platform is also a passing state).
platform_restrictions[key] = bool(
value.get("personalDeviceEnrollmentBlocked", False)
) or bool(value.get("platformBlocked", False))
configurations.append(
DeviceEnrollmentConfiguration(
id=config.get("id", ""),
priority=config.get("priority", 0),
odata_type=odata_type,
platform_restrictions=platform_restrictions,
)
)
# Follow pagination using the opaque nextLink until exhausted.
next_link = data.get("@odata.nextLink") or data.get("nextLink")
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return configurations
async def _load_intune_configuration(
self,
) -> tuple[
@@ -320,3 +393,22 @@ class IntuneManagedDevice(BaseModel):
device_name: str = ""
compliance_state: str = ""
management_agent: str = ""
class DeviceEnrollmentConfiguration(BaseModel):
"""Represents an Intune device enrollment (platform restrictions) configuration.
Attributes:
id: The device enrollment configuration identifier.
priority: The configuration priority (0 is the default configuration).
odata_type: The Graph ``@odata.type`` of the configuration.
platform_restrictions: Mapping of platform restriction name to a boolean
indicating whether personal-device enrollment is blocked (or the
platform is fully blocked) for that platform.
"""
id: str
priority: int = 0
odata_type: Optional[str] = None
# Mapping of platform restriction name -> personalDeviceEnrollmentBlocked flag.
platform_restrictions: Dict[str, bool] = {}
@@ -0,0 +1,62 @@
from unittest import mock
from prowler.providers.m365.services.intune.intune_service import (
DeviceEnrollmentConfiguration,
)
from tests.providers.m365.m365_fixtures import set_mocked_m365_provider
CHECK_MODULE_PATH = "prowler.providers.m365.services.intune.intune_device_enrollment_personal_device_restricted.intune_device_enrollment_personal_device_restricted"
class Test_intune_device_enrollment_personal_device_restricted:
def _run(self, configurations):
intune_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}.intune_client", new=intune_client),
):
from prowler.providers.m365.services.intune.intune_device_enrollment_personal_device_restricted.intune_device_enrollment_personal_device_restricted import (
intune_device_enrollment_personal_device_restricted,
)
intune_client.device_enrollment_configurations = configurations
return intune_device_enrollment_personal_device_restricted().execute()
def test_no_default_config(self):
assert self._run([]) == []
def test_all_blocked(self):
result = self._run(
[
DeviceEnrollmentConfiguration(
id="cfg",
priority=0,
platform_restrictions={
"iosRestriction": True,
"androidRestriction": True,
"windowsRestriction": True,
},
)
]
)
assert len(result) == 1
assert result[0].status == "PASS"
def test_one_allowed(self):
result = self._run(
[
DeviceEnrollmentConfiguration(
id="cfg",
priority=0,
platform_restrictions={
"iosRestriction": True,
"androidRestriction": False,
},
)
]
)
assert len(result) == 1
assert result[0].status == "FAIL"
@@ -96,6 +96,134 @@ def _build_intune_service(
return Intune(set_mocked_m365_provider())
class Test_Intune_DeviceEnrollmentConfigurations:
def _run_getter(self, payload):
import json
service = Intune.__new__(Intune)
client = mock.MagicMock()
builder = (
client.device_management.device_enrollment_configurations.with_url.return_value
)
builder.to_get_request_information.return_value = mock.MagicMock()
client.request_adapter.send_primitive_async = AsyncMock(
return_value=json.dumps(payload).encode()
)
service.client = client
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(
service._get_device_enrollment_configurations()
)
finally:
loop.close()
def test_parses_platform_restrictions_and_platform_blocked(self):
payload = {
"value": [
{
"@odata.type": "#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration",
"id": "default",
"priority": 0,
"iosRestriction": {
"personalDeviceEnrollmentBlocked": True,
"platformBlocked": False,
},
"androidRestriction": {
"personalDeviceEnrollmentBlocked": False,
"platformBlocked": True,
},
"windowsRestriction": {
"personalDeviceEnrollmentBlocked": False,
"platformBlocked": False,
},
},
{
"@odata.type": "#microsoft.graph.deviceEnrollmentWindowsHelloForBusinessConfiguration",
"id": "whfb",
"priority": 0,
},
]
}
result = self._run_getter(payload)
# Only the platform-restrictions config is captured (WHfB ignored).
assert len(result) == 1
config = result[0]
assert config.priority == 0
# ios: personal blocked -> compliant; android: platformBlocked -> compliant;
# windows: neither -> not compliant.
assert config.platform_restrictions == {
"iosRestriction": True,
"androidRestriction": True,
"windowsRestriction": False,
}
def test_empty_response(self):
assert self._run_getter({"value": []}) == []
def test_follows_pagination_across_pages(self):
import json
first_page = {
"value": [
{
"@odata.type": "#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration",
"id": "page-one",
"priority": 0,
"iosRestriction": {
"personalDeviceEnrollmentBlocked": True,
"platformBlocked": False,
},
},
],
"@odata.nextLink": "https://graph.microsoft.com/beta/deviceManagement/deviceEnrollmentConfigurations?$skiptoken=abc",
}
second_page = {
"value": [
{
"@odata.type": "#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration",
"id": "page-two",
"priority": 1,
"androidRestriction": {
"personalDeviceEnrollmentBlocked": False,
"platformBlocked": True,
},
},
],
}
service = Intune.__new__(Intune)
client = mock.MagicMock()
builder = (
client.device_management.device_enrollment_configurations.with_url.return_value
)
builder.to_get_request_information.return_value = mock.MagicMock()
client.request_adapter.send_primitive_async = AsyncMock(
side_effect=[
json.dumps(first_page).encode(),
json.dumps(second_page).encode(),
]
)
service.client = client
loop = asyncio.new_event_loop()
try:
result = loop.run_until_complete(
service._get_device_enrollment_configurations()
)
finally:
loop.close()
# Configurations from BOTH pages must be collected.
assert len(result) == 2
assert {config.id for config in result} == {"page-one", "page-two"}
assert client.request_adapter.send_primitive_async.await_count == 2
# The second request must follow the opaque nextLink, not repeat the first.
client.device_management.device_enrollment_configurations.with_url.assert_any_call(
first_page["@odata.nextLink"]
)
class Test_Intune_Service:
def test_get_settings_secure_by_default_true(self):
intune = _build_intune_service()