public/Get-MsecEntraPimAlert.ps1

function Get-MsecEntraPimAlert {
    <#
    .SYNOPSIS
        Privileged Identity Management's own security alerts about the tenant's privileged
        access - findings that reach no other product.

    .DESCRIPTION
        PIM scans privileged access and raises its own findings: roles assigned outside PIM, too
        many global administrators, roles that do not require multifactor authentication to
        activate, stale accounts in privileged roles. They are posture findings about
        configuration, not detections of an event.

        THESE ALERTS REACH NEITHER DEFENDER XDR NOR SENTINEL. Verified on one tenant: 333
        Defender XDR alerts over 30 days across Defender for Office 365, Defender for Cloud,
        Sentinel, DLP and Defender for Endpoint, with no PIM product among them; and no row in
        Sentinel's SecurityAlert mentioning PIM at all. There is no connector that brings them.
        The only trace that reaches a log is a single audit record when one is RESOLVED, which
        names the alert after the fact and says nothing about what is active now - so without
        this command, a live PIM alert is visible only to someone opening the PIM blade.

        AN INACTIVE ALERT IS NOT AN ABSENT ONE, which is why every alert definition the tenant
        scans for is returned with IsActive saying where it stands. Returning only the firing
        ones would make "we are not scanning for that" and "that is fine" look identical.

        INCIDENT COUNT IS THE SIZE OF THE FINDING, not its severity. 'Roles are being assigned
        outside Privileged Identity Management' with 104 incidents and with 1 are the same
        alert at the same severity and very different problems.

        THIS IS A BETA GRAPH ENDPOINT. /beta/identityGovernance/roleManagementAlerts has no v1.0
        equivalent at the time of writing, so the shape can change under it. The command reads
        it rather than pretending the data is unavailable, and this note is here so a future
        failure is recognised as an API change rather than a tenant with nothing to report.

        Needs 'RoleManagementAlert.Read.Directory'. Global Reader does NOT cover it - measured:
        the endpoint returns 403 to an app holding Global Reader and 20 other Graph permissions.

    .PARAMETER ActiveOnly
        Only alerts currently firing.

    .PARAMETER IncludeIncidents
        Fetch the incidents behind each alert - the specific roles, and for some alert types the
        specific people. One extra request per alert, so it is a switch rather than the default.
        The incident shape VARIES BY ALERT TYPE: 'Roles don't require MFA for activation' names
        only the role, while 'Eligible administrators aren't activating' names the assignee and
        when they last activated. Common fields are projected; Raw keeps the rest.

    .PARAMETER Scope
        The scope to query. 'DirectoryRole' (default) covers Entra directory roles.

    .EXAMPLE
        Get-MsecEntraPimAlert

        Every PIM alert definition with where it stands, firing or not.

    .EXAMPLE
        Get-MsecEntraPimAlert -ActiveOnly | Sort-Object IncidentCount -Descending

        What PIM currently objects to, biggest first.

    .EXAMPLE
        Get-MsecEntraPimAlert -ActiveOnly |
            Where-Object AlertName -match 'outside'

        Whether roles are being assigned outside PIM - the finding that standing privilege is
        accumulating by a route the activation record never shows.

    .OUTPUTS
        PSCustomObject per alert, PSTypeName 'MsecEntraPimAlert'.

    .NOTES
        Pairs with Get-MsecEntraPimActivation, which shows the alert being resolved but never
        the alert itself.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [switch] $ActiveOnly,
        [switch] $IncludeIncidents,

        [Parameter()]
        [ValidateSet('DirectoryRole')]
        [string] $Scope = 'DirectoryRole'
    )

    Assert-MsecSession

    $path = "/beta/identityGovernance/roleManagementAlerts/alerts?`$filter=scopeId eq '/' and scopeType eq '$Scope'&`$expand=alertDefinition"

    $alerts = $null
    try { $alerts = @(Invoke-MsecGraphRequest -Path $path -All) }
    catch {
        if ($_.Exception.Message -match '403|Forbidden') {
            throw "Forbidden reading PIM alerts. The msec app needs 'RoleManagementAlert.Read.Directory' (admin consent required) - the Global Reader directory role does NOT cover this endpoint. Original error: $($_.Exception.Message)"
        }
        if ($_.Exception.Message -match '404|BadRequest|400') {
            throw "PIM alerts could not be read ($($_.Exception.Message)). This is a BETA Graph endpoint (/beta/identityGovernance/roleManagementAlerts) and its shape can change - treat this as an API change rather than a tenant with no alerts."
        }
        throw
    }

    foreach ($a in $alerts) {
        $def = $a.alertDefinition

        # $null, not 0, where the count is absent: an alert whose size was not reported is not
        # an alert with nothing behind it.
        $incidents = if ($null -ne $a.incidentCount) { [int]$a.incidentCount } else { $null }

        if ($ActiveOnly -and -not $a.isActive) { continue }

        # NEVER SCANNED IS NOT A DATE IN THE YEAR 1. An alert the tenant has not evaluated comes
        # back as 0001-01-01, and comparing against DateTime::MinValue does not catch it -
        # parsing a Z-suffixed sentinel converts to local time, so east of UTC it lands just
        # above MinValue and renders as a real timestamp. The year is what separates them.
        # Observed on 'Potential stale accounts in a privileged role'.
        $scanned = $null
        if ($a.lastScannedDateTime) {
            $parsed = [datetime]::MinValue
            if ([datetime]::TryParse([string]$a.lastScannedDateTime, [ref] $parsed) -and $parsed.Year -gt 1) {
                $scanned = $parsed
            }
        }

        # INCIDENT SHAPE VARIES BY ALERT TYPE, so the projection keeps what is common and Raw
        # keeps everything: noMfaOnRoleActivation carries only the role, while
        # redundantAssignment adds the assignee and when they last activated. Flattening to a
        # single fixed shape would silently drop the half that differs.
        $incidentRows = @()
        if ($IncludeIncidents) {
            try {
                foreach ($i in @(Invoke-MsecGraphRequest -Path "/beta/identityGovernance/roleManagementAlerts/alerts/$($a.id)/alertIncidents" -All)) {
                    $last = $null
                    if ($i.lastActivationDateTime) {
                        $lp = [datetime]::MinValue
                        if ([datetime]::TryParse([string]$i.lastActivationDateTime, [ref] $lp) -and $lp.Year -gt 1) { $last = $lp }
                    }
                    $incidentRows += [PSCustomObject]@{
                        PSTypeName     = 'MsecEntraPimAlertIncident'
                        AlertName      = [string]$def.displayName
                        IncidentType   = ([string]$i.'@odata.type') -replace '^#microsoft\.graph\.', ''
                        Role           = [string]$i.roleDisplayName
                        RoleTemplateId = [string]$i.roleTemplateId
                        Assignee       = [string]$i.assigneeDisplayName
                        AssigneeUpn    = [string]$i.assigneeUserPrincipalName
                        # $null means NEVER ACTIVATED, which is the finding for a redundant
                        # assignment - not a missing value.
                        LastActivation = $last
                        IncidentId     = [string]$i.id
                        Raw            = $i
                    }
                }
            }
            catch {
                Write-Warning ("Could not read incidents for '{0}' - its Incidents list is empty, which is NOT the same as the alert having none: {1}" -f $def.displayName, $_.Exception.Message)
            }
        }

        [PSCustomObject]@{
            PSTypeName       = 'MsecEntraPimAlert'
            AlertName        = [string]$def.displayName
            # False means PIM scanned and found nothing, which is a measurement. It is NOT the
            # same as the alert being switched off - see IsEnabled.
            IsActive         = [bool]$a.isActive
            IncidentCount    = $incidents
            Severity         = [string]$def.severityLevel
            # Whether the tenant scans for this at all. A disabled definition reports
            # IsActive false for the same reason a clean tenant does.
            IsEnabled        = if ($null -ne $def.isConfigurable -and $null -ne $a.alertConfiguration.isEnabled) {
                                   [bool]$a.alertConfiguration.isEnabled
                               } else { $null }
            # $null where the alert has never been scanned - see above.
            LastScannedUtc   = $scanned
            # Populated only with -IncludeIncidents. Empty array otherwise, which is why the
            # switch exists rather than it being inferred from the array being empty.
            Incidents        = @($incidentRows)
            Description      = [string]$def.description
            HowToPrevent     = [string]$def.howToPrevent
            IsRemediatable   = if ($null -ne $def.isRemediatable) { [bool]$def.isRemediatable } else { $null }
            ScopeType        = [string]$a.scopeType
            AlertId          = [string]$a.id
            AlertDefinitionId = [string]$def.id
        }
    }
}