mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-16 08:24:15 +00:00
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:
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",
|
||||
|
||||
Whitespace-only changes.
+41
@@ -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."
|
||||
}
|
||||
+157
@@ -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
|
||||
|
||||
|
||||
+578
@@ -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)"
|
||||
)
|
||||
Reference in new issue
Block a user