From b3caee88e4453b2f8873560814e108b399573ed0 Mon Sep 17 00:00:00 2001 From: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:42:06 +0200 Subject: [PATCH] fix(m365): skip future hires in MFA capable check (#11511) --- prowler/CHANGELOG.md | 1 + .../m365/services/entra/entra_service.py | 4 + .../entra_users_mfa_capable.py | 13 +- .../entra_users_mfa_capable_test.py | 128 ++++++++++++++++++ .../entra/microsoft365_entra_service_test.py | 5 + 5 files changed, 150 insertions(+), 1 deletion(-) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 69a91b7128..69fe6276b7 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -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) --- diff --git a/prowler/providers/m365/services/entra/entra_service.py b/prowler/providers/m365/services/entra/entra_service.py index c3ea05acee..20f2dbf3a3 100644 --- a/prowler/providers/m365/services/entra/entra_service.py +++ b/prowler/providers/m365/services/entra/entra_service.py @@ -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): diff --git a/prowler/providers/m365/services/entra/entra_users_mfa_capable/entra_users_mfa_capable.py b/prowler/providers/m365/services/entra/entra_users_mfa_capable/entra_users_mfa_capable.py index 9485676d30..d3e75cef7f 100644 --- a/prowler/providers/m365/services/entra/entra_users_mfa_capable/entra_users_mfa_capable.py +++ b/prowler/providers/m365/services/entra/entra_users_mfa_capable/entra_users_mfa_capable.py @@ -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(), diff --git a/tests/providers/m365/services/entra/entra_users_mfa_capable/entra_users_mfa_capable_test.py b/tests/providers/m365/services/entra/entra_users_mfa_capable/entra_users_mfa_capable_test.py index 8bf0c88f77..86e8e38f22 100644 --- a/tests/providers/m365/services/entra/entra_users_mfa_capable/entra_users_mfa_capable_test.py +++ b/tests/providers/m365/services/entra/entra_users_mfa_capable/entra_users_mfa_capable_test.py @@ -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 diff --git a/tests/providers/m365/services/entra/microsoft365_entra_service_test.py b/tests/providers/m365/services/entra/microsoft365_entra_service_test.py index f2ee0b2c84..4643ea93cc 100644 --- a/tests/providers/m365/services/entra/microsoft365_entra_service_test.py +++ b/tests/providers/m365/services/entra/microsoft365_entra_service_test.py @@ -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.