feat(m365): add entra_dynamic_group_member_of_rule_not_used check (#13003)

Co-authored-by: kinomakino <3662588+kinomakino@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-13 15:59:01 +02:00
1 parent c438858dbd
commit 80c4113879
8 files changed
+901 -1

No files matched your search

@@ -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
@@ -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",
@@ -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 <id> -MembershipRule '<new attribute-based rule>' -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."
}
@@ -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
@@ -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
@@ -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
@@ -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)"
)