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 and Microsoft-managed
    platform certificates where credentials are managed by Microsoft.

    Can optionally remove expired credentials with -RemoveExpiredCredentials.

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

.PARAMETER IncludeMicrosoftCerts
    Include Microsoft platform certificates (*.microsoft.com, *.azure.com, etc.). Default is $false.

.PARAMETER RemoveExpiredCredentials
    Remove expired credentials. Use with -WhatIf to preview changes.
    Requires Application.ReadWrite.All permission.

.PARAMETER ExportPath
    Optional path to export results to CSV.

.EXAMPLE
    Get-UnprotectedServicePrincipals

    Returns all third-party service principals with credential issues.

.EXAMPLE
    Get-UnprotectedServicePrincipals -RemoveExpiredCredentials -WhatIf

    Shows what expired credentials WOULD be removed without actually removing them.

.EXAMPLE
    Get-UnprotectedServicePrincipals -RemoveExpiredCredentials

    Actually removes expired credentials (prompts for confirmation).

.EXAMPLE
    Get-UnprotectedServicePrincipals | Where-Object { $_.RiskLevel -eq 'HIGH' }

    Shows only high-risk credential issues.

.NOTES
    Author: Kent Agent (kentagent-ai)
    Created: 2026-03-11
    Updated: 2026-03-12 (Added -RemoveExpiredCredentials with WhatIf support)
    Requires: Microsoft.Graph PowerShell module
    Permissions: Application.Read.All (read), Application.ReadWrite.All (remove)

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

function Get-UnprotectedServicePrincipals {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    param(
        [Parameter(Mandatory = $false)]
        [bool]$IncludeMicrosoftApps = $false,

        [Parameter(Mandatory = $false)]
        [bool]$IncludeMicrosoftCerts = $false,

        [Parameter(Mandatory = $false)]
        [switch]$RemoveExpiredCredentials,

        [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'"
        }

        # Check for write permissions if removing credentials
        if ($RemoveExpiredCredentials) {
            if ('Application.ReadWrite.All' -notin $context.Scopes) {
                Write-Warning "Missing Application.ReadWrite.All scope. Reconnect with: Connect-MgGraph -Scopes 'Application.ReadWrite.All'"
            }
        }

        # Microsoft's tenant ID (for first-party apps)
        $microsoftTenantId = '72f988bf-86f1-41af-91ab-2d7cd011db47'
        
        # Microsoft-managed certificate patterns (CN= or display names)
        # These are platform certificates managed by Microsoft services
        $microsoftCertPatterns = @(
            '\.microsoft\.com$'
            '\.azure\.com$'
            '\.azure-api\.net$'
            '\.windows\.net$'
            '\.windowsazure\.com$'
            '\.dynamics\.com$'
            '\.office\.com$'
            '\.office365\.com$'
            '\.sharepoint\.com$'
            '\.onmicrosoft\.com$'
            '\.microsoftonline\.com$'
            '\.powerapps\.com$'
            '\.powerva\.microsoft\.com$'
            '^CN=Microsoft '
            '^CN=Azure '
            '^MS-Organization-'
        )
        $microsoftCertRegex = $microsoftCertPatterns -join '|'

        $results = New-Object System.Collections.ArrayList
        $removedCount = 0
        $skippedCount = 0
    }

    process {
        Write-Verbose "Retrieving service principals..."
        
        try {
            $servicePrincipals = Get-MgServicePrincipal -All -Property Id, AppId, DisplayName, AppOwnerOrganizationId, KeyCredentials, PasswordCredentials -ErrorAction Stop
        }
        catch {
            throw "Failed to retrieve service principals: $_"
        }

        Write-Host "Processing $($servicePrincipals.Count) service principals..." -ForegroundColor Cyan
        $skippedMicrosoftCerts = 0

        foreach ($sp in $servicePrincipals) {
            # Check if Microsoft first-party app
            $isMicrosoftApp = $sp.AppOwnerOrganizationId -eq $microsoftTenantId

            # Skip Microsoft apps unless explicitly requested
            if ($isMicrosoftApp -and -not $IncludeMicrosoftApps) {
                continue
            }

            $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' }
                $credentialName = $cred.DisplayName
                
                # Check if this is a Microsoft-managed certificate
                $isMicrosoftCert = $false
                if ($credentialName -and $credentialName -match $microsoftCertRegex) {
                    $isMicrosoftCert = $true
                }
                
                # Skip Microsoft-managed certificates unless explicitly requested
                if ($isMicrosoftCert -and -not $IncludeMicrosoftCerts) {
                    $skippedMicrosoftCerts++
                    Write-Verbose "Skipping Microsoft-managed cert: $credentialName on $($sp.DisplayName)"
                    continue
                }

                $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)
                    }

                    $resultObj = [PSCustomObject]@{
                        DisplayName        = $sp.DisplayName
                        AppId              = $sp.AppId
                        ServicePrincipalId = $sp.Id
                        IsMicrosoftApp     = $isMicrosoftApp
                        IsMicrosoftCert    = $isMicrosoftCert
                        CredentialType     = $credentialType
                        CredentialName     = $credentialName
                        StartDate          = $cred.StartDateTime
                        ExpiryDate         = $cred.EndDateTime
                        DaysExpired        = $daysExpired
                        KeyId              = $cred.KeyId
                        Issue              = 'Expired Credential'
                        RiskLevel          = $riskLevel
                        Recommendation     = if ($isMicrosoftApp -or $isMicrosoftCert) {
                            'Microsoft-managed credential - likely auto-renewed, verify before removal'
                        } else {
                            "Remove expired $credentialType (expired $daysExpired days ago)"
                        }
                        Removed            = $false
                    }

                    # Remove expired credential if requested
                    if ($RemoveExpiredCredentials -and -not $isMicrosoftApp -and -not $isMicrosoftCert) {
                        $target = "$credentialType '$credentialName' from $($sp.DisplayName)"
                        
                        if ($PSCmdlet.ShouldProcess($target, "Remove expired credential")) {
                            try {
                                # Need to get the Application object to remove credentials
                                $app = Get-MgApplication -Filter "appId eq '$($sp.AppId)'" -ErrorAction SilentlyContinue
                                
                                if ($app) {
                                    if ($credentialType -eq 'Secret') {
                                        Remove-MgApplicationPassword -ApplicationId $app.Id -KeyId $cred.KeyId -ErrorAction Stop
                                    } else {
                                        Remove-MgApplicationKey -ApplicationId $app.Id -KeyId $cred.KeyId -ErrorAction Stop
                                    }
                                    $resultObj.Removed = $true
                                    $removedCount++
                                    Write-Host " [REMOVED] $target" -ForegroundColor Green
                                } else {
                                    Write-Warning "Cannot find application for $($sp.DisplayName) - may be external/enterprise app only"
                                    $skippedCount++
                                }
                            }
                            catch {
                                Write-Warning "Failed to remove $target : $_"
                                $skippedCount++
                            }
                        }
                    }

                    [void]$results.Add($resultObj)
                }
                elseif ($neverExpires) {
                    $neverExpireCount++
                    
                    [void]$results.Add([PSCustomObject]@{
                        DisplayName        = $sp.DisplayName
                        AppId              = $sp.AppId
                        ServicePrincipalId = $sp.Id
                        IsMicrosoftApp     = $isMicrosoftApp
                        IsMicrosoftCert    = $isMicrosoftCert
                        CredentialType     = $credentialType
                        CredentialName     = $credentialName
                        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)"
                        Removed            = $false
                    })
                }
                else {
                    $activeCount++
                }
            }

            # Flag service principals with excessive credential accumulation
            $totalCredentials = $allCredentials.Count
            if ($totalCredentials -gt 5) {
                [void]$results.Add([PSCustomObject]@{
                    DisplayName        = $sp.DisplayName
                    AppId              = $sp.AppId
                    ServicePrincipalId = $sp.Id
                    IsMicrosoftApp     = $isMicrosoftApp
                    IsMicrosoftCert    = $false
                    CredentialType     = 'Multiple'
                    CredentialName     = $null
                    StartDate          = $null
                    ExpiryDate         = $null
                    DaysExpired        = $null
                    KeyId              = $null
                    Issue              = 'Excessive Credentials'
                    RiskLevel          = 'MEDIUM'
                    Recommendation     = "Review and consolidate credentials (found $totalCredentials credentials, $expiredCount expired)"
                    Removed            = $false
                })
            }
        }
    }

    end {
        Write-Host ""
        
        if ($skippedMicrosoftCerts -gt 0) {
            Write-Host "Skipped $skippedMicrosoftCerts Microsoft-managed certificates (use -IncludeMicrosoftCerts `$true to include)" -ForegroundColor Gray
        }

        if ($results.Count -gt 0) {
            $expiredCreds = ($results | Where-Object { $_.Issue -eq 'Expired Credential' }).Count
            $noExpiryCreds = ($results | Where-Object { $_.Issue -eq 'No Expiration' }).Count
            $excessiveCreds = ($results | Where-Object { $_.Issue -eq 'Excessive Credentials' }).Count
            $highRisk = ($results | Where-Object { $_.RiskLevel -eq 'HIGH' }).Count

            Write-Host "=== Credential Issues Summary ===" -ForegroundColor Yellow
            Write-Host "Total issues: $($results.Count)" -ForegroundColor White
            Write-Host "Expired credentials: $expiredCreds" -ForegroundColor $(if ($expiredCreds -gt 0) { 'Red' } else { 'Green' })
            Write-Host "No expiration set: $noExpiryCreds" -ForegroundColor $(if ($noExpiryCreds -gt 0) { 'Yellow' } else { 'Green' })
            Write-Host "Excessive credentials: $excessiveCreds" -ForegroundColor $(if ($excessiveCreds -gt 0) { 'Yellow' } else { 'Green' })
            Write-Host "High risk items: $highRisk" -ForegroundColor $(if ($highRisk -gt 0) { 'Red' } else { 'Green' })
            
            if ($RemoveExpiredCredentials) {
                Write-Host ""
                Write-Host "Removed: $removedCount | Skipped: $skippedCount" -ForegroundColor Cyan
            }
            
            Write-Host "=================================" -ForegroundColor Yellow

            # Export if requested
            if ($ExportPath) {
                $results | Export-Csv -Path $ExportPath -NoTypeInformation
                Write-Host ""
                Write-Host "Results exported to: $ExportPath" -ForegroundColor Green
            }
        }
        else {
            Write-Host "[OK] No credential issues found!" -ForegroundColor Green
        }

        Write-Host ""
        return $results
    }
}

Export-ModuleMember -Function Get-UnprotectedServicePrincipals -ErrorAction SilentlyContinue