mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-09 21:14:22 +00:00
fix(m365): skip future hires in MFA capable check (#11511)
This commit is contained in:
@@ -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):
|
||||
|
||||
+12
-1
@@ -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(),
|
||||
|
||||
+128
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user