feat(m365): add entra_seamless_sso_disabled security check (#10086)

Co-authored-by: HugoPBrito <hugopbrit@gmail.com>
Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
This commit is contained in:
Andoni Alonso
2026-02-19 18:19:07 +01:00
committed by GitHub
parent 48b94b2a9f
commit e8c0a37d50
9 changed files with 474 additions and 4 deletions

View File

@@ -41,6 +41,7 @@ When using service principal authentication, add these **Application Permissions
- `AuditLog.Read.All`: Required for Entra service.
- `Directory.Read.All`: Required for all services.
- `OnPremDirectorySynchronization.Read.All`: Required for `entra_seamless_sso_disabled` check (hybrid deployments).
- `Policy.Read.All`: Required for all services.
- `SecurityIdentitiesHealth.Read.All`: Required for `defenderidentity_health_issues_no_open` check.
- `SecurityIdentitiesSensors.Read.All`: Required for `defenderidentity_health_issues_no_open` check.
@@ -108,6 +109,7 @@ Browser and Azure CLI authentication methods limit scanning capabilities to chec
- `AuditLog.Read.All`: Required for Entra service
- `Directory.Read.All`: Required for all services
- `OnPremDirectorySynchronization.Read.All`: Required for `entra_seamless_sso_disabled` check (hybrid deployments)
- `Policy.Read.All`: Required for all services
- `SecurityIdentitiesHealth.Read.All`: Required for `defenderidentity_health_issues_no_open` check
- `SecurityIdentitiesSensors.Read.All`: Required for `defenderidentity_health_issues_no_open` check

View File

@@ -23,6 +23,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- CSA CCM 4.0 for the Alibaba Cloud provider [(#10061)](https://github.com/prowler-cloud/prowler/pull/10061)
- ECS Exec (ECS-006) privilege escalation detection via `ecs:ExecuteCommand` + `ecs:DescribeTasks` [(#10066)](https://github.com/prowler-cloud/prowler/pull/10066)
- `defenderxdr_endpoint_privileged_user_exposed_credentials` check for M365 provider [(#10084)](https://github.com/prowler-cloud/prowler/pull/10084)
- `entra_seamless_sso_disabled` check for m365 provider [(#10086)](https://github.com/prowler-cloud/prowler/pull/10086)
- Registry scan mode for `image` provider: enumerate and scan all images from OCI standard, Docker Hub, and ECR [(#9985)](https://github.com/prowler-cloud/prowler/pull/9985)
- Add file descriptor limits (`ulimits`) to Docker Compose worker services to prevent `Too many open files` errors [(#10107)](https://github.com/prowler-cloud/prowler/pull/10107)

View File

@@ -201,7 +201,8 @@
"admincenter_users_admins_reduced_license_footprint",
"entra_admin_portals_access_restriction",
"entra_admin_users_phishing_resistant_mfa_enabled",
"entra_policy_guest_users_access_restrictions"
"entra_policy_guest_users_access_restrictions",
"entra_seamless_sso_disabled"
]
},
{
@@ -217,7 +218,8 @@
}
],
"Checks": [
"admincenter_settings_password_never_expire"
"admincenter_settings_password_never_expire",
"entra_seamless_sso_disabled"
]
},
{
@@ -239,6 +241,7 @@
"entra_admin_users_sign_in_frequency_enabled",
"entra_legacy_authentication_blocked",
"entra_managed_device_required_for_authentication",
"entra_seamless_sso_disabled",
"entra_users_mfa_enabled",
"exchange_organization_modern_authentication_enabled",
"exchange_transport_config_smtp_auth_disabled",
@@ -643,7 +646,8 @@
"entra_admin_users_sign_in_frequency_enabled",
"entra_app_registration_no_unused_privileged_permissions",
"entra_policy_ensure_default_user_cannot_create_tenants",
"entra_policy_guest_invite_only_for_admin_roles"
"entra_policy_guest_invite_only_for_admin_roles",
"entra_seamless_sso_disabled"
]
},
{
@@ -680,6 +684,7 @@
"entra_admin_users_sign_in_frequency_enabled",
"entra_admin_users_mfa_enabled",
"entra_managed_device_required_for_authentication",
"entra_seamless_sso_disabled",
"entra_users_mfa_enabled",
"entra_identity_protection_sign_in_risk_enabled"
]

View File

@@ -1148,7 +1148,8 @@
"Id": "4.1.2",
"Description": "Ensure that password hash sync is enabled for hybrid deployments",
"Checks": [
"entra_password_hash_sync_enabled"
"entra_password_hash_sync_enabled",
"entra_seamless_sso_disabled"
],
"Attributes": [
{

View File

@@ -0,0 +1,38 @@
{
"Provider": "m365",
"CheckID": "entra_seamless_sso_disabled",
"CheckTitle": "Entra hybrid deployment does not have Seamless SSO enabled",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "Directory Sync Settings",
"ResourceGroup": "IAM",
"Description": "**Seamless Single Sign-On (SSO)** in hybrid Microsoft Entra deployments allows automatic authentication for domain-joined devices on the corporate network.\n\nThis check verifies the actual Seamless SSO configuration in directory synchronization settings. Modern devices with **Primary Refresh Token** (PRT) support no longer require Seamless SSO.",
"Risk": "Seamless SSO can be exploited for **lateral movement** between on-premises domains and Entra ID when an Entra Connect server is compromised. It can also be used to perform **brute force attacks** against Entra ID, as authentication through the AZUREADSSOACC account bypasses standard protections.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/entra/identity/hybrid/connect/how-to-connect-sso"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Open Microsoft Entra Connect configuration tool on the on-premises server.\n2. Navigate to **Change User Sign In**.\n3. Uncheck **Enable single sign-on**.\n4. Complete the configuration wizard.\n5. In Active Directory, run `Get-AzureADSSOStatus` to verify Seamless SSO shows `\"enable\":false`.\n6. Run `Disable-AzureADSSOForest` with domain admin credentials to remove the AZUREADSSOACC account.",
"Terraform": ""
},
"Recommendation": {
"Text": "Disable **Seamless SSO** in hybrid environments where modern devices support *Primary Refresh Token (PRT)*. Regularly audit Entra Connect settings and verify that the AZUREADSSOACC computer account is removed from Active Directory.",
"Url": "https://hub.prowler.com/check/entra_seamless_sso_disabled"
}
},
"Categories": [
"e3"
],
"DependsOn": [],
"RelatedTo": [
"entra_password_hash_sync_enabled"
],
"Notes": "Applies only to hybrid Microsoft Entra deployments using Entra Connect sync. The check reads the seamless_sso_enabled flag from the directory on-premises synchronization settings via Microsoft Graph API."
}

View File

@@ -0,0 +1,82 @@
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_seamless_sso_disabled(Check):
"""Check that Seamless Single Sign-On (SSO) is disabled for Microsoft Entra hybrid deployments.
Seamless SSO allows users to sign in without typing their passwords when on
corporate devices connected to the corporate network. When an Entra Connect server
is compromised, Seamless SSO can enable lateral movement between on-premises domains
and Entra ID, and it can also be exploited for brute force attacks. Modern devices with
Primary Refresh Token (PRT) support make this feature unnecessary for most organizations.
- PASS: Seamless SSO is disabled or on-premises sync is not enabled (cloud-only).
- FAIL: Seamless SSO is enabled in a hybrid deployment, or cannot verify due to insufficient permissions.
"""
def execute(self) -> List[CheckReportM365]:
"""Execute the Seamless SSO disabled check.
Checks the directory sync settings to determine if Seamless SSO is enabled.
For hybrid environments, this check verifies the actual Seamless SSO configuration
rather than inferring from on-premises sync status.
Returns:
A list of CheckReportM365 objects with the result of the check.
"""
findings = []
# Check if there was an error retrieving directory sync settings
if entra_client.directory_sync_error:
for organization in entra_client.organizations:
report = CheckReportM365(
self.metadata(),
resource=organization,
resource_id=organization.id,
resource_name=organization.name,
)
# Only FAIL for hybrid orgs; cloud-only orgs don't need this permission
if organization.on_premises_sync_enabled:
report.status = "FAIL"
report.status_extended = f"Cannot verify Seamless SSO status for {organization.name}: {entra_client.directory_sync_error}."
else:
report.status = "PASS"
report.status_extended = f"Entra organization {organization.name} is cloud-only (no on-premises sync), Seamless SSO is not applicable."
findings.append(report)
return findings
# Process directory sync settings if available
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}",
)
if sync_settings.seamless_sso_enabled:
report.status = "FAIL"
report.status_extended = f"Entra directory sync {sync_settings.id} has Seamless SSO enabled, which can be exploited for lateral movement and brute force attacks."
else:
report.status = "PASS"
report.status_extended = f"Entra directory sync {sync_settings.id} has Seamless SSO disabled."
findings.append(report)
# If no directory sync settings and no error, it's a cloud-only tenant
if not entra_client.directory_sync_settings:
for organization in entra_client.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), Seamless SSO is not applicable."
findings.append(report)
return findings

View File

@@ -5,6 +5,7 @@ from enum import Enum
from typing import Dict, List, Optional
from uuid import UUID
from msgraph.generated.models.o_data_errors.o_data_error import ODataError
from msgraph.generated.security.microsoft_graph_security_run_hunting_query.run_hunting_query_post_request_body import (
RunHuntingQueryPostRequestBody,
)
@@ -78,6 +79,7 @@ class Entra(M365Service):
self._get_organization(),
self._get_users(),
self._get_oauth_apps(),
self._get_directory_sync_settings(),
)
)
@@ -88,6 +90,7 @@ class Entra(M365Service):
self.organizations = attributes[4]
self.users = attributes[5]
self.oauth_apps: Optional[Dict[str, OAuthApp]] = attributes[6]
self.directory_sync_settings, self.directory_sync_error = attributes[7]
self.user_accounts_status = {}
if created_loop:
@@ -411,6 +414,57 @@ class Entra(M365Service):
return organizations
async def _get_directory_sync_settings(self):
"""Retrieve on-premises directory synchronization settings.
Fetches the directory synchronization configuration from Microsoft Graph API
to determine the state of synchronization features such as password sync,
device writeback, and other hybrid identity settings.
Returns:
A tuple containing:
- A list of DirectorySyncSettings objects, or an empty list if retrieval fails.
- An error message string if there was an access error, None otherwise.
"""
logger.info("Entra - Getting directory sync settings...")
directory_sync_settings = []
error_message = None
try:
sync_data = await self.client.directory.on_premises_synchronization.get()
for sync in getattr(sync_data, "value", []) or []:
features = getattr(sync, "features", None)
directory_sync_settings.append(
DirectorySyncSettings(
id=sync.id,
password_sync_enabled=getattr(
features, "password_sync_enabled", False
)
or False,
seamless_sso_enabled=getattr(
features, "seamless_sso_enabled", False
)
or False,
)
)
except ODataError as error:
error_code = getattr(error.error, "code", None) if error.error else None
if error_code == "Authorization_RequestDenied":
error_message = "Insufficient privileges to read directory sync settings. Required permission: OnPremDirectorySynchronization.Read.All or OnPremDirectorySynchronization.ReadWrite.All"
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error_message}"
)
else:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
error_message = str(error)
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
error_message = str(error)
return directory_sync_settings, error_message
async def _get_users(self):
logger.info("Entra - Getting users...")
users = {}
@@ -747,6 +801,19 @@ class Organization(BaseModel):
on_premises_sync_enabled: bool
class DirectorySyncSettings(BaseModel):
"""On-premises directory synchronization settings.
Represents the synchronization configuration for a tenant, including feature
flags that control hybrid identity behaviors such as password synchronization
and Seamless SSO.
"""
id: str
password_sync_enabled: bool = False
seamless_sso_enabled: bool = False
class Group(BaseModel):
id: str
name: str

View File

@@ -0,0 +1,274 @@
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
class Test_entra_seamless_sso_disabled:
def test_seamless_sso_disabled(self):
"""Test PASS when Seamless SSO is disabled in 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(
"prowler.providers.m365.services.entra.entra_seamless_sso_disabled.entra_seamless_sso_disabled.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_seamless_sso_disabled.entra_seamless_sso_disabled import (
entra_seamless_sso_disabled,
)
sync_settings = DirectorySyncSettings(
id="sync-001",
password_sync_enabled=True,
seamless_sso_enabled=False,
)
entra_client.directory_sync_settings = [sync_settings]
entra_client.directory_sync_error = None
entra_client.organizations = []
check = entra_seamless_sso_disabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Entra directory sync sync-001 has Seamless SSO disabled."
)
assert result[0].resource_id == "sync-001"
assert result[0].resource_name == "Directory Sync sync-001"
assert result[0].location == "global"
def test_seamless_sso_enabled(self):
"""Test FAIL when Seamless SSO is enabled in 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(
"prowler.providers.m365.services.entra.entra_seamless_sso_disabled.entra_seamless_sso_disabled.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_seamless_sso_disabled.entra_seamless_sso_disabled import (
entra_seamless_sso_disabled,
)
sync_settings = DirectorySyncSettings(
id="sync-001",
password_sync_enabled=True,
seamless_sso_enabled=True,
)
entra_client.directory_sync_settings = [sync_settings]
entra_client.directory_sync_error = None
entra_client.organizations = []
check = entra_seamless_sso_disabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Entra directory sync sync-001 has Seamless SSO enabled, which can be exploited for lateral movement and brute force attacks."
)
assert result[0].resource_id == "sync-001"
assert result[0].resource_name == "Directory Sync sync-001"
assert result[0].location == "global"
def test_multiple_sync_settings_mixed(self):
"""Test mixed results with multiple directory sync configurations."""
entra_client = mock.MagicMock()
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_seamless_sso_disabled.entra_seamless_sso_disabled.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_seamless_sso_disabled.entra_seamless_sso_disabled import (
entra_seamless_sso_disabled,
)
sync_settings_1 = DirectorySyncSettings(
id="sync-001",
password_sync_enabled=True,
seamless_sso_enabled=True,
)
sync_settings_2 = DirectorySyncSettings(
id="sync-002",
password_sync_enabled=True,
seamless_sso_enabled=False,
)
entra_client.directory_sync_settings = [sync_settings_1, sync_settings_2]
entra_client.directory_sync_error = None
entra_client.organizations = []
check = entra_seamless_sso_disabled()
result = check.execute()
assert len(result) == 2
assert result[0].status == "FAIL"
assert result[0].resource_id == "sync-001"
assert result[1].status == "PASS"
assert result[1].resource_id == "sync-002"
def test_cloud_only_no_sync_settings(self):
"""Test PASS for cloud-only tenant with 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(
"prowler.providers.m365.services.entra.entra_seamless_sso_disabled.entra_seamless_sso_disabled.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_seamless_sso_disabled.entra_seamless_sso_disabled import (
entra_seamless_sso_disabled,
)
org = Organization(
id="org1",
name="Cloud Only Org",
on_premises_sync_enabled=False,
)
entra_client.directory_sync_settings = []
entra_client.directory_sync_error = None
entra_client.organizations = [org]
check = entra_seamless_sso_disabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Entra organization Cloud Only Org is cloud-only (no on-premises sync), Seamless SSO is not applicable."
)
assert result[0].resource_id == "org1"
assert result[0].resource_name == "Cloud Only Org"
def test_insufficient_permissions_error(self):
"""Test FAIL when there's a permission error reading 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(
"prowler.providers.m365.services.entra.entra_seamless_sso_disabled.entra_seamless_sso_disabled.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_seamless_sso_disabled.entra_seamless_sso_disabled import (
entra_seamless_sso_disabled,
)
org = Organization(
id="org1",
name="Prowler Org",
on_premises_sync_enabled=True,
)
entra_client.directory_sync_settings = []
entra_client.directory_sync_error = "Insufficient privileges to read directory sync settings. Required permission: OnPremDirectorySynchronization.Read.All or OnPremDirectorySynchronization.ReadWrite.All"
entra_client.organizations = [org]
check = entra_seamless_sso_disabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "Cannot verify Seamless SSO status" in result[0].status_extended
assert "Insufficient privileges" in result[0].status_extended
assert (
"OnPremDirectorySynchronization.Read.All" in result[0].status_extended
)
assert result[0].resource_id == "org1"
assert result[0].resource_name == "Prowler Org"
def test_insufficient_permissions_cloud_only_passes(self):
"""Test PASS for cloud-only org even when there's a permission error."""
entra_client = mock.MagicMock()
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_seamless_sso_disabled.entra_seamless_sso_disabled.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_seamless_sso_disabled.entra_seamless_sso_disabled import (
entra_seamless_sso_disabled,
)
# Cloud-only org (on_premises_sync_enabled=False)
org = Organization(
id="org1",
name="Cloud Only Org",
on_premises_sync_enabled=False,
)
entra_client.directory_sync_settings = []
entra_client.directory_sync_error = (
"Insufficient privileges to read directory sync settings."
)
entra_client.organizations = [org]
check = entra_seamless_sso_disabled()
result = check.execute()
# Should PASS because cloud-only orgs don't need this permission
assert len(result) == 1
assert result[0].status == "PASS"
assert "cloud-only" in result[0].status_extended
assert result[0].resource_id == "org1"
def test_empty_everything(self):
"""Test no findings when both sync settings and organizations are empty."""
entra_client = mock.MagicMock()
entra_client.directory_sync_settings = []
entra_client.directory_sync_error = None
entra_client.organizations = []
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_seamless_sso_disabled.entra_seamless_sso_disabled.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_seamless_sso_disabled.entra_seamless_sso_disabled import (
entra_seamless_sso_disabled,
)
check = entra_seamless_sso_disabled()
result = check.execute()
assert len(result) == 0