Public/Get-DormantEnterpriseApplications.ps1

<#
.SYNOPSIS
    Finds enterprise applications with no recent sign-in activity.

.DESCRIPTION
    Identifies dormant enterprise applications that haven't been used in a specified
    number of days. These applications may be candidates for cleanup or disabling.
    
    Can optionally disable dormant applications or list already-disabled applications.

    Uses the Microsoft Graph beta API to access lastSignInDateTime for service principals.

.PARAMETER DaysInactive
    Number of days without sign-in activity to consider an app dormant. Default is 90.

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

.PARAMETER IncludeDisabled
    Include already-disabled applications in the output. Default is $false.

.PARAMETER DisabledOnly
    Show only disabled applications (for audit/cleanup purposes).

.PARAMETER DisableApps
    Disable dormant applications. Use with -WhatIf to preview changes.
    Requires Application.ReadWrite.All permission.

.PARAMETER ExportPath
    Optional path to export results to CSV.

.EXAMPLE
    Get-DormantEnterpriseApplications

    Returns enterprise applications with no sign-ins in the last 90 days.

.EXAMPLE
    Get-DormantEnterpriseApplications -DaysInactive 180

    Returns apps inactive for 180+ days.

.EXAMPLE
    Get-DormantEnterpriseApplications -DisableApps -WhatIf

    Shows which apps WOULD be disabled without actually disabling them.

.EXAMPLE
    Get-DormantEnterpriseApplications -DisabledOnly

    Lists all currently disabled enterprise applications.

.EXAMPLE
    Get-DormantEnterpriseApplications -DisableApps -Confirm:$false

    Disables dormant apps without prompting (use with caution!).

.NOTES
    Author: Kent Agent (kentagent-ai)
    Created: 2026-03-12
    Requires: Microsoft.Graph PowerShell module
    Permissions: Application.Read.All (read), Application.ReadWrite.All (disable)
    
    Note: Uses beta API for lastSignInDateTime. This property may not be available
    for all apps, especially those that have never been used.

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

function Get-DormantEnterpriseApplications {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    param(
        [Parameter(Mandatory = $false)]
        [ValidateRange(1, 365)]
        [int]$DaysInactive = 90,

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

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

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

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

        [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 disabling apps
        if ($DisableApps) {
            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
        $microsoftTenantId = '72f988bf-86f1-41af-91ab-2d7cd011db47'
        
        $results = New-Object System.Collections.ArrayList
        $disabledCount = 0
        $inactiveThreshold = (Get-Date).AddDays(-$DaysInactive)
    }

    process {
        Write-Host ""
        
        if ($DisabledOnly) {
            Write-Host "=== Disabled Enterprise Applications ===" -ForegroundColor Cyan
        } else {
            Write-Host "=== Dormant Enterprise Applications (>$DaysInactive days inactive) ===" -ForegroundColor Cyan
        }
        
        Write-Host "Retrieving service principals..." -ForegroundColor Gray

        try {
            # Use beta API to get lastSignInDateTime
            $uri = "https://graph.microsoft.com/beta/servicePrincipals?`$select=id,appId,displayName,accountEnabled,appOwnerOrganizationId,servicePrincipalType,createdDateTime,signInAudience&`$top=999"
            
            $allServicePrincipals = New-Object System.Collections.ArrayList
            
            do {
                $response = Invoke-MgGraphRequest -Method GET -Uri $uri -ErrorAction Stop
                
                if ($response.value) {
                    foreach ($sp in $response.value) {
                        [void]$allServicePrincipals.Add($sp)
                    }
                }
                
                $uri = $response.'@odata.nextLink'
            } while ($null -ne $uri)
            
            Write-Host "Found $($allServicePrincipals.Count) service principals" -ForegroundColor Gray
        }
        catch {
            throw "Failed to retrieve service principals: $_"
        }

        # Now get sign-in activity for each (this requires checking sign-in logs or beta signInActivity)
        Write-Host "Checking sign-in activity..." -ForegroundColor Gray
        
        $processedCount = 0
        $totalToProcess = $allServicePrincipals.Count

        foreach ($sp in $allServicePrincipals) {
            $processedCount++
            
            if ($processedCount % 50 -eq 0) {
                Write-Progress -Activity "Analyzing applications" -Status "$processedCount / $totalToProcess" -PercentComplete (($processedCount / $totalToProcess) * 100)
            }

            # Skip Microsoft apps unless requested
            $isMicrosoftApp = $sp.appOwnerOrganizationId -eq $microsoftTenantId
            if ($isMicrosoftApp -and -not $IncludeMicrosoftApps) {
                continue
            }

            # Skip managed identities
            if ($sp.servicePrincipalType -eq 'ManagedIdentity') {
                continue
            }

            $isDisabled = -not $sp.accountEnabled

            # Handle DisabledOnly mode
            if ($DisabledOnly) {
                if ($isDisabled) {
                    [void]$results.Add([PSCustomObject]@{
                        DisplayName        = $sp.displayName
                        AppId              = $sp.appId
                        ServicePrincipalId = $sp.id
                        AccountEnabled     = $false
                        IsMicrosoftApp     = $isMicrosoftApp
                        ServicePrincipalType = $sp.servicePrincipalType
                        CreatedDateTime    = $sp.createdDateTime
                        LastSignIn         = $null
                        DaysInactive       = $null
                        Status             = 'Disabled'
                        Recommendation     = 'Review if still needed, consider deletion'
                    })
                }
                continue
            }

            # Skip disabled apps unless requested
            if ($isDisabled -and -not $IncludeDisabled) {
                continue
            }

            # Get last sign-in activity from beta API
            $lastSignIn = $null
            $daysInactiveCalc = $null
            
            try {
                $activityUri = "https://graph.microsoft.com/beta/servicePrincipals/$($sp.id)?`$select=signInActivity"
                $activityResponse = Invoke-MgGraphRequest -Method GET -Uri $activityUri -ErrorAction SilentlyContinue
                
                if ($activityResponse.signInActivity) {
                    $lastSignIn = $activityResponse.signInActivity.lastSignInDateTime
                    if ($null -eq $lastSignIn) {
                        $lastSignIn = $activityResponse.signInActivity.lastNonInteractiveSignInDateTime
                    }
                }
            }
            catch {
                Write-Verbose "Could not get sign-in activity for $($sp.displayName): $_"
            }

            # Calculate days inactive
            if ($lastSignIn) {
                $lastSignInDate = [DateTime]$lastSignIn
                $daysInactiveCalc = ((Get-Date) - $lastSignInDate).Days
            } else {
                # No sign-in data - check if app is old enough
                if ($sp.createdDateTime) {
                    $createdDate = [DateTime]$sp.createdDateTime
                    $daysInactiveCalc = ((Get-Date) - $createdDate).Days
                }
            }

            # Check if dormant
            $isDormant = $false
            if ($null -ne $lastSignIn) {
                $isDormant = $lastSignIn -lt $inactiveThreshold
            } elseif ($null -ne $sp.createdDateTime) {
                # Never signed in - dormant if created before threshold
                $createdDate = [DateTime]$sp.createdDateTime
                $isDormant = $createdDate -lt $inactiveThreshold
            }

            if (-not $isDormant -and -not $isDisabled) {
                continue  # Skip active apps
            }

            $status = if ($isDisabled) {
                'Disabled'
            } elseif ($null -eq $lastSignIn) {
                'Never Used'
            } else {
                'Dormant'
            }

            $riskLevel = if ($null -eq $lastSignIn -and $daysInactiveCalc -gt 180) {
                'HIGH'
            } elseif ($daysInactiveCalc -gt 180) {
                'HIGH'
            } elseif ($daysInactiveCalc -gt 90) {
                'MEDIUM'
            } else {
                'LOW'
            }

            $recommendation = if ($isDisabled) {
                'Already disabled - review if deletion is appropriate'
            } elseif ($null -eq $lastSignIn) {
                'Never used - consider disabling or removing'
            } else {
                "Inactive for $daysInactiveCalc days - evaluate if still needed"
            }

            $resultObj = [PSCustomObject]@{
                DisplayName        = $sp.displayName
                AppId              = $sp.appId
                ServicePrincipalId = $sp.id
                AccountEnabled     = $sp.accountEnabled
                IsMicrosoftApp     = $isMicrosoftApp
                ServicePrincipalType = $sp.servicePrincipalType
                CreatedDateTime    = $sp.createdDateTime
                LastSignIn         = $lastSignIn
                DaysInactive       = $daysInactiveCalc
                Status             = $status
                RiskLevel          = $riskLevel
                Recommendation     = $recommendation
                WasDisabled        = $false
            }

            # Disable app if requested
            if ($DisableApps -and -not $isDisabled -and -not $isMicrosoftApp) {
                $target = "enterprise app '$($sp.displayName)'"
                
                if ($PSCmdlet.ShouldProcess($target, "Disable")) {
                    try {
                        Update-MgServicePrincipal -ServicePrincipalId $sp.id -AccountEnabled:$false -ErrorAction Stop
                        $resultObj.WasDisabled = $true
                        $resultObj.AccountEnabled = $false
                        $disabledCount++
                        Write-Host " [DISABLED] $($sp.displayName)" -ForegroundColor Yellow
                    }
                    catch {
                        Write-Warning "Failed to disable $($sp.displayName): $_"
                    }
                }
            }

            [void]$results.Add($resultObj)
        }

        Write-Progress -Activity "Analyzing applications" -Completed
    }

    end {
        Write-Host ""
        
        if ($results.Count -gt 0) {
            if ($DisabledOnly) {
                Write-Host "=== Disabled Applications Summary ===" -ForegroundColor Yellow
                Write-Host "Total disabled apps: $($results.Count)" -ForegroundColor White
            } else {
                $dormantCount = ($results | Where-Object { $_.Status -eq 'Dormant' }).Count
                $neverUsedCount = ($results | Where-Object { $_.Status -eq 'Never Used' }).Count
                $alreadyDisabledCount = ($results | Where-Object { $_.Status -eq 'Disabled' }).Count
                $highRisk = ($results | Where-Object { $_.RiskLevel -eq 'HIGH' }).Count

                Write-Host "=== Dormant Applications Summary ===" -ForegroundColor Yellow
                Write-Host "Total issues: $($results.Count)" -ForegroundColor White
                Write-Host "Dormant (no recent sign-ins): $dormantCount" -ForegroundColor $(if ($dormantCount -gt 0) { 'Yellow' } else { 'Green' })
                Write-Host "Never used: $neverUsedCount" -ForegroundColor $(if ($neverUsedCount -gt 0) { 'Red' } else { 'Green' })
                
                if ($IncludeDisabled) {
                    Write-Host "Already disabled: $alreadyDisabledCount" -ForegroundColor Gray
                }
                
                Write-Host "High risk (>180 days): $highRisk" -ForegroundColor $(if ($highRisk -gt 0) { 'Red' } else { 'Green' })
                
                if ($DisableApps) {
                    Write-Host ""
                    Write-Host "Disabled in this run: $disabledCount" -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 {
            if ($DisabledOnly) {
                Write-Host "[OK] No disabled enterprise applications found!" -ForegroundColor Green
            } else {
                Write-Host "[OK] No dormant enterprise applications found!" -ForegroundColor Green
            }
        }

        Write-Host ""
        return $results
    }
}

Export-ModuleMember -Function Get-DormantEnterpriseApplications -ErrorAction SilentlyContinue