Private/Get-CustomDetectionComparableState.ps1

function Get-CustomDetectionComparableState {
    <#
    .SYNOPSIS
        Projects a detection rule into a canonical shape for change detection.
 
    .DESCRIPTION
        Works for the local request body and for rules returned by the Graph
        API, whether they carry the legacy properties, the current properties
        or both. Legacy values are translated so both sides compare on the
        same fields. Collections are sorted so ordering never counts as a change.
    #>

    [CmdletBinding()]
    [OutputType([System.Collections.Specialized.OrderedDictionary])]
    param(
        [Parameter(Mandatory)]
        [PSObject]$Rule
    )

    function ConvertTo-SortedObject {
        param([object]$Value)
        if ($null -eq $Value) { return $null }
        $map = ConvertTo-CustomDetectionHashtable -InputObject $Value
        if ($null -ne $map) {
            $sorted = [ordered]@{}
            foreach ($key in ($map.Keys | Sort-Object)) {
                $sorted[$key] = ConvertTo-SortedObject -Value $map[$key]
            }
            return $sorted
        }
        if ($Value -isnot [string] -and $Value -is [System.Collections.IEnumerable]) {
            $items = @($Value | ForEach-Object { ConvertTo-SortedObject -Value $_ })
            # Ordinal, so items that differ only by case never tie. The casts select the non-generic overload, the one that reorders the items
            $keys = [string[]]@($items | ForEach-Object { ConvertTo-Json -InputObject $_ -Compress -Depth 10 })
            [Array]::Sort([Array]$keys, [Array]$items, [System.Collections.IComparer][System.StringComparer]::Ordinal)
            return , $items
        }
        return $Value
    }

    function ConvertTo-SortedJson {
        param([object]$Value)
        if ($null -eq $Value) { return '' }
        $sorted = ConvertTo-SortedObject -Value $Value
        if ($null -eq $sorted) { return '' }
        return ($sorted | ConvertTo-Json -Compress -Depth 10)
    }

    $status = Get-CustomDetectionValue -Object $Rule -Path 'status'
    if ([string]::IsNullOrWhiteSpace("$status")) {
        $status = ConvertTo-CustomDetectionStatus -IsEnabled (Get-CustomDetectionValue -Object $Rule -Path 'isEnabled')
    }

    $frequencySource = Get-CustomDetectionValue -Object $Rule -Path 'schedule.frequency'
    if ([string]::IsNullOrWhiteSpace("$frequencySource")) {
        $frequencySource = Get-CustomDetectionValue -Object $Rule -Path 'schedule.period'
    }
    $frequency = ''
    if (-not [string]::IsNullOrWhiteSpace("$frequencySource")) {
        try {
            $frequency = ConvertTo-CustomDetectionFrequency -Value $frequencySource
        } catch {
            # An unrecognised remote value still has to compare, so keep it verbatim
            $frequency = "$frequencySource".Trim().ToUpperInvariant()
        }
    }

    $tacticsSource = Get-CustomDetectionValue -Object $Rule -Path 'detectionAction.alertTemplate.tactics'
    $tactics = $null
    if ($null -ne $tacticsSource -and @($tacticsSource).Count -gt 0) {
        $tactics = @(ConvertTo-CustomDetectionTactics -Tactics $tacticsSource)
    } else {
        $category = Get-CustomDetectionValue -Object $Rule -Path 'detectionAction.alertTemplate.category'
        $techniques = Get-CustomDetectionValue -Object $Rule -Path 'detectionAction.alertTemplate.mitreTechniques'
        if (-not [string]::IsNullOrWhiteSpace("$category")) {
            $tactics = @(ConvertTo-CustomDetectionTactics -Category "$category" -Techniques @($techniques))
        }
    }

    $entityMappingsSource = Get-CustomDetectionValue -Object $Rule -Path 'detectionAction.alertTemplate.entityMappings'
    $entityMappings = $null
    if (Test-CustomDetectionCollection -Value $entityMappingsSource) {
        $entityMappings = ConvertFrom-CustomDetectionEntityMappings -EntityMappings $entityMappingsSource
    } else {
        $impactedAssets = Get-CustomDetectionValue -Object $Rule -Path 'detectionAction.alertTemplate.impactedAssets'
        if ($null -ne $impactedAssets -and @($impactedAssets).Count -gt 0) {
            $entityMappings = ConvertFrom-CustomDetectionEntityMappings -ImpactedAssets @($impactedAssets)
        }
    }

    $automatedActionsSource = Get-CustomDetectionValue -Object $Rule -Path 'detectionAction.automatedActions'
    $actions = @()
    if (Test-CustomDetectionCollection -Value $automatedActionsSource) {
        $actions = @(ConvertFrom-CustomDetectionAutomatedActions -AutomatedActions $automatedActionsSource -WarningAction SilentlyContinue)
    } else {
        $responseActions = Get-CustomDetectionValue -Object $Rule -Path 'detectionAction.responseActions'
        if ($null -ne $responseActions -and @($responseActions).Count -gt 0) {
            $translated = ConvertTo-CustomDetectionAutomatedActions -ResponseActions @($responseActions)
            $actions = @(ConvertFrom-CustomDetectionAutomatedActions -AutomatedActions $translated -WarningAction SilentlyContinue)
        }
    }

    $scopeSource = Get-CustomDetectionValue -Object $Rule -Path 'detectionAction.organizationalScope'
    $deviceGroups = @()
    if ($null -ne $scopeSource) {
        $scopeMap = ConvertTo-CustomDetectionHashtable -InputObject $scopeSource
        if ($null -ne $scopeMap) {
            if ($scopeMap.Contains('deviceGroups') -and $null -ne $scopeMap['deviceGroups']) {
                $deviceGroups = @($scopeMap['deviceGroups'])
            } elseif ($scopeMap.Contains('scopeNames') -and $null -ne $scopeMap['scopeNames']) {
                $deviceGroups = @($scopeMap['scopeNames'])
            }
        } else {
            $deviceGroups = @($scopeSource)
        }
    }

    $customDetails = [ordered]@{}
    $customDetailsSource = ConvertTo-CustomDetectionHashtable -InputObject (Get-CustomDetectionValue -Object $Rule -Path 'detectionAction.alertTemplate.customDetails')
    if ($null -ne $customDetailsSource) {
        foreach ($key in ($customDetailsSource.Keys | Sort-Object)) {
            $value = $customDetailsSource[$key]
            if ($null -ne $value -and "$value" -ne '') {
                $customDetails[$key] = "$value"
            }
        }
    }

    return [ordered]@{
        displayName        = "$(Get-CustomDetectionValue -Object $Rule -Path 'displayName')"
        status             = "$status"
        description        = "$(Get-CustomDetectionValue -Object $Rule -Path 'description')"
        frequency          = $frequency
        queryText          = "$(Get-CustomDetectionValue -Object $Rule -Path 'queryCondition.queryText')"
        title              = "$(Get-CustomDetectionValue -Object $Rule -Path 'detectionAction.alertTemplate.title')"
        alertDescription   = "$(Get-CustomDetectionValue -Object $Rule -Path 'detectionAction.alertTemplate.description')"
        severity           = ("$(Get-CustomDetectionValue -Object $Rule -Path 'detectionAction.alertTemplate.severity')").ToLowerInvariant()
        recommendedActions = "$(Get-CustomDetectionValue -Object $Rule -Path 'detectionAction.alertTemplate.recommendedActions')"
        tactics            = ConvertTo-SortedJson -Value $tactics
        entityMappings     = ConvertTo-SortedJson -Value $entityMappings
        customDetails      = ConvertTo-SortedJson -Value $customDetails
        automatedActions   = ConvertTo-SortedJson -Value $actions
        deviceGroups       = ConvertTo-SortedJson -Value @($deviceGroups | ForEach-Object { "$_" } | Sort-Object)
    }
}