Public/Get-UnprotectedServicePrincipals.ps1

<#
.SYNOPSIS
    Finds service principals with unprotected, expired, or excessive credentials.

.DESCRIPTION
    Audits service principal credentials (certificates and secrets) to identify security risks:
    - Expired credentials that should be removed
    - Credentials without expiration dates (never expire)
    - Excessive credential accumulation
    
    Automatically excludes Microsoft first-party applications where credentials
    are managed by Microsoft.

.PARAMETER IncludeMicrosoftApps
    Include Microsoft first-party applications in the audit. Default is $false.

.PARAMETER ExportPath
    Optional path to export results to CSV.

.EXAMPLE
    Get-UnprotectedServicePrincipals

    Returns all third-party service principals with credential issues.

.EXAMPLE
    Get-UnprotectedServicePrincipals -IncludeMicrosoftApps $true

    Includes Microsoft first-party apps in the audit (may show false positives).

.NOTES
    Author: Kent Agent (kentagent-ai)
    Created: 2026-03-11
    Updated: 2026-03-12 (v2.2.2 - improved Microsoft-managed certificate detection)
    Requires: Microsoft.Graph PowerShell module
    Permissions: Application.Read.All

.LINK
    https://github.com/kentagent-ai/EntraIDSecurityScripts
#>

function Get-UnprotectedServicePrincipals {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $false)]
        [bool]$IncludeMicrosoftApps = $false,

        [Parameter(Mandatory = $false)]
        [string]$ExportPath
    )

    begin {
        # Verify Graph connection
        $context = Get-MgContext
        if (-not $context) {
            throw "Not connected to Microsoft Graph. Run: Connect-MgGraph -Scopes 'Application.Read.All'"
        }

        # Microsoft's tenant ID (for first-party apps)
        $microsoftTenantId = '72f988bf-86f1-41af-91ab-2d7cd011db47'
        
        $results = [System.Collections.Generic.List[PSCustomObject]]::new()
    }

    process {
        Write-Verbose "Retrieving service principals..."
        
        try {
            # Get all service principals with only needed properties
            $sps = Get-MgServicePrincipal -All -Property Id, DisplayName, AppId, AppOwnerOrganizationId, KeyCredentials, PasswordCredentials, ServicePrincipalType -ErrorAction Stop
        }
        catch {
            throw "Failed to retrieve service principals: $_"
        }

        Write-Verbose "Found $($sps.Count) service principals"
        Write-Host "Analyzing credentials for $($sps.Count) service principals..." -ForegroundColor Cyan

        $processedCount = 0
        foreach ($sp in $sps) {
            $processedCount++
            if ($processedCount % 100 -eq 0) {
                Write-Progress -Activity "Analyzing service principals" -Status "$processedCount of $($sps.Count)" -PercentComplete (($processedCount / $sps.Count) * 100)
            }

            # Filter Microsoft first-party apps unless explicitly requested
            $isMicrosoftApp = $sp.AppOwnerOrganizationId -eq $microsoftTenantId
            if ($isMicrosoftApp -and -not $IncludeMicrosoftApps) {
                Write-Verbose "Skipping Microsoft app: $($sp.DisplayName)"
                continue
            }

            # Skip managed identities (they don't have user-manageable credentials)
            if ($sp.ServicePrincipalType -eq 'ManagedIdentity') {
                Write-Verbose "Skipping managed identity: $($sp.DisplayName)"
                continue
            }

            # Combine all credentials
            $allCredentials = @()
            $allCredentials += $sp.KeyCredentials
            $allCredentials += $sp.PasswordCredentials

            if ($allCredentials.Count -eq 0) {
                continue  # Skip service principals with no credentials
            }

            # Track issues per service principal
            $expiredCount = 0
            $neverExpireCount = 0
            $activeCount = 0

            foreach ($cred in $allCredentials) {
                $now = Get-Date
                $credentialType = if ($cred.Type) { 'Certificate' } else { 'Secret' }
                $isExpired = $cred.EndDateTime -and ($cred.EndDateTime -lt $now)
                $neverExpires = $null -eq $cred.EndDateTime -or $cred.EndDateTime -gt $now.AddYears(10)

                # Categorize the credential
                if ($isExpired) {
                    $expiredCount++
                    
                    # Calculate how long it's been expired
                    $daysExpired = ($now - $cred.EndDateTime).Days
                    
                    $riskLevel = if ($daysExpired -gt 365) {
                        'HIGH'  # Expired over a year ago
                    } elseif ($daysExpired -gt 90) {
                        'MEDIUM'  # Expired over 90 days ago
                    } else {
                        'LOW'  # Recently expired (might be in rotation)
                    }

                    $results.Add([PSCustomObject]@{
                        DisplayName        = $sp.DisplayName
                        AppId              = $sp.AppId
                        ServicePrincipalId = $sp.Id
                        IsMicrosoftApp     = $isMicrosoftApp
                        CredentialType     = $credentialType
                        CredentialName     = $cred.DisplayName
                        StartDate          = $cred.StartDateTime
                        ExpiryDate         = $cred.EndDateTime
                        DaysExpired        = $daysExpired
                        KeyId              = $cred.KeyId
                        Issue              = 'Expired Credential'
                        RiskLevel          = $riskLevel
                        Recommendation     = if ($isMicrosoftApp) {
                            'Microsoft-managed app - verify if managed by Microsoft before removal'
                        } else {
                            "Remove expired $credentialType (expired $daysExpired days ago)"
                        }
                    })
                }
                elseif ($neverExpires) {
                    $neverExpireCount++
                    
                    $results.Add([PSCustomObject]@{
                        DisplayName        = $sp.DisplayName
                        AppId              = $sp.AppId
                        ServicePrincipalId = $sp.Id
                        IsMicrosoftApp     = $isMicrosoftApp
                        CredentialType     = $credentialType
                        CredentialName     = $cred.DisplayName
                        StartDate          = $cred.StartDateTime
                        ExpiryDate         = $cred.EndDateTime
                        DaysExpired        = $null
                        KeyId              = $cred.KeyId
                        Issue              = 'No Expiration'
                        RiskLevel          = 'HIGH'
                        Recommendation     = "Set expiration policy for $credentialType (recommended: 1-2 years for certificates, 6-12 months for secrets)"
                    })
                }
                else {
                    $activeCount++
                }
            }

            # Flag service principals with excessive credential accumulation
            $totalCredentials = $allCredentials.Count
            if ($totalCredentials -gt 5) {
                $results.Add([PSCustomObject]@{
                    DisplayName        = $sp.DisplayName
                    AppId              = $sp.AppId
                    ServicePrincipalId = $sp.Id
                    IsMicrosoftApp     = $isMicrosoftApp
                    CredentialType     = 'Multiple'
                    CredentialName     = "Total: $totalCredentials ($expiredCount expired, $activeCount active)"
                    StartDate          = $null
                    ExpiryDate         = $null
                    DaysExpired        = $null
                    KeyId              = $null
                    Issue              = 'Excessive Credentials'
                    RiskLevel          = 'MEDIUM'
                    Recommendation     = "Review and clean up unused credentials (has $totalCredentials total, $expiredCount expired)"
                })
            }
        }

        Write-Progress -Activity "Analyzing service principals" -Completed
    }

    end {
        Write-Verbose "Found $($results.Count) credential issues"

        # Summary
        $high = ($results | Where-Object { $_.RiskLevel -eq 'HIGH' }).Count
        $medium = ($results | Where-Object { $_.RiskLevel -eq 'MEDIUM' }).Count
        $low = ($results | Where-Object { $_.RiskLevel -eq 'LOW' }).Count
        $expired = ($results | Where-Object { $_.Issue -eq 'Expired Credential' }).Count
        $neverExpire = ($results | Where-Object { $_.Issue -eq 'No Expiration' }).Count

        Write-Host "`n=== Service Principal Credential Issues ===" -ForegroundColor Yellow
        Write-Host "Total issues found: $($results.Count)" -ForegroundColor White
        Write-Host "HIGH risk: $high" -ForegroundColor $(if ($high -gt 0) { 'Red' } else { 'Green' })
        Write-Host "MEDIUM risk: $medium" -ForegroundColor $(if ($medium -gt 0) { 'Yellow' } else { 'Green' })
        Write-Host "LOW risk: $low" -ForegroundColor $(if ($low -gt 0) { 'Yellow' } else { 'Green' })
        Write-Host "`nBy Issue Type:" -ForegroundColor White
        Write-Host " Expired credentials: $expired" -ForegroundColor Gray
        Write-Host " No expiration set: $neverExpire" -ForegroundColor Gray
        Write-Host "==========================================`n" -ForegroundColor Yellow

        # Export if requested
        if ($ExportPath) {
            try {
                $results | Export-Csv -Path $ExportPath -NoTypeInformation -Encoding UTF8
                Write-Host "Results exported to: $ExportPath" -ForegroundColor Green
            }
            catch {
                Write-Error "Failed to export results: $_"
            }
        }

        return $results
    }
}

Export-ModuleMember -Function Get-UnprotectedServicePrincipals -ErrorAction SilentlyContinue