feat(m365): add check for directory sync object takeover protection (#11098)

Co-authored-by: shadyfox <git@twink.energy>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
Co-authored-by: omobolaji adeyan <omobolaji.adeyan@gmail.com>
This commit is contained in:
PrettyFox0
2026-06-17 12:15:14 +02:00
committed by GitHub
co-authored by shadyfox Claude Opus 4.6 Daniel Barranquero omobolaji adeyan
parent 0463cd1559
commit 8d4ec561c2
6 changed files with 512 additions and 0 deletions
+1
View File
@@ -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
@@ -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."
}
@@ -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
@@ -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):
@@ -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
)