diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 4b2cef24ab..3d9e6ccbc9 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ### 🚀 Added +- `entra_conditional_access_policy_risky_sign_in_mfa` check for m365 provider [(#10168)](https://github.com/prowler-cloud/prowler/pull/10168) - `entra_app_registration_no_unused_privileged_permissions` check for m365 provider [(#10080)](https://github.com/prowler-cloud/prowler/pull/10080) - `defenderidentity_health_issues_no_open` check for M365 provider [(#10087)](https://github.com/prowler-cloud/prowler/pull/10087) - `organization_verified_badge` check for GitHub provider [(#10033)](https://github.com/prowler-cloud/prowler/pull/10033) diff --git a/prowler/compliance/m365/cis_4.0_m365.json b/prowler/compliance/m365/cis_4.0_m365.json index 5af364c93e..c5135c229f 100644 --- a/prowler/compliance/m365/cis_4.0_m365.json +++ b/prowler/compliance/m365/cis_4.0_m365.json @@ -1260,6 +1260,7 @@ "Id": "5.2.2.7", "Description": "Microsoft Entra ID Protection sign-in risk detects risks in real-time and offline. A risky sign-in is an indicator for a sign-in attempt that might not have been performed by the legitimate owner of a user account.**Note:** While Identity Protection also provides two risk policies with limited conditions, Microsoft highly recommends setting up risk-based policies in Conditional Access as opposed to the \"legacy method\" for the following benefits:- Enhanced diagnostic data- Report-only mode integration- Graph API support- Use more Conditional Access attributes like sign-in frequency in the policy", "Checks": [ + "entra_conditional_access_policy_risky_sign_in_mfa", "entra_identity_protection_sign_in_risk_enabled" ], "Attributes": [ diff --git a/prowler/compliance/m365/cis_6.0_m365.json b/prowler/compliance/m365/cis_6.0_m365.json index 5e61be11e0..c6f29dfb87 100644 --- a/prowler/compliance/m365/cis_6.0_m365.json +++ b/prowler/compliance/m365/cis_6.0_m365.json @@ -1492,6 +1492,7 @@ "Id": "5.2.2.7", "Description": "Microsoft Entra ID Protection sign-in risk detects risks in real-time and offline. A risky sign-in is an indicator for a sign-in attempt that might not have been performed by the legitimate owner of a user account. Enable Identity Protection sign-in risk policies.", "Checks": [ + "entra_conditional_access_policy_risky_sign_in_mfa", "entra_identity_protection_sign_in_risk_enabled" ], "Attributes": [ diff --git a/prowler/compliance/m365/iso27001_2022_m365.json b/prowler/compliance/m365/iso27001_2022_m365.json index ac9c677f39..1805454913 100644 --- a/prowler/compliance/m365/iso27001_2022_m365.json +++ b/prowler/compliance/m365/iso27001_2022_m365.json @@ -20,6 +20,7 @@ "Checks": [ "defender_antiphishing_policy_configured", "defender_antispam_policy_inbound_no_allowed_domains", + "entra_conditional_access_policy_risky_sign_in_mfa", "entra_identity_protection_sign_in_risk_enabled", "entra_identity_protection_user_risk_enabled" ] @@ -120,6 +121,7 @@ "defenderxdr_endpoint_privileged_user_exposed_credentials", "defender_identity_health_issues_no_open", "entra_admin_users_phishing_resistant_mfa_enabled", + "entra_conditional_access_policy_risky_sign_in_mfa", "entra_identity_protection_sign_in_risk_enabled", "entra_identity_protection_user_risk_enabled" ] @@ -240,6 +242,7 @@ "entra_admin_users_sign_in_frequency_enabled", "entra_admin_users_mfa_enabled", "entra_admin_users_sign_in_frequency_enabled", + "entra_conditional_access_policy_risky_sign_in_mfa", "entra_legacy_authentication_blocked", "entra_managed_device_required_for_authentication", "entra_seamless_sso_disabled", @@ -685,6 +688,7 @@ "Checks": [ "entra_admin_users_sign_in_frequency_enabled", "entra_admin_users_mfa_enabled", + "entra_conditional_access_policy_risky_sign_in_mfa", "entra_managed_device_required_for_authentication", "entra_seamless_sso_disabled", "entra_users_mfa_enabled", diff --git a/prowler/compliance/m365/prowler_threatscore_m365.json b/prowler/compliance/m365/prowler_threatscore_m365.json index 512c401c8c..de40c972ce 100644 --- a/prowler/compliance/m365/prowler_threatscore_m365.json +++ b/prowler/compliance/m365/prowler_threatscore_m365.json @@ -406,6 +406,7 @@ "Id": "1.2.5", "Description": "Enable Identity Protection sign-in risk policies ", "Checks": [ + "entra_conditional_access_policy_risky_sign_in_mfa", "entra_identity_protection_sign_in_risk_enabled" ], "Attributes": [ diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_risky_sign_in_mfa/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_risky_sign_in_mfa/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_risky_sign_in_mfa/entra_conditional_access_policy_risky_sign_in_mfa.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_risky_sign_in_mfa/entra_conditional_access_policy_risky_sign_in_mfa.metadata.json new file mode 100644 index 0000000000..ed9f45d0fc --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_risky_sign_in_mfa/entra_conditional_access_policy_risky_sign_in_mfa.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "m365", + "CheckID": "entra_conditional_access_policy_risky_sign_in_mfa", + "CheckTitle": "Conditional Access Policy requires MFA for medium and high risk sign-ins", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "Conditional Access Policy", + "ResourceGroup": "IAM", + "Description": "A Conditional Access policy should require **multifactor authentication** for sign-ins classified as `medium` or `high` risk, targeting all users and all cloud applications.\n\nThe policy must also enforce **sign-in frequency** set to `everyTime` to require re-authentication on each risky sign-in attempt.", + "Risk": "Without a Conditional Access policy enforcing MFA on risky sign-ins, attackers who compromise credentials can authenticate without additional verification. This exposes the tenant to **unauthorized access**, **lateral movement**, and **data exfiltration** from accounts flagged as risky by Identity Protection.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-policy-risk", + "https://learn.microsoft.com/en-us/entra/id-protection/howto-identity-protection-configure-risk-policies" + ], + "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. Select **New policy**.\n4. Under **Users**, select **All users**.\n5. Under **Cloud apps or actions**, select **All cloud apps**.\n6. Under **Conditions** > **Sign-in risk**, set to **Yes** and check **High** and **Medium**.\n7. Under **Grant**, select **Require multifactor authentication**.\n8. Under **Session** > **Sign-in frequency**, select **Every time**.\n9. Set **Enable policy** to **On** and click **Create**.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure a Conditional Access policy to enforce MFA on risky sign-ins. Use Entra ID Identity Protection risk signals to target `medium` and `high` risk levels, and set sign-in frequency to **every time** to prevent session reuse after risk detection.", + "Url": "https://hub.prowler.com/check/entra_conditional_access_policy_risky_sign_in_mfa" + } + }, + "Categories": [ + "identity-access", + "e5" + ], + "DependsOn": [], + "RelatedTo": [ + "entra_identity_protection_sign_in_risk_enabled" + ], + "Notes": "Requires Entra ID P2 license. Legacy risk policies in Identity Protection retire October 2026 - must use Conditional Access policies." +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_risky_sign_in_mfa/entra_conditional_access_policy_risky_sign_in_mfa.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_risky_sign_in_mfa/entra_conditional_access_policy_risky_sign_in_mfa.py new file mode 100644 index 0000000000..af5257710b --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_risky_sign_in_mfa/entra_conditional_access_policy_risky_sign_in_mfa.py @@ -0,0 +1,86 @@ +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, + RiskLevel, + SignInFrequencyInterval, +) + + +class entra_conditional_access_policy_risky_sign_in_mfa(Check): + """Check if a Conditional Access policy requires MFA for medium and high risk sign-ins. + + This check verifies that at least one enabled Conditional Access policy is configured to + require multifactor authentication for risky sign-ins, targeting all users and all applications, + with sign-in frequency set to every time to force re-authentication. + - PASS: An enabled CA policy requires MFA for medium and high risk sign-ins with every-time sign-in frequency. + - FAIL: No CA policy addresses risky sign-ins with MFA requirement, or the policy is missing required conditions. + """ + + def execute(self) -> list[CheckReportM365]: + """Execute the check for Conditional Access policy requiring MFA on risky sign-ins. + + Returns: + list[CheckReportM365]: A list containing the results of the check. + """ + findings = [] + + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Conditional Access Policies", + resource_id="conditionalAccessPolicies", + ) + report.status = "FAIL" + report.status_extended = "No Conditional Access Policy requires MFA for risky sign-ins." + + 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 ( + ConditionalAccessGrantControl.MFA + not in policy.grant_controls.built_in_controls + ): + continue + + if ( + SignInFrequencyInterval.EVERY_TIME + != policy.session_controls.sign_in_frequency.interval + ): + continue + + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name=policy.display_name, + resource_id=policy.id, + ) + + has_high = RiskLevel.HIGH in policy.conditions.sign_in_risk_levels + has_medium = RiskLevel.MEDIUM in policy.conditions.sign_in_risk_levels + + if not has_high or not has_medium: + report.status = "FAIL" + report.status_extended = f"Conditional Access Policy '{policy.display_name}' requires MFA but does not cover both high and medium sign-in risk levels." + elif policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING: + report.status = "FAIL" + report.status_extended = f"Conditional Access Policy '{policy.display_name}' requires MFA for high and medium risk sign-ins but is set to report-only mode and does not enforce protection." + else: + report.status = "PASS" + report.status_extended = f"Conditional Access Policy '{policy.display_name}' requires MFA for high and medium risk sign-ins and enforces re-authentication every time." + break + + findings.append(report) + + return findings diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_risky_sign_in_mfa/entra_conditional_access_policy_risky_sign_in_mfa_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_risky_sign_in_mfa/entra_conditional_access_policy_risky_sign_in_mfa_test.py new file mode 100644 index 0000000000..784b6fe2f9 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_risky_sign_in_mfa/entra_conditional_access_policy_risky_sign_in_mfa_test.py @@ -0,0 +1,675 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.m365.services.entra.entra_service import ( + ApplicationsConditions, + ConditionalAccessGrantControl, + ConditionalAccessPolicyState, + Conditions, + GrantControlOperator, + GrantControls, + PersistentBrowser, + RiskLevel, + 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_risky_sign_in_mfa.entra_conditional_access_policy_risky_sign_in_mfa" + + +class Test_entra_conditional_access_policy_risky_sign_in_mfa: + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_risky_sign_in_mfa.entra_conditional_access_policy_risky_sign_in_mfa import ( + entra_conditional_access_policy_risky_sign_in_mfa, + ) + + entra_client.conditional_access_policies = {} + + check = entra_conditional_access_policy_risky_sign_in_mfa() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires MFA for risky sign-ins." + ) + 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): + 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_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_risky_sign_in_mfa.entra_conditional_access_policy_risky_sign_in_mfa import ( + entra_conditional_access_policy_risky_sign_in_mfa, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + id: ConditionalAccessPolicy( + id=id, + display_name="Test", + 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=[], + ), + user_risk_levels=[], + sign_in_risk_levels=[RiskLevel.HIGH, RiskLevel.MEDIUM], + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.MFA], + operator=GrantControlOperator.OR, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=True, + frequency=None, + type=None, + interval=SignInFrequencyInterval.EVERY_TIME, + ), + ), + state=ConditionalAccessPolicyState.DISABLED, + ) + } + + check = entra_conditional_access_policy_risky_sign_in_mfa() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires MFA for risky sign-ins." + ) + 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_missing_risk_levels(self): + id = str(uuid4()) + display_name = "Risky Sign-in 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_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_risky_sign_in_mfa.entra_conditional_access_policy_risky_sign_in_mfa import ( + entra_conditional_access_policy_risky_sign_in_mfa, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + id: ConditionalAccessPolicy( + id=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=[], + ), + user_risk_levels=[], + sign_in_risk_levels=[RiskLevel.LOW], + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.MFA], + operator=GrantControlOperator.OR, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=True, + frequency=None, + type=None, + interval=SignInFrequencyInterval.EVERY_TIME, + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_risky_sign_in_mfa() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Conditional Access Policy '{display_name}' requires MFA but does not cover both high and medium sign-in risk levels." + ) + assert ( + result[0].resource + == entra_client.conditional_access_policies[id].dict() + ) + assert result[0].resource_name == display_name + assert result[0].resource_id == id + assert result[0].location == "global" + + def test_policy_report_only_mode(self): + id = str(uuid4()) + display_name = "Risky Sign-in 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_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_risky_sign_in_mfa.entra_conditional_access_policy_risky_sign_in_mfa import ( + entra_conditional_access_policy_risky_sign_in_mfa, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + id: ConditionalAccessPolicy( + id=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=[], + ), + user_risk_levels=[], + sign_in_risk_levels=[RiskLevel.HIGH, RiskLevel.MEDIUM], + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.MFA], + operator=GrantControlOperator.OR, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=True, + frequency=None, + type=None, + interval=SignInFrequencyInterval.EVERY_TIME, + ), + ), + state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, + ) + } + + check = entra_conditional_access_policy_risky_sign_in_mfa() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Conditional Access Policy '{display_name}' requires MFA for high and medium risk sign-ins but is set to report-only mode and does not enforce protection." + ) + assert ( + result[0].resource + == entra_client.conditional_access_policies[id].dict() + ) + assert result[0].resource_name == display_name + assert result[0].resource_id == id + assert result[0].location == "global" + + def test_policy_no_mfa_grant_control(self): + 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_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_risky_sign_in_mfa.entra_conditional_access_policy_risky_sign_in_mfa import ( + entra_conditional_access_policy_risky_sign_in_mfa, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + id: ConditionalAccessPolicy( + id=id, + display_name="Test", + 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=[], + ), + user_risk_levels=[], + sign_in_risk_levels=[RiskLevel.HIGH, RiskLevel.MEDIUM], + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.BLOCK], + operator=GrantControlOperator.OR, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=True, + frequency=None, + type=None, + interval=SignInFrequencyInterval.EVERY_TIME, + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_risky_sign_in_mfa() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires MFA for risky sign-ins." + ) + 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_all_users(self): + 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_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_risky_sign_in_mfa.entra_conditional_access_policy_risky_sign_in_mfa import ( + entra_conditional_access_policy_risky_sign_in_mfa, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + id: ConditionalAccessPolicy( + id=id, + display_name="Test", + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["some-user-id"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + user_risk_levels=[], + sign_in_risk_levels=[RiskLevel.HIGH, RiskLevel.MEDIUM], + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.MFA], + operator=GrantControlOperator.OR, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=True, + frequency=None, + type=None, + interval=SignInFrequencyInterval.EVERY_TIME, + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_risky_sign_in_mfa() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires MFA for risky sign-ins." + ) + 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_all_applications(self): + 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_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_risky_sign_in_mfa.entra_conditional_access_policy_risky_sign_in_mfa import ( + entra_conditional_access_policy_risky_sign_in_mfa, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + id: ConditionalAccessPolicy( + id=id, + display_name="Test", + 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=[], + ), + user_risk_levels=[], + sign_in_risk_levels=[RiskLevel.HIGH, RiskLevel.MEDIUM], + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.MFA], + operator=GrantControlOperator.OR, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=True, + frequency=None, + type=None, + interval=SignInFrequencyInterval.EVERY_TIME, + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_risky_sign_in_mfa() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires MFA for risky sign-ins." + ) + 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_sign_in_frequency_not_every_time(self): + 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_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_risky_sign_in_mfa.entra_conditional_access_policy_risky_sign_in_mfa import ( + entra_conditional_access_policy_risky_sign_in_mfa, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + id: ConditionalAccessPolicy( + id=id, + display_name="Test", + 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=[], + ), + user_risk_levels=[], + sign_in_risk_levels=[RiskLevel.HIGH, RiskLevel.MEDIUM], + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.MFA], + operator=GrantControlOperator.OR, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=True, + frequency=1, + type=None, + interval=SignInFrequencyInterval.TIME_BASED, + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_risky_sign_in_mfa() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires MFA for risky sign-ins." + ) + 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_fully_compliant(self): + id = str(uuid4()) + display_name = "Risky Sign-in 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_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_risky_sign_in_mfa.entra_conditional_access_policy_risky_sign_in_mfa import ( + entra_conditional_access_policy_risky_sign_in_mfa, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + id: ConditionalAccessPolicy( + id=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=[], + ), + user_risk_levels=[], + sign_in_risk_levels=[RiskLevel.HIGH, RiskLevel.MEDIUM], + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.MFA], + operator=GrantControlOperator.OR, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=True, + frequency=None, + type=None, + interval=SignInFrequencyInterval.EVERY_TIME, + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_risky_sign_in_mfa() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Conditional Access Policy '{display_name}' requires MFA for high and medium risk sign-ins and enforces re-authentication every time." + ) + assert ( + result[0].resource + == entra_client.conditional_access_policies[id].dict() + ) + assert result[0].resource_name == display_name + assert result[0].resource_id == id + assert result[0].location == "global"