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