modules/Devolutions.CIEM.Graph/Public/Save-CIEMExposureSnapshot.ps1

function NewCIEMIdentityExposureSnapshotItem {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$Summary,

        [Parameter(Mandatory)]
        [int]$DiscoveryRunId,

        [Parameter(Mandatory)]
        [string]$ObservedAt
    )

    $ErrorActionPreference = 'Stop'

    $signals = Get-CIEMIdentityRiskSignals -PrincipalId ([string]$Summary.Id)
    $roleAssignments = @($signals.RoleAssignments)
    $targetAssignment = @($roleAssignments |
        Sort-Object `
            @{ Expression = { if ([bool]$_.IsPrivileged) { 0 } else { 1 } } }, `
            @{ Expression = { [string]$_.Scope } } |
        Select-Object -First 1)
    $targetName = if ($targetAssignment.Count -eq 1) { [string]$targetAssignment[0].Scope } else { '' }
    $severity = ConvertToCIEMExposureSeverityLabel -Severity ([string]$Summary.RiskLevel)
    $state = [PSCustomObject]@{
        RiskLevel        = $severity
        EntitlementCount = [int]$Summary.EntitlementCount
        PrivilegedCount  = [int]$Summary.PrivilegedCount
        InheritedCount   = [int]$Summary.InheritedCount
        RoleAssignments  = @($roleAssignments | Select-Object RoleName, Scope, IsPrivileged, IsInherited, InheritedFrom)
    }

    [PSCustomObject]@{
        DiscoveryRunId        = $DiscoveryRunId
        ExposureKey           = "identity:$($Summary.Id)"
        ExposureType          = 'IdentityRisk'
        Severity              = $severity
        SeverityRank          = ConvertCIEMExposureSeverityRank -Severity $severity
        ImpactedIdentityId    = [string]$Summary.Id
        ImpactedIdentityName  = [string]$Summary.DisplayName
        ImpactedIdentityType  = [string]$Summary.PrincipalType
        ImpactedResourceId    = $targetName
        ImpactedResourceName  = $targetName
        Title                 = [string]$Summary.DisplayName
        StateJson             = $state | ConvertTo-Json -Depth 6 -Compress
        Evidence              = "$($Summary.EntitlementCount) entitlement(s); $($Summary.PrivilegedCount) privileged; $($Summary.InheritedCount) inherited; target $targetName"
        ObservedAt            = $ObservedAt
    }
}

function NewCIEMAttackPathExposureSnapshotItem {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$AttackPath,

        [Parameter(Mandatory)]
        [int]$DiscoveryRunId,

        [Parameter(Mandatory)]
        [string]$ObservedAt
    )

    $ErrorActionPreference = 'Stop'

    $pathNodes = @($AttackPath.Path)
    if ($pathNodes.Count -eq 0) {
        throw "Attack path '$($AttackPath.Id)' has no path nodes."
    }

    $identityKinds = @('EntraUser', 'EntraServicePrincipal', 'EntraManagedIdentity', 'EntraGroup')
    $identityNode = @($pathNodes | Where-Object { $identityKinds -contains [string]$_.kind } | Select-Object -First 1)
    $targetNode = $pathNodes[$pathNodes.Count - 1]
    $identityId = if ($identityNode.Count -eq 1) { [string]$identityNode[0].id } else { '' }
    $identityName = if ($identityNode.Count -eq 1 -and $identityNode[0].display_name) { [string]$identityNode[0].display_name } else { $identityId }
    $identityType = if ($identityNode.Count -eq 1) {
        switch ([string]$identityNode[0].kind) {
            'EntraUser' { 'User'; break }
            'EntraServicePrincipal' { 'ServicePrincipal'; break }
            'EntraManagedIdentity' { 'ManagedIdentity'; break }
            'EntraGroup' { 'Group'; break }
            default { throw "Unsupported attack path identity kind '$($identityNode[0].kind)'." }
        }
    } else { '' }
    $targetId = [string]$targetNode.id
    $targetName = if ($targetNode.display_name) { [string]$targetNode.display_name } else { $targetId }
    $severity = ConvertToCIEMExposureSeverityLabel -Severity ([string]$AttackPath.Severity)
    $state = [PSCustomObject]@{
        PatternName = [string]$AttackPath.PatternName
        RuleId      = [string]$AttackPath.RuleId
        PathChain   = [string]$AttackPath.PathChain
    }

    [PSCustomObject]@{
        DiscoveryRunId        = $DiscoveryRunId
        ExposureKey           = "attack-path:$($AttackPath.Id)"
        ExposureType          = 'AttackPath'
        Severity              = $severity
        SeverityRank          = ConvertCIEMExposureSeverityRank -Severity $severity
        ImpactedIdentityId    = $identityId
        ImpactedIdentityName  = $identityName
        ImpactedIdentityType  = $identityType
        ImpactedResourceId    = $targetId
        ImpactedResourceName  = $targetName
        Title                 = [string]$AttackPath.PatternName
        StateJson             = $state | ConvertTo-Json -Compress
        Evidence              = [string]$AttackPath.PathChain
        ObservedAt            = $ObservedAt
    }
}

function Save-CIEMExposureSnapshot {
    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Materializes local exposure snapshot records for a discovery run')]
    [OutputType([PSCustomObject[]])]
    param(
        [Parameter(Mandatory)]
        [int]$DiscoveryRunId
    )

    $ErrorActionPreference = 'Stop'

    $run = @(Get-CIEMAzureDiscoveryRun -Id $DiscoveryRunId)
    if ($run.Count -ne 1) {
        throw "Expected one discovery run with Id '$DiscoveryRunId', found $($run.Count)."
    }
    if ([string]::IsNullOrWhiteSpace([string]$run[0].CompletedAt)) {
        throw "Discovery run '$DiscoveryRunId' must be completed before an exposure snapshot can be saved."
    }

    $observedAt = [string]$run[0].CompletedAt
    $items = @()
    foreach ($summary in @(Get-CIEMIdentityRiskSummary)) {
        $items += NewCIEMIdentityExposureSnapshotItem -Summary $summary -DiscoveryRunId $DiscoveryRunId -ObservedAt $observedAt
    }
    foreach ($attackPath in @(Get-CIEMAttackPath)) {
        $items += NewCIEMAttackPathExposureSnapshotItem -AttackPath $attackPath -DiscoveryRunId $DiscoveryRunId -ObservedAt $observedAt
    }

    Invoke-CIEMQuery -Query 'DELETE FROM ciem_exposure_snapshot_items WHERE discovery_run_id = @discovery_run_id' -Parameters @{ discovery_run_id = $DiscoveryRunId } -AsNonQuery | Out-Null

    foreach ($item in $items) {
        Invoke-CIEMQuery -Query @"
INSERT INTO ciem_exposure_snapshot_items (
    discovery_run_id, exposure_key, exposure_type, severity, severity_rank,
    impacted_identity_id, impacted_identity_name, impacted_identity_type,
    impacted_resource_id, impacted_resource_name, title, state_json, evidence,
    observed_at
)
VALUES (
    @discovery_run_id, @exposure_key, @exposure_type, @severity, @severity_rank,
    @impacted_identity_id, @impacted_identity_name, @impacted_identity_type,
    @impacted_resource_id, @impacted_resource_name, @title, @state_json, @evidence,
    @observed_at
)
"@
 -Parameters @{
            discovery_run_id       = $item.DiscoveryRunId
            exposure_key           = $item.ExposureKey
            exposure_type          = $item.ExposureType
            severity               = $item.Severity
            severity_rank          = $item.SeverityRank
            impacted_identity_id   = $item.ImpactedIdentityId
            impacted_identity_name = $item.ImpactedIdentityName
            impacted_identity_type = $item.ImpactedIdentityType
            impacted_resource_id   = $item.ImpactedResourceId
            impacted_resource_name = $item.ImpactedResourceName
            title                  = $item.Title
            state_json             = $item.StateJson
            evidence               = $item.Evidence
            observed_at            = $item.ObservedAt
        } -AsNonQuery | Out-Null
    }

    @($items)
}