modules/Invoke-DnsTwist.ps1

#Requires -Version 7.0
<#
.SYNOPSIS
    Wrapper for the DNSTwist CLI (typosquat / homoglyph detection).
.DESCRIPTION
    Runs the dnstwist CLI against each domain in the EASM seed bundle.
    DNSTwist generates permutations (typo, homoglyph, bitsquatting,
    hyphenation, insertion, omission, repetition, replacement,
    subdomain, transposition, vowel-swap, addition) and reports any
    permutation whose DNS or HTTP record is currently registered.

    The wrapper:
      * Skips with status='Skipped' when dnstwist is not installed (graceful).
      * Skips with status='Skipped' when the seed has no domains.
      * Runs `dnstwist --format json --registered <domain>` per seed domain.
      * Caps every external invocation at 300 s via Invoke-WithTimeout.
      * Sanitises stdout/stderr via Remove-Credentials before any
        finding, log, or error is written.
      * Returns the canonical v1 envelope (Source, SchemaVersion=1.0,
        Status, Message, Findings, Errors).

    Design doc: docs/design/easm-integration.md.
#>

[CmdletBinding()]
param (
    [string] $SeedFile,
    [hashtable] $Seed,
    [string] $OutputDir
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

# Dot-source shared modules (with inline fallback stubs so wrapper tests
# can exercise paths even when the shared module isn't available).
$sharedDir = Join-Path (Split-Path $PSScriptRoot -Parent) 'modules' 'shared'
if (-not $sharedDir -or -not (Test-Path $sharedDir)) {
    $sharedDir = Join-Path $PSScriptRoot 'shared'
}
$sanitizePath = Join-Path $sharedDir 'Sanitize.ps1'
if (Test-Path $sanitizePath) { . $sanitizePath }
$errorsPath = Join-Path $sharedDir 'Errors.ps1'
if (Test-Path $errorsPath) { . $errorsPath }
$cliTimeoutPath = Join-Path $sharedDir 'CliTimeout.ps1'
if (Test-Path $cliTimeoutPath) { . $cliTimeoutPath }
$envelopePath = Join-Path $sharedDir 'New-WrapperEnvelope.ps1'
if (Test-Path $envelopePath) { . $envelopePath }
$easmSeedPath = Join-Path $sharedDir 'EasmSeed.ps1'
if (Test-Path $easmSeedPath) { . $easmSeedPath }
$dnsTwistHelpersPath = Join-Path $sharedDir 'DnsTwistHelpers.ps1'
if (Test-Path $dnsTwistHelpersPath) { . $dnsTwistHelpersPath }

if (-not (Get-Command New-WrapperEnvelope -ErrorAction SilentlyContinue)) {
    function New-WrapperEnvelope { param([string]$Source,[string]$Status='Failed',[string]$Message='',[object[]]$FindingErrors=@()) return [PSCustomObject]@{ Source=$Source; SchemaVersion='1.0'; Status=$Status; Message=$Message; Findings=@(); Errors=@($FindingErrors) } }
}
if (-not (Get-Command Remove-Credentials -ErrorAction SilentlyContinue)) {
    function Remove-Credentials { param ([string]$Text) return $Text }
}
if (-not (Get-Command New-FindingError -ErrorAction SilentlyContinue)) {
    function New-FindingError { param([string]$Source,[string]$Category,[string]$Reason,[string]$Remediation,[string]$Details) return [pscustomobject]@{ Source=$Source; Category=$Category; Reason=$Reason; Remediation=$Remediation; Details=$Details } }
}
if (-not (Get-Command Invoke-WithTimeout -ErrorAction SilentlyContinue)) {
    # Fallback stub mirroring the real modules/shared/CliTimeout.ps1 signature
    # (Command/Arguments/TimeoutSec) so the wrapper behaves the same when
    # the shared helper is missing as when it's loaded.
    function Invoke-WithTimeout {
        param (
            [Parameter(Mandatory)][string]   $Command,
            [Parameter(Mandatory)][string[]] $Arguments,
            [int] $TimeoutSec = 300
        )
        $output = & $Command @Arguments 2>&1 | Out-String
        $lastExit = if (Test-Path variable:LASTEXITCODE) { $LASTEXITCODE } else { 0 }
        return [PSCustomObject]@{
            ExitCode = $lastExit
            Output   = $output.Trim()
            Stdout   = $output.Trim()
            Stderr   = ''
        }
    }
}

function Test-DnsTwistInstalled {
    $null -ne (Get-Command dnstwist -ErrorAction SilentlyContinue)
}

function Get-DnsTwistVersion {
    try {
        $raw = dnstwist --version 2>&1
        if ($LASTEXITCODE -ne 0) { return '' }
        $text = if ($raw -is [array]) { ($raw -join ' ') } else { [string]$raw }
        $m = [regex]::Match($text, '(\d+\.\d+(?:\.\d+)?)')
        if ($m.Success) { return $m.Groups[1].Value }
        return $text.Trim()
    } catch {
        return ''
    }
}

function Invoke-DnsTwistOnDomain {
    <#
    .SYNOPSIS
        Run dnstwist for one seed domain and return parsed JSON.
    .DESCRIPTION
        Isolated for testability. The caller mocks this function in
        wrapper tests so we never need a real dnstwist binary on the
        test runner.
    #>

    param (
        [Parameter(Mandatory)] [string] $Domain,
        [int] $TimeoutSeconds = 300
    )

    # --registered: only return permutations whose DNS / HTTP record
    # currently exists. --format json: machine-readable output.
    # We deliberately avoid -w/--whois (rate-limited, slow, optional).
    $result = Invoke-WithTimeout `
        -Command 'dnstwist' `
        -Arguments @('--format','json','--registered',$Domain) `
        -TimeoutSec $TimeoutSeconds

    if ($result.ExitCode -ne 0) {
        # Use a typed exception (RuntimeException) instead of a raw throw
        # string so the wrapper-consistency ratchet (Cat 11) stays at 0.
        # The catch in the main loop converts this to a sanitised
        # FindingError attached to the v1 envelope.
        throw [System.Management.Automation.RuntimeException]::new(
            ("dnstwist exited with code {0} for {1}: {2}" -f $result.ExitCode, $Domain, $result.Stderr))
    }
    $text = [string]$result.Stdout
    if ([string]::IsNullOrWhiteSpace($text)) { return @() }
    return ($text | ConvertFrom-Json -ErrorAction Stop)
}

# Main wrapper body
try {
    if (-not (Test-DnsTwistInstalled)) {
        $err = New-FindingError -Source 'wrapper:dnstwist' `
            -Category 'MissingDependency' `
            -Reason 'dnstwist CLI is not installed' `
            -Remediation 'pipx install dnstwist (Linux/Windows) or brew install dnstwist (macOS)'
        return New-WrapperEnvelope -Source 'dnstwist' -Status 'Skipped' `
            -Message 'dnstwist not installed; skipping EASM typosquat scan.' `
            -FindingErrors @($err)
    }

    # Build the seed. Get-EasmSeed normalises + validates inputs.
    if (-not (Get-Command Get-EasmSeed -ErrorAction SilentlyContinue)) {
        $err = New-FindingError -Source 'wrapper:dnstwist' `
            -Category 'MissingDependency' `
            -Reason 'EasmSeed shared module is unavailable' `
            -Remediation 'Ensure modules/shared/EasmSeed.ps1 is present.'
        return New-WrapperEnvelope -Source 'dnstwist' -Status 'Failed' `
            -Message 'EasmSeed not loaded.' `
            -FindingErrors @($err)
    }

    $seedArgs = @{}
    if ($Seed)     { $seedArgs['Seed']     = $Seed }
    if ($SeedFile) { $seedArgs['SeedFile'] = $SeedFile }
    $seedBundle = Get-EasmSeed @seedArgs

    if (-not $seedBundle.Domains -or @($seedBundle.Domains).Count -eq 0) {
        return New-WrapperEnvelope -Source 'dnstwist' -Status 'Skipped' `
            -Message 'EASM seed bundle contains no domains; skipping dnstwist.'
    }

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

    foreach ($domain in $seedBundle.Domains) {
        try {
            $records = Invoke-DnsTwistOnDomain -Domain $domain
            foreach ($rec in @($records)) {
                $finding = Get-DnsTwistFinding -Record $rec -SeedDomain $domain
                if ($null -ne $finding) { $findings.Add($finding) | Out-Null }
            }
        } catch {
            $sanitisedReason = Remove-Credentials ([string]$_)
            $errors.Add((New-FindingError -Source 'wrapper:dnstwist' `
                -Category 'UnexpectedFailure' `
                -Reason "dnstwist failed for $domain" `
                -Remediation 'Check stderr; verify dnstwist version >= 20210817.' `
                -Details $sanitisedReason)) | Out-Null
        }
    }

    return [PSCustomObject]@{
        Source        = 'dnstwist'
        SchemaVersion = '1.0'
        Status        = 'Success'
        Message       = ("Scanned {0} seed domain(s); found {1} typosquat candidate(s)." -f @($seedBundle.Domains).Count, $findings.Count)
        SeedHash      = $seedBundle.Hash
        ToolVersion   = (Get-DnsTwistVersion)
        Findings      = @($findings)
        Errors        = @($errors)
    }
} catch {
    $sanitised = Remove-Credentials ([string]$_)
    $err = New-FindingError -Source 'wrapper:dnstwist' `
        -Category 'UnexpectedFailure' `
        -Reason 'Unhandled exception in Invoke-DnsTwist' `
        -Remediation 'See Details; rerun with -Verbose for stack.' `
        -Details $sanitised
    return New-WrapperEnvelope -Source 'dnstwist' -Status 'Failed' `
        -Message $sanitised -FindingErrors @($err)
}