Private/Complete-CustomDetectionPatchBody.ps1

function Complete-CustomDetectionPatchBody {
    <#
    .SYNOPSIS
        Builds the PATCH body from a full rule body.
 
    .DESCRIPTION
        The API keeps any collection that a PATCH omits, so an action, entity
        mapping or device group removed from the file would stay live. The
        PATCH body therefore sends an empty list for every collection the
        remote rule carries and the file does not, including collections this
        module does not know. Each cleared collection is named in a warning.
        Collections neither side carries are left out. Custom details are
        left out when unset because the API offers no way to clear them.
        The rule id is left out because it cannot change. Without a remote
        rule every known collection is named, which is the shape that
        predates the remote-driven one.
    #>

    [CmdletBinding()]
    [OutputType([System.Collections.Specialized.OrderedDictionary])]
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary]$Body,

        [Parameter()]
        [AllowNull()]
        [PSObject]$Remote
    )

    # Collections the remote rule carries with at least one item, annotations excluded
    function Get-RemoteCollectionList {
        param([object]$Source)
        $names = [System.Collections.Generic.List[string]]::new()
        $map = ConvertTo-CustomDetectionHashtable -InputObject $Source
        if ($null -eq $map) { return $names }
        foreach ($key in $map.Keys) {
            if ("$key".StartsWith('@')) { continue }
            if ($null -ne $map[$key] -and @($map[$key]).Count -gt 0) { $names.Add([string]$key) }
        }
        return $names
    }

    function Merge-CollectionSet {
        param([System.Collections.IDictionary]$Local, [string[]]$Known, [object]$RemoteSource)

        $merged = [ordered]@{}
        $names = [System.Collections.Generic.List[string]]::new()
        if ($null -ne $Remote) {
            foreach ($name in (Get-RemoteCollectionList -Source $RemoteSource)) { $names.Add($name) }
        } else {
            foreach ($name in $Known) { $names.Add($name) }
        }
        if ($Local) {
            foreach ($name in $Local.Keys) { if (-not $names.Contains([string]$name)) { $names.Add([string]$name) } }
        }
        foreach ($name in $names) {
            if ($Local -and $Local.Contains($name) -and $null -ne $Local[$name]) {
                $merged[$name] = [object[]]@($Local[$name])
            } else {
                if ($null -ne $Remote) {
                    $unknownNote = if ($name -notin $Known) { " (not one of this module's known columns)" } else { '' }
                    Write-Warning "The rule carries '$name'$unknownNote that the file does not set. It is cleared."
                }
                $merged[$name] = [object[]]@()
            }
        }
        return $merged
    }

    $patch = [ordered]@{}
    foreach ($key in $Body.Keys) {
        if ($key -ne 'id') { $patch[$key] = $Body[$key] }
    }

    $detectionAction = [ordered]@{}
    foreach ($key in $Body['detectionAction'].Keys) {
        $detectionAction[$key] = $Body['detectionAction'][$key]
    }
    $patch['detectionAction'] = $detectionAction

    $alertTemplate = [ordered]@{}
    foreach ($key in $detectionAction['alertTemplate'].Keys) {
        $alertTemplate[$key] = $detectionAction['alertTemplate'][$key]
    }
    $detectionAction['alertTemplate'] = $alertTemplate

    $entityMappings = Merge-CollectionSet -Local $alertTemplate['entityMappings'] -Known @((Get-CustomDetectionEntityMappingColumns).Keys) -RemoteSource (Get-CustomDetectionValue -Object $Remote -Path 'detectionAction.alertTemplate.entityMappings')
    if ($entityMappings.Count -gt 0) {
        $alertTemplate['entityMappings'] = $entityMappings
    } else {
        $alertTemplate.Remove('entityMappings')
    }

    $automatedActions = Merge-CollectionSet -Local $detectionAction['automatedActions'] -Known @((Get-CustomDetectionActionMap).Collection) -RemoteSource (Get-CustomDetectionValue -Object $Remote -Path 'detectionAction.automatedActions')
    if ($automatedActions.Count -gt 0) {
        $detectionAction['automatedActions'] = $automatedActions
    } else {
        $detectionAction.Remove('automatedActions')
    }

    if (-not $detectionAction.Contains('organizationalScope') -or $null -eq $detectionAction['organizationalScope']) {
        $remoteScope = ConvertTo-CustomDetectionHashtable -InputObject (Get-CustomDetectionValue -Object $Remote -Path 'detectionAction.organizationalScope')
        $remoteHasGroups = $false
        if ($null -ne $remoteScope) {
            foreach ($key in @('deviceGroups', 'scopeNames')) {
                if ($remoteScope.Contains($key) -and $null -ne $remoteScope[$key] -and @($remoteScope[$key]).Count -gt 0) { $remoteHasGroups = $true }
            }
        }
        if ($null -eq $Remote -or $remoteHasGroups) {
            $detectionAction['organizationalScope'] = [ordered]@{ deviceGroups = [object[]]@() }
        } else {
            $detectionAction.Remove('organizationalScope')
        }
    }

    return $patch
}