Skip to content

fix(m365/admincenter): _get_groups is not paginated — groups past page 1 silently dropped from group-scoped checks #11469

@b-abderrahmane

Description

@b-abderrahmane

Steps to Reproduce

In an M365 tenant with more than 100 total groups, run any check that iterates admincenter_client.groups, e.g.:

prowler m365 --check admincenter_groups_not_public_visibility

Expected behavior

A finding (PASS or FAIL) for every Unified group in the tenant.

Actual Result with Screenshots or Logs

Only Unified groups that land on Graph's first /groups page (default 100 items, unfiltered) are reported. Anything past page 1 is silently dropped — neither PASS nor FAIL.

Repro tenant: 164 total groups, 31 Unified. admincenter_groups_not_public_visibility produced 18 findings (16 Unified groups found on page 1, plus duplicates from a re-run); 13 Unified groups, including visibility=Public ones that should have been flagged FAIL, are missing entirely.

Graph confirms @odata.nextLink is set on the first response:

$ curl -sS -H "Authorization: Bearer $TOKEN" \
    "https://graph.microsoft.com/v1.0/groups?\$top=100" | jq '"@odata.nextLink" | length'
1   # next page exists, Prowler does not follow it

Root cause

prowler/providers/m365/services/admincenter/admincenter_service.py, _get_groups (lines 172–194 on main at bcd282d3d):

async def _get_groups(self):
    ...
    groups_list = await self.client.groups.get()
    for group in groups_list.value:          # only first page
        groups.update({...})
    # no while-loop, no odata_next_link follow-up

_get_users in the same file does paginate (added by PR #8858). The same pattern needs to be applied to _get_groups. _get_directory_roles and the /domains iteration in _get_password_policy have the same single-page pattern and should be paginated for consistency, though the practical impact there is small.

Suggested fix

Mirror _get_users:

async def _get_groups(self):
    logger.info("M365 - Getting groups...")
    groups = {}
    try:
        groups_response = await self.client.groups.get()
        while groups_response:
            for group in getattr(groups_response, "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_response, "odata_next_link", None)
            if not next_link:
                break
            groups_response = await self.client.groups.with_url(next_link).get()
    except Exception as error:
        logger.error(f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}")
    return groups

Verified on the repro tenant: admincenter_groups_not_public_visibility jumped from 18 to 30+ findings and the previously-missing Public groups now correctly appear as FAIL. Existing unit tests in tests/providers/m365/services/admincenter/ all pass against the patched module.

Happy to send a PR.

How did you install Prowler?

Docker (prowlercloud/prowler-api:5.27.0).

Prowler version

prowler 5.27.0 — also present on main at HEAD (bcd282d3d).

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions