modules/shared/DnsTwistHelpers.ps1

#Requires -Version 7.4
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

<#
.SYNOPSIS
    Pure helpers for the dnstwist wrapper. Extracted from
    modules/Invoke-DnsTwist.ps1 so unit tests can exercise the real
    implementation (instead of re-declaring it inline and risking drift).
#>


function Test-DnsRecordPresent {
    <#
    .SYNOPSIS
        Robust presence check for a JSON-array DNS record property.
    .DESCRIPTION
        dnstwist sometimes emits dns_a / dns_mx / dns_ns / dns_aaaa as
        JSON null; in PowerShell @($null).Count is 1, so a naive
        @(...).Count -gt 0 check would falsely report the record as
        present and incorrectly bump severity. Filtering out
        null / whitespace-only entries before counting makes the helper
        robust across dnstwist JSON variants. Defined at script scope
        (rather than inside Get-DnsTwistFinding) so it does not pollute
        the script scope on every call and so other helpers can reuse it.
    #>

    param (
        [Parameter(Mandatory)] $Record,
        [Parameter(Mandatory)] [string] $Name
    )
    if (-not $Record.PSObject.Properties[$Name]) { return $false }
    $val = $Record.$Name
    if ($null -eq $val) { return $false }
    $items = @($val) | Where-Object { $null -ne $_ -and -not [string]::IsNullOrWhiteSpace([string]$_) }
    return @($items).Count -gt 0
}

function Get-DnsTwistFinding {
    <#
    .SYNOPSIS
        Convert a single dnstwist record (already parsed from JSON) into a
        v1 envelope finding. Pure function, easy to unit-test without dnstwist.
    .DESCRIPTION
        Severity rubric (per docs/design/easm-integration.md s5.5):
          - homoglyph / homograph + DNS-registered: High
            (any DNS record A/AAAA/MX/NS counts as registered; these
            permutations have the highest phishing potential and any
            live DNS makes them weaponisable)
          - any other registered + resolving variant: Medium
          - registered but not resolving: Low
    #>

    param (
        [Parameter(Mandatory)] [object] $Record,
        [Parameter(Mandatory)] [string] $SeedDomain
    )

    $fuzzer = if ($Record.PSObject.Properties['fuzzer']) { [string]$Record.fuzzer } else { '' }
    $domain = if ($Record.PSObject.Properties['domain']) { [string]$Record.domain } else { '' }
    if ([string]::IsNullOrWhiteSpace($domain)) { return $null }

    # Skip the synthetic "original*" record that dnstwist always emits as
    # the first entry. It's the seed domain itself, not a typosquat.
    if ($fuzzer -like 'original*') { return $null }

    # Robustly check for non-empty DNS record arrays. dnstwist sometimes
    # emits dns_a/dns_mx as JSON null; in PowerShell @($null).Count is 1,
    # so we must filter out null/empty entries before counting.
    $hasA    = Test-DnsRecordPresent -Record $Record -Name 'dns_a'
    $hasMx   = Test-DnsRecordPresent -Record $Record -Name 'dns_mx'
    $hasNs   = Test-DnsRecordPresent -Record $Record -Name 'dns_ns'
    $hasAaaa = Test-DnsRecordPresent -Record $Record -Name 'dns_aaaa'

    $registered = $hasA -or $hasMx -or $hasNs -or $hasAaaa
    $severity = if (-not $registered) { 'Low' }
                elseif ($fuzzer -match 'homoglyph|homograph') { 'High' }
                else { 'Medium' }

    $detailParts = [System.Collections.Generic.List[string]]::new()
    $detailParts.Add(("Permutation '{0}' of seed '{1}' is registered." -f $fuzzer, $SeedDomain)) | Out-Null
    if ($hasA)    { $detailParts.Add("A: $((@($Record.dns_a) -join ', '))") | Out-Null }
    if ($hasMx)   { $detailParts.Add("MX: $((@($Record.dns_mx) -join ', '))") | Out-Null }
    if ($hasNs)   { $detailParts.Add("NS: $((@($Record.dns_ns) -join ', '))") | Out-Null }

    return [PSCustomObject]@{
        Id           = "dnstwist:${SeedDomain}:${fuzzer}:${domain}"
        RuleId       = "dnstwist-$fuzzer"
        Title        = "Possible typosquat: $domain (variant of $SeedDomain)"
        Category     = 'External Attack Surface'
        Severity     = $severity
        Compliant    = $false
        Detail       = ($detailParts -join ' ')
        Remediation  = 'Investigate ownership; consider defensive registration or takedown if malicious.'
        ResourceId   = $domain
        Pillar       = 'Exposure'
        Impact       = if ($severity -eq 'High') { 'High' } else { 'Medium' }
        Effort       = 'Medium'
        DeepLinkUrl  = "https://dnstwist.it/?domain=$SeedDomain"
        SeedDomain   = $SeedDomain
        Permutation  = $domain
        Fuzzer       = $fuzzer
    }
}