Compare commits

...
Author SHA1 Message Date
Hugo P.Brito 6cd2ffbca2 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.
2026-05-11 12:55:32 +01:00
Hugo P.Brito a646c68308 chore(m365): align PIM stale alert check with project formatters and drop test __init__.py
Apply poetry's black to entra_service.py so the file matches the
configuration CI's sdk-code-quality job uses. Also remove the redundant
tests/__init__.py for this check; pytest discovers tests by path and the
project convention is to keep test directories package-free.
2026-05-11 12:24:20 +01:00
Hugo P.Brito 62ad4e5b9f docs(m365): move PIM stale alert changelog entry to 5.27.0
The PIM stale sign-in alert check entry was placed under the already
released 5.24.1 section. Move it to a new [5.27.0] (UNRELEASED) block at
the top of the changelog so the entry lands in the actual shipping
release notes.
2026-05-11 12:01:09 +01:00
Hugo P.Brito ce8f6037c1 fix(m365): drop misleading fallbacks when parsing PIM alert incidents
The DirectoryRoleStaleSignInAlertIncident schema does not expose 'subject',
'createdDateTime', or any field whose 'id' represents the affected user, so
the previous fallbacks silently substituted the wrong values and made
status_extended messages refer to the incident GUID instead of the actual
user when the API response omitted assigneeId/assigneeDisplayName.

Read each field directly from the documented incident properties so an
empty value stays empty rather than being papered over with unrelated
data.
2026-05-11 12:00:08 +01:00
Hugo P.Brito b49e49f543 fix(m365): require active PIM stale alert before flagging accounts
The PIM alert object exposes an is_active flag that goes False once the
alert condition stops firing, even though the previous number_of_affected_items
count can stick around in the API response. The check was ignoring that
flag, so a tenant whose alert was already resolved or dismissed could
still receive a FAIL based on stale counters.

Gate the FAIL branch on alert.is_active so only currently-firing alerts
produce findings; everything else (resolved alert, inactive with leftover
counts) is reported as PASS. A regression test covers the inactive-with-
counts case to lock the behavior in.
2026-05-11 11:48:46 +01:00
Hugo P.Brito 4cf2207d58 fix(m365): emit a single MANUAL finding when PIM stale alert is unavailable
The previous behavior fanned out a FAIL finding for every Organization the
tenant returned whenever the stale sign-in alert was missing from the API
response. That conflates three distinct conditions — no Microsoft Entra ID
P2 license, alert disabled, or insufficient permission — into the same
verdict, penalizes tenants without PIM, and emits N near-duplicate findings
for what is logically a single tenant-level state.

Emit a single MANUAL finding pinned to the first organization instead, with
a status_extended that lists the actionable causes so the operator can
choose what to remediate. MANUAL is the right verdict because the cause may
be legitimate (no P2) rather than misconfiguration.
2026-05-11 11:43:42 +01:00
Hugo P.Brito 080bc174fa fix(m365): drop external test-suite reference from PIM stale alert metadata
Remove the third-party documentation URL so the public metadata only
points to Microsoft's official PIM alert guidance.
2026-05-11 11:41:51 +01:00
Hugo P.Brito 6fac51047c Merge remote-tracking branch 'origin/master' into feat/prowler-846 2026-05-11 11:34:17 +01:00
Hugo P.Brito 15a5527910 feat(m365): add entra_pim_stale_sign_in_alert security check
Add new security check entra_pim_stale_sign_in_alert for m365 provider.
Includes check implementation, metadata, and unit tests.
2026-04-20 14:37:40 +01:00
13 changed files with 892 additions and 5 deletions
+9
View File
@@ -2,6 +2,15 @@
All notable changes to the **Prowler SDK** are documented in this file.
## [5.27.0] (Prowler UNRELEASED)
### 🚀 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)
---
## [5.26.0] (Prowler UNRELEASED)
### 🚀 Added
+7 -2
View File
@@ -1502,7 +1502,10 @@
{
"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_only_management",
"entra_pim_stale_sign_in_alert"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",
@@ -1544,7 +1547,9 @@
{
"Id": "5.3.3",
"Description": "Access reviews enable administrators to establish an efficient automated process for reviewing group memberships, access to enterprise applications, and role assignments. These reviews can be scheduled to recur regularly, with flexible options for delegating the task of reviewing membership to different members of the organization.Ensure `Access reviews` for high privileged Entra ID roles are done `monthly` or more frequently. These reviews should include **at a minimum** the roles listed below:- Global Administrator- Exchange Administrator- SharePoint Administrator- Teams Administrator- Security Administrator**Note:** An access review is created for each role selected after completing the process.",
"Checks": [],
"Checks": [
"entra_pim_stale_sign_in_alert"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",
+7 -2
View File
@@ -1803,7 +1803,10 @@
{
"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_only_management",
"entra_pim_stale_sign_in_alert"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",
@@ -1845,7 +1848,9 @@
{
"Id": "5.3.3",
"Description": "Access reviews enable administrators to establish an efficient automated process for reviewing group memberships, access to enterprise applications, and role assignments. Ensure 'Access reviews' for privileged roles are configured to be done monthly or more frequently.",
"Checks": [],
"Checks": [
"entra_pim_stale_sign_in_alert"
],
"Attributes": [
{
"Section": "5 Microsoft Entra admin center",
@@ -281,6 +281,8 @@
"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",
"sharepoint_external_sharing_restricted",
@@ -672,6 +674,8 @@
"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",
"entra_seamless_sso_disabled"
@@ -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,38 @@
{
"Provider": "m365",
"CheckID": "entra_pim_stale_sign_in_alert",
"CheckTitle": "PIM stale sign-in alert detects unused privileged accounts that may be compromised",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "NotDefined",
"ResourceGroup": "governance",
"Description": "Microsoft Entra PIM monitors privileged role assignments and raises a **stale sign-in alert** when accounts have not authenticated within a configured period (default 30 days).\n\n*Stale privileged accounts indicate roles that may be over-provisioned or abandoned.*",
"Risk": "Stale accounts retaining **privileged roles** expand the attack surface by providing dormant credentials that attackers can exploit undetected. Compromised stale accounts enable **privilege escalation** and **lateral movement** without triggering normal user activity alerts, weakening confidentiality and integrity.",
"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. Sign in to the Microsoft Entra admin center\n2. Navigate to Identity governance > Privileged Identity Management > Alerts\n3. Review the 'Potential stale accounts in a privileged role' alert\n4. For each affected account, remove unnecessary role assignments or confirm the account is still required\n5. Investigate whether stale accounts show signs of compromise",
"Terraform": ""
},
"Recommendation": {
"Text": "Apply **least privilege** by removing privileged role assignments from accounts that no longer require them. Implement **just-in-time access** via PIM eligible assignments instead of permanent roles. Establish periodic **access reviews** to detect and remediate stale privileged accounts before they can be exploited.",
"Url": "https://hub.prowler.com/check/entra_pim_stale_sign_in_alert"
}
},
"Categories": [
"identity-access",
"e3"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "Requires Microsoft Entra ID P2 license and RoleManagement.Read.All permission. The stale sign-in threshold is configurable in PIM alert settings (default: 30 days)."
}
@@ -0,0 +1,81 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.entra.entra_client import entra_client
STALE_SIGN_IN_ALERT_DEFINITION_ID = "DirectoryRole_StaleSignInAlert"
class entra_pim_stale_sign_in_alert(Check):
"""Check if there are stale accounts in privileged roles detected by PIM.
This check verifies that Privileged Identity Management (PIM) does not
report any stale sign-in alerts for users with privileged role assignments.
A stale account is one that has not signed in within a configured period
(default 30 days) while still retaining a privileged directory role.
Stale privileged accounts represent a significant security risk because
unused credentials in elevated roles can be exploited by attackers without
detection.
"""
def execute(self) -> List[CheckReportM365]:
"""Execute the PIM stale sign-in alert check.
Retrieves the PIM stale sign-in alert from the Entra client and generates
a report indicating whether stale accounts exist in privileged roles.
Returns:
List[CheckReportM365]: A list containing the report object with the result of the check.
"""
findings = []
stale_alert = entra_client.pim_alerts.get(STALE_SIGN_IN_ALERT_DEFINITION_ID)
if stale_alert:
report = CheckReportM365(
self.metadata(),
resource=stale_alert,
resource_id=stale_alert.id,
resource_name="PIM Stale Sign-In Alert",
)
if stale_alert.is_active and stale_alert.number_of_affected_items > 0:
affected_users = ", ".join(
incident.assignee_display_name or incident.assignee_id
for incident in stale_alert.affected_items[:5]
)
suffix = (
f" and {stale_alert.number_of_affected_items - 5} more"
if stale_alert.number_of_affected_items > 5
else ""
)
report.status = "FAIL"
report.status_extended = (
f"PIM detected {stale_alert.number_of_affected_items} "
f"stale account(s) in privileged roles: {affected_users}{suffix}."
)
else:
report.status = "PASS"
report.status_extended = "PIM stale sign-in alert reports no stale accounts in privileged roles."
findings.append(report)
elif entra_client.organizations:
organization = entra_client.organizations[0]
report = CheckReportM365(
self.metadata(),
resource=organization,
resource_id=organization.id,
resource_name=organization.name,
)
report.status = "MANUAL"
report.status_extended = (
"PIM stale sign-in 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
@@ -5,6 +5,8 @@ from enum import Enum
from typing import Dict, List, Optional
from uuid import UUID
from kiota_abstractions.method import Method
from kiota_abstractions.request_information import RequestInformation
from msgraph.generated.models.o_data_errors.o_data_error import ODataError
from msgraph.generated.security.microsoft_graph_security_run_hunting_query.run_hunting_query_post_request_body import (
RunHuntingQueryPostRequestBody,
@@ -23,7 +25,7 @@ class Entra(M365Service):
This class provides methods to retrieve and manage Microsoft Entra ID
security policies and configurations, including authorization policies,
conditional access policies, admin consent policies, groups, organizations,
users, and OAuth application data from Defender XDR.
users, OAuth application data from Defender XDR, and PIM alerts.
Attributes:
tenant_domain (str): The tenant domain.
@@ -36,6 +38,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 (dict): Dictionary of PIM alerts keyed by alert definition ID.
"""
def __init__(self, provider: M365Provider):
@@ -83,6 +86,7 @@ class Entra(M365Service):
self._get_oauth_apps(),
self._get_directory_sync_settings(),
self._get_authentication_method_configurations(),
self._get_pim_alerts(),
)
)
@@ -98,6 +102,7 @@ class Entra(M365Service):
self.authentication_method_configurations: Dict[
str, AuthenticationMethodConfiguration
] = attributes[9]
self.pim_alerts: Dict[str, PimAlert] = attributes[10]
self.user_accounts_status = {}
if created_loop:
@@ -1055,6 +1060,72 @@ OAuthAppInfo
)
return authentication_method_configurations
async def _get_pim_alerts(self):
"""Retrieve Privileged Identity Management (PIM) role management alerts.
Fetches unified role management alerts from the Microsoft Graph API to
identify security issues such as stale accounts in privileged roles.
Uses a raw HTTP request since the SDK does not expose this endpoint natively.
Returns:
Dict[str, PimAlert]: Dictionary of PIM alerts keyed by alert definition ID.
"""
logger.info("Entra - Getting PIM alerts...")
pim_alerts = {}
try:
request_info = RequestInformation()
request_info.http_method = Method.GET
request_info.url = "https://graph.microsoft.com/v1.0/identityGovernance/roleManagement/alerts/alerts?$expand=alertIncidents"
response = await self.client.request_adapter.send_primitive_async(
request_info, "bytes", {}
)
if response:
data = json.loads(response)
for alert in data.get("value", []):
alert_definition_id = alert.get("alertDefinitionId", "")
incidents = alert.get("alertIncidents", [])
affected_items = []
for incident in incidents:
affected_items.append(
PimAlertIncident(
assignee_display_name=incident.get(
"assigneeDisplayName", ""
),
assignee_id=incident.get("assigneeId", ""),
role_display_name=incident.get("roleDisplayName", ""),
last_sign_in_date_time=incident.get(
"lastSignInDateTime", ""
),
)
)
pim_alerts[alert_definition_id] = PimAlert(
id=alert.get("id", ""),
alert_definition_id=alert_definition_id,
scope_id=alert.get("scopeId", "/"),
scope_type=alert.get("scopeType", ""),
is_active=alert.get("isActive", False),
number_of_affected_items=alert.get("numberOfAffectedItems", 0),
incident_count=alert.get("incidentCount", 0),
affected_items=affected_items,
)
except ODataError as error:
error_code = getattr(error.error, "code", None) if error.error else None
if error_code == "Authorization_RequestDenied":
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: "
"Insufficient privileges to read PIM alerts. "
"Required permission: RoleManagement.Read.All or RoleManagement.ReadWrite.Directory"
)
else:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return pim_alerts
class ConditionalAccessPolicyState(Enum):
ENABLED = "enabled"
@@ -1486,3 +1557,43 @@ class OAuthApp(BaseModel):
is_admin_consented: bool = False
last_used_time: Optional[str] = None
app_origin: str = ""
class PimAlertIncident(BaseModel):
"""Model representing an incident (affected resource) within a PIM alert.
Attributes:
assignee_display_name: The display name of the user with a stale sign-in.
assignee_id: The unique identifier of the affected user.
role_display_name: The privileged role assigned to the user.
last_sign_in_date_time: The last sign-in date for the user (ISO 8601 format).
"""
assignee_display_name: str = ""
assignee_id: str = ""
role_display_name: str = ""
last_sign_in_date_time: str = ""
class PimAlert(BaseModel):
"""Model representing a Privileged Identity Management (PIM) alert.
Attributes:
id: The unique identifier of the alert.
alert_definition_id: The alert type identifier (e.g., 'DirectoryRole_StaleSignInAlert').
scope_id: The scope of the alert (typically '/' for tenant-wide).
scope_type: The scope type (e.g., 'DirectoryRole').
is_active: Whether the alert is currently active.
number_of_affected_items: Count of resources affected by the alert.
incident_count: Number of incidents reported for the alert.
affected_items: List of affected resources (incidents).
"""
id: str
alert_definition_id: str
scope_id: str = "/"
scope_type: str = ""
is_active: bool = False
number_of_affected_items: int = 0
incident_count: int = 0
affected_items: List[PimAlertIncident] = []
@@ -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
@@ -0,0 +1,275 @@
from unittest import mock
from prowler.providers.m365.services.entra.entra_service import (
Organization,
PimAlert,
PimAlertIncident,
)
from tests.providers.m365.m365_fixtures import set_mocked_m365_provider
class Test_entra_pim_stale_sign_in_alert:
def test_no_stale_accounts(self):
"""PASS: PIM stale sign-in alert exists with no 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_stale_sign_in_alert.entra_pim_stale_sign_in_alert.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_pim_stale_sign_in_alert.entra_pim_stale_sign_in_alert import (
entra_pim_stale_sign_in_alert,
)
entra_client.pim_alerts = {
"DirectoryRole_StaleSignInAlert": PimAlert(
id="alert-001",
alert_definition_id="DirectoryRole_StaleSignInAlert",
scope_id="/",
scope_type="DirectoryRole",
is_active=True,
number_of_affected_items=0,
incident_count=0,
affected_items=[],
)
}
check = entra_pim_stale_sign_in_alert()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "PIM stale sign-in alert reports no stale accounts in privileged roles."
)
assert result[0].resource_id == "alert-001"
assert result[0].resource_name == "PIM Stale Sign-In Alert"
assert result[0].location == "global"
def test_stale_accounts_detected(self):
"""FAIL: PIM stale sign-in alert has 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_stale_sign_in_alert.entra_pim_stale_sign_in_alert.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_pim_stale_sign_in_alert.entra_pim_stale_sign_in_alert import (
entra_pim_stale_sign_in_alert,
)
entra_client.pim_alerts = {
"DirectoryRole_StaleSignInAlert": PimAlert(
id="alert-001",
alert_definition_id="DirectoryRole_StaleSignInAlert",
scope_id="/",
scope_type="DirectoryRole",
is_active=True,
number_of_affected_items=2,
incident_count=2,
affected_items=[
PimAlertIncident(
assignee_display_name="John Doe",
assignee_id="user-001",
role_display_name="Global Administrator",
last_sign_in_date_time="2025-01-01T00:00:00Z",
),
PimAlertIncident(
assignee_display_name="Jane Smith",
assignee_id="user-002",
role_display_name="Security Administrator",
last_sign_in_date_time="2025-02-01T00:00:00Z",
),
],
)
}
check = entra_pim_stale_sign_in_alert()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "2 stale account(s)" in result[0].status_extended
assert "John Doe" in result[0].status_extended
assert "Jane Smith" in result[0].status_extended
assert result[0].resource_id == "alert-001"
assert result[0].resource_name == "PIM Stale Sign-In Alert"
assert result[0].location == "global"
def test_stale_accounts_more_than_five(self):
"""FAIL: PIM stale sign-in alert with more than 5 affected items truncates display."""
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_stale_sign_in_alert.entra_pim_stale_sign_in_alert.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_pim_stale_sign_in_alert.entra_pim_stale_sign_in_alert import (
entra_pim_stale_sign_in_alert,
)
affected_items = [
PimAlertIncident(
assignee_display_name=f"User {i}",
assignee_id=f"user-{i:03d}",
role_display_name="Global Administrator",
last_sign_in_date_time="2025-01-01T00:00:00Z",
)
for i in range(7)
]
entra_client.pim_alerts = {
"DirectoryRole_StaleSignInAlert": PimAlert(
id="alert-001",
alert_definition_id="DirectoryRole_StaleSignInAlert",
scope_id="/",
scope_type="DirectoryRole",
is_active=True,
number_of_affected_items=7,
incident_count=7,
affected_items=affected_items,
)
}
check = entra_pim_stale_sign_in_alert()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "7 stale account(s)" in result[0].status_extended
assert "and 2 more" in result[0].status_extended
assert result[0].resource_id == "alert-001"
def test_alert_not_configured(self):
"""MANUAL: PIM stale sign-in alert is not available."""
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_stale_sign_in_alert.entra_pim_stale_sign_in_alert.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_pim_stale_sign_in_alert.entra_pim_stale_sign_in_alert import (
entra_pim_stale_sign_in_alert,
)
entra_client.pim_alerts = {}
entra_client.organizations = [
Organization(
id="org-001",
name="Contoso",
on_premises_sync_enabled=False,
),
Organization(
id="org-002",
name="Contoso Two",
on_premises_sync_enabled=False,
),
]
check = entra_pim_stale_sign_in_alert()
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"
assert result[0].location == "global"
def test_inactive_alert_with_lingering_affected_items(self):
"""PASS: alert reports affected items 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_stale_sign_in_alert.entra_pim_stale_sign_in_alert.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_pim_stale_sign_in_alert.entra_pim_stale_sign_in_alert import (
entra_pim_stale_sign_in_alert,
)
entra_client.pim_alerts = {
"DirectoryRole_StaleSignInAlert": PimAlert(
id="alert-resolved",
alert_definition_id="DirectoryRole_StaleSignInAlert",
scope_id="/",
scope_type="DirectoryRole",
is_active=False,
number_of_affected_items=3,
incident_count=3,
affected_items=[
PimAlertIncident(
assignee_display_name="Resolved User",
assignee_id="user-resolved",
role_display_name="Global Administrator",
last_sign_in_date_time="2024-01-01T00:00:00Z",
)
],
)
}
check = entra_pim_stale_sign_in_alert()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert "no stale accounts" in result[0].status_extended
assert result[0].resource_id == "alert-resolved"
def test_empty_pim_alerts_no_organizations(self):
"""No findings when PIM alerts empty and 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_stale_sign_in_alert.entra_pim_stale_sign_in_alert.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_pim_stale_sign_in_alert.entra_pim_stale_sign_in_alert import (
entra_pim_stale_sign_in_alert,
)
entra_client.pim_alerts = {}
entra_client.organizations = []
check = entra_pim_stale_sign_in_alert()
result = check.execute()
assert len(result) == 0