diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 4bc9ee066d..420b7e7654 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -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) diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_groups_management_restricted/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_groups_management_restricted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_groups_management_restricted/entra_conditional_access_policy_groups_management_restricted.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_groups_management_restricted/entra_conditional_access_policy_groups_management_restricted.metadata.json new file mode 100644 index 0000000000..ccfa9eb086 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_groups_management_restricted/entra_conditional_access_policy_groups_management_restricted.metadata.json @@ -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`." +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_groups_management_restricted/entra_conditional_access_policy_groups_management_restricted.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_groups_management_restricted/entra_conditional_access_policy_groups_management_restricted.py new file mode 100644 index 0000000000..884f065257 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_groups_management_restricted/entra_conditional_access_policy_groups_management_restricted.py @@ -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'])}" + ) diff --git a/prowler/providers/m365/services/entra/entra_service.py b/prowler/providers/m365/services/entra/entra_service.py index bbaa80edcc..f8713aff8a 100644 --- a/prowler/providers/m365/services/entra/entra_service.py +++ b/prowler/providers/m365/services/entra/entra_service.py @@ -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() - for group in groups_data.value: - groups.append( - Group( - id=group.id, - name=group.display_name, - groupTypes=group.group_types, - membershipRule=group.membership_rule, - ) + 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 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): diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_groups_management_restricted/entra_conditional_access_policy_groups_management_restricted_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_groups_management_restricted/entra_conditional_access_policy_groups_management_restricted_test.py new file mode 100644 index 0000000000..db2c68be69 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_groups_management_restricted/entra_conditional_access_policy_groups_management_restricted_test.py @@ -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