feat(m365): add entra_break_glass_users_fido2_security_key_registered security check (#10213)

Co-authored-by: Daniel Barranquero <74871504+danibarranqueroo@users.noreply.github.com>
This commit is contained in:
Hugo Pereira Brito
2026-03-03 13:58:44 +01:00
committed by GitHub
parent dfca97633e
commit e96ea54f3b
13 changed files with 816 additions and 21 deletions
+1
View File
@@ -34,6 +34,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- CIS 6.0 for the AWS provider [(#10127)](https://github.com/prowler-cloud/prowler/pull/10127)
- `entra_conditional_access_policy_require_mfa_for_management_api` check for M365 provider [(#10150)](https://github.com/prowler-cloud/prowler/pull/10150)
- OpenStack provider multiple regions support [(#10135)](https://github.com/prowler-cloud/prowler/pull/10135)
- `entra_break_glass_account_fido2_security_key_registered` check for m365 provider [(#10213)](https://github.com/prowler-cloud/prowler/pull/10213)
- `entra_default_app_management_policy_enabled` check for M365 provider [(#9898)](https://github.com/prowler-cloud/prowler/pull/9898)
- OpenStack networking service with 6 security checks [(#9970)](https://github.com/prowler-cloud/prowler/pull/9970)
- OpenStack block storage service with 7 security checks [(#10120)](https://github.com/prowler-cloud/prowler/pull/10120)
+3 -1
View File
@@ -32,6 +32,7 @@
"Id": "1.1.2",
"Description": "Emergency access or \"break glass\" accounts are limited for emergency scenarios where normal administrative accounts are unavailable. They are not assigned to a specific user and will have a combination of physical and technical controls to prevent them from being accessed outside a true emergency. These emergencies could be due to several things, including:- Technical failures of a cellular provider or Microsoft related service such as MFA.- The last remaining Global Administrator account is inaccessible.Ensure two `Emergency Access` accounts have been defined.**Note:** Microsoft provides several recommendations for these accounts and how to configure them. For more information on this, please refer to the references section. The CIS Benchmark outlines the more critical things to consider.",
"Checks": [
"entra_break_glass_account_fido2_security_key_registered",
"entra_emergency_access_exclusion"
],
"Attributes": [
@@ -1214,7 +1215,8 @@
"Id": "5.2.2.5",
"Description": "Authentication strength is a Conditional Access control that allows administrators to specify which combination of authentication methods can be used to access a resource. For example, they can make only phishing-resistant authentication methods available to access a sensitive resource. But to access a non-sensitive resource, they can allow less secure multifactor authentication (MFA) combinations, such as password + SMS.Microsoft has 3 built-in authentication strengths. MFA strength, Passwordless MFA strength, and Phishing-resistant MFA strength. Ensure administrator roles are using a CA policy with `Phishing-resistant MFA strength`.Administrators can then enroll using one of 3 methods:- FIDO2 Security Key- Windows Hello for Business- Certificate-based authentication (Multi-Factor)**Note:** Additional steps to configure methods such as FIDO2 keys are not covered here but can be found in related MS articles in the references section. The Conditional Access policy only ensures 1 of the 3 methods is used.**Warning:** Administrators should be pre-registered for a strong authentication mechanism before this Conditional Access Policy is enforced. Additionally, as stated elsewhere in the CIS Benchmark a break-glass administrator account should be excluded from this policy to ensure unfettered access in the case of an emergency.",
"Checks": [
"entra_admin_users_phishing_resistant_mfa_enabled"
"entra_admin_users_phishing_resistant_mfa_enabled",
"entra_break_glass_account_fido2_security_key_registered"
],
"Attributes": [
{
+3 -1
View File
@@ -32,6 +32,7 @@
"Id": "1.1.2",
"Description": "Emergency access or 'break glass' accounts are limited for emergency scenarios where normal administrative accounts are unavailable. They are not assigned to a specific user and will have a combination of physical and technical controls to prevent them from being accessed outside a true emergency. Ensure two Emergency Access accounts have been defined.",
"Checks": [
"entra_break_glass_account_fido2_security_key_registered",
"entra_emergency_access_exclusion"
],
"Attributes": [
@@ -1446,7 +1447,8 @@
"Id": "5.2.2.5",
"Description": "Authentication strength is a Conditional Access control that allows administrators to specify which combination of authentication methods can be used to access a resource. Ensure administrator roles are using a CA policy with Phishing-resistant MFA strength.",
"Checks": [
"entra_admin_users_phishing_resistant_mfa_enabled"
"entra_admin_users_phishing_resistant_mfa_enabled",
"entra_break_glass_account_fido2_security_key_registered"
],
"Attributes": [
{
@@ -240,6 +240,7 @@
"defenderxdr_endpoint_privileged_user_exposed_credentials",
"entra_admin_users_mfa_enabled",
"entra_admin_users_sign_in_frequency_enabled",
"entra_break_glass_account_fido2_security_key_registered",
"entra_default_app_management_policy_enabled",
"entra_all_apps_conditional_access_coverage",
"entra_legacy_authentication_blocked",
@@ -649,6 +650,7 @@
"entra_admin_users_mfa_enabled",
"entra_admin_users_phishing_resistant_mfa_enabled",
"entra_admin_users_sign_in_frequency_enabled",
"entra_break_glass_account_fido2_security_key_registered",
"entra_app_registration_no_unused_privileged_permissions",
"entra_policy_ensure_default_user_cannot_create_tenants",
"entra_policy_guest_invite_only_for_admin_roles",
@@ -690,6 +692,7 @@
"entra_admin_users_mfa_enabled",
"entra_admin_users_sign_in_frequency_enabled",
"entra_all_apps_conditional_access_coverage",
"entra_break_glass_account_fido2_security_key_registered",
"entra_identity_protection_sign_in_risk_enabled",
"entra_managed_device_required_for_authentication",
"entra_seamless_sso_disabled",
@@ -27,7 +27,8 @@
"Id": "1.1.2",
"Description": "Ensure multifactor authentication is enabled for all users in administrative roles",
"Checks": [
"entra_admin_users_mfa_enabled"
"entra_admin_users_mfa_enabled",
"entra_break_glass_account_fido2_security_key_registered"
],
"Attributes": [
{
@@ -81,7 +82,8 @@
"Id": "1.1.5",
"Description": "Ensure 'Phishing-resistant MFA strength' is required for Administrators",
"Checks": [
"entra_admin_users_phishing_resistant_mfa_enabled"
"entra_admin_users_phishing_resistant_mfa_enabled",
"entra_break_glass_account_fido2_security_key_registered"
],
"Attributes": [
{
@@ -0,0 +1,40 @@
{
"Provider": "m365",
"CheckID": "entra_break_glass_account_fido2_security_key_registered",
"CheckTitle": "Break glass account has a FIDO2 security key registered for phishing-resistant authentication",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "critical",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "Break glass (emergency access) accounts should have at least one **FIDO2 security key** registered as their authentication method. These accounts are identified as users excluded from all enabled Conditional Access policies.",
"Risk": "Without FIDO2 security keys, break glass accounts rely on weaker authentication methods vulnerable to **phishing, credential theft, and man-in-the-middle attacks**. Compromised emergency access accounts could grant an attacker unrestricted tenant access, bypassing all Conditional Access protections.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/security-emergency-access",
"https://learn.microsoft.com/en-us/entra/identity/authentication/concept-authentication-passwordless#fido2-security-keys"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to Microsoft Entra admin center > Users.\n2. Select the break glass account.\n3. Go to Authentication methods > Add authentication method.\n4. Select FIDO2 Security Key and follow the registration steps.\n5. Store the physical FIDO2 key in a secure location (e.g., physical safe).",
"Terraform": ""
},
"Recommendation": {
"Text": "Register at least one **FIDO2 security key** for each break glass account. Store the physical keys in a secure, offline location such as a safe. Use phishing-resistant authentication to protect emergency access accounts from credential-based attacks.",
"Url": "https://hub.prowler.com/check/entra_break_glass_account_fido2_security_key_registered"
}
},
"Categories": [
"identity-access",
"e3"
],
"DependsOn": [],
"RelatedTo": [
"entra_emergency_access_exclusion"
],
"Notes": "Break glass accounts are identified as users excluded from all enabled Conditional Access policies. This check requires the entra_emergency_access_exclusion check to pass first for meaningful results."
}
@@ -0,0 +1,104 @@
from collections import Counter
from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.entra.entra_client import entra_client
from prowler.providers.m365.services.entra.entra_service import (
ConditionalAccessPolicyState,
)
class entra_break_glass_account_fido2_security_key_registered(Check):
"""Ensure that break glass accounts have FIDO2 security keys registered.
This check identifies break glass (emergency access) accounts by finding users
excluded from all enabled Conditional Access policies, then verifies each has
at least one FIDO2 security key registered as an authentication method.
- PASS: The break glass account has a FIDO2 security key (fido2SecurityKey) registered.
- MANUAL: The account has a device-bound passkey but it cannot be confirmed as FIDO2,
or no break glass accounts could be identified.
- FAIL: The break glass account does not have a FIDO2 security key registered.
"""
def execute(self) -> list[CheckReportM365]:
"""Execute the check for FIDO2 registration on break glass accounts.
Returns:
A list of reports containing the result of the check.
"""
findings = []
enabled_policies = [
policy
for policy in entra_client.conditional_access_policies.values()
if policy.state != ConditionalAccessPolicyState.DISABLED
]
if not enabled_policies:
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name="Break Glass Accounts",
resource_id="breakGlassAccounts",
)
report.status = "MANUAL"
report.status_extended = "No enabled Conditional Access policies found. Break glass accounts cannot be identified to verify FIDO2 registration."
findings.append(report)
return findings
total_policy_count = len(enabled_policies)
excluded_users_counter = Counter()
for policy in enabled_policies:
user_conditions = policy.conditions.user_conditions
if user_conditions:
for user_id in user_conditions.excluded_users:
excluded_users_counter[user_id] += 1
break_glass_user_ids = [
user_id
for user_id, count in excluded_users_counter.items()
if count == total_policy_count
]
if not break_glass_user_ids:
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name="Break Glass Accounts",
resource_id="breakGlassAccounts",
)
report.status = "MANUAL"
report.status_extended = "No break glass accounts identified. No users are excluded from all enabled Conditional Access policies."
findings.append(report)
return findings
for user_id in break_glass_user_ids:
user = entra_client.users.get(user_id)
if not user:
continue
report = CheckReportM365(
metadata=self.metadata(),
resource=user,
resource_name=user.name,
resource_id=user.id,
)
auth_methods = set(user.authentication_methods)
has_fido2 = "fido2SecurityKey" in auth_methods
has_passkey_device_bound = "passKeyDeviceBound" in auth_methods
if has_fido2:
report.status = "PASS"
report.status_extended = f"Break glass account {user.name} has a FIDO2 security key registered."
elif has_passkey_device_bound:
report.status = "MANUAL"
report.status_extended = f"Break glass account {user.name} has a device-bound passkey registered, but it cannot be confirmed whether it is a FIDO2 security key."
else:
report.status = "FAIL"
report.status_extended = f"Break glass account {user.name} does not have a FIDO2 security key registered."
findings.append(report)
return findings
@@ -95,10 +95,19 @@ class entra_emergency_access_exclusion(Check):
report.status = "PASS"
exclusion_details = []
if users_excluded_from_all:
exclusion_details.append(f"{len(users_excluded_from_all)} user(s)")
user_names = []
for user_id in users_excluded_from_all:
user = entra_client.users.get(user_id)
user_names.append(user.name if user else user_id)
exclusion_details.append(f"user(s): {', '.join(user_names)}")
if groups_excluded_from_all:
exclusion_details.append(f"{len(groups_excluded_from_all)} group(s)")
report.status_extended = f"{' and '.join(exclusion_details)} excluded as emergency access across all {total_policy_count} enabled Conditional Access policies."
group_names = []
groups_by_id = {g.id: g for g in entra_client.groups}
for group_id in groups_excluded_from_all:
group = groups_by_id.get(group_id)
group_names.append(group.name if group else group_id)
exclusion_details.append(f"group(s): {', '.join(group_names)}")
report.status_extended = f"Emergency access {' and '.join(exclusion_details)} excluded from all {total_policy_count} enabled Conditional Access policies."
else:
report.status = "FAIL"
report.status_extended = f"No user or group is excluded as emergency access from all {total_policy_count} enabled Conditional Access policies."
@@ -590,6 +590,7 @@ class Entra(M365Service):
while users_response:
for user in getattr(users_response, "value", []) or []:
reg_info = registration_details.get(user.id, {})
users[user.id] = User(
id=user.id,
name=user.display_name,
@@ -597,10 +598,13 @@ 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, False)),
is_mfa_capable=reg_info.get("is_mfa_capable", False),
account_enabled=not self.user_accounts_status.get(
user.id, {}
).get("AccountDisabled", False),
authentication_methods=reg_info.get(
"authentication_methods", []
),
)
next_link = getattr(users_response, "odata_next_link", None)
@@ -614,6 +618,16 @@ class Entra(M365Service):
return users
async def _get_user_registration_details(self):
"""Retrieve user authentication method registration details.
Fetches registration details from the Microsoft Graph API, including
MFA capability and the specific authentication methods each user has registered.
Returns:
dict: A dictionary mapping user IDs to their registration details,
where each value is a dict with 'is_mfa_capable' (bool) and
'authentication_methods' (list of str).
"""
registration_details = {}
try:
registration_builder = (
@@ -623,9 +637,14 @@ class Entra(M365Service):
while registration_response:
for detail in getattr(registration_response, "value", []) or []:
registration_details.update(
{detail.id: getattr(detail, "is_mfa_capable", False)}
)
registration_details[detail.id] = {
"is_mfa_capable": getattr(detail, "is_mfa_capable", False),
"authentication_methods": [
str(method)
for method in getattr(detail, "methods_registered", [])
or []
],
}
next_link = getattr(registration_response, "odata_next_link", None)
if not next_link:
@@ -1030,12 +1049,26 @@ class AdminRoles(Enum):
class User(BaseModel):
"""Model representing a Microsoft Entra ID user.
Attributes:
id: The user's unique identifier.
name: The user's display name.
on_premises_sync_enabled: Whether the user is synced from on-premises directory.
directory_roles_ids: List of directory role template IDs assigned to the user.
is_mfa_capable: Whether the user has registered a strong authentication method for MFA.
account_enabled: Whether the user account is enabled.
authentication_methods: List of authentication method types registered by the user
(e.g., 'fido2SecurityKey', 'microsoftAuthenticatorPush', 'mobilePhone').
"""
id: str
name: str
on_premises_sync_enabled: bool
directory_roles_ids: List[str] = []
is_mfa_capable: bool = False
account_enabled: bool = True
authentication_methods: List[str] = []
class InvitationsFrom(Enum):
@@ -0,0 +1,502 @@
from unittest import mock
from uuid import uuid4
from prowler.providers.m365.services.entra.entra_service import (
ApplicationsConditions,
ConditionalAccessGrantControl,
ConditionalAccessPolicyState,
Conditions,
GrantControlOperator,
GrantControls,
PersistentBrowser,
SessionControls,
SignInFrequency,
SignInFrequencyInterval,
User,
UsersConditions,
)
from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_break_glass_account_fido2_security_key_registered.entra_break_glass_account_fido2_security_key_registered"
def _make_policy(policy_id, excluded_users=None, excluded_groups=None, state=None):
"""Create a ConditionalAccessPolicy for testing."""
from prowler.providers.m365.services.entra.entra_service import (
ConditionalAccessPolicy,
)
return ConditionalAccessPolicy(
id=policy_id,
display_name=f"Policy {policy_id[:8]}",
conditions=Conditions(
application_conditions=ApplicationsConditions(
included_applications=["All"],
excluded_applications=[],
included_user_actions=[],
),
user_conditions=UsersConditions(
included_groups=[],
excluded_groups=excluded_groups or [],
included_users=["All"],
excluded_users=excluded_users or [],
included_roles=[],
excluded_roles=[],
),
),
grant_controls=GrantControls(
built_in_controls=[ConditionalAccessGrantControl.MFA],
operator=GrantControlOperator.AND,
),
session_controls=SessionControls(
persistent_browser=PersistentBrowser(is_enabled=False, mode="always"),
sign_in_frequency=SignInFrequency(
is_enabled=False,
frequency=None,
type=None,
interval=SignInFrequencyInterval.EVERY_TIME,
),
),
state=state or ConditionalAccessPolicyState.ENABLED,
)
class Test_entra_break_glass_account_fido2_security_key_registered:
def test_no_conditional_access_policies(self):
"""Test MANUAL when there are no Conditional Access policies."""
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(
f"{CHECK_MODULE_PATH}.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_break_glass_account_fido2_security_key_registered.entra_break_glass_account_fido2_security_key_registered import (
entra_break_glass_account_fido2_security_key_registered,
)
entra_client.conditional_access_policies = {}
check = entra_break_glass_account_fido2_security_key_registered()
result = check.execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert (
"No enabled Conditional Access policies found"
in result[0].status_extended
)
assert result[0].resource == {}
assert result[0].resource_name == "Break Glass Accounts"
assert result[0].resource_id == "breakGlassAccounts"
assert result[0].location == "global"
def test_all_policies_disabled(self):
"""Test MANUAL when all Conditional Access policies are disabled."""
policy_id = str(uuid4())
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(
f"{CHECK_MODULE_PATH}.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_break_glass_account_fido2_security_key_registered.entra_break_glass_account_fido2_security_key_registered import (
entra_break_glass_account_fido2_security_key_registered,
)
entra_client.conditional_access_policies = {
policy_id: _make_policy(
policy_id, state=ConditionalAccessPolicyState.DISABLED
),
}
check = entra_break_glass_account_fido2_security_key_registered()
result = check.execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert (
"No enabled Conditional Access policies found"
in result[0].status_extended
)
def test_no_break_glass_accounts_identified(self):
"""Test MANUAL when no user is excluded from all CA policies."""
policy_id_1 = str(uuid4())
policy_id_2 = str(uuid4())
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(
f"{CHECK_MODULE_PATH}.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_break_glass_account_fido2_security_key_registered.entra_break_glass_account_fido2_security_key_registered import (
entra_break_glass_account_fido2_security_key_registered,
)
# User-1 excluded from policy 1, user-2 from policy 2 -- no one excluded from all
entra_client.conditional_access_policies = {
policy_id_1: _make_policy(policy_id_1, excluded_users=["user-1"]),
policy_id_2: _make_policy(policy_id_2, excluded_users=["user-2"]),
}
check = entra_break_glass_account_fido2_security_key_registered()
result = check.execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "No break glass accounts identified" in result[0].status_extended
def test_break_glass_user_with_fido2(self):
"""Test PASS when break glass account has FIDO2 registered."""
policy_id_1 = str(uuid4())
policy_id_2 = str(uuid4())
bg_user_id = str(uuid4())
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(
f"{CHECK_MODULE_PATH}.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_break_glass_account_fido2_security_key_registered.entra_break_glass_account_fido2_security_key_registered import (
entra_break_glass_account_fido2_security_key_registered,
)
entra_client.conditional_access_policies = {
policy_id_1: _make_policy(policy_id_1, excluded_users=[bg_user_id]),
policy_id_2: _make_policy(policy_id_2, excluded_users=[bg_user_id]),
}
entra_client.users = {
bg_user_id: User(
id=bg_user_id,
name="BreakGlass1",
on_premises_sync_enabled=False,
authentication_methods=[
"fido2SecurityKey",
"microsoftAuthenticatorPush",
],
),
}
check = entra_break_glass_account_fido2_security_key_registered()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert "BreakGlass1" in result[0].status_extended
assert "FIDO2 security key registered" in result[0].status_extended
assert result[0].resource_name == "BreakGlass1"
assert result[0].resource_id == bg_user_id
def test_break_glass_user_without_fido2(self):
"""Test FAIL when break glass account lacks FIDO2."""
policy_id_1 = str(uuid4())
policy_id_2 = str(uuid4())
bg_user_id = str(uuid4())
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(
f"{CHECK_MODULE_PATH}.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_break_glass_account_fido2_security_key_registered.entra_break_glass_account_fido2_security_key_registered import (
entra_break_glass_account_fido2_security_key_registered,
)
entra_client.conditional_access_policies = {
policy_id_1: _make_policy(policy_id_1, excluded_users=[bg_user_id]),
policy_id_2: _make_policy(policy_id_2, excluded_users=[bg_user_id]),
}
entra_client.users = {
bg_user_id: User(
id=bg_user_id,
name="BreakGlass1",
on_premises_sync_enabled=False,
authentication_methods=["mobilePhone", "email"],
),
}
check = entra_break_glass_account_fido2_security_key_registered()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "BreakGlass1" in result[0].status_extended
assert (
"does not have a FIDO2 security key registered"
in result[0].status_extended
)
def test_break_glass_user_with_empty_authentication_methods(self):
"""Test FAIL when break glass account has no authentication methods."""
policy_id = str(uuid4())
bg_user_id = str(uuid4())
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(
f"{CHECK_MODULE_PATH}.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_break_glass_account_fido2_security_key_registered.entra_break_glass_account_fido2_security_key_registered import (
entra_break_glass_account_fido2_security_key_registered,
)
entra_client.conditional_access_policies = {
policy_id: _make_policy(policy_id, excluded_users=[bg_user_id]),
}
entra_client.users = {
bg_user_id: User(
id=bg_user_id,
name="BreakGlass1",
on_premises_sync_enabled=False,
authentication_methods=[],
),
}
check = entra_break_glass_account_fido2_security_key_registered()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
"does not have a FIDO2 security key registered"
in result[0].status_extended
)
def test_break_glass_user_with_passkey_device_bound(self):
"""Test MANUAL when break glass account has passKeyDeviceBound but not fido2SecurityKey."""
policy_id_1 = str(uuid4())
policy_id_2 = str(uuid4())
bg_user_id = str(uuid4())
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(
f"{CHECK_MODULE_PATH}.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_break_glass_account_fido2_security_key_registered.entra_break_glass_account_fido2_security_key_registered import (
entra_break_glass_account_fido2_security_key_registered,
)
entra_client.conditional_access_policies = {
policy_id_1: _make_policy(policy_id_1, excluded_users=[bg_user_id]),
policy_id_2: _make_policy(policy_id_2, excluded_users=[bg_user_id]),
}
entra_client.users = {
bg_user_id: User(
id=bg_user_id,
name="BreakGlass1",
on_premises_sync_enabled=False,
authentication_methods=["passKeyDeviceBound"],
),
}
check = entra_break_glass_account_fido2_security_key_registered()
result = check.execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "BreakGlass1" in result[0].status_extended
assert "device-bound passkey registered" in result[0].status_extended
assert "cannot be confirmed" in result[0].status_extended
def test_multiple_break_glass_users_mixed_results(self):
"""Test mixed results when one BG user has FIDO2 and another does not."""
policy_id_1 = str(uuid4())
policy_id_2 = str(uuid4())
bg_user_id_1 = str(uuid4())
bg_user_id_2 = str(uuid4())
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(
f"{CHECK_MODULE_PATH}.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_break_glass_account_fido2_security_key_registered.entra_break_glass_account_fido2_security_key_registered import (
entra_break_glass_account_fido2_security_key_registered,
)
entra_client.conditional_access_policies = {
policy_id_1: _make_policy(
policy_id_1, excluded_users=[bg_user_id_1, bg_user_id_2]
),
policy_id_2: _make_policy(
policy_id_2, excluded_users=[bg_user_id_1, bg_user_id_2]
),
}
entra_client.users = {
bg_user_id_1: User(
id=bg_user_id_1,
name="BreakGlass1",
on_premises_sync_enabled=False,
authentication_methods=["fido2SecurityKey"],
),
bg_user_id_2: User(
id=bg_user_id_2,
name="BreakGlass2",
on_premises_sync_enabled=False,
authentication_methods=["mobilePhone"],
),
}
check = entra_break_glass_account_fido2_security_key_registered()
result = check.execute()
assert len(result) == 2
statuses = {r.resource_name: r.status for r in result}
assert statuses["BreakGlass1"] == "PASS"
assert statuses["BreakGlass2"] == "FAIL"
def test_break_glass_user_not_in_users_dict(self):
"""Test that a user excluded from all policies but not in users dict is skipped."""
policy_id = str(uuid4())
bg_user_id = str(uuid4())
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(
f"{CHECK_MODULE_PATH}.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_break_glass_account_fido2_security_key_registered.entra_break_glass_account_fido2_security_key_registered import (
entra_break_glass_account_fido2_security_key_registered,
)
entra_client.conditional_access_policies = {
policy_id: _make_policy(policy_id, excluded_users=[bg_user_id]),
}
# User not present in the users dict
entra_client.users = {}
check = entra_break_glass_account_fido2_security_key_registered()
result = check.execute()
assert len(result) == 0
def test_disabled_policies_ignored(self):
"""Test that disabled policies are not considered for identifying break glass accounts."""
policy_id_enabled = str(uuid4())
policy_id_disabled = str(uuid4())
bg_user_id = str(uuid4())
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(
f"{CHECK_MODULE_PATH}.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_break_glass_account_fido2_security_key_registered.entra_break_glass_account_fido2_security_key_registered import (
entra_break_glass_account_fido2_security_key_registered,
)
# User excluded from the enabled policy but not the disabled one
entra_client.conditional_access_policies = {
policy_id_enabled: _make_policy(
policy_id_enabled, excluded_users=[bg_user_id]
),
policy_id_disabled: _make_policy(
policy_id_disabled,
excluded_users=[],
state=ConditionalAccessPolicyState.DISABLED,
),
}
entra_client.users = {
bg_user_id: User(
id=bg_user_id,
name="BreakGlass1",
on_premises_sync_enabled=False,
authentication_methods=["fido2SecurityKey"],
),
}
check = entra_break_glass_account_fido2_security_key_registered()
result = check.execute()
# Only 1 enabled policy and user is excluded from it → break glass user identified
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].resource_name == "BreakGlass1"
@@ -8,10 +8,12 @@ from prowler.providers.m365.services.entra.entra_service import (
Conditions,
GrantControlOperator,
GrantControls,
Group,
PersistentBrowser,
SessionControls,
SignInFrequency,
SignInFrequencyInterval,
User,
UsersConditions,
)
from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
@@ -334,12 +336,23 @@ class Test_entra_emergency_access_exclusion:
),
}
entra_client.users = {
emergency_user_id: User(
id=emergency_user_id,
name="BreakGlass1",
on_premises_sync_enabled=False,
authentication_methods=[],
),
}
entra_client.groups = []
check = entra_emergency_access_exclusion()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert "BreakGlass1" in result[0].status_extended
assert (
"1 user(s) excluded as emergency access across all 2 enabled Conditional Access policies"
"excluded from all 2 enabled Conditional Access policies"
in result[0].status_extended
)
assert result[0].resource_name == "Conditional Access Policies"
@@ -445,12 +458,23 @@ class Test_entra_emergency_access_exclusion:
),
}
entra_client.users = {}
entra_client.groups = [
Group(
id=emergency_group_id,
name="BreakGlassGroup",
groupTypes=[],
membershipRule=None,
),
]
check = entra_emergency_access_exclusion()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert "BreakGlassGroup" in result[0].status_extended
assert (
"1 group(s) excluded as emergency access across all 2 enabled Conditional Access policies"
"excluded from all 2 enabled Conditional Access policies"
in result[0].status_extended
)
assert result[0].resource_name == "Conditional Access Policies"
@@ -557,12 +581,31 @@ class Test_entra_emergency_access_exclusion:
),
}
entra_client.users = {
emergency_user_id: User(
id=emergency_user_id,
name="BreakGlass1",
on_premises_sync_enabled=False,
authentication_methods=[],
),
}
entra_client.groups = [
Group(
id=emergency_group_id,
name="BreakGlassGroup",
groupTypes=[],
membershipRule=None,
),
]
check = entra_emergency_access_exclusion()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert "BreakGlass1" in result[0].status_extended
assert "BreakGlassGroup" in result[0].status_extended
assert (
"1 user(s) and 1 group(s) excluded as emergency access across all 2 enabled Conditional Access policies"
"excluded from all 2 enabled Conditional Access policies"
in result[0].status_extended
)
assert result[0].resource_name == "Conditional Access Policies"
@@ -668,14 +711,25 @@ class Test_entra_emergency_access_exclusion:
),
}
entra_client.users = {
emergency_user_id: User(
id=emergency_user_id,
name="BreakGlass1",
on_premises_sync_enabled=False,
authentication_methods=[],
),
}
entra_client.groups = []
check = entra_emergency_access_exclusion()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].resource_name == "Conditional Access Policies"
assert result[0].resource_id == "conditionalAccessPolicies"
assert "BreakGlass1" in result[0].status_extended
assert (
"1 user(s) excluded as emergency access across all 1 enabled Conditional Access policies"
"excluded from all 1 enabled Conditional Access policies"
in result[0].status_extended
)
@@ -780,12 +834,23 @@ class Test_entra_emergency_access_exclusion:
),
}
entra_client.users = {
emergency_user_id: User(
id=emergency_user_id,
name="BreakGlass1",
on_premises_sync_enabled=False,
authentication_methods=[],
),
}
entra_client.groups = []
check = entra_emergency_access_exclusion()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert "BreakGlass1" in result[0].status_extended
assert (
"1 user(s) excluded as emergency access across all 2 enabled Conditional Access policies"
"excluded from all 2 enabled Conditional Access policies"
in result[0].status_extended
)
assert result[0].resource_name == "Conditional Access Policies"
@@ -486,8 +486,16 @@ class Test_Entra_Service:
registration_details_response = SimpleNamespace(
value=[
SimpleNamespace(id="user-1", is_mfa_capable=True),
SimpleNamespace(id="user-6", is_mfa_capable=True),
SimpleNamespace(
id="user-1",
is_mfa_capable=True,
methods_registered=["fido2SecurityKey"],
),
SimpleNamespace(
id="user-6",
is_mfa_capable=True,
methods_registered=["mobilePhone"],
),
],
odata_next_link=None,
)
@@ -520,19 +528,31 @@ class Test_Entra_Service:
assert users["user-6"].account_enabled is False
assert users["user-1"].is_mfa_capable is True
assert users["user-2"].is_mfa_capable is False
assert users["user-1"].authentication_methods == ["fido2SecurityKey"]
assert users["user-6"].authentication_methods == ["mobilePhone"]
assert users["user-2"].authentication_methods == []
def test__get_user_registration_details_handles_pagination(self):
entra_service = Entra.__new__(Entra)
registration_response_page_one = SimpleNamespace(
value=[
SimpleNamespace(id="user-1", is_mfa_capable=True),
SimpleNamespace(
id="user-1",
is_mfa_capable=True,
methods_registered=[
"fido2SecurityKey",
"microsoftAuthenticatorPush",
],
),
],
odata_next_link="next-link",
)
registration_response_page_two = SimpleNamespace(
value=[
SimpleNamespace(id="user-2", is_mfa_capable=False),
SimpleNamespace(
id="user-2", is_mfa_capable=False, methods_registered=[]
),
],
odata_next_link=None,
)
@@ -557,7 +577,19 @@ class Test_Entra_Service:
entra_service._get_user_registration_details()
)
assert registration_details == {"user-1": True, "user-2": False}
assert registration_details == {
"user-1": {
"is_mfa_capable": True,
"authentication_methods": [
"fido2SecurityKey",
"microsoftAuthenticatorPush",
],
},
"user-2": {
"is_mfa_capable": False,
"authentication_methods": [],
},
}
registration_builder.get.assert_awaited()
registration_builder.with_url.assert_called_once_with("next-link")
registration_builder_next.get.assert_awaited()