From 923958908df34b24815f61459345cd05f155a414 Mon Sep 17 00:00:00 2001 From: HugoPBrito Date: Fri, 27 Feb 2026 14:31:23 +0100 Subject: [PATCH] feat(m365): enhance entra_legacy_authentication_blocked security check Enhance the existing check to validate that Conditional Access policies block legacy authentication for all users and all cloud apps. Updates metadata with detailed risk, description, and remediation guidance. Improves check logic to verify policy targets all users, all cloud apps, and includes both exchangeActiveSync and other client app types. Adds report-only policy detection. Updates unit tests for new logic. --- prowler/CHANGELOG.md | 1 + ...egacy_authentication_blocked.metadata.json | 21 +- .../entra_legacy_authentication_blocked.py | 15 +- ...ntra_legacy_authentication_blocked_test.py | 518 ++++++++++++++++-- 4 files changed, 500 insertions(+), 55 deletions(-) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 48b755a193..8efdf41dcb 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -36,6 +36,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - CIS 6.0 for the AWS provider [(#10127)](https://github.com/prowler-cloud/prowler/pull/10127) - `entra_require_mfa_for_management_api` check for m365 provider [(#10150)](https://github.com/prowler-cloud/prowler/pull/10150) - OpenStack provider multiple regions support [(#10135)](https://github.com/prowler-cloud/prowler/pull/10135) +- `entra_legacy_authentication_blocked` check for m365 provider [(#10196)](https://github.com/prowler-cloud/prowler/pull/10196) ### 🔄 Changed diff --git a/prowler/providers/m365/services/entra/entra_legacy_authentication_blocked/entra_legacy_authentication_blocked.metadata.json b/prowler/providers/m365/services/entra/entra_legacy_authentication_blocked/entra_legacy_authentication_blocked.metadata.json index aee31bc8cd..3e2fd703ee 100644 --- a/prowler/providers/m365/services/entra/entra_legacy_authentication_blocked/entra_legacy_authentication_blocked.metadata.json +++ b/prowler/providers/m365/services/entra/entra_legacy_authentication_blocked/entra_legacy_authentication_blocked.metadata.json @@ -1,7 +1,7 @@ { "Provider": "m365", "CheckID": "entra_legacy_authentication_blocked", - "CheckTitle": "Ensure that Conditional Access policy blocks legacy authentication", + "CheckTitle": "Conditional Access policy blocks legacy authentication for all users and cloud apps", "CheckType": [], "ServiceName": "entra", "SubServiceName": "", @@ -9,23 +9,28 @@ "Severity": "critical", "ResourceType": "Conditional Access Policy", "ResourceGroup": "IAM", - "Description": "Ensure that Conditional Access policy blocks legacy authentication in Microsoft Entra ID to enforce modern authentication methods and protect against credential-stuffing and brute-force attacks.", - "Risk": "Legacy authentication protocols do not support MFA, making them vulnerable to credential-stuffing and brute-force attacks. Attackers commonly exploit these protocols to bypass security controls and gain unauthorized access.", - "RelatedUrl": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/policy-block-legacy-authentication", + "Description": "A Conditional Access policy in Microsoft Entra ID blocks **legacy authentication** protocols (IMAP, POP3, SMTP, MAPI) by targeting `exchangeActiveSync` and `other` client app types for all users and all cloud apps.\n\nThe policy must be in an **enabled** state with a **block** grant control to be effective.", + "Risk": "Legacy authentication protocols do not support **MFA**, making them the primary vector for **password spray attacks** (99%+ use legacy auth). Attackers exploit protocols like IMAP, POP3, and SMTP to bypass Conditional Access controls and gain unauthorized access to user accounts.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/policy-block-legacy-authentication", + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/block-legacy-authentication" + ], "Remediation": { "Code": { "CLI": "", "NativeIaC": "", - "Other": "1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Click expand Protection > Conditional Access select Policies. 3. Create a new policy by selecting New policy. Under Users include All users. Under Target resources include All cloud apps and do not create any exclusions. Under Conditions select Client apps and check the boxes for Exchange ActiveSync clients and Other clients. Under Grant select Block Access. Click Select. 4. Set the policy On and click Create.", + "Other": "1. Navigate to the Microsoft Entra admin center (https://entra.microsoft.com)\n2. Go to **Protection** > **Conditional Access** > **Policies**\n3. Select **New policy**\n4. Under **Users**, include **All users**\n5. Under **Target resources**, include **All cloud apps**\n6. Under **Conditions** > **Client apps**, check **Exchange ActiveSync clients** and **Other clients**\n7. Under **Grant**, select **Block access**\n8. Set the policy to **On** and click **Create**", "Terraform": "" }, "Recommendation": { - "Text": "Enforce Conditional Access policies to block legacy authentication across all users in Microsoft Entra ID. Ensure all applications and devices use modern authentication methods such as OAuth 2.0. For necessary exceptions (e.g., multifunction printers), configure secure alternatives following Microsoft's mail flow best practices.", - "Url": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/policy-block-legacy-authentication" + "Text": "Block legacy authentication protocols using a Conditional Access policy targeting all users and all cloud apps. Apply the **zero trust** principle by enforcing modern authentication methods (OAuth 2.0) and disabling legacy protocols that cannot support MFA.", + "Url": "https://hub.prowler.com/check/entra_legacy_authentication_blocked" } }, "Categories": [ - "e3" + "e3", + "identity-access" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/m365/services/entra/entra_legacy_authentication_blocked/entra_legacy_authentication_blocked.py b/prowler/providers/m365/services/entra/entra_legacy_authentication_blocked/entra_legacy_authentication_blocked.py index f625cb6182..0a50334d54 100644 --- a/prowler/providers/m365/services/entra/entra_legacy_authentication_blocked/entra_legacy_authentication_blocked.py +++ b/prowler/providers/m365/services/entra/entra_legacy_authentication_blocked/entra_legacy_authentication_blocked.py @@ -8,16 +8,20 @@ from prowler.providers.m365.services.entra.entra_service import ( class entra_legacy_authentication_blocked(Check): - """Check if at least one Conditional Access policy blocks legacy authentication. + """Check if a Conditional Access policy blocks legacy authentication for all users and cloud apps. - This check ensures that at least one Conditional Access policy blocks legacy authentication. + This check verifies that an enabled Conditional Access policy exists that blocks + legacy authentication protocols (Exchange ActiveSync and other legacy clients) + targeting all users and all cloud applications. + - PASS: An enabled policy blocks legacy authentication for all users and cloud apps. + - FAIL: No enabled policy blocks legacy authentication, or the policy is in report-only mode. """ def execute(self) -> list[CheckReportM365]: - """Execute the check to ensure that at least one Conditional Access policy blocks legacy authentication. + """Execute the legacy authentication blocking check. Returns: - list[CheckReportM365]: A list containing the results of the check. + A list of reports containing the result of the check. """ findings = [] report = CheckReportM365( @@ -47,7 +51,8 @@ class entra_legacy_authentication_blocked(Check): if ( ClientAppType.EXCHANGE_ACTIVE_SYNC not in policy.conditions.client_app_types - or ClientAppType.OTHER_CLIENTS not in policy.conditions.client_app_types + or ClientAppType.OTHER_CLIENTS + not in policy.conditions.client_app_types ): continue diff --git a/tests/providers/m365/services/entra/entra_legacy_authentication_blocked/entra_legacy_authentication_blocked_test.py b/tests/providers/m365/services/entra/entra_legacy_authentication_blocked/entra_legacy_authentication_blocked_test.py index bfd2c1d001..871f703140 100644 --- a/tests/providers/m365/services/entra/entra_legacy_authentication_blocked/entra_legacy_authentication_blocked_test.py +++ b/tests/providers/m365/services/entra/entra_legacy_authentication_blocked/entra_legacy_authentication_blocked_test.py @@ -18,6 +18,17 @@ from prowler.providers.m365.services.entra.entra_service import ( ) from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider +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.EVERY_TIME, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions(is_enabled=False), +) + class Test_entra_legacy_authentication_blocked: def test_entra_no_conditional_access_policies(self): @@ -107,20 +118,7 @@ class Test_entra_legacy_authentication_blocked: ], 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, - ), - application_enforced_restrictions=ApplicationEnforcedRestrictions( - is_enabled=False - ), - ), + session_controls=DEFAULT_SESSION_CONTROLS, state=ConditionalAccessPolicyState.DISABLED, ) } @@ -192,20 +190,7 @@ class Test_entra_legacy_authentication_blocked: ], 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, - ), - application_enforced_restrictions=ApplicationEnforcedRestrictions( - is_enabled=False - ), - ), + session_controls=DEFAULT_SESSION_CONTROLS, state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, ) } @@ -280,20 +265,7 @@ class Test_entra_legacy_authentication_blocked: ], 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, - ), - application_enforced_restrictions=ApplicationEnforcedRestrictions( - is_enabled=False - ), - ), + session_controls=DEFAULT_SESSION_CONTROLS, state=ConditionalAccessPolicyState.ENABLED, ) } @@ -313,3 +285,465 @@ class Test_entra_legacy_authentication_blocked: assert result[0].resource_name == display_name assert result[0].resource_id == id assert result[0].location == "global" + + def test_entra_block_legacy_authentication_missing_client_app_types(self): + id = str(uuid4()) + display_name = "Test" + 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_legacy_authentication_blocked.entra_legacy_authentication_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_legacy_authentication_blocked.entra_legacy_authentication_blocked import ( + entra_legacy_authentication_blocked, + ) + 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=[], + ), + client_app_types=[ + ClientAppType.EXCHANGE_ACTIVE_SYNC, + ], + user_risk_levels=[], + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.BLOCK, + ], + operator=GrantControlOperator.AND, + ), + session_controls=DEFAULT_SESSION_CONTROLS, + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_legacy_authentication_blocked() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy blocks legacy authentication." + ) + assert result[0].resource == {} + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + + def test_entra_block_legacy_authentication_missing_exchange_active_sync(self): + id = str(uuid4()) + display_name = "Test" + 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_legacy_authentication_blocked.entra_legacy_authentication_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_legacy_authentication_blocked.entra_legacy_authentication_blocked import ( + entra_legacy_authentication_blocked, + ) + 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=[], + ), + client_app_types=[ + ClientAppType.OTHER_CLIENTS, + ], + user_risk_levels=[], + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.BLOCK, + ], + operator=GrantControlOperator.AND, + ), + session_controls=DEFAULT_SESSION_CONTROLS, + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_legacy_authentication_blocked() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy blocks legacy authentication." + ) + assert result[0].resource == {} + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + + def test_entra_block_legacy_authentication_not_all_users(self): + id = str(uuid4()) + display_name = "Test" + 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_legacy_authentication_blocked.entra_legacy_authentication_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_legacy_authentication_blocked.entra_legacy_authentication_blocked import ( + entra_legacy_authentication_blocked, + ) + 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=["user-id-123"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[ + ClientAppType.EXCHANGE_ACTIVE_SYNC, + ClientAppType.OTHER_CLIENTS, + ], + user_risk_levels=[], + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.BLOCK, + ], + operator=GrantControlOperator.AND, + ), + session_controls=DEFAULT_SESSION_CONTROLS, + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_legacy_authentication_blocked() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy blocks legacy authentication." + ) + assert result[0].resource == {} + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + + def test_entra_block_legacy_authentication_not_all_apps(self): + id = str(uuid4()) + display_name = "Test" + 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_legacy_authentication_blocked.entra_legacy_authentication_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_legacy_authentication_blocked.entra_legacy_authentication_blocked import ( + entra_legacy_authentication_blocked, + ) + 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=["app-id-123"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[ + ClientAppType.EXCHANGE_ACTIVE_SYNC, + ClientAppType.OTHER_CLIENTS, + ], + user_risk_levels=[], + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.BLOCK, + ], + operator=GrantControlOperator.AND, + ), + session_controls=DEFAULT_SESSION_CONTROLS, + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_legacy_authentication_blocked() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy blocks legacy authentication." + ) + assert result[0].resource == {} + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + + def test_entra_block_legacy_authentication_no_block_grant(self): + id = str(uuid4()) + display_name = "Test" + 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_legacy_authentication_blocked.entra_legacy_authentication_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_legacy_authentication_blocked.entra_legacy_authentication_blocked import ( + entra_legacy_authentication_blocked, + ) + 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=[], + ), + client_app_types=[ + ClientAppType.EXCHANGE_ACTIVE_SYNC, + ClientAppType.OTHER_CLIENTS, + ], + user_risk_levels=[], + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.MFA, + ], + operator=GrantControlOperator.AND, + ), + session_controls=DEFAULT_SESSION_CONTROLS, + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_legacy_authentication_blocked() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy blocks legacy authentication." + ) + assert result[0].resource == {} + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + + def test_entra_block_legacy_authentication_multiple_policies_report_only_and_enabled( + self, + ): + report_only_id = str(uuid4()) + enabled_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( + "prowler.providers.m365.services.entra.entra_legacy_authentication_blocked.entra_legacy_authentication_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_legacy_authentication_blocked.entra_legacy_authentication_blocked import ( + entra_legacy_authentication_blocked, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + report_only_id: ConditionalAccessPolicy( + id=report_only_id, + display_name="Report Only Policy", + 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=[ + ClientAppType.EXCHANGE_ACTIVE_SYNC, + ClientAppType.OTHER_CLIENTS, + ], + user_risk_levels=[], + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.BLOCK, + ], + operator=GrantControlOperator.AND, + ), + session_controls=DEFAULT_SESSION_CONTROLS, + state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, + ), + enabled_id: ConditionalAccessPolicy( + id=enabled_id, + display_name="Enabled Block Policy", + 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=[ + ClientAppType.EXCHANGE_ACTIVE_SYNC, + ClientAppType.OTHER_CLIENTS, + ], + user_risk_levels=[], + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.BLOCK, + ], + operator=GrantControlOperator.AND, + ), + session_controls=DEFAULT_SESSION_CONTROLS, + state=ConditionalAccessPolicyState.ENABLED, + ), + } + + check = entra_legacy_authentication_blocked() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Conditional Access Policy 'Enabled Block Policy' blocks legacy authentication." + ) + assert ( + result[0].resource + == entra_client.conditional_access_policies[enabled_id].dict() + ) + assert result[0].resource_name == "Enabled Block Policy" + assert result[0].resource_id == enabled_id + assert result[0].location == "global"