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

function NewCIEMExposureChange {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateSet('NewRisk', 'RemovedRisk', 'RiskIncrease')]
        [string]$ChangeType,

        [Parameter()]
        [object]$Previous,

        [Parameter()]
        [object]$Current,

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

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

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

    $ErrorActionPreference = 'Stop'

    $source = if ($Current) { $Current } else { $Previous }
    $severity = if ($ChangeType -eq 'RemovedRisk') { [string]$Previous.severity } else { [string]$Current.severity }
    $severityRank = if ($ChangeType -eq 'RemovedRisk') { [int]$Previous.severity_rank } else { [int]$Current.severity_rank }
    $previousSeverity = if ($Previous) { [string]$Previous.severity } else { $null }
    $currentSeverity = if ($Current) { [string]$Current.severity } else { $null }
    $firstSeenAt = if ($Current) { [string]$Current.observed_at } else { $CreatedAt }
    $title = [string]$source.title
    $evidence = switch ($ChangeType) {
        'NewRisk' { "New $severity $($source.exposure_type) exposure: $title" }
        'RemovedRisk' { "Removed $severity $($source.exposure_type) exposure: $title" }
        'RiskIncrease' { "$($source.exposure_type) exposure increased from $previousSeverity to ${currentSeverity}: $title" }
    }

    [PSCustomObject]@{
        Id                     = "${CurrentDiscoveryRunId}:${ChangeType}:$($source.exposure_key)"
        PreviousDiscoveryRunId = $PreviousDiscoveryRunId
        CurrentDiscoveryRunId  = $CurrentDiscoveryRunId
        ExposureKey            = [string]$source.exposure_key
        ChangeType             = $ChangeType
        ExposureType           = [string]$source.exposure_type
        Severity               = $severity
        SeverityRank           = $severityRank
        Title                  = $title
        PreviousSeverity       = $previousSeverity
        CurrentSeverity        = $currentSeverity
        ImpactedIdentityId     = [string]$source.impacted_identity_id
        ImpactedIdentityName   = [string]$source.impacted_identity_name
        ImpactedIdentityType   = [string]$source.impacted_identity_type
        ImpactedResourceId     = [string]$source.impacted_resource_id
        ImpactedResourceName   = [string]$source.impacted_resource_name
        FirstSeenAt            = $firstSeenAt
        PreviousStateJson      = if ($Previous) { [string]$Previous.state_json } else { $null }
        CurrentStateJson       = if ($Current) { [string]$Current.state_json } else { $null }
        Evidence               = $evidence
        CreatedAt              = $CreatedAt
    }
}

function Compare-CIEMExposureSnapshot {
    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Persists local exposure-change records generated from two snapshots')]
    [OutputType([PSCustomObject[]])]
    param(
        [Parameter(Mandatory)]
        [int]$PreviousDiscoveryRunId,

        [Parameter(Mandatory)]
        [int]$CurrentDiscoveryRunId
    )

    $ErrorActionPreference = 'Stop'

    if ($PreviousDiscoveryRunId -eq $CurrentDiscoveryRunId) {
        throw 'PreviousDiscoveryRunId and CurrentDiscoveryRunId must be different.'
    }

    $previousRows = @(Invoke-CIEMQuery -Query 'SELECT * FROM ciem_exposure_snapshot_items WHERE discovery_run_id = @discovery_run_id' -Parameters @{ discovery_run_id = $PreviousDiscoveryRunId })
    $currentRows = @(Invoke-CIEMQuery -Query 'SELECT * FROM ciem_exposure_snapshot_items WHERE discovery_run_id = @discovery_run_id' -Parameters @{ discovery_run_id = $CurrentDiscoveryRunId })

    $previousByKey = @{}
    foreach ($row in $previousRows) {
        $previousByKey[[string]$row.exposure_key] = $row
    }

    $currentByKey = @{}
    foreach ($row in $currentRows) {
        $currentByKey[[string]$row.exposure_key] = $row
    }

    $createdAt = (Get-Date).ToString('o')
    $changes = @()

    foreach ($current in $currentRows) {
        $key = [string]$current.exposure_key
        $previous = $previousByKey[$key]
        if ($null -eq $previous) {
            if (TestCIEMExposureSeverityIsRisk -Severity ([string]$current.severity)) {
                $changes += NewCIEMExposureChange -ChangeType 'NewRisk' -Previous $null -Current $current -PreviousDiscoveryRunId $PreviousDiscoveryRunId -CurrentDiscoveryRunId $CurrentDiscoveryRunId -CreatedAt $createdAt
            }
        }
        elseif ([int]$current.severity_rank -lt [int]$previous.severity_rank -and (TestCIEMExposureSeverityIsRisk -Severity ([string]$current.severity))) {
            $changes += NewCIEMExposureChange -ChangeType 'RiskIncrease' -Previous $previous -Current $current -PreviousDiscoveryRunId $PreviousDiscoveryRunId -CurrentDiscoveryRunId $CurrentDiscoveryRunId -CreatedAt $createdAt
        }
        elseif ((TestCIEMExposureSeverityIsRisk -Severity ([string]$previous.severity)) -and -not (TestCIEMExposureSeverityIsRisk -Severity ([string]$current.severity))) {
            $changes += NewCIEMExposureChange -ChangeType 'RemovedRisk' -Previous $previous -Current $current -PreviousDiscoveryRunId $PreviousDiscoveryRunId -CurrentDiscoveryRunId $CurrentDiscoveryRunId -CreatedAt $createdAt
        }
    }

    foreach ($previous in $previousRows) {
        $key = [string]$previous.exposure_key
        if (-not $currentByKey.ContainsKey($key) -and (TestCIEMExposureSeverityIsRisk -Severity ([string]$previous.severity))) {
            $changes += NewCIEMExposureChange -ChangeType 'RemovedRisk' -Previous $previous -Current $null -PreviousDiscoveryRunId $PreviousDiscoveryRunId -CurrentDiscoveryRunId $CurrentDiscoveryRunId -CreatedAt $createdAt
        }
    }

    Invoke-CIEMQuery -Query 'DELETE FROM ciem_exposure_changes WHERE current_discovery_run_id = @current_discovery_run_id' -Parameters @{ current_discovery_run_id = $CurrentDiscoveryRunId } -AsNonQuery | Out-Null

    foreach ($change in $changes) {
        Invoke-CIEMQuery -Query @"
INSERT INTO ciem_exposure_changes (
    id, previous_discovery_run_id, current_discovery_run_id, exposure_key,
    change_type, exposure_type, severity, severity_rank, title, previous_severity,
    current_severity, impacted_identity_id, impacted_identity_name,
    impacted_identity_type, impacted_resource_id, impacted_resource_name,
    first_seen_at, previous_state_json, current_state_json, evidence, created_at
)
VALUES (
    @id, @previous_discovery_run_id, @current_discovery_run_id, @exposure_key,
    @change_type, @exposure_type, @severity, @severity_rank, @title, @previous_severity,
    @current_severity, @impacted_identity_id, @impacted_identity_name,
    @impacted_identity_type, @impacted_resource_id, @impacted_resource_name,
    @first_seen_at, @previous_state_json, @current_state_json, @evidence, @created_at
)
"@
 -Parameters @{
            id                        = $change.Id
            previous_discovery_run_id = $change.PreviousDiscoveryRunId
            current_discovery_run_id  = $change.CurrentDiscoveryRunId
            exposure_key              = $change.ExposureKey
            change_type               = $change.ChangeType
            exposure_type             = $change.ExposureType
            severity                  = $change.Severity
            severity_rank             = $change.SeverityRank
            title                     = $change.Title
            previous_severity         = $change.PreviousSeverity
            current_severity          = $change.CurrentSeverity
            impacted_identity_id      = $change.ImpactedIdentityId
            impacted_identity_name    = $change.ImpactedIdentityName
            impacted_identity_type    = $change.ImpactedIdentityType
            impacted_resource_id      = $change.ImpactedResourceId
            impacted_resource_name    = $change.ImpactedResourceName
            first_seen_at             = $change.FirstSeenAt
            previous_state_json       = $change.PreviousStateJson
            current_state_json        = $change.CurrentStateJson
            evidence                  = $change.Evidence
            created_at                = $change.CreatedAt
        } -AsNonQuery | Out-Null
    }

    @(Get-CIEMExposureChange -CurrentDiscoveryRunId $CurrentDiscoveryRunId)
}