diff --git a/prowler/changelog.d/entra-dynamic-group-member-of-rule.added.md b/prowler/changelog.d/entra-dynamic-group-member-of-rule.added.md new file mode 100644 index 0000000000..a2b32f6177 --- /dev/null +++ b/prowler/changelog.d/entra-dynamic-group-member-of-rule.added.md @@ -0,0 +1 @@ +`entra_dynamic_group_member_of_rule_not_used` check for M365 provider, flagging dynamic groups whose membership rule uses the `memberOf` operator that Microsoft retires on November 3, 2026 diff --git a/prowler/compliance/m365/iso27001_2022_m365.json b/prowler/compliance/m365/iso27001_2022_m365.json index f6e5fa1cec..784099be32 100644 --- a/prowler/compliance/m365/iso27001_2022_m365.json +++ b/prowler/compliance/m365/iso27001_2022_m365.json @@ -208,6 +208,7 @@ "entra_conditional_access_policy_block_unknown_device_platforms", "entra_conditional_access_policy_directory_sync_account_excluded", "entra_conditional_access_policy_corporate_device_sign_in_frequency_enforced", + "entra_dynamic_group_member_of_rule_not_used", "entra_policy_guest_users_access_restrictions", "entra_seamless_sso_disabled" ] @@ -280,6 +281,7 @@ "Checks": [ "entra_admin_portals_access_restriction", "entra_app_registration_no_unused_privileged_permissions", + "entra_dynamic_group_member_of_rule_not_used", "entra_policy_guest_users_access_restrictions", "entra_service_principal_no_secrets_for_permanent_tier0_roles", "sharepoint_external_sharing_managed", diff --git a/prowler/providers/m365/services/entra/entra_dynamic_group_member_of_rule_not_used/__init__.py b/prowler/providers/m365/services/entra/entra_dynamic_group_member_of_rule_not_used/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_dynamic_group_member_of_rule_not_used/entra_dynamic_group_member_of_rule_not_used.metadata.json b/prowler/providers/m365/services/entra/entra_dynamic_group_member_of_rule_not_used/entra_dynamic_group_member_of_rule_not_used.metadata.json new file mode 100644 index 0000000000..6019b03cd8 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_dynamic_group_member_of_rule_not_used/entra_dynamic_group_member_of_rule_not_used.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "m365", + "CheckID": "entra_dynamic_group_member_of_rule_not_used", + "CheckTitle": "Dynamic group rules prevent stale access by avoiding the retiring memberOf operator", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "This check verifies that dynamic membership groups in Microsoft Entra ID do not use the **memberOf** rule operator (`user.memberof` / `device.memberof`).\n\nMicrosoft is retiring this public-preview operator on November 3, 2026. Groups that still use it will stop updating and remain frozen in their last known membership.", + "Risk": "A frozen dynamic group retains every user or device from retirement time and never updates membership. Departed or re-roled members keep app assignments, Teams/SharePoint access, group-based licences, and **Conditional Access** targeting (inclusion or exclusion). This creates a silent, growing access-control gap.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/entra/identity/users/groups-dynamic-rule-member-of", + "https://learn.microsoft.com/entra/identity/users/groups-dynamic-membership", + "https://maester.dev/docs/tests/MT.1197" + ], + "Remediation": { + "Code": { + "CLI": "Update-MgGroup -GroupId -MembershipRule '' -MembershipRuleProcessingState On", + "NativeIaC": "", + "Other": "1. Sign in to the Microsoft Entra admin center (https://entra.microsoft.com/)\n2. Navigate to Identity > Groups > All groups\n3. Select the affected dynamic group\n4. Open Dynamic membership rules\n5. Replace the memberOf expression with attribute-based rules (e.g. user.department, user.extensionAttributeN, user.employeeType)\n6. Alternatively, convert the group to Assigned membership under Properties > Membership type\n7. Validate the resulting membership and every app, Conditional Access policy, licence, and SharePoint/Teams assignment that uses the group", + "Terraform": "" + }, + "Recommendation": { + "Text": "Replace the **memberOf** operator in dynamic group rules with attribute-based expressions (e.g. `user.department`, `user.extensionAttributeN`) or convert the group to **Assigned** membership before the operator retirement date. Validate all dependent policies and assignments after migration.", + "Url": "https://hub.prowler.com/check/entra_dynamic_group_member_of_rule_not_used" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [ + "entra_dynamic_group_for_guests_created" + ], + "Notes": "Ported from Maester control MT.1197 (Test-MtDynamicGroupMemberOfRule, MIT licensed). The check does not depend on the current date for PASS/FAIL; the date only changes the wording in status_extended." +} diff --git a/prowler/providers/m365/services/entra/entra_dynamic_group_member_of_rule_not_used/entra_dynamic_group_member_of_rule_not_used.py b/prowler/providers/m365/services/entra/entra_dynamic_group_member_of_rule_not_used/entra_dynamic_group_member_of_rule_not_used.py new file mode 100644 index 0000000000..c808080893 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_dynamic_group_member_of_rule_not_used/entra_dynamic_group_member_of_rule_not_used.py @@ -0,0 +1,157 @@ +"""Check that dynamic membership groups do not use the retiring memberOf rule operator.""" + +import re +from datetime import date +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + +# Pre-compiled patterns ------------------------------------------------- +# After stripping quoted literals, detect the memberOf property reference. +_MEMBEROF_PROPERTY_RE = re.compile(r"(?i)\b(user|device)\.memberof\b") + +# Extract GUIDs that appear in a memberOf expression (on the original rule). +_GUID_RE = re.compile(r"(?i)\b[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\b") + +# Matches single- or double-quoted string literals to strip before detection. +# Entra rules escape quotes inside literals with a backtick (e.g. "a`"b"). +_QUOTED_LITERAL_RE = re.compile(r"""('(?:`.|[^'`])*'|"(?:`.|[^"`])*")""") + +# The date Microsoft retires the memberOf preview operator. +_RETIREMENT_DATE = date(2026, 11, 3) + + +def _rule_uses_memberof(rule: str) -> bool: + """Return True if *rule* references the memberOf property outside quoted literals. + + Stripping quoted strings first avoids false positives like + ``user.department -eq "user.memberOf"``. + """ + stripped = _QUOTED_LITERAL_RE.sub("", rule) + return bool(_MEMBEROF_PROPERTY_RE.search(stripped)) + + +def _extract_source_group_ids(rule: str) -> List[str]: + """Return deduplicated GUIDs found after a memberOf reference in the original rule. + + GUIDs are collected from the full (unstripped) rule and deduplicated + case-insensitively while preserving the original casing. + """ + # Find the position of the first memberOf property reference. + match = _MEMBEROF_PROPERTY_RE.search(rule) + if not match: + return [] + + after_memberof = rule[match.end() :] + seen: set = set() + result: list = [] + for guid_match in _GUID_RE.finditer(after_memberof): + guid = guid_match.group() + lower = guid.lower() + if lower not in seen: + seen.add(lower) + result.append(guid) + return result + + +class entra_dynamic_group_member_of_rule_not_used(Check): + """Ensure dynamic membership groups do not use the retiring memberOf rule operator. + + This check evaluates every Entra ID group whose ``groupTypes`` contains + ``DynamicMembership``. The ``memberOf`` operator + (``user.memberof`` / ``device.memberof``) is a public preview that + Microsoft is ending on November 3, 2026. After that date, dynamic groups + still using the operator stop updating and stay frozen in their last known + membership, creating a silent access-control gap. + + - PASS: The dynamic group's membership rule does not reference the + ``memberOf`` property, or the group has no membership rule. + - FAIL: The dynamic group's membership rule references ``user.memberof`` + or ``device.memberof``. + - MANUAL: Groups could not be read (permissions, throttling, API error). + """ + + def execute(self) -> List[CheckReportM365]: + """Execute the memberOf rule check for all dynamic membership groups. + + Returns: + A list of reports, one per dynamic membership group evaluated. + """ + findings: List[CheckReportM365] = [] + + # If groups could not be retrieved, emit a single tenant-level MANUAL. + if entra_client.groups_error: + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Dynamic groups", + resource_id="groups", + ) + report.status = "MANUAL" + report.status_extended = ( + f"Cannot evaluate dynamic group memberOf rules: " + f"{entra_client.groups_error}." + ) + findings.append(report) + return findings + + today = date.today() + + for group in entra_client.groups: + if "DynamicMembership" not in group.groupTypes: + continue + + report = CheckReportM365( + metadata=self.metadata(), + resource=group, + resource_name=group.name, + resource_id=group.id, + ) + + # A dynamic group without a rule cannot use the memberOf operator. + if not group.membershipRule: + report.status = "PASS" + report.status_extended = ( + f"Dynamic group {group.name} has no membership rule, so it " + f"does not use the memberOf rule operator." + ) + findings.append(report) + continue + + # Normalise CR/LF to spaces for matching. + normalised_rule = group.membershipRule.replace("\r", " ").replace("\n", " ") + + if _rule_uses_memberof(normalised_rule): + processing_state = group.membership_rule_processing_state or "Unknown" + source_ids = _extract_source_group_ids(normalised_rule) + source_ids_text = ( + f" referencing source group(s): {', '.join(source_ids)}" + if source_ids + else "" + ) + + if today < _RETIREMENT_DATE: + retirement_text = "is being retired on November 3, 2026" + else: + retirement_text = ( + "was retired on November 3, 2026 and the group " + "membership may already be stale" + ) + + report.status = "FAIL" + report.status_extended = ( + f"Dynamic group {group.name} (processing: {processing_state}) " + f"uses the memberOf rule operator which {retirement_text}" + f"{source_ids_text}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Dynamic group {group.name} does not use the memberOf " + f"rule operator." + ) + + 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 7f124ff0b4..e8e43562be 100644 --- a/prowler/providers/m365/services/entra/entra_service.py +++ b/prowler/providers/m365/services/entra/entra_service.py @@ -90,6 +90,10 @@ class Entra(M365Service): # dependencies) fails, so checks can report that users are unavailable # instead of silently evaluating an empty directory. self.users_error: Optional[str] = None + # Set when the Microsoft Graph /groups request fails (permissions, + # throttling, mid-pagination error), so checks can emit a MANUAL + # finding instead of silently evaluating an empty group list. + self.groups_error: Optional[str] = None self.exchange_mailbox_permission_service_principals_error: Optional[str] = None attributes = loop.run_until_complete( gather( @@ -810,6 +814,14 @@ class Entra(M365Service): ) async def _get_groups(self): + """Retrieve all Entra ID groups via Microsoft Graph. + + Fetches groups with their core properties including dynamic membership + configuration. On any API error (permissions, throttling, + mid-pagination failure) the partial result collected so far is returned + **and** ``self.groups_error`` is set so that checks can emit a MANUAL + finding instead of silently evaluating an incomplete group list. + """ logger.info("Entra - Getting groups...") groups = [] try: @@ -820,6 +832,7 @@ class Entra(M365Service): "displayName", "groupTypes", "membershipRule", + "membershipRuleProcessingState", "isAssignableToRole", "isManagementRestricted", ], @@ -840,6 +853,9 @@ class Entra(M365Service): name=group.display_name, groupTypes=group.group_types or [], membershipRule=group.membership_rule, + membership_rule_processing_state=getattr( + group, "membership_rule_processing_state", None + ), is_assignable_to_role=getattr( group, "is_assignable_to_role", False ) @@ -854,11 +870,25 @@ class Entra(M365Service): next_link = getattr(groups_data, "odata_next_link", None) if not next_link: break - groups_data = await self.client.groups.with_url(next_link).get() + try: + groups_data = await self.client.groups.with_url(next_link).get() + except Exception as pagination_error: + logger.error( + f"{pagination_error.__class__.__name__}[{pagination_error.__traceback__.tb_lineno}]: {pagination_error}" + ) + self.groups_error = ( + f"Groups list may be incomplete due to a pagination error: " + f"{pagination_error.__class__.__name__}" + ) + break except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + self.groups_error = ( + f"Unable to retrieve groups from Microsoft Graph " + f"({error.__class__.__name__})" + ) return groups async def _get_organization(self): @@ -2672,10 +2702,13 @@ class AuthenticationMethodConfiguration(BaseModel): class Group(BaseModel): + """Model representing a Microsoft Entra ID group.""" + id: str name: str groupTypes: List[str] membershipRule: Optional[str] + membership_rule_processing_state: Optional[str] = None is_assignable_to_role: bool = False is_management_restricted: bool = False diff --git a/tests/providers/m365/services/entra/entra_dynamic_group_member_of_rule_not_used/entra_dynamic_group_member_of_rule_not_used_test.py b/tests/providers/m365/services/entra/entra_dynamic_group_member_of_rule_not_used/entra_dynamic_group_member_of_rule_not_used_test.py new file mode 100644 index 0000000000..de439bf599 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_dynamic_group_member_of_rule_not_used/entra_dynamic_group_member_of_rule_not_used_test.py @@ -0,0 +1,578 @@ +from datetime import date +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import Group +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +CHECK_MODULE = ( + "prowler.providers.m365.services.entra." + "entra_dynamic_group_member_of_rule_not_used." + "entra_dynamic_group_member_of_rule_not_used" +) + + +class Test_entra_dynamic_group_member_of_rule_not_used: + """Tests for the entra_dynamic_group_member_of_rule_not_used check.""" + + def test_no_dynamic_groups(self): + """No dynamic groups exist — check returns no findings.""" + entra_client = mock.MagicMock() + entra_client.groups = [] + entra_client.groups_error = None + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 0 + + def test_only_assigned_groups(self): + """Assigned (non-dynamic) groups produce no findings.""" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-assigned", + name="Static Group", + groupTypes=["Unified"], + membershipRule=None, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 0 + + def test_groups_error_returns_manual(self): + """When groups could not be read, emit a single MANUAL finding.""" + entra_client = mock.MagicMock() + entra_client.groups_error = ( + "Unable to retrieve groups from Microsoft Graph (ODataError)" + ) + entra_client.groups = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].resource_id == "groups" + assert result[0].resource_name == "Dynamic groups" + assert "Cannot evaluate" in result[0].status_extended + assert result[0].status_extended.endswith(".") + + def test_dynamic_group_null_rule_returns_pass(self): + """A dynamic group with a null membership rule cannot use memberOf.""" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-null-rule", + name="Null Rule Group", + groupTypes=["DynamicMembership"], + membershipRule=None, + membership_rule_processing_state="On", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "has no membership rule" in result[0].status_extended + + def test_dynamic_group_empty_rule_returns_pass(self): + """A dynamic group with an empty membership rule cannot use memberOf.""" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-empty-rule", + name="Empty Rule Group", + groupTypes=["DynamicMembership"], + membershipRule="", + membership_rule_processing_state="On", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_pass_no_memberof_rule(self): + """A dynamic group with attribute-based rule passes.""" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-pass", + name="Guests", + groupTypes=["DynamicMembership"], + membershipRule='(user.userType -eq "Guest")', + membership_rule_processing_state="On", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "g-pass" + assert result[0].resource_name == "Guests" + assert "does not use the memberOf" in result[0].status_extended + assert result[0].status_extended.endswith(".") + + def test_fail_user_memberof_before_retirement(self): + """A dynamic group using user.memberof fails with pre-retirement wording.""" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-fail-user", + name="All Finance (memberOf)", + groupTypes=["DynamicMembership"], + membershipRule="user.memberof -any (group.objectId -in ['0282e19d-bf41-435d-92a4-99bab93af305','11111111-2222-3333-4444-555555555555'])", + membership_rule_processing_state="On", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + mock.patch( + f"{CHECK_MODULE}.date", + wraps=date, + ) as mock_date, + ): + mock_date.today.return_value = date(2026, 10, 1) + + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "g-fail-user" + assert "processing: On" in result[0].status_extended + assert "is being retired on November 3, 2026" in result[0].status_extended + assert "0282e19d-bf41-435d-92a4-99bab93af305" in result[0].status_extended + assert "11111111-2222-3333-4444-555555555555" in result[0].status_extended + assert result[0].status_extended.endswith(".") + + def test_fail_device_memberof_after_retirement(self): + """A dynamic group using device.memberof fails with post-retirement wording.""" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-fail-device", + name="Corp Devices", + groupTypes=["DynamicMembership"], + membershipRule="device.memberOf -any (group.objectId -in ['aabbccdd-1122-3344-5566-778899001122'])", + membership_rule_processing_state="Paused", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + mock.patch( + f"{CHECK_MODULE}.date", + wraps=date, + ) as mock_date, + ): + mock_date.today.return_value = date(2026, 11, 3) + + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "processing: Paused" in result[0].status_extended + assert "was retired on November 3, 2026" in result[0].status_extended + assert "aabbccdd-1122-3344-5566-778899001122" in result[0].status_extended + + def test_no_false_positive_memberof_in_quoted_string(self): + """memberOf inside a quoted literal must not trigger a FAIL.""" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-quoted", + name="Dept Group", + groupTypes=["DynamicMembership"], + membershipRule='user.department -eq "user.memberOf"', + membership_rule_processing_state="On", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_fail_memberof_after_backtick_escaped_quote(self): + """A backtick-escaped quote in an earlier literal must not hide memberOf.""" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-escaped", + name="Escaped Quote Group", + groupTypes=["DynamicMembership"], + membershipRule=( + 'user.department -eq "R`"D" -or user.memberof -any ' + "(group.objectId -in ['11111111-1111-1111-1111-111111111111']) " + '-or user.city -eq "Madrid"' + ), + membership_rule_processing_state="On", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_case_insensitive_detection(self): + """Detection is case-insensitive (User.MemberOf should FAIL).""" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-case", + name="Case Group", + groupTypes=["DynamicMembership"], + membershipRule="User.MemberOf -any (group.objectId -in ['aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'])", + membership_rule_processing_state="On", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + mock.patch( + f"{CHECK_MODULE}.date", + wraps=date, + ) as mock_date, + ): + mock_date.today.return_value = date(2026, 10, 1) + + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_multiline_rule_normalised(self): + """Rules with CR/LF are normalised before matching.""" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-multiline", + name="Multiline Group", + groupTypes=["DynamicMembership"], + membershipRule="user.memberof\r\n-any (group.objectId -in ['12345678-1234-1234-1234-123456789abc'])", + membership_rule_processing_state="On", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + mock.patch( + f"{CHECK_MODULE}.date", + wraps=date, + ) as mock_date, + ): + mock_date.today.return_value = date(2026, 10, 1) + + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "12345678-1234-1234-1234-123456789abc" in result[0].status_extended + + def test_mixed_groups_pass_and_fail(self): + """Multiple groups: one PASS, one FAIL, one assigned (skipped).""" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-assigned", + name="Static Group", + groupTypes=["Unified"], + membershipRule=None, + ), + Group( + id="g-good", + name="Good Dynamic", + groupTypes=["DynamicMembership"], + membershipRule='(user.department -eq "Engineering")', + membership_rule_processing_state="On", + ), + Group( + id="g-bad", + name="Bad Dynamic", + groupTypes=["DynamicMembership"], + membershipRule="user.memberof -any (group.objectId -in ['abcdef01-2345-6789-abcd-ef0123456789'])", + membership_rule_processing_state="On", + ), + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + mock.patch( + f"{CHECK_MODULE}.date", + wraps=date, + ) as mock_date, + ): + mock_date.today.return_value = date(2026, 10, 1) + + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + # Assigned group is skipped, so 2 findings + assert len(result) == 2 + statuses = {r.resource_id: r.status for r in result} + assert statuses["g-good"] == "PASS" + assert statuses["g-bad"] == "FAIL" + + def test_fail_unknown_processing_state(self): + """When processing state is None, it shows as 'Unknown'.""" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-unknown-state", + name="Unknown State Group", + groupTypes=["DynamicMembership"], + membershipRule="user.memberof -any (group.objectId -in ['aabbccdd-0000-1111-2222-333344445555'])", + membership_rule_processing_state=None, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + mock.patch( + f"{CHECK_MODULE}.date", + wraps=date, + ) as mock_date, + ): + mock_date.today.return_value = date(2026, 10, 1) + + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "processing: Unknown" in result[0].status_extended + + def test_duplicate_guids_deduplicated(self): + """Duplicate GUIDs in the rule are deduplicated in status_extended.""" + guid = "aabbccdd-1122-3344-5566-778899001122" + entra_client = mock.MagicMock() + entra_client.groups_error = None + entra_client.groups = [ + Group( + id="g-dup-guid", + name="Dup GUID Group", + groupTypes=["DynamicMembership"], + membershipRule=f"user.memberof -any (group.objectId -in ['{guid}','{guid}'])", + membership_rule_processing_state="On", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + mock.patch( + f"{CHECK_MODULE}.date", + wraps=date, + ) as mock_date, + ): + mock_date.today.return_value = date(2026, 10, 1) + + from prowler.providers.m365.services.entra.entra_dynamic_group_member_of_rule_not_used.entra_dynamic_group_member_of_rule_not_used import ( + entra_dynamic_group_member_of_rule_not_used, + ) + + check = entra_dynamic_group_member_of_rule_not_used() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + # GUID should appear only once in the source group list + assert result[0].status_extended.count(guid) == 1 diff --git a/tests/providers/m365/services/entra/microsoft365_entra_service_test.py b/tests/providers/m365/services/entra/microsoft365_entra_service_test.py index 86462d4902..6c01b8ee70 100644 --- a/tests/providers/m365/services/entra/microsoft365_entra_service_test.py +++ b/tests/providers/m365/services/entra/microsoft365_entra_service_test.py @@ -2073,3 +2073,91 @@ class TestAppRegistrationRedirectUris: assert apps["obj-2"].web_redirect_uris == [] assert apps["obj-2"].spa_redirect_uris == [] assert apps["obj-2"].public_client_redirect_uris == [] + + +class TestGetGroups: + @staticmethod + def _graph_group(group_id, rule=None, processing_state=None): + return SimpleNamespace( + id=group_id, + display_name=f"Group {group_id}", + group_types=["DynamicMembership"] if rule else None, + membership_rule=rule, + membership_rule_processing_state=processing_state, + is_assignable_to_role=None, + is_management_restricted=None, + ) + + def test_maps_dynamic_rule_fields_across_pages(self): + entra_service = Entra.__new__(Entra) + entra_service.groups_error = None + page_one = SimpleNamespace( + value=[ + self._graph_group( + "g-1", + rule="user.memberof -any (group.objectId -in ['x'])", + processing_state="Paused", + ) + ], + odata_next_link="next-link", + ) + page_two = SimpleNamespace( + value=[self._graph_group("g-2")], odata_next_link=None + ) + next_page_builder = SimpleNamespace(get=AsyncMock(return_value=page_two)) + with_url_mock = MagicMock(return_value=next_page_builder) + entra_service.client = SimpleNamespace( + groups=SimpleNamespace( + get=AsyncMock(return_value=page_one), with_url=with_url_mock + ) + ) + + groups = asyncio.run(entra_service._get_groups()) + + with_url_mock.assert_called_once_with("next-link") + assert entra_service.groups_error is None + assert [group.id for group in groups] == ["g-1", "g-2"] + assert groups[0].groupTypes == ["DynamicMembership"] + assert groups[0].membershipRule.startswith("user.memberof") + assert groups[0].membership_rule_processing_state == "Paused" + assert groups[1].groupTypes == [] + assert groups[1].membershipRule is None + assert groups[1].membership_rule_processing_state is None + assert groups[1].is_assignable_to_role is False + + def test_pagination_error_keeps_partial_groups_and_sets_error(self): + entra_service = Entra.__new__(Entra) + entra_service.groups_error = None + page_one = SimpleNamespace( + value=[self._graph_group("g-1")], odata_next_link="next-link" + ) + failing_builder = SimpleNamespace( + get=AsyncMock(side_effect=RuntimeError("throttled")) + ) + entra_service.client = SimpleNamespace( + groups=SimpleNamespace( + get=AsyncMock(return_value=page_one), + with_url=MagicMock(return_value=failing_builder), + ) + ) + + groups = asyncio.run(entra_service._get_groups()) + + assert [group.id for group in groups] == ["g-1"] + assert "pagination error: RuntimeError" in entra_service.groups_error + + def test_initial_error_returns_no_groups_and_sets_error(self): + entra_service = Entra.__new__(Entra) + entra_service.groups_error = None + entra_service.client = SimpleNamespace( + groups=SimpleNamespace( + get=AsyncMock(side_effect=PermissionError("forbidden")) + ) + ) + + groups = asyncio.run(entra_service._get_groups()) + + assert groups == [] + assert entra_service.groups_error == ( + "Unable to retrieve groups from Microsoft Graph (PermissionError)" + )