From bba1ef4ee45289edb56683649e375a47a1ab83dc Mon Sep 17 00:00:00 2001 From: "Hugo P.Brito" Date: Wed, 8 Apr 2026 16:34:05 +0100 Subject: [PATCH] feat(m365): add entra_conditional_access_policy_sign_in_frequency_enforced security check Add new security check entra_conditional_access_policy_sign_in_frequency_enforced for m365 provider. Includes check implementation, metadata, and unit tests. --- prowler/CHANGELOG.md | 1 + .../compliance/m365/iso27001_2022_m365.json | 3 + .../__init__.py | 0 ...y_sign_in_frequency_enforced.metadata.json | 39 ++ ...ccess_policy_sign_in_frequency_enforced.py | 127 ++++ .../m365/services/entra/entra_service.py | 76 +++ .../__init__.py | 0 ..._policy_sign_in_frequency_enforced_test.py | 593 ++++++++++++++++++ 8 files changed, 839 insertions(+) create mode 100644 prowler/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_enforced/__init__.py create mode 100644 prowler/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_enforced/entra_conditional_access_policy_sign_in_frequency_enforced.metadata.json create mode 100644 prowler/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_enforced/entra_conditional_access_policy_sign_in_frequency_enforced.py create mode 100644 tests/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_enforced/__init__.py create mode 100644 tests/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_enforced/entra_conditional_access_policy_sign_in_frequency_enforced_test.py diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 56c363b9cf..d5ab387f30 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -20,6 +20,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `calendar_external_sharing_primary_calendar`, `calendar_external_sharing_secondary_calendar`, and `calendar_external_invitations_warning` checks for Google Workspace provider using the Cloud Identity Policy API [(#10597)](https://github.com/prowler-cloud/prowler/pull/10597) - `entra_conditional_access_policy_device_registration_mfa_required` check and `entra_intune_enrollment_sign_in_frequency_every_time` enhancement for M365 provider [(#10222)](https://github.com/prowler-cloud/prowler/pull/10222) - `entra_conditional_access_policy_block_elevated_insider_risk` check for M365 provider [(#10234)](https://github.com/prowler-cloud/prowler/pull/10234) +- `entra_conditional_access_policy_sign_in_frequency_enforced` check for m365 provider [(#10618)](https://github.com/prowler-cloud/prowler/pull/10618) - `Vercel` provider support with 30 checks [(#10189)](https://github.com/prowler-cloud/prowler/pull/10189) ### 🔄 Changed diff --git a/prowler/compliance/m365/iso27001_2022_m365.json b/prowler/compliance/m365/iso27001_2022_m365.json index ede6727166..af08dac5d2 100644 --- a/prowler/compliance/m365/iso27001_2022_m365.json +++ b/prowler/compliance/m365/iso27001_2022_m365.json @@ -207,6 +207,7 @@ "entra_admin_portals_access_restriction", "entra_admin_users_phishing_resistant_mfa_enabled", "entra_conditional_access_policy_block_o365_elevated_insider_risk", + "entra_conditional_access_policy_sign_in_frequency_enforced", "entra_policy_guest_users_access_restrictions", "entra_seamless_sso_disabled" ] @@ -250,6 +251,7 @@ "entra_conditional_access_policy_device_registration_mfa_required", "entra_intune_enrollment_sign_in_frequency_every_time", "entra_conditional_access_policy_device_code_flow_blocked", + "entra_conditional_access_policy_sign_in_frequency_enforced", "entra_legacy_authentication_blocked", "entra_managed_device_required_for_authentication", "entra_seamless_sso_disabled", @@ -711,6 +713,7 @@ "entra_break_glass_account_fido2_security_key_registered", "entra_conditional_access_policy_approved_client_app_required_for_mobile", "entra_conditional_access_policy_device_code_flow_blocked", + "entra_conditional_access_policy_sign_in_frequency_enforced", "entra_identity_protection_sign_in_risk_enabled", "entra_managed_device_required_for_authentication", "entra_seamless_sso_disabled", diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_enforced/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_enforced/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_enforced/entra_conditional_access_policy_sign_in_frequency_enforced.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_enforced/entra_conditional_access_policy_sign_in_frequency_enforced.metadata.json new file mode 100644 index 0000000000..eca3a5a1fd --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_enforced/entra_conditional_access_policy_sign_in_frequency_enforced.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "m365", + "CheckID": "entra_conditional_access_policy_sign_in_frequency_enforced", + "CheckTitle": "Conditional Access policy enforces sign-in frequency for non-corporate devices", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Microsoft Entra **Conditional Access** policy with **sign-in frequency** controls how often users must re-authenticate when accessing resources from non-corporate devices.\n\nThis ensures that sessions on unmanaged devices are time-limited, reducing the window of opportunity for unauthorized access through stale sessions.", + "Risk": "Without sign-in frequency enforcement on non-corporate devices, user sessions may persist indefinitely on unmanaged devices.\n\n- **Session hijacking** on shared or public devices becomes more likely\n- **Stolen session tokens** remain valid for extended periods\n- **Unauthorized access** from compromised personal devices", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-session-lifetime", + "https://maester.dev/docs/tests/MT.1018" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com.\n2. Expand **Protection** > **Conditional Access** and select **Policies**.\n3. Click **New policy**.\n4. Under **Users**, select **All users**.\n5. Under **Target resources**, select **All cloud apps**.\n6. Under **Conditions** > **Filter for devices**, configure a rule to target non-corporate devices (e.g., non-compliant and non-domain-joined).\n7. Under **Session**, enable **Sign-in frequency** and set it to the desired interval (e.g., 1 hour).\n8. Set the policy to **On** and click **Create**.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure a Conditional Access policy to enforce **sign-in frequency** on non-corporate devices. Use device filters to target unmanaged endpoints and set a time-based re-authentication interval to limit session duration.\n\nThis aligns with **zero trust** principles by ensuring sessions on untrusted devices are regularly validated.", + "Url": "https://hub.prowler.com/check/entra_conditional_access_policy_sign_in_frequency_enforced" + } + }, + "Categories": [ + "e3" + ], + "DependsOn": [], + "RelatedTo": [ + "entra_conditional_access_policy_app_enforced_restrictions" + ], + "Notes": "This check corresponds to Maester test MT.1018 (Test-MtCaEnforceSignInFrequency). A qualifying policy must target all users, all applications, enforce time-based sign-in frequency, and include a device filter targeting non-corporate devices." +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_enforced/entra_conditional_access_policy_sign_in_frequency_enforced.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_enforced/entra_conditional_access_policy_sign_in_frequency_enforced.py new file mode 100644 index 0000000000..d74c5efe69 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_enforced/entra_conditional_access_policy_sign_in_frequency_enforced.py @@ -0,0 +1,127 @@ +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, + DeviceFilterMode, + SignInFrequencyInterval, +) + + +class entra_conditional_access_policy_sign_in_frequency_enforced(Check): + """Check if at least one Conditional Access policy enforces sign-in frequency for non-corporate devices. + + This check verifies that the tenant has at least one enabled Conditional Access policy + that enforces time-based sign-in frequency targeting all users and all applications, + with a device filter scoping the policy to non-corporate (unmanaged) devices. + + - PASS: At least one enabled policy enforces sign-in frequency with a device filter + targeting non-corporate devices, for all users and all applications. + - FAIL: No enabled policy meets the sign-in frequency enforcement criteria for + non-corporate devices. + """ + + def _targets_all_users(self, included_users: list[str]) -> bool: + """Check if the policy targets all users. + + Returns True if 'All' is in the included users list. + """ + return "All" in included_users + + def _targets_all_applications(self, included_applications: list[str]) -> bool: + """Check if the policy targets all applications. + + Returns True if 'All' is in the included applications list. + """ + return "All" in included_applications + + def _has_sign_in_frequency_enabled(self, policy) -> bool: + """Check if the policy has time-based sign-in frequency enabled. + + Returns True if sign-in frequency is enabled with a time-based interval. + """ + sign_in_freq = policy.session_controls.sign_in_frequency + return ( + sign_in_freq.is_enabled + and sign_in_freq.interval == SignInFrequencyInterval.TIME_BASED + ) + + def _has_non_corporate_device_filter(self, policy) -> bool: + """Check if the policy has a device filter targeting non-corporate devices. + + The device filter can target non-corporate devices in two ways: + - Include mode: The filter rule matches non-compliant and non-domain-joined devices. + - Exclude mode: The filter rule excludes compliant or domain-joined devices. + + Returns True if the policy has a device filter configured with a rule. + """ + device_conditions = policy.conditions.device_conditions + if not device_conditions: + return False + if not device_conditions.device_filter_mode: + return False + if not device_conditions.device_filter_rule: + return False + if device_conditions.device_filter_mode == DeviceFilterMode.INCLUDE: + rule = device_conditions.device_filter_rule.lower() + return ( + "device.iscompliant" in rule or "device.trusttype" in rule + ) + elif device_conditions.device_filter_mode == DeviceFilterMode.EXCLUDE: + rule = device_conditions.device_filter_rule.lower() + return ( + "device.iscompliant" in rule or "device.trusttype" in rule + ) + return False + + def execute(self) -> list[CheckReportM365]: + """Execute the check for sign-in frequency enforcement in Conditional Access policies. + + Returns: + list[CheckReportM365]: A list 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 enforces sign-in frequency for non-corporate devices." + + for policy in entra_client.conditional_access_policies.values(): + if policy.state == ConditionalAccessPolicyState.DISABLED: + continue + + if not self._targets_all_users( + policy.conditions.user_conditions.included_users + ): + continue + + if not self._targets_all_applications( + policy.conditions.application_conditions.included_applications + ): + continue + + if not self._has_sign_in_frequency_enabled(policy): + continue + + if not self._has_non_corporate_device_filter(policy): + continue + + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name=policy.display_name, + resource_id=policy.id, + ) + if policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING: + report.status = "FAIL" + report.status_extended = f"Conditional Access Policy '{policy.display_name}' reports sign-in frequency for non-corporate devices but does not enforce it." + else: + report.status = "PASS" + report.status_extended = f"Conditional Access Policy '{policy.display_name}' enforces sign-in frequency for non-corporate devices." + break + + 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..1050d75693 100644 --- a/prowler/providers/m365/services/entra/entra_service.py +++ b/prowler/providers/m365/services/entra/entra_service.py @@ -331,6 +331,65 @@ class Entra(M365Service): authentication_flows=self._parse_authentication_flows( raw_auth_flows_map.get(policy.id) ), + device_conditions=DeviceConditions( + device_filter_mode=( + DeviceFilterMode( + getattr( + getattr( + getattr( + policy.conditions, + "devices", + None, + ), + "device_filter", + None, + ), + "mode", + None, + ) + ) + if getattr( + getattr( + policy.conditions, "devices", None + ), + "device_filter", + None, + ) + and getattr( + getattr( + getattr( + policy.conditions, "devices", None + ), + "device_filter", + None, + ), + "mode", + None, + ) + else None + ), + device_filter_rule=( + getattr( + getattr( + getattr( + policy.conditions, "devices", None + ), + "device_filter", + None, + ), + "rule", + None, + ) + if getattr( + getattr( + policy.conditions, "devices", None + ), + "device_filter", + None, + ) + else None + ), + ), ), grant_controls=GrantControls( built_in_controls=( @@ -992,6 +1051,20 @@ class InsiderRiskLevel(Enum): ELEVATED = "elevated" +class DeviceFilterMode(Enum): + """Mode for device filter in Conditional Access policies.""" + + INCLUDE = "include" + EXCLUDE = "exclude" + + +class DeviceConditions(BaseModel): + """Model representing device conditions for Conditional Access policies.""" + + device_filter_mode: Optional[DeviceFilterMode] = None + device_filter_rule: Optional[str] = None + + class PlatformConditions(BaseModel): """Model representing platform conditions for Conditional Access policies.""" @@ -1013,6 +1086,8 @@ class AuthenticationFlows(BaseModel): class Conditions(BaseModel): + """Model representing conditions for Conditional Access policies.""" + application_conditions: Optional[ApplicationsConditions] user_conditions: Optional[UsersConditions] client_app_types: Optional[List[ClientAppType]] @@ -1021,6 +1096,7 @@ class Conditions(BaseModel): insider_risk_levels: Optional[InsiderRiskLevel] = None platform_conditions: Optional[PlatformConditions] = None authentication_flows: Optional[AuthenticationFlows] = None + device_conditions: Optional[DeviceConditions] = None class PersistentBrowser(BaseModel): diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_enforced/__init__.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_enforced/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_enforced/entra_conditional_access_policy_sign_in_frequency_enforced_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_enforced/entra_conditional_access_policy_sign_in_frequency_enforced_test.py new file mode 100644 index 0000000000..c9d9f14f78 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_sign_in_frequency_enforced/entra_conditional_access_policy_sign_in_frequency_enforced_test.py @@ -0,0 +1,593 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.m365.services.entra.entra_service import ( + ApplicationsConditions, + ConditionalAccessPolicyState, + Conditions, + DeviceConditions, + DeviceFilterMode, + GrantControlOperator, + GrantControls, + PersistentBrowser, + SessionControls, + SignInFrequency, + SignInFrequencyInterval, + SignInFrequencyType, + 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_sign_in_frequency_enforced.entra_conditional_access_policy_sign_in_frequency_enforced" + + +def _make_policy( + policy_id, + display_name, + state=ConditionalAccessPolicyState.ENABLED, + included_users=None, + included_applications=None, + sign_in_frequency_enabled=True, + sign_in_frequency_interval=SignInFrequencyInterval.TIME_BASED, + sign_in_frequency_value=1, + sign_in_frequency_type=SignInFrequencyType.HOURS, + device_filter_mode=None, + device_filter_rule=None, +): + """Create a ConditionalAccessPolicy with the given parameters.""" + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + return ConditionalAccessPolicy( + id=policy_id, + 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 ["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + device_conditions=DeviceConditions( + device_filter_mode=device_filter_mode, + device_filter_rule=device_filter_rule, + ), + ), + grant_controls=GrantControls( + built_in_controls=[], + operator=GrantControlOperator.AND, + authentication_strength=None, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser(is_enabled=False, mode="always"), + sign_in_frequency=SignInFrequency( + is_enabled=sign_in_frequency_enabled, + frequency=sign_in_frequency_value, + type=sign_in_frequency_type, + interval=sign_in_frequency_interval, + ), + ), + state=state, + ) + + +class Test_entra_conditional_access_policy_sign_in_frequency_enforced: + """Tests for sign-in frequency enforcement on non-corporate devices.""" + + def test_entra_no_conditional_access_policies(self): + """Test FAIL when no conditional access policies exist.""" + 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_sign_in_frequency_enforced.entra_conditional_access_policy_sign_in_frequency_enforced import ( + entra_conditional_access_policy_sign_in_frequency_enforced, + ) + + entra_client.conditional_access_policies = {} + + check = entra_conditional_access_policy_sign_in_frequency_enforced() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy enforces sign-in frequency for non-corporate devices." + ) + assert result[0].resource == {} + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + assert result[0].location == "global" + + def test_entra_policy_disabled(self): + """Test FAIL when a qualifying policy is 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_conditional_access_policy_sign_in_frequency_enforced.entra_conditional_access_policy_sign_in_frequency_enforced import ( + entra_conditional_access_policy_sign_in_frequency_enforced, + ) + + entra_client.conditional_access_policies = { + policy_id: _make_policy( + policy_id=policy_id, + display_name="Disabled Policy", + state=ConditionalAccessPolicyState.DISABLED, + device_filter_mode=DeviceFilterMode.INCLUDE, + device_filter_rule="device.isCompliant -ne True -or device.trustType -ne \"ServerAD\"", + ) + } + + check = entra_conditional_access_policy_sign_in_frequency_enforced() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + + def test_entra_policy_enabled_for_reporting(self): + """Test FAIL when policy is enabled for reporting but not enforcing.""" + policy_id = str(uuid4()) + display_name = "Reporting Only Policy" + 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_sign_in_frequency_enforced.entra_conditional_access_policy_sign_in_frequency_enforced import ( + entra_conditional_access_policy_sign_in_frequency_enforced, + ) + + entra_client.conditional_access_policies = { + policy_id: _make_policy( + policy_id=policy_id, + display_name=display_name, + state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, + device_filter_mode=DeviceFilterMode.INCLUDE, + device_filter_rule="device.isCompliant -ne True -or device.trustType -ne \"ServerAD\"", + ) + } + + check = entra_conditional_access_policy_sign_in_frequency_enforced() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Conditional Access Policy '{display_name}' reports sign-in frequency for non-corporate devices but does not enforce it." + ) + assert result[0].resource_name == display_name + assert result[0].resource_id == policy_id + + def test_entra_policy_missing_all_users(self): + """Test FAIL when policy does not target all users.""" + 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_conditional_access_policy_sign_in_frequency_enforced.entra_conditional_access_policy_sign_in_frequency_enforced import ( + entra_conditional_access_policy_sign_in_frequency_enforced, + ) + + entra_client.conditional_access_policies = { + policy_id: _make_policy( + policy_id=policy_id, + display_name="Limited Users Policy", + included_users=["user1@example.com"], + device_filter_mode=DeviceFilterMode.INCLUDE, + device_filter_rule="device.isCompliant -ne True", + ) + } + + check = entra_conditional_access_policy_sign_in_frequency_enforced() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_name == "Conditional Access Policies" + + def test_entra_policy_missing_all_applications(self): + """Test FAIL when policy does not target all applications.""" + 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_conditional_access_policy_sign_in_frequency_enforced.entra_conditional_access_policy_sign_in_frequency_enforced import ( + entra_conditional_access_policy_sign_in_frequency_enforced, + ) + + entra_client.conditional_access_policies = { + policy_id: _make_policy( + policy_id=policy_id, + display_name="Limited Apps Policy", + included_applications=["Office365"], + device_filter_mode=DeviceFilterMode.INCLUDE, + device_filter_rule="device.isCompliant -ne True", + ) + } + + check = entra_conditional_access_policy_sign_in_frequency_enforced() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_name == "Conditional Access Policies" + + def test_entra_policy_sign_in_frequency_not_enabled(self): + """Test FAIL when sign-in frequency is not enabled.""" + 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_conditional_access_policy_sign_in_frequency_enforced.entra_conditional_access_policy_sign_in_frequency_enforced import ( + entra_conditional_access_policy_sign_in_frequency_enforced, + ) + + entra_client.conditional_access_policies = { + policy_id: _make_policy( + policy_id=policy_id, + display_name="No Sign-In Freq Policy", + sign_in_frequency_enabled=False, + device_filter_mode=DeviceFilterMode.INCLUDE, + device_filter_rule="device.isCompliant -ne True", + ) + } + + check = entra_conditional_access_policy_sign_in_frequency_enforced() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_name == "Conditional Access Policies" + + def test_entra_policy_sign_in_frequency_not_time_based(self): + """Test FAIL when sign-in frequency interval is not time-based.""" + 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_conditional_access_policy_sign_in_frequency_enforced.entra_conditional_access_policy_sign_in_frequency_enforced import ( + entra_conditional_access_policy_sign_in_frequency_enforced, + ) + + entra_client.conditional_access_policies = { + policy_id: _make_policy( + policy_id=policy_id, + display_name="EveryTime Policy", + sign_in_frequency_interval=SignInFrequencyInterval.EVERY_TIME, + device_filter_mode=DeviceFilterMode.INCLUDE, + device_filter_rule="device.isCompliant -ne True", + ) + } + + check = entra_conditional_access_policy_sign_in_frequency_enforced() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_name == "Conditional Access Policies" + + def test_entra_policy_no_device_filter(self): + """Test FAIL when policy has no device filter.""" + 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_conditional_access_policy_sign_in_frequency_enforced.entra_conditional_access_policy_sign_in_frequency_enforced import ( + entra_conditional_access_policy_sign_in_frequency_enforced, + ) + + entra_client.conditional_access_policies = { + policy_id: _make_policy( + policy_id=policy_id, + display_name="No Device Filter Policy", + ) + } + + check = entra_conditional_access_policy_sign_in_frequency_enforced() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_name == "Conditional Access Policies" + + def test_entra_policy_device_filter_include_compliant(self): + """Test PASS with include mode device filter targeting non-compliant devices.""" + policy_id = str(uuid4()) + display_name = "Sign-In Freq Include Filter" + 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_sign_in_frequency_enforced.entra_conditional_access_policy_sign_in_frequency_enforced import ( + entra_conditional_access_policy_sign_in_frequency_enforced, + ) + + entra_client.conditional_access_policies = { + policy_id: _make_policy( + policy_id=policy_id, + display_name=display_name, + device_filter_mode=DeviceFilterMode.INCLUDE, + device_filter_rule='device.isCompliant -ne True -or device.trustType -ne "ServerAD"', + ) + } + + check = entra_conditional_access_policy_sign_in_frequency_enforced() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Conditional Access Policy '{display_name}' enforces sign-in frequency for non-corporate devices." + ) + assert result[0].resource_name == display_name + assert result[0].resource_id == policy_id + + def test_entra_policy_device_filter_exclude_compliant(self): + """Test PASS with exclude mode device filter excluding corporate devices.""" + policy_id = str(uuid4()) + display_name = "Sign-In Freq Exclude Filter" + 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_sign_in_frequency_enforced.entra_conditional_access_policy_sign_in_frequency_enforced import ( + entra_conditional_access_policy_sign_in_frequency_enforced, + ) + + entra_client.conditional_access_policies = { + policy_id: _make_policy( + policy_id=policy_id, + display_name=display_name, + device_filter_mode=DeviceFilterMode.EXCLUDE, + device_filter_rule='device.isCompliant -eq True -and device.trustType -eq "ServerAD"', + ) + } + + check = entra_conditional_access_policy_sign_in_frequency_enforced() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_name == display_name + assert result[0].resource_id == policy_id + + def test_entra_policy_device_filter_unrelated_rule(self): + """Test FAIL when device filter rule does not target corporate device properties.""" + 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_conditional_access_policy_sign_in_frequency_enforced.entra_conditional_access_policy_sign_in_frequency_enforced import ( + entra_conditional_access_policy_sign_in_frequency_enforced, + ) + + entra_client.conditional_access_policies = { + policy_id: _make_policy( + policy_id=policy_id, + display_name="Unrelated Filter Policy", + device_filter_mode=DeviceFilterMode.INCLUDE, + device_filter_rule='device.displayName -contains "kiosk"', + ) + } + + check = entra_conditional_access_policy_sign_in_frequency_enforced() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_name == "Conditional Access Policies" + + def test_entra_multiple_policies_one_compliant(self): + """Test PASS when at least one policy among multiple is compliant.""" + policy_id_1 = str(uuid4()) + policy_id_2 = str(uuid4()) + display_name_2 = "Compliant Sign-In Freq Policy" + 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_sign_in_frequency_enforced.entra_conditional_access_policy_sign_in_frequency_enforced import ( + entra_conditional_access_policy_sign_in_frequency_enforced, + ) + + entra_client.conditional_access_policies = { + policy_id_1: _make_policy( + policy_id=policy_id_1, + display_name="Non-Compliant Policy", + sign_in_frequency_enabled=False, + ), + policy_id_2: _make_policy( + policy_id=policy_id_2, + display_name=display_name_2, + device_filter_mode=DeviceFilterMode.INCLUDE, + device_filter_rule='device.trustType -ne "ServerAD"', + ), + } + + check = entra_conditional_access_policy_sign_in_frequency_enforced() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_name == display_name_2 + assert result[0].resource_id == policy_id_2 + + def test_entra_policy_with_trust_type_only(self): + """Test PASS with device filter referencing only trustType.""" + policy_id = str(uuid4()) + display_name = "TrustType Filter Policy" + 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_sign_in_frequency_enforced.entra_conditional_access_policy_sign_in_frequency_enforced import ( + entra_conditional_access_policy_sign_in_frequency_enforced, + ) + + entra_client.conditional_access_policies = { + policy_id: _make_policy( + policy_id=policy_id, + display_name=display_name, + device_filter_mode=DeviceFilterMode.EXCLUDE, + device_filter_rule='device.trustType -eq "ServerAD"', + ) + } + + check = entra_conditional_access_policy_sign_in_frequency_enforced() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_name == display_name