fix(m365): skip future hires in MFA capable check (#11511)

This commit is contained in:
Hugo Pereira Brito
2026-06-09 15:42:06 +02:00
committed by GitHub
parent d9f90e50b8
commit b3caee88e4
5 changed files with 150 additions and 1 deletions
+1
View File
@@ -21,6 +21,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
### 🐞 Fixed
- `load_and_validate_config_file` now unwraps namespaced config for every built-in and external provider, and no longer leaks the full file as the provider's config when the file is namespaced [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700)
- `entra_users_mfa_capable` no longer flags pre-provisioned users with future `employeeHireDate`; future-hire date comparisons now tolerate naive datetimes [(#11511)](https://github.com/prowler-cloud/prowler/pull/11511)
- M365 Admin Center group enumeration now follows Microsoft Graph pagination so group-scoped checks include groups beyond the first page [(#11510)](https://github.com/prowler-cloud/prowler/pull/11510)
---
@@ -830,6 +830,7 @@ class Entra(M365Service):
"userType",
"accountEnabled",
"onPremisesSyncEnabled",
"employeeHireDate",
],
)
)
@@ -890,6 +891,7 @@ class Entra(M365Service):
"authentication_methods", []
),
user_type=getattr(user, "user_type", None),
employee_hire_date=getattr(user, "employee_hire_date", None),
)
next_link = getattr(users_response, "odata_next_link", None)
@@ -1725,6 +1727,7 @@ class User(BaseModel):
user_type: The user account type as reported by Microsoft Graph
(typically 'Member' or 'Guest'). ``None`` when Microsoft Graph does not
return the property; checks must not assume a default in that case.
employee_hire_date: The user's hire date as reported by Microsoft Graph.
"""
id: str
@@ -1735,6 +1738,7 @@ class User(BaseModel):
account_enabled: bool = True
authentication_methods: List[str] = []
user_type: Optional[str] = None
employee_hire_date: Optional[datetime] = None
class InvitationsFrom(Enum):
@@ -1,3 +1,4 @@
from datetime import datetime, timezone
from typing import List
from prowler.lib.check.models import Check, CheckReportM365
@@ -12,7 +13,8 @@ class entra_users_mfa_capable(Check):
Microsoft 365 Foundations Benchmark recommendation 5.2.3.4
("Ensure all member users are 'MFA capable'").
Guest users and disabled accounts are excluded from the evaluation.
Guest users, disabled accounts, and future hires are excluded from the
evaluation.
- PASS: The member user is MFA capable.
- FAIL: The member user is not MFA capable, or MFA capability cannot be
@@ -38,6 +40,15 @@ class entra_users_mfa_capable(Check):
for user in entra_client.users.values():
if user.user_type == "Guest" or not user.account_enabled:
continue
if user.employee_hire_date:
employee_hire_date = user.employee_hire_date
if (
employee_hire_date.tzinfo is None
or employee_hire_date.utcoffset() is None
):
employee_hire_date = employee_hire_date.replace(tzinfo=timezone.utc)
if employee_hire_date > datetime.now(timezone.utc):
continue
report = CheckReportM365(
metadata=self.metadata(),
@@ -1,3 +1,4 @@
from datetime import datetime, timedelta, timezone
from unittest import mock
from uuid import uuid4
@@ -326,6 +327,133 @@ class Test_entra_users_mfa_capable:
assert len(result) == 0
def test_future_hire_member_user_not_checked(self):
"""Future-hire member user is not active yet: expected no results."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.user_registration_details_error = None
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="Future Hire",
on_premises_sync_enabled=False,
directory_roles_ids=[],
is_mfa_capable=False,
account_enabled=True,
user_type="Member",
employee_hire_date=datetime.now(timezone.utc) + timedelta(days=1),
)
}
check = entra_users_mfa_capable()
result = check.execute()
assert len(result) == 0
def test_naive_future_hire_member_user_not_checked(self):
"""Naive future-hire datetimes are treated as UTC and skipped."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.user_registration_details_error = None
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="Future Hire",
on_premises_sync_enabled=False,
directory_roles_ids=[],
is_mfa_capable=False,
account_enabled=True,
user_type="Member",
employee_hire_date=(
datetime.now(timezone.utc) + timedelta(days=1)
).replace(tzinfo=None),
)
}
check = entra_users_mfa_capable()
result = check.execute()
assert len(result) == 0
def test_current_hire_member_user_is_checked(self):
"""Current-hire member user is active now: expected evaluation."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.user_registration_details_error = None
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="Current Hire",
on_premises_sync_enabled=False,
directory_roles_ids=[],
is_mfa_capable=False,
account_enabled=True,
user_type="Member",
employee_hire_date=datetime.now(timezone.utc),
)
}
check = entra_users_mfa_capable()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].status_extended == "User Current Hire is not MFA capable."
assert result[0].resource == entra_client.users[user_id]
assert result[0].resource_name == "Current Hire"
assert result[0].resource_id == user_id
def test_member_and_guest_users(self):
"""Mix of member and guest users: only member users should be checked."""
entra_client = mock.MagicMock
@@ -411,6 +411,7 @@ class Test_Entra_Service:
id="user-1",
display_name="User 1",
on_premises_sync_enabled=True,
employee_hire_date=datetime(2026, 6, 10, tzinfo=timezone.utc),
),
SimpleNamespace(
id="user-2",
@@ -535,6 +536,7 @@ class Test_Entra_Service:
"userType",
"accountEnabled",
"onPremisesSyncEnabled",
"employeeHireDate",
}
with_url_mock.assert_called_once_with("next-link")
assert users["user-1"].directory_roles_ids == ["role-template-1"]
@@ -548,6 +550,9 @@ class Test_Entra_Service:
assert users["user-1"].authentication_methods == ["fido2SecurityKey"]
assert users["user-6"].authentication_methods == ["mobilePhone"]
assert users["user-2"].authentication_methods == []
assert users["user-1"].employee_hire_date == datetime(
2026, 6, 10, tzinfo=timezone.utc
)
def test__get_users_uses_graph_account_enabled_for_disabled_guests(self):
"""Regression test for https://github.com/prowler-cloud/prowler/issues/10921.