Private/Apps.ps1

function Get-M365SnapshotApps {
    param(
        [Parameter(Mandatory=$true)]
        [hashtable]$GraphHeaders,

        [Parameter(Mandatory=$true)]
        [hashtable]$SignInHeaders,

        [Parameter(Mandatory=$true)]
        [object]$Token,

        [Parameter(Mandatory=$true)]
        [int]$EffectiveMaxAppRegistrations,

        [Parameter(Mandatory=$true)]
        [switch]$LoadAllAppRegistrations,

        [Parameter(Mandatory=$true)]
        [int]$MaxAppRegistrations,

        [Parameter(Mandatory=$true)]
        [switch]$ReturnObjects
    )

    $appRegistrations = @()
    $appsWithSecretRisk = @()

    try {
        $uri = "https://graph.microsoft.com/v1.0/applications?`$select=id,appId,displayName,signInAudience,createdDateTime,passwordCredentials,requiredResourceAccess&`$top=999"
        $allApps = @()

        do {
            $response = Invoke-RestMethod -Uri $uri `
                                          -Headers $GraphHeaders `
                                          -Method Get `
                                          -ErrorAction Stop

            if ($response.value) {
                $remaining = $EffectiveMaxAppRegistrations - $allApps.Count
                if ($remaining -gt 0) {
                    $appsPage = @($response.value)
                    if ($appsPage.Count -gt $remaining) {
                        $allApps += ($appsPage | Select-Object -First $remaining)
                    }
                    else {
                        $allApps += $appsPage
                    }
                }
            }

            if ($allApps.Count -ge $EffectiveMaxAppRegistrations) {
                $uri = $null
            }
            else {
                $uri = $response.'@odata.nextLink'
            }
        } while ($uri)

        $resourceAppIds = @($allApps | ForEach-Object { $_.requiredResourceAccess } | ForEach-Object { $_.resourceAppId } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique)
        $resourceMetadataByAppId = @{}

        foreach ($resourceAppId in $resourceAppIds) {
            try {
                $servicePrincipalUri = "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appId eq '$resourceAppId'&`$select=appId,displayName,appRoles,oauth2PermissionScopes"
                $resourceResponse = Invoke-RestMethod -Uri $servicePrincipalUri `
                                                      -Headers $GraphHeaders `
                                                      -Method Get `
                                                      -ErrorAction Stop

                if ($resourceResponse.value -and $resourceResponse.value.Count -gt 0) {
                    $resourceSp = $resourceResponse.value | Select-Object -First 1

                    $scopeById = @{}
                    foreach ($scope in @($resourceSp.oauth2PermissionScopes)) {
                        if ($scope.Id) {
                            $scopeById[$scope.Id.ToString().ToLower()] = if ([string]::IsNullOrWhiteSpace($scope.Value)) { $scope.Id } else { $scope.Value }
                        }
                    }

                    $appRoleById = @{}
                    foreach ($appRole in @($resourceSp.appRoles)) {
                        if ($appRole.Id) {
                            $appRoleById[$appRole.Id.ToString().ToLower()] = if ([string]::IsNullOrWhiteSpace($appRole.Value)) { $appRole.Id } else { $appRole.Value }
                        }
                    }

                    $resourceMetadataByAppId[$resourceAppId] = [PSCustomObject]@{
                        Name = if ([string]::IsNullOrWhiteSpace($resourceSp.displayName)) { $resourceAppId } else { $resourceSp.displayName }
                        ScopeById = $scopeById
                        AppRoleById = $appRoleById
                    }
                }
                else {
                    $resourceMetadataByAppId[$resourceAppId] = [PSCustomObject]@{
                        Name = $resourceAppId
                        ScopeById = @{}
                        AppRoleById = @{}
                    }
                }
            }
            catch {
                $resourceMetadataByAppId[$resourceAppId] = [PSCustomObject]@{
                    Name = $resourceAppId
                    ScopeById = @{}
                    AppRoleById = @{}
                }
            }
        }

        $lastUsageByAppId = @{}
        $lastUsageSourceByAppId = @{}
        try {
            $activityUri = "https://graph.microsoft.com/beta/reports/servicePrincipalSignInActivities?`$top=999"
            do {
                $activityResponse = Invoke-RestMethod -Uri $activityUri `
                                                      -Headers $GraphHeaders `
                                                      -Method Get `
                                                      -ErrorAction Stop

                foreach ($activity in @($activityResponse.value)) {
                    $activityAppId = [string]$activity.appId
                    if ([string]::IsNullOrWhiteSpace($activityAppId)) {
                        continue
                    }

                    $lastDate = $null
                    if (-not [string]::IsNullOrWhiteSpace([string]$activity.lastSignInDateTime)) {
                        $lastDate = [DateTime]$activity.lastSignInDateTime
                    }
                    elseif (-not [string]::IsNullOrWhiteSpace([string]$activity.lastNonInteractiveSignInDateTime)) {
                        $lastDate = [DateTime]$activity.lastNonInteractiveSignInDateTime
                    }

                    if ($null -ne $lastDate) {
                        $appIdKey = $activityAppId.ToLower()
                        if (-not $lastUsageByAppId.ContainsKey($appIdKey) -or $lastDate -gt $lastUsageByAppId[$appIdKey]) {
                            $lastUsageByAppId[$appIdKey] = $lastDate
                            $lastUsageSourceByAppId[$appIdKey] = "ServicePrincipalActivity"
                        }
                    }
                }

                $activityUri = $activityResponse.'@odata.nextLink'
            } while ($activityUri)
        }
        catch {
            if (($_.Exception.Message -match '403') -or ($_.Exception.Message -match 'Forbidden')) {
            }
            elseif (-not $ReturnObjects) {
                Write-Host "[INFO] App last-usage data unavailable (requires additional Graph access such as AuditLog.Read.All): $($_.Exception.Message)" -ForegroundColor DarkGray
            }
        }

        $appUsagePermissionDeniedNoticeShown = $false
        $auditSignInLatestByAppId = @{}

        try {
            $targetAppIds = @{}
            foreach ($targetApp in $allApps) {
                if (-not [string]::IsNullOrWhiteSpace([string]$targetApp.appId)) {
                    $targetAppIds[[string]$targetApp.appId.ToLower()] = $true
                }
            }

            $signInUri = "https://graph.microsoft.com/v1.0/auditLogs/signIns?`$select=appId,createdDateTime&`$filter=appId ne null&`$orderby=createdDateTime desc&`$top=1000"
            $signInPageCount = 0
            do {
                $signInPageCount++
                if (-not $ReturnObjects) {
                    Write-Host " Loading audit sign-in page $signInPageCount..." -ForegroundColor DarkGray
                }

                $signInResponse = Invoke-RestMethod -Uri $signInUri `
                                                    -Headers $SignInHeaders `
                                                    -Method Get `
                                                    -ErrorAction Stop

                foreach ($signIn in @($signInResponse.value)) {
                    $signInAppId = [string]$signIn.appId
                    if ([string]::IsNullOrWhiteSpace($signInAppId)) {
                        continue
                    }

                    $signInAppIdKey = $signInAppId.ToLower()
                    if (-not $targetAppIds.ContainsKey($signInAppIdKey)) {
                        continue
                    }

                    if ($auditSignInLatestByAppId.ContainsKey($signInAppIdKey)) {
                        continue
                    }

                    if (-not [string]::IsNullOrWhiteSpace([string]$signIn.createdDateTime)) {
                        $auditSignInLatestByAppId[$signInAppIdKey] = ([DateTime]$signIn.createdDateTime).ToUniversalTime()
                    }
                }

                $signInUri = $signInResponse.'@odata.nextLink'
            } while ($signInUri -and $signInPageCount -lt 10)
        }
        catch {
            if (($_.Exception.Message -match '403') -or ($_.Exception.Message -match 'Forbidden')) {
                if (-not $ReturnObjects -and -not $appUsagePermissionDeniedNoticeShown) {
                    Write-Host "[INFO] App last-usage enrichment unavailable (AuditLog.Read.All may be missing or not consented). LastUsageDate will be reported as '(unknown)'." -ForegroundColor DarkGray
                    $appUsagePermissionDeniedNoticeShown = $true
                }
            }
            elseif (-not $ReturnObjects) {
                Write-Host "[INFO] Audit sign-in usage enrichment unavailable: $($_.Exception.Message)" -ForegroundColor DarkGray
            }
        }

        $nowUtc = (Get-Date).ToUniversalTime()
        $thresholdUtc = $nowUtc.AddDays(30)

        $totalAppsToProcess = $allApps.Count
        $currentAppIndex = 0
        foreach ($app in $allApps) {
            $currentAppIndex++
            $permissionEntries = @()

            foreach ($resourceAccessSet in @($app.requiredResourceAccess)) {
                if ($null -eq $resourceAccessSet) {
                    continue
                }

                $resourceAppId = [string]$resourceAccessSet.resourceAppId
                if ([string]::IsNullOrWhiteSpace($resourceAppId)) {
                    continue
                }

                $resourceMeta = $resourceMetadataByAppId[$resourceAppId]
                if ($null -eq $resourceMeta) {
                    $resourceMeta = [PSCustomObject]@{ Name = $resourceAppId; ScopeById = @{}; AppRoleById = @{} }
                }

                foreach ($permission in @($resourceAccessSet.resourceAccess)) {
                    $permissionId = [string]$permission.id
                    $permissionType = [string]$permission.type
                    $permissionName = $permissionId
                    $permissionIdKey = $permissionId.ToLower()

                    if ($permissionType -eq 'Scope' -and $resourceMeta.ScopeById.ContainsKey($permissionIdKey)) {
                        $permissionName = $resourceMeta.ScopeById[$permissionIdKey]
                    }
                    elseif ($permissionType -eq 'Role' -and $resourceMeta.AppRoleById.ContainsKey($permissionIdKey)) {
                        $permissionName = $resourceMeta.AppRoleById[$permissionIdKey]
                    }

                    $permissionEntries += "{0}: {1} [{2}]" -f $resourceMeta.Name, $permissionName, $permissionType
                }
            }

            $permissionEntries = @($permissionEntries | Sort-Object)

            $secretEndDates = @()
            foreach ($secret in @($app.passwordCredentials)) {
                if (-not [string]::IsNullOrWhiteSpace([string]$secret.endDateTime)) {
                    try {
                        $secretEndDates += ([DateTime]$secret.endDateTime).ToUniversalTime()
                    }
                    catch {
                    }
                }
            }

            $expiredSecrets = @($secretEndDates | Where-Object { $_ -lt $nowUtc })
            $expiringSoonSecrets = @($secretEndDates | Where-Object { $_ -ge $nowUtc -and $_ -le $thresholdUtc })

            $secretStatus = if ($expiredSecrets.Count -gt 0) {
                "Expired"
            }
            elseif ($expiringSoonSecrets.Count -gt 0) {
                "ExpiringIn30Days"
            }
            elseif ($secretEndDates.Count -gt 0) {
                "Valid"
            }
            else {
                "NoSecrets"
            }

            $appIdKey = ([string]$app.appId).ToLower()
            $lastUsageDate = if ($lastUsageByAppId.ContainsKey($appIdKey)) { $lastUsageByAppId[$appIdKey] } else { $null }
            $lastUsageSource = if ($lastUsageSourceByAppId.ContainsKey($appIdKey)) { $lastUsageSourceByAppId[$appIdKey] } else { "(not available)" }

            if (-not $ReturnObjects) {
                Write-Host " [$currentAppIndex/$totalAppsToProcess] Checking audit log for $($app.displayName)." -ForegroundColor DarkGray
            }

            if ($null -eq $lastUsageDate -and -not [string]::IsNullOrWhiteSpace([string]$app.appId)) {
                if ($auditSignInLatestByAppId.ContainsKey($appIdKey)) {
                    $lastUsageDate = $auditSignInLatestByAppId[$appIdKey]
                    $lastUsageSource = "AuditSignIn"
                    $lastUsageByAppId[$appIdKey] = $lastUsageDate
                    $lastUsageSourceByAppId[$appIdKey] = $lastUsageSource
                }
            }

            $appObject = [PSCustomObject]@{
                DisplayName = $app.displayName
                AppId = $app.appId
                SignInAudience = $app.signInAudience
                CreatedDateTime = $app.createdDateTime
                PermissionCount = $permissionEntries.Count
                Permissions = ($permissionEntries -join "; ")
                SecretCount = $secretEndDates.Count
                SecretExpiryDates = if ($secretEndDates.Count -gt 0) { (($secretEndDates | Sort-Object) | ForEach-Object { $_.ToString("yyyy-MM-dd") }) -join ", " } else { "(none)" }
                SecretStatus = $secretStatus
                ExpiredSecretCount = $expiredSecrets.Count
                ExpiringIn30DaysCount = $expiringSoonSecrets.Count
                LastUsageDate = if ($null -ne $lastUsageDate) { $lastUsageDate.ToString("yyyy-MM-dd") } else { "(unknown)" }
                LastUsageDateTime = $lastUsageDate
                LastUsageSource = $lastUsageSource
                PrivilegedPermissionCount = 0
                HighRiskPermissionCount = 0
                SensitivePermissionCount = 0
                SensitivePermissions = "(none)"
                IsSensitive = $false
                SensitiveReason = "(none)"
            }

            $appRegistrations += $appObject

            if ($secretStatus -eq "Expired" -or $secretStatus -eq "ExpiringIn30Days") {
                $appsWithSecretRisk += $appObject
            }
        }

        if (-not $ReturnObjects) {
            Write-Host "[OK] Found $($appRegistrations.Count) app registrations ($($appsWithSecretRisk.Count) with secret expiry risk)`n" -ForegroundColor Green
            if (-not $LoadAllAppRegistrations -and $appRegistrations.Count -ge $MaxAppRegistrations) {
                Write-Host "[INFO] App registration collection limited to $MaxAppRegistrations items. Use -LoadAllAppRegistrations to remove this limit.`n" -ForegroundColor DarkGray
            }
        }
    }
    catch {
        if (-not $ReturnObjects) {
            Write-Host "[WARNING] Could not collect app registrations (Application.Read.All may be missing): $($_.Exception.Message)`n" -ForegroundColor Yellow
        }
    }

    return [PSCustomObject]@{
        AppRegistrations = @($appRegistrations)
        AppsWithSecretRisk = @($appsWithSecretRisk)
    }
}