feat(m365): add entra_conditional_access_policy_mdm_compliant_device_required check (#10220)

Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
This commit is contained in:
Hugo Pereira Brito
2026-03-26 10:36:30 +00:00
committed by GitHub
parent dd00d71a07
commit c651f60e3a
12 changed files with 1418 additions and 0 deletions
@@ -47,6 +47,9 @@ When using service principal authentication, add these **Application Permissions
- `SecurityIdentitiesSensors.Read.All`: Required for `defenderidentity_health_issues_no_open` check.
- `SharePointTenantSettings.Read.All`: Required for SharePoint service.
- `ThreatHunting.Read.All`: Required for Defender XDR checks (`defenderxdr_endpoint_privileged_user_exposed_credentials`, `defenderxdr_critical_asset_management_pending_approvals`).
- `DeviceManagementServiceConfig.Read.All`: Required for Intune device management settings used by `entra_conditional_access_policy_mdm_compliant_device_required` check.
- `DeviceManagementConfiguration.Read.All`: Required for Intune device compliance policies used by `entra_conditional_access_policy_mdm_compliant_device_required` check.
- `DeviceManagementManagedDevices.Read.All`: Required for Intune managed devices used by `entra_conditional_access_policy_mdm_compliant_device_required` check.
**External API Permissions:**
+1
View File
@@ -10,6 +10,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `cloudfront_distributions_logging_enabled` detects Standard Logging v2 via CloudWatch Log Delivery [(#10090)](https://github.com/prowler-cloud/prowler/pull/10090)
- `glue_etl_jobs_no_secrets_in_arguments` check for plaintext secrets in AWS Glue ETL job arguments [(#10368)](https://github.com/prowler-cloud/prowler/pull/10368)
- `awslambda_function_no_dead_letter_queue`, `awslambda_function_using_cross_account_layers`, and `awslambda_function_env_vars_not_encrypted_with_cmk` checks for AWS Lambda [(#10381)](https://github.com/prowler-cloud/prowler/pull/10381)
- `entra_conditional_access_policy_mdm_compliant_device_required` check for M365 provider [(#10220)](https://github.com/prowler-cloud/prowler/pull/10220)
### 🔄 Changed
@@ -0,0 +1,43 @@
{
"Provider": "m365",
"CheckID": "entra_conditional_access_policy_mdm_compliant_device_required",
"CheckTitle": "Conditional Access policy requires an MDM-compliant device for all cloud app access",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "An enabled Conditional Access policy enforces **Require device to be marked as compliant** for **all users** across **all cloud apps** with no application exclusions. This check also verifies that Intune has assigned compliance policies and at least one currently reported **compliant MDM-managed device**. If Graph exposes tenant settings, it also checks **secure-by-default** evaluation.",
"Risk": "Without a mandatory MDM-backed compliant device requirement, users can access cloud resources from **unmanaged or non-compliant devices**. If Intune compliance policies are missing, unassigned, or devices without policy assignment can remain compliant, Conditional Access can create a **false sense of protection** and increase the risk of **credential theft** and **data exfiltration**.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-grant",
"https://learn.microsoft.com/en-us/intune/intune-service/protect/device-compliance-get-started",
"https://learn.microsoft.com/en-us/intune/intune-service/protect/conditional-access",
"https://learn.microsoft.com/en-us/windows/client-management/mdm-overview",
"https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-policy-compliant-device"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Sign in to the Microsoft Intune admin center https://intune.microsoft.com with a role that can manage device compliance and enrollment.\n2. Confirm the tenant has Microsoft Intune enabled and the target users have the required Microsoft Entra ID P1 or P2 and Intune licensing needed for Conditional Access with compliance.\n3. In Intune, go to **Devices** > **Compliance** > **Policies** and select **Create policy**.\n4. Choose the target platform, configure the compliance settings your organization requires, review the configuration, and save the policy.\n5. Open the new compliance policy, go to **Assignments**, and assign it to the users or devices that must satisfy the MDM-compliant device requirement.\n6. In Intune, go to **Endpoint security** > **Device compliance** > **Compliance policy settings**.\n7. Set **Mark devices with no compliance policy assigned as** to **Not compliant** so devices without policy assignment cannot be treated as compliant.\n8. Configure or confirm an enrollment path for at least one supported platform. For a lab, the simplest path is usually a Windows device enrolled through **Company Portal**.\n9. Enroll at least one device into Intune MDM, complete registration, and allow the device to sync.\n10. In Intune, verify the enrolled device appears under **Devices** as managed through an MDM/Intune management channel and that its compliance state becomes **Compliant**.\n11. Sign in to the Microsoft Entra admin center https://entra.microsoft.com.\n12. Go to **Protection** > **Conditional Access** > **Policies** and select **New policy**.\n13. Under **Users**, include **All users**. This check does not evaluate **excluded users**, but it does require **All users** to be included.\n14. Under **Target resources**, include **All cloud apps** and do not exclude applications.\n15. Under **Grant**, select **Grant access** and enable **Require device to be marked as compliant**.\n16. If you add extra grant controls such as MFA, configure **Require all the selected controls** and do not weaken the policy with **Require one of the selected controls**.\n17. Set **Enable policy** to **On** and create the policy after validating impact.\n18. If Prowler uses an application identity to read Intune, grant and consent the Microsoft Graph permissions **DeviceManagementServiceConfig.Read.All**, **DeviceManagementConfiguration.Read.All**, and **DeviceManagementManagedDevices.Read.All** so the Intune portion of this check can be verified.",
"Terraform": ""
},
"Recommendation": {
"Text": "Enforce a Conditional Access policy requiring all users to access cloud applications only from **MDM-compliant devices**. Back that policy with Microsoft Intune device compliance policies that are actually assigned, configure Intune so devices without a compliance policy assignment are treated as **Not compliant**, and verify that at least one device is currently enrolled and reporting a **compliant** state through an MDM/Intune management channel. If Microsoft Graph exposes Intune tenant settings, also confirm the tenant reports **secure-by-default** compliance evaluation.",
"Url": "https://hub.prowler.com/check/entra_conditional_access_policy_mdm_compliant_device_required"
}
},
"Categories": [
"identity-access",
"e3"
],
"DependsOn": [],
"RelatedTo": [
"entra_managed_device_required_for_authentication"
],
"Notes": "For this check, **MDM-compliant device** means a Conditional Access policy requires **device marked as compliant**, Microsoft Intune is configured with at least one assigned device compliance policy, and Intune currently reports at least one **compliant** device under an MDM/Intune management channel. This check evaluates Conditional Access plus Intune configuration and current Intune device evidence together. It requires **All users** to be included, but it does **not** evaluate the **excluded users** field. It does require **All cloud apps** to be included and it fails when applications are excluded. It also fails when the compliant-device control is weakened by an **OR** grant operator with alternative controls such as MFA or authentication strength. When Microsoft Graph exposes **deviceManagement/settings.secureByDefault**, the check validates that setting and fails if devices without policy assignment can remain compliant. Some tenants can return **settings = null** in both Graph **v1.0** and **beta** even when Intune is active; in that case, the check does not fail solely because secure-by-default cannot be verified. The Intune portion of the check still validates compliance policies, compliance policy assignments, and the existence of at least one currently reported compliant MDM-managed device; a newly configured tenant with no compliant managed devices yet will still fail. If the identity used by Prowler cannot read Intune compliance policies, assignments, or managed devices, the result can become **MANUAL** instead of **PASS**."
}
@@ -0,0 +1,198 @@
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 (
ConditionalAccessGrantControl,
ConditionalAccessPolicy,
ConditionalAccessPolicyState,
GrantControlOperator,
)
from prowler.providers.m365.services.intune.intune_client import intune_client
from prowler.providers.m365.services.intune.intune_service import Intune
class entra_conditional_access_policy_mdm_compliant_device_required(Check):
"""Ensure a Conditional Access policy requires an MDM-compliant device for all cloud app access.
This check verifies that at least one enabled Conditional Access policy enforces
the compliant device grant control for all cloud applications and that Microsoft
Intune compliance prerequisites are configured to make that MDM requirement
effective.
- PASS: An enabled policy requires a compliant device for all cloud app access.
- FAIL: No policy mandates device compliance, or Intune prerequisites are not configured.
- MANUAL: Intune prerequisites cannot be verified due to missing visibility.
"""
def execute(self) -> list[CheckReportM365]:
"""Execute the check logic.
Returns:
A list of reports containing the result of the check.
"""
findings = []
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name="Conditional Access Policies",
resource_id="conditionalAccessPolicies",
)
report.status = "FAIL"
report.status_extended = "No Conditional Access Policy requires an MDM-compliant device for all cloud app access."
reporting_policy = None
for policy in entra_client.conditional_access_policies.values():
if not self._is_candidate_policy(policy):
continue
if policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING:
if reporting_policy is None:
reporting_policy = policy
continue
report = self._build_policy_report(policy)
verification_error = getattr(intune_client, "verification_error", None)
if verification_error:
report.status = "MANUAL"
report.status_extended = (
f"Conditional Access Policy '{policy.display_name}' requires an "
"MDM-compliant device for all cloud app access, but Microsoft "
f"Intune MDM compliance prerequisites could not be verified. {verification_error}"
)
findings.append(report)
return findings
compliance_policies = (
getattr(intune_client, "compliance_policies", []) or []
)
if not compliance_policies:
report.status = "FAIL"
report.status_extended = (
f"Conditional Access Policy '{policy.display_name}' requires an "
"MDM-compliant device for all cloud app access, but no Microsoft "
"Intune device compliance policies are configured."
)
findings.append(report)
return findings
assigned_policies = [
compliance_policy
for compliance_policy in compliance_policies
if getattr(compliance_policy, "assignment_count", 0) > 0
]
if not assigned_policies:
report.status = "FAIL"
report.status_extended = (
f"Conditional Access Policy '{policy.display_name}' requires an "
"MDM-compliant device for all cloud app access, but no Microsoft "
"Intune device compliance policy is assigned."
)
findings.append(report)
return findings
settings = getattr(intune_client, "settings", None)
secure_by_default = getattr(settings, "secure_by_default", None)
if secure_by_default is False:
report.status = "FAIL"
report.status_extended = (
f"Conditional Access Policy '{policy.display_name}' requires an "
"MDM-compliant device for all cloud app access, but Microsoft "
"Intune allows devices without an assigned compliance policy to "
"remain compliant."
)
findings.append(report)
return findings
managed_devices = getattr(intune_client, "managed_devices", []) or []
mdm_compliant_devices = [
managed_device
for managed_device in managed_devices
if getattr(managed_device, "compliance_state", "") == "compliant"
and Intune.is_mdm_managed_device(
getattr(managed_device, "management_agent", "")
)
]
if not mdm_compliant_devices:
report.status = "FAIL"
report.status_extended = (
f"Conditional Access Policy '{policy.display_name}' requires an "
"MDM-compliant device for all cloud app access, but Microsoft "
"Intune does not currently report any compliant MDM-managed devices."
)
findings.append(report)
return findings
report.status = "PASS"
if secure_by_default is None:
report.status_extended = (
f"Conditional Access Policy '{policy.display_name}' requires an "
"MDM-compliant device for all cloud app access, and Microsoft Intune "
"is configured with assigned compliance policies and at least one "
"compliant MDM-managed device. Microsoft Graph did not return "
"device management settings, so secure-by-default compliance "
"evaluation could not be verified."
)
else:
report.status_extended = (
f"Conditional Access Policy '{policy.display_name}' requires an "
"MDM-compliant device for all cloud app access, and Microsoft Intune "
"is configured with assigned compliance policies, secure-by-default "
"compliance evaluation, and at least one compliant MDM-managed device."
)
findings.append(report)
return findings
if reporting_policy is not None:
report = self._build_policy_report(reporting_policy)
report.status = "FAIL"
report.status_extended = (
f"Conditional Access Policy '{reporting_policy.display_name}' reports "
"the requirement of an MDM-compliant device for all cloud app access "
"but does not enforce it."
)
findings.append(report)
return findings
def _build_policy_report(self, policy: ConditionalAccessPolicy) -> CheckReportM365:
return CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name=policy.display_name,
resource_id=policy.id,
)
@staticmethod
def _is_candidate_policy(policy: ConditionalAccessPolicy) -> bool:
if policy.state == ConditionalAccessPolicyState.DISABLED:
return False
application_conditions = policy.conditions.application_conditions
user_conditions = policy.conditions.user_conditions
if not application_conditions or not user_conditions:
return False
if "All" not in user_conditions.included_users:
return False
if "All" not in application_conditions.included_applications:
return False
if application_conditions.excluded_applications != []:
return False
if (
ConditionalAccessGrantControl.COMPLIANT_DEVICE
not in policy.grant_controls.built_in_controls
):
return False
if policy.grant_controls.operator == GrantControlOperator.OR and (
len(policy.grant_controls.built_in_controls) > 1
or policy.grant_controls.authentication_strength is not None
):
return False
return True
@@ -0,0 +1,4 @@
from prowler.providers.common.provider import Provider
from prowler.providers.m365.services.intune.intune_service import Intune
intune_client = Intune(Provider.get_global_provider())
@@ -0,0 +1,315 @@
import asyncio
from typing import Optional
from kiota_abstractions.base_request_configuration import RequestConfiguration
from pydantic.v1 import BaseModel
from prowler.lib.logger import logger
from prowler.providers.m365.lib.service.service import M365Service
from prowler.providers.m365.m365_provider import M365Provider
class Intune(M365Service):
"""Microsoft Intune service class."""
MDM_MANAGEMENT_AGENTS = {
"mdm",
"easMdm",
"intuneClient",
"easIntuneClient",
"configurationManagerClientMdm",
"configurationManagerClientMdmEas",
"microsoft365ManagedMdm",
}
def __init__(self, provider: M365Provider):
super().__init__(provider)
self.tenant_domain = provider.identity.tenant_domain
self.settings: Optional[IntuneSettings] = None
self.compliance_policies: Optional[list[IntuneCompliancePolicy]] = []
self.managed_devices: Optional[list[IntuneManagedDevice]] = []
self.verification_error: Optional[str] = None
loop = self._get_event_loop()
try:
(
settings,
settings_error,
policies,
policies_error,
managed_devices,
managed_devices_error,
) = loop.run_until_complete(self._load_intune_configuration())
self.settings = settings
self.compliance_policies = policies
self.managed_devices = managed_devices
self.verification_error = (
" ".join(
error
for error in [
settings_error,
policies_error,
managed_devices_error,
]
if error
)
or None
)
finally:
self._cleanup_event_loop(loop)
def _get_event_loop(self) -> asyncio.AbstractEventLoop:
"""Get or create an event loop for async operations."""
try:
loop = asyncio.get_running_loop()
if loop.is_running():
raise RuntimeError(
"Cannot initialize Intune service while event loop is running"
)
return loop
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
return loop
def _cleanup_event_loop(self, loop: asyncio.AbstractEventLoop) -> None:
"""Clean up the event loop if we created it."""
try:
if loop and not loop.is_running():
asyncio.set_event_loop(None)
loop.close()
except Exception as error:
logger.debug(f"Intune - Failed to clean up event loop: {error}")
async def _load_intune_configuration(
self,
) -> tuple[
Optional["IntuneSettings"],
Optional[str],
Optional[list["IntuneCompliancePolicy"]],
Optional[str],
Optional[list["IntuneManagedDevice"]],
Optional[str],
]:
settings, settings_error = await self._get_settings()
policies, policies_error = await self._get_compliance_policies()
managed_devices, managed_devices_error = await self._get_managed_devices()
return (
settings,
settings_error,
policies,
policies_error,
managed_devices,
managed_devices_error,
)
async def _get_settings(self) -> tuple[Optional["IntuneSettings"], Optional[str]]:
"""Retrieve Intune tenant settings required for compliance evaluation."""
logger.info("Intune - Getting device management settings...")
try:
from msgraph.generated.device_management.device_management_request_builder import (
DeviceManagementRequestBuilder,
)
query_parameters = (
DeviceManagementRequestBuilder.DeviceManagementRequestBuilderGetQueryParameters()
)
query_parameters.select = ["settings"]
request_configuration = RequestConfiguration(
query_parameters=query_parameters
)
device_management = await self.client.device_management.get(
request_configuration=request_configuration
)
settings = getattr(device_management, "settings", None)
if settings is None:
return (
IntuneSettings(secure_by_default=None),
None,
)
return (
IntuneSettings(
secure_by_default=getattr(settings, "secure_by_default", None)
),
None,
)
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return (
None,
"Could not read Microsoft Intune device management settings. Ensure the Service Principal has DeviceManagementServiceConfig.Read.All permission granted.",
)
async def _get_compliance_policies(
self,
) -> tuple[Optional[list["IntuneCompliancePolicy"]], Optional[str]]:
"""Retrieve Intune device compliance policies and their assignments."""
logger.info("Intune - Getting device compliance policies...")
policies: list[IntuneCompliancePolicy] = []
try:
response = (
await self.client.device_management.device_compliance_policies.get()
)
while response:
for policy in getattr(response, "value", []) or []:
assignment_count, assignment_error = (
await self._get_assignment_count(getattr(policy, "id", ""))
)
if assignment_error:
return None, assignment_error
policies.append(
IntuneCompliancePolicy(
id=getattr(policy, "id", ""),
display_name=getattr(policy, "display_name", ""),
assignment_count=assignment_count,
)
)
next_link = getattr(response, "odata_next_link", None)
if not next_link:
break
response = await self.client.device_management.device_compliance_policies.with_url(
next_link
).get()
return policies, None
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return (
None,
"Could not read Microsoft Intune device compliance policies. Ensure the Service Principal has DeviceManagementConfiguration.Read.All permission granted.",
)
async def _get_assignment_count(self, policy_id: str) -> tuple[int, Optional[str]]:
"""Count assignments for a single Intune device compliance policy."""
if not policy_id:
return 0, None
try:
assignments_response = await self.client.device_management.device_compliance_policies.by_device_compliance_policy_id(
policy_id
).assignments.get()
assignment_count = 0
while assignments_response:
assignment_count += len(
getattr(assignments_response, "value", []) or []
)
next_link = getattr(assignments_response, "odata_next_link", None)
if not next_link:
break
assignments_response = (
await self.client.device_management.device_compliance_policies.by_device_compliance_policy_id(
policy_id
)
.assignments.with_url(next_link)
.get()
)
return assignment_count, None
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return (
0,
"Could not read Microsoft Intune device compliance policy assignments. Ensure the Service Principal has DeviceManagementConfiguration.Read.All permission granted.",
)
async def _get_managed_devices(
self,
) -> tuple[Optional[list["IntuneManagedDevice"]], Optional[str]]:
"""Retrieve Intune managed devices needed for operational evidence."""
logger.info("Intune - Getting managed devices...")
managed_devices: list[IntuneManagedDevice] = []
try:
from msgraph.generated.device_management.managed_devices.managed_devices_request_builder import (
ManagedDevicesRequestBuilder,
)
query_parameters = (
ManagedDevicesRequestBuilder.ManagedDevicesRequestBuilderGetQueryParameters()
)
query_parameters.select = [
"id",
"deviceName",
"complianceState",
"managementAgent",
]
request_configuration = RequestConfiguration(
query_parameters=query_parameters
)
response = await self.client.device_management.managed_devices.get(
request_configuration=request_configuration
)
while response:
for device in getattr(response, "value", []) or []:
managed_devices.append(
IntuneManagedDevice(
id=getattr(device, "id", ""),
device_name=getattr(device, "device_name", ""),
compliance_state=(
str(getattr(device, "compliance_state", ""))
if getattr(device, "compliance_state", None)
else ""
),
management_agent=(
str(getattr(device, "management_agent", ""))
if getattr(device, "management_agent", None)
else ""
),
)
)
next_link = getattr(response, "odata_next_link", None)
if not next_link:
break
response = await self.client.device_management.managed_devices.with_url(
next_link
).get()
return managed_devices, None
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return (
None,
"Could not read Microsoft Intune managed devices. Ensure the Service Principal has DeviceManagementManagedDevices.Read.All permission granted.",
)
@classmethod
def is_mdm_managed_device(cls, management_agent: str) -> bool:
"""Return whether a management agent represents MDM or Intune management."""
return management_agent in cls.MDM_MANAGEMENT_AGENTS
class IntuneSettings(BaseModel):
secure_by_default: Optional[bool] = None
class IntuneCompliancePolicy(BaseModel):
id: str
display_name: str
assignment_count: int = 0
class IntuneManagedDevice(BaseModel):
id: str
device_name: str = ""
compliance_state: str = ""
management_agent: str = ""
@@ -0,0 +1,485 @@
from types import SimpleNamespace
from unittest import mock
from uuid import uuid4
from prowler.providers.m365.services.entra.entra_service import (
ApplicationEnforcedRestrictions,
ApplicationsConditions,
ConditionalAccessGrantControl,
ConditionalAccessPolicy,
ConditionalAccessPolicyState,
Conditions,
GrantControlOperator,
GrantControls,
PersistentBrowser,
SessionControls,
SignInFrequency,
SignInFrequencyInterval,
UsersConditions,
)
from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
CHECK_MODULE = (
"prowler.providers.m365.services.entra."
"entra_conditional_access_policy_mdm_compliant_device_required."
"entra_conditional_access_policy_mdm_compliant_device_required"
)
def build_policy(
*,
included_users=None,
excluded_users=None,
included_applications=None,
excluded_applications=None,
built_in_controls=None,
operator=GrantControlOperator.OR,
authentication_strength=None,
state=ConditionalAccessPolicyState.ENABLED,
display_name="Test",
):
policy_id = str(uuid4())
return ConditionalAccessPolicy(
id=policy_id,
display_name=display_name,
conditions=Conditions(
application_conditions=ApplicationsConditions(
included_applications=included_applications or ["All"],
excluded_applications=excluded_applications or [],
included_user_actions=[],
),
user_conditions=UsersConditions(
included_groups=[],
excluded_groups=[],
included_users=included_users or ["All"],
excluded_users=excluded_users or [],
included_roles=[],
excluded_roles=[],
),
),
grant_controls=GrantControls(
built_in_controls=built_in_controls
or [ConditionalAccessGrantControl.COMPLIANT_DEVICE],
operator=operator,
authentication_strength=authentication_strength,
),
session_controls=SessionControls(
persistent_browser=PersistentBrowser(is_enabled=False, mode="always"),
sign_in_frequency=SignInFrequency(
is_enabled=False,
frequency=None,
type=None,
interval=SignInFrequencyInterval.TIME_BASED,
),
application_enforced_restrictions=ApplicationEnforcedRestrictions(
is_enabled=False
),
),
state=state,
)
def build_intune_client(
*,
verification_error=None,
secure_by_default=True,
assignment_counts=None,
managed_devices=None,
):
assignment_counts = assignment_counts if assignment_counts is not None else [1]
return SimpleNamespace(
verification_error=verification_error,
settings=SimpleNamespace(secure_by_default=secure_by_default),
compliance_policies=[
SimpleNamespace(
id=str(uuid4()),
display_name=f"Compliance Policy {index}",
assignment_count=assignment_count,
)
for index, assignment_count in enumerate(assignment_counts, start=1)
],
managed_devices=(
managed_devices
if managed_devices is not None
else [
SimpleNamespace(
id=str(uuid4()),
device_name="Managed Device 1",
compliance_state="compliant",
management_agent="mdm",
)
]
),
)
class Test_entra_conditional_access_policy_mdm_compliant_device_required:
def _run_check(self, conditional_access_policies, intune_client):
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.conditional_access_policies = conditional_access_policies
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(f"{CHECK_MODULE}.entra_client", new=entra_client),
mock.patch(f"{CHECK_MODULE}.intune_client", new=intune_client),
):
from prowler.providers.m365.services.entra.entra_conditional_access_policy_mdm_compliant_device_required.entra_conditional_access_policy_mdm_compliant_device_required import (
entra_conditional_access_policy_mdm_compliant_device_required,
)
check = entra_conditional_access_policy_mdm_compliant_device_required()
return check.execute()
def test_no_conditional_access_policies(self):
result = self._run_check({}, build_intune_client())
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "No Conditional Access Policy requires an MDM-compliant device for all cloud app access."
)
assert result[0].resource == {}
assert result[0].resource_name == "Conditional Access Policies"
assert result[0].resource_id == "conditionalAccessPolicies"
assert result[0].location == "global"
def test_reporting_only_policy_fails(self):
policy = build_policy(state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING)
result = self._run_check({policy.id: policy}, build_intune_client())
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Conditional Access Policy '{policy.display_name}' reports the requirement of an MDM-compliant device for all cloud app access but does not enforce it."
)
assert result[0].resource == policy.dict()
assert result[0].resource_name == policy.display_name
assert result[0].resource_id == policy.id
assert result[0].location == "global"
def test_specific_users_policy_fails(self):
policy = build_policy(included_users=["specific-user-id"])
result = self._run_check({policy.id: policy}, build_intune_client())
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "No Conditional Access Policy requires an MDM-compliant device for all cloud app access."
)
def test_policy_with_excluded_users_passes(self):
policy = build_policy(excluded_users=["break-glass-id"])
result = self._run_check({policy.id: policy}, build_intune_client())
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"Conditional Access Policy '{policy.display_name}' requires an MDM-compliant device for all cloud app access, and Microsoft Intune is configured with assigned compliance policies, secure-by-default compliance evaluation, and at least one compliant MDM-managed device."
)
assert result[0].resource == policy.dict()
def test_policy_with_excluded_applications_fails(self):
policy = build_policy(excluded_applications=["office-app-id"])
result = self._run_check({policy.id: policy}, build_intune_client())
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "No Conditional Access Policy requires an MDM-compliant device for all cloud app access."
)
def test_policy_with_or_mfa_fails(self):
policy = build_policy(
built_in_controls=[
ConditionalAccessGrantControl.COMPLIANT_DEVICE,
ConditionalAccessGrantControl.MFA,
],
operator=GrantControlOperator.OR,
)
result = self._run_check({policy.id: policy}, build_intune_client())
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "No Conditional Access Policy requires an MDM-compliant device for all cloud app access."
)
def test_policy_with_or_authentication_strength_fails(self):
policy = build_policy(
operator=GrantControlOperator.OR,
authentication_strength="Phishing-resistant MFA",
)
result = self._run_check({policy.id: policy}, build_intune_client())
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "No Conditional Access Policy requires an MDM-compliant device for all cloud app access."
)
def test_intune_verification_error_returns_manual(self):
policy = build_policy()
intune_client = build_intune_client(
verification_error=(
"Could not read Microsoft Intune device management settings. "
"Ensure the Service Principal has DeviceManagementServiceConfig.Read.All permission granted."
)
)
result = self._run_check({policy.id: policy}, intune_client)
assert len(result) == 1
assert result[0].status == "MANUAL"
assert (
result[0].status_extended
== f"Conditional Access Policy '{policy.display_name}' requires an MDM-compliant device for all cloud app access, but Microsoft Intune MDM compliance prerequisites could not be verified. {intune_client.verification_error}"
)
assert result[0].resource == policy.dict()
def test_no_intune_compliance_policies_fails(self):
policy = build_policy()
intune_client = build_intune_client()
intune_client.compliance_policies = []
result = self._run_check({policy.id: policy}, intune_client)
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Conditional Access Policy '{policy.display_name}' requires an MDM-compliant device for all cloud app access, but no Microsoft Intune device compliance policies are configured."
)
assert result[0].resource == policy.dict()
def test_unassigned_intune_compliance_policies_fail(self):
policy = build_policy()
result = self._run_check(
{policy.id: policy},
build_intune_client(assignment_counts=[0, 0]),
)
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Conditional Access Policy '{policy.display_name}' requires an MDM-compliant device for all cloud app access, but no Microsoft Intune device compliance policy is assigned."
)
assert result[0].resource == policy.dict()
def test_secure_by_default_disabled_fails(self):
policy = build_policy()
result = self._run_check(
{policy.id: policy},
build_intune_client(secure_by_default=False),
)
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Conditional Access Policy '{policy.display_name}' requires an MDM-compliant device for all cloud app access, but Microsoft Intune allows devices without an assigned compliance policy to remain compliant."
)
assert result[0].resource == policy.dict()
def test_missing_secure_by_default_does_not_block_pass(self):
policy = build_policy()
result = self._run_check(
{policy.id: policy},
build_intune_client(secure_by_default=None),
)
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"Conditional Access Policy '{policy.display_name}' requires an MDM-compliant device for all cloud app access, and Microsoft Intune is configured with assigned compliance policies and at least one compliant MDM-managed device. Microsoft Graph did not return device management settings, so secure-by-default compliance evaluation could not be verified."
)
assert result[0].resource == policy.dict()
def test_no_compliant_mdm_managed_devices_fails(self):
policy = build_policy()
result = self._run_check(
{policy.id: policy},
build_intune_client(managed_devices=[]),
)
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Conditional Access Policy '{policy.display_name}' requires an MDM-compliant device for all cloud app access, but Microsoft Intune does not currently report any compliant MDM-managed devices."
)
assert result[0].resource == policy.dict()
def test_non_mdm_compliant_devices_do_not_pass(self):
policy = build_policy()
result = self._run_check(
{policy.id: policy},
build_intune_client(
managed_devices=[
SimpleNamespace(
id=str(uuid4()),
device_name="EAS Device",
compliance_state="compliant",
management_agent="eas",
)
]
),
)
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Conditional Access Policy '{policy.display_name}' requires an MDM-compliant device for all cloud app access, but Microsoft Intune does not currently report any compliant MDM-managed devices."
)
assert result[0].resource == policy.dict()
def test_disabled_policy_is_ignored(self):
"""Disabled policy is properly ignored, resulting in generic FAIL."""
policy = build_policy(state=ConditionalAccessPolicyState.DISABLED)
result = self._run_check({policy.id: policy}, build_intune_client())
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "No Conditional Access Policy requires an MDM-compliant device for all cloud app access."
)
assert result[0].resource == {}
assert result[0].resource_name == "Conditional Access Policies"
assert result[0].resource_id == "conditionalAccessPolicies"
def test_policy_without_compliant_device_grant_is_skipped(self):
"""Policy without COMPLIANT_DEVICE grant control is skipped."""
policy = build_policy(
built_in_controls=[ConditionalAccessGrantControl.MFA],
)
result = self._run_check({policy.id: policy}, build_intune_client())
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "No Conditional Access Policy requires an MDM-compliant device for all cloud app access."
)
def test_policy_targeting_specific_apps_is_skipped(self):
"""Policy targeting specific apps instead of All is skipped."""
policy = build_policy(included_applications=["specific-app-id"])
result = self._run_check({policy.id: policy}, build_intune_client())
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "No Conditional Access Policy requires an MDM-compliant device for all cloud app access."
)
def test_noncompliant_mdm_device_does_not_count(self):
"""MDM-managed device with compliance_state='noncompliant' doesn't count as compliant."""
policy = build_policy()
result = self._run_check(
{policy.id: policy},
build_intune_client(
managed_devices=[
SimpleNamespace(
id=str(uuid4()),
device_name="Noncompliant MDM Device",
compliance_state="noncompliant",
management_agent="mdm",
)
]
),
)
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Conditional Access Policy '{policy.display_name}' requires an MDM-compliant device for all cloud app access, but Microsoft Intune does not currently report any compliant MDM-managed devices."
)
assert result[0].resource == policy.dict()
def test_reporting_policy_ignored_when_enabled_policy_exists(self):
"""Report-only policy is ignored when a valid enabled policy also exists."""
reporting_policy = build_policy(
state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING,
display_name="Reporting Policy",
)
enabled_policy = build_policy(
state=ConditionalAccessPolicyState.ENABLED,
display_name="Enabled Policy",
)
result = self._run_check(
{reporting_policy.id: reporting_policy, enabled_policy.id: enabled_policy},
build_intune_client(),
)
assert len(result) == 1
assert result[0].status == "PASS"
assert "Enabled Policy" in result[0].status_extended
assert result[0].resource == enabled_policy.dict()
def test_mixed_assigned_unassigned_compliance_policies_pass(self):
"""Mixed assigned/unassigned compliance policies (e.g. [0, 1]) still pass."""
policy = build_policy()
result = self._run_check(
{policy.id: policy},
build_intune_client(assignment_counts=[0, 1]),
)
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].resource == policy.dict()
def test_enabled_policy_with_intune_prerequisites_passes(self):
policy = build_policy(
built_in_controls=[
ConditionalAccessGrantControl.COMPLIANT_DEVICE,
ConditionalAccessGrantControl.MFA,
],
operator=GrantControlOperator.AND,
)
result = self._run_check({policy.id: policy}, build_intune_client())
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"Conditional Access Policy '{policy.display_name}' requires an MDM-compliant device for all cloud app access, and Microsoft Intune is configured with assigned compliance policies, secure-by-default compliance evaluation, and at least one compliant MDM-managed device."
)
assert result[0].resource == policy.dict()
assert result[0].resource_name == policy.display_name
assert result[0].resource_id == policy.id
assert result[0].location == "global"
@@ -0,0 +1,369 @@
import asyncio
from types import SimpleNamespace
from unittest import mock
from unittest.mock import AsyncMock, patch
from prowler.providers.m365.services.intune.intune_service import (
Intune,
IntuneCompliancePolicy,
IntuneManagedDevice,
IntuneSettings,
)
from tests.providers.m365.m365_fixtures import set_mocked_m365_provider
# --- Mock async helpers for patching Intune methods ---
async def mock_get_settings_with_secure_by_default(_):
return IntuneSettings(secure_by_default=True), None
async def mock_get_settings_null(_):
return IntuneSettings(secure_by_default=None), None
async def mock_get_settings_error(_):
return (
None,
"Could not read Microsoft Intune device management settings. Ensure the Service Principal has DeviceManagementServiceConfig.Read.All permission granted.",
)
async def mock_get_compliance_policies_with_assignments(_):
return [
IntuneCompliancePolicy(
id="policy-1", display_name="Windows Policy", assignment_count=2
),
IntuneCompliancePolicy(
id="policy-2", display_name="iOS Policy", assignment_count=0
),
], None
async def mock_get_compliance_policies_empty(_):
return [], None
async def mock_get_compliance_policies_error(_):
return (
None,
"Could not read Microsoft Intune device compliance policies. Ensure the Service Principal has DeviceManagementConfiguration.Read.All permission granted.",
)
async def mock_get_managed_devices_with_compliant(_):
return [
IntuneManagedDevice(
id="device-1",
device_name="Laptop-1",
compliance_state="compliant",
management_agent="mdm",
),
], None
async def mock_get_managed_devices_empty(_):
return [], None
async def mock_get_managed_devices_error(_):
return (
None,
"Could not read Microsoft Intune managed devices. Ensure the Service Principal has DeviceManagementManagedDevices.Read.All permission granted.",
)
def _build_intune_service(
get_settings_mock=mock_get_settings_with_secure_by_default,
get_compliance_policies_mock=mock_get_compliance_policies_with_assignments,
get_managed_devices_mock=mock_get_managed_devices_with_compliant,
):
"""Instantiate Intune with patched async methods."""
with (
patch(
"prowler.providers.m365.services.intune.intune_service.Intune._get_settings",
new=get_settings_mock,
),
patch(
"prowler.providers.m365.services.intune.intune_service.Intune._get_compliance_policies",
new=get_compliance_policies_mock,
),
patch(
"prowler.providers.m365.services.intune.intune_service.Intune._get_managed_devices",
new=get_managed_devices_mock,
),
):
return Intune(set_mocked_m365_provider())
class Test_Intune_Service:
def test_get_settings_secure_by_default_true(self):
intune = _build_intune_service()
assert intune.settings is not None
assert intune.settings.secure_by_default is True
assert intune.verification_error is None
def test_get_settings_null(self):
intune = _build_intune_service(
get_settings_mock=mock_get_settings_null,
)
assert intune.settings is not None
assert intune.settings.secure_by_default is None
assert intune.verification_error is None
def test_get_settings_error(self):
intune = _build_intune_service(
get_settings_mock=mock_get_settings_error,
)
assert intune.settings is None
assert intune.verification_error is not None
assert "DeviceManagementServiceConfig.Read.All" in intune.verification_error
def test_get_compliance_policies(self):
intune = _build_intune_service()
assert intune.compliance_policies is not None
assert len(intune.compliance_policies) == 2
assert intune.compliance_policies[0].id == "policy-1"
assert intune.compliance_policies[0].display_name == "Windows Policy"
assert intune.compliance_policies[0].assignment_count == 2
assert intune.compliance_policies[1].assignment_count == 0
def test_get_compliance_policies_empty(self):
intune = _build_intune_service(
get_compliance_policies_mock=mock_get_compliance_policies_empty,
)
assert intune.compliance_policies == []
assert intune.verification_error is None
def test_get_compliance_policies_error(self):
intune = _build_intune_service(
get_compliance_policies_mock=mock_get_compliance_policies_error,
)
assert intune.compliance_policies is None
assert intune.verification_error is not None
assert "DeviceManagementConfiguration.Read.All" in intune.verification_error
def test_get_managed_devices(self):
intune = _build_intune_service()
assert intune.managed_devices is not None
assert len(intune.managed_devices) == 1
assert intune.managed_devices[0].id == "device-1"
assert intune.managed_devices[0].device_name == "Laptop-1"
assert intune.managed_devices[0].compliance_state == "compliant"
assert intune.managed_devices[0].management_agent == "mdm"
def test_get_managed_devices_empty(self):
intune = _build_intune_service(
get_managed_devices_mock=mock_get_managed_devices_empty,
)
assert intune.managed_devices == []
assert intune.verification_error is None
def test_get_managed_devices_error(self):
intune = _build_intune_service(
get_managed_devices_mock=mock_get_managed_devices_error,
)
assert intune.managed_devices is None
assert intune.verification_error is not None
assert "DeviceManagementManagedDevices.Read.All" in intune.verification_error
def test_multiple_errors_concatenated(self):
intune = _build_intune_service(
get_settings_mock=mock_get_settings_error,
get_compliance_policies_mock=mock_get_compliance_policies_error,
)
assert intune.verification_error is not None
assert "DeviceManagementServiceConfig.Read.All" in intune.verification_error
assert "DeviceManagementConfiguration.Read.All" in intune.verification_error
def test_is_mdm_managed_device_true(self):
for agent in [
"mdm",
"easMdm",
"intuneClient",
"easIntuneClient",
"configurationManagerClientMdm",
"configurationManagerClientMdmEas",
"microsoft365ManagedMdm",
]:
assert Intune.is_mdm_managed_device(agent) is True
def test_is_mdm_managed_device_false(self):
for agent in ["eas", "googleCloudDevicePolicyController", "", "unknown"]:
assert Intune.is_mdm_managed_device(agent) is False
def test_intune_get_compliance_policies_pagination():
"""Test that _get_compliance_policies handles pagination correctly."""
intune = Intune.__new__(Intune)
policy_page_one = [
SimpleNamespace(id="policy-1", display_name="Policy 1"),
]
policy_page_two = [
SimpleNamespace(id="policy-2", display_name="Policy 2"),
]
response_page_one = SimpleNamespace(
value=policy_page_one,
odata_next_link="next-link",
)
response_page_two = SimpleNamespace(
value=policy_page_two,
odata_next_link=None,
)
assignments_response = SimpleNamespace(
value=[SimpleNamespace()],
odata_next_link=None,
)
mock_client = mock.MagicMock()
mock_policies = mock_client.device_management.device_compliance_policies
mock_policies.get = AsyncMock(return_value=response_page_one)
mock_policies.with_url.return_value.get = AsyncMock(return_value=response_page_two)
mock_policies.by_device_compliance_policy_id.return_value.assignments.get = (
AsyncMock(return_value=assignments_response)
)
intune.client = mock_client
loop = asyncio.new_event_loop()
try:
policies, error = loop.run_until_complete(intune._get_compliance_policies())
finally:
loop.close()
assert error is None
assert len(policies) == 2
assert policies[0].id == "policy-1"
assert policies[1].id == "policy-2"
assert policies[0].assignment_count == 1
assert policies[1].assignment_count == 1
def test_intune_get_managed_devices_pagination():
"""Test that _get_managed_devices handles pagination correctly."""
intune = Intune.__new__(Intune)
device_page_one = [
SimpleNamespace(
id="device-1",
device_name="Laptop-1",
compliance_state="compliant",
management_agent="mdm",
),
]
device_page_two = [
SimpleNamespace(
id="device-2",
device_name="Laptop-2",
compliance_state="noncompliant",
management_agent="eas",
),
]
response_page_one = SimpleNamespace(
value=device_page_one,
odata_next_link="next-link",
)
response_page_two = SimpleNamespace(
value=device_page_two,
odata_next_link=None,
)
mock_client = mock.MagicMock()
mock_managed_devices = mock_client.device_management.managed_devices
mock_managed_devices.get = AsyncMock(return_value=response_page_one)
mock_managed_devices.with_url.return_value.get = AsyncMock(
return_value=response_page_two
)
intune.client = mock_client
loop = asyncio.new_event_loop()
try:
devices, error = loop.run_until_complete(intune._get_managed_devices())
finally:
loop.close()
assert error is None
assert len(devices) == 2
assert devices[0].id == "device-1"
assert devices[0].compliance_state == "compliant"
assert devices[0].management_agent == "mdm"
assert devices[1].id == "device-2"
assert devices[1].compliance_state == "noncompliant"
assert devices[1].management_agent == "eas"
def test_intune_get_settings_with_secure_by_default():
"""Test _get_settings when Graph returns settings with secure_by_default."""
intune = Intune.__new__(Intune)
device_management_response = SimpleNamespace(
settings=SimpleNamespace(secure_by_default=True)
)
mock_client = mock.MagicMock()
mock_client.device_management.get = AsyncMock(
return_value=device_management_response
)
intune.client = mock_client
loop = asyncio.new_event_loop()
try:
settings, error = loop.run_until_complete(intune._get_settings())
finally:
loop.close()
assert error is None
assert settings is not None
assert settings.secure_by_default is True
def test_intune_get_settings_null_settings():
"""Test _get_settings when Graph returns settings = None."""
intune = Intune.__new__(Intune)
device_management_response = SimpleNamespace(settings=None)
mock_client = mock.MagicMock()
mock_client.device_management.get = AsyncMock(
return_value=device_management_response
)
intune.client = mock_client
loop = asyncio.new_event_loop()
try:
settings, error = loop.run_until_complete(intune._get_settings())
finally:
loop.close()
assert error is None
assert settings is not None
assert settings.secure_by_default is None
def test_intune_get_settings_exception():
"""Test _get_settings handles exceptions gracefully."""
intune = Intune.__new__(Intune)
mock_client = mock.MagicMock()
mock_client.device_management.get = AsyncMock(side_effect=Exception("API Error"))
intune.client = mock_client
loop = asyncio.new_event_loop()
try:
settings, error = loop.run_until_complete(intune._get_settings())
finally:
loop.close()
assert settings is None
assert error is not None
assert "DeviceManagementServiceConfig.Read.All" in error