diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index a380b07115..b1327a7024 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -19,6 +19,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `aks_cluster_auto_upgrade_enabled` check for Azure provider [(#11027)](https://github.com/prowler-cloud/prowler/pull/11027) - Jira timeout preventing the calls from hanging indefinitely when the Jira endpoint is unreachable or slow [(#11602)](https://github.com/prowler-cloud/prowler/pull/11602) - TLS certificate verification in the `codepipeline_project_repo_private` check, which previously used an unverified SSL context, leaving the repository-visibility probe open to MITM tampering [(#11603)](https://github.com/prowler-cloud/prowler/pull/11603) +- `entra_directory_sync_object_takeover_blocked` check for the M365 provider, verifying that hybrid Entra tenants block cloud object takeover through both soft-match and hard-match directory synchronization [(#11098)](https://github.com/prowler-cloud/prowler/pull/11098) ### 🔄 Changed diff --git a/prowler/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/__init__.py b/prowler/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked.metadata.json b/prowler/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked.metadata.json new file mode 100644 index 0000000000..0cc14e2965 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked.metadata.json @@ -0,0 +1,42 @@ +{ + "Provider": "m365", + "CheckID": "entra_directory_sync_object_takeover_blocked", + "CheckTitle": "Microsoft Entra directory sync must block object takeover (soft- and hard-matching)", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "When on-premises directory synchronization is enabled, both blockSoftMatchEnabled and blockCloudObjectTakeoverThroughHardMatchEnabled must be true. Without these blocks, an attacker who can write to on-premises AD can craft an object that matches a privileged cloud account and take it over.", + "Risk": "An attacker with write access to on-premises Active Directory can create an object whose UPN, SMTP address, or ImmutableID matches an existing cloud-only account (e.g. Global Administrator). When the sync engine processes this object, it merges the on-premises identity into the cloud account, effectively granting the attacker full control of that privileged account.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/graph/api/resources/onpremisesdirectorysynchronization?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/graph/api/resources/onpremisesdirectorysynchronizationfeature?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/entra/identity/hybrid/connect/how-to-connect-syncservice-features" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Open Microsoft Entra admin center\n2. Navigate to Identity > Hybrid management > Microsoft Entra Connect > Connect Sync\n3. Enable 'Block soft match' and 'Block cloud object takeover through hard match'\n4. Alternatively, use Microsoft Graph API to set both features to true on the onPremisesDirectorySynchronization resource", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable both blockSoftMatchEnabled and blockCloudObjectTakeoverThroughHardMatchEnabled on the on-premises directory synchronization configuration. These should remain enabled permanently except during time-boxed migration windows.", + "Url": "https://hub.prowler.com/check/entra_directory_sync_object_takeover_blocked" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [ + "entra_password_hash_sync_enabled", + "entra_seamless_sso_disabled" + ], + "Notes": "This check only applies to hybrid tenants with on-premises directory synchronization enabled. Cloud-only tenants receive a PASS since the attack path does not exist." +} diff --git a/prowler/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked.py b/prowler/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked.py new file mode 100644 index 0000000000..d1f00a70ff --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked.py @@ -0,0 +1,118 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + + +class entra_directory_sync_object_takeover_blocked(Check): + """Check that directory sync blocks object takeover via soft-match and hard-match. + + When on-premises directory synchronization is enabled, an attacker who can + write to on-premises AD can craft an object that matches a privileged cloud + account and take it over. Both blockSoftMatchEnabled and + blockCloudObjectTakeoverThroughHardMatchEnabled must be true to prevent this. + + The attack path only exists on hybrid tenants, so the tenant's + organization.onPremisesSyncEnabled is evaluated first. Microsoft Graph + returns an onPremisesSynchronization object (with all features disabled) even + for cloud-only tenants, so the directory sync features must not be evaluated + unless on-premises synchronization is actually enabled. + + - PASS: The tenant is cloud-only, or both block flags are enabled. + - FAIL: On-premises sync is enabled and either block flag is disabled. + - MANUAL: On-premises sync is enabled but the settings cannot be read + (insufficient permissions) or were not returned by Microsoft Graph. + """ + + def execute(self) -> List[CheckReportM365]: + findings = [] + + organizations = entra_client.organizations or [] + on_premises_sync_enabled = any( + organization.on_premises_sync_enabled for organization in organizations + ) + + # Cloud-only tenant: the object takeover attack path does not exist, so + # the directory sync features are not evaluated even if Microsoft Graph + # returns an (all-disabled) onPremisesSynchronization object. + if organizations and not on_premises_sync_enabled: + for organization in organizations: + report = CheckReportM365( + self.metadata(), + resource=organization, + resource_id=organization.id, + resource_name=organization.name, + ) + report.status = "PASS" + report.status_extended = ( + f"Entra organization {organization.name} is cloud-only " + "(no on-premises sync), object takeover protection is not " + "applicable." + ) + findings.append(report) + return findings + + # Hybrid tenant but the directory sync settings could not be read. + if entra_client.directory_sync_error: + for organization in organizations: + report = CheckReportM365( + self.metadata(), + resource=organization, + resource_id=organization.id, + resource_name=organization.name, + ) + report.status = "MANUAL" + report.status_extended = ( + f"Cannot verify object takeover protection for " + f"{organization.name}: {entra_client.directory_sync_error}." + ) + findings.append(report) + return findings + + for sync_settings in entra_client.directory_sync_settings: + report = CheckReportM365( + self.metadata(), + resource=sync_settings, + resource_id=sync_settings.id, + resource_name=f"Directory Sync {sync_settings.id}", + ) + + disabled_flags = [] + if not sync_settings.block_soft_match_enabled: + disabled_flags.append("blockSoftMatchEnabled") + if not sync_settings.block_cloud_object_takeover_through_hard_match_enabled: + disabled_flags.append("blockCloudObjectTakeoverThroughHardMatchEnabled") + + if not disabled_flags: + report.status = "PASS" + report.status_extended = ( + f"Entra directory sync {sync_settings.id} blocks both soft-match " + "and hard-match object takeover." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Entra directory sync {sync_settings.id} does not block object " + f"takeover: {', '.join(disabled_flags)} disabled." + ) + + findings.append(report) + + # Hybrid tenant that reported on-premises sync but returned no settings. + if not entra_client.directory_sync_settings: + for organization in organizations: + report = CheckReportM365( + self.metadata(), + resource=organization, + resource_id=organization.id, + resource_name=organization.name, + ) + report.status = "MANUAL" + report.status_extended = ( + f"Entra organization {organization.name} has on-premises sync " + "enabled, but no directory sync settings were returned. Review " + "the tenant configuration manually." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/m365/services/entra/entra_service.py b/prowler/providers/m365/services/entra/entra_service.py index 20f2dbf3a3..39d958b44b 100644 --- a/prowler/providers/m365/services/entra/entra_service.py +++ b/prowler/providers/m365/services/entra/entra_service.py @@ -791,6 +791,16 @@ class Entra(M365Service): features, "seamless_sso_enabled", False ) or False, + block_soft_match_enabled=getattr( + features, "block_soft_match_enabled", False + ) + or False, + block_cloud_object_takeover_through_hard_match_enabled=getattr( + features, + "block_cloud_object_takeover_through_hard_match_enabled", + False, + ) + or False, ) ) except ODataError as error: @@ -1637,6 +1647,8 @@ class DirectorySyncSettings(BaseModel): id: str password_sync_enabled: bool = False seamless_sso_enabled: bool = False + block_soft_match_enabled: bool = False + block_cloud_object_takeover_through_hard_match_enabled: bool = False class AuthenticationMethodConfiguration(BaseModel): diff --git a/tests/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked_test.py b/tests/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked_test.py new file mode 100644 index 0000000000..6c8696da55 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked_test.py @@ -0,0 +1,339 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + DirectorySyncSettings, + Organization, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +CHECK_MODULE = ( + "prowler.providers.m365.services.entra." + "entra_directory_sync_object_takeover_blocked." + "entra_directory_sync_object_takeover_blocked" +) + + +def _hybrid_org(): + return Organization( + id="org-001", + name="Hybrid Org", + on_premises_sync_enabled=True, + ) + + +def _cloud_only_org(): + return Organization( + id="org-001", + name="Cloud Only Org", + on_premises_sync_enabled=False, + ) + + +class Test_entra_directory_sync_object_takeover_blocked: + def test_both_blocks_enabled(self): + """PASS when both soft-match and hard-match blocks are enabled.""" + entra_client = mock.MagicMock() + + 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_directory_sync_object_takeover_blocked.entra_directory_sync_object_takeover_blocked import ( + entra_directory_sync_object_takeover_blocked, + ) + + entra_client.directory_sync_settings = [ + DirectorySyncSettings( + id="sync-001", + block_soft_match_enabled=True, + block_cloud_object_takeover_through_hard_match_enabled=True, + ) + ] + entra_client.directory_sync_error = None + entra_client.organizations = [_hybrid_org()] + + check = entra_directory_sync_object_takeover_blocked() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "blocks both soft-match and hard-match" in result[0].status_extended + + def test_soft_match_disabled(self): + """FAIL when soft-match block is disabled on a hybrid tenant.""" + entra_client = mock.MagicMock() + + 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_directory_sync_object_takeover_blocked.entra_directory_sync_object_takeover_blocked import ( + entra_directory_sync_object_takeover_blocked, + ) + + entra_client.directory_sync_settings = [ + DirectorySyncSettings( + id="sync-001", + block_soft_match_enabled=False, + block_cloud_object_takeover_through_hard_match_enabled=True, + ) + ] + entra_client.directory_sync_error = None + entra_client.organizations = [_hybrid_org()] + + check = entra_directory_sync_object_takeover_blocked() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "blockSoftMatchEnabled" in result[0].status_extended + + def test_hard_match_disabled(self): + """FAIL when hard-match block is disabled on a hybrid tenant.""" + entra_client = mock.MagicMock() + + 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_directory_sync_object_takeover_blocked.entra_directory_sync_object_takeover_blocked import ( + entra_directory_sync_object_takeover_blocked, + ) + + entra_client.directory_sync_settings = [ + DirectorySyncSettings( + id="sync-001", + block_soft_match_enabled=True, + block_cloud_object_takeover_through_hard_match_enabled=False, + ) + ] + entra_client.directory_sync_error = None + entra_client.organizations = [_hybrid_org()] + + check = entra_directory_sync_object_takeover_blocked() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "blockCloudObjectTakeoverThroughHardMatchEnabled" + in result[0].status_extended + ) + + def test_both_blocks_disabled(self): + """FAIL when both blocks are disabled on a hybrid tenant.""" + entra_client = mock.MagicMock() + + 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_directory_sync_object_takeover_blocked.entra_directory_sync_object_takeover_blocked import ( + entra_directory_sync_object_takeover_blocked, + ) + + entra_client.directory_sync_settings = [ + DirectorySyncSettings( + id="sync-001", + block_soft_match_enabled=False, + block_cloud_object_takeover_through_hard_match_enabled=False, + ) + ] + entra_client.directory_sync_error = None + entra_client.organizations = [_hybrid_org()] + + check = entra_directory_sync_object_takeover_blocked() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "blockSoftMatchEnabled" in result[0].status_extended + assert ( + "blockCloudObjectTakeoverThroughHardMatchEnabled" + in result[0].status_extended + ) + + def test_cloud_only_tenant(self): + """PASS when tenant is cloud-only and no sync object is returned.""" + entra_client = mock.MagicMock() + + 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_directory_sync_object_takeover_blocked.entra_directory_sync_object_takeover_blocked import ( + entra_directory_sync_object_takeover_blocked, + ) + + entra_client.directory_sync_settings = [] + entra_client.directory_sync_error = None + entra_client.organizations = [_cloud_only_org()] + + check = entra_directory_sync_object_takeover_blocked() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "cloud-only" in result[0].status_extended + + def test_cloud_only_tenant_with_sync_object_returned(self): + """PASS for cloud-only tenants even when Graph returns a sync object. + + Microsoft Graph returns an onPremisesSynchronization object (with all + features disabled) for cloud-only tenants. The check must not treat the + disabled flags as a FAIL when on-premises sync is not enabled. + """ + entra_client = mock.MagicMock() + + 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_directory_sync_object_takeover_blocked.entra_directory_sync_object_takeover_blocked import ( + entra_directory_sync_object_takeover_blocked, + ) + + entra_client.directory_sync_settings = [ + DirectorySyncSettings( + id="tenant-id", + password_sync_enabled=False, + seamless_sso_enabled=False, + block_soft_match_enabled=False, + block_cloud_object_takeover_through_hard_match_enabled=False, + ) + ] + entra_client.directory_sync_error = None + entra_client.organizations = [_cloud_only_org()] + + check = entra_directory_sync_object_takeover_blocked() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "cloud-only" in result[0].status_extended + + def test_permission_error_hybrid(self): + """MANUAL when permissions are insufficient for a hybrid tenant.""" + entra_client = mock.MagicMock() + + 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_directory_sync_object_takeover_blocked.entra_directory_sync_object_takeover_blocked import ( + entra_directory_sync_object_takeover_blocked, + ) + + entra_client.directory_sync_settings = [] + entra_client.directory_sync_error = "Insufficient privileges" + entra_client.organizations = [_hybrid_org()] + + check = entra_directory_sync_object_takeover_blocked() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "Cannot verify" in result[0].status_extended + assert "Insufficient privileges" in result[0].status_extended + + def test_permission_error_cloud_only(self): + """PASS when settings cannot be read but the tenant is cloud-only.""" + entra_client = mock.MagicMock() + + 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_directory_sync_object_takeover_blocked.entra_directory_sync_object_takeover_blocked import ( + entra_directory_sync_object_takeover_blocked, + ) + + entra_client.directory_sync_settings = [] + entra_client.directory_sync_error = "Insufficient privileges" + entra_client.organizations = [_cloud_only_org()] + + check = entra_directory_sync_object_takeover_blocked() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "cloud-only" in result[0].status_extended + + def test_hybrid_no_settings_returned(self): + """MANUAL when a hybrid tenant returns no directory sync settings.""" + entra_client = mock.MagicMock() + + 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_directory_sync_object_takeover_blocked.entra_directory_sync_object_takeover_blocked import ( + entra_directory_sync_object_takeover_blocked, + ) + + entra_client.directory_sync_settings = [] + entra_client.directory_sync_error = None + entra_client.organizations = [_hybrid_org()] + + check = entra_directory_sync_object_takeover_blocked() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + "no directory sync settings were returned" in result[0].status_extended + )