public/cisa/exchange/Resolve-SPFRecord.ps1

function Resolve-SPFRecord {
    <#
    .SYNOPSIS
        Returns a list of all IP addresses from an SPF record.

    .DESCRIPTION
        A function to resolve and parse SPF records for a given domain name.

        Supported SPF directives and functions include:
        - mx
        - a
        - ip4 und ip6
        - redirect
        - Warning for too many include entries

        Not supported:
        - explanation
        - macros

    .PARAMETER Name
        The domain name to resolve the SPF record for.

    .PARAMETER Server
        The DNS server to use for the query. If not specified, the system's default DNS server will be used.

    .EXAMPLE
        Resolve-SPFRecord microsoft.com

        Resolves the SPF record for the domain "microsoft.com" using the default DNS server.

    .EXAMPLE
        Resolve-SPFRecord -Name microsoft.com -Server 1.1.1.1

        Resolves the SPF record for the domain "microsoft.com" using the specified DNS server.

    .LINK
        https://maester.dev/docs/commands/Resolve-SPFRecord

    .LINK
        https://cloudbrothers.info/en/powershell-tip-resolve-spf/
    #>


    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Colors are beautiful')]
    [OutputType([SPFRecord[]], [System.String])]
    [CmdletBinding()]
    param (
        # Domain name to resolve SPF record for.
        [Parameter(Mandatory = $true,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true,
            Position = 1)]
        [ValidateNotNullOrEmpty()]
        [string]$Name,

        # DNS server to use for the query.
        [Parameter(Mandatory = $false,
            ValueFromPipelineByPropertyName = $true,
            Position = 2)]
        [ValidateNotNullOrEmpty()]
        [string]$Server,

        # Provide a referrer to build valid objects during recursive calls of the function.
        [Parameter(Mandatory = $false, DontShow = $true)]
        [string]$Referrer,

        # Track visited domains to prevent circular references during recursive calls of the function.
        [Parameter(Mandatory = $false, DontShow = $true)]
        [string[]]$Visited = @()
    )

    begin {
        class SPFRecord {
            [string] $SPFSourceDomain
            [string] $IPAddress
            [string] $Referrer
            [string] $Qualifier
            [bool] $Include

            # Constructor: Creates a new SPFRecord object, with a specified IPAddress
            SPFRecord ([string] $IPAddress) {
                $this.IPAddress = $IPAddress
            }

            # Constructor: Creates a new SPFRecord object, with a specified IPAddress and DNSName
            SPFRecord ([string] $IPAddress, [String] $DNSName) {
                $this.IPAddress = $IPAddress
                $this.SPFSourceDomain = $DNSName
            }

            # Constructor: Creates a new SPFRecord object, with a specified IPAddress and DNSName and
            SPFRecord ([string] $IPAddress, [String] $DNSName, [String] $Qualifier) {
                $this.IPAddress = $IPAddress
                $this.SPFSourceDomain = $DNSName
                $this.Qualifier = $Qualifier
            }
        }
    }

    process {
        # Add current domain to visited list for circular reference detection
        $Visited = $Visited + $Name

        # Keep track of number of DNS queries
        # DNS Lookup Limit = 10
        # https://tools.ietf.org/html/rfc7208#section-4.6.4
        # Query DNS Record
        try {
            if ($IsWindows -or $PSVersionTable.PSEdition -eq 'Desktop') {
                if ($Server) {
                    $DNSRecords = Resolve-DnsName -Server $Server -Name $Name -Type TXT -ErrorAction Stop
                } else {
                    $DNSRecords = Resolve-DnsName -Name $Name -Type TXT -ErrorAction Stop
                }
            } else {
                $cmdletCheck = Get-Command 'Resolve-Dns' -ErrorAction SilentlyContinue
                if ($cmdletCheck) {
                    $dnsParams = @{
                        Query     = $Name
                        QueryType = 'TXT'
                    }
                    if ($Server) {
                        $dnsParams['NameServer'] = $Server
                    }
                    $answers = (Resolve-Dns @dnsParams).Answers
                    $DNSRecords = $answers | ForEach-Object {
                        [PSCustomObject]@{
                            Name    = $_.DomainName
                            Type    = $_.RecordType
                            TTL     = $_.TimeToLive
                            Strings = $_.Text
                        }
                    }
                } else {
                    Write-Error 'For non-Windows platforms, please install DnsClient-PS module: Install-Module DnsClient-PS -Scope CurrentUser'
                    return
                }
            }
        } catch [System.Management.Automation.CommandNotFoundException] {
            Write-Error "Unsupported platform: $_"
            return
        } catch {
            Write-Error "Failed to obtain DNS record for ${Name}: $_"
            return
        }
        # Check SPF record
        $SPFRecord = $DNSRecords | Where-Object { $_.Strings -match '^v=spf1' }
        # Validate SPF record
        $SPFCount = ($SPFRecord | Measure-Object).Count

        if ( $SPFCount -eq 0) {
            # If there is no error show an error
            Write-Verbose "No SPF record found for `"$Name`""
        } elseif ( $SPFCount -ge 2 ) {
            # Multiple DNS Records are not allowed
            # https://tools.ietf.org/html/rfc7208#section-3.2
            Write-Verbose "There is more than one SPF for domain `"$Name`""
        } else {
            # Multiple Strings in a Single DNS Record
            # https://tools.ietf.org/html/rfc7208#section-3.3
            $SPFString = $SPFRecord.Strings -join ''
            # Split the directives at the whitespace
            $SPFDirectives = $SPFString -split ' '

            # Check for a redirect
            if ( $SPFDirectives -match 'redirect' ) {
                $RedirectRecord = $SPFDirectives -match 'redirect' -replace 'redirect='
                Write-Verbose "[REDIRECT]`t$RedirectRecord"
                # Follow the redirect and resolve the redirect
                # Check for circular SPF references to prevent infinite loops
                if ( $RedirectRecord -eq $Name ) {
                    Write-Warning "Self-referencing SPF redirect detected for $Name"
                    return
                } elseif ( $RedirectRecord -notin $Visited ) {
                    if ($Server) {
                        Resolve-SPFRecord -Name "$RedirectRecord" -Server $Server -Referrer $Name -Visited $Visited
                    } else {
                        Resolve-SPFRecord -Name "$RedirectRecord" -Referrer $Name -Visited $Visited
                    }
                } else {
                    Write-Warning "Circular SPF reference detected: $Name -> $RedirectRecord"
                    return
                }
            } else {

                # Extract the qualifier
                $Qualifier = switch ( $SPFDirectives -match '^[+-?~]all$' -replace 'all' ) {
                    '+' { 'pass' }
                    '-' { 'fail' }
                    '~' { 'softfail' }
                    '?' { 'neutral' }
                }

                $ReturnValues = foreach ($SPFDirective in $SPFDirectives) {
                    switch -Regex ($SPFDirective) {
                        '%[{%-_]' {
                            Write-Verbose "[$_]`tMacros are not supported. For more information, see https://tools.ietf.org/html/rfc7208#section-7"
                            continue
                        }
                        '^exp:.*$' {
                            Write-Verbose "[$_]`tExplanation is not supported. For more information, see https://tools.ietf.org/html/rfc7208#section-6.2"
                            continue
                        }
                        '^include:.*$' {
                            # Follow the include and resolve the include
                            $IncludeTarget = ( $SPFDirective -replace '^include:' )
                            # Check for circular SPF references to prevent infinite loops
                            if ( $IncludeTarget -eq $Name ) {
                                Write-Warning "Self-referencing SPF include detected for $Name"
                                continue
                            } elseif ( $IncludeTarget -notin $Visited ) {
                                if ($Server) {
                                    Resolve-SPFRecord -Name $IncludeTarget -Server $Server -Referrer $Name -Visited $Visited
                                } else {
                                    Resolve-SPFRecord -Name $IncludeTarget -Referrer $Name -Visited $Visited
                                }
                            } else {
                                Write-Warning "Circular SPF reference detected: $Name includes $IncludeTarget"
                                continue
                            }
                        }
                        '^ip[46]:.*$' {
                            Write-Verbose "[IP]`tSPF entry: $SPFDirective"
                            $SPFObject = [SPFRecord]::New( ($SPFDirective -replace '^ip[46]:'), $Name, $Qualifier)
                            if ( $PSBoundParameters.ContainsKey('Referrer') ) {
                                $SPFObject.Referrer = $Referrer
                                $SPFObject.Include = $true
                            }
                            $SPFObject
                        }
                        '^a:.*$' {
                            Write-Verbose "[A]`tSPF entry: $SPFDirective"
                            # Extract the domain from the directive (e.g., "a:sub.example.com" -> "sub.example.com")
                            $aDomain = $SPFDirective -replace '^a:'
                            if ([string]::IsNullOrEmpty($aDomain)) {
                                $aDomain = $Name  # If no domain specified, use current domain
                            }

                            if ( $IsWindows -or $PSVersionTable.PSEdition -eq 'Desktop' ) {
                                if ($Server) {
                                    $DNSRecords = Resolve-DnsName -Server $Server -Name $aDomain -Type A -ErrorAction SilentlyContinue
                                } else {
                                    $DNSRecords = Resolve-DnsName -Name $aDomain -Type A -ErrorAction SilentlyContinue
                                }
                            } else {
                                $dnsParams = @{
                                    Query     = $aDomain
                                    QueryType = 'A'
                                }
                                if ($Server) {
                                    $dnsParams['NameServer'] = $Server
                                }
                                $answers = (Resolve-Dns @dnsParams -ErrorAction SilentlyContinue).Answers
                                $DNSRecords = $answers | ForEach-Object {
                                    [PSCustomObject]@{
                                        Name       = $_.DomainName
                                        Type       = $_.RecordType
                                        TTL        = $_.TimeToLive
                                        DataLength = $_.RawDataLength
                                        Section    = 'Answer'
                                        IPAddress  = $_.Address
                                    }
                                }
                            }
                            # Check SPF record
                            foreach ($IPAddress in ($DNSRecords.IPAddress) ) {
                                $SPFObject = [SPFRecord]::New( $IPAddress, $aDomain, $Qualifier)
                                if ( $PSBoundParameters.ContainsKey('Referrer') ) {
                                    $SPFObject.Referrer = $Referrer
                                    $SPFObject.Include = $true
                                }
                                $SPFObject
                            }
                        }
                        '^mx:.*$' {
                            Write-Verbose "[MX]`tSPF entry: $SPFDirective"
                            # Extract the domain from the directive (e.g., "mx:mail.example.com" -> "mail.example.com")
                            $mxDomain = $SPFDirective -replace '^mx:'
                            if ([string]::IsNullOrEmpty($mxDomain)) {
                                $mxDomain = $Name  # If no domain specified, use current domain
                            }

                            if ( $IsWindows -or $PSVersionTable.PSEdition -eq 'Desktop' ) {
                                if ($Server) {
                                    $MXDNSRecords = Resolve-DnsName -Server $Server -Name $mxDomain -Type MX -ErrorAction SilentlyContinue
                                } else {
                                    $MXDNSRecords = Resolve-DnsName -Name $mxDomain -Type MX -ErrorAction SilentlyContinue
                                }
                            } else {
                                $dnsParams = @{
                                    Query     = $mxDomain
                                    QueryType = 'MX'
                                }
                                if ($Server) {
                                    $dnsParams['NameServer'] = $Server
                                }
                                $answers = (Resolve-Dns @dnsParams -ErrorAction SilentlyContinue).Answers
                                $MXDNSRecords = $answers | ForEach-Object {
                                    [PSCustomObject]@{
                                        Name         = $_.DomainName
                                        Type         = $_.RecordType
                                        TTL          = $_.TimeToLive
                                        NameExchange = $_.Exchange
                                        Preference   = $_.Preference
                                    }
                                }
                            }
                            foreach ($MXRecord in ($MXDNSRecords.NameExchange) ) {
                                # Resolve A records for each MX host
                                if ( $IsWindows -or $PSVersionTable.PSEdition -eq 'Desktop' ) {
                                    if ($Server) {
                                        $ADNSRecords = Resolve-DnsName -Server $Server -Name $MXRecord -Type A -ErrorAction SilentlyContinue
                                    } else {
                                        $ADNSRecords = Resolve-DnsName -Name $MXRecord -Type A -ErrorAction SilentlyContinue
                                    }
                                } else {
                                    $dnsParams = @{
                                        Query     = $MXRecord
                                        QueryType = 'A'
                                    }
                                    if ($Server) {
                                        $dnsParams['NameServer'] = $Server
                                    }
                                    $answers = (Resolve-Dns @dnsParams -ErrorAction SilentlyContinue).Answers
                                    $ADNSRecords = $answers | ForEach-Object {
                                        [PSCustomObject]@{
                                            Name       = $_.DomainName
                                            Type       = $_.RecordType
                                            TTL        = $_.TimeToLive
                                            DataLength = $_.RawDataLength
                                            Section    = 'Answer'
                                            IPAddress  = $_.Address
                                        }
                                    }
                                }
                                foreach ($IPAddress in ($ADNSRecords.IPAddress) ) {
                                    $SPFObject = [SPFRecord]::New( $IPAddress, $mxDomain, $Qualifier)
                                    if ( $PSBoundParameters.ContainsKey('Referrer') ) {
                                        $SPFObject.Referrer = $Referrer
                                        $SPFObject.Include = $true
                                    }
                                    $SPFObject
                                }
                            }
                        }
                        default {
                            Write-Verbose "[$_]`t Unknown directive"
                        }
                    }
                }

                $DNSQuerySum = $ReturnValues.Referrer + $ReturnValues.SPFSourceDomain | Select-Object -Unique | Where-Object { $_ -ne $Name } | Measure-Object | Select-Object -ExpandProperty Count
                if ( $DNSQuerySum -gt 6) {
                    Write-Verbose "Watch your includes!`nThe maximum number of DNS queries is 10 and you have already $DNSQuerySum.`nCheck https://tools.ietf.org/html/rfc7208#section-4.6.4"
                }
                if ( $DNSQuerySum -gt 10) {
                    Write-Verbose "Too many DNS queries made ($DNSQuerySum).`nMust not exceed 10 DNS queries.`nCheck https://tools.ietf.org/html/rfc7208#section-4.6.4"
                }

                return $ReturnValues
            }
        }
    }
}