diff --git a/prowler/providers/microsoft365/services/entra/entra_dynamic_group_for_guests_created/__init__.py b/prowler/providers/microsoft365/services/entra/entra_dynamic_group_for_guests_created/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/microsoft365/services/entra/entra_dynamic_group_for_guests_created/entra_dynamic_group_for_guests_created.metadata.json b/prowler/providers/microsoft365/services/entra/entra_dynamic_group_for_guests_created/entra_dynamic_group_for_guests_created.metadata.json new file mode 100644 index 0000000000..55c2916196 --- /dev/null +++ b/prowler/providers/microsoft365/services/entra/entra_dynamic_group_for_guests_created/entra_dynamic_group_for_guests_created.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "microsoft365", + "CheckID": "entra_dynamic_group_for_guests_created", + "CheckTitle": "Ensure a dynamic group for guest users is created.", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Group Settings", + "Description": "Ensure that a dynamic group is created for guest users in Microsoft Entra to enforce conditional access policies and security controls automatically.", + "Risk": "Without a dynamic group for guest users, administrators may need to manually manage access controls, leading to potential security gaps and inconsistent policy enforcement.", + "RelatedUrl": "https://learn.microsoft.com/en-us/entra/identity/users/groups-create-rule", + "Remediation": { + "Code": { + "CLI": "New-MgGroup -DisplayName 'Dynamic Guest Users' -MailNickname 'DynGuestUsers' -MailEnabled $false -SecurityEnabled $true -GroupTypes 'DynamicMembership' -MembershipRule '(user.userType -eq \"Guest\")' -MembershipRuleProcessingState 'On'", + "NativeIaC": "", + "Other": "1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Click to expand Identity > Groups and select All groups. 3. Select 'New group' and configure: Group type: Security, Membership type: Dynamic User. 4. Add dynamic query with rule: (user.userType -eq 'Guest'). 5. Click Save.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Create a dynamic group for guest users to automate policy enforcement and access control.", + "Url": "https://learn.microsoft.com/en-us/entra/identity/users/groups-create-rule" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/microsoft365/services/entra/entra_dynamic_group_for_guests_created/entra_dynamic_group_for_guests_created.py b/prowler/providers/microsoft365/services/entra/entra_dynamic_group_for_guests_created/entra_dynamic_group_for_guests_created.py new file mode 100644 index 0000000000..9bd70f624c --- /dev/null +++ b/prowler/providers/microsoft365/services/entra/entra_dynamic_group_for_guests_created/entra_dynamic_group_for_guests_created.py @@ -0,0 +1,54 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportMicrosoft365 +from prowler.providers.microsoft365.services.entra.entra_client import entra_client + + +class entra_dynamic_group_for_guests_created(Check): + """ + Check if a dynamic group for guest users is created in Microsoft Entra. + + This check verifies that a dynamic group exists for guest users in Microsoft Entra. + A dynamic group for guest users should have the group type 'DynamicMembership' and a membership rule + that restricts membership to users with a userType equal to 'Guest'. This configuration enables + automated enforcement of conditional access policies and reduces manual management of guest access. + """ + + def execute(self) -> List[CheckReportMicrosoft365]: + """ + Execute the dynamic group for guest users check. + + Iterates over the groups retrieved from the Microsoft Entra client and generates a report + indicating whether at least one dynamic group exists with a membership rule targeting guest users. + + Returns: + List[CheckReportMicrosoft365]: A list containing a single report with the result of the check. + """ + findings = [] + if entra_client.groups: + dynamic_group = None + for group in entra_client.groups: + if "DynamicMembership" in group.groupTypes and group.membershipRule: + if 'user.userType -eq "Guest"' in group.membershipRule: + dynamic_group = group + break + + report = CheckReportMicrosoft365( + self.metadata(), + resource=dynamic_group if dynamic_group else {}, + resource_name=dynamic_group.name if dynamic_group else "Group", + resource_id=dynamic_group.id if dynamic_group else "group", + ) + report.status = "FAIL" + report.status_extended = ( + "No dynamic group for guest users was found in Microsoft Entra." + ) + + if dynamic_group: + report.status = "PASS" + report.status_extended = ( + "A dynamic group for guest users is created in Microsoft Entra." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/microsoft365/services/entra/entra_service.py b/prowler/providers/microsoft365/services/entra/entra_service.py index 327675aacc..c571cc3aa7 100644 --- a/prowler/providers/microsoft365/services/entra/entra_service.py +++ b/prowler/providers/microsoft365/services/entra/entra_service.py @@ -17,12 +17,14 @@ class Entra(Microsoft365Service): attributes = loop.run_until_complete( gather( self._get_authorization_policy(), + self._get_groups(), self._get_admin_consent_policy(), ) ) self.authorization_policy = attributes[0] - self.admin_consent_policy = attributes[1] + self.groups = attributes[1] + self.admin_consent_policy = attributes[2] async def _get_authorization_policy(self): logger.info("Entra - Getting authorization policy...") @@ -85,6 +87,26 @@ class Entra(Microsoft365Service): return authorization_policy + async def _get_groups(self): + 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, + ) + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return groups + async def _get_admin_consent_policy(self): logger.info("Entra - Getting group settings...") admin_consent_policy = None @@ -121,6 +143,13 @@ class AuthorizationPolicy(BaseModel): default_user_role_permissions: Optional[DefaultUserRolePermissions] +class Group(BaseModel): + id: str + name: str + groupTypes: List[str] + membershipRule: Optional[str] + + class AdminConsentPolicy(BaseModel): admin_consent_enabled: bool notify_reviewers: bool diff --git a/tests/providers/microsoft365/services/entra/entra_dynamic_group_for_guests_created/entra_dynamic_group_for_guests_created_test.py b/tests/providers/microsoft365/services/entra/entra_dynamic_group_for_guests_created/entra_dynamic_group_for_guests_created_test.py new file mode 100644 index 0000000000..3bc5fc9b2f --- /dev/null +++ b/tests/providers/microsoft365/services/entra/entra_dynamic_group_for_guests_created/entra_dynamic_group_for_guests_created_test.py @@ -0,0 +1,110 @@ +from unittest import mock + +from prowler.providers.microsoft365.services.entra.entra_service import Group +from tests.providers.microsoft365.microsoft365_fixtures import ( + set_mocked_microsoft365_provider, +) + + +class Test_entra_dynamic_group_for_guests_created: + def test_no_groups(self): + """ + Test when no groups exist: + The check should return an empty list of findings. + """ + entra_client = mock.MagicMock() + entra_client.groups = [] + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_microsoft365_provider(), + ), mock.patch( + "prowler.providers.microsoft365.services.entra.entra_dynamic_group_for_guests_created.entra_dynamic_group_for_guests_created.entra_client", + new=entra_client, + ): + from prowler.providers.microsoft365.services.entra.entra_dynamic_group_for_guests_created.entra_dynamic_group_for_guests_created import ( + entra_dynamic_group_for_guests_created, + ) + + check = entra_dynamic_group_for_guests_created() + result = check.execute() + assert len(result) == 0 + + def test_group_not_dynamic(self): + """ + Test when a group exists but is not dynamic: + The check should FAIL with the default message. + """ + entra_client = mock.MagicMock() + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_microsoft365_provider(), + ), mock.patch( + "prowler.providers.microsoft365.services.entra.entra_dynamic_group_for_guests_created.entra_dynamic_group_for_guests_created.entra_client", + new=entra_client, + ): + entra_client.groups = [ + Group( + id="group1", + name="Group 1", + groupTypes=["Unified"], + membershipRule=None, + ) + ] + + from prowler.providers.microsoft365.services.entra.entra_dynamic_group_for_guests_created.entra_dynamic_group_for_guests_created import ( + entra_dynamic_group_for_guests_created, + ) + + check = entra_dynamic_group_for_guests_created() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "No dynamic group for guest users was found in Microsoft Entra." + ) + assert result[0].resource_id == "group" + assert result[0].resource_name == "Group" + assert result[0].location == "global" + assert result[0].resource == {} + + def test_dynamic_group_with_proper_membership_rule(self): + """ + Test when a group is dynamic and its membership rule correctly restricts guest users: + The check should PASS. + """ + entra_client = mock.MagicMock() + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_microsoft365_provider(), + ), mock.patch( + "prowler.providers.microsoft365.services.entra.entra_dynamic_group_for_guests_created.entra_dynamic_group_for_guests_created.entra_client", + new=entra_client, + ): + entra_client.groups = [ + Group( + id="group3", + name="Group 3", + groupTypes=["DynamicMembership"], + membershipRule='user.userType -eq "Guest"', + ) + ] + + from prowler.providers.microsoft365.services.entra.entra_dynamic_group_for_guests_created.entra_dynamic_group_for_guests_created import ( + entra_dynamic_group_for_guests_created, + ) + + check = entra_dynamic_group_for_guests_created() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "A dynamic group for guest users is created in Microsoft Entra." + ) + assert result[0].resource_id == "group3" + assert result[0].resource_name == "Group 3" + assert result[0].location == "global" + assert result[0].resource == entra_client.groups[0].dict() diff --git a/tests/providers/microsoft365/services/entra/microsoft365_entra_service_test.py b/tests/providers/microsoft365/services/entra/microsoft365_entra_service_test.py index 9fa1ceb958..2da3a417af 100644 --- a/tests/providers/microsoft365/services/entra/microsoft365_entra_service_test.py +++ b/tests/providers/microsoft365/services/entra/microsoft365_entra_service_test.py @@ -28,6 +28,22 @@ async def mock_entra_get_authorization_policy(_): ) +async def mock_entra_get_groups(_): + group1 = { + "id": "id-1", + "name": "group1", + "groupTypes": ["DynamicMembership"], + "membershipRule": 'user.userType -eq "Guest"', + } + group2 = { + "id": "id-2", + "name": "group2", + "groupTypes": ["Assigned"], + "membershipRule": "", + } + return [group1, group2] + + async def mock_entra_get_admin_consent_policy(_): return AdminConsentPolicy( admin_consent_enabled=True, @@ -66,6 +82,22 @@ class Test_Entra_Service: ) ) + @patch( + "prowler.providers.microsoft365.services.entra.entra_service.Entra._get_groups", + new=mock_entra_get_groups, + ) + def test_get_groups(self): + entra_client = Entra(set_mocked_microsoft365_provider()) + assert len(entra_client.groups) == 2 + assert entra_client.groups[0]["id"] == "id-1" + assert entra_client.groups[0]["name"] == "group1" + assert entra_client.groups[0]["groupTypes"] == ["DynamicMembership"] + assert entra_client.groups[0]["membershipRule"] == 'user.userType -eq "Guest"' + assert entra_client.groups[1]["id"] == "id-2" + assert entra_client.groups[1]["name"] == "group2" + assert entra_client.groups[1]["groupTypes"] == ["Assigned"] + assert entra_client.groups[1]["membershipRule"] == "" + @patch( "prowler.providers.microsoft365.services.entra.entra_service.Entra._get_admin_consent_policy", new=mock_entra_get_admin_consent_policy,