feat(m365): add entra_authentication_method_sms_voice_disabled security check (#10212)

This commit is contained in:
Hugo Pereira Brito
2026-03-03 13:08:02 +01:00
committed by GitHub
parent 012fd84cb0
commit 548a137046
6 changed files with 383 additions and 0 deletions

View File

@@ -6,6 +6,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
### 🚀 Added
- `entra_authentication_method_sms_voice_disabled` check for m365 provider [(#10212)](https://github.com/prowler-cloud/prowler/pull/10212)
- `Google Workspace` provider support with Directory service including 1 security check [(#10022)](https://github.com/prowler-cloud/prowler/pull/10022)
- `entra_conditional_access_policy_app_enforced_restrictions` check for M365 provider [(#10058)](https://github.com/prowler-cloud/prowler/pull/10058)
- `entra_app_registration_no_unused_privileged_permissions` check for M365 provider [(#10080)](https://github.com/prowler-cloud/prowler/pull/10080)

View File

@@ -0,0 +1,37 @@
{
"Provider": "m365",
"CheckID": "entra_authentication_method_sms_voice_disabled",
"CheckTitle": "SMS and Voice authentication methods are disabled in the tenant",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "SMS and Voice authentication methods should be disabled in the tenant's authentication methods policy. These methods are vulnerable to **SIM-swapping**, **interception**, and **social engineering** attacks, and are deprecated by NIST SP 800-63B as out-of-band authenticators.",
"Risk": "Enabled SMS or Voice authentication allows attackers to bypass MFA through **SIM-swapping** or **SS7 protocol interception**, gaining unauthorized access to accounts. These methods lack cryptographic binding to the device, making them significantly weaker than phishing-resistant alternatives.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/entra/identity/authentication/concept-authentication-phone-options",
"https://pages.nist.gov/800-63-3/sp800-63b.html"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Protection** > **Authentication methods** > **Policies**\n3. Select **SMS** and set its status to **Disabled**, then click **Save**\n4. Select **Voice call** and set its status to **Disabled**, then click **Save**\n5. Ensure users have alternative phishing-resistant MFA methods configured (e.g., FIDO2, Microsoft Authenticator)",
"Terraform": ""
},
"Recommendation": {
"Text": "Disable SMS and Voice authentication methods and adopt **phishing-resistant** alternatives such as FIDO2 security keys or Microsoft Authenticator. Use Authentication Strengths in Conditional Access policies to enforce only strong MFA methods across the tenant.",
"Url": "https://hub.prowler.com/check/entra_authentication_method_sms_voice_disabled"
}
},
"Categories": [
"identity-access"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}

View File

@@ -0,0 +1,69 @@
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_authentication_method_sms_voice_disabled(Check):
"""
Ensure that SMS and Voice authentication methods are disabled in Microsoft Entra.
This check verifies that the tenant's authentication methods policy has both SMS and
Voice methods disabled, as they are vulnerable to SIM-swapping, interception, and
social engineering attacks. NIST SP 800-63B deprecates SMS as an out-of-band
authenticator.
- PASS: Both SMS and Voice authentication methods are disabled.
- FAIL: SMS and/or Voice authentication methods are enabled.
"""
def execute(self) -> List[CheckReportM365]:
"""Execute the SMS and Voice authentication method check.
Evaluates the authentication method configurations from the Entra client
and checks whether both SMS and Voice methods are disabled.
Returns:
A list with a single report containing the result of the check.
"""
findings = []
configs = entra_client.authentication_method_configurations
sms_config = configs.get("Sms")
voice_config = configs.get("Voice")
if sms_config or voice_config:
sms_enabled = sms_config and sms_config.state == "enabled"
voice_enabled = voice_config and voice_config.state == "enabled"
report = CheckReportM365(
metadata=self.metadata(),
resource=sms_config or voice_config,
resource_name="SMS and Voice Authentication Methods",
resource_id=entra_client.tenant_domain,
)
if sms_enabled and voice_enabled:
report.status = "FAIL"
report.status_extended = (
"SMS and Voice authentication methods are enabled in the tenant."
)
elif sms_enabled:
report.status = "FAIL"
report.status_extended = (
"SMS authentication method is enabled in the tenant."
)
elif voice_enabled:
report.status = "FAIL"
report.status_extended = (
"Voice authentication method is enabled in the tenant."
)
else:
report.status = "PASS"
report.status_extended = (
"SMS and Voice authentication methods are disabled in the tenant."
)
findings.append(report)
return findings

View File

@@ -35,6 +35,7 @@ class Entra(M365Service):
users (dict): Dictionary of users.
user_accounts_status (dict): Dictionary of user account statuses.
oauth_apps (dict): Dictionary of OAuth applications from Defender XDR.
authentication_method_configurations (dict): Dictionary of authentication method configurations.
"""
def __init__(self, provider: M365Provider):
@@ -81,6 +82,7 @@ class Entra(M365Service):
self._get_default_app_management_policy(),
self._get_oauth_apps(),
self._get_directory_sync_settings(),
self._get_authentication_method_configurations(),
)
)
@@ -93,6 +95,9 @@ class Entra(M365Service):
self.default_app_management_policy = attributes[6]
self.oauth_apps: Optional[Dict[str, OAuthApp]] = attributes[7]
self.directory_sync_settings, self.directory_sync_error = attributes[8]
self.authentication_method_configurations: Dict[
str, AuthenticationMethodConfiguration
] = attributes[9]
self.user_accounts_status = {}
if created_loop:
@@ -756,6 +761,41 @@ OAuthAppInfo
return oauth_apps
async def _get_authentication_method_configurations(self):
"""Retrieve authentication method configurations from Microsoft Entra.
Fetches the authentication methods policy and extracts the configuration
state for each authentication method (e.g., SMS, Voice, FIDO2, etc.).
Returns:
Dict[str, AuthenticationMethodConfiguration]: Dictionary of authentication
method configurations keyed by method ID (e.g., 'sms', 'voice').
"""
logger.info("Entra - Getting authentication method configurations...")
authentication_method_configurations = {}
try:
policy = await self.client.policies.authentication_methods_policy.get()
for config in (
getattr(policy, "authentication_method_configurations", []) or []
):
method_id = getattr(config, "id", "")
if method_id:
authentication_method_configurations[method_id] = (
AuthenticationMethodConfiguration(
id=method_id,
state=(
getattr(config, "state", None).value
if getattr(config, "state", None)
else "disabled"
),
)
)
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return authentication_method_configurations
class ConditionalAccessPolicyState(Enum):
ENABLED = "enabled"
@@ -916,6 +956,21 @@ class DirectorySyncSettings(BaseModel):
seamless_sso_enabled: bool = False
class AuthenticationMethodConfiguration(BaseModel):
"""Authentication method configuration from the authentication methods policy.
Represents the state of a specific authentication method (e.g., SMS, Voice,
FIDO2) within the tenant's authentication methods policy.
Attributes:
id: The authentication method identifier (e.g., 'sms', 'voice').
state: The state of the authentication method ('enabled' or 'disabled').
"""
id: str
state: str = "disabled"
class Group(BaseModel):
id: str
name: str

View File

@@ -0,0 +1,221 @@
from unittest import mock
from prowler.providers.m365.services.entra.entra_service import (
AuthenticationMethodConfiguration,
)
from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
class Test_entra_authentication_method_sms_voice_disabled:
def test_no_configurations(self):
"""
Test when authentication_method_configurations is empty:
The check should return an empty list of findings.
"""
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_authentication_method_sms_voice_disabled.entra_authentication_method_sms_voice_disabled.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_authentication_method_sms_voice_disabled.entra_authentication_method_sms_voice_disabled import (
entra_authentication_method_sms_voice_disabled,
)
entra_client.authentication_method_configurations = {}
entra_client.tenant_domain = DOMAIN
check = entra_authentication_method_sms_voice_disabled()
result = check.execute()
assert len(result) == 0
def test_both_disabled(self):
"""
Test when both SMS and Voice are disabled:
The check should return a single PASS finding.
"""
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_authentication_method_sms_voice_disabled.entra_authentication_method_sms_voice_disabled.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_authentication_method_sms_voice_disabled.entra_authentication_method_sms_voice_disabled import (
entra_authentication_method_sms_voice_disabled,
)
entra_client.authentication_method_configurations = {
"Sms": AuthenticationMethodConfiguration(
id="Sms",
state="disabled",
),
"Voice": AuthenticationMethodConfiguration(
id="Voice",
state="disabled",
),
}
entra_client.tenant_domain = DOMAIN
check = entra_authentication_method_sms_voice_disabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "SMS and Voice authentication methods are disabled in the tenant."
)
assert result[0].resource_id == DOMAIN
assert result[0].resource_name == "SMS and Voice Authentication Methods"
assert result[0].location == "global"
def test_both_enabled(self):
"""
Test when both SMS and Voice are enabled:
The check should return a single FAIL finding.
"""
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_authentication_method_sms_voice_disabled.entra_authentication_method_sms_voice_disabled.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_authentication_method_sms_voice_disabled.entra_authentication_method_sms_voice_disabled import (
entra_authentication_method_sms_voice_disabled,
)
entra_client.authentication_method_configurations = {
"Sms": AuthenticationMethodConfiguration(
id="Sms",
state="enabled",
),
"Voice": AuthenticationMethodConfiguration(
id="Voice",
state="enabled",
),
}
entra_client.tenant_domain = DOMAIN
check = entra_authentication_method_sms_voice_disabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "SMS and Voice authentication methods are enabled in the tenant."
)
assert result[0].resource_id == DOMAIN
assert result[0].resource_name == "SMS and Voice Authentication Methods"
assert result[0].location == "global"
def test_sms_enabled_voice_disabled(self):
"""
Test when SMS is enabled and Voice is disabled:
The check should return a single FAIL finding.
"""
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_authentication_method_sms_voice_disabled.entra_authentication_method_sms_voice_disabled.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_authentication_method_sms_voice_disabled.entra_authentication_method_sms_voice_disabled import (
entra_authentication_method_sms_voice_disabled,
)
entra_client.authentication_method_configurations = {
"Sms": AuthenticationMethodConfiguration(
id="Sms",
state="enabled",
),
"Voice": AuthenticationMethodConfiguration(
id="Voice",
state="disabled",
),
}
entra_client.tenant_domain = DOMAIN
check = entra_authentication_method_sms_voice_disabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "SMS authentication method is enabled in the tenant."
)
assert result[0].resource_id == DOMAIN
assert result[0].resource_name == "SMS and Voice Authentication Methods"
assert result[0].location == "global"
def test_sms_disabled_voice_enabled(self):
"""
Test when SMS is disabled and Voice is enabled:
The check should return a single FAIL finding.
"""
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_authentication_method_sms_voice_disabled.entra_authentication_method_sms_voice_disabled.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_authentication_method_sms_voice_disabled.entra_authentication_method_sms_voice_disabled import (
entra_authentication_method_sms_voice_disabled,
)
entra_client.authentication_method_configurations = {
"Sms": AuthenticationMethodConfiguration(
id="Sms",
state="disabled",
),
"Voice": AuthenticationMethodConfiguration(
id="Voice",
state="enabled",
),
}
entra_client.tenant_domain = DOMAIN
check = entra_authentication_method_sms_voice_disabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Voice authentication method is enabled in the tenant."
)
assert result[0].resource_id == DOMAIN
assert result[0].resource_name == "SMS and Voice Authentication Methods"
assert result[0].location == "global"