modules/normalizers/Normalize-DnsTwist.ps1

#Requires -Version 7.4
<#
.SYNOPSIS
    Normalizer for dnstwist (typosquat / homoglyph) findings.
.DESCRIPTION
    Converts the v1 wrapper envelope from Invoke-DnsTwist.ps1 into v2.2
    FindingRow objects via New-FindingRow.

    Each finding maps to EntityType=ExternalAsset / Platform=External by
    default; if the orchestrator passes -EntityIndex (built from the
    current EntityStore via Get-EasmEntityIndex), the normalizer routes
    the finding to the matching AzureResource when one exists.

    Domain=ExternalAttackSurface, Pillar=Exposure.
#>

[CmdletBinding()]
param ()

. "$PSScriptRoot\..\shared\Schema.ps1"
. "$PSScriptRoot\..\shared\Canonicalize.ps1"
. "$PSScriptRoot\..\shared\EasmCorrelator.ps1"

function Get-PropertyValue {
    param ([object]$Obj, [string]$Name, [object]$Default = $null)
    if ($null -eq $Obj) { return $Default }
    if (-not $Obj.PSObject.Properties[$Name]) { return $Default }
    $v = $Obj.PSObject.Properties[$Name].Value
    if ($null -eq $v) { return $Default }
    return $v
}

function Normalize-DnsTwist {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [PSCustomObject] $ToolResult,

        # Optional: pre-built entity lookup index from
        # Get-EasmEntityIndex. When supplied, findings whose permutation
        # matches an existing AzureResource hostname are anchored there.
        # When omitted, every finding is treated as ExternalAsset.
        [hashtable] $EntityIndex
    )

    if ($ToolResult.Status -ne 'Success' -or -not $ToolResult.Findings) {
        return @()
    }

    $runId = [guid]::NewGuid().ToString()
    $useIndex = $null -ne $EntityIndex
    $toolVersion = [string](Get-PropertyValue -Obj $ToolResult -Name 'ToolVersion' -Default '')

    $rows = [System.Collections.Generic.List[PSCustomObject]]::new()

    foreach ($f in $ToolResult.Findings) {
        $findingId  = [string](Get-PropertyValue -Obj $f -Name 'Id'       -Default ([guid]::NewGuid().ToString()))
        $title      = [string](Get-PropertyValue -Obj $f -Name 'Title'    -Default 'Possible typosquat')
        $category   = [string](Get-PropertyValue -Obj $f -Name 'Category' -Default 'External Attack Surface')
        $detail     = [string](Get-PropertyValue -Obj $f -Name 'Detail'   -Default '')
        $remed      = [string](Get-PropertyValue -Obj $f -Name 'Remediation' -Default '')
        $resourceId = [string](Get-PropertyValue -Obj $f -Name 'ResourceId'  -Default '')
        $deepLink   = [string](Get-PropertyValue -Obj $f -Name 'DeepLinkUrl' -Default '')
        $ruleId     = [string](Get-PropertyValue -Obj $f -Name 'RuleId'      -Default '')
        $impact     = [string](Get-PropertyValue -Obj $f -Name 'Impact'      -Default 'Medium')
        $effort     = [string](Get-PropertyValue -Obj $f -Name 'Effort'      -Default 'Medium')
        $pillar     = [string](Get-PropertyValue -Obj $f -Name 'Pillar'      -Default 'Exposure')
        $perm       = [string](Get-PropertyValue -Obj $f -Name 'Permutation' -Default $resourceId)
        $seedDomain = [string](Get-PropertyValue -Obj $f -Name 'SeedDomain'  -Default '')

        $rawSev = [string](Get-PropertyValue -Obj $f -Name 'Severity' -Default 'Medium')
        $severity = switch -Regex ($rawSev.ToString().ToLowerInvariant()) {
            'critical'        { 'Critical' }
            'high'            { 'High' }
            'medium|moderate' { 'Medium' }
            'low'             { 'Low' }
            'info'            { 'Info' }
            default           { 'Medium' }
        }

        # Entity resolution. Try to anchor the typosquat permutation to
        # an Azure-owned resource (rare; would mean we own a homoglyph
        # variant of our own brand and exposed it). Far more commonly,
        # the permutation is registered by a third party and falls
        # through to ExternalAsset.
        $entityRef = if ($useIndex -and $perm) {
            Resolve-EasmEntity -Index $EntityIndex -HostName $perm
        } else {
            [PSCustomObject]@{
                EntityId   = if ($perm) { "host:$($perm.ToLowerInvariant())" } else { 'external:unknown' }
                EntityType = 'ExternalAsset'
                Platform   = 'External'
                Confidence = 'Unconfirmed'
                MatchedOn  = 'none'
            }
        }

        # Canonicalize the resolved EntityId so we don't leak casing /
        # trailing-dot variants into the entity store. New-FindingRow
        # itself does not canonicalize, so we must do it here.
        try {
            $canon = ConvertTo-CanonicalEntityId -RawId $entityRef.EntityId -EntityType $entityRef.EntityType
            $entityRef = [PSCustomObject]@{
                EntityId   = $canon.CanonicalId
                EntityType = $canon.EntityType
                Platform   = $canon.Platform
                Confidence = $entityRef.Confidence
                MatchedOn  = $entityRef.MatchedOn
            }
        } catch {
            # If canonicalization rejects the ID (e.g. malformed AzureResource
            # ARM ID from a misconfigured index), fall back to ExternalAsset
            # rather than dropping the finding.
            $fallbackId = if ($perm) { "host:$($perm.ToLowerInvariant().TrimEnd('.'))" } else { 'external:unknown' }
            $entityRef = [PSCustomObject]@{
                EntityId   = $fallbackId
                EntityType = 'ExternalAsset'
                Platform   = 'External'
                Confidence = 'Unconfirmed'
                MatchedOn  = 'fallback'
            }
        }

        $row = New-FindingRow -Id $findingId `
            -Source 'dnstwist' `
            -EntityId $entityRef.EntityId `
            -EntityType $entityRef.EntityType `
            -Platform $entityRef.Platform `
            -Title $title `
            -Compliant $false `
            -ProvenanceRunId $runId `
            -Category $category `
            -Severity $severity `
            -Detail $detail `
            -Remediation $remed `
            -ResourceId $resourceId `
            -RuleId $ruleId `
            -Pillar $pillar `
            -Impact $impact `
            -Effort $effort `
            -DeepLinkUrl $deepLink `
            -Confidence $entityRef.Confidence `
            -BaselineTags @("dnstwist:fuzzer:$([string](Get-PropertyValue -Obj $f -Name 'Fuzzer' -Default ''))", "dnstwist:seed:$seedDomain") `
            -ToolVersion $toolVersion

        if ($null -ne $row) { $rows.Add($row) | Out-Null }
    }

    return @($rows)
}