Private/ConvertTo-CustomDetectionEntityMappings.ps1

function ConvertTo-CustomDetectionEntityMappings {
    <#
    .SYNOPSIS
        Builds the alertTemplate.entityMappings object.
 
    .DESCRIPTION
        Accepts either the legacy impactedEntities list (entityType plus
        entityIdentifier) or an entityMappings object keyed by entity
        collection. Both return the Graph shape: a dictionary of collections,
        each holding a list of column mapping items.
    #>

    [CmdletBinding(DefaultParameterSetName = 'EntityMappings')]
    [OutputType([System.Collections.Specialized.OrderedDictionary])]
    param(
        [Parameter(ParameterSetName = 'ImpactedEntities')]
        [AllowNull()]
        [object[]]$ImpactedEntities,

        [Parameter(ParameterSetName = 'EntityMappings')]
        [AllowNull()]
        [object]$EntityMappings,

        [Parameter()]
        [switch]$SkipIdentifierValidation
    )

    $collectionColumns = Get-CustomDetectionEntityMappingColumns

    $legacyTypes = @{
        machine = @{ Collection = 'hosts'; DefaultColumn = 'nameColumn' }
        device  = @{ Collection = 'hosts'; DefaultColumn = 'nameColumn' }
        user    = @{ Collection = 'accounts'; DefaultColumn = 'nameColumn' }
        account = @{ Collection = 'accounts'; DefaultColumn = 'nameColumn' }
        mailbox = @{ Collection = 'mailboxes'; DefaultColumn = 'primaryAddressColumn' }
    }

    # Legacy types with one fixed column. The identifier is the query column name
    $columnTypes = @{
        ip            = @{ Collection = 'ips'; Column = 'addressColumn' }
        url           = @{ Collection = 'urls'; Column = 'addressColumn' }
        filehash      = @{ Collection = 'files'; Column = 'hash' }
        process       = @{ Collection = 'processes'; Column = 'hash' }
        registrykey   = @{ Collection = 'registryValues'; Column = 'keyColumn' }
        registryvalue = @{ Collection = 'registryValues'; Column = 'valueNameColumn' }
    }

    $legacyIdentifiers = Get-CustomDetectionLegacyIdentifierMap

    # Identifiers that share a prefix describe one entity, so accountSid and accountDomain
    # land in one item while initiatingAccountName starts another
    function Get-IdentifierPrefix {
        param([string]$Identifier)

        foreach ($suffix in @('ObjectId', 'Sid', 'Upn', 'Name', 'Domain', 'Id')) {
            if ($Identifier.Length -gt $suffix.Length -and $Identifier.EndsWith($suffix, [System.StringComparison]::OrdinalIgnoreCase)) {
                return $Identifier.Substring(0, $Identifier.Length - $suffix.Length).ToLowerInvariant()
            }
        }
        return $Identifier.ToLowerInvariant()
    }

    $groupItems = @{}

    function Add-MappingColumn {
        param([System.Collections.Specialized.OrderedDictionary]$Result, [string]$Collection, [string]$Column, [string]$Value, [string]$Group, [switch]$Pair)

        if (-not $Result.Contains($Collection)) {
            $Result[$Collection] = [System.Collections.Generic.List[object]]::new()
        }
        $groupKey = "$Collection|$Group"
        $target = $null
        if ($Pair) {
            # The column joins the first item that does not carry it yet, so the same entries pair the same way whatever their order
            foreach ($item in $Result[$Collection]) {
                if (-not $item.Contains($Column)) {
                    $target = $item
                    break
                }
            }
        } elseif ($groupItems.ContainsKey($groupKey) -and -not $groupItems[$groupKey].Contains($Column)) {
            $target = $groupItems[$groupKey]
        }
        if (-not $target) {
            $target = [ordered]@{}
            $Result[$Collection].Add($target)
            $groupItems[$groupKey] = $target
        }
        $target[$Column] = $Value
    }

    $result = [ordered]@{}

    if ($PSCmdlet.ParameterSetName -eq 'ImpactedEntities') {
        foreach ($entity in @($ImpactedEntities)) {
            $map = ConvertTo-CustomDetectionHashtable -InputObject $entity
            if (-not $map) {
                throw "Each item in impactedEntities must be a mapping with entityType and entityIdentifier. Got '$entity'."
            }
            $entityType = "$($map['entityType'])".Trim()
            $identifier = "$($map['entityIdentifier'])".Trim()
            if (-not $entityType -or -not $identifier) {
                throw 'Each impactedEntities entry needs entityType and entityIdentifier.'
            }

            $columnType = $columnTypes[$entityType.ToLowerInvariant()]
            if ($columnType) {
                $column = $columnType.Column
                if ($column -eq 'hash') {
                    if ($identifier -match 'sha256') {
                        $column = 'sha256Column'
                    } elseif ($identifier -match 'sha1') {
                        $column = 'sha1Column'
                    } else {
                        throw "Identifier '$identifier' for entity type '$entityType' must name a SHA1 or SHA256 column."
                    }
                }
                $columnValue = $identifier.Substring(0, 1).ToUpperInvariant() + $identifier.Substring(1)
                # A registry key and a value name describe one registry value, so they pair into one item. Every other column type is one entity per identifier
                $pair = $columnType.Collection -eq 'registryValues'
                Add-MappingColumn -Result $result -Collection $columnType.Collection -Column $column -Value $columnValue -Group $identifier -Pair:$pair
                continue
            }

            $typeInfo = $legacyTypes[$entityType.ToLowerInvariant()]
            if (-not $typeInfo) {
                throw "Entity type '$entityType' is not supported in impactedEntities. Use entityMappings for this entity."
            }

            $collection = $typeInfo.Collection
            $column = $null
            $canonical = $null
            foreach ($candidateColumn in $legacyIdentifiers[$collection].Keys) {
                $match = $legacyIdentifiers[$collection][$candidateColumn] | Where-Object { $_ -eq $identifier } | Select-Object -First 1
                if ($match) {
                    $column = $candidateColumn
                    $canonical = $match
                    break
                }
            }

            if (-not $column) {
                $validList = ($legacyIdentifiers[$collection].Values | ForEach-Object { $_ }) -join ', '
                if ($SkipIdentifierValidation) {
                    Write-Warning "Identifier '$identifier' for entity type '$entityType' is not in the official documentation. Valid identifiers are: $validList"
                    $column = $typeInfo.DefaultColumn
                    $canonical = $identifier
                } else {
                    throw "Invalid identifier '$identifier' for entity type '$entityType'. Valid identifiers are: $validList"
                }
            } elseif ($canonical -eq 'accountId') {
                Write-Warning "Identifier 'accountId' has no dedicated entity mapping column. Mapped to accounts.nameColumn."
            }

            $columnValue = $canonical.Substring(0, 1).ToUpperInvariant() + $canonical.Substring(1)
            Add-MappingColumn -Result $result -Collection $collection -Column $column -Value $columnValue -Group (Get-IdentifierPrefix -Identifier $canonical)
        }
    } else {
        $mappings = ConvertTo-CustomDetectionHashtable -InputObject $EntityMappings
        foreach ($key in @($mappings.Keys)) {
            $collection = $collectionColumns.Keys | Where-Object { $_ -eq $key } | Select-Object -First 1
            if (-not $collection) {
                $known = $collectionColumns.Keys -join ', '
                if ($SkipIdentifierValidation) {
                    Write-Warning "Unknown entity mapping '$key'. Known mappings are: $known"
                    $collection = $key
                } else {
                    throw "Unknown entity mapping '$key'. Known mappings are: $known"
                }
            }

            $rawItems = ConvertTo-CustomDetectionList -Value $mappings[$key]

            foreach ($rawItem in $rawItems) {
                $item = ConvertTo-CustomDetectionHashtable -InputObject $rawItem
                if (-not $item) {
                    throw "Each item in entity mapping '$collection' must be a mapping of column name to value. Got '$rawItem'."
                }
                $cleanItem = [ordered]@{}
                $columns = Get-CustomDetectionPopulatedEntry -Map $item
                foreach ($columnKey in @($columns.Keys)) {
                    $value = $columns[$columnKey]
                    if ($value -isnot [string]) {
                        throw "Column '$columnKey' of entity mapping '$collection' must be a single column name."
                    }
                    if ($collectionColumns.Contains($collection) -and $columnKey -notin $collectionColumns[$collection]) {
                        $validColumns = $collectionColumns[$collection] -join ', '
                        if ($SkipIdentifierValidation) {
                            Write-Warning "Column '$columnKey' is not documented for '$collection'. Documented columns are: $validColumns"
                        } else {
                            throw "Invalid column '$columnKey' for entity mapping '$collection'. Documented columns are: $validColumns"
                        }
                    }
                    $cleanItem[$columnKey] = $value
                }
                if ($cleanItem.Count -eq 0) { continue }
                if (-not $result.Contains($collection)) {
                    $result[$collection] = [System.Collections.Generic.List[object]]::new()
                }
                $result[$collection].Add($cleanItem)
            }
        }
    }

    # The API rejects an account mapping that has no key column and no name plus domain pair.
    # Legacy input is dropped with a warning, which is what the API did with impactedAssets.
    # Explicit input is an error unless validation is skipped.
    if ($result.Contains('accounts')) {
        $dropIncomplete = ($PSCmdlet.ParameterSetName -eq 'ImpactedEntities') -or $SkipIdentifierValidation
        $complete = [System.Collections.Generic.List[object]]::new()
        foreach ($item in $result['accounts']) {
            $hasKeyColumn = $item.Contains('aadUserIdColumn') -or $item.Contains('sidColumn') -or $item.Contains('upnColumn')
            $hasNameAndDomain = $item.Contains('nameColumn') -and ($item.Contains('ntDomainColumn') -or $item.Contains('dnsDomainColumn') -or $item.Contains('upnSuffixColumn'))
            if ($hasKeyColumn -or $hasNameAndDomain) {
                $complete.Add($item)
                continue
            }
            $columns = ($item.Keys | ForEach-Object { "$_ = $($item[$_])" }) -join ', '
            $message = "Account mapping ($columns) needs aadUserIdColumn, sidColumn, upnColumn, or nameColumn together with a domain column."
            if (-not $dropIncomplete) {
                throw $message
            }
            Write-Warning "$message It is dropped."
        }
        if ($complete.Count -eq 0) {
            $result.Remove('accounts')
        } else {
            $result['accounts'] = $complete
        }
    }

    if ($result.Count -eq 0) {
        return $null
    }

    # Columns follow the documented order, so the same entries give the same body whatever their order in the file
    foreach ($collection in @($result.Keys)) {
        $items = [System.Collections.Generic.List[object]]::new()
        $knownColumns = if ($collectionColumns.Contains($collection)) { $collectionColumns[$collection] } else { @() }
        foreach ($item in $result[$collection]) {
            $items.Add((ConvertTo-CustomDetectionOrderedMap -Map $item -Order $knownColumns))
        }
        $result[$collection] = [object[]]$items.ToArray()
    }

    return $result
}