diff --git a/prowler/providers/microsoft365/services/entra/entra_admin_mfa_enabled_for_administrative_roles/__init__.py b/prowler/providers/microsoft365/services/entra/entra_admin_mfa_enabled_for_administrative_roles/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/microsoft365/services/entra/entra_admin_mfa_enabled_for_administrative_roles/entra_admin_mfa_enabled_for_administrative_roles.metadata.json b/prowler/providers/microsoft365/services/entra/entra_admin_mfa_enabled_for_administrative_roles/entra_admin_mfa_enabled_for_administrative_roles.metadata.json new file mode 100644 index 0000000000..942e1a3c97 --- /dev/null +++ b/prowler/providers/microsoft365/services/entra/entra_admin_mfa_enabled_for_administrative_roles/entra_admin_mfa_enabled_for_administrative_roles.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "microsoft365", + "CheckID": "entra_admin_mfa_enabled_for_administrative_roles", + "CheckTitle": "Ensure multifactor authentication is enabled for all users in administrative roles.", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "Conditional Access Policy", + "Description": "Ensure that multifactor authentication (MFA) is enabled for all users in administrative roles to enhance security and reduce the risk of unauthorized access.", + "Risk": "Without MFA enabled for administrative roles, attackers could compromise privileged accounts with only a single authentication factor, increasing the risk of data breaches and unauthorized access to sensitive resources.", + "RelatedUrl": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-policy-admin-mfa", + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Protection > Conditional Access and select Policies. 3. Click 'New policy' and configure: Users: Select users and groups > Directory roles (include admin roles). Target resources: Include 'All cloud apps' with no exclusions. Grant: Select 'Grant Access' and check 'Require multifactor authentication'. 4. Set policy to 'Report Only' for testing before full enforcement. 5. Click 'Create'.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable MFA for all users in administrative roles using a Conditional Access policy in Microsoft Entra.", + "Url": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-policy-admin-mfa" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/microsoft365/services/entra/entra_admin_mfa_enabled_for_administrative_roles/entra_admin_mfa_enabled_for_administrative_roles.py b/prowler/providers/microsoft365/services/entra/entra_admin_mfa_enabled_for_administrative_roles/entra_admin_mfa_enabled_for_administrative_roles.py new file mode 100644 index 0000000000..d69276f135 --- /dev/null +++ b/prowler/providers/microsoft365/services/entra/entra_admin_mfa_enabled_for_administrative_roles/entra_admin_mfa_enabled_for_administrative_roles.py @@ -0,0 +1,80 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportMicrosoft365 +from prowler.providers.microsoft365.services.entra.entra_client import entra_client +from prowler.providers.microsoft365.services.entra.entra_service import ( + AdminRoles, + ConditionalAccessGrantControl, + ConditionalAccessPolicyState, +) + + +class entra_admin_mfa_enabled_for_administrative_roles(Check): + """ + Ensure multifactor authentication is enabled for all users in administrative roles. + + This check verifies that at least one Conditional Access Policy in Microsoft Entra, which is in an enabled state, + applies to administrative roles and enforces multifactor authentication (MFA). Enforcing MFA for privileged accounts + is critical to reduce the risk of unauthorized access through compromised credentials. + + The check fails if no enabled policy is found that requires MFA for any administrative role. + """ + + def execute(self) -> List[CheckReportMicrosoft365]: + """ + Execute the admin MFA requirement check for administrative roles. + + Iterates over the Conditional Access Policies retrieved from the Entra client and generates a report + indicating whether MFA is enforced for users in administrative roles. + + Returns: + List[CheckReportMicrosoft365]: A list containing a single report with the result of the check. + """ + findings = [] + + report = CheckReportMicrosoft365( + metadata=self.metadata(), + resource={}, + resource_name="Conditional Access Policies", + resource_id="conditionalAccessPolicies", + ) + + report.status = "FAIL" + report.status_extended = "No Conditional Access Policy requiring MFA for administrative roles was found." + + for policy in entra_client.conditional_access_policies.values(): + if policy.state == ConditionalAccessPolicyState.DISABLED: + continue + + if not ({admin_role.value for admin_role in AdminRoles}).issubset( + set(policy.conditions.user_conditions.included_roles) + ): + if "All" not in policy.conditions.user_conditions.included_users: + continue + + if ( + "All" + not in policy.conditions.application_conditions.included_applications + ): + continue + + if ( + ConditionalAccessGrantControl.MFA + in policy.grant_controls.built_in_controls + ): + report = CheckReportMicrosoft365( + metadata=self.metadata(), + resource=entra_client.conditional_access_policies, + resource_name=policy.display_name, + resource_id=policy.id, + ) + report.status = "PASS" + report.status_extended = f"Conditional Access Policy '{policy.display_name}' enforces MFA for administrative roles." + + if policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING: + report.status = "FAIL" + report.status_extended = f"Conditional Access Policy '{policy.display_name}' only reports MFA for administrative roles but does not enforce it." + break + + findings.append(report) + return findings diff --git a/tests/providers/microsoft365/services/entra/entra_admin_mfa_enabled_for_administrative_roles/entra_admin_mfa_enabled_for_administrative_roles_test.py b/tests/providers/microsoft365/services/entra/entra_admin_mfa_enabled_for_administrative_roles/entra_admin_mfa_enabled_for_administrative_roles_test.py new file mode 100644 index 0000000000..ed30be2c7a --- /dev/null +++ b/tests/providers/microsoft365/services/entra/entra_admin_mfa_enabled_for_administrative_roles/entra_admin_mfa_enabled_for_administrative_roles_test.py @@ -0,0 +1,539 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.microsoft365.services.entra.entra_service import ( + ApplicationsConditions, + ConditionalAccessGrantControl, + ConditionalAccessPolicy, + ConditionalAccessPolicyState, + Conditions, + GrantControls, + GrantControlOperator, + PersistentBrowser, + SessionControls, + SignInFrequency, + SignInFrequencyInterval, + UsersConditions, +) +from tests.providers.microsoft365.microsoft365_fixtures import ( + DOMAIN, + set_mocked_microsoft365_provider, +) + + +class Test_entra_admin_mfa_enabled_for_administrative_roles: + def test_no_conditional_access_policies(self): + """No conditional access policies configured: expected FAIL.""" + 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_microsoft365_provider(), + ), + mock.patch( + "prowler.providers.microsoft365.services.entra.entra_admin_mfa_enabled_for_administrative_roles.entra_admin_mfa_enabled_for_administrative_roles.entra_client", + new=entra_client, + ), + ): + from prowler.providers.microsoft365.services.entra.entra_admin_mfa_enabled_for_administrative_roles.entra_admin_mfa_enabled_for_administrative_roles import ( + entra_admin_mfa_enabled_for_administrative_roles, + ) + + entra_client.conditional_access_policies = {} + + check = entra_admin_mfa_enabled_for_administrative_roles() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requiring MFA for administrative roles was found." + ) + assert result[0].resource == {} + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + + def test_policy_disabled(self): + """Policy in DISABLED state: expected to be ignored and return FAIL.""" + 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_microsoft365_provider(), + ), + mock.patch( + "prowler.providers.microsoft365.services.entra.entra_admin_mfa_enabled_for_administrative_roles.entra_admin_mfa_enabled_for_administrative_roles.entra_client", + new=entra_client, + ), + ): + from prowler.providers.microsoft365.services.entra.entra_admin_mfa_enabled_for_administrative_roles.entra_admin_mfa_enabled_for_administrative_roles import ( + entra_admin_mfa_enabled_for_administrative_roles, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name="Disabled Policy", + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], excluded_applications=[] + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.MFA], + operator=GrantControlOperator.AND, + ), + 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, + ), + ), + state=ConditionalAccessPolicyState.DISABLED, + ) + } + + check = entra_admin_mfa_enabled_for_administrative_roles() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requiring MFA for administrative roles was found." + ) + assert result[0].resource == {} + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + + def test_policy_missing_admin_roles(self): + """ + Enabled policy that does not apply to administrative roles: + Does not include 'All' in included_users nor administrative roles in included_roles. + Expected FAIL. + """ + 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_microsoft365_provider(), + ), + mock.patch( + "prowler.providers.microsoft365.services.entra.entra_admin_mfa_enabled_for_administrative_roles.entra_admin_mfa_enabled_for_administrative_roles.entra_client", + new=entra_client, + ), + ): + from prowler.providers.microsoft365.services.entra.entra_admin_mfa_enabled_for_administrative_roles.entra_admin_mfa_enabled_for_administrative_roles import ( + entra_admin_mfa_enabled_for_administrative_roles, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name="No Admin Roles Policy", + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], excluded_applications=[] + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=[], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.MFA], + operator=GrantControlOperator.AND, + ), + 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, + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_admin_mfa_enabled_for_administrative_roles() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requiring MFA for administrative roles was found." + ) + assert result[0].resource == {} + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + + def test_policy_missing_application_all(self): + """ + Enabled policy that includes administrative users (via 'All') + but does not have "All" in included_applications. + Expected FAIL. + """ + 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_microsoft365_provider(), + ), + mock.patch( + "prowler.providers.microsoft365.services.entra.entra_admin_mfa_enabled_for_administrative_roles.entra_admin_mfa_enabled_for_administrative_roles.entra_client", + new=entra_client, + ), + ): + from prowler.providers.microsoft365.services.entra.entra_admin_mfa_enabled_for_administrative_roles.entra_admin_mfa_enabled_for_administrative_roles import ( + entra_admin_mfa_enabled_for_administrative_roles, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name="Missing Application All Policy", + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["MicrosoftAdminPortals"], + excluded_applications=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.MFA], + operator=GrantControlOperator.AND, + ), + 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, + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_admin_mfa_enabled_for_administrative_roles() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requiring MFA for administrative roles was found." + ) + assert result[0].resource == {} + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + + def test_policy_valid(self): + """ + Valid policy: + - State enabled for reporting only + - Applies to administrative roles via 'All' in included_users + - Application conditions include "All" + - MFA is configured in grant_controls + + Expected FAIL due to is only for reporting. + """ + policy_id = str(uuid4()) + display_name = "Valid MFA Policy" + 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_microsoft365_provider(), + ), + mock.patch( + "prowler.providers.microsoft365.services.entra.entra_admin_mfa_enabled_for_administrative_roles.entra_admin_mfa_enabled_for_administrative_roles.entra_client", + new=entra_client, + ), + ): + from prowler.providers.microsoft365.services.entra.entra_admin_mfa_enabled_for_administrative_roles.entra_admin_mfa_enabled_for_administrative_roles import ( + entra_admin_mfa_enabled_for_administrative_roles, + ) + + 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=[] + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.MFA], + operator=GrantControlOperator.AND, + ), + 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, + ), + ), + state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, + ) + } + + check = entra_admin_mfa_enabled_for_administrative_roles() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + expected_status_extended = f"Conditional Access Policy '{display_name}' only reports MFA for administrative roles but does not enforce it." + assert result[0].status_extended == expected_status_extended + assert result[0].resource == entra_client.conditional_access_policies + assert result[0].resource_name == display_name + assert result[0].resource_id == policy_id + + def test_policy_valid_through_roles(self): + """ + Valid policy: + - State enabled (ENABLED) + - Applies to administrative roles + - Application conditions include "All" + - MFA is configured in grant_controls + + Expected PASS. + """ + policy_id = str(uuid4()) + display_name = "Valid MFA Policy" + 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_microsoft365_provider(), + ), + mock.patch( + "prowler.providers.microsoft365.services.entra.entra_admin_mfa_enabled_for_administrative_roles.entra_admin_mfa_enabled_for_administrative_roles.entra_client", + new=entra_client, + ), + ): + from prowler.providers.microsoft365.services.entra.entra_admin_mfa_enabled_for_administrative_roles.entra_admin_mfa_enabled_for_administrative_roles import ( + entra_admin_mfa_enabled_for_administrative_roles, + ) + + 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=[] + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=[], + excluded_users=[], + included_roles=[ + "9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3", + "c4e39bd9-1100-46d3-8c65-fb160da0071f", + "b0f54661-2d74-4c50-afa3-1ec803f12efe", + "158c047a-c907-4556-b7ef-446551a6b5f7", + "b1be1c3e-b65d-4f19-8427-f6fa0d97feb9", + "29232cdf-9323-42fd-ade2-1d097af3e4de", + "62e90394-69f5-4237-9190-012177145e10", + "f2ef992c-3afb-46b9-b7cf-a126ee74c451", + "729827e3-9c14-49f7-bb1b-9608f156bbb8", + "966707d0-3269-4727-9be2-8c3a10f19b9d", + "7be44c8a-adaf-4e2a-84d6-ab2649e08a13", + "e8611ab8-c189-46e8-94e1-60213ab1f814", + "194ae4cb-b126-40b2-bd5b-6091b380977d", + "f28a1f50-f6e7-4571-818b-6a12f2af6b6c", + "fe930be7-5e62-47db-91af-98c3a49a38b1", + ], + excluded_roles=[], + ), + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.MFA], + operator=GrantControlOperator.AND, + ), + 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, + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_admin_mfa_enabled_for_administrative_roles() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + expected_status_extended = f"Conditional Access Policy '{display_name}' enforces MFA for administrative roles." + assert result[0].status_extended == expected_status_extended + assert result[0].resource == entra_client.conditional_access_policies + assert result[0].resource_name == display_name + assert result[0].resource_id == policy_id + + def test_policy_valid_one_missing_role(self): + """ + Valid policy: + - State enabled (ENABLED or ENABLED_FOR_REPORTING) + - Applies to administrative roles except one + - Application conditions include "All" + - MFA is configured in grant_controls + + Expected FAIL. + """ + policy_id = str(uuid4()) + display_name = "Valid MFA Policy" + 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_microsoft365_provider(), + ), + mock.patch( + "prowler.providers.microsoft365.services.entra.entra_admin_mfa_enabled_for_administrative_roles.entra_admin_mfa_enabled_for_administrative_roles.entra_client", + new=entra_client, + ), + ): + from prowler.providers.microsoft365.services.entra.entra_admin_mfa_enabled_for_administrative_roles.entra_admin_mfa_enabled_for_administrative_roles import ( + entra_admin_mfa_enabled_for_administrative_roles, + ) + + 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=[] + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=[], + excluded_users=[], + included_roles=[ + "9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3", + "c4e39bd9-1100-46d3-8c65-fb160da0071f", + "b0f54661-2d74-4c50-afa3-1ec803f12efe", + "158c047a-c907-4556-b7ef-446551a6b5f7", + "b1be1c3e-b65d-4f19-8427-f6fa0d97feb9", + "29232cdf-9323-42fd-ade2-1d097af3e4de", + "62e90394-69f5-4237-9190-012177145e10", + "f2ef992c-3afb-46b9-b7cf-a126ee74c451", + "729827e3-9c14-49f7-bb1b-9608f156bbb8", + "966707d0-3269-4727-9be2-8c3a10f19b9d", + "7be44c8a-adaf-4e2a-84d6-ab2649e08a13", + "e8611ab8-c189-46e8-94e1-60213ab1f814", + "194ae4cb-b126-40b2-bd5b-6091b380977d", + "f28a1f50-f6e7-4571-818b-6a12f2af6b6c", + ], + excluded_roles=[], + ), + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.MFA], + operator=GrantControlOperator.AND, + ), + 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, + ), + ), + state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, + ) + } + + check = entra_admin_mfa_enabled_for_administrative_roles() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + expected_status_extended = "No Conditional Access Policy requiring MFA for administrative roles was found." + assert result[0].status_extended == expected_status_extended + assert result[0].resource == {} + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies"