modules/Devolutions.CIEM.Graph/Public/Get-CIEMIdentityRiskSignals.ps1
|
function Get-CIEMIdentityRiskSignals { <# .SYNOPSIS Returns detailed risk signals for a specific identity. .DESCRIPTION For a given principal ID, queries graph_nodes and graph_edges to return all role assignments, identifies inherited roles, computes risk signals (dormant permissions, public exposure, disabled accounts), and for managed identities resolves the hosting resource. .PARAMETER PrincipalId The identity node ID to analyze. #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory)] [string]$PrincipalId ) $ErrorActionPreference = 'Stop' if ($null -eq $script:DormantPermissionThresholdDays) { throw 'Module variable $script:DormantPermissionThresholdDays is not initialized. The module may not have loaded correctly.' } # Get the identity from graph nodes $identity = @(Get-CIEMGraphNode -Id $PrincipalId) if ($identity.Count -eq 0) { throw "Identity not found: $PrincipalId" } $identity = $identity[0] # Parse pre-computed properties from the node's Properties JSON $props = $null if ($identity.Properties) { $props = $identity.Properties | ConvertFrom-Json } $accountEnabled = if ($null -ne $props -and $null -ne $props.accountEnabled) { [bool]$props.accountEnabled } else { $true } $daysSinceSignIn = if ($null -ne $props -and $null -ne $props.daysSinceSignIn) { [int]$props.daysSinceSignIn } else { $null } $lastSignIn = if ($null -ne $props -and $props.lastSignIn) { $props.lastSignIn } else { $null } $lastInteractiveSignIn = if ($null -ne $props -and $props.lastInteractiveSignIn) { $props.lastInteractiveSignIn } else { $null } $lastNonInteractiveSignIn = if ($null -ne $props -and $props.lastNonInteractiveSignIn) { $props.lastNonInteractiveSignIn } else { $null } $isManagedIdentity = $identity.Kind -eq 'EntraManagedIdentity' # Get all role assignment edges (HasRole = direct, InheritedRole = via group) $directRoleEdges = @(Get-CIEMGraphEdge -SourceId $PrincipalId -Kind 'HasRole') $inheritedRoleEdges = @(Get-CIEMGraphEdge -SourceId $PrincipalId -Kind 'InheritedRole') $allRoleEdges = @($directRoleEdges) + @($inheritedRoleEdges) # Build role assignments from edges $roleAssignments = @(foreach ($edge in $allRoleEdges) { $edgeProps = $null if ($edge.Properties) { $edgeProps = $edge.Properties | ConvertFrom-Json } $roleName = if ($edgeProps -and $edgeProps.role_name) { $edgeProps.role_name } else { 'Unknown' } $isPrivileged = if ($edgeProps -and $null -ne $edgeProps.privileged) { [bool]$edgeProps.privileged } else { $false } $scope = if ($edgeProps -and $edgeProps.scope) { $edgeProps.scope } else { $edge.TargetId } $isInherited = $edge.Kind -eq 'InheritedRole' # For inherited roles, read the group name directly from the edge's Properties JSON # (set by Invoke-CIEMGraphComputedEdgeBuild as inherited_from_name) $inheritedFrom = $null if ($isInherited -and $edgeProps -and $edgeProps.inherited_from_name) { $inheritedFrom = $edgeProps.inherited_from_name } [PSCustomObject]@{ RoleName = $roleName Scope = $scope IsPrivileged = $isPrivileged IsInherited = $isInherited InheritedFrom = $inheritedFrom } }) $inheritedRoles = @($roleAssignments | Where-Object { $_.IsInherited }) # Compute risk signals $riskSignals = @() # 1. Dormant privileged permissions $hasPrivilegedRole = ($roleAssignments | Where-Object { $_.IsPrivileged }) -as [bool] if ($hasPrivilegedRole -and ($null -eq $daysSinceSignIn -or $daysSinceSignIn -gt $script:DormantPermissionThresholdDays)) { if ($null -eq $daysSinceSignIn) { $riskSignals += [PSCustomObject]@{ Signal = 'dormant-privileged-permissions' Severity = 'Critical' Description = 'Holds privileged role with no recorded sign-in activity' DaysSinceSignIn = $null } } else { $riskSignals += [PSCustomObject]@{ Signal = 'dormant-privileged-permissions' Severity = 'Critical' Description = "Holds privileged role with no sign-in activity for $daysSinceSignIn days" DaysSinceSignIn = $daysSinceSignIn } } } # 2. Group-inherited privileged role $inheritedPrivileged = @($inheritedRoles | Where-Object { $_.IsPrivileged }) foreach ($ip in $inheritedPrivileged) { $riskSignals += [PSCustomObject]@{ Signal = 'group-inherited-privileged-role' Severity = 'High' Description = "Holds $($ip.RoleName) via group '$($ip.InheritedFrom)'" } } # 3. Disabled account with active assignments if (-not $accountEnabled -and $allRoleEdges.Count -gt 0) { $riskSignals += [PSCustomObject]@{ Signal = 'disabled-with-permissions' Severity = 'High' Description = "Account is disabled but still holds $($allRoleEdges.Count) active role assignments" } } # 4. Managed identity public exposure $hostingResource = $null if ($isManagedIdentity) { # HasManagedIdentity: source=VM, target=MI principal $hostEdges = @(Get-CIEMGraphEdge -TargetId $PrincipalId -Kind 'HasManagedIdentity') if ($hostEdges.Count -gt 0) { $hostNodes = @(Get-CIEMGraphNode -Id $hostEdges[0].SourceId) if ($hostNodes.Count -gt 0) { $hostNode = $hostNodes[0] # Check for public IP via NIC -> VM (AttachedTo) and NIC -> PublicIP (HasPublicIP) $nicEdges = @(Get-CIEMGraphEdge -TargetId $hostNode.Id -Kind 'AttachedTo') $hasPublicIP = $false foreach ($nicEdge in $nicEdges) { if (@(Get-CIEMGraphEdge -SourceId $nicEdge.SourceId -Kind 'HasPublicIP').Count -gt 0) { $hasPublicIP = $true break } } $hostingResource = [PSCustomObject]@{ Id = $hostNode.Id Name = $hostNode.DisplayName Type = $hostNode.Kind ResourceGroup = $hostNode.ResourceGroup HasPublicIP = $hasPublicIP } if ($hasPublicIP) { $riskSignals += [PSCustomObject]@{ Signal = 'managed-identity-public-exposure' Severity = 'Critical' Description = "Managed identity on $($hostNode.DisplayName) with public IP address" } } } } } [PSCustomObject]@{ Identity = [PSCustomObject]@{ Id = $identity.Id DisplayName = $identity.DisplayName Type = $identity.Kind AccountEnabled = $accountEnabled LastSignIn = $lastSignIn LastInteractiveSignIn = $lastInteractiveSignIn LastNonInteractiveSignIn = $lastNonInteractiveSignIn } RoleAssignments = $roleAssignments RiskSignals = $riskSignals InheritedRoles = $inheritedRoles HostingResource = $hostingResource } } |