From 92b838866a7d542c68419d600f55a8ec7912f9d0 Mon Sep 17 00:00:00 2001 From: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:45:12 +0100 Subject: [PATCH] feat(m365): add entra_conditional_access_policy_mfa_enforced_for_guest_users security check (#10616) Co-authored-by: Hugo P.Brito --- prowler/CHANGELOG.md | 1 + .../compliance/m365/iso27001_2022_m365.json | 4 + .../__init__.py | 0 ...mfa_enforced_for_guest_users.metadata.json | 42 ++ ...ess_policy_mfa_enforced_for_guest_users.py | 143 ++++ .../m365/services/entra/entra_service.py | 109 +++ ...olicy_mfa_enforced_for_guest_users_test.py | 705 ++++++++++++++++++ 7 files changed, 1004 insertions(+) create mode 100644 prowler/providers/m365/services/entra/entra_conditional_access_policy_mfa_enforced_for_guest_users/__init__.py create mode 100644 prowler/providers/m365/services/entra/entra_conditional_access_policy_mfa_enforced_for_guest_users/entra_conditional_access_policy_mfa_enforced_for_guest_users.metadata.json create mode 100644 prowler/providers/m365/services/entra/entra_conditional_access_policy_mfa_enforced_for_guest_users/entra_conditional_access_policy_mfa_enforced_for_guest_users.py create mode 100644 tests/providers/m365/services/entra/entra_conditional_access_policy_mfa_enforced_for_guest_users/entra_conditional_access_policy_mfa_enforced_for_guest_users_test.py diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 590b39d172..08160d69de 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `iam_policy_no_wildcard_marketplace_subscribe` and `iam_inline_policy_no_wildcard_marketplace_subscribe` checks for AWS provider [(#10525)](https://github.com/prowler-cloud/prowler/pull/10525) - `bedrock_vpc_endpoints_configured` check for AWS provider [(#10591)](https://github.com/prowler-cloud/prowler/pull/10591) - `exchange_organization_delicensing_resiliency_enabled` check for m365 provider [(#10608)](https://github.com/prowler-cloud/prowler/pull/10608) +- `entra_conditional_access_policy_mfa_enforced_for_guest_users` check for M365 provider [(#10616)](https://github.com/prowler-cloud/prowler/pull/10616) ### 🔄 Changed diff --git a/prowler/compliance/m365/iso27001_2022_m365.json b/prowler/compliance/m365/iso27001_2022_m365.json index fe65de76f1..215abdce84 100644 --- a/prowler/compliance/m365/iso27001_2022_m365.json +++ b/prowler/compliance/m365/iso27001_2022_m365.json @@ -206,6 +206,7 @@ "admincenter_users_admins_reduced_license_footprint", "entra_admin_portals_access_restriction", "entra_admin_users_phishing_resistant_mfa_enabled", + "entra_conditional_access_policy_mfa_enforced_for_guest_users", "entra_conditional_access_policy_block_o365_elevated_insider_risk", "entra_policy_guest_users_access_restrictions", "entra_seamless_sso_disabled" @@ -245,6 +246,7 @@ "entra_admin_users_mfa_enabled", "entra_admin_users_sign_in_frequency_enabled", "entra_break_glass_account_fido2_security_key_registered", + "entra_conditional_access_policy_mfa_enforced_for_guest_users", "entra_default_app_management_policy_enabled", "entra_all_apps_conditional_access_coverage", "entra_conditional_access_policy_device_registration_mfa_required", @@ -687,6 +689,7 @@ "entra_conditional_access_policy_app_enforced_restrictions", "entra_conditional_access_policy_block_elevated_insider_risk", "entra_conditional_access_policy_block_o365_elevated_insider_risk", + "entra_conditional_access_policy_mfa_enforced_for_guest_users", "entra_policy_guest_users_access_restrictions", "sharepoint_external_sharing_restricted" ] @@ -708,6 +711,7 @@ "entra_admin_users_sign_in_frequency_enabled", "entra_all_apps_conditional_access_coverage", "entra_conditional_access_policy_device_registration_mfa_required", + "entra_conditional_access_policy_mfa_enforced_for_guest_users", "entra_intune_enrollment_sign_in_frequency_every_time", "entra_break_glass_account_fido2_security_key_registered", "entra_conditional_access_policy_approved_client_app_required_for_mobile", diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_mfa_enforced_for_guest_users/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_mfa_enforced_for_guest_users/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_mfa_enforced_for_guest_users/entra_conditional_access_policy_mfa_enforced_for_guest_users.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_mfa_enforced_for_guest_users/entra_conditional_access_policy_mfa_enforced_for_guest_users.metadata.json new file mode 100644 index 0000000000..f99c94b7a7 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_mfa_enforced_for_guest_users/entra_conditional_access_policy_mfa_enforced_for_guest_users.metadata.json @@ -0,0 +1,42 @@ +{ + "Provider": "m365", + "CheckID": "entra_conditional_access_policy_mfa_enforced_for_guest_users", + "CheckTitle": "Conditional Access Policy enforces MFA for guest and external users", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Microsoft Entra **Conditional Access** is verified to have at least one **enabled** policy that requires **multifactor authentication** for all **guest and external user types** across all cloud applications. This includes internal guests, B2B collaboration guests and members, B2B direct connect users, other external users, and service providers.", + "Risk": "Without MFA for guest users, compromised external accounts can access tenant resources using only a password. Attackers may exploit **B2B collaboration**, **direct connect**, or **service provider** accounts to exfiltrate data, escalate privileges, or move laterally across the organization.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/policy-alt-require-mfa-guest-access" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center (https://entra.microsoft.com).\n2. Expand **Protection** > **Conditional Access** and select **Policies**.\n3. Click **New policy**.\n4. Under **Users**, select **Include** > **Select users and groups** > check **Guest or external users** > select all guest user types.\n5. Under **Target resources**, select **Include** > **All cloud apps**.\n6. Under **Grant**, select **Grant access** > check **Require multifactor authentication** > click **Select**.\n7. Set the policy to **Report-only** until validated, then enable it.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enforce **MFA** via **Conditional Access** for all **guest and external user types** across all cloud applications. Prefer **phishing-resistant** methods, monitor guest sign-ins, and regularly review external collaboration settings.", + "Url": "https://hub.prowler.com/check/entra_conditional_access_policy_mfa_enforced_for_guest_users" + } + }, + "Categories": [ + "identity-access", + "trust-boundaries", + "e3" + ], + "DependsOn": [], + "RelatedTo": [ + "entra_policy_guest_users_access_restrictions", + "entra_policy_guest_invite_only_for_admin_roles", + "entra_dynamic_group_for_guests_created" + ], + "Notes": "Conditional Access policies require Microsoft Entra ID P1 or P2 licenses." +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_mfa_enforced_for_guest_users/entra_conditional_access_policy_mfa_enforced_for_guest_users.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_mfa_enforced_for_guest_users/entra_conditional_access_policy_mfa_enforced_for_guest_users.py new file mode 100644 index 0000000000..7d27741df9 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_mfa_enforced_for_guest_users/entra_conditional_access_policy_mfa_enforced_for_guest_users.py @@ -0,0 +1,143 @@ +"""Check if at least one Conditional Access policy requires MFA for guest users.""" + +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 ( + ALL_GUEST_USER_TYPES, + ConditionalAccessGrantControl, + ConditionalAccessPolicyState, + ExternalTenantsMembershipKind, +) + + +class entra_conditional_access_policy_mfa_enforced_for_guest_users(Check): + """Check if at least one enabled Conditional Access policy requires MFA for guest users. + + This check verifies that the Microsoft Entra tenant has at least one + enabled Conditional Access policy that requires multifactor authentication + (MFA) for all guest and external user types across all cloud applications. + + - PASS: At least one enabled CA policy requires MFA for all guest user types. + - FAIL: No enabled CA policy enforces MFA for guest users. + """ + + def execute(self) -> list[CheckReportM365]: + """Execute the check logic. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Conditional Access Policies", + resource_id="conditionalAccessPolicies", + ) + report.status = "FAIL" + report.status_extended = ( + "No Conditional Access Policy requires MFA for guest users." + ) + + reporting_policy = None + + for policy in entra_client.conditional_access_policies.values(): + if policy.state == ConditionalAccessPolicyState.DISABLED: + continue + + # Policy must require MFA (built-in control or authentication strength) + # and must not only require password change. + has_mfa = ( + ConditionalAccessGrantControl.MFA + in policy.grant_controls.built_in_controls + ) + has_auth_strength = ( + policy.grant_controls.authentication_strength is not None + ) + only_password_change = policy.grant_controls.built_in_controls == [ + ConditionalAccessGrantControl.PASSWORD_CHANGE + ] + + if not (has_mfa or has_auth_strength) or only_password_change: + continue + + # Policy must target all cloud applications. + if not policy.conditions.application_conditions: + continue + + if ( + "All" + not in policy.conditions.application_conditions.included_applications + ): + continue + + # Policy must target guest users: either include all users, or + # specifically include all guest/external user types. + targets_all_users = ( + "All" in policy.conditions.user_conditions.included_users + ) + targets_guests_via_include = ( + "GuestsOrExternalUsers" + in policy.conditions.user_conditions.included_users + ) + excludes_all_guests = ( + "GuestsOrExternalUsers" + in policy.conditions.user_conditions.excluded_users + ) + + included_guests = ( + policy.conditions.user_conditions.included_guests_or_external_users + ) + targets_all_guest_types = included_guests is not None and ( + ALL_GUEST_USER_TYPES + <= set(included_guests.guest_or_external_user_types) + and included_guests.external_tenants_membership_kind + in (None, ExternalTenantsMembershipKind.ALL) + ) + + if not ( + targets_all_users + or targets_guests_via_include + or targets_all_guest_types + ): + continue + + # Policy must not exclude guest/external user types. + excluded_guests = ( + policy.conditions.user_conditions.excluded_guests_or_external_users + ) + if excludes_all_guests or ( + excluded_guests is not None + and excluded_guests.guest_or_external_user_types + ): + continue + + if policy.state == ConditionalAccessPolicyState.ENABLED: + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name=policy.display_name, + resource_id=policy.id, + ) + report.status = "PASS" + report.status_extended = f"Conditional Access Policy {policy.display_name} requires MFA for guest users." + break + + if ( + policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING + and reporting_policy is None + ): + reporting_policy = policy + + if report.status == "FAIL" and reporting_policy: + report = CheckReportM365( + metadata=self.metadata(), + resource=reporting_policy, + resource_name=reporting_policy.display_name, + resource_id=reporting_policy.id, + ) + report.status = "FAIL" + report.status_extended = f"Conditional Access Policy {reporting_policy.display_name} targets guest users with MFA but is only in report-only mode." + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_service.py b/prowler/providers/m365/services/entra/entra_service.py index b224aabdcc..9960046ca1 100644 --- a/prowler/providers/m365/services/entra/entra_service.py +++ b/prowler/providers/m365/services/entra/entra_service.py @@ -264,6 +264,20 @@ class Entra(M365Service): [], ) ], + included_guests_or_external_users=self._parse_guests_or_external_users( + getattr( + policy.conditions.users, + "include_guests_or_external_users", + None, + ) + ), + excluded_guests_or_external_users=self._parse_guests_or_external_users( + getattr( + policy.conditions.users, + "exclude_guests_or_external_users", + None, + ) + ), ), client_app_types=[ ClientAppType(client_app_type) @@ -546,6 +560,56 @@ class Entra(M365Service): return AuthenticationFlows(transfer_methods=transfer_methods) + @staticmethod + def _parse_guests_or_external_users( + sdk_obj, + ) -> "GuestsOrExternalUsers | None": + """Parse guest or external user conditions from the MS Graph SDK object. + + The SDK deserializes ``guestOrExternalUserTypes`` via + ``get_collection_of_enum_values``, returning a list of SDK enum members. + + Args: + sdk_obj: A ``ConditionalAccessGuestsOrExternalUsers`` SDK object, or ``None``. + + Returns: + A ``GuestsOrExternalUsers`` model instance, or ``None`` if the input is absent. + """ + if sdk_obj is None: + return None + + raw_types = getattr(sdk_obj, "guest_or_external_user_types", None) or [] + raw_membership_kind = getattr( + getattr(sdk_obj, "external_tenants", None), + "membership_kind", + None, + ) + membership_kind = None + if raw_membership_kind is not None: + raw_membership_kind = getattr( + raw_membership_kind, + "value", + raw_membership_kind, + ) + try: + membership_kind = ExternalTenantsMembershipKind(raw_membership_kind) + except ValueError: + logger.warning( + f"Unknown external tenants membership kind: {raw_membership_kind}" + ) + + guest_types: list[GuestOrExternalUserType] = [] + for raw_type in raw_types: + try: + guest_types.append(GuestOrExternalUserType(raw_type.value)) + except (ValueError, AttributeError): + logger.warning(f"Unknown guest or external user type: {raw_type}") + + return GuestsOrExternalUsers( + guest_or_external_user_types=guest_types, + external_tenants_membership_kind=membership_kind, + ) + @staticmethod def _parse_app_management_restrictions(restrictions): """Parse credential restrictions from the Graph API response into AppManagementRestrictions.""" @@ -957,13 +1021,58 @@ class ApplicationsConditions(BaseModel): included_user_actions: List[UserAction] +class GuestOrExternalUserType(Enum): + """Guest or external user types for Conditional Access policies. + + Reference: https://learn.microsoft.com/en-us/graph/api/resources/conditionalaccessguestsorexternalusers + """ + + NONE = "none" + INTERNAL_GUEST = "internalGuest" + B2B_COLLABORATION_GUEST = "b2bCollaborationGuest" + B2B_COLLABORATION_MEMBER = "b2bCollaborationMember" + B2B_DIRECT_CONNECT_USER = "b2bDirectConnectUser" + OTHER_EXTERNAL_USER = "otherExternalUser" + SERVICE_PROVIDER = "serviceProvider" + + +class ExternalTenantsMembershipKind(Enum): + """External tenant scope for guest or external user conditions.""" + + ALL = "all" + ENUMERATED = "enumerated" + UNKNOWN_FUTURE_VALUE = "unknownFutureValue" + + +# All guest/external user types that represent actual guest or external users. +ALL_GUEST_USER_TYPES = { + GuestOrExternalUserType.INTERNAL_GUEST, + GuestOrExternalUserType.B2B_COLLABORATION_GUEST, + GuestOrExternalUserType.B2B_COLLABORATION_MEMBER, + GuestOrExternalUserType.B2B_DIRECT_CONNECT_USER, + GuestOrExternalUserType.OTHER_EXTERNAL_USER, + GuestOrExternalUserType.SERVICE_PROVIDER, +} + + +class GuestsOrExternalUsers(BaseModel): + """Model representing guest or external user conditions in Conditional Access policies.""" + + guest_or_external_user_types: List[GuestOrExternalUserType] = [] + external_tenants_membership_kind: Optional[ExternalTenantsMembershipKind] = None + + class UsersConditions(BaseModel): + """Model representing user conditions for Conditional Access policies.""" + included_groups: List[str] excluded_groups: List[str] included_users: List[str] excluded_users: List[str] included_roles: List[str] excluded_roles: List[str] + included_guests_or_external_users: Optional[GuestsOrExternalUsers] = None + excluded_guests_or_external_users: Optional[GuestsOrExternalUsers] = None class RiskLevel(Enum): diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_mfa_enforced_for_guest_users/entra_conditional_access_policy_mfa_enforced_for_guest_users_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_mfa_enforced_for_guest_users/entra_conditional_access_policy_mfa_enforced_for_guest_users_test.py new file mode 100644 index 0000000000..5fb8f985c2 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_mfa_enforced_for_guest_users/entra_conditional_access_policy_mfa_enforced_for_guest_users_test.py @@ -0,0 +1,705 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.m365.services.entra.entra_service import ( + ALL_GUEST_USER_TYPES, + ApplicationsConditions, + ConditionalAccessGrantControl, + ConditionalAccessPolicyState, + Conditions, + ExternalTenantsMembershipKind, + GrantControlOperator, + GrantControls, + GuestOrExternalUserType, + GuestsOrExternalUsers, + PersistentBrowser, + SessionControls, + SignInFrequency, + UsersConditions, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_conditional_access_policy_mfa_enforced_for_guest_users.entra_conditional_access_policy_mfa_enforced_for_guest_users" + + +def build_policy( + *, + display_name: str, + state: ConditionalAccessPolicyState, + included_applications: list[str] | None = None, + included_users: list[str] | None = None, + excluded_users: list[str] | None = None, + built_in_controls: list[ConditionalAccessGrantControl] | None = None, + authentication_strength: str | None = None, + included_guests_or_external_users: GuestsOrExternalUsers | None = None, + excluded_guests_or_external_users: GuestsOrExternalUsers | None = None, +): + """Build a ConditionalAccessPolicy for testing.""" + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + return ConditionalAccessPolicy( + id=str(uuid4()), + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=included_applications or ["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=included_users or [], + excluded_users=excluded_users or [], + included_roles=[], + excluded_roles=[], + included_guests_or_external_users=included_guests_or_external_users, + excluded_guests_or_external_users=excluded_guests_or_external_users, + ), + client_app_types=[], + user_risk_levels=[], + ), + grant_controls=GrantControls( + built_in_controls=built_in_controls or [], + operator=GrantControlOperator.OR, + authentication_strength=authentication_strength, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser(is_enabled=False, mode="always"), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=None, + ), + ), + state=state, + ) + + +class Test_entra_conditional_access_policy_mfa_enforced_for_guest_users: + """Tests for the entra_conditional_access_policy_mfa_enforced_for_guest_users check.""" + + def test_no_conditional_access_policies(self): + """Test FAIL 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_conditional_access_policy_mfa_enforced_for_guest_users.entra_conditional_access_policy_mfa_enforced_for_guest_users import ( + entra_conditional_access_policy_mfa_enforced_for_guest_users, + ) + + entra_client.conditional_access_policies = {} + + result = ( + entra_conditional_access_policy_mfa_enforced_for_guest_users().execute() + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires MFA for guest users." + ) + assert result[0].resource == {} + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + + def test_disabled_policy_is_skipped(self): + """Test FAIL when the only matching policy is disabled.""" + 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_conditional_access_policy_mfa_enforced_for_guest_users.entra_conditional_access_policy_mfa_enforced_for_guest_users import ( + entra_conditional_access_policy_mfa_enforced_for_guest_users, + ) + + policy = build_policy( + display_name="MFA for Guests", + state=ConditionalAccessPolicyState.DISABLED, + included_users=["GuestsOrExternalUsers"], + built_in_controls=[ConditionalAccessGrantControl.MFA], + ) + entra_client.conditional_access_policies = {policy.id: policy} + + result = ( + entra_conditional_access_policy_mfa_enforced_for_guest_users().execute() + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires MFA for guest users." + ) + + def test_policy_enabled_targeting_all_users_with_mfa(self): + """Test PASS when an enabled policy targets all users with MFA for all apps.""" + 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_conditional_access_policy_mfa_enforced_for_guest_users.entra_conditional_access_policy_mfa_enforced_for_guest_users import ( + entra_conditional_access_policy_mfa_enforced_for_guest_users, + ) + + policy = build_policy( + display_name="MFA for All Users", + state=ConditionalAccessPolicyState.ENABLED, + included_users=["All"], + built_in_controls=[ConditionalAccessGrantControl.MFA], + ) + entra_client.conditional_access_policies = {policy.id: policy} + + result = ( + entra_conditional_access_policy_mfa_enforced_for_guest_users().execute() + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Conditional Access Policy MFA for All Users requires MFA for guest users." + ) + assert result[0].resource_id == policy.id + assert result[0].resource_name == "MFA for All Users" + + def test_policy_enabled_targeting_guests_or_external_users(self): + """Test PASS when an enabled policy specifically targets GuestsOrExternalUsers.""" + 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_conditional_access_policy_mfa_enforced_for_guest_users.entra_conditional_access_policy_mfa_enforced_for_guest_users import ( + entra_conditional_access_policy_mfa_enforced_for_guest_users, + ) + + policy = build_policy( + display_name="MFA for Guest Users", + state=ConditionalAccessPolicyState.ENABLED, + included_users=["GuestsOrExternalUsers"], + built_in_controls=[ConditionalAccessGrantControl.MFA], + ) + entra_client.conditional_access_policies = {policy.id: policy} + + result = ( + entra_conditional_access_policy_mfa_enforced_for_guest_users().execute() + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Conditional Access Policy MFA for Guest Users requires MFA for guest users." + ) + assert result[0].resource_id == policy.id + + def test_policy_enabled_targeting_all_guest_types_via_included_guests(self): + """Test PASS when policy targets all six guest types via included_guests_or_external_users.""" + 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_conditional_access_policy_mfa_enforced_for_guest_users.entra_conditional_access_policy_mfa_enforced_for_guest_users import ( + entra_conditional_access_policy_mfa_enforced_for_guest_users, + ) + + policy = build_policy( + display_name="MFA for All Guest Types", + state=ConditionalAccessPolicyState.ENABLED, + built_in_controls=[ConditionalAccessGrantControl.MFA], + included_guests_or_external_users=GuestsOrExternalUsers( + guest_or_external_user_types=list(ALL_GUEST_USER_TYPES), + ), + ) + entra_client.conditional_access_policies = {policy.id: policy} + + result = ( + entra_conditional_access_policy_mfa_enforced_for_guest_users().execute() + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == policy.id + + def test_policy_with_authentication_strength_passes(self): + """Test PASS when policy uses authentication strength instead of MFA built-in control.""" + 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_conditional_access_policy_mfa_enforced_for_guest_users.entra_conditional_access_policy_mfa_enforced_for_guest_users import ( + entra_conditional_access_policy_mfa_enforced_for_guest_users, + ) + + policy = build_policy( + display_name="Auth Strength for Guests", + state=ConditionalAccessPolicyState.ENABLED, + included_users=["GuestsOrExternalUsers"], + authentication_strength="Phishing-resistant MFA", + ) + entra_client.conditional_access_policies = {policy.id: policy} + + result = ( + entra_conditional_access_policy_mfa_enforced_for_guest_users().execute() + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == policy.id + + def test_policy_only_password_change_fails(self): + """Test FAIL when the policy only requires password change.""" + 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_conditional_access_policy_mfa_enforced_for_guest_users.entra_conditional_access_policy_mfa_enforced_for_guest_users import ( + entra_conditional_access_policy_mfa_enforced_for_guest_users, + ) + + policy = build_policy( + display_name="Password Change for Guests", + state=ConditionalAccessPolicyState.ENABLED, + included_users=["GuestsOrExternalUsers"], + built_in_controls=[ConditionalAccessGrantControl.PASSWORD_CHANGE], + ) + entra_client.conditional_access_policies = {policy.id: policy} + + result = ( + entra_conditional_access_policy_mfa_enforced_for_guest_users().execute() + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires MFA for guest users." + ) + + def test_policy_not_targeting_all_apps_fails(self): + """Test FAIL when the policy does not target all cloud applications.""" + 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_conditional_access_policy_mfa_enforced_for_guest_users.entra_conditional_access_policy_mfa_enforced_for_guest_users import ( + entra_conditional_access_policy_mfa_enforced_for_guest_users, + ) + + policy = build_policy( + display_name="MFA for Guests Specific App", + state=ConditionalAccessPolicyState.ENABLED, + included_applications=["some-specific-app-id"], + included_users=["GuestsOrExternalUsers"], + built_in_controls=[ConditionalAccessGrantControl.MFA], + ) + entra_client.conditional_access_policies = {policy.id: policy} + + result = ( + entra_conditional_access_policy_mfa_enforced_for_guest_users().execute() + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires MFA for guest users." + ) + + def test_policy_not_targeting_guests_fails(self): + """Test FAIL when the policy does not target guest users.""" + 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_conditional_access_policy_mfa_enforced_for_guest_users.entra_conditional_access_policy_mfa_enforced_for_guest_users import ( + entra_conditional_access_policy_mfa_enforced_for_guest_users, + ) + + policy = build_policy( + display_name="MFA for Specific Users", + state=ConditionalAccessPolicyState.ENABLED, + included_users=[str(uuid4())], + built_in_controls=[ConditionalAccessGrantControl.MFA], + ) + entra_client.conditional_access_policies = {policy.id: policy} + + result = ( + entra_conditional_access_policy_mfa_enforced_for_guest_users().execute() + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires MFA for guest users." + ) + + def test_policy_with_partial_guest_types_fails(self): + """Test FAIL when policy only targets some guest types but not all six.""" + 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_conditional_access_policy_mfa_enforced_for_guest_users.entra_conditional_access_policy_mfa_enforced_for_guest_users import ( + entra_conditional_access_policy_mfa_enforced_for_guest_users, + ) + + policy = build_policy( + display_name="MFA for Some Guests", + state=ConditionalAccessPolicyState.ENABLED, + built_in_controls=[ConditionalAccessGrantControl.MFA], + included_guests_or_external_users=GuestsOrExternalUsers( + guest_or_external_user_types=[ + GuestOrExternalUserType.B2B_COLLABORATION_GUEST, + GuestOrExternalUserType.INTERNAL_GUEST, + ], + ), + ) + entra_client.conditional_access_policies = {policy.id: policy} + + result = ( + entra_conditional_access_policy_mfa_enforced_for_guest_users().execute() + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires MFA for guest users." + ) + + def test_policy_with_excluded_guest_types_fails(self): + """Test FAIL when the policy excludes guest/external user types.""" + 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_conditional_access_policy_mfa_enforced_for_guest_users.entra_conditional_access_policy_mfa_enforced_for_guest_users import ( + entra_conditional_access_policy_mfa_enforced_for_guest_users, + ) + + policy = build_policy( + display_name="MFA for Guests with Exclusions", + state=ConditionalAccessPolicyState.ENABLED, + included_users=["All"], + built_in_controls=[ConditionalAccessGrantControl.MFA], + excluded_guests_or_external_users=GuestsOrExternalUsers( + guest_or_external_user_types=[ + GuestOrExternalUserType.SERVICE_PROVIDER, + ], + ), + ) + entra_client.conditional_access_policies = {policy.id: policy} + + result = ( + entra_conditional_access_policy_mfa_enforced_for_guest_users().execute() + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires MFA for guest users." + ) + + def test_policy_excluding_guests_via_excluded_users_fails(self): + """Test FAIL when the policy excludes GuestsOrExternalUsers.""" + 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_conditional_access_policy_mfa_enforced_for_guest_users.entra_conditional_access_policy_mfa_enforced_for_guest_users import ( + entra_conditional_access_policy_mfa_enforced_for_guest_users, + ) + + policy = build_policy( + display_name="MFA for Users Except Guests", + state=ConditionalAccessPolicyState.ENABLED, + included_users=["All"], + excluded_users=["GuestsOrExternalUsers"], + built_in_controls=[ConditionalAccessGrantControl.MFA], + ) + entra_client.conditional_access_policies = {policy.id: policy} + + result = ( + entra_conditional_access_policy_mfa_enforced_for_guest_users().execute() + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires MFA for guest users." + ) + + def test_policy_with_selected_external_tenants_fails(self): + """Test FAIL when the policy only targets selected external tenants.""" + 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_conditional_access_policy_mfa_enforced_for_guest_users.entra_conditional_access_policy_mfa_enforced_for_guest_users import ( + entra_conditional_access_policy_mfa_enforced_for_guest_users, + ) + + policy = build_policy( + display_name="MFA for Selected External Tenants", + state=ConditionalAccessPolicyState.ENABLED, + built_in_controls=[ConditionalAccessGrantControl.MFA], + included_guests_or_external_users=GuestsOrExternalUsers( + guest_or_external_user_types=list(ALL_GUEST_USER_TYPES), + external_tenants_membership_kind=ExternalTenantsMembershipKind.ENUMERATED, + ), + ) + entra_client.conditional_access_policies = {policy.id: policy} + + result = ( + entra_conditional_access_policy_mfa_enforced_for_guest_users().execute() + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires MFA for guest users." + ) + + def test_reporting_only_policy_fails_with_detail(self): + """Test FAIL with detail when the matching policy is in report-only mode.""" + 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_conditional_access_policy_mfa_enforced_for_guest_users.entra_conditional_access_policy_mfa_enforced_for_guest_users import ( + entra_conditional_access_policy_mfa_enforced_for_guest_users, + ) + + policy = build_policy( + display_name="MFA for Guests Report Only", + state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, + included_users=["GuestsOrExternalUsers"], + built_in_controls=[ConditionalAccessGrantControl.MFA], + ) + entra_client.conditional_access_policies = {policy.id: policy} + + result = ( + entra_conditional_access_policy_mfa_enforced_for_guest_users().execute() + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Conditional Access Policy MFA for Guests Report Only targets guest users with MFA but is only in report-only mode." + ) + assert result[0].resource_id == policy.id + assert result[0].resource_name == "MFA for Guests Report Only" + + def test_no_application_conditions_fails(self): + """Test FAIL when the policy has no application conditions.""" + 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_conditional_access_policy_mfa_enforced_for_guest_users.entra_conditional_access_policy_mfa_enforced_for_guest_users import ( + entra_conditional_access_policy_mfa_enforced_for_guest_users, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + policy_id = str(uuid4()) + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name="No App Conditions", + conditions=Conditions( + application_conditions=None, + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["GuestsOrExternalUsers"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.MFA], + operator=GrantControlOperator.OR, + authentication_strength=None, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=None, + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + result = ( + entra_conditional_access_policy_mfa_enforced_for_guest_users().execute() + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires MFA for guest users." + ) + + def test_no_mfa_grant_control_fails(self): + """Test FAIL when the policy does not require MFA as a grant control.""" + 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_conditional_access_policy_mfa_enforced_for_guest_users.entra_conditional_access_policy_mfa_enforced_for_guest_users import ( + entra_conditional_access_policy_mfa_enforced_for_guest_users, + ) + + policy = build_policy( + display_name="Compliant Device for Guests", + state=ConditionalAccessPolicyState.ENABLED, + included_users=["GuestsOrExternalUsers"], + built_in_controls=[ConditionalAccessGrantControl.COMPLIANT_DEVICE], + ) + entra_client.conditional_access_policies = {policy.id: policy} + + result = ( + entra_conditional_access_policy_mfa_enforced_for_guest_users().execute() + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires MFA for guest users." + )