Compare commits

...

1 Commits

Author SHA1 Message Date
Hugo P.Brito 42ea4417be feat(m365): add entra_pim_role_usage_alert_exists security check
Add new security check entra_pim_role_usage_alert_exists for m365 provider.
Includes check implementation, metadata, and unit tests.
2026-04-20 14:54:15 +01:00
10 changed files with 382 additions and 2 deletions
+4
View File
@@ -4,6 +4,10 @@ All notable changes to the **Prowler SDK** are documented in this file.
## [5.24.1] (Prowler v5.24.1)
### 🚀 Added
- `entra_pim_role_usage_alert_exists` check for m365 provider [(#10799)](https://github.com/prowler-cloud/prowler/pull/10799)
### 🔄 Changed
- `msgraph-sdk` from 1.23.0 to 1.55.0 and `azure-mgmt-resource` from 23.3.0 to 24.0.0, removing `marshmallow` as is a transitively dev dependency [(#10733)](https://github.com/prowler-cloud/prowler/pull/10733)
+3 -1
View File
@@ -1502,7 +1502,9 @@
{
"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": [],
"Checks": [
"entra_pim_role_usage_alert_exists"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",
+3 -1
View File
@@ -1803,7 +1803,9 @@
{
"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": [],
"Checks": [
"entra_pim_role_usage_alert_exists"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",
@@ -281,6 +281,7 @@
"Checks": [
"entra_admin_portals_access_restriction",
"entra_app_registration_no_unused_privileged_permissions",
"entra_pim_role_usage_alert_exists",
"entra_policy_guest_users_access_restrictions",
"sharepoint_external_sharing_managed",
"sharepoint_external_sharing_restricted",
@@ -672,6 +673,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_role_usage_alert_exists",
"entra_policy_ensure_default_user_cannot_create_tenants",
"entra_policy_guest_invite_only_for_admin_roles",
"entra_seamless_sso_disabled"
@@ -0,0 +1,38 @@
{
"Provider": "m365",
"CheckID": "entra_pim_role_usage_alert_exists",
"CheckTitle": "PIM alert for unused privileged roles monitors stale role assignments",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "Privileged Identity Management (PIM) can be configured to alert when **privileged roles are not being used**. This alert detects stale role assignments where administrators have not exercised their assigned privileges, helping identify unnecessary access that should be reviewed or removed.",
"Risk": "Without monitoring for unused privileged roles, **stale role assignments** accumulate undetected. Dormant privileged accounts become targets for **credential theft** and **lateral movement**, expanding the attack surface. Attackers who compromise an unused admin account gain elevated access with lower detection risk.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-how-to-configure-security-alerts",
"https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-security-alerts"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. In the Microsoft Entra admin center, go to Identity governance > Privileged Identity Management > Microsoft Entra roles > Alerts.\n2. Locate the alert **Administrators aren't using their privileged roles**.\n3. Click the alert to review its settings and ensure it is enabled.\n4. Configure the alert threshold and notification settings as needed.\n5. Review any flagged stale role assignments and remove unnecessary access.",
"Terraform": ""
},
"Recommendation": {
"Text": "Enable PIM alerts for **unused privileged roles** to detect stale assignments. Apply the **principle of least privilege** by regularly reviewing role assignments and removing access that is no longer needed. Use **time-bound eligible assignments** instead of permanent active roles.",
"Url": "https://hub.prowler.com/check/entra_pim_role_usage_alert_exists"
}
},
"Categories": [
"identity-access",
"e5"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,50 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.entra.entra_client import entra_client
# The alert definition ID for "Administrators aren't using their privileged roles"
# (also known as the StaleSignInAlert or inactive role assignment alert).
STALE_SIGN_IN_ALERT_DEFINITION_ID = "DirectoryRoleInactiveAlertDefinition"
class entra_pim_role_usage_alert_exists(Check):
"""
Ensure that the PIM alert for unused privileged roles is configured and active.
This check verifies that Privileged Identity Management (PIM) is configured
to alert when administrators are not using their assigned privileged roles,
helping detect stale or unnecessary role assignments.
- PASS: The PIM alert for unused privileged roles exists and is active.
- FAIL: The PIM alert for unused privileged roles does not exist or is not active.
"""
def execute(self) -> List[CheckReportM365]:
"""Execute the check logic.
Returns:
A list of reports containing the result of the check.
"""
findings = []
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name="PIM Role Usage Alert",
resource_id="pimRoleUsageAlert",
)
report.status = "FAIL"
report.status_extended = "PIM alert for unused privileged roles does not exist or is not active."
for alert in entra_client.pim_alerts:
if (
STALE_SIGN_IN_ALERT_DEFINITION_ID
in alert.alert_definition_id
and alert.is_active
):
report.status = "PASS"
report.status_extended = "PIM alert for unused privileged roles exists and is active."
break
findings.append(report)
return findings
@@ -36,6 +36,7 @@ class Entra(M365Service):
user_accounts_status (dict): Dictionary of user account statuses.
oauth_apps (dict): Dictionary of OAuth applications from Defender XDR.
authentication_method_configurations (dict): Dictionary of authentication method configurations.
pim_alerts (list): List of PIM alerts configured in the tenant.
"""
def __init__(self, provider: M365Provider):
@@ -83,6 +84,7 @@ class Entra(M365Service):
self._get_oauth_apps(),
self._get_directory_sync_settings(),
self._get_authentication_method_configurations(),
self._get_pim_alerts(),
)
)
@@ -98,6 +100,7 @@ class Entra(M365Service):
self.authentication_method_configurations: Dict[
str, AuthenticationMethodConfiguration
] = attributes[9]
self.pim_alerts: List[PIMAlert] = attributes[10]
self.user_accounts_status = {}
if created_loop:
@@ -1019,6 +1022,45 @@ OAuthAppInfo
return oauth_apps
async def _get_pim_alerts(self):
"""Retrieve PIM (Privileged Identity Management) alerts from Microsoft Entra.
Fetches PIM alerts from the identity governance API to determine which
alert policies are configured and active in the tenant, including
alerts for unused privileged roles.
Returns:
list[PIMAlert]: A list of PIM alerts configured in the tenant,
or an empty list if retrieval fails.
"""
logger.info("Entra - Getting PIM alerts...")
pim_alerts = []
try:
alerts_response = (
await self.client.identity_governance.role_management_alerts.alerts.get()
)
for alert in getattr(alerts_response, "value", []) or []:
pim_alerts.append(
PIMAlert(
id=getattr(alert, "id", ""),
alert_definition_id=getattr(
alert, "alert_definition_id", ""
),
scope_id=getattr(alert, "scope_id", "") or "",
scope_type=getattr(alert, "scope_type", "") or "",
is_active=getattr(alert, "is_active", False) or False,
number_of_affected_items=getattr(
alert, "number_of_affected_items", 0
)
or 0,
)
)
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return pim_alerts
async def _get_authentication_method_configurations(self):
"""Retrieve authentication method configurations from Microsoft Entra.
@@ -1481,3 +1523,23 @@ class OAuthApp(BaseModel):
is_admin_consented: bool = False
last_used_time: Optional[str] = None
app_origin: str = ""
class PIMAlert(BaseModel):
"""Model representing a PIM (Privileged Identity Management) alert.
Attributes:
id: The unique identifier for the alert.
alert_definition_id: The identifier of the alert definition type.
scope_id: The scope ID (typically the tenant ID).
scope_type: The scope type (e.g., 'DirectoryRole').
is_active: Whether the alert is currently active/enabled.
number_of_affected_items: The number of items affected by the alert.
"""
id: str
alert_definition_id: str
scope_id: str = ""
scope_type: str = ""
is_active: bool = False
number_of_affected_items: int = 0
@@ -0,0 +1,220 @@
from unittest import mock
from prowler.providers.m365.services.entra.entra_service import PIMAlert
from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
class Test_entra_pim_role_usage_alert_exists:
def test_no_pim_alerts(self):
"""Test when no PIM alerts exist - should FAIL since the alert is always evaluated."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
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_role_usage_alert_exists.entra_pim_role_usage_alert_exists.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_pim_role_usage_alert_exists.entra_pim_role_usage_alert_exists import (
entra_pim_role_usage_alert_exists,
)
entra_client.pim_alerts = []
check = entra_pim_role_usage_alert_exists()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "PIM alert for unused privileged roles does not exist or is not active."
)
assert result[0].resource_name == "PIM Role Usage Alert"
assert result[0].resource_id == "pimRoleUsageAlert"
def test_entra_pim_role_usage_alert_exists_pass(self):
"""Test when the PIM alert for unused privileged roles exists and is active."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
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_role_usage_alert_exists.entra_pim_role_usage_alert_exists.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_pim_role_usage_alert_exists.entra_pim_role_usage_alert_exists import (
entra_pim_role_usage_alert_exists,
)
entra_client.pim_alerts = [
PIMAlert(
id="alert-1",
alert_definition_id="DirectoryRoleInactiveAlertDefinition",
scope_id="tenant-id",
scope_type="DirectoryRole",
is_active=True,
number_of_affected_items=3,
),
]
check = entra_pim_role_usage_alert_exists()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "PIM alert for unused privileged roles exists and is active."
)
assert result[0].resource_name == "PIM Role Usage Alert"
assert result[0].resource_id == "pimRoleUsageAlert"
def test_entra_pim_role_usage_alert_exists_fail_not_active(self):
"""Test when the PIM alert for unused privileged roles exists but is not active."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
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_role_usage_alert_exists.entra_pim_role_usage_alert_exists.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_pim_role_usage_alert_exists.entra_pim_role_usage_alert_exists import (
entra_pim_role_usage_alert_exists,
)
entra_client.pim_alerts = [
PIMAlert(
id="alert-1",
alert_definition_id="DirectoryRoleInactiveAlertDefinition",
scope_id="tenant-id",
scope_type="DirectoryRole",
is_active=False,
number_of_affected_items=0,
),
]
check = entra_pim_role_usage_alert_exists()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "PIM alert for unused privileged roles does not exist or is not active."
)
assert result[0].resource_name == "PIM Role Usage Alert"
assert result[0].resource_id == "pimRoleUsageAlert"
def test_entra_pim_role_usage_alert_exists_fail_different_alert(self):
"""Test when PIM alerts exist but none match the expected definition ID."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
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_role_usage_alert_exists.entra_pim_role_usage_alert_exists.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_pim_role_usage_alert_exists.entra_pim_role_usage_alert_exists import (
entra_pim_role_usage_alert_exists,
)
entra_client.pim_alerts = [
PIMAlert(
id="alert-1",
alert_definition_id="SomeOtherAlertDefinition",
scope_id="tenant-id",
scope_type="DirectoryRole",
is_active=True,
number_of_affected_items=1,
),
]
check = entra_pim_role_usage_alert_exists()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "PIM alert for unused privileged roles does not exist or is not active."
)
assert result[0].resource_name == "PIM Role Usage Alert"
assert result[0].resource_id == "pimRoleUsageAlert"
def test_entra_pim_role_usage_alert_exists_pass_among_multiple_alerts(self):
"""Test when multiple PIM alerts exist and the correct one is active."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
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_role_usage_alert_exists.entra_pim_role_usage_alert_exists.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_pim_role_usage_alert_exists.entra_pim_role_usage_alert_exists import (
entra_pim_role_usage_alert_exists,
)
entra_client.pim_alerts = [
PIMAlert(
id="alert-1",
alert_definition_id="SomeOtherAlertDefinition",
scope_id="tenant-id",
scope_type="DirectoryRole",
is_active=True,
number_of_affected_items=1,
),
PIMAlert(
id="alert-2",
alert_definition_id="DirectoryRoleInactiveAlertDefinition",
scope_id="tenant-id",
scope_type="DirectoryRole",
is_active=True,
number_of_affected_items=5,
),
]
check = entra_pim_role_usage_alert_exists()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "PIM alert for unused privileged roles exists and is active."
)
assert result[0].resource_name == "PIM Role Usage Alert"
assert result[0].resource_id == "pimRoleUsageAlert"