Src/Private/Get-AbrEntraIDApplications.ps1

function Get-AbrEntraIDApplications {
    <#
    .SYNOPSIS
    Documents Entra ID App Registrations and Enterprise Applications (Service Principals).
    .NOTES
        Version: 0.1.21
        Author: Pai Wei Sing
    #>

    [CmdletBinding()]
    param (
        [Parameter(Position = 0, Mandatory)]
        [string]$TenantId
    )

    begin {
        Write-PScriboMessage -Message "Collecting Entra ID Applications for tenant $TenantId."
        Show-AbrDebugExecutionTime -Start -TitleMessage 'Applications'
    }

    process {
        #region App Registrations
        # Section{} created unconditionally so catch{} always writes inside it
        Section -Style Heading2 'App Registrations' {
            Paragraph "The following section documents the App Registrations (application objects) in tenant $TenantId."
            BlankLine

            try {
                Write-Host " - Retrieving App Registrations..."
                $AppResponse = Invoke-MgGraphRequest -Method GET `
                    -Uri 'https://graph.microsoft.com/v1.0/applications?$select=id,displayName,appId,signInAudience,createdDateTime,passwordCredentials,keyCredentials,web,requiredResourceAccess&$top=999' `
                    -ErrorAction Stop
                $Apps = $AppResponse.value
                while ($AppResponse.'@odata.nextLink') {
                    $AppResponse = Invoke-MgGraphRequest -Method GET -Uri $AppResponse.'@odata.nextLink' -ErrorAction Stop
                    $Apps += $AppResponse.value
                }

                if ($Apps) {
                    $AppObj = [System.Collections.ArrayList]::new()
                    $Now    = Get-Date
                    foreach ($App in ($Apps | Sort-Object { $_.displayName })) {
                        try {
                            $SecretStatus = 'No Secrets'
                            $AppPassCreds = if ($App.passwordCredentials) { $App.passwordCredentials } else { @() }
                            if ($AppPassCreds) {
                                $ExpiredSecrets  = @($AppPassCreds | Where-Object { $_.endDateTime -and [datetime]$_.endDateTime -lt $Now })
                                $ExpiringSecrets = @($AppPassCreds | Where-Object { $_.endDateTime -and [datetime]$_.endDateTime -ge $Now -and ([datetime]$_.endDateTime - $Now).Days -lt 30 })
                                $ValidSecrets    = @($AppPassCreds | Where-Object { $_.endDateTime -and [datetime]$_.endDateTime -ge $Now -and ([datetime]$_.endDateTime - $Now).Days -ge 30 })

                                if ($ExpiredSecrets.Count -gt 0 -and $ValidSecrets.Count -eq 0) {
                                    $SecretStatus = "EXPIRED ($($ExpiredSecrets.Count) secret(s))"
                                } elseif ($ExpiredSecrets.Count -gt 0) {
                                    $SecretStatus = "$($ExpiredSecrets.Count) EXPIRED / $($ValidSecrets.Count) valid"
                                } elseif ($ExpiringSecrets.Count -gt 0) {
                                    $Soonest = [datetime]($ExpiringSecrets | Sort-Object { $_.endDateTime } | Select-Object -First 1).endDateTime
                                    $SecretStatus = "Expiring in $(($Soonest - $Now).Days) day(s)"
                                } else {
                                    $LatestValid = ($App.PasswordCredentials | Sort-Object EndDateTime | Select-Object -Last 1)
                                    $SecretStatus = $LatestValid.EndDateTime.ToString('yyyy-MM-dd')
                                }
                            }

                            $CertStatus = 'No Certificates'
                            $AppKeyCreds = if ($App.keyCredentials) { $App.keyCredentials } else { @() }
                            if ($AppKeyCreds) {
                                $ExpiredCerts  = @($AppKeyCreds | Where-Object { $_.endDateTime -and [datetime]$_.endDateTime -lt $Now })
                                $ExpiringCerts = @($AppKeyCreds | Where-Object { $_.endDateTime -and [datetime]$_.endDateTime -ge $Now -and ([datetime]$_.endDateTime - $Now).Days -lt 30 })
                                $ValidCerts    = @($AppKeyCreds | Where-Object { $_.endDateTime -and [datetime]$_.endDateTime -ge $Now -and ([datetime]$_.endDateTime - $Now).Days -ge 30 })

                                if ($ExpiredCerts.Count -gt 0 -and $ValidCerts.Count -eq 0) {
                                    $CertStatus = "EXPIRED ($($ExpiredCerts.Count) cert(s))"
                                } elseif ($ExpiredCerts.Count -gt 0) {
                                    $CertStatus = "$($ExpiredCerts.Count) EXPIRED / $($ValidCerts.Count) valid"
                                } elseif ($ExpiringCerts.Count -gt 0) {
                                    $Soonest = [datetime]($ExpiringCerts | Sort-Object { $_.endDateTime } | Select-Object -First 1).endDateTime
                                    $CertStatus = "Expiring in $(($Soonest - $Now).Days) day(s)"
                                } else {
                                    $LatestValid = ($AppKeyCreds | Sort-Object { $_.endDateTime } | Select-Object -Last 1)
                                    $CertStatus = ([datetime]$LatestValid.endDateTime).ToString('yyyy-MM-dd')
                                }
                            }

                            $RedirectUriList = if ($App.web -and $App.web.redirectUris) { $App.web.redirectUris } else { @() }
                            $RedirectUris = if ($RedirectUriList) { ($RedirectUriList -join '; ').Substring(0, [Math]::Min(60, ($RedirectUriList -join '; ').Length)) } else { '--' }

                            $appInObj = [ordered] @{
                                'Application Name'     = $App.displayName
                                'App ID (Client ID)'   = $App.appId
                                'Sign-In Audience'     = $App.signInAudience
                                'Secret Status'        = $SecretStatus
                                'Certificate Status'   = $CertStatus
                                'Redirect URIs'        = $RedirectUris
                                'Created'              = if ($App.createdDateTime) { ([datetime]$App.createdDateTime).ToString('yyyy-MM-dd') } else { '--' }
                            }
                            $AppObj.Add([pscustomobject]$appInObj) | Out-Null
                        } catch {
                            Write-PScriboMessage -IsWarning -Message "App '$($App.displayName)': $($_.Exception.Message)"
                        }
                    }

                    $null = (& {
                        if ($HealthCheck.EntraID.Applications) {
                            $null = ($AppObj | Where-Object { $_.'Secret Status' -like 'EXPIRED*' }      | Set-Style -Style Critical | Out-Null)
                            $null = ($AppObj | Where-Object { $_.'Secret Status' -like 'Expiring*' }     | Set-Style -Style Warning | Out-Null)
                            $null = ($AppObj | Where-Object { $_.'Secret Status' -like '*EXPIRED*' }     | Set-Style -Style Critical | Out-Null)
                            $null = ($AppObj | Where-Object { $_.'Certificate Status' -like 'EXPIRED*' } | Set-Style -Style Critical | Out-Null)
                            $null = ($AppObj | Where-Object { $_.'Certificate Status' -like '*EXPIRED*' } | Set-Style -Style Critical | Out-Null)
                            $null = ($AppObj | Where-Object { $_.'Sign-In Audience' -like '*All*' }      | Set-Style -Style Warning | Out-Null)
                        }
                    })

                    $AppTableParams = @{ Name = "App Registrations - $TenantId"; List = $false; ColumnWidths = 18, 22, 12, 12, 12, 14, 10 }
                    if ($Report.ShowTableCaptions) { $AppTableParams['Caption'] = "- $($AppTableParams.Name)" }
                    $AppObj | Table @AppTableParams

                    $null = ($script:ExcelSheets['App Registrations'] = $AppObj)

                    #region App Credential Expiry Warnings
                    try {
                        $Now        = Get-Date
                        $Warn30     = [System.Collections.ArrayList]::new()
                        $Warn90     = [System.Collections.ArrayList]::new()

                        foreach ($App in $AllApps) {
                            # Check password credentials (client secrets)
                            foreach ($Cred in $App.PasswordCredentials) {
                                if (-not $Cred.EndDateTime) { continue }
                                $DaysLeft = ($Cred.EndDateTime - $Now).Days
                                $entry = [pscustomobject][ordered]@{
                                    'Application'   = $App.DisplayName
                                    'Type'          = 'Client Secret'
                                    'Display Name'  = if ($Cred.DisplayName) { $Cred.DisplayName } else { '(unnamed)' }
                                    'Expires'       = $Cred.EndDateTime.ToString('yyyy-MM-dd')
                                    'Days Left'     = $DaysLeft
                                    'Status'        = if ($DaysLeft -lt 0) { '[EXPIRED]' } elseif ($DaysLeft -le 30) { '[CRITICAL]' } else { '[WARNING]' }
                                }
                                if ($DaysLeft -le 30)  { $null = $Warn30.Add($entry) }
                                elseif ($DaysLeft -le 90) { $null = $Warn90.Add($entry) }
                            }
                            # Check key credentials (certificates)
                            foreach ($Cert in $App.KeyCredentials) {
                                if (-not $Cert.EndDateTime) { continue }
                                $DaysLeft = ($Cert.EndDateTime - $Now).Days
                                $entry = [pscustomobject][ordered]@{
                                    'Application'   = $App.DisplayName
                                    'Type'          = 'Certificate'
                                    'Display Name'  = if ($Cert.DisplayName) { $Cert.DisplayName } else { '(unnamed)' }
                                    'Expires'       = $Cert.EndDateTime.ToString('yyyy-MM-dd')
                                    'Days Left'     = $DaysLeft
                                    'Status'        = if ($DaysLeft -lt 0) { '[EXPIRED]' } elseif ($DaysLeft -le 30) { '[CRITICAL]' } else { '[WARNING]' }
                                }
                                if ($DaysLeft -le 30)  { $null = $Warn30.Add($entry) }
                                elseif ($DaysLeft -le 90) { $null = $Warn90.Add($entry) }
                            }
                        }

                        $AllExpiring = @($Warn30) + @($Warn90) | Sort-Object 'Days Left'
                        if ($AllExpiring.Count -gt 0) {
                            Section -Style Heading3 'App Credential Expiry Warnings' {
                                Paragraph "The following application credentials (client secrets or certificates) are expiring within 90 days or have already expired in tenant $TenantId. Expired credentials cause authentication failures."
                                BlankLine
                                $null = (& { if ($HealthCheck.EntraID.Applications) {
                                    $null = ($AllExpiring | Where-Object { $_.Status -eq '[EXPIRED]'  } | Set-Style -Style Critical | Out-Null)
                                    $null = ($AllExpiring | Where-Object { $_.Status -eq '[CRITICAL]' } | Set-Style -Style Critical | Out-Null)
                                    $null = ($AllExpiring | Where-Object { $_.Status -eq '[WARNING]'  } | Set-Style -Style Warning  | Out-Null)
                                }})
                                $ExpiryTableParams = @{ Name = "App Credential Expiry - $TenantId"; List = $false; ColumnWidths = 22, 12, 20, 14, 10, 12 }
                                if ($Report.ShowTableCaptions) { $ExpiryTableParams['Caption'] = "- $($ExpiryTableParams.Name)" }
                                $AllExpiring | Table @ExpiryTableParams
                                $null = ($script:ExcelSheets['App Credential Expiry'] = $AllExpiring)
                            }
                        }
                    } catch {
                        Write-AbrDebugLog "App credential expiry check failed: $($_.Exception.Message)" 'WARN' 'APPLICATIONS'
                    }
                    #endregion

                    #region ACSC E8 App Registrations Assessment
                    BlankLine
                    Paragraph "ACSC Essential Eight Maturity Level Assessment -- Application Registrations:"
                    BlankLine
                    try {
                        $ExpiredSecrets  = @($AppObj | Where-Object { $_.'Secret Status' -like '*EXPIRED*' }).Count
                        $ExpiringSecrets = @($AppObj | Where-Object { $_.'Secret Status' -like 'Expiring*' }).Count
                        $MultiTenant     = @($AppObj | Where-Object { $_.'Sign-In Audience' -like '*All*' }).Count

                        # Check for secrets older than 12 months (ML3)
                        $Now = Get-Date
                        $OldSecrets = 0
                        if ($Apps) {
                            foreach ($App in $Apps) {
                                if ($App.passwordCredentials) {
                                    foreach ($cred in $App.passwordCredentials) {
                                        if ($cred.endDateTime) {
                                            $age = ($Now - [datetime]$cred.endDateTime).Days * -1
                                            if ($age -gt 365) { $OldSecrets++ ; break }
                                        }
                                    }
                                }
                            }
                        }

                        #region ACSC E8 Assessment (definitions from Src/Compliance/ACSC.E8.json)
                        $_ComplianceVars = @{
                            'ExpiredSecrets' = $ExpiredSecrets
                            'ExpiringSecrets' = $ExpiringSecrets
                            'OldSecrets' = $OldSecrets
                            'MultiTenant' = $MultiTenant
                            'NoCredentialApps' = $NoCredentialApps
                            'CertOnlyApps' = $CertOnlyApps
                            'SecretOnlyApps' = $SecretOnlyApps
                            'CertAppCount' = $CertAppCount
                        }
                        $E8AppChecks = Build-AbrComplianceChecks `
                            -Definitions (Get-AbrE8Checks -Section 'Applications') `
                            -Framework E8 `
                            -CallerVariables $_ComplianceVars
                        New-AbrE8AssessmentTable -Checks $E8AppChecks -Name 'App Registrations' -TenantId $TenantId
                        # Consolidated into ACSC E8 Assessment sheet
                    if ($E8AppChecks) { $null = $script:E8AllChecks.AddRange([object[]](@($E8AppChecks | Select-Object @{N='Section';E={'Applications'}}, ML, Control, Status, Detail ))) }
                        #endregion

                        if ($script:IncludeCISBaseline) {
                            BlankLine
                            Paragraph "CIS Microsoft 365 Foundations Benchmark Assessment -- Application Registrations:"
                            BlankLine
                            #region CIS Assessment (definitions from Src/Compliance/CIS.M365.json)
                            $CISAppChecks = Build-AbrComplianceChecks `
                                -Definitions (Get-AbrCISChecks -Section 'Applications') `
                                -Framework CIS `
                                -CallerVariables $_ComplianceVars
                            New-AbrCISAssessmentTable -Checks $CISAppChecks -Name 'App Registrations' -TenantId $TenantId
                            # Consolidated into CIS Assessment sheet
                    if ($CISAppChecks) { $null = $script:CISAllChecks.AddRange([object[]](@($CISAppChecks | Select-Object @{N='Section';E={'Applications'}}, CISControl, Level, Status, Detail ))) }
                            #endregion
                        }
                    } catch {
                        Write-AbrSectionError -Section 'E8 Apps Assessment' -Message "$($_.Exception.Message)"
                    }
                    #endregion
                } else {
                    Paragraph "No App Registrations found in tenant $TenantId."
                }
            } catch {
                Write-AbrSectionError -Section 'App Registrations section' -Message "$($_.Exception.Message)"
            }
        } # end Section App Registrations
        #endregion

        #region Enterprise Applications (Service Principals)
        if ($InfoLevel.ServicePrincipals -ge 1) {
            Section -Style Heading2 'Enterprise Applications' {
                Paragraph "The following section documents the Enterprise Applications (Service Principals) in tenant $TenantId."
                BlankLine

                try {
                    Write-Host " - Retrieving Enterprise Applications (Service Principals)..."
                    $SPResponse = Invoke-MgGraphRequest -Method GET `
                        -Uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$select=id,displayName,appId,accountEnabled,servicePrincipalType,appOwnerOrganizationId,tags,createdDateTime&`$top=999" `
                        -ErrorAction Stop
                    $AllSPsRaw = $SPResponse.value
                    while ($SPResponse.'@odata.nextLink') {
                        $SPResponse = Invoke-MgGraphRequest -Method GET -Uri $SPResponse.'@odata.nextLink' -ErrorAction Stop
                        $AllSPsRaw += $SPResponse.value
                    }
                    $AllSPs = $AllSPsRaw | Where-Object {
                        ($_.tags -and $_.tags -contains 'WindowsAzureActiveDirectoryIntegratedApp') -or
                        $_.servicePrincipalType -eq 'Application'
                    }

                    $TotalSPCount = @($AllSPs).Count
                    $Truncated    = $TotalSPCount -gt 200
                    $SPs          = $AllSPs | Sort-Object { $_.displayName } | Select-Object -First 200

                    if ($Truncated) {
                        Paragraph "NOTE: $TotalSPCount Enterprise Applications found. This table shows the first 200 (alphabetically). Review the Excel export for the full list."
                    }

                    if ($SPs) {
                        $SPObj = [System.Collections.ArrayList]::new()
                        foreach ($SP in $SPs) {
                            $spInObj = [ordered] @{
                                'Application Name' = $SP.displayName
                                'App ID'           = $SP.appId
                                'Account Enabled'  = $SP.accountEnabled
                                'SP Type'          = $SP.servicePrincipalType
                                'App Owner Tenant' = if ($SP.appOwnerOrganizationId) { $SP.appOwnerOrganizationId } else { 'This Tenant' }
                            }
                            $SPObj.Add([pscustomobject](ConvertTo-HashToYN $spInObj)) | Out-Null
                        }

                        $null = (& {if ($HealthCheck.EntraID.Applications) {
                            $null = ($SPObj | Where-Object { $_.'Account Enabled' -eq 'No' } | Set-Style -Style Warning | Out-Null)
                        }})

                        $SPTableParams = @{ Name = "Enterprise Applications - $TenantId"; List = $false; ColumnWidths = 28, 26, 14, 14, 18 }
                        if ($Report.ShowTableCaptions) { $SPTableParams['Caption'] = "- $($SPTableParams.Name)" }
                        $SPObj | Table @SPTableParams

                        $AllSPObj = [System.Collections.ArrayList]::new()
                        foreach ($SP in ($AllSPs | Sort-Object DisplayName)) {
                            $allSpInObj = [ordered] @{
                                'Application Name' = $SP.displayName
                                'App ID'           = $SP.AppId
                                'Account Enabled'  = if ($SP.accountEnabled) { 'Yes' } else { 'No' }
                                'SP Type'          = $SP.ServicePrincipalType
                                'App Owner Tenant' = if ($SP.appOwnerOrganizationId) { $SP.appOwnerOrganizationId } else { 'This Tenant' }
                            }
                            $AllSPObj.Add([pscustomobject]$allSpInObj) | Out-Null
                        }
                        $null = ($script:ExcelSheets['Enterprise Apps'] = $AllSPObj)
                    } else {
                        Paragraph "No Enterprise Applications found in tenant $TenantId."
                    }
                } catch {
                    Write-AbrSectionError -Section 'Enterprise Applications section' -Message "$($_.Exception.Message)"
                }
            } # end Section Enterprise Applications
        }
        #endregion

        #region OAuth2 Permission Grants & App Role Assignments
        if ($InfoLevel.ServicePrincipals -ge 2) {
            Section -Style Heading2 'OAuth2 Delegated Permission Grants' {
                Paragraph "The following section documents delegated OAuth2 permission grants in tenant $TenantId. Admin-consented grants (ConsentType = AllPrincipals) apply to all users and represent the highest risk -- review these carefully."
                BlankLine

                try {
                    Write-Host " - Retrieving OAuth2 permission grants (delegated permissions)..."
                    $OAuth2Grants = Get-MgOauth2PermissionGrant -All -ErrorAction Stop

                    if ($OAuth2Grants) {
                        $GrantObj = [System.Collections.ArrayList]::new()
                        foreach ($Grant in ($OAuth2Grants | Sort-Object ClientId)) {
                            try {
                                $ClientSP   = Get-MgServicePrincipal -ServicePrincipalId $Grant.ClientId   -Property DisplayName -ErrorAction SilentlyContinue
                                $ResourceSP = Get-MgServicePrincipal -ServicePrincipalId $Grant.ResourceId -Property DisplayName -ErrorAction SilentlyContinue

                                $grantInObj = [ordered] @{
                                    'Client App'   = if ($ClientSP)   { $ClientSP.DisplayName }   else { $Grant.ClientId }
                                    'Resource'     = if ($ResourceSP) { $ResourceSP.DisplayName } else { $Grant.ResourceId }
                                    'Consent Type' = $Grant.ConsentType
                                    'Scopes'       = if ($Grant.Scope) { $Grant.Scope.Trim() } else { '--' }
                                    'Expiry'       = if ($Grant.ExpiryTime) { ($Grant.ExpiryTime).ToString('yyyy-MM-dd') } else { 'No Expiry' }
                                }
                                $GrantObj.Add([pscustomobject]$grantInObj) | Out-Null
                            } catch {
                                # Per-item errors: log as warning only, don't write Paragraph here (inside loop)
                                Write-PScriboMessage -IsWarning -Message "OAuth2 grant processing: $($_.Exception.Message)"
                            }
                        }

                        $null = (& {
                            if ($HealthCheck.EntraID.Applications) {
                                $null = ($GrantObj | Where-Object { $_.'Consent Type' -eq 'AllPrincipals' } | Set-Style -Style Warning | Out-Null)
                            }
                        })

                        $GrantTableParams = @{ Name = "OAuth2 Delegated Permission Grants - $TenantId"; List = $false; ColumnWidths = 22, 22, 14, 32, 10 }
                        if ($Report.ShowTableCaptions) { $GrantTableParams['Caption'] = "- $($GrantTableParams.Name)" }
                        $GrantObj | Table @GrantTableParams

                        $null = ($script:ExcelSheets['OAuth2 Grants'] = $GrantObj)
                    } else {
                        Paragraph "No OAuth2 permission grants found in tenant $TenantId."
                    }
                } catch {
                    Write-AbrSectionError -Section 'OAuth2 Permission Grants section' -Message "$($_.Exception.Message)"
                }
            } # end Section OAuth2

            Section -Style Heading2 'Application Role Assignments (App Permissions)' {
                Paragraph "The following section documents application permission role assignments (non-delegated, app-to-app permissions) in tenant $TenantId. These permissions act without a signed-in user and should be regularly reviewed for least-privilege."
                BlankLine

                try {
                    Write-Host " - Retrieving app role assignments (application permissions)..."
                    $AppRoles = Invoke-MgGraphRequest -Method GET `
                        -Uri 'https://graph.microsoft.com/v1.0/servicePrincipals?$top=999&$select=id,displayName,appRoleAssignments' `
                        -ErrorAction SilentlyContinue

                    if ($AppRoles -and $AppRoles.value) {
                        $AppRoleObj = [System.Collections.ArrayList]::new()
                        foreach ($SP in $AppRoles.value) {
                            if ($SP.appRoleAssignments) {
                                foreach ($Role in $SP.appRoleAssignments) {
                                    $arInObj = [ordered] @{
                                        'Principal (Client)' = $SP.displayName
                                        'Resource App'       = $Role.resourceDisplayName
                                        'App Role ID'        = $Role.appRoleId
                                        'Created'            = if ($Role.createdDateTime) { ([datetime]$Role.createdDateTime).ToString('yyyy-MM-dd') } else { '--' }
                                    }
                                    $AppRoleObj.Add([pscustomobject]$arInObj) | Out-Null
                                }
                            }
                        }

                        if ($AppRoleObj.Count -gt 0) {
                            $AppRoleTableParams = @{ Name = "App Role Assignments - $TenantId"; List = $false; ColumnWidths = 28, 28, 32, 12 }
                            if ($Report.ShowTableCaptions) { $AppRoleTableParams['Caption'] = "- $($AppRoleTableParams.Name)" }
                            $AppRoleObj | Table @AppRoleTableParams
                            $null = ($script:ExcelSheets['App Role Assignments'] = $AppRoleObj)
                        } else {
                            Paragraph "No application role assignments found in tenant $TenantId."
                        }
                    } else {
                        Paragraph "No application role assignment data returned for tenant $TenantId."
                    }
                } catch {
                    Write-AbrSectionError -Section 'App Role Assignments section' -Message "$($_.Exception.Message)"
                }
            } # end Section App Role Assignments
        }
        #endregion
    }

    end {
        Show-AbrDebugExecutionTime -End -TitleMessage 'Applications'
    }
}