feat(m365): add Entra Conditional Access group management restriction (#11342)

Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
This commit is contained in:
Samyak Choudhary
2026-07-01 15:08:54 +05:30
committed by GitHub
parent 21d9d6192e
commit 883ffa1fdb
6 changed files with 483 additions and 9 deletions
+1
View File
@@ -9,6 +9,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Per-requirement configuration validation for compliance frameworks via `ConfigRequirements`, so a requirement is reported as FAIL when its configurable checks ran with a configuration too loose to satisfy it (applied across all compliance outputs: CSV, OCSF, and console tables) [(#11669)](https://github.com/prowler-cloud/prowler/pull/11669)
- `entra_conditional_access_policy_explicitly_targets_azure_devops` check for M365 provider, verifying at least one enabled Conditional Access policy explicitly includes the Azure DevOps cloud application instead of relying on a broad "All cloud apps" policy [(#11182)](https://github.com/prowler-cloud/prowler/pull/11182)
- `entra_conditional_access_policy_no_exclusion_gaps` check for M365 provider, verifying every user, group, role, or application excluded from an enabled Conditional Access policy stays in scope of another enabled policy [(#11577)](https://github.com/prowler-cloud/prowler/pull/11577)
- `entra_conditional_access_policy_groups_management_restricted` check for M365 provider, verifying every security group referenced by an enabled or report-only Conditional Access policy is management-restricted or role-assignable [(#11342)](https://github.com/prowler-cloud/prowler/pull/11342)
- `stepfunctions_statemachine_encrypted_with_cmk` check for AWS provider, verifying that each Step Functions state machine uses a customer-managed KMS key for encryption at rest rather than the default AWS-owned key [(#11538)](https://github.com/prowler-cloud/prowler/pull/11538)
- CIS Controls v8.1 universal compliance framework mapping existing checks across 18 providers (AWS, Azure, GCP, Kubernetes, M365, GitHub, AlibabaCloud, OracleCloud, GoogleWorkspace, Okta, Cloudflare, Vercel, MongoDB Atlas, OpenStack, Linode, StackIT, NHN, and Scaleway) to the 18 CIS Critical Security Controls and their Safeguards [(#11700)](https://github.com/prowler-cloud/prowler/pull/11700)
- CIS Microsoft 365 Foundations Benchmark v7.0.0 compliance framework for the M365 provider [(#11699)](https://github.com/prowler-cloud/prowler/pull/11699)
@@ -0,0 +1,39 @@
{
"Provider": "m365",
"CheckID": "entra_conditional_access_policy_groups_management_restricted",
"CheckTitle": "Conditional Access policy groups are management-restricted or role-assignable",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "Microsoft Entra **Conditional Access** policies are evaluated for security groups referenced in `includeGroups` and `excludeGroups`. Referenced groups must be protected by **Restricted Management Administrative Unit** membership or configured as **role-assignable groups**.",
"Risk": "Groups referenced by **Conditional Access** policies become privileged identity control points. If their membership can be changed by broad group administrators or applications, an attacker may bypass access controls by adding themselves to an excluded group or removing themselves from an included group, weakening **confidentiality** and **integrity** without modifying the policy.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/graph/api/resources/conditionalaccesspolicy?view=graph-rest-1.0",
"https://learn.microsoft.com/en-us/graph/api/resources/group?view=graph-rest-1.0",
"https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/admin-units-restricted-management",
"https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/groups-concept"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Sign in to the Microsoft Entra admin center at https://entra.microsoft.com.\n2. Review every group used by enabled or report-only Conditional Access policies under Users > Include and Users > Exclude.\n3. For each group, either place it in a Restricted Management Administrative Unit or recreate/use a role-assignable group where appropriate.\n4. Restrict group ownership and membership management to privileged administrators.\n5. Remove stale or deleted group references from Conditional Access policies.",
"Terraform": ""
},
"Recommendation": {
"Text": "Protect every security group that scopes **Conditional Access** decisions with **Restricted Management Administrative Units** or **role-assignable group** controls. Regularly audit group membership, owners, and stale Conditional Access references.",
"Url": "https://hub.prowler.com/check/entra_conditional_access_policy_groups_management_restricted"
}
},
"Categories": [
"identity-access"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "Conditional Access policies require Microsoft Entra ID P1 or P2 licenses. The check relies on Microsoft Graph group properties `isManagementRestricted` and `isAssignableToRole`, which must be requested with `$select`."
}
@@ -0,0 +1,130 @@
from collections import defaultdict
from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.entra.entra_client import entra_client
from prowler.providers.m365.services.entra.entra_service import (
ConditionalAccessPolicyState,
)
class entra_conditional_access_policy_groups_management_restricted(Check):
"""Ensure Conditional Access group scopes are protected against broad management.
Security groups referenced by enabled or report-only Conditional Access
policies (in ``includeGroups`` or ``excludeGroups``) are privileged control
points: anyone able to change their membership can silently bypass or weaken
a policy. This check reports one finding per referenced group.
- PASS: The group is management-restricted or role-assignable, or no enabled
or report-only policy references any group.
- FAIL: The group is neither management-restricted nor role-assignable.
- MANUAL: The group reference no longer resolves in Microsoft Entra ID and
must be verified or removed.
"""
def execute(self) -> list[CheckReportM365]:
"""Execute the check logic.
Returns:
A list of reports, one per group referenced by an enabled or
report-only Conditional Access policy.
"""
findings = []
group_usage = defaultdict(lambda: {"include": [], "exclude": []})
for policy in entra_client.conditional_access_policies.values():
if policy.state == ConditionalAccessPolicyState.DISABLED:
continue
user_conditions = policy.conditions.user_conditions
if not user_conditions:
continue
for group_id in user_conditions.included_groups:
group_usage[group_id]["include"].append(policy)
for group_id in user_conditions.excluded_groups:
group_usage[group_id]["exclude"].append(policy)
if not group_usage:
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name="Conditional Access Policies",
resource_id="conditionalAccessPolicies",
)
report.status = "PASS"
report.status_extended = (
"No enabled or report-only Conditional Access Policy references "
"groups."
)
findings.append(report)
return findings
groups_by_id = {group.id: group for group in entra_client.groups}
for group_id in sorted(group_usage):
usage = self._policy_usage(group_usage[group_id])
group = groups_by_id.get(group_id)
if not group:
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name=group_id,
resource_id=group_id,
)
report.status = "MANUAL"
report.status_extended = (
f"Group {group_id} referenced by Conditional Access Policies "
f"could not be resolved in Microsoft Entra ID; verify the group "
f"exists or remove the stale reference ({usage})."
)
findings.append(report)
continue
report = CheckReportM365(
metadata=self.metadata(),
resource=group,
resource_name=group.name,
resource_id=group.id,
)
if group.is_management_restricted or group.is_assignable_to_role:
report.status = "PASS"
report.status_extended = (
f"Group {group.name} ({group.id}) referenced by Conditional "
f"Access Policies is management-restricted or role-assignable."
)
else:
report.status = "FAIL"
report.status_extended = (
f"Group {group.name} ({group.id}) referenced by Conditional "
f"Access Policies is neither management-restricted nor "
f"role-assignable ({usage})."
)
findings.append(report)
return findings
@staticmethod
def _policy_usage(usage) -> str:
"""Render the include/exclude policy usage of a group as a string.
Args:
usage: Mapping with ``include`` and ``exclude`` lists of policies.
Returns:
A string such as ``"include policies: A; exclude policies: B"``.
"""
def policy_names(policies):
if not policies:
return "none"
return ", ".join(sorted({policy.display_name for policy in policies}))
return (
f"include policies: {policy_names(usage['include'])}; "
f"exclude policies: {policy_names(usage['exclude'])}"
)
@@ -7,6 +7,7 @@ from typing import Any, Dict, List, Optional, Set, Tuple
from uuid import UUID
from kiota_abstractions.base_request_configuration import RequestConfiguration
from msgraph.generated.groups.groups_request_builder import GroupsRequestBuilder
from msgraph.generated.models.o_data_errors.o_data_error import ODataError
from msgraph.generated.security.microsoft_graph_security_run_hunting_query.run_hunting_query_post_request_body import (
RunHuntingQueryPostRequestBody,
@@ -743,16 +744,48 @@ class Entra(M365Service):
logger.info("Entra - Getting groups...")
groups = []
try:
groups_data = await self.client.groups.get()
query_parameters = (
GroupsRequestBuilder.GroupsRequestBuilderGetQueryParameters(
select=[
"id",
"displayName",
"groupTypes",
"membershipRule",
"isAssignableToRole",
"isManagementRestricted",
],
)
)
request_configuration = RequestConfiguration(
query_parameters=query_parameters,
)
groups_data = await self.client.groups.get(
request_configuration=request_configuration,
)
while groups_data:
for group in groups_data.value:
groups.append(
Group(
id=group.id,
name=group.display_name,
groupTypes=group.group_types,
groupTypes=group.group_types or [],
membershipRule=group.membership_rule,
is_assignable_to_role=getattr(
group, "is_assignable_to_role", False
)
or False,
is_management_restricted=getattr(
group, "is_management_restricted", False
)
or False,
)
)
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()
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
@@ -1818,6 +1851,8 @@ class Group(BaseModel):
name: str
groupTypes: List[str]
membershipRule: Optional[str]
is_assignable_to_role: bool = False
is_management_restricted: bool = False
class AdminConsentPolicy(BaseModel):
@@ -0,0 +1,269 @@
from unittest import mock
from uuid import uuid4
from prowler.providers.m365.services.entra.entra_service import (
ApplicationEnforcedRestrictions,
ApplicationsConditions,
ConditionalAccessGrantControl,
ConditionalAccessPolicy,
ConditionalAccessPolicyState,
Conditions,
GrantControlOperator,
GrantControls,
Group,
PersistentBrowser,
SessionControls,
SignInFrequency,
SignInFrequencyInterval,
UsersConditions,
)
from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
CHECK_MODULE = (
"prowler.providers.m365.services.entra."
"entra_conditional_access_policy_groups_management_restricted."
"entra_conditional_access_policy_groups_management_restricted.entra_client"
)
def _make_policy(
included_groups=None,
excluded_groups=None,
state=ConditionalAccessPolicyState.ENABLED,
display_name="Conditional Access Policy",
):
return ConditionalAccessPolicy(
id=str(uuid4()),
display_name=display_name,
conditions=Conditions(
application_conditions=ApplicationsConditions(
included_applications=["All"],
excluded_applications=[],
included_user_actions=[],
),
user_conditions=UsersConditions(
included_groups=included_groups or [],
excluded_groups=excluded_groups or [],
included_users=["All"],
excluded_users=[],
included_roles=[],
excluded_roles=[],
),
client_app_types=[],
user_risk_levels=[],
),
grant_controls=GrantControls(
built_in_controls=[ConditionalAccessGrantControl.MFA],
operator=GrantControlOperator.OR,
authentication_strength=None,
),
session_controls=SessionControls(
persistent_browser=PersistentBrowser(is_enabled=False, mode="always"),
sign_in_frequency=SignInFrequency(
is_enabled=False,
frequency=None,
type=None,
interval=SignInFrequencyInterval.EVERY_TIME,
),
application_enforced_restrictions=ApplicationEnforcedRestrictions(
is_enabled=False
),
),
state=state,
)
def _entra_client_mock():
client = mock.MagicMock()
client.audited_tenant = "audited_tenant"
client.audited_domain = DOMAIN
client.groups = []
client.conditional_access_policies = {}
return client
class Test_entra_conditional_access_policy_groups_management_restricted:
def test_no_enabled_or_report_only_policy_references_groups(self):
entra_client = _entra_client_mock()
entra_client.conditional_access_policies = {
"policy-1": _make_policy(state=ConditionalAccessPolicyState.DISABLED)
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(CHECK_MODULE, new=entra_client),
):
from prowler.providers.m365.services.entra.entra_conditional_access_policy_groups_management_restricted.entra_conditional_access_policy_groups_management_restricted import (
entra_conditional_access_policy_groups_management_restricted,
)
check = entra_conditional_access_policy_groups_management_restricted()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "No enabled or report-only Conditional Access Policy references groups."
)
assert result[0].resource_id == "conditionalAccessPolicies"
assert result[0].resource_name == "Conditional Access Policies"
assert result[0].location == "global"
def test_policy_without_user_conditions_is_treated_as_no_referenced_groups(self):
entra_client = _entra_client_mock()
policy = _make_policy(display_name="Policy Without User Conditions")
policy.conditions.user_conditions = None
entra_client.conditional_access_policies = {"policy-1": policy}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(CHECK_MODULE, new=entra_client),
):
from prowler.providers.m365.services.entra.entra_conditional_access_policy_groups_management_restricted.entra_conditional_access_policy_groups_management_restricted import (
entra_conditional_access_policy_groups_management_restricted,
)
check = entra_conditional_access_policy_groups_management_restricted()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "No enabled or report-only Conditional Access Policy references groups."
)
def test_all_referenced_groups_are_protected(self):
entra_client = _entra_client_mock()
entra_client.groups = [
Group(
id="group-1",
name="Restricted Group",
groupTypes=[],
membershipRule=None,
is_management_restricted=True,
),
Group(
id="group-2",
name="Role Assignable Group",
groupTypes=[],
membershipRule=None,
is_assignable_to_role=True,
),
]
entra_client.conditional_access_policies = {
"policy-1": _make_policy(
included_groups=["group-1"],
excluded_groups=["group-2"],
display_name="Protected Policy",
)
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(CHECK_MODULE, new=entra_client),
):
from prowler.providers.m365.services.entra.entra_conditional_access_policy_groups_management_restricted.entra_conditional_access_policy_groups_management_restricted import (
entra_conditional_access_policy_groups_management_restricted,
)
check = entra_conditional_access_policy_groups_management_restricted()
result = check.execute()
assert len(result) == 2
assert {report.status for report in result} == {"PASS"}
assert {report.resource_id for report in result} == {"group-1", "group-2"}
for report in result:
assert "is management-restricted or role-assignable" in (
report.status_extended
)
def test_unprotected_group_fails_with_include_and_exclude_usage(self):
entra_client = _entra_client_mock()
entra_client.groups = [
Group(
id="group-1",
name="Unprotected Group",
groupTypes=[],
membershipRule=None,
)
]
entra_client.conditional_access_policies = {
"policy-1": _make_policy(
included_groups=["group-1"],
display_name="Include Policy",
),
"policy-2": _make_policy(
excluded_groups=["group-1"],
state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING,
display_name="Report Only Exclusion Policy",
),
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(CHECK_MODULE, new=entra_client),
):
from prowler.providers.m365.services.entra.entra_conditional_access_policy_groups_management_restricted.entra_conditional_access_policy_groups_management_restricted import (
entra_conditional_access_policy_groups_management_restricted,
)
check = entra_conditional_access_policy_groups_management_restricted()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].resource_id == "group-1"
assert result[0].resource_name == "Unprotected Group"
assert "Group Unprotected Group (group-1)" in result[0].status_extended
assert (
"neither management-restricted nor role-assignable"
in result[0].status_extended
)
assert "include policies: Include Policy" in result[0].status_extended
assert (
"exclude policies: Report Only Exclusion Policy"
in result[0].status_extended
)
def test_unresolved_group_reference_is_manual(self):
entra_client = _entra_client_mock()
entra_client.conditional_access_policies = {
"policy-1": _make_policy(
excluded_groups=["deleted-group"],
display_name="Policy With Stale Group",
)
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(CHECK_MODULE, new=entra_client),
):
from prowler.providers.m365.services.entra.entra_conditional_access_policy_groups_management_restricted.entra_conditional_access_policy_groups_management_restricted import (
entra_conditional_access_policy_groups_management_restricted,
)
check = entra_conditional_access_policy_groups_management_restricted()
result = check.execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert result[0].resource_id == "deleted-group"
assert "could not be resolved" in result[0].status_extended
assert "exclude policies: Policy With Stale Group" in result[0].status_extended