feat(entra): add new check entra_users_mfa_capable (#7734)

Co-authored-by: Andoni Alonso <14891798+andoniaf@users.noreply.github.com>
This commit is contained in:
Hugo Pereira Brito
2025-05-21 10:31:56 +02:00
committed by GitHub
parent 2a61610fec
commit 4e84507130
9 changed files with 246 additions and 1 deletions

View File

@@ -247,6 +247,7 @@ Prowler for M365 requires two types of permission scopes to be set (if you want
- `User.Read` (IMPORTANT: this must be set as **delegated**): Required for the sign-in.
- `Sites.Read.All`: Required for SharePoint service.
- `SharePointTenantSettings.Read.All`: Required for SharePoint service.
- `AuditLog.Read.All`: Required for Entra service.
- **Powershell Modules Permissions**: These are set at the `M365_USER` level, so the user used to run Prowler must have one of the following roles:
- `Global Reader` (recommended): this allows you to read all roles needed.

View File

@@ -8,6 +8,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Update the compliance list supported for each provider from docs. [(#7694)](https://github.com/prowler-cloud/prowler/pull/7694)
- Allow setting cluster name in in-cluster mode in Kubernetes. [(#7695)](https://github.com/prowler-cloud/prowler/pull/7695)
- Add Prowler ThreatScore for M365 provider. [(#7692)](https://github.com/prowler-cloud/prowler/pull/7692)
- Add new check `entra_users_mfa_capable`. [(#7734)](https://github.com/prowler-cloud/prowler/pull/7734)
- Add new check `admincenter_organization_customer_lockbox_enabled`. [(#7732)](https://github.com/prowler-cloud/prowler/pull/7732)
- Add new check `admincenter_external_calendar_sharing_disabled`. [(#7733)](https://github.com/prowler-cloud/prowler/pull/7733)
- Add GitHub provider. [(#5787)](https://github.com/prowler-cloud/prowler/pull/5787)

View File

@@ -1421,7 +1421,9 @@
{
"Id": "5.2.3.4",
"Description": "Microsoft defines Multifactor authentication capable as being registered and enabled for a strong authentication method. The method must also be allowed by the authentication methods policy.Ensure all member users are `MFA capable`.",
"Checks": [],
"Checks": [
"entra_users_mfa_capable"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",

View File

@@ -377,6 +377,19 @@ class Entra(M365Service):
for member in members:
user_roles_map.setdefault(member.id, []).append(role_template_id)
try:
registration_details_list = (
await self.client.reports.authentication_methods.user_registration_details.get()
)
registration_details = {
detail.id: detail for detail in registration_details_list.value
}
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
registration_details = {}
for user in users_list.value:
users[user.id] = User(
id=user.id,
@@ -385,6 +398,11 @@ class Entra(M365Service):
True if (user.on_premises_sync_enabled) else False
),
directory_roles_ids=user_roles_map.get(user.id, []),
is_mfa_capable=(
registration_details.get(user.id, {}).is_mfa_capable
if registration_details.get(user.id, None) is not None
else False
),
)
except Exception as error:
logger.error(
@@ -563,6 +581,7 @@ class User(BaseModel):
name: str
on_premises_sync_enabled: bool
directory_roles_ids: List[str] = []
is_mfa_capable: bool = False
class InvitationsFrom(Enum):

View File

@@ -0,0 +1,32 @@
{
"Provider": "m365",
"CheckID": "entra_users_mfa_capable",
"CheckTitle": "Ensure all users are MFA capable",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "critical",
"ResourceType": "Conditional Access Policy",
"Description": "Ensure all users are being registered and enabled for multifactor authentication.",
"Risk": "Users who are not MFA capable are more vulnerable to account compromise, as they may rely solely on single-factor authentication (typically a password), which can be easily phished or cracked.",
"RelatedUrl": "https://learn.microsoft.com/en-us/entra/identity/authentication/concept-mfa-howitworks",
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "Remediation steps will depend on the status of the personnel in question or configuration of Conditional Access policies. Administrators should review each user identified on a case-by-case basis.",
"Terraform": ""
},
"Recommendation": {
"Text": "Ensure all member users are MFA capable by registering and enabling a strong authentication method that complies with the organization's authentication policy. Regularly review user status to detect gaps in MFA deployment and correct misconfigurations.",
"Url": "https://learn.microsoft.com/en-us/entra/identity/authentication/concept-mfa-howitworks"
}
},
"Categories": [
"e3"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}

View File

@@ -0,0 +1,45 @@
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_users_mfa_capable(Check):
"""
Ensure all users are MFA capable.
This check verifies if users are MFA capable.
The check fails if any user is not MFA capable.
"""
def execute(self) -> List[CheckReportM365]:
"""
Execute the admin MFA capable check for all users.
Iterates over the users retrieved from the Entra client and generates a report
indicating if users are MFA capable.
Returns:
List[CheckReportM365]: A list containing a single report with the result of the check.
"""
findings = []
for user in entra_client.users.values():
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name="Users",
resource_id="users",
)
if not user.is_mfa_capable:
report.status = "FAIL"
report.status_extended = f"User {user.name} is not MFA capable."
else:
report.status = "PASS"
report.status_extended = f"User {user.name} is MFA capable."
findings.append(report)
return findings

View File

@@ -0,0 +1,139 @@
from unittest import mock
from uuid import uuid4
from prowler.providers.m365.services.entra.entra_service import User
from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
class Test_entra_users_mfa_capable:
def test_user_not_mfa_capable(self):
"""User is not MFA capable: expected FAIL."""
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_users_mfa_capable.entra_users_mfa_capable.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_users_mfa_capable.entra_users_mfa_capable import (
entra_users_mfa_capable,
)
user_id = str(uuid4())
entra_client.users = {
user_id: User(
id=user_id,
name="Test User",
on_premises_sync_enabled=False,
directory_roles_ids=[],
is_mfa_capable=False,
)
}
check = entra_users_mfa_capable()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].status_extended == "User Test User is not MFA capable."
assert result[0].resource == {}
assert result[0].resource_name == "Users"
assert result[0].resource_id == "users"
def test_user_mfa_capable(self):
"""User is MFA capable: expected PASS."""
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_users_mfa_capable.entra_users_mfa_capable.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_users_mfa_capable.entra_users_mfa_capable import (
entra_users_mfa_capable,
)
user_id = str(uuid4())
entra_client.users = {
user_id: User(
id=user_id,
name="Test User",
on_premises_sync_enabled=False,
directory_roles_ids=[],
is_mfa_capable=True,
)
}
check = entra_users_mfa_capable()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].status_extended == "User Test User is MFA capable."
assert result[0].resource == {}
assert result[0].resource_name == "Users"
assert result[0].resource_id == "users"
def test_multiple_users(self):
"""Multiple users with different MFA capabilities: expected mixed results."""
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_users_mfa_capable.entra_users_mfa_capable.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_users_mfa_capable.entra_users_mfa_capable import (
entra_users_mfa_capable,
)
user1_id = str(uuid4())
user2_id = str(uuid4())
entra_client.users = {
user1_id: User(
id=user1_id,
name="Test User 1",
on_premises_sync_enabled=False,
directory_roles_ids=[],
is_mfa_capable=True,
),
user2_id: User(
id=user2_id,
name="Test User 2",
on_premises_sync_enabled=False,
directory_roles_ids=[],
is_mfa_capable=False,
),
}
check = entra_users_mfa_capable()
result = check.execute()
assert len(result) == 2
# First user (MFA capable)
assert result[0].status == "PASS"
assert result[0].status_extended == "User Test User 1 is MFA capable."
# Second user (not MFA capable)
assert result[1].status == "FAIL"
assert result[1].status_extended == "User Test User 2 is not MFA capable."

View File

@@ -121,18 +121,21 @@ async def mock_entra_get_users(_):
name="User 1",
directory_roles_ids=[AdminRoles.GLOBAL_ADMINISTRATOR.value],
on_premises_sync_enabled=True,
is_mfa_capable=True,
),
"user-2": User(
id="user-2",
name="User 2",
directory_roles_ids=[AdminRoles.GLOBAL_ADMINISTRATOR.value],
on_premises_sync_enabled=False,
is_mfa_capable=False,
),
"user-3": User(
id="user-3",
name="User 3",
directory_roles_ids=[AdminRoles.GLOBAL_ADMINISTRATOR.value],
on_premises_sync_enabled=True,
is_mfa_capable=False,
),
}
@@ -278,12 +281,14 @@ class Test_Entra_Service:
assert entra_client.users["user-1"].directory_roles_ids == [
AdminRoles.GLOBAL_ADMINISTRATOR.value
]
assert entra_client.users["user-1"].is_mfa_capable
assert entra_client.users["user-1"].on_premises_sync_enabled
assert entra_client.users["user-2"].id == "user-2"
assert entra_client.users["user-2"].name == "User 2"
assert entra_client.users["user-2"].directory_roles_ids == [
AdminRoles.GLOBAL_ADMINISTRATOR.value
]
assert not entra_client.users["user-2"].is_mfa_capable
assert not entra_client.users["user-2"].on_premises_sync_enabled
assert entra_client.users["user-3"].id == "user-3"
assert entra_client.users["user-3"].name == "User 3"
@@ -291,3 +296,4 @@ class Test_Entra_Service:
AdminRoles.GLOBAL_ADMINISTRATOR.value
]
assert entra_client.users["user-3"].on_premises_sync_enabled
assert not entra_client.users["user-3"].is_mfa_capable