diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index d5ea440e6c..3c0c234fb9 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to the **Prowler SDK** are documented in this file. ## [5.21.0] (Prowler UNRELEASED) +### 🚀 Added + +- `entra_conditional_access_policy_device_code_flow_blocked` check for M365 provider [(#10218)](https://github.com/prowler-cloud/prowler/pull/10218) + ### 🔄 Changed - Update M365 SharePoint service metadata to new format [(#9684)](https://github.com/prowler-cloud/prowler/pull/9684) diff --git a/prowler/compliance/m365/cis_6.0_m365.json b/prowler/compliance/m365/cis_6.0_m365.json index 2e6d61f9b0..641159443a 100644 --- a/prowler/compliance/m365/cis_6.0_m365.json +++ b/prowler/compliance/m365/cis_6.0_m365.json @@ -1606,7 +1606,9 @@ { "Id": "5.2.2.12", "Description": "The Microsoft identity platform supports the device authorization grant, which allows users to sign in to input-constrained devices such as a smart TV, IoT device, or a printer. Ensure the device code sign-in flow is blocked.", - "Checks": [], + "Checks": [ + "entra_conditional_access_policy_device_code_flow_blocked" + ], "Attributes": [ { "Section": "5 Microsoft Entra admin center", diff --git a/prowler/compliance/m365/iso27001_2022_m365.json b/prowler/compliance/m365/iso27001_2022_m365.json index aaf5451f30..f14e56b77f 100644 --- a/prowler/compliance/m365/iso27001_2022_m365.json +++ b/prowler/compliance/m365/iso27001_2022_m365.json @@ -243,6 +243,7 @@ "entra_break_glass_account_fido2_security_key_registered", "entra_default_app_management_policy_enabled", "entra_all_apps_conditional_access_coverage", + "entra_conditional_access_policy_device_code_flow_blocked", "entra_legacy_authentication_blocked", "entra_managed_device_required_for_authentication", "entra_seamless_sso_disabled", @@ -464,6 +465,7 @@ "defender_malware_policy_common_attachments_filter_enabled", "defender_malware_policy_comprehensive_attachments_filter_applied", "defender_malware_policy_notifications_internal_users_malware_enabled", + "entra_conditional_access_policy_device_code_flow_blocked", "entra_identity_protection_sign_in_risk_enabled", "entra_identity_protection_user_risk_enabled", "entra_legacy_authentication_blocked", @@ -694,8 +696,9 @@ "entra_admin_users_mfa_enabled", "entra_admin_users_sign_in_frequency_enabled", "entra_all_apps_conditional_access_coverage", - "entra_conditional_access_policy_approved_client_app_required_for_mobile", "entra_break_glass_account_fido2_security_key_registered", + "entra_conditional_access_policy_approved_client_app_required_for_mobile", + "entra_conditional_access_policy_device_code_flow_blocked", "entra_identity_protection_sign_in_risk_enabled", "entra_managed_device_required_for_authentication", "entra_seamless_sso_disabled", diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_device_code_flow_blocked/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_device_code_flow_blocked/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_device_code_flow_blocked/entra_conditional_access_policy_device_code_flow_blocked.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_device_code_flow_blocked/entra_conditional_access_policy_device_code_flow_blocked.metadata.json new file mode 100644 index 0000000000..256b1f88c6 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_device_code_flow_blocked/entra_conditional_access_policy_device_code_flow_blocked.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "m365", + "CheckID": "entra_conditional_access_policy_device_code_flow_blocked", + "CheckTitle": "Conditional Access policy blocks device code flow to prevent phishing attacks", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Conditional Access policies restrict the **device code authentication flow**, which is commonly abused in phishing campaigns to hijack user sessions. A policy targeting `deviceCodeFlow` in authentication flow conditions with a block grant control prevents this attack vector.", + "Risk": "Device code flow is heavily exploited in phishing attacks such as **Storm-2372**, where attackers trick users into entering device codes on legitimate Microsoft login pages. Without a blocking policy, attackers can obtain tokens and gain persistent access to organizational resources.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-conditions#authentication-flows", + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/policy-block-authentication-flows" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com.\n2. Expand **Protection** > **Conditional Access** and select **Policies**.\n3. Click **New policy**.\n4. Under **Users**, include **All users** and exclude break-glass accounts.\n5. Under **Target resources**, include **All cloud apps**.\n6. Under **Conditions** > **Authentication flows**, select **Device code flow**.\n7. Under **Grant**, select **Block access**.\n8. Set the policy to **On** and click **Create**.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Block device code flow via Conditional Access to mitigate phishing attacks that abuse this authentication method. Exclude only break-glass accounts and legitimate service accounts that require device code flow. Regularly review exceptions to minimize the attack surface.", + "Url": "https://hub.prowler.com/check/entra_conditional_access_policy_device_code_flow_blocked" + } + }, + "Categories": [ + "identity-access", + "trust-boundaries", + "e3" + ], + "DependsOn": [], + "RelatedTo": [ + "entra_legacy_authentication_blocked" + ], + "Notes": "The authenticationFlows condition in Conditional Access may require the beta Graph API endpoint and the Prefer: include-unknown-enum-members header." +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_device_code_flow_blocked/entra_conditional_access_policy_device_code_flow_blocked.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_device_code_flow_blocked/entra_conditional_access_policy_device_code_flow_blocked.py new file mode 100644 index 0000000000..0b0b677a62 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_device_code_flow_blocked/entra_conditional_access_policy_device_code_flow_blocked.py @@ -0,0 +1,78 @@ +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, + TransferMethod, +) + + +class entra_conditional_access_policy_device_code_flow_blocked(Check): + """Check if at least one Conditional Access policy blocks device code flow. + + This check ensures that at least one enabled Conditional Access policy + targets the device code authentication flow and blocks access, protecting + against phishing attacks that abuse this flow (e.g., Storm-2372). + + - PASS: An enabled Conditional Access policy blocks device code flow. + - FAIL: No Conditional Access policy restricts device code flow. + """ + + def execute(self) -> list[CheckReportM365]: + """Execute the check to verify device code flow is blocked by a Conditional Access policy. + + 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 device code flow." + + for policy in entra_client.conditional_access_policies.values(): + if policy.state == ConditionalAccessPolicyState.DISABLED: + continue + + if "All" not in policy.conditions.user_conditions.included_users: + continue + + if ( + "All" + not in policy.conditions.application_conditions.included_applications + ): + continue + + if not policy.conditions.authentication_flows: + continue + + if ( + TransferMethod.DEVICE_CODE_FLOW + not in policy.conditions.authentication_flows.transfer_methods + ): + continue + + if ( + ConditionalAccessGrantControl.BLOCK + in policy.grant_controls.built_in_controls + ): + 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}' reports device code flow but does not block it." + else: + report.status = "PASS" + report.status_extended = f"Conditional Access Policy '{policy.display_name}' blocks device code flow." + break + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_service.py b/prowler/providers/m365/services/entra/entra_service.py index 45a6c7df08..c7af127bc8 100644 --- a/prowler/providers/m365/services/entra/entra_service.py +++ b/prowler/providers/m365/services/entra/entra_service.py @@ -172,6 +172,18 @@ class Entra(M365Service): conditional_access_policies_list = ( await self.client.identity.conditional_access.policies.get() ) + + # TODO: Remove this workaround once microsoft/kiota-python#515 is + # fixed and a new version of microsoft-kiota-serialization-json is + # released (see PR microsoft/kiota-python#516). At that point, use + # the SDK's native deserialization for authentication_flows instead. + # + # The SDK deserializer uses get_collection_of_enum_values for + # transferMethods, but the Graph API returns it as a single string + # (e.g., "deviceCodeFlow"), causing the SDK to return an empty list. + # We fetch the raw JSON to correctly parse transferMethods. + raw_auth_flows_map = await self._get_raw_authentication_flows() + for policy in conditional_access_policies_list.value: conditional_access_policies[policy.id] = ConditionalAccessPolicy( id=policy.id, @@ -301,6 +313,9 @@ class Entra(M365Service): ) ], ), + authentication_flows=self._parse_authentication_flows( + raw_auth_flows_map.get(policy.id) + ), ), grant_controls=GrantControls( built_in_controls=( @@ -446,6 +461,76 @@ class Entra(M365Service): ) return default_app_management_policy + async def _get_raw_authentication_flows(self) -> dict: + """Fetch authentication flows from the Graph API using a raw JSON request. + + TODO: Remove this method once microsoft/kiota-python#515 is fixed and + a new version of microsoft-kiota-serialization-json is released + (see PR microsoft/kiota-python#516). At that point, revert to using + the SDK's native deserialization via policy.conditions.authentication_flows. + + The SDK deserializer incorrectly handles the transferMethods field + (uses get_collection_of_enum_values for a single string value), + so we fetch the raw JSON to correctly parse it. + + Returns: + A dict mapping policy ID to the raw authenticationFlows dict. + """ + auth_flows_map = {} + try: + request_info = ( + self.client.identity.conditional_access.policies.to_get_request_information() + ) + request_info.headers.try_add("Prefer", "include-unknown-enum-members") + response = await self.client.request_adapter.send_primitive_async( + request_info, "bytes", {} + ) + if response: + data = json.loads(response) + for policy in data.get("value", []): + policy_id = policy.get("id") + auth_flows = policy.get("conditions", {}).get("authenticationFlows") + if policy_id and auth_flows: + auth_flows_map[policy_id] = auth_flows + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return auth_flows_map + + @staticmethod + def _parse_authentication_flows(auth_flows) -> "AuthenticationFlows | None": + """Parse authentication flows from a raw JSON dict. + + TODO: Remove this method once microsoft/kiota-python#515 is fixed and + revert to parsing the SDK's ConditionalAccessAuthenticationFlows object + directly (see PR microsoft/kiota-python#516). + + Args: + auth_flows: A dict from the raw JSON response (e.g., {"transferMethods": "deviceCodeFlow"}). + + Returns: + AuthenticationFlows object or None if not present. + """ + if not auth_flows: + return None + + transfer_methods = [] + raw_value = auth_flows.get("transferMethods") + if raw_value: + # The API may return a single string or a comma-separated value + methods = raw_value.split(",") if isinstance(raw_value, str) else raw_value + for method_str in methods: + method_str = method_str.strip() + try: + transfer_methods.append(TransferMethod(method_str)) + except ValueError: + logger.warning( + f"Unknown authentication flow transfer method: {method_str}" + ) + + return AuthenticationFlows(transfer_methods=transfer_methods) + @staticmethod def _parse_app_management_restrictions(restrictions): """Parse credential restrictions from the Graph API response into AppManagementRestrictions.""" @@ -888,6 +973,19 @@ class PlatformConditions(BaseModel): exclude_platforms: List[str] = [] +class TransferMethod(Enum): + """Transfer methods for authentication flows in Conditional Access policies.""" + + DEVICE_CODE_FLOW = "deviceCodeFlow" + AUTHENTICATION_TRANSFER = "authenticationTransfer" + + +class AuthenticationFlows(BaseModel): + """Model representing authentication flows conditions in Conditional Access policies.""" + + transfer_methods: List[TransferMethod] = [] + + class Conditions(BaseModel): application_conditions: Optional[ApplicationsConditions] user_conditions: Optional[UsersConditions] @@ -895,6 +993,7 @@ class Conditions(BaseModel): user_risk_levels: List[RiskLevel] = [] sign_in_risk_levels: List[RiskLevel] = [] platform_conditions: Optional[PlatformConditions] = None + authentication_flows: Optional[AuthenticationFlows] = None class PersistentBrowser(BaseModel): diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_device_code_flow_blocked/entra_conditional_access_policy_device_code_flow_blocked_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_device_code_flow_blocked/entra_conditional_access_policy_device_code_flow_blocked_test.py new file mode 100644 index 0000000000..8c0a6a86f8 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_device_code_flow_blocked/entra_conditional_access_policy_device_code_flow_blocked_test.py @@ -0,0 +1,882 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.m365.services.entra.entra_service import ( + ApplicationEnforcedRestrictions, + ApplicationsConditions, + AuthenticationFlows, + ConditionalAccessGrantControl, + ConditionalAccessPolicyState, + Conditions, + GrantControlOperator, + GrantControls, + PersistentBrowser, + SessionControls, + SignInFrequency, + SignInFrequencyInterval, + TransferMethod, + UsersConditions, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + + +class Test_entra_conditional_access_policy_device_code_flow_blocked: + def test_no_conditional_access_policies(self): + 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( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked import ( + entra_conditional_access_policy_device_code_flow_blocked, + ) + + entra_client.conditional_access_policies = {} + + check = entra_conditional_access_policy_device_code_flow_blocked() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy blocks device code flow." + ) + 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): + policy_id = str(uuid4()) + display_name = "Block Device Code Flow" + 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( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked import ( + entra_conditional_access_policy_device_code_flow_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=Conditions( + 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=[], + user_risk_levels=[], + authentication_flows=AuthenticationFlows( + transfer_methods=[TransferMethod.DEVICE_CODE_FLOW] + ), + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.BLOCK, + ], + operator=GrantControlOperator.AND, + authentication_strength=None, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.EVERY_TIME, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ), + state=ConditionalAccessPolicyState.DISABLED, + ) + } + + check = entra_conditional_access_policy_device_code_flow_blocked() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy blocks device code flow." + ) + 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_no_authentication_flows(self): + policy_id = str(uuid4()) + display_name = "Block Legacy Auth" + 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( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked import ( + entra_conditional_access_policy_device_code_flow_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=Conditions( + 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=[], + user_risk_levels=[], + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.BLOCK, + ], + operator=GrantControlOperator.AND, + authentication_strength=None, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.EVERY_TIME, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_device_code_flow_blocked() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy blocks device code flow." + ) + 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(self): + policy_id = str(uuid4()) + display_name = "Block Device Code Flow" + 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( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked import ( + entra_conditional_access_policy_device_code_flow_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=Conditions( + 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=[], + user_risk_levels=[], + authentication_flows=AuthenticationFlows( + transfer_methods=[TransferMethod.DEVICE_CODE_FLOW] + ), + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.BLOCK, + ], + operator=GrantControlOperator.AND, + authentication_strength=None, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.EVERY_TIME, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ), + state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, + ) + } + + check = entra_conditional_access_policy_device_code_flow_blocked() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Conditional Access Policy '{display_name}' reports device code flow but does not block it." + ) + 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_enabled_blocks_device_code_flow(self): + policy_id = str(uuid4()) + display_name = "Block Device Code Flow" + 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( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked import ( + entra_conditional_access_policy_device_code_flow_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=Conditions( + 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=[], + user_risk_levels=[], + authentication_flows=AuthenticationFlows( + transfer_methods=[TransferMethod.DEVICE_CODE_FLOW] + ), + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.BLOCK, + ], + operator=GrantControlOperator.AND, + authentication_strength=None, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.EVERY_TIME, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_device_code_flow_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 device code flow." + ) + 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_different_transfer_method(self): + policy_id = str(uuid4()) + display_name = "Block Auth Transfer" + 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( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked import ( + entra_conditional_access_policy_device_code_flow_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=Conditions( + 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=[], + user_risk_levels=[], + authentication_flows=AuthenticationFlows( + transfer_methods=[TransferMethod.AUTHENTICATION_TRANSFER] + ), + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.BLOCK, + ], + operator=GrantControlOperator.AND, + authentication_strength=None, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.EVERY_TIME, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_device_code_flow_blocked() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy blocks device code flow." + ) + 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_multiple_policies_first_disabled_second_enabled(self): + disabled_id = str(uuid4()) + enabled_id = str(uuid4()) + enabled_name = "Block Device Code Flow - Enabled" + 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( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked import ( + entra_conditional_access_policy_device_code_flow_blocked, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + disabled_id: ConditionalAccessPolicy( + id=disabled_id, + display_name="Block Device Code Flow - Disabled", + conditions=Conditions( + 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=[], + user_risk_levels=[], + authentication_flows=AuthenticationFlows( + transfer_methods=[TransferMethod.DEVICE_CODE_FLOW] + ), + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.BLOCK, + ], + operator=GrantControlOperator.AND, + authentication_strength=None, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.EVERY_TIME, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ), + state=ConditionalAccessPolicyState.DISABLED, + ), + enabled_id: ConditionalAccessPolicy( + id=enabled_id, + display_name=enabled_name, + conditions=Conditions( + 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=[], + user_risk_levels=[], + authentication_flows=AuthenticationFlows( + transfer_methods=[TransferMethod.DEVICE_CODE_FLOW] + ), + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.BLOCK, + ], + operator=GrantControlOperator.AND, + authentication_strength=None, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.EVERY_TIME, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ), + } + + check = entra_conditional_access_policy_device_code_flow_blocked() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Conditional Access Policy '{enabled_name}' blocks device code flow." + ) + assert ( + result[0].resource + == entra_client.conditional_access_policies[enabled_id].dict() + ) + assert result[0].resource_name == enabled_name + assert result[0].resource_id == enabled_id + assert result[0].location == "global" + + def test_policy_not_targeting_all_users(self): + policy_id = str(uuid4()) + display_name = "Block Device Code Flow - Specific Group" + 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( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked import ( + entra_conditional_access_policy_device_code_flow_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=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=["some-group-id"], + excluded_groups=[], + included_users=[], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + authentication_flows=AuthenticationFlows( + transfer_methods=[TransferMethod.DEVICE_CODE_FLOW] + ), + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.BLOCK, + ], + operator=GrantControlOperator.AND, + authentication_strength=None, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.EVERY_TIME, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_device_code_flow_blocked() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy blocks device code flow." + ) + 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_not_targeting_all_cloud_apps(self): + policy_id = str(uuid4()) + display_name = "Block Device Code Flow - Specific App" + 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( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked import ( + entra_conditional_access_policy_device_code_flow_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=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["some-app-id"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + authentication_flows=AuthenticationFlows( + transfer_methods=[TransferMethod.DEVICE_CODE_FLOW] + ), + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.BLOCK, + ], + operator=GrantControlOperator.AND, + authentication_strength=None, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.EVERY_TIME, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_device_code_flow_blocked() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy blocks device code flow." + ) + 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_with_device_code_flow_but_no_block(self): + policy_id = str(uuid4()) + display_name = "MFA for Device Code Flow" + 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( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked import ( + entra_conditional_access_policy_device_code_flow_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=Conditions( + 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=[], + user_risk_levels=[], + authentication_flows=AuthenticationFlows( + transfer_methods=[TransferMethod.DEVICE_CODE_FLOW] + ), + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.MFA, + ], + operator=GrantControlOperator.AND, + authentication_strength=None, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.EVERY_TIME, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_device_code_flow_blocked() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy blocks device code flow." + ) + assert result[0].resource == {} + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + assert result[0].location == "global"