Public/Get-IOOrphanedApps.ps1

function Get-IOOrphanedApps {
    <#
    .SYNOPSIS
        Finds app registrations and enterprise apps with no owners assigned.
    .EXAMPLE
        Get-IOOrphanedApps
    .EXAMPLE
        Get-IOOrphanedApps -IncludeEnterpriseApps -ToCsv "orphaned-apps.csv"
    #>

    [CmdletBinding()]
    param(
        [switch]$IncludeEnterpriseApps,

        [string]$ToCsv
    )

    $cmdName = $MyInvocation.MyCommand.Name
    Write-IOLog 'Scanning for app registrations with no owners...' -Level Info -Component $cmdName

    $results = [System.Collections.Generic.List[PSCustomObject]]::new()

    # ── App Registrations ──────────────────────────────────────────────────
    $apps = Invoke-IOGraphRequest -Uri 'v1.0/applications?$select=id,displayName,appId,createdDateTime'
    $total = ($apps | Measure-Object).Count
    $counter = 0

    if ($total -gt 0) {
    try {
    foreach ($app in $apps) {
        $counter++
        if ($counter % 50 -eq 0) {
            Write-Progress -Activity 'Checking app registration owners' -Status "$counter / $total" -PercentComplete (($counter / $total) * 100)
        }

        try {
            $owners = Invoke-IOGraphRequest -Uri "v1.0/applications/$($app.id)/owners?`$select=id,displayName,userPrincipalName" -NoPagination -SkipConnectionCheck
        }
        catch {
            $owners = @()
        }

        if (-not $owners -or $owners.Count -eq 0) {
            $created = if ($app.createdDateTime) { [datetime]::Parse($app.createdDateTime, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AssumeUniversal).ToString('yyyy-MM-dd') } else { 'Unknown' }
            $results.Add([PSCustomObject]@{
                DisplayName  = $app.displayName
                AppId        = $app.appId
                ObjectId     = $app.id
                Type         = 'AppRegistration'
                CreatedDate  = $created
                OwnerCount   = 0
            })
        }
    }
    }
    finally {
        Write-Progress -Activity 'Checking app registration owners' -Completed
    }
    } # end if total > 0

    # ── Enterprise Apps (Service Principals) ───────────────────────────────
    if ($IncludeEnterpriseApps) {
        Write-IOLog 'Also scanning enterprise apps (service principals)...' -Level Info -Component $cmdName
        $sps = Invoke-IOGraphRequest -Uri "v1.0/servicePrincipals?`$filter=servicePrincipalType eq 'Application'&`$select=id,displayName,appId,createdDateTime"
        $total = ($sps | Measure-Object).Count
        $counter = 0

        try {
        foreach ($sp in $sps) {
            $counter++
            if ($counter % 50 -eq 0 -and $total -gt 0) {
                Write-Progress -Activity 'Checking enterprise app owners' -Status "$counter / $total" -PercentComplete (($counter / $total) * 100)
            }

            try {
                $owners = Invoke-IOGraphRequest -Uri "v1.0/servicePrincipals/$($sp.id)/owners?`$select=id,displayName" -NoPagination -SkipConnectionCheck
            }
            catch {
                $owners = @()
            }

            if (-not $owners -or $owners.Count -eq 0) {
                $created = if ($sp.createdDateTime) { [datetime]::Parse($sp.createdDateTime, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AssumeUniversal).ToString('yyyy-MM-dd') } else { 'Unknown' }
                $results.Add([PSCustomObject]@{
                    DisplayName  = $sp.displayName
                    AppId        = $sp.appId
                    ObjectId     = $sp.id
                    Type         = 'EnterpriseApp'
                    CreatedDate  = $created
                    OwnerCount   = 0
                })
            }
        }
        }
        finally {
            Write-Progress -Activity 'Checking enterprise app owners' -Completed
        }
    }

    $sorted = $results | Sort-Object Type, DisplayName
    Export-IOResult -Data $sorted -ToCsv $ToCsv -CommandName $cmdName
}