diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index dd35527c8e..69a91b7128 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -21,6 +21,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ### 🐞 Fixed - `load_and_validate_config_file` now unwraps namespaced config for every built-in and external provider, and no longer leaks the full file as the provider's config when the file is namespaced [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700) +- M365 Admin Center group enumeration now follows Microsoft Graph pagination so group-scoped checks include groups beyond the first page [(#11510)](https://github.com/prowler-cloud/prowler/pull/11510) --- diff --git a/prowler/providers/m365/services/admincenter/admincenter_service.py b/prowler/providers/m365/services/admincenter/admincenter_service.py index 45be16d11a..3899dced67 100644 --- a/prowler/providers/m365/services/admincenter/admincenter_service.py +++ b/prowler/providers/m365/services/admincenter/admincenter_service.py @@ -175,17 +175,23 @@ class AdminCenter(M365Service): try: groups_list = await self.client.groups.get() groups.update({}) - for group in groups_list.value: - groups.update( - { - group.id: Group( - id=group.id, - name=getattr(group, "display_name", ""), - visibility=getattr(group, "visibility", ""), - group_types=getattr(group, "group_types", []) or [], - ) - } - ) + while groups_list: + for group in getattr(groups_list, "value", []) or []: + groups.update( + { + group.id: Group( + id=group.id, + name=getattr(group, "display_name", ""), + visibility=getattr(group, "visibility", ""), + group_types=getattr(group, "group_types", []) or [], + ) + } + ) + + next_link = getattr(groups_list, "odata_next_link", None) + if not next_link: + break + groups_list = await self.client.groups.with_url(next_link).get() except Exception as error: logger.error( diff --git a/tests/providers/m365/services/admincenter/admincenter_service_test.py b/tests/providers/m365/services/admincenter/admincenter_service_test.py index ce0a2a3050..4eedba05e1 100644 --- a/tests/providers/m365/services/admincenter/admincenter_service_test.py +++ b/tests/providers/m365/services/admincenter/admincenter_service_test.py @@ -252,3 +252,49 @@ def test_admincenter__get_groups_maps_group_types(): assert groups["id-2"].group_types == [] assert groups["id-3"].group_types == [] assert groups["id-3"].visibility == "Public" + + +def test_admincenter__get_groups_handles_pagination(): + admincenter_service = AdminCenter.__new__(AdminCenter) + + groups_response_page_one = SimpleNamespace( + value=[ + SimpleNamespace( + id="id-1", + display_name="First Unified Group", + visibility="Private", + group_types=["Unified"], + ) + ], + odata_next_link="next-link", + ) + groups_response_page_two = SimpleNamespace( + value=[ + SimpleNamespace( + id="id-2", + display_name="Second Unified Group", + visibility="Public", + group_types=["Unified"], + ) + ], + odata_next_link=None, + ) + + groups_with_url_builder = SimpleNamespace( + get=AsyncMock(return_value=groups_response_page_two) + ) + with_url_mock = MagicMock(return_value=groups_with_url_builder) + groups_builder = SimpleNamespace( + get=AsyncMock(return_value=groups_response_page_one), + with_url=with_url_mock, + ) + admincenter_service.client = SimpleNamespace(groups=groups_builder) + + groups = asyncio.run(admincenter_service._get_groups()) + + assert len(groups) == 2 + assert groups_builder.get.await_count == 1 + with_url_mock.assert_called_once_with("next-link") + assert groups["id-1"].name == "First Unified Group" + assert groups["id-2"].name == "Second Unified Group" + assert groups["id-2"].visibility == "Public"