diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index de9700ab0b..da182a53f8 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ### 🚀 Added - `entra_conditional_access_policy_approved_client_app_required_for_mobile` check for m365 provider [(#10216)](https://github.com/prowler-cloud/prowler/pull/10216) +- `entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required` check for M365 provider [(#10197)](https://github.com/prowler-cloud/prowler/pull/10197) ### 🔄 Changed diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required.metadata.json new file mode 100644 index 0000000000..5b592a0732 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "m365", + "CheckID": "entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required", + "CheckTitle": "Conditional Access requires compliant device OR hybrid joined device OR MFA for admins or all users", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "A **Conditional Access policy** enforces one of the following grant controls for admin roles or all users across all cloud apps: - 'Require device to be marked as compliant' - 'Require Microsoft Entra hybrid joined device' - 'Require multifactor authentication' This ensures that access is provided only under strong authentication or trusted device conditions.", + "Risk": "If this policy is not implemented, attackers with compromised credentials may gain access from unmanaged devices or without strong authentication, increasing the likelihood of **unauthorized access and data breaches**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-grant", + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-policy-compliant-device" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to Microsoft Entra admin center https://entra.microsoft.com.\n2. Go to Protection > Conditional Access > Policies and create or edit a policy.\n3. Under Users, include All users or administrative roles.\n4. Under Target resources, include All cloud apps.\n5. Under Grant, select Grant access and enable these controls: Require multifactor authentication, Require device to be marked as compliant, and Require Microsoft Entra hybrid joined device.\n6. Set Grant operator to Require one of the selected controls.\n7. Start in Report-only mode for validation and then switch to On.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enforce a Conditional Access baseline where admins or all users must satisfy at least one strong control: compliant device, hybrid joined device, or MFA.", + "Url": "https://hub.prowler.com/check/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [ + "entra_managed_device_required_for_authentication" + ], + "Notes": "" +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required.py new file mode 100644 index 0000000000..f872fadac3 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required.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 ( + AdminRoles, + ConditionalAccessGrantControl, + ConditionalAccessPolicyState, + GrantControlOperator, +) + +REQUIRED_GRANT_CONTROLS = { + ConditionalAccessGrantControl.MFA, + ConditionalAccessGrantControl.COMPLIANT_DEVICE, + ConditionalAccessGrantControl.DOMAIN_JOINED_DEVICE, +} +ADMIN_ROLE_IDS = {role.value for role in AdminRoles} + + +class entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required( + Check +): + """Check that CA enforces compliant or hybrid joined device or MFA for admins/all users.""" + + def _targets_admins_or_all_users(self, policy) -> bool: + if "All" in policy.conditions.user_conditions.included_users: + return True + + included_roles = set(policy.conditions.user_conditions.included_roles) + return bool(ADMIN_ROLE_IDS.intersection(included_roles)) + + def execute(self) -> list[CheckReportM365]: + 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 compliant device, hybrid joined device, or MFA for admin roles or all users across all cloud apps." + + for policy in entra_client.conditional_access_policies.values(): + if policy.state == ConditionalAccessPolicyState.DISABLED: + continue + + if not self._targets_admins_or_all_users(policy): + continue + + if ( + "All" + not in policy.conditions.application_conditions.included_applications + ): + continue + + policy_grant_controls = set(policy.grant_controls.built_in_controls) + if not REQUIRED_GRANT_CONTROLS.issubset(policy_grant_controls): + continue + + if policy.grant_controls.operator != GrantControlOperator.OR: + 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} reports compliant device, hybrid joined device, or MFA for admin roles or all users but does not enforce it." + else: + report.status = "PASS" + report.status_extended = f"Conditional Access Policy {policy.display_name} enforces compliant device, hybrid joined device, or MFA for admin roles or all users across all cloud apps." + break + + findings.append(report) + return findings diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required_test.py new file mode 100644 index 0000000000..43e4edbb69 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required_test.py @@ -0,0 +1,430 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.m365.services.entra.entra_service import ( + AdminRoles, + ApplicationEnforcedRestrictions, + ApplicationsConditions, + ConditionalAccessGrantControl, + 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_compliant_device_hybrid_joined_device_mfa_required.entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required" + +DEFAULT_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), +) + +EMPTY_USER_CONDITIONS = UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=[], + excluded_users=[], + included_roles=[], + excluded_roles=[], +) + +ALL_USER_CONDITIONS = UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], +) + +ADMIN_ROLE_USER_CONDITIONS = UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=[], + excluded_users=[], + included_roles=[AdminRoles.GLOBAL_ADMINISTRATOR.value], + excluded_roles=[], +) + +EMPTY_APP_CONDITIONS = ApplicationsConditions( + included_applications=[], + excluded_applications=[], + included_user_actions=[], +) + +ALL_APP_CONDITIONS = ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], +) + +REQUIRED_GRANT_CONTROLS = GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.MFA, + ConditionalAccessGrantControl.COMPLIANT_DEVICE, + ConditionalAccessGrantControl.DOMAIN_JOINED_DEVICE, + ], + operator=GrantControlOperator.OR, +) + + +class Test_entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required: + def test_entra_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(f"{CHECK_MODULE}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required.entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required import ( + entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required, + ) + + entra_client.conditional_access_policies = {} + result = ( + entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required() + ).execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires compliant device, hybrid joined device, or MFA for admin roles or all users across all cloud apps." + ) + + def test_entra_policy_not_targeting_admins_or_all_users(self): + policy_id = str(uuid4()) + 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}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required.entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required import ( + entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name="No Admins or All Users", + conditions=Conditions( + application_conditions=ALL_APP_CONDITIONS, + user_conditions=EMPTY_USER_CONDITIONS, + client_app_types=[], + ), + grant_controls=REQUIRED_GRANT_CONTROLS, + session_controls=DEFAULT_SESSION_CONTROLS, + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + result = ( + entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required() + ).execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_entra_policy_not_targeting_all_apps(self): + policy_id = str(uuid4()) + 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}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required.entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required import ( + entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name="Not All Apps", + conditions=Conditions( + application_conditions=EMPTY_APP_CONDITIONS, + user_conditions=ALL_USER_CONDITIONS, + client_app_types=[], + ), + grant_controls=REQUIRED_GRANT_CONTROLS, + session_controls=DEFAULT_SESSION_CONTROLS, + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + result = ( + entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required() + ).execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_entra_policy_missing_required_controls(self): + policy_id = str(uuid4()) + 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}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required.entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required import ( + entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name="Missing Hybrid Joined", + conditions=Conditions( + application_conditions=ALL_APP_CONDITIONS, + user_conditions=ALL_USER_CONDITIONS, + client_app_types=[], + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.MFA, + ConditionalAccessGrantControl.COMPLIANT_DEVICE, + ], + operator=GrantControlOperator.OR, + ), + session_controls=DEFAULT_SESSION_CONTROLS, + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + result = ( + entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required() + ).execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_entra_policy_operator_not_or(self): + policy_id = str(uuid4()) + 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}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required.entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required import ( + entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name="AND Operator", + conditions=Conditions( + application_conditions=ALL_APP_CONDITIONS, + user_conditions=ALL_USER_CONDITIONS, + client_app_types=[], + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.MFA, + ConditionalAccessGrantControl.COMPLIANT_DEVICE, + ConditionalAccessGrantControl.DOMAIN_JOINED_DEVICE, + ], + operator=GrantControlOperator.AND, + ), + session_controls=DEFAULT_SESSION_CONTROLS, + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + result = ( + entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required() + ).execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_entra_policy_reporting_only(self): + policy_id = str(uuid4()) + display_name = "Report Only" + 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}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required.entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required import ( + entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required, + ) + 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=ALL_APP_CONDITIONS, + user_conditions=ADMIN_ROLE_USER_CONDITIONS, + client_app_types=[], + ), + grant_controls=REQUIRED_GRANT_CONTROLS, + session_controls=DEFAULT_SESSION_CONTROLS, + state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, + ) + } + + result = ( + entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required() + ).execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Conditional Access Policy {display_name} reports compliant device, hybrid joined device, or MFA for admin roles or all users but does not enforce it." + ) + + def test_entra_policy_enabled_pass_for_all_users(self): + policy_id = str(uuid4()) + display_name = "All Users" + 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}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required.entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required import ( + entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required, + ) + 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=ALL_APP_CONDITIONS, + user_conditions=ALL_USER_CONDITIONS, + client_app_types=[], + ), + grant_controls=REQUIRED_GRANT_CONTROLS, + session_controls=DEFAULT_SESSION_CONTROLS, + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + result = ( + entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required() + ).execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Conditional Access Policy {display_name} enforces compliant device, hybrid joined device, or MFA for admin roles or all users across all cloud apps." + ) + + def test_entra_policy_enabled_pass_for_admin_roles(self): + policy_id = str(uuid4()) + display_name = "Admin Roles" + 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}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required.entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required import ( + entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required, + ) + 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=ALL_APP_CONDITIONS, + user_conditions=ADMIN_ROLE_USER_CONDITIONS, + client_app_types=[], + ), + grant_controls=REQUIRED_GRANT_CONTROLS, + session_controls=DEFAULT_SESSION_CONTROLS, + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + result = ( + entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required() + ).execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_name == display_name + assert result[0].resource_id == policy_id