Compare commits

...

1 Commits

Author SHA1 Message Date
HugoPBrito
11ff512607 feat(m365): add entra_conditional_access_policy_unknown_device_blocked security check
Add new security check entra_conditional_access_policy_unknown_device_blocked for m365 provider.
Includes check implementation, metadata, and unit tests.
2026-03-03 13:28:35 +01:00
8 changed files with 613 additions and 0 deletions

View File

@@ -39,6 +39,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- OpenStack compute service with 7 security checks [(#9944)](https://github.com/prowler-cloud/prowler/pull/9944)
- OpenStack image service with 6 security checks [(#10096)](https://github.com/prowler-cloud/prowler/pull/10096)
- IaC `--provider-uid` flag to specify the provider UID for the IaC provider [(#10233)](https://github.com/prowler-cloud/prowler/pull/10233)
- `entra_conditional_access_policy_unknown_device_blocked` check for M365 provider [(#10235)](https://github.com/prowler-cloud/prowler/pull/10235)
### 🔄 Changed

View File

@@ -242,6 +242,7 @@
"entra_admin_users_sign_in_frequency_enabled",
"entra_default_app_management_policy_enabled",
"entra_all_apps_conditional_access_coverage",
"entra_conditional_access_policy_unknown_device_blocked",
"entra_legacy_authentication_blocked",
"entra_managed_device_required_for_authentication",
"entra_seamless_sso_disabled",
@@ -616,6 +617,7 @@
"defenderxdr_endpoint_privileged_user_exposed_credentials",
"entra_admin_users_phishing_resistant_mfa_enabled",
"entra_conditional_access_policy_app_enforced_restrictions",
"entra_conditional_access_policy_unknown_device_blocked",
"entra_managed_device_required_for_authentication",
"entra_managed_device_required_for_mfa_registration",
"entra_users_mfa_capable",
@@ -670,6 +672,7 @@
"Checks": [
"entra_admin_portals_access_restriction",
"entra_conditional_access_policy_app_enforced_restrictions",
"entra_conditional_access_policy_unknown_device_blocked",
"entra_policy_guest_users_access_restrictions",
"sharepoint_external_sharing_restricted"
]
@@ -690,6 +693,7 @@
"entra_admin_users_mfa_enabled",
"entra_admin_users_sign_in_frequency_enabled",
"entra_all_apps_conditional_access_coverage",
"entra_conditional_access_policy_unknown_device_blocked",
"entra_identity_protection_sign_in_risk_enabled",
"entra_managed_device_required_for_authentication",
"entra_seamless_sso_disabled",

View File

@@ -99,6 +99,7 @@
"Id": "1.1.6",
"Description": "Ensure a managed device is required for authentication",
"Checks": [
"entra_conditional_access_policy_unknown_device_blocked",
"entra_managed_device_required_for_authentication"
],
"Attributes": [
@@ -117,6 +118,7 @@
"Id": "1.1.7",
"Description": "Ensure a managed device is required for MFA registration",
"Checks": [
"entra_conditional_access_policy_unknown_device_blocked",
"entra_managed_device_required_for_mfa_registration"
],
"Attributes": [

View File

@@ -0,0 +1,39 @@
{
"Provider": "m365",
"CheckID": "entra_conditional_access_policy_unknown_device_blocked",
"CheckTitle": "Conditional Access policy blocks access from unknown or unsupported device platforms",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "Conditional Access Policy",
"ResourceGroup": "IAM",
"Description": "Conditional Access policy that includes **all device platforms** and excludes the five known platforms (`android`, `iOS`, `windows`, `macOS`, `linux`) with a **block** grant control prevents sign-ins from unrecognized or unsupported devices.",
"Risk": "Without blocking unknown device platforms, attackers can sign in from **unmanaged or spoofed devices** that bypass compliance and security controls.\n\nThis increases the risk of **unauthorized access** and makes it harder to enforce device-based security policies.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-condition-device-platforms",
"https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-policy-unknown-unsupported-device"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com.\n2. Expand **Protection** > **Conditional Access** and select **Policies**.\n3. Click **New policy**.\n4. Under **Users**, select **All users**.\n5. Under **Target resources**, select **All cloud apps**.\n6. Under **Conditions** > **Device platforms**, set **Include** to **Any device** and **Exclude** the five known platforms: Android, iOS, Windows, macOS, Linux.\n7. Under **Grant**, select **Block access**.\n8. Set the policy to **On** and click **Create**.",
"Terraform": ""
},
"Recommendation": {
"Text": "Create a Conditional Access policy that blocks access from unknown device platforms. Include all platforms and exclude the five known ones (`android`, `iOS`, `windows`, `macOS`, `linux`). Pair this with **device compliance** policies for defense in depth, since platform detection relies on user agent strings.",
"Url": "https://hub.prowler.com/check/entra_conditional_access_policy_unknown_device_blocked"
}
},
"Categories": [
"identity-access"
],
"DependsOn": [],
"RelatedTo": [
"entra_managed_device_required_for_authentication"
],
"Notes": "Device platform detection relies on user agent strings, which can be spoofed. This policy should be paired with device compliance policies for stronger security."
}

View File

@@ -0,0 +1,79 @@
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,
ConditionalAccessPolicyState,
)
KNOWN_PLATFORMS = {"android", "iOS", "windows", "macOS", "linux"}
class entra_conditional_access_policy_unknown_device_blocked(Check):
"""Check if at least one Conditional Access policy blocks unknown device platforms.
This check verifies that at least one enabled Conditional Access policy
blocks access from unknown or unsupported device platforms by including
all platforms and excluding the five known ones (android, iOS, windows,
macOS, linux) with a block grant control.
- PASS: An enabled CA policy blocks access from unknown/unsupported device platforms.
- FAIL: No CA policy restricts access from unrecognized device platforms.
"""
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 blocks access from unknown or unsupported device platforms."
for policy in entra_client.conditional_access_policies.values():
if policy.state == ConditionalAccessPolicyState.DISABLED:
continue
if not policy.conditions.platform_conditions:
continue
if (
"all"
not in policy.conditions.platform_conditions.included_platforms
):
continue
if not KNOWN_PLATFORMS.issubset(
set(policy.conditions.platform_conditions.excluded_platforms)
):
continue
if (
ConditionalAccessGrantControl.BLOCK
not in policy.grant_controls.built_in_controls
):
continue
report = CheckReportM365(
metadata=self.metadata(),
resource=policy,
resource_name=policy.display_name,
resource_id=policy.id,
)
if policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING:
report.status = "FAIL"
report.status_extended = f"Conditional Access Policy '{policy.display_name}' is configured to block unknown device platforms but is only in report-only mode."
else:
report.status = "PASS"
report.status_extended = f"Conditional Access Policy '{policy.display_name}' blocks access from unknown or unsupported device platforms."
break
findings.append(report)
return findings

View File

@@ -256,6 +256,28 @@ class Entra(M365Service):
[],
)
],
platform_conditions=(
PlatformConditions(
included_platforms=[
platform
for platform in getattr(
policy.conditions.platforms,
"include_platforms",
[],
)
],
excluded_platforms=[
platform
for platform in getattr(
policy.conditions.platforms,
"exclude_platforms",
[],
)
],
)
if getattr(policy.conditions, "platforms", None)
else None
),
user_risk_levels=[
RiskLevel(risk_level)
for risk_level in getattr(
@@ -798,10 +820,18 @@ class ClientAppType(Enum):
OTHER_CLIENTS = "other"
class PlatformConditions(BaseModel):
"""Model representing device platform conditions for Conditional Access policies."""
included_platforms: List[str] = []
excluded_platforms: List[str] = []
class Conditions(BaseModel):
application_conditions: Optional[ApplicationsConditions]
user_conditions: Optional[UsersConditions]
client_app_types: Optional[List[ClientAppType]]
platform_conditions: Optional[PlatformConditions] = None
user_risk_levels: List[RiskLevel] = []
sign_in_risk_levels: List[RiskLevel] = []

View File

@@ -0,0 +1,458 @@
from unittest import mock
from uuid import uuid4
from prowler.providers.m365.services.entra.entra_service import (
ApplicationsConditions,
ConditionalAccessGrantControl,
ConditionalAccessPolicyState,
Conditions,
GrantControlOperator,
GrantControls,
PersistentBrowser,
PlatformConditions,
SessionControls,
SignInFrequency,
SignInFrequencyInterval,
UsersConditions,
)
from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_conditional_access_policy_unknown_device_blocked.entra_conditional_access_policy_unknown_device_blocked"
KNOWN_PLATFORMS = ["android", "iOS", "windows", "macOS", "linux"]
def _default_session_controls():
return SessionControls(
persistent_browser=PersistentBrowser(is_enabled=False, mode="always"),
sign_in_frequency=SignInFrequency(
is_enabled=False,
frequency=None,
type=None,
interval=SignInFrequencyInterval.EVERY_TIME,
),
)
def _default_conditions(**overrides):
defaults = dict(
application_conditions=ApplicationsConditions(
included_applications=["All"],
excluded_applications=[],
included_user_actions=[],
),
user_conditions=UsersConditions(
included_groups=[],
excluded_groups=[],
included_users=["All"],
excluded_users=[],
included_roles=[],
excluded_roles=[],
),
client_app_types=[],
platform_conditions=PlatformConditions(
included_platforms=["all"],
excluded_platforms=KNOWN_PLATFORMS,
),
user_risk_levels=[],
)
defaults.update(overrides)
return Conditions(**defaults)
def _default_grant_controls(**overrides):
defaults = dict(
built_in_controls=[ConditionalAccessGrantControl.BLOCK],
operator=GrantControlOperator.OR,
authentication_strength=None,
)
defaults.update(overrides)
return GrantControls(**defaults)
class Test_entra_conditional_access_policy_unknown_device_blocked:
def test_no_conditional_access_policies(self):
"""Test FAIL when there are no Conditional Access policies."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
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_conditional_access_policy_unknown_device_blocked.entra_conditional_access_policy_unknown_device_blocked import (
entra_conditional_access_policy_unknown_device_blocked,
)
entra_client.conditional_access_policies = {}
check = entra_conditional_access_policy_unknown_device_blocked()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "No Conditional Access Policy blocks access from unknown or unsupported device platforms."
)
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_policy_disabled(self):
"""Test FAIL when the only matching policy is disabled."""
policy_id = str(uuid4())
display_name = "Block Unknown Devices"
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
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_conditional_access_policy_unknown_device_blocked.entra_conditional_access_policy_unknown_device_blocked import (
entra_conditional_access_policy_unknown_device_blocked,
)
from prowler.providers.m365.services.entra.entra_service import (
ConditionalAccessPolicy,
)
entra_client.conditional_access_policies = {
policy_id: ConditionalAccessPolicy(
id=policy_id,
display_name=display_name,
conditions=_default_conditions(),
grant_controls=_default_grant_controls(),
session_controls=_default_session_controls(),
state=ConditionalAccessPolicyState.DISABLED,
)
}
check = entra_conditional_access_policy_unknown_device_blocked()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "No Conditional Access Policy blocks access from unknown or unsupported device platforms."
)
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_policy_enabled_for_reporting_only(self):
"""Test FAIL when the matching policy is only in report-only mode."""
policy_id = str(uuid4())
display_name = "Block Unknown Devices"
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
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_conditional_access_policy_unknown_device_blocked.entra_conditional_access_policy_unknown_device_blocked import (
entra_conditional_access_policy_unknown_device_blocked,
)
from prowler.providers.m365.services.entra.entra_service import (
ConditionalAccessPolicy,
)
entra_client.conditional_access_policies = {
policy_id: ConditionalAccessPolicy(
id=policy_id,
display_name=display_name,
conditions=_default_conditions(),
grant_controls=_default_grant_controls(),
session_controls=_default_session_controls(),
state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING,
)
}
check = entra_conditional_access_policy_unknown_device_blocked()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Conditional Access Policy '{display_name}' is configured to block unknown device platforms but is only in report-only mode."
)
assert (
result[0].resource
== entra_client.conditional_access_policies[policy_id].dict()
)
assert result[0].resource_name == display_name
assert result[0].resource_id == policy_id
assert result[0].location == "global"
def test_policy_no_platform_conditions(self):
"""Test FAIL when the policy has no platform conditions."""
policy_id = str(uuid4())
display_name = "Block Without Platforms"
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
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_conditional_access_policy_unknown_device_blocked.entra_conditional_access_policy_unknown_device_blocked import (
entra_conditional_access_policy_unknown_device_blocked,
)
from prowler.providers.m365.services.entra.entra_service import (
ConditionalAccessPolicy,
)
entra_client.conditional_access_policies = {
policy_id: ConditionalAccessPolicy(
id=policy_id,
display_name=display_name,
conditions=_default_conditions(platform_conditions=None),
grant_controls=_default_grant_controls(),
session_controls=_default_session_controls(),
state=ConditionalAccessPolicyState.ENABLED,
)
}
check = entra_conditional_access_policy_unknown_device_blocked()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "No Conditional Access Policy blocks access from unknown or unsupported device platforms."
)
assert result[0].resource == {}
assert result[0].resource_name == "Conditional Access Policies"
assert result[0].resource_id == "conditionalAccessPolicies"
def test_policy_does_not_include_all_platforms(self):
"""Test FAIL when the policy does not include all platforms."""
policy_id = str(uuid4())
display_name = "Partial Platform Block"
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
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_conditional_access_policy_unknown_device_blocked.entra_conditional_access_policy_unknown_device_blocked import (
entra_conditional_access_policy_unknown_device_blocked,
)
from prowler.providers.m365.services.entra.entra_service import (
ConditionalAccessPolicy,
)
entra_client.conditional_access_policies = {
policy_id: ConditionalAccessPolicy(
id=policy_id,
display_name=display_name,
conditions=_default_conditions(
platform_conditions=PlatformConditions(
included_platforms=["android", "iOS"],
excluded_platforms=[],
)
),
grant_controls=_default_grant_controls(),
session_controls=_default_session_controls(),
state=ConditionalAccessPolicyState.ENABLED,
)
}
check = entra_conditional_access_policy_unknown_device_blocked()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "No Conditional Access Policy blocks access from unknown or unsupported device platforms."
)
assert result[0].resource == {}
def test_policy_missing_excluded_platforms(self):
"""Test FAIL when the policy does not exclude all five known platforms."""
policy_id = str(uuid4())
display_name = "Incomplete Exclusions"
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
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_conditional_access_policy_unknown_device_blocked.entra_conditional_access_policy_unknown_device_blocked import (
entra_conditional_access_policy_unknown_device_blocked,
)
from prowler.providers.m365.services.entra.entra_service import (
ConditionalAccessPolicy,
)
entra_client.conditional_access_policies = {
policy_id: ConditionalAccessPolicy(
id=policy_id,
display_name=display_name,
conditions=_default_conditions(
platform_conditions=PlatformConditions(
included_platforms=["all"],
excluded_platforms=["android", "iOS", "windows"],
)
),
grant_controls=_default_grant_controls(),
session_controls=_default_session_controls(),
state=ConditionalAccessPolicyState.ENABLED,
)
}
check = entra_conditional_access_policy_unknown_device_blocked()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "No Conditional Access Policy blocks access from unknown or unsupported device platforms."
)
assert result[0].resource == {}
def test_policy_no_block_grant_control(self):
"""Test FAIL when the policy does not use block as grant control."""
policy_id = str(uuid4())
display_name = "MFA Instead of Block"
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
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_conditional_access_policy_unknown_device_blocked.entra_conditional_access_policy_unknown_device_blocked import (
entra_conditional_access_policy_unknown_device_blocked,
)
from prowler.providers.m365.services.entra.entra_service import (
ConditionalAccessPolicy,
)
entra_client.conditional_access_policies = {
policy_id: ConditionalAccessPolicy(
id=policy_id,
display_name=display_name,
conditions=_default_conditions(),
grant_controls=_default_grant_controls(
built_in_controls=[ConditionalAccessGrantControl.MFA]
),
session_controls=_default_session_controls(),
state=ConditionalAccessPolicyState.ENABLED,
)
}
check = entra_conditional_access_policy_unknown_device_blocked()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "No Conditional Access Policy blocks access from unknown or unsupported device platforms."
)
assert result[0].resource == {}
def test_policy_enabled_and_compliant(self):
"""Test PASS when an enabled policy blocks unknown device platforms."""
policy_id = str(uuid4())
display_name = "Block Unknown Devices"
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
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_conditional_access_policy_unknown_device_blocked.entra_conditional_access_policy_unknown_device_blocked import (
entra_conditional_access_policy_unknown_device_blocked,
)
from prowler.providers.m365.services.entra.entra_service import (
ConditionalAccessPolicy,
)
entra_client.conditional_access_policies = {
policy_id: ConditionalAccessPolicy(
id=policy_id,
display_name=display_name,
conditions=_default_conditions(),
grant_controls=_default_grant_controls(),
session_controls=_default_session_controls(),
state=ConditionalAccessPolicyState.ENABLED,
)
}
check = entra_conditional_access_policy_unknown_device_blocked()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"Conditional Access Policy '{display_name}' blocks access from unknown or unsupported device platforms."
)
assert (
result[0].resource
== entra_client.conditional_access_policies[policy_id].dict()
)
assert result[0].resource_name == display_name
assert result[0].resource_id == policy_id
assert result[0].location == "global"