feat(m365): add entra_pim_only_management check sharing PIM alert fetch

Add the PIM-only management security check on top of the shared
_get_pim_alerts implementation already introduced for the PIM stale
sign-in alert check (#10798). Avoid duplicating the service-layer fetch
that the original branch carried with its own beta endpoint + httpx
client; instead, consume the v1.0 unified roleManagement alerts feed via
the dict already populated on entra_client.

Detection logic: look up an active PIM alert whose definition id contains
'RolesAssignedOutsidePim'. FAIL when the alert is active with affected
items, PASS when it exists with no items (or is inactive), and MANUAL
when the alert is unavailable (no Microsoft Entra ID P2, alert disabled,
or insufficient permissions).

Compliance: extend CIS 4.0/6.0 control 5.3.1 and ISO 27001:2022 A.5.16 /
A.5.18 mappings to include this check alongside the stale sign-in alert
counterpart.
This commit is contained in:
Hugo P.Brito committed 2026-05-11 12:55:32 +01:00
1 parent a646c68308
commit 6cd2ffbca2
8 files changed
+364

No files matched your search

@@ -0,0 +1,229 @@
from unittest import mock
from prowler.providers.m365.services.entra.entra_service import (
Organization,
PimAlert,
)
from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
CONTOSO_ORG = Organization(
id="org-001",
name="Contoso",
on_premises_sync_enabled=False,
)
class Test_entra_pim_only_management:
def test_no_roles_assigned_outside_pim(self):
"""PASS when the RolesAssignedOutsidePim alert has zero affected items."""
entra_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_pim_only_management.entra_pim_only_management.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_pim_only_management.entra_pim_only_management import (
entra_pim_only_management,
)
entra_client.organizations = [CONTOSO_ORG]
entra_client.pim_alerts = {
"DirectoryRole_00000000-0000-0000-0000-000000000000_RolesAssignedOutsidePimAlert": PimAlert(
id="alert-1",
alert_definition_id="DirectoryRole_00000000-0000-0000-0000-000000000000_RolesAssignedOutsidePimAlert",
is_active=False,
number_of_affected_items=0,
),
}
entra_client.tenant_domain = DOMAIN
check = entra_pim_only_management()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
"managed through Privileged Identity Management"
in result[0].status_extended
)
assert result[0].resource_id == "alert-1"
assert result[0].resource_name == "PIM Roles Assigned Outside Of PIM Alert"
def test_roles_assigned_outside_pim(self):
"""FAIL when the RolesAssignedOutsidePim alert is active with affected items."""
entra_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_pim_only_management.entra_pim_only_management.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_pim_only_management.entra_pim_only_management import (
entra_pim_only_management,
)
entra_client.organizations = [CONTOSO_ORG]
entra_client.pim_alerts = {
"DirectoryRole_00000000-0000-0000-0000-000000000000_RolesAssignedOutsidePimAlert": PimAlert(
id="alert-1",
alert_definition_id="DirectoryRole_00000000-0000-0000-0000-000000000000_RolesAssignedOutsidePimAlert",
is_active=True,
number_of_affected_items=3,
),
}
entra_client.tenant_domain = DOMAIN
check = entra_pim_only_management()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "3 privileged role assignment(s)" in result[0].status_extended
assert "outside of PIM" in result[0].status_extended
assert result[0].resource_id == "alert-1"
def test_no_pim_alerts(self):
"""MANUAL when there are no PIM alerts (likely no P2 license)."""
entra_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_pim_only_management.entra_pim_only_management.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_pim_only_management.entra_pim_only_management import (
entra_pim_only_management,
)
entra_client.organizations = [CONTOSO_ORG]
entra_client.pim_alerts = {}
entra_client.tenant_domain = DOMAIN
check = entra_pim_only_management()
result = check.execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "not available" in result[0].status_extended
assert "P2" in result[0].status_extended
assert result[0].resource_id == "org-001"
assert result[0].resource_name == "Contoso"
def test_other_pim_alerts_only(self):
"""MANUAL when PIM alerts exist but none match the RolesAssignedOutsidePim definition."""
entra_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_pim_only_management.entra_pim_only_management.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_pim_only_management.entra_pim_only_management import (
entra_pim_only_management,
)
entra_client.organizations = [CONTOSO_ORG]
entra_client.pim_alerts = {
"TooManyGlobalAdminsAssignedToTenantAlert": PimAlert(
id="alert-other",
alert_definition_id="TooManyGlobalAdminsAssignedToTenantAlert",
is_active=True,
number_of_affected_items=5,
),
}
entra_client.tenant_domain = DOMAIN
check = entra_pim_only_management()
result = check.execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "not available" in result[0].status_extended
def test_inactive_alert_with_lingering_affected_items(self):
"""PASS when the RolesAssignedOutsidePim alert reports counts but is not active."""
entra_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_pim_only_management.entra_pim_only_management.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_pim_only_management.entra_pim_only_management import (
entra_pim_only_management,
)
entra_client.organizations = [CONTOSO_ORG]
entra_client.pim_alerts = {
"DirectoryRole_00000000-0000-0000-0000-000000000000_RolesAssignedOutsidePimAlert": PimAlert(
id="alert-1",
alert_definition_id="DirectoryRole_00000000-0000-0000-0000-000000000000_RolesAssignedOutsidePimAlert",
is_active=False,
number_of_affected_items=3,
),
}
entra_client.tenant_domain = DOMAIN
check = entra_pim_only_management()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
"managed through Privileged Identity Management"
in result[0].status_extended
)
def test_no_organizations_returns_empty(self):
"""No findings when the provider returns no organizations."""
entra_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_pim_only_management.entra_pim_only_management.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_pim_only_management.entra_pim_only_management import (
entra_pim_only_management,
)
entra_client.organizations = []
entra_client.pim_alerts = {}
entra_client.tenant_domain = DOMAIN
check = entra_pim_only_management()
result = check.execute()
assert len(result) == 0