Public/Get-TntM365RiskyUserReport.ps1

function Get-TntM365RiskyUserReport {
    <#
    .SYNOPSIS
        Generates a Microsoft 365 risky user report using Azure AD Identity Protection signals.
 
    .DESCRIPTION
        Connects to Microsoft Graph and retrieves risky user findings, including risk levels, states,
        and detection metadata. The report helps identify accounts that require remediation or policy
        enforcement.
 
    .PARAMETER TenantId
        The Azure AD Tenant ID (GUID) to analyze.
 
    .PARAMETER ClientId
        The Application (Client) ID of the registered Azure AD application.
 
    .PARAMETER ClientSecret
        The client secret for the application. Accepts SecureString or plain String.
 
    .PARAMETER CertificateThumbprint
        The certificate thumbprint for certificate-based authentication. Alternative to ClientSecret.
 
    .PARAMETER RiskLevel
        Optional list of risk levels (None, Low, Medium, High) to filter results.
 
    .PARAMETER DaysBack
        Number of days of risk history to include. Defaults to 90 days.
 
    .PARAMETER MaxRiskyUsers
        Maximum number of risky users to process. Defaults to 1000.
 
    .EXAMPLE
        Get-TntM365RiskyUserReport -TenantId $tenantId -ClientId $clientId -ClientSecret $secret
 
        Returns complete risky user analysis as a PSCustomObject.
 
    .EXAMPLE
        Get-TntM365RiskyUserReport -TenantId $tenantId -ClientId $clientId -ClientSecret $secret -RiskLevel High
 
        Returns report filtered for High risk users only.
 
    .OUTPUTS
        System.Management.Automation.PSCustomObject
 
        Returns an object with the following structure:
        - ReportDate: Timestamp of generation
        - TenantId: The analyzed tenant
        - Summary: Statistics on risky users and detections
        - RiskyUsers: Detailed list of users at risk
        - RiskDetections: Detailed list of risk detection events
        - RiskyServicePrincipals: Detailed list of risky service principals
 
    .NOTES
        Author: Tom de Leeuw
        Website: https://systom.dev
        Module: TenantReports
 
        Required Permissions:
        - IdentityRiskyUser.Read.All (Application)
        - IdentityRiskEvent.Read.All (Application)
        - IdentityRiskyServicePrincipal.Read.All (Application)
 
    .LINK
        https://systom.dev
    #>


    [CmdletBinding(DefaultParameterSetName = 'ClientSecret')]
    [OutputType([System.Management.Automation.PSCustomObject])]
    param(
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'ClientSecret')]
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Certificate')]
        [ValidateNotNullOrEmpty()]
        [Alias('Tenant')]
        [string]$TenantId,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'ClientSecret')]
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Certificate')]
        [ValidatePattern('^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$')]
        [Alias('ApplicationId')]
        [string]$ClientId,

        [Parameter(Mandatory = $true, ParameterSetName = 'ClientSecret', ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [Alias('Secret', 'ApplicationSecret')]
        [SecureString]$ClientSecret,

        [Parameter(Mandatory = $true, ParameterSetName = 'Certificate', ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [Alias('Thumbprint')]
        [string]$CertificateThumbprint,

        [Parameter(Mandatory = $true, ParameterSetName = 'Interactive')]
        [switch]$Interactive,

        [Parameter()]
        [ValidateSet('None', 'Low', 'Medium', 'High')]
        [string[]]$RiskLevel,

        [Parameter()]
        [ValidateRange(1, 365)]
        [int]$DaysBack = 90,

        [Parameter()]
        [ValidateRange(1, 100000)]
        [int]$MaxRiskyUsers = 1000,

        [Parameter()]
        [switch]$IncludeHistory
    )

    begin {
        # Calculate start date
        $StartDate = [datetime]::UtcNow.AddDays(-$DaysBack).ToString('yyyy-MM-ddTHH:mm:ssZ')
        Write-Information "STARTED : Risky users report generation for past $($DaysBack) days..." -InformationAction Continue
    }

    process {
        # Interactive authentication is not supported for this function
        # Identity Protection APIs require application permissions (IdentityRiskyUser.Read.All, etc.)
        if ($Interactive) {
            Write-Warning 'Get-TntM365RiskyUserReport requires application permissions and cannot run with interactive authentication.'
            Write-Warning 'The following application permissions are required: IdentityRiskyUser.Read.All, IdentityRiskEvent.Read.All, IdentityRiskyServicePrincipal.Read.All'
            Write-Warning 'Use -ClientSecret or -CertificateThumbprint authentication instead.'
            return $null
        }

        try {
            # Establish connection
            $ConnectionParams = Get-ConnectionParameters -BoundParameters $PSBoundParameters
            $ConnectionInfo   = Connect-TntGraphSession @ConnectionParams

            Write-Verbose "Retrieving risky users (max $($MaxRiskyUsers))..."

            # Build filter parameters
            $FilterParams = @{
                Filter = "riskLastUpdatedDateTime ge $($StartDate)"
            }

            if ($RiskLevel) {
                $RiskLevelConditions    = $RiskLevel.ForEach({ "RiskLevel eq '$($_.ToLower())'" })
                $RiskLevelFilterString  = "($($RiskLevelConditions -join ' or '))"
                $FilterParams.Filter   += " and $RiskLevelFilterString"
                Write-Verbose "Filtering by riskLevel: $($RiskLevel -join ', ')"
            }

            Write-Verbose "Using filter: $($FilterParams.Filter)"
            $RiskyUserProperties = @(
                'Id',
                'UserDisplayName',
                'UserPrincipalName',
                'RiskLevel',
                'RiskState',
                'RiskDetail',
                'IsProcessing',
                'IsDeleted',
                'LastRiskDetectionDate',
                'DetectedRisks',
                'riskLastUpdatedDateTime'
            )
            
            $RiskyUsers = Get-MgRiskyUser -All @FilterParams -Top $MaxRiskyUsers -ErrorAction SilentlyContinue | Select-Object $RiskyUserProperties
            Write-Verbose "Found $($RiskyUsers.Count) risky users."

            # Process Risky Users data for report, including fetching risk event types
            $RiskyUsersReport = foreach ($RiskyUser in $RiskyUsers) {
                $RiskEventTypes = @()
                if ($IncludeHistory) {
                    try {
                        $RiskyUserHistory = Get-MgRiskyUserHistory -RiskyUserId $RiskyUser.Id -ErrorAction SilentlyContinue
                        if ($RiskyUserHistory) {
                            $RiskEventTypes = ($RiskyUserHistory.Activity | Select-Object -ExpandProperty RiskEventTypes | Select-Object -Unique) -join ', '
                        }
                    } catch {
                        Write-Warning "Could not retrieve risk history for user $($RiskyUser.UserPrincipalName): $($_.Exception.Message)"
                        $RiskEventTypes = 'Error retrieving history'
                    }
                }

                [PSCustomObject]@{
                    UserId                  = $RiskyUser.Id
                    DisplayName             = $RiskyUser.UserDisplayName
                    UserPrincipalName       = $RiskyUser.UserPrincipalName
                    RiskLevel               = $RiskyUser.RiskLevel
                    RiskState               = $RiskyUser.RiskState
                    RiskDetail              = $RiskyUser.RiskDetail
                    LastRiskDetectionDate   = $RiskyUser.LastRiskDetectionDate
                    RiskEventTypes          = $RiskEventTypes
                    IsProcessing            = $RiskyUser.IsProcessing
                    IsDeleted               = $RiskyUser.IsDeleted
                    RiskLastUpdatedDateTime = $RiskyUser.RiskLastUpdatedDateTime
                }
            }

            # Collect Risk Detections
            Write-Verbose "Retrieving risk detections..."
            $FilterString   = "LastUpdatedDateTime ge $($StartDate)"
            $RiskDetections = Get-MgRiskDetection -All -Filter $FilterString -ErrorAction SilentlyContinue
            
            $RiskDetectionsReport = foreach ($RiskDetection in $RiskDetections) {
                [PSCustomObject]@{
                    'Id'                      = $RiskDetection.id
                    'RiskDetectionDateTime'   = $RiskDetection.DetectedDateTime
                    'RiskLevel'               = $RiskDetection.riskLevel
                    'RiskState'               = $RiskDetection.riskState
                    'RiskDetail'              = $RiskDetection.riskDetail
                    'RiskType'                = $RiskDetection.riskEventType
                    'UserId'                  = $RiskDetection.userId
                    'UserDisplayName'         = $RiskDetection.userDisplayName
                    'UserPrincipalName'       = $RiskDetection.userPrincipalName
                    'RiskLastUpdatedDateTime' = $RiskDetection.LastUpdatedDateTime
                    'RiskEventTypes'          = $RiskDetection.riskEventType
                }
            }

            # Collect risky Service Principals
            Write-Verbose "Retrieving risky service principals..."
            $FilterString           = "riskLastUpdatedDateTime ge $($StartDate)"
            $RiskyServicePrincipals = Get-MgRiskyServicePrincipal -All -Filter $FilterString -ErrorAction SilentlyContinue
            
            $RiskyServicePrincipalReport = foreach ($RiskyService in $RiskyServicePrincipals) {
                [PSCustomObject]@{
                    'Id'                          = $RiskyService.id
                    'AppID'                       = $RiskyService.appId
                    'ServicePrincipalDisplayName' = $RiskyService.DisplayName
                    'RiskLastUpdatedDateTime'     = $RiskyService.RiskLastUpdatedDateTime
                    'RiskLevel'                   = $RiskyService.riskLevel
                    'RiskState'                   = $RiskyService.riskState
                    'ServicePrincipalType'        = $RiskyService.servicePrincipalType
                }
            }

            $Summary = [PSCustomObject]@{
                TotalRiskyUsers                = if ($RiskyUsersReport) { $RiskyUsersReport.Count } else { 0 }
                RiskyUsersConfirmedCompromised = if ($RiskyUsersReport) { $RiskyUsersReport.Where({ $_.RiskDetail -eq 'UserConfirmedCompromised' }).Count } else { 0 }
                RiskyUsersAtHighLevel          = if ($RiskyUsersReport) { $RiskyUsersReport.Where({ $_.RiskLevel -eq 'High' }).Count } else { 0 }
                TotalRiskDetections            = if ($RiskDetectionsReport) { $RiskDetectionsReport.Count } else { 0 }
                TotalRiskyServicePrincipals    = if ($RiskyServicePrincipalReport) { $RiskyServicePrincipalReport.Count } else { 0 }
            }

            Write-Information "FINISHED : Risky users report - $($Summary.TotalRiskyUsers) risky users found" -InformationAction Continue

            [PSCustomObject]@{
                ReportDate             = Get-Date
                TenantId               = $TenantId
                Summary                = $Summary
                RiskyUsers             = $RiskyUsersReport | Sort-Object RiskLevel, DisplayName
                RiskDetections         = $RiskDetectionsReport | Sort-Object RiskLevel, RiskDetectionDateTime -Descending
                RiskyServicePrincipals = $RiskyServicePrincipalReport | Sort-Object RiskLevel, ServicePrincipalDisplayName
            }
        }
        catch {
            $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                [System.Exception]::new("Get-TntM365RiskyUserReport failed: $($_.Exception.Message)", $_.Exception),
                'GetTntM365RiskyUserReportError',
                [System.Management.Automation.ErrorCategory]::OperationStopped,
                $TenantId
            )
            $PSCmdlet.ThrowTerminatingError($errorRecord)
        }
        finally {
            if ($ConnectionInfo.ShouldDisconnect) {
                Disconnect-TntGraphSession -ConnectionState $ConnectionInfo
            }
        }
    }
}