Public/Get-AACServicePrincipalInsight.ps1
|
function Get-AACServicePrincipalInsight { <# .EXTERNALHELP Azure.Admin.Console-help.xml .SYNOPSIS Lists every service principal and managed identity that holds an Azure role - application or another tenant's, system- or user-assigned identity, policy assignment identity - with its roles, the resources it is attached to, and credentials about to expire. .DESCRIPTION Reads, read-only: Resource Graph the role assignments to service principals and the role names (tenant-wide), the user-assigned identities and the resources they are attached to Microsoft Graph each principal (type, owner tenant, the resource a managed identity belongs to, its own credentials) and the app registrations' secrets and certificates Each principal (AAC.ServicePrincipalInsight): its kind, role assignments and privileged roles, attached resources (user-assigned identities), credentials and the next to expire, and findings: High another tenant's application with a privileged role Medium another tenant's application with any role; only expired credentials; a user-assigned identity with roles attached to nothing, or shared by many resources with a privileged role Low a credential expiring within -ExpiryDays (30); a client secret valid for more than a year Deleted principals are left to Get-AACRoleAssignment and Get-AACOrphanedIdentity. Reader in Azure; Directory.Read.All and Application.Read.All in Entra ID. .PARAMETER SubscriptionId Only role assignments in these subscriptions (management group and root ones always count). .PARAMETER ManagementGroupId Only the subscriptions under these management groups (at any depth). .PARAMETER Kind Only these kinds: Application, ExternalApplication, ManagedIdentity. .PARAMETER ExpiryDays A credential expiring within this many days is a finding. Default 30. .PARAMETER SharedLimit A user-assigned identity with a privileged role attached to more resources than this is a finding. Default 10. .PARAMETER CsvPath Write the principals to this CSV file. .PARAMETER HtmlPath Write an interactive HTML report. .PARAMETER PdfPath Write a PDF report. .PARAMETER Title The reports' title. .PARAMETER PassThru Show the view and also return the principals. .PARAMETER NoDisplay Return the principals without showing the view. .PARAMETER NoPaging Show the whole view at once. .EXAMPLE Get-AACServicePrincipalInsight Every workload identity with Azure access, the riskiest first. .EXAMPLE Get-AACServicePrincipalInsight -Kind ExternalApplication -NoDisplay | Select-Object ServicePrincipal, OwnerTenant, PrivilegedRoles Other tenants' applications with access to your Azure. .EXAMPLE Get-AACServicePrincipalInsight -ExpiryDays 60 -CsvPath .\Expiring.csv Credentials to rotate in the next two months. .OUTPUTS AAC.ServicePrincipalInsight #> [CmdletBinding()] [OutputType('AAC.ServicePrincipalInsight')] param( [ValidatePattern('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')] [string[]] $SubscriptionId, [string[]] $ManagementGroupId, [ValidateSet('Application', 'ExternalApplication', 'ManagedIdentity')] [string[]] $Kind, [ValidateRange(1, 365)] [int] $ExpiryDays = 30, [ValidateRange(1, 1000)] [int] $SharedLimit = 10, [string] $CsvPath, [string] $HtmlPath, [string] $PdfPath, [string] $Title = 'Service principals', [switch] $PassThru, [switch] $NoDisplay, [switch] $NoPaging ) trap { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { return }; $PSCmdlet.ThrowTerminatingError((Show-AACError -ErrorRecord $_ -Cmdlet $PSCmdlet)) } $pipedOnward = $MyInvocation.PipelinePosition -lt $MyInvocation.PipelineLength $interactive = -not $NoDisplay -and -not $pipedOnward $resolve = { param([string] $Path) if ($Path) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } } $request = @{ SubscriptionId = @($SubscriptionId | Where-Object { $_ }); ManagementGroupId = @($ManagementGroupId | Where-Object { $_ }); ExpiryDays = $ExpiryDays; SharedLimit = $SharedLimit; TenantId = [string]$(if ($script:AACSession) { $script:AACSession.TenantId }) } $null = Get-AACAccessToken if ($interactive) { Write-AACRule -Title 'Azure Admin Console :: Service principals' -Color 'mediumpurple' } $outcome = Invoke-AACProgress -ScriptBlock { Update-AACProgress -Id 'scope' -Indeterminate -Description 'Finding the subscriptions' $scope = Resolve-AACScope -SubscriptionId $request.SubscriptionId -ManagementGroupId $request.ManagementGroupId Update-AACProgress -Id 'scope' -Complete -Description "Scope: $($scope.Label)" Update-AACProgress -Id 'read' -Indeterminate -Description 'Reading the service principals'' role assignments and the user-assigned identities' $read = Invoke-AACGraphBatch -Query ([ordered]@{ roleAssignments = @{ Tenant = $true; Query = "authorizationresources | where type =~ 'microsoft.authorization/roleassignments' and tostring(properties.principalType) =~ 'ServicePrincipal' | project id, principalId = tolower(tostring(properties.principalId)), roleId = tolower(tostring(properties.roleDefinitionId)), scope = tolower(tostring(properties.scope))" } roleDefinitions = @{ Tenant = $true; Query = "authorizationresources | where type =~ 'microsoft.authorization/roledefinitions' | project id = tolower(id), roleName = tostring(properties.roleName)" } userIdentities = @{ Tenant = $true; Query = "resources | where type =~ 'microsoft.managedidentity/userassignedidentities' | project id = tolower(id), name, principalId = tolower(tostring(properties.principalId))" } attached = @{ Tenant = $true; Query = "resources | where isnotnull(identity.userAssignedIdentities) | project id, identities = identity.userAssignedIdentities" } }) -AllowFailure @('userIdentities', 'attached') # Role assignments in scope: under a subscription in scope, or above (management group, root). $inScope = @{}; foreach ($id in $scope.Ids) { $inScope[$id.ToLowerInvariant()] = $true } $read.Rows['roleAssignments'] = @($read.Rows['roleAssignments'] | Where-Object { $_ -and (-not ([string]$_['scope'] -match '^/subscriptions/([^/]+)') -or $inScope.Contains($Matches[1])) }) $principals = @($read.Rows['roleAssignments'] | ForEach-Object { [string]$_['principalId'] } | Where-Object { $_ } | Select-Object -Unique) Update-AACProgress -Id 'read' -Complete -Description ('{0:N0} service principal(s) hold Azure roles' -f $principals.Count) Update-AACProgress -Id 'entra' -Indeterminate -Description "Looking up $($principals.Count) service principal(s) in Entra ID" $directory = Get-AACDirectoryObject -Id $principals -Property 'appOwnerOrganizationId', 'alternativeNames', 'passwordCredentials', 'keyCredentials' $apps = @{} if (-not $directory.Error) { $appIds = @($directory.Objects.Values | Where-Object { [string]$_['servicePrincipalType'] -eq 'Application' -and (-not $request.TenantId -or [string]$_['appOwnerOrganizationId'] -eq $request.TenantId) } | ForEach-Object { [string]$_['appId'] } | Where-Object { $_ } | Sort-Object -Unique) # Sorted, so the same principals always make the same requests (the directory lookup comes back in any order). if ($appIds.Count) { $q = [ordered]@{} for ($i = 0; $i -lt $appIds.Count; $i += 15) { $filter = (@($appIds[$i..([Math]::Min($i + 14, $appIds.Count - 1))] | ForEach-Object { "'$_'" }) -join ',') $q["apps$i"] = "https://graph.microsoft.com/v1.0/applications?`$filter=appId in ($filter)&`$select=appId,displayName,passwordCredentials,keyCredentials" } $answer = Read-AACGraphQuery -Query $q foreach ($key in @($answer.Data.Keys)) { foreach ($a in @($answer.Data[$key] | Where-Object { $_ })) { $apps[([string]$a['appId']).ToLowerInvariant()] = $a } } } } Update-AACProgress -Id 'entra' -Complete -Description ('Looked up {0:N0} principal(s) and {1:N0} app registration(s)' -f $directory.Objects.Count, $apps.Count) @{ Result = (ConvertTo-AACServicePrincipalInsight -Read $read -Directory $directory -Application $apps -TenantId $request.TenantId -ExpiryDays $request.ExpiryDays -SharedLimit $request.SharedLimit); Scope = $scope } } $principalRows = @($outcome.Result.Principals) if ($Kind) { $principalRows = @($principalRows | Where-Object { ($Kind -contains 'Application' -and $_.Kind -eq 'Application') -or ($Kind -contains 'ExternalApplication' -and $_.Kind -eq 'Application (external)') -or ($Kind -contains 'ManagedIdentity' -and $_.Kind -like 'Managed identity*') }) } $s = $outcome.Result.Stats $rank = Get-AACSeverityRank $report = @{ Subtitle = 'Service principals: every application and managed identity with Azure roles - what it is, what it can do, and its credentials' Facts = [ordered]@{ Scope = $outcome.Scope.Label; Expiring = "within $ExpiryDays days" } Status = $(if ($s.High) { 'Failed' } elseif (@($principalRows | Where-Object Findings).Count) { 'Warning' } else { 'Success' }) Headline = "$($s.Principals) service principal(s) hold Azure roles: $($s.External) from other tenants, $($s.Managed) managed identit(ies), $($s.Expiring) with a credential expiring within $ExpiryDays days" Tiles = @( @{ Value = '{0:N0}' -f $s.Principals; Label = 'with Azure roles'; Tone = 'info' } @{ Value = '{0:N0}' -f $s.External; Label = 'other tenants'' apps'; Tone = $(if ($s.External) { 'warn' } else { 'good' }); Table = 'principals'; Filters = @{ Kind = 'Application (external)' } } @{ Value = '{0:N0}' -f $s.Managed; Label = 'managed identities'; Tone = 'neutral' } @{ Value = '{0:N0}' -f $s.Expiring; Label = 'credentials expiring'; Tone = $(if ($s.Expiring) { 'warn' } else { 'good' }) } @{ Value = '{0:N0}' -f $s.High; Label = 'high findings'; Tone = $(if ($s.High) { 'bad' } else { 'good' }); Table = 'principals'; Filters = @{ Severity = 'High' } } ) Notices = @($outcome.Result.Notices | ForEach-Object { @{ Status = 'Warning'; Text = $_ } }) Charts = @( @{ Title = 'By kind'; Kind = 'donut'; CenterLabel = 'principals'; Items = @($principalRows | Group-Object { ($_.Kind -replace ':.*\)$', ')') } | Sort-Object Count -Descending | ForEach-Object { @{ Label = $_.Name; Value = $_.Count } }); Console = $true } @{ Title = 'Most role assignments'; Items = @($principalRows | Sort-Object RoleAssignments -Descending | Select-Object -First 10 | ForEach-Object { @{ Label = $_.ServicePrincipal; Value = $_.RoleAssignments; Filter = $_.ServicePrincipal } }); Table = 'principals'; Column = 'ServicePrincipal'; Tone = 'violet' } ) Tables = @( @{ Id = 'principals'; Title = 'Service principals with Azure roles'; Section = 'Principals'; Rows = $principalRows; Noun = 'principals'; GroupBy = @('Kind'); ConsoleLimit = 30 Empty = 'No service principal holds an Azure role in scope.'; EmptyStatus = 'Info' Columns = @( @{ Key = 'Severity'; Label = 'Severity'; Type = 'badge'; Tones = $rank.Tone; Facet = $true; Console = $true; Pdf = $true } @{ Key = 'ServicePrincipal'; Label = 'Service principal'; Console = $true; Pdf = $true } @{ Key = 'Kind'; Label = 'Kind'; Facet = $true; Console = $true; Pdf = $true } @{ Key = 'RoleAssignments'; Label = 'Roles'; Type = 'number'; Console = $true; Pdf = $true } @{ Key = 'PrivilegedRoles'; Label = 'Privileged'; Console = $true; Pdf = $true } @{ Key = 'AttachedResources'; Label = 'Attached to'; Type = 'number'; Console = $true } @{ Key = 'NextExpiry'; Label = 'Next expiry'; Type = 'date'; Console = $true; Pdf = $true } @{ Key = 'Findings'; Label = 'Findings'; Type = 'wide'; Pdf = $true } @{ Key = 'Roles'; Label = 'Role assignments'; Type = 'wide' } @{ Key = 'Credentials'; Label = 'Credentials' } @{ Key = 'OwnerTenant'; Label = 'Owner tenant'; Type = 'mono' } @{ Key = 'AppId'; Label = 'App ID'; Type = 'mono' } @{ Key = 'ResourceId'; Label = 'Resource'; Type = 'mono' } ) } ) Hint = '-Kind narrows it; -ExpiryDays sets what expiring means; -NoDisplay returns the principals; -HtmlPath, -PdfPath or -CsvPath for a report.' } Invoke-AACReportOutput -Report $report -Title $Title -CsvObject $principalRows -Noun 'principal' -CsvPath (& $resolve $CsvPath) -HtmlPath (& $resolve $HtmlPath) -PdfPath (& $resolve $PdfPath) ` -ShowView:$interactive -NoPaging:$NoPaging -Object $principalRows -ReturnObject:($PassThru -or $NoDisplay -or $pipedOnward) } |