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
2026-05-11 12:55:32 +01:00
parent a646c68308
commit 6cd2ffbca2
8 changed files with 364 additions and 0 deletions
+1
View File
@@ -6,6 +6,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
### 🚀 Added
- `entra_pim_only_management` check for m365 provider [(#10848)](https://github.com/prowler-cloud/prowler/pull/10848)
- `entra_pim_stale_sign_in_alert` check for m365 provider [(#10798)](https://github.com/prowler-cloud/prowler/pull/10798)
---
@@ -1503,6 +1503,7 @@
"Id": "5.3.1",
"Description": "Microsoft Entra Privileged Identity Management can be used to audit roles, allow just in time activation of roles and allow for periodic role attestation. Organizations should remove permanent members from privileged Office 365 roles and instead make them eligible, through a JIT activation workflow.",
"Checks": [
"entra_pim_only_management",
"entra_pim_stale_sign_in_alert"
],
"Attributes": [
@@ -1804,6 +1804,7 @@
"Id": "5.3.1",
"Description": "Microsoft Entra Privileged Identity Management can be used to audit roles, allow just in time activation of roles and allow for periodic role attestation. Organizations should remove permanent members from privileged Office 365 roles and instead make them eligible, through a JIT activation workflow. Ensure 'Privileged Identity Management' is used to manage roles.",
"Checks": [
"entra_pim_only_management",
"entra_pim_stale_sign_in_alert"
],
"Attributes": [
@@ -281,6 +281,7 @@
"Checks": [
"entra_admin_portals_access_restriction",
"entra_app_registration_no_unused_privileged_permissions",
"entra_pim_only_management",
"entra_pim_stale_sign_in_alert",
"entra_policy_guest_users_access_restrictions",
"sharepoint_external_sharing_managed",
@@ -673,6 +674,7 @@
"entra_admin_users_sign_in_frequency_enabled",
"entra_break_glass_account_fido2_security_key_registered",
"entra_app_registration_no_unused_privileged_permissions",
"entra_pim_only_management",
"entra_pim_stale_sign_in_alert",
"entra_policy_ensure_default_user_cannot_create_tenants",
"entra_policy_guest_invite_only_for_admin_roles",
@@ -0,0 +1,35 @@
{
"Provider": "m365",
"CheckID": "entra_pim_only_management",
"CheckTitle": "PIM-only management ensures privileged role assignments are governed and auditable",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "Checks whether all privileged role assignments in Microsoft Entra ID are managed through Privileged Identity Management (PIM). Detects active PIM alerts for role assignments made directly, bypassing PIM governance controls such as approval workflows, justification requirements, and time-bound access.",
"Risk": "Role assignments made outside PIM bypass governance controls, removing audit trails, approval workflows, and time-bound access. This may indicate an active attack or privilege escalation, as adversaries can silently grant persistent administrative access without detection, impacting confidentiality, integrity, and availability.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-configure",
"https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-how-to-configure-security-alerts"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Sign in to the Microsoft Entra admin center > Identity Governance > Privileged Identity Management > Microsoft Entra roles > Alerts\n2. Review the 'Roles are being assigned outside of Privileged Identity Management' alert\n3. For each affected assignment, click the alert to view details\n4. Remove the direct role assignments and re-create them as PIM-eligible assignments\n5. Ensure all future role assignments are made through PIM",
"Terraform": ""
},
"Recommendation": {
"Text": "Enable **PIM-only management** by ensuring all privileged role assignments are made through Privileged Identity Management. Remove any direct (permanent) role assignments that bypass PIM and replace them with eligible assignments. Configure PIM alerts to monitor for assignments made outside PIM and review them regularly.",
"Url": "https://hub.prowler.com/check/entra_pim_only_management"
}
},
"Categories": ["identity-access"],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,95 @@
"""Check for role assignments made outside of Privileged Identity Management (PIM)."""
from typing import List
from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.entra.entra_client import entra_client
# Substring match against alert_definition_id. Microsoft Graph PIM exposes this
# alert under names such as ``RolesAssignedOutsidePimAlertDefinition`` (v1.0)
# and ``DirectoryRole_<scope>_RolesAssignedOutsidePimAlert`` (legacy beta).
# Matching on the stable suffix keeps the check working regardless of which
# format the API returns for the tenant being scanned.
ROLES_ASSIGNED_OUTSIDE_PIM_ALERT_SUBSTRING = "RolesAssignedOutsidePim"
class entra_pim_only_management(Check):
"""Ensure all privileged role assignments are managed through PIM.
PIM raises ``RolesAssignedOutsidePim`` when a privileged directory role is
granted to a principal directly, bypassing PIM's approval workflows,
justification requirements, and time-bound access. This check inspects the
PIM alert feed to detect that condition.
- PASS: The alert exists and reports no affected items.
- FAIL: The alert is active and has one or more affected items.
- MANUAL: PIM alerts are not available for the tenant (no Microsoft Entra
ID P2 license, alert disabled, or insufficient permissions to read PIM).
"""
def execute(self) -> List[CheckReportM365]:
"""Execute the PIM-only management check.
Returns:
list[CheckReportM365]: One finding per tenant, or an empty list if
no organization is exposed by the provider.
"""
findings = []
if not entra_client.organizations:
return findings
organization = entra_client.organizations[0]
matching_alert = next(
(
alert
for alert in entra_client.pim_alerts.values()
if ROLES_ASSIGNED_OUTSIDE_PIM_ALERT_SUBSTRING
in (alert.alert_definition_id or "")
),
None,
)
if matching_alert is None:
report = CheckReportM365(
metadata=self.metadata(),
resource=organization,
resource_id=organization.id,
resource_name=organization.name,
)
report.status = "MANUAL"
report.status_extended = (
"PIM 'roles assigned outside of PIM' alert is not available. "
"This can happen when the tenant lacks Microsoft Entra ID P2, "
"the alert is disabled, or the running credentials cannot read "
"PIM alerts. Review the alert configuration in the Entra admin "
"center under Identity Governance > Privileged Identity "
"Management > Alerts."
)
findings.append(report)
return findings
report = CheckReportM365(
metadata=self.metadata(),
resource=matching_alert,
resource_id=matching_alert.id,
resource_name="PIM Roles Assigned Outside Of PIM Alert",
)
if matching_alert.is_active and matching_alert.number_of_affected_items > 0:
report.status = "FAIL"
report.status_extended = (
f"PIM detected {matching_alert.number_of_affected_items} "
"privileged role assignment(s) made outside of PIM, bypassing "
"governance controls."
)
else:
report.status = "PASS"
report.status_extended = (
"All privileged role assignments are managed through "
"Privileged Identity Management (PIM)."
)
findings.append(report)
return findings
@@ -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