Public/Discovery/Get-PrivilegedServicePrincipal.ps1

function Get-PrivilegedServicePrincipal {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $false)]
        [ValidateSet('Critical', 'High', 'Medium', 'Low', 'All')]
        [string]$Criticality = 'All',

        [Parameter(Mandatory = $false)]
        [string]$PermissionPattern,

        [Parameter(Mandatory = $false)]
        [string]$ServicePrincipalName,

        [Parameter(Mandatory = $false)]
        [ValidateSet('Object', 'JSON', 'CSV', 'Table')]
        [string]$OutputFormat = 'Object'
    )

    begin {
        Write-Verbose "Starting function $($MyInvocation.MyCommand.Name)"
        $MyInvocation.MyCommand.Name | Invoke-BlackCat -ResourceTypeName 'MSGraph'
        
        # Start timing analytics
        $stopwatch = [System.Diagnostics.Stopwatch]::StartNew()

        $privilegedPermissions = @{
            # Critical permissions - can read/write anything
            'Directory.ReadWrite.All'                     = @{ Name = 'Directory.ReadWrite.All'; Criticality = 'Critical'; Description = 'Read and write directory data' }
            'Application.ReadWrite.All'                   = @{ Name = 'Application.ReadWrite.All'; Criticality = 'Critical'; Description = 'Read and write all applications' }
            'AppRoleAssignment.ReadWrite.All'             = @{ Name = 'AppRoleAssignment.ReadWrite.All'; Criticality = 'Critical'; Description = 'Manage app role assignments for any app' }
            'RoleManagement.ReadWrite.Directory'          = @{ Name = 'RoleManagement.ReadWrite.Directory'; Criticality = 'Critical'; Description = 'Read and write role management data' }
            'Domain.ReadWrite.All'                        = @{ Name = 'Domain.ReadWrite.All'; Criticality = 'Critical'; Description = 'Read and write domains' }
            
            # High permissions - significant access
            'User.ReadWrite.All'                          = @{ Name = 'User.ReadWrite.All'; Criticality = 'High'; Description = 'Read and write all users' }
            'Group.ReadWrite.All'                         = @{ Name = 'Group.ReadWrite.All'; Criticality = 'High'; Description = 'Read and write all groups' }
            'Directory.Read.All'                          = @{ Name = 'Directory.Read.All'; Criticality = 'High'; Description = 'Read directory data' }
            'Application.Read.All'                        = @{ Name = 'Application.Read.All'; Criticality = 'High'; Description = 'Read all applications' }
            'DeviceManagementConfiguration.ReadWrite.All' = @{ Name = 'DeviceManagementConfiguration.ReadWrite.All'; Criticality = 'High'; Description = 'Read and write device management configuration' }
            'Policy.ReadWrite.All'                        = @{ Name = 'Policy.ReadWrite.All'; Criticality = 'High'; Description = 'Read and write organization policies' }
            'RoleManagement.Read.Directory'               = @{ Name = 'RoleManagement.Read.Directory'; Criticality = 'High'; Description = 'Read role management data' }
            'PrivilegedAccess.ReadWrite.AzureAD'          = @{ Name = 'PrivilegedAccess.ReadWrite.AzureAD'; Criticality = 'High'; Description = 'Read and write privileged access settings' }
            
            # Medium permissions - moderate access
            'User.Read.All'                               = @{ Name = 'User.Read.All'; Criticality = 'Medium'; Description = 'Read all users' }
            'Group.Read.All'                              = @{ Name = 'Group.Read.All'; Criticality = 'Medium'; Description = 'Read all groups' }
            'Mail.ReadWrite'                              = @{ Name = 'Mail.ReadWrite'; Criticality = 'Medium'; Description = 'Read and write mail' }
            'Files.ReadWrite.All'                         = @{ Name = 'Files.ReadWrite.All'; Criticality = 'Medium'; Description = 'Read and write all files' }
            'Sites.ReadWrite.All'                         = @{ Name = 'Sites.ReadWrite.All'; Criticality = 'Medium'; Description = 'Read and write all site collections' }
            'DeviceManagementConfiguration.Read.All'      = @{ Name = 'DeviceManagementConfiguration.Read.All'; Criticality = 'Medium'; Description = 'Read device management configuration' }
            
            # Low permissions - limited but notable access
            'Mail.Read'                                   = @{ Name = 'Mail.Read'; Criticality = 'Low'; Description = 'Read mail' }
            'Files.Read.All'                              = @{ Name = 'Files.Read.All'; Criticality = 'Low'; Description = 'Read all files' }
            'Calendars.ReadWrite'                         = @{ Name = 'Calendars.ReadWrite'; Criticality = 'Low'; Description = 'Read and write calendars' }
        }

        if ($Criticality -ne 'All') {
            $privilegedPermissions = $privilegedPermissions.GetEnumerator() | Where-Object { $_.Value.Criticality -eq $Criticality } | ForEach-Object { @{ $_.Key = $_.Value } }
            Write-Verbose "Filtered to $($privilegedPermissions.Count) $Criticality permissions"
        }

        Write-Output "Loaded $($privilegedPermissions.Count) privileged permissions"
    }

    process {
        try {
            Write-Verbose "Retrieving service principals with optimized query"
            $queryUrl = "servicePrincipals?`$filter=servicePrincipalType eq 'Application'&`$select=id,appId,displayName,servicePrincipalType,accountEnabled,createdDateTime"
            
            $servicePrincipals = Invoke-MsGraph -relativeUrl $queryUrl
            
            if (-not $servicePrincipals) {
                Write-Warning "No service principals found."
                return
            }
            
            Write-Verbose "Retrieved $($servicePrincipals.Count) application service principals"
            
            $resourceCache = @{}
            
            $results = @()
            $totalCount = $servicePrincipals.Count
            
            if ($ServicePrincipalName) {
                $servicePrincipals = $servicePrincipals | Where-Object { $_.displayName -like "*$ServicePrincipalName*" }
                Write-Verbose "Filtered to $($servicePrincipals.Count) service principals matching name pattern"
            }
            
            if ($servicePrincipals.Count -eq 0) {
                Write-Verbose "No service principals to process after filtering"
                return $null
            }
            
            Write-Verbose "Processing $($servicePrincipals.Count) service principals for privileged permissions using batch processing"
            
            $allBatchRequests = @()
            $requestMap = @{}
            
            for ($i = 0; $i -lt $servicePrincipals.Count; $i++) {
                $sp = $servicePrincipals[$i]
                
                $appRoleRequestId = "appRole_$i"
                $allBatchRequests += @{
                    id = $appRoleRequestId
                    method = "GET"
                    url = "/servicePrincipals/$($sp.id)/appRoleAssignments?`$select=resourceId,appRoleId"
                }
                $requestMap[$appRoleRequestId] = @{ ServicePrincipal = $sp; Type = "AppRole" }
                
                $oauth2RequestId = "oauth2_$i"
                $allBatchRequests += @{
                    id = $oauth2RequestId
                    method = "GET"
                    url = "/servicePrincipals/$($sp.id)/oauth2PermissionGrants?`$select=resourceId,scope"
                }
                $requestMap[$oauth2RequestId] = @{ ServicePrincipal = $sp; Type = "OAuth2" }
            }
            
            Write-Verbose "Created $($allBatchRequests.Count) batch requests for $($servicePrincipals.Count) service principals"
            
            $batchAnalytics = @{
                TotalBatches = [math]::Ceiling($allBatchRequests.Count / 10.0)
                SuccessfulBatches = 0
                FailedBatches = 0
                IndividualFallbacks = 0
                BatchStartTime = Get-Date
            }
            
            $allBatchResponses = @{}
            $batchSize = 20
            
            for ($batchIndex = 0; $batchIndex -lt $allBatchRequests.Count; $batchIndex += $batchSize) {
                $batchRequests = $allBatchRequests[$batchIndex..([Math]::Min($batchIndex + $batchSize - 1, $allBatchRequests.Count - 1))]
                
                Write-Verbose "Executing batch $([Math]::Floor($batchIndex / $batchSize) + 1) with $($batchRequests.Count) requests"
                
                try {
                    $batchPayload = @{
                        requests = $batchRequests
                    } | ConvertTo-Json -Depth 10
                    
                    $batchResponse = Invoke-RestMethod -Uri "$($sessionVariables.graphUri)/`$batch" -Method POST -Headers $script:graphHeader -ContentType "application/json" -Body $batchPayload -UserAgent $sessionVariables.userAgent
                    
                    foreach ($response in $batchResponse.responses) {
                        if ($response.status -eq 200 -and $response.body) {
                            $allBatchResponses[$response.id] = $response.body
                        } else {
                            Write-Verbose "Request $($response.id) failed with status $($response.status)"
                        }
                    }
                    $batchAnalytics.SuccessfulBatches++
                }
                catch {
                    Write-Warning "Batch request failed: $($_.Exception.Message). Falling back to individual calls for this batch."
                    $batchAnalytics.FailedBatches++
                    
                    foreach ($request in $batchRequests) {
                        try {
                            $url = $request.url.TrimStart('/')
                            $individualResponse = Invoke-MsGraph -relativeUrl $url -NoBatch -OutputFormat Object -ErrorAction SilentlyContinue
                            if ($individualResponse) {
                                $allBatchResponses[$request.id] = $individualResponse
                            }
                            $batchAnalytics.IndividualFallbacks++
                        }
                        catch {
                            Write-Verbose "Individual fallback request failed for $($request.id)"
                        }
                    }
                }
            }
            
            $batchAnalytics.BatchEndTime = Get-Date
            $batchAnalytics.BatchProcessingTime = ($batchAnalytics.BatchEndTime - $batchAnalytics.BatchStartTime).TotalSeconds
            
            Write-Verbose "Completed batch processing. Processing responses for $($servicePrincipals.Count) service principals"
            
            for ($i = 0; $i -lt $servicePrincipals.Count; $i++) {
                $sp = $servicePrincipals[$i]
                $foundPermissions = @()
                
                # Show progress every 50 SPs
                if ($i % 50 -eq 0) {
                    Write-Verbose "Processing service principal $($i + 1) of $($servicePrincipals.Count): $($sp.displayName)"
                }
                
                $appRoleRequestId = "appRole_$i"
                if ($allBatchResponses.ContainsKey($appRoleRequestId)) {
                    $appRoleData = $allBatchResponses[$appRoleRequestId]
                    $appRoleAssignments = if ($appRoleData.value) { $appRoleData.value } else { $appRoleData }
                    
                    if ($appRoleAssignments) {
                        foreach ($appRole in $appRoleAssignments) {
                            try {
                                $resource = $null
                                if ($resourceCache.ContainsKey($appRole.resourceId)) {
                                    $resource = $resourceCache[$appRole.resourceId]
                                }
                                else {
                                    $resource = Invoke-MsGraph -relativeUrl "servicePrincipals/$($appRole.resourceId)?`$select=displayName,appRoles" -NoBatch -OutputFormat Object -ErrorAction SilentlyContinue
                                    if ($resource) {
                                        $resourceCache[$appRole.resourceId] = $resource
                                    }
                                }
                                
                                if ($resource -and $resource.appRoles) {
                                    # Find the specific app role
                                    $roleDefinition = $resource.appRoles | Where-Object { $_.id -eq $appRole.appRoleId }
                                    
                                    if ($roleDefinition -and $privilegedPermissions.ContainsKey($roleDefinition.value)) {
                                        $foundPermissions += @{
                                            PermissionName = $roleDefinition.value
                                            PermissionType = 'Application'
                                            Resource = $resource.displayName
                                            Criticality = $privilegedPermissions[$roleDefinition.value].Criticality
                                            Description = $privilegedPermissions[$roleDefinition.value].Description
                                        }
                                        Write-Verbose "Found privileged app permission: $($roleDefinition.value) on $($sp.displayName)"
                                    }
                                }
                            }
                            catch {
                                # Silently continue
                            }
                        }
                    }
                }
                
                $oauth2RequestId = "oauth2_$i"
                if ($allBatchResponses.ContainsKey($oauth2RequestId)) {
                    $oauth2Data = $allBatchResponses[$oauth2RequestId]
                    $oauth2Grants = if ($oauth2Data.value) { $oauth2Data.value } else { $oauth2Data }
                    
                    if ($oauth2Grants) {
                        foreach ($grant in $oauth2Grants) {
                            if ($grant.scope) {
                                $scopes = $grant.scope -split '\s+'
                                foreach ($scope in $scopes) {
                                    if ($privilegedPermissions.ContainsKey($scope)) {
                                        try {
                                            $resource = $null
                                            if ($resourceCache.ContainsKey($grant.resourceId)) {
                                                $resource = $resourceCache[$grant.resourceId]
                                            }
                                            else {
                                                $resource = Invoke-MsGraph -relativeUrl "servicePrincipals/$($grant.resourceId)?`$select=displayName" -NoBatch -OutputFormat Object -ErrorAction SilentlyContinue
                                                if ($resource) {
                                                    $resourceCache[$grant.resourceId] = $resource
                                                }
                                            }
                                            
                                            $foundPermissions += @{
                                                PermissionName = $scope
                                                PermissionType = 'Delegated'
                                                Resource = if ($resource) { $resource.displayName } else { 'Unknown' }
                                                Criticality = $privilegedPermissions[$scope].Criticality
                                                Description = $privilegedPermissions[$scope].Description
                                            }
                                            Write-Verbose "Found privileged delegated permission: $scope on $($sp.displayName)"
                                        }
                                        catch {
                                            # Silently continue
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
                
                if ($PermissionPattern -and $foundPermissions.Count -gt 0) {
                    $foundPermissions = $foundPermissions | Where-Object { $_.PermissionName -like "*$PermissionPattern*" }
                }
                
                if ($foundPermissions.Count -gt 0) {
                    # Get the highest criticality level
                    $highestCriticality = ($foundPermissions.Criticality | ForEach-Object {
                            switch ($_) {
                                "Critical" { 4 }
                                "High" { 3 }
                                "Medium" { 2 }
                                "Low" { 1 }
                                default { 0 }
                            }
                        } | Measure-Object -Maximum).Maximum
                    
                    $criticalityName = switch ($highestCriticality) {
                        4 { "Critical" }
                        3 { "High" }
                        2 { "Medium" }
                        1 { "Low" }
                        default { "Unknown" }
                    }
                    
                    $results += [PSCustomObject]@{
                        Id                   = $sp.id
                        AppId                = $sp.appId
                        DisplayName          = $sp.displayName
                        PermissionCount      = $foundPermissions.Count
                        Permissions          = $foundPermissions
                        HighestCriticality   = $criticalityName
                        ServicePrincipalType = $sp.servicePrincipalType
                        IsEnabled            = $sp.accountEnabled
                        CreatedDateTime      = $sp.createdDateTime
                    }
                    
                    Write-Verbose "Added privileged SP: $($sp.displayName) with $($foundPermissions.Count) permissions"
                }
            }
            
            Write-Verbose "Completed processing. Found $($results.Count) privileged service principals from $($servicePrincipals.Count) processed (reduced from $totalCount total)"
            
            $results = $results | Sort-Object -Property @{
                Expression = {
                    switch ($_.HighestCriticality) {
                        "Critical" { 4 }
                        "High" { 3 }
                        "Medium" { 2 }
                        "Low" { 1 }
                        default { 0 }
                    }
                }
            }, DisplayName -Descending
            
            if ($results.Count -eq 0) {
                Write-Host "No privileged service principals found matching the criteria." -ForegroundColor Yellow
                return $null
            }
            else {
                Write-Host "Found $($results.Count) privileged service principals." -ForegroundColor Green
                
                $formatParam = @{
                    Data         = $results
                    OutputFormat = $OutputFormat
                    FunctionName = $MyInvocation.MyCommand.Name
                }
                
                return Format-BlackCatOutput @formatParam
            }
        }
        catch {
            Write-Warning "An error occurred while retrieving privileged service principals: $($_.Exception.Message)"
            Write-Verbose "Stack Trace: $($_.ScriptStackTrace)"
            return $null
        }
    }

    end {
        $stopwatch.Stop()
        $elapsedTime = $stopwatch.Elapsed
        $totalSeconds = [math]::Round($elapsedTime.TotalSeconds, 2)
        
        Write-Host "`n=== Execution Analytics ===" -ForegroundColor Cyan
        Write-Host "Total execution time: $totalSeconds seconds" -ForegroundColor Green
        
        # Show batch processing analytics if available
        if ($batchAnalytics) {
            $batchTime = [math]::Round($batchAnalytics.BatchProcessingTime, 2)
            $batchEfficiency = if ($batchAnalytics.TotalBatches -gt 0) { 
                [math]::Round(($batchAnalytics.SuccessfulBatches / $batchAnalytics.TotalBatches) * 100, 1)
            } else { 0 }
            
            Write-Host "`nBatch Processing Analytics:" -ForegroundColor Yellow
            Write-Host " - Total batches: $($batchAnalytics.TotalBatches)" -ForegroundColor White
            Write-Host " - Successful batches: $($batchAnalytics.SuccessfulBatches)" -ForegroundColor Green
            Write-Host " - Failed batches: $($batchAnalytics.FailedBatches)" -ForegroundColor Red
            Write-Host " - Individual fallbacks: $($batchAnalytics.IndividualFallbacks)" -ForegroundColor Yellow
            Write-Host " - Batch efficiency: $batchEfficiency%" -ForegroundColor Cyan
            Write-Host " - Batch processing time: $batchTime seconds" -ForegroundColor White
        }
        
        Write-Host "`nTime breakdown:" -ForegroundColor Yellow
        Write-Host " - Minutes: $($elapsedTime.Minutes)" -ForegroundColor White
        Write-Host " - Seconds: $($elapsedTime.Seconds)" -ForegroundColor White
        Write-Host " - Milliseconds: $($elapsedTime.Milliseconds)" -ForegroundColor White
        Write-Host " - Total seconds: $totalSeconds" -ForegroundColor Cyan
        
        Write-Verbose "Completed function $($MyInvocation.MyCommand.Name) in $totalSeconds seconds"
    }

    <#
    .SYNOPSIS
        Discovers service principals with privileged permissions in Entra ID.
 
    .DESCRIPTION
        This function identifies service principals that have been granted privileged permissions in Entra ID.
        It checks both application permissions (app roles) and delegated permissions (OAuth2 scopes) to identify
        service principals with high-risk access. Permissions are categorized by criticality level (Critical, High, Medium, Low).
 
    .PARAMETER Criticality
        Filter results by permission criticality level.
        Valid values: Critical, High, Medium, Low, All
        Default: All
 
    .PARAMETER PermissionPattern
        Filter results by permission name pattern. Only returns service principals with permissions matching the pattern.
 
    .PARAMETER ServicePrincipalName
        Filter results by service principal display name (partial match).
 
    .PARAMETER OutputFormat
        Specifies the output format.
        Valid values: Object, JSON, CSV, Table
        Default: Object
 
    .EXAMPLE
        Get-PrivilegedServicePrincipal -Criticality Critical -OutputFormat Table
 
        Returns all service principals with Critical permissions and displays them in a table format.
 
    .EXAMPLE
        Get-PrivilegedServicePrincipal -PermissionPattern "*ReadWrite*"
 
        Returns all service principals with permissions containing "ReadWrite".
 
    .EXAMPLE
        Get-PrivilegedServicePrincipal -ServicePrincipalName "Azure" -OutputFormat JSON
 
        Returns all service principals with "Azure" in their name that have privileged permissions and exports the results as JSON.
 
        Performs a stealthy scan of only the first 50 service principals looking for Critical permissions.
 
    .NOTES
        Requires the BlackCat module and appropriate Entra ID permissions to enumerate service principals and their permissions.
         
        Privileged permissions checked include:
        - Critical: Directory.ReadWrite.All, Application.ReadWrite.All, AppRoleAssignment.ReadWrite.All
        - High: User.ReadWrite.All, Group.ReadWrite.All, Directory.Read.All
        - Medium: User.Read.All, Group.Read.All, Mail.ReadWrite, Files.ReadWrite.All
        - Low: Mail.Read, Files.Read.All, Calendars.ReadWrite
    #>

}