Modules/Providers/ExportEXOProvider.psm1

function Export-EXOProvider {
    <#
    .Description
    Gets the Exchange Online (EXO) settings that are relevant
    to the SCuBA EXO baselines using direct REST API calls.
    .Functionality
    Internal
    #>


    [CmdletBinding()]
    [OutputType([String])]
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [string[]]
        $PreferredDnsResolvers,

        [Parameter(Mandatory = $true)]
        [ValidateSet($true, $false)]
        [boolean]
        $SkipDoH,

        [Parameter(Mandatory = $true)]
        [string]
        $AccessToken,

        [Parameter(Mandatory = $true)]
        [string]
        $ApiEndpoint
    )

    $HelperFolderPath = Join-Path -Path $PSScriptRoot -ChildPath "ProviderHelpers"
    Import-Module (Join-Path -Path $HelperFolderPath -ChildPath "CommandTracker.psm1")
    Import-Module (Join-Path -Path $HelperFolderPath -ChildPath "EXORestHelper.psm1")
    $Tracker = Get-CommandTracker

    <#
    MS.EXO.1.1v2
    #>

    $RemoteDomains = ConvertTo-Json @($Tracker.TryCommand("Invoke-EXORestMethod", @{CmdletName = "Get-RemoteDomain"; ApiEndpoint = $ApiEndpoint; AccessToken = $AccessToken}))

    <#
    MS.EXO.2.2v3 SPF
    #>

    $AcceptedDomains = $Tracker.TryCommand("Invoke-EXORestMethod", @{CmdletName = "Get-AcceptedDomain"; ApiEndpoint = $ApiEndpoint; AccessToken = $AccessToken})
    $SPFRecords = ConvertTo-Json @($Tracker.TryCommand("Get-ScubaSpfRecord", @{
        "Domains"=$AcceptedDomains;
        "PreferredDnsResolvers"=$PreferredDnsResolvers;
        "SkipDoH"=$SkipDoH;
    })) -Depth 4

    <#
    MS.EXO.3.1v1 DKIM
    #>

    $DKIMConfig = ConvertTo-Json @($Tracker.TryCommand("Invoke-EXORestMethod", @{CmdletName = "Get-DkimSigningConfig"; ApiEndpoint = $ApiEndpoint; AccessToken = $AccessToken}))
    $DKIMRecords = ConvertTo-Json @($Tracker.TryCommand("Get-ScubaDkimRecord", @{
        "Domains"=$AcceptedDomains;
        "PreferredDnsResolvers"=$PreferredDnsResolvers;
        "SkipDoH"=$SkipDoH;
    })) -Depth 4

    <#
    MS.EXO.4.1v1 DMARC
    #>

    $DMARCRecords = ConvertTo-Json @($Tracker.TryCommand("Get-ScubaDmarcRecord", @{
        "Domains"=$AcceptedDomains;
        "PreferredDnsResolvers"=$PreferredDnsResolvers;
        "SkipDoH"=$SkipDoH;
    })) -Depth 4

    <#
    MS.EXO.5.1v1
    #>

    $TransportConfig = ConvertTo-Json @($Tracker.TryCommand("Invoke-EXORestMethod", @{CmdletName = "Get-TransportConfig"; ApiEndpoint = $ApiEndpoint; AccessToken = $AccessToken}))

    <#
    MS.EXO.6.1v1
    #>

    $SharingPolicy = ConvertTo-Json @($Tracker.TryCommand("Invoke-EXORestMethod", @{CmdletName = "Get-SharingPolicy"; ApiEndpoint = $ApiEndpoint; AccessToken = $AccessToken}))

    <#
    MS.EXO.7.1v1
    #>

    $TransportRules = ConvertTo-Json @($Tracker.TryCommand("Invoke-EXORestMethod", @{CmdletName = "Get-TransportRule"; ApiEndpoint = $ApiEndpoint; AccessToken = $AccessToken}))

    <#
    MS.EXO.13.1v1
    #>

    $OrgConfigProperties = @(
        "Name",
        "DisplayName",
        "AuditDisabled",
        "HybridConfigurationStatus",
        "OAuth2ClientProfileEnabled",
        "DefaultAuthenticationPolicy",
        "SendFromAliasEnabled",
        "ConnectorsEnabled",
        "ConnectorsEnabledForTeams",
        "CustomerLockboxEnabled",
        "SmtpActionableMessagesEnabled",
        "ActivityBasedAuthenticationTimeoutEnabled",
        "ActivityBasedAuthenticationTimeoutInterval",
        "BookingsEnabled"
    )
    $Config = $Tracker.TryCommand("Invoke-EXORestMethod", @{CmdletName = "Get-OrganizationConfig"; ApiEndpoint = $ApiEndpoint; AccessToken = $AccessToken}) | Select-Object $OrgConfigProperties
    $Config = ConvertTo-Json @($Config)

    # Hybrid / connector configuration
    $InboundConnectors = ConvertTo-Json @($Tracker.TryCommand("Invoke-EXORestMethod", @{CmdletName = "Get-InboundConnector"; ApiEndpoint = $ApiEndpoint; AccessToken = $AccessToken})) -Depth 4
    $OutboundConnectors = ConvertTo-Json @($Tracker.TryCommand("Invoke-EXORestMethod", @{CmdletName = "Get-OutboundConnector"; ApiEndpoint = $ApiEndpoint; AccessToken = $AccessToken})) -Depth 4
    $IntraOrgConnectors = ConvertTo-Json @($Tracker.TryCommand("Invoke-EXORestMethod", @{CmdletName = "Get-IntraOrganizationConnector"; ApiEndpoint = $ApiEndpoint; AccessToken = $AccessToken})) -Depth 4
    $OrgRelationships = ConvertTo-Json @($Tracker.TryCommand("Invoke-EXORestMethod", @{CmdletName = "Get-OrganizationRelationship"; ApiEndpoint = $ApiEndpoint; AccessToken = $AccessToken})) -Depth 4

    # Convert accepted domains to JSON format AFTER its used in Get-ScubaSpfRecord, Get-ScubaDkimRecord, and Get-ScubaDmarcRecord methods.
    # We want to store the accepted domain values to check for the "IsCoexistenceDomain" property.
    $AcceptedDomains = ConvertTo-Json @($AcceptedDomains)

    # Used in the reporter to check successful cmdlet invocation
    $SuccessfulCommands = ConvertTo-Json @($Tracker.GetSuccessfulCommands())
    $UnSuccessfulCommands = ConvertTo-Json @($Tracker.GetUnSuccessfulCommands())

    # Note the spacing and the last comma in the json is important
    $json = @"
    "remote_domains": $RemoteDomains,
    "accepted_domains": $AcceptedDomains,
    "spf_records": $SPFRecords,
    "dkim_config": $DKIMConfig,
    "dkim_records": $DKIMRecords,
    "dmarc_records": $DMARCRecords,
    "transport_config": $TransportConfig,
    "sharing_policy": $SharingPolicy,
    "transport_rule": $TransportRules,
    "org_config": $Config,
    "inbound_connectors": $InboundConnectors,
    "outbound_connectors": $OutboundConnectors,
    "intra_org_connectors": $IntraOrgConnectors,
    "org_relationships": $OrgRelationships,
    "exo_successful_commands": $SuccessfulCommands,
    "exo_unsuccessful_commands": $UnSuccessfulCommands,
"@


    $json
}

# A $script:scoped variable used to indicate the preferred DoH server.
# Initialize to empty string to indicate that we don't yet know the
# preferred server. Will be set when the Select-DohServer function is
# called.
$DohServer = ""

function Select-DohServer {
    <#
    .Description
    Iterates through several DoH servers. Returns the first successful server. If none are successful, returns $null.
    .Functionality
    Internal
    #>

    $DoHServers = @("cloudflare-dns.com", "[2606:4700:4700::1111]", "1.1.1.1")
    $PreferredServer = $null
    foreach ($Server in $DoHServers) {
        try {
            # Attempt to resolve a.root-servers.net over DoH. The domain chosen is somewhat
            # arbitrary, as we don't care what the answer is, only if the query succeeds/fails.
            # a.root-servers.net, the address of one of the DNS root servers, was chosen as a
            # benign, highly-available domain.
            $Uri = "https://$($Server)/dns-query?name=a.root-servers.net"
            Invoke-WebRequest -Headers @{"accept"="application/dns-json"} -Uri $Uri `
                -TimeoutSec 2 -UseBasicParsing -ErrorAction "Stop" | Out-Null
            # No error was thrown, return this server
            $PreferredServer = $Server
            break
        }
        catch {
            # This server didn't work, try the next one
            continue
        }
    }
    $PreferredServer
}

function Invoke-RobustDnsTxt {
    <#
    .Description
    Requests the TXT record for the given qname. First tries to make the query over traditional DNS
    but retries over DoH in the event of failure.
    .Parameter Qname
    The fully-qualified domain name to request.
    .Parameter PreferredDnsResolvers
    IP addresses of DNS resolvers that should be used. If empty, the system default will be used.
    .Parameter SkipDoH
    If true, do not try over DoH if the traditional query fails.
    .Parameter MaxTries
    The number of times to retry each kind of query. If all queries are unsuccessful, the traditional
    queries and the DoH queries will each be made $MaxTries times. Default is 2.
    .Functionality
    Internal
    #>

    [CmdletBinding()]
    [OutputType([System.Collections.Hashtable])]
    param (
        [Parameter(Mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        [string]
        $Qname,

        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [string[]]
        $PreferredDnsResolvers,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [ValidateSet($true, $false)]
        [boolean]
        $SkipDoH,

        [Parameter(Mandatory=$false)]
        [ValidateRange(1, [int]::MaxValue)]
        [int]
        $MaxTries = 2
    )

    $Results = @{
        "Answers" = @();
        "NXDomain" = $false;
        "LogEntries" = @();
        "Errors" = @()
    }

    $TradResult = Invoke-TraditionalDns -Qname $Qname -MaxTries $MaxTries `
        -PreferredDnsResolvers $PreferredDnsResolvers
    $Results['Answers'] += $TradResult['Answers']
    $Results['NXDomain'] = $TradResult['NXDomain']
    $Results['LogEntries'] += $TradResult['LogEntries']
    $Results['Errors'] += $TradResult['Errors']

    if ($Results.Answers.Length -eq 0 -and -not $SkipDoH) {
        # The traditional DNS query(ies) failed. Retry with DoH
        $DoHResult = Invoke-DoH -Qname $Qname -MaxTries $MaxTries
        $Results['Answers'] += $DoHResult['Answers']
        $Results['NXDomain'] = $DoHResult['NXDomain']
        $Results['LogEntries'] += $DoHResult['LogEntries']
        $Results['Errors'] += $DoHResult['Errors']
    }
    $Results

}

function Invoke-TraditionalDns {
    <#
    .Description
    Requests the TXT record for the given qname over traditional DNS.
    .Parameter Qname
    The fully-qualified domain name to request.
    .Parameter PreferredDnsResolvers
    IP addresses of DNS resolvers that should be used. If empty, the system default will be used.
    .Parameter MaxTries
    The number of times to retry the query.
    .Functionality
    Internal
    #>

    [CmdletBinding()]
    [OutputType([System.Collections.Hashtable])]
    param (
        [Parameter(Mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        [string]
        $Qname,

        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [string[]]
        $PreferredDnsResolvers,

        [Parameter(Mandatory=$false)]
        [ValidateRange(1, [int]::MaxValue)]
        [int]
        $MaxTries = 2
    )

    $Answers = @()
    $NXDomain = $false
    $Errors = @()
    $LogEntries = @()

    $TryNumber = 0
    while ($TryNumber -lt $MaxTries) {
        $TryNumber += 1
        try {
            if ($PreferredDnsResolvers.Length -gt 0) {
                $Response = Resolve-DnsName $Qname txt -Server $PreferredDnsResolvers `
                    -ErrorAction Stop | Where-Object {$_.Section -eq "Answer"}
            }
            else {
                $Response = Resolve-DnsName $Qname txt -ErrorAction Stop | Where-Object {$_.Section -eq "Answer"}
            }
            if ($Response.Strings.Length -gt 0) {
                # We got our answer, so break out of the retry loop, no
                # need to retry the traditional query or retry with DoH.

                # Resolve-DnsName breaks long answers into multiple strings, we need to
                # join them back together
                $StringsJoined = ($Response | ForEach-Object { $_.Strings -Join "" } )
                if ($StringsJoined -is [String]) {
                    $NAnswers = 1
                }
                else {
                    $NAnswers = $StringsJoined.Length
                }

                $LogEntries += @{
                    "query_name"=$Qname;
                    "query_method"="traditional";
                    "query_result"="Query returned $NAnswers txt records";
                    "query_answers"=$StringsJoined;
                }
                $Answers += $StringsJoined
                break
            }
            else {
                # The answer section was empty. This usually means that while the domain exists, but
                # there are no records of the requested type. No need to retry the traditional query,
                # this was not a transient failure. Don't set $Success to $true though, as we want to
                # retry this query from a public resolver, in case the internal DNS server returns a
                # different answer than what is served to the public (i.e., split horizon DNS).
                $LogEntries += @{
                    "query_name"=$Qname;
                    "query_method"="traditional";
                    "query_result"="Query returned 0 txt records";
                    "query_answers"=@();
                }
                break
            }
        }
        catch {
            if ($_.FullyQualifiedErrorId -eq "DNS_ERROR_RCODE_NAME_ERROR,Microsoft.DnsClient.Commands.ResolveDnsName") {
                # The server returned NXDomain, no need to retry the traditional query,
                # this was not a transient failure.
                $LogEntries += @{
                    "query_name"=$Qname;
                    "query_method"="traditional";
                    "query_result"="Query returned NXDomain";
                    "query_answers"=@();
                }
                $NXDomain = $True
                break
            }
            else {
                # The query failed, possibly a transient failure. Retry if we haven't reached $MaxsTries.
                $LogEntries += @{
                    "query_name"=$Qname;
                    "query_method"="traditional";
                    "query_result"="Query resulted in exception, $($_.FullyQualifiedErrorId)";
                    "query_answers"=@();
                }
                $Errors += $_.FullyQualifiedErrorId
            }
        }
    }

    @{
        "Answers" = $Answers;
        "NXDomain" = $NXDomain;
        "LogEntries" = $LogEntries;
        "Errors" = $Errors;
    }
}

function Invoke-DoH {
    <#
    .Description
    Requests the TXT record for the given qname over traditional DNS.
    .Parameter Qname
    The fully-qualified domain name to request.
    .Parameter MaxTries
    The number of times to retry the query.
    .Functionality
    Internal
    #>

    [CmdletBinding()]
    [OutputType([System.Collections.Hashtable])]
    param (
        [Parameter(Mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        [string]
        $Qname,

        [Parameter(Mandatory=$false)]
        [ValidateRange(1, [int]::MaxValue)]
        [int]
        $MaxTries = 2
    )

    $Answers = @()
    $NXDomain = $false
    $Errors = @()
    $LogEntries = @()

    if ($script:DohServer -eq "") {
        # We haven't determined if DoH is available yet, select the first server that works
        $script:DohServer = Select-DohServer
    }
    if ($null -eq $script:DohServer) {
        # None of the DoH servers are accessible
        $LogEntries += @{"query_name"=$Qname; "query_method"="DoH"; "query_result"="NA, DoH servers unreachable"}
        return @{
            "Answers" = $Answers;
            "NXDomain" = $NXDomain;
            "LogEntries" = $LogEntries;
            "Errors" = $Errors;
        }
    }

    # DoH is available, query for the domain
    $TryNumber = 0
    while ($TryNumber -lt $MaxTries) {
        $TryNumber += 1
        try {
            $Uri = "https://$($script:DohServer)/dns-query?name=$($Qname)&type=txt"
            $Headers = @{"accept"="application/dns-json"}
            $RawResponse = $(Invoke-WebRequest -Headers $Headers -Uri $Uri -UseBasicParsing -ErrorAction "Stop").RawContent
            $ResponseLines = $RawResponse -Split "`n"
            $LastLine = $ResponseLines[$ResponseLines.Length - 1]
            $ResponseBody = ConvertFrom-Json $LastLine
            if ($ResponseBody.Status -eq 0) {
                # 0 indicates there was no error
                if ($null -eq $ResponseBody.Answer) {
                    # Edge case where the domain exists but there are no txt records available
                    $LogEntries += @{
                        "query_name"=$Qname;
                        "query_method"="DoH";
                        "query_result"="Query returned 0 txt records";
                        "query_answers"=@();
                    }
                }
                else {
                    if ($ResponseBody.Answer.data -is [String]) {
                        $Length = 1
                    }
                    else {
                        $Length = $ResponseBody.Answer.data.Length
                    }
                    $LogEntries += @{
                        "query_name"=$Qname;
                        "query_method"="DoH";
                        "query_result"="Query returned $Length txt records";
                        "query_answers"=($ResponseBody.Answer.data | ForEach-Object {$_.Replace('"', '')});
                    }
                    $Answers += ($ResponseBody.Answer.data | ForEach-Object {$_.Replace('"', '')})
                }
                break
            }
            elseif ($ResponseBody.Status -eq 3) {
                # 3 indicates NXDomain. The DNS query succeeded, but the domain did not exist.
                $LogEntries += @{
                    "query_name"=$Qname;
                    "query_method"="DoH";
                    "query_result"="Query returned NXDomain";
                    "query_answers"=@();
                }
                $NXDomain = $true
                break
            }
            else {
                # The remainder of the response codes indicate that the query did not succeed.
                # Retry if we haven't reached $MaxTries.
                $LogEntries += @{
                    "query_name"=$Qname;
                    "query_method"="DoH";
                    "query_result"="Query returned response code $($ResponseBody.Status)";
                    "query_answers"=@();
                }
            }
        }
        catch {
            # The DoH query failed, likely due to a network issue. Retry if we haven't reached
            # $MaxTries.
            $LogEntries += @{
                "query_name"=$Qname;
                "query_method"="DoH";
                "query_result"="Query resulted in exception, $($_.FullyQualifiedErrorId)";
                "query_answers"=@();
            }
            $Errors += $_.FullyQualifiedErrorId
        }
    }

    @{
        "Answers" = $Answers;
        "NXDomain" = $NXDomain;
        "LogEntries" = $LogEntries;
        "Errors" = $Errors;
    }
}

function Get-ScubaSpfRecord {
    <#
    .Description
    Gets the SPF records for each domain in $Domains
    .Functionality
    Internal
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        [System.Object[]]
        $Domains,

        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [string[]]
        $PreferredDnsResolvers,

        [Parameter(Mandatory = $true)]
        [ValidateSet($true, $false)]
        [boolean]
        $SkipDoH
    )

    $SPFRecords = @()

    foreach ($d in $Domains) {
        $Compliant = $false
        $Response = Invoke-RobustDnsTxt $d.DomainName -PreferredDnsResolvers $PreferredDnsResolvers `
            -SkipDoH $SkipDoH
        $DomainName = $d.DomainName
        if ($Response.Answers.Length -gt 0) {
            # We got some answers - are they SPF records?
            $SPFAnswers = ($Response.Answers | Where-Object { $_.StartsWith("v=spf1 ") }  )
            if ($SPFAnswers.Length -gt 0) {
                # We have an SPF record - does it fail?
                $SPFReject = ($SPFAnswers | Where-Object { $_.Contains("-all") -or $_.Contains("~all") -or $_.Contains("redirect") }  )
                if ($SPFReject.Length -gt 0) {
                    # Yes! This is the "good" case
                    $Message = "SPF record found."
                    $Compliant = $true
                }
                else {
                    # There is an SPF record but it doesn't fail
                    $Message = "SPF record found, but it does not fail unapproved senders or redirect to one that does."
                }
            }
            else {
                # An answer was returned but it didn't start with "v=spf1 "
                $Message = "Domain name exists but no SPF records returned."
            }
        }
        # For the three remaining cases, we didn't get an answer
        elseif ($Response.NXDomain) {
            $Message = "Domain does not exist."
        }
        elseif ($Response.Errors -gt 0) {
            $Message = "Exceptions other than NXDOMAIN returned."
        }
        else {
            $Message = "Domain name exists but no answers returned."
        }
        $SPFRecords += [PSCustomObject]@{
            "domain" = $DomainName;
            "compliant" = $Compliant;
            "message" = $Message;
            "rdata" = @($Response.Answers);
            "log" = $Response.LogEntries;
        }
    }

    $SPFRecords
}

function Get-ScubaDkimRecord {
    <#
    .Description
    Gets the DKIM records for each domain in $Domains
    .Functionality
    Internal
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        [System.Object[]]
        $Domains,

        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [string[]]
        $PreferredDnsResolvers,

        [Parameter(Mandatory = $true)]
        [ValidateSet($true, $false)]
        [boolean]
        $SkipDoH
    )

    $DKIMRecords = @()

    foreach ($d in $Domains) {
        if ($d.IsCoexistenceDomain) {
            # Skip the coexistence domain (e.g., contoso.mail.onmicrosoft.com).
            # It's not actually possible to publish custom DNS records for this
            # domain.
            continue
        }
        $DomainName = $d.DomainName
        $selectors = "selector1", "selector2"
        $selectors += "selector1.$DomainName" -replace "\.", "-"
        $selectors += "selector2.$DomainName" -replace "\.", "-"

        $LogEntries = @()
        foreach ($s in $selectors) {
            $Response = Invoke-RobustDnsTxt "$s._domainkey.$DomainName" -PreferredDnsResolvers $PreferredDnsResolvers `
            -SkipDoH $SkipDoH
            $LogEntries += $Response.LogEntries
            if ($Response.Answers.Length -eq 0) {
                # The DKIM record does not exist with this selector, we need to try again with
                # a different one
                continue
            }
            else {
                # The DKIM record exists with this selector, no need to try the rest
                break
            }
        }

        $DKIMRecords += [PSCustomObject]@{
            "domain" = $DomainName;
            "rdata" = @($Response.Answers);
            "log" = $LogEntries;
        }
    }

    $DKIMRecords
}

function Get-ScubaDmarcRecord {
    <#
    .Description
    Gets the DMARC records for each domain in $Domains
    .Functionality
    Internal
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        [System.Object[]]
        $Domains,

        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [string[]]
        $PreferredDnsResolvers,

        [Parameter(Mandatory = $true)]
        [ValidateSet($true, $false)]
        [boolean]
        $SkipDoH
    )

    $DMARCRecords = @()

    foreach ($d in $Domains) {
        if ($d.IsCoexistenceDomain) {
            # Skip the coexistence domain (e.g., contoso.mail.onmicrosoft.com).
            # See https://github.com/cisagov/ScubaGear/issues/1514.
            continue
        }
        $LogEntries = @()
        # First check to see if the record is available at the full domain level
        $DomainName = $d.DomainName
        $Response = Invoke-RobustDnsTxt "_dmarc.$DomainName" -PreferredDnsResolvers $PreferredDnsResolvers `
            -SkipDoH $SkipDoH
        $LogEntries += $Response.LogEntries
        # RFC 9989 step 2 applies to the starting target as well as every
        # subsequent tree-walk target. Do not let unrelated TXT records or an
        # ambiguous set of DMARC records suppress parent-domain discovery.
        $ValidAnswers = @($Response.Answers | Where-Object { $_ -match '^v=DMARC1' })

        if ($ValidAnswers.Length -eq 1) {
            $Response = [PSCustomObject]@{
                "Answers"    = @($ValidAnswers)
                "Errors"     = $Response.Errors
                "NXDomain"   = $Response.NXDomain
                "LogEntries" = $Response.LogEntries
            }
        }
        else {
            $Response = [PSCustomObject]@{
                "Answers"    = @()
                "Errors"     = $Response.Errors
                "NXDomain"   = $Response.NXDomain
                "LogEntries" = $Response.LogEntries
            }

            # RFC 9989 replaces Public Suffix List organizational-domain discovery with
            # a bounded DNS Tree Walk. Keep the author-domain query above, then walk
            # parent domains without exceeding eight total DNS queries.
            $AuthorDomainLabels = @($d.DomainName.Split(".") | Where-Object { $_ -ne "" })
            $Labels = @($AuthorDomainLabels)
            $Queries = 1
            $CandidateResponse = $null
            $PolicyResponse = $null
            $PolicyDomain = $null

            while ($Labels.Length -gt 1 -and $Queries -lt 8) {
                if ($Labels.Length -ge 8) {
                    $Labels = @($Labels[($Labels.Length - 7)..($Labels.Length - 1)])
                }
                else {
                    $Labels = @($Labels[1..($Labels.Length - 1)])
                }

                $TreeWalkDomain = $Labels -join "."
                $CandidateResponse = Invoke-RobustDnsTxt "_dmarc.$TreeWalkDomain" `
                    -PreferredDnsResolvers $PreferredDnsResolvers `
                    -SkipDoH $SkipDoH
                $Queries += 1
                $LogEntries += $CandidateResponse.LogEntries

                # RFC 9989 tree-walk steps 2 and 6: an answer only counts as this
                # target's DMARC policy if it is itself a valid DMARC record (begins
                # with the version tag). Multiple valid records at the same target are
                # ambiguous per RFC 7489 6.6.3 and are treated as no usable record
                # there, same as zero answers. A single valid record carrying the psd
                # tag marks the walk boundary explicitly, so stop there instead of
                # continuing unconditionally through the remaining labels.
                $ValidAnswers = @($CandidateResponse.Answers | Where-Object { $_ -match '^v=DMARC1' })

                if ($ValidAnswers.Length -eq 1) {
                    $ValidatedResponse = [PSCustomObject]@{
                        "Answers"    = @($ValidAnswers)
                        "Errors"     = $CandidateResponse.Errors
                        "NXDomain"   = $CandidateResponse.NXDomain
                        "LogEntries" = $CandidateResponse.LogEntries
                    }

                    if ($ValidAnswers[0] -match 'psd=y\b') {
                        # The Organizational Domain is exactly one label below the PSD.
                        # Prefer its record over the PSD record when one was discovered.
                        $OrganizationalDomainStart = $AuthorDomainLabels.Length - $Labels.Length - 1
                        $OrganizationalDomain = if ($OrganizationalDomainStart -ge 0) {
                            $AuthorDomainLabels[$OrganizationalDomainStart..($AuthorDomainLabels.Length - 1)] -join "."
                        }
                        else {
                            $null
                        }

                        $Response = if ($PolicyDomain -eq $OrganizationalDomain) {
                            $PolicyResponse
                        }
                        else {
                            $ValidatedResponse
                        }
                        break
                    }

                    $Response = $ValidatedResponse
                    $PolicyResponse = $ValidatedResponse
                    $PolicyDomain = $TreeWalkDomain

                    if ($ValidAnswers[0] -match 'psd=n\b') {
                        break
                    }
                }
            }
        }

        $DomainName = $d.DomainName
        $DMARCRecords += [PSCustomObject]@{
            "domain" = $DomainName;
            "rdata" = @($Response.Answers);
            "log" = $LogEntries;
        }
    }

    $DMARCRecords
}

# SIG # Begin signature block
# MIIu9wYJKoZIhvcNAQcCoIIu6DCCLuQCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCA7XCBHR2KAy78Z
# xHg8pOZ5nEfirX46+TOADApn2uvZQKCCE6MwggWQMIIDeKADAgECAhAFmxtXno4h
# MuI5B72nd3VcMA0GCSqGSIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQK
# EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNV
# BAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBHNDAeFw0xMzA4MDExMjAwMDBaFw0z
# ODAxMTUxMjAwMDBaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJ
# bmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0
# IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB
# AL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsbhA3EMB/z
# G6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iTcMKyunWZ
# anMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGbNOsFxl7s
# Wxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclPXuU15zHL
# 2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCrVYJBMtfb
# BHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFPObURWBf3
# JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTvkpI6nj3c
# AORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWMcCxBYKqx
# YxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls5Q5SUUd0
# viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBRa2+xq4aL
# T8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjQjBAMA8GA1Ud
# EwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBTs1+OC0nFdZEzf
# Lmc/57qYrhwPTzANBgkqhkiG9w0BAQwFAAOCAgEAu2HZfalsvhfEkRvDoaIAjeNk
# aA9Wz3eucPn9mkqZucl4XAwMX+TmFClWCzZJXURj4K2clhhmGyMNPXnpbWvWVPjS
# PMFDQK4dUPVS/JA7u5iZaWvHwaeoaKQn3J35J64whbn2Z006Po9ZOSJTROvIXQPK
# 7VB6fWIhCoDIc2bRoAVgX+iltKevqPdtNZx8WorWojiZ83iL9E3SIAveBO6Mm0eB
# cg3AFDLvMFkuruBx8lbkapdvklBtlo1oepqyNhR6BvIkuQkRUNcIsbiJeoQjYUIp
# 5aPNoiBB19GcZNnqJqGLFNdMGbJQQXE9P01wI4YMStyB0swylIQNCAmXHE/A7msg
# dDDS4Dk0EIUhFQEI6FUy3nFJ2SgXUE3mvk3RdazQyvtBuEOlqtPDBURPLDab4vri
# RbgjU2wGb2dVf0a1TD9uKFp5JtKkqGKX0h7i7UqLvBv9R0oN32dmfrJbQdA75PQ7
# 9ARj6e/CVABRoIoqyc54zNXqhwQYs86vSYiv85KZtrPmYQ/ShQDnUBrkG5WdGaG5
# nLGbsQAe79APT0JsyQq87kP6OnGlyE0mpTX9iV28hWIdMtKgK1TtmlfB2/oQzxm3
# i0objwG2J5VT6LaJbVu8aNQj6ItRolb58KaAoNYes7wPD1N1KarqE3fk3oyBIa0H
# EEcRrYc9B9F1vM/zZn4wggawMIIEmKADAgECAhAIrUCyYNKcTJ9ezam9k67ZMA0G
# CSqGSIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJ
# bmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0
# IFRydXN0ZWQgUm9vdCBHNDAeFw0yMTA0MjkwMDAwMDBaFw0zNjA0MjgyMzU5NTla
# MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UE
# AxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEz
# ODQgMjAyMSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDVtC9C
# 0CiteLdd1TlZG7GIQvUzjOs9gZdwxbvEhSYwn6SOaNhc9es0JAfhS0/TeEP0F9ce
# 2vnS1WcaUk8OoVf8iJnBkcyBAz5NcCRks43iCH00fUyAVxJrQ5qZ8sU7H/Lvy0da
# E6ZMswEgJfMQ04uy+wjwiuCdCcBlp/qYgEk1hz1RGeiQIXhFLqGfLOEYwhrMxe6T
# SXBCMo/7xuoc82VokaJNTIIRSFJo3hC9FFdd6BgTZcV/sk+FLEikVoQ11vkunKoA
# FdE3/hoGlMJ8yOobMubKwvSnowMOdKWvObarYBLj6Na59zHh3K3kGKDYwSNHR7Oh
# D26jq22YBoMbt2pnLdK9RBqSEIGPsDsJ18ebMlrC/2pgVItJwZPt4bRc4G/rJvmM
# 1bL5OBDm6s6R9b7T+2+TYTRcvJNFKIM2KmYoX7BzzosmJQayg9Rc9hUZTO1i4F4z
# 8ujo7AqnsAMrkbI2eb73rQgedaZlzLvjSFDzd5Ea/ttQokbIYViY9XwCFjyDKK05
# huzUtw1T0PhH5nUwjewwk3YUpltLXXRhTT8SkXbev1jLchApQfDVxW0mdmgRQRNY
# mtwmKwH0iU1Z23jPgUo+QEdfyYFQc4UQIyFZYIpkVMHMIRroOBl8ZhzNeDhFMJlP
# /2NPTLuqDQhTQXxYPUez+rbsjDIJAsxsPAxWEQIDAQABo4IBWTCCAVUwEgYDVR0T
# AQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUaDfg67Y7+F8Rhvv+YXsIiGX0TkIwHwYD
# VR0jBBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMG
# A1UdJQQMMAoGCCsGAQUFBwMDMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYY
# aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2Fj
# ZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNV
# HR8EPDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRU
# cnVzdGVkUm9vdEc0LmNybDAcBgNVHSAEFTATMAcGBWeBDAEDMAgGBmeBDAEEATAN
# BgkqhkiG9w0BAQwFAAOCAgEAOiNEPY0Idu6PvDqZ01bgAhql+Eg08yy25nRm95Ry
# sQDKr2wwJxMSnpBEn0v9nqN8JtU3vDpdSG2V1T9J9Ce7FoFFUP2cvbaF4HZ+N3HL
# IvdaqpDP9ZNq4+sg0dVQeYiaiorBtr2hSBh+3NiAGhEZGM1hmYFW9snjdufE5Btf
# Q/g+lP92OT2e1JnPSt0o618moZVYSNUa/tcnP/2Q0XaG3RywYFzzDaju4ImhvTnh
# OE7abrs2nfvlIVNaw8rpavGiPttDuDPITzgUkpn13c5UbdldAhQfQDN8A+KVssIh
# dXNSy0bYxDQcoqVLjc1vdjcshT8azibpGL6QB7BDf5WIIIJw8MzK7/0pNVwfiThV
# 9zeKiwmhywvpMRr/LhlcOXHhvpynCgbWJme3kuZOX956rEnPLqR0kq3bPKSchh/j
# wVYbKyP/j7XqiHtwa+aguv06P0WmxOgWkVKLQcBIhEuWTatEQOON8BUozu3xGFYH
# Ki8QxAwIZDwzj64ojDzLj4gLDb879M4ee47vtevLt/B3E+bnKD+sEq6lLyJsQfmC
# XBVmzGwOysWGw/YmMwwHS6DTBwJqakAwSEs0qFEgu60bhQjiWQ1tygVQK+pKHJ6l
# /aCnHwZ05/LWUpD9r4VIIflXO7ScA+2GRfS0YW6/aOImYIbqyK+p/pQd52MbOoZW
# eE4wggdXMIIFP6ADAgECAhAMM6tnPejLgA9WVhXroQvSMA0GCSqGSIb3DQEBCwUA
# MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UE
# AxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEz
# ODQgMjAyMSBDQTEwHhcNMjYwMTE0MDAwMDAwWhcNMjcwMTEzMjM1OTU5WjBfMQsw
# CQYDVQQGEwJVUzEdMBsGA1UECBMURGlzdHJpY3Qgb2YgQ29sdW1iaWExEzARBgNV
# BAcTCldhc2hpbmd0b24xDTALBgNVBAoTBENJU0ExDTALBgNVBAMTBENJU0EwggIi
# MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCuXYolNHqlh6smLTE592waXheZ
# 8VHzxeds4pMaepGuwmjf8d1jG9wUNuJX9/qb0a1dgGz5D/EAz5NRTIin4SZYQEE8
# qvdl2yQJ5uWxXIjsFbrOyc1fWscUXw0Kt7OPLOafcEkdDoe8K0tO4h2GL3RWRzjp
# uLfQhhnAmD6NT1l+ughnfmarV/ODgIn/RFR4YORlu4YP2xQX6KRxeTDslg7F+z6X
# +t87/U8m8gQ9XTm5kBmteP4GcE/ytnyI+ScIxNRybzGomWIBm848XDE5yYhlYQ2R
# SnCoo6M4CRqp9WFGVyoLkoPP0OlxzryKWaE1/nuPbYG/kf/rUB1OhqxvSSGwmNhs
# vkkjsC0Z9H5Jy6heFdoxOu/+ZQksKoP/fMvHxuCCtkIJbV8tk0oT6MQ8EJbgsWDZ
# TKhui1wxW6JIZyBOMPWoZUOouOzo2h5Cz7LBPKME5FkcUzcs47lpRlDkJco4PLcj
# wJSo4XPnx3G/2DIjNEFNyfKCWfH8uW6nJjmDBiveFZ2j0YvgdQ+7MOjQnw7R/MAD
# DTagrKl3rLV60+X2TY6/onKhCUuU3pMAjVbOwZ3PkzDLZnsEGRfm6hgp6014aXml
# t8h4nu+uC41U8vUSGHl0vqKuzvmShLmnI+Iv0l95pmnqomuCZzRDrjEoaLPx7OxL
# Dy/Id9E7yQDip4jBdQIDAQABo4ICAzCCAf8wHwYDVR0jBBgwFoAUaDfg67Y7+F8R
# hvv+YXsIiGX0TkIwHQYDVR0OBBYEFN30sVU+fpfQQfPQWMhkO1qd+MxoMD4GA1Ud
# IAQ3MDUwMwYGZ4EMAQQBMCkwJwYIKwYBBQUHAgEWG2h0dHA6Ly93d3cuZGlnaWNl
# cnQuY29tL0NQUzAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwMw
# gbUGA1UdHwSBrTCBqjBToFGgT4ZNaHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0Rp
# Z2lDZXJ0VHJ1c3RlZEc0Q29kZVNpZ25pbmdSU0E0MDk2U0hBMzg0MjAyMUNBMS5j
# cmwwU6BRoE+GTWh0dHA6Ly9jcmw0LmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0
# ZWRHNENvZGVTaWduaW5nUlNBNDA5NlNIQTM4NDIwMjFDQTEuY3JsMIGUBggrBgEF
# BQcBAQSBhzCBhDAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29t
# MFwGCCsGAQUFBzAChlBodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNl
# cnRUcnVzdGVkRzRDb2RlU2lnbmluZ1JTQTQwOTZTSEEzODQyMDIxQ0ExLmNydDAJ
# BgNVHRMEAjAAMA0GCSqGSIb3DQEBCwUAA4ICAQA77T42YiOx5wWPItgo+kvB+Gzb
# ZFCRHRFfTAZZNQt9o/0CqNmyA2xFklX4t5Z9VNdiIOx14AnmJdQkcRdk3vsU5gby
# jKEup7LTWtvcWrl6hQwGNt3l892BgUbPKsPBE+AriktVqn5yMSXVVzeboqsqAG8e
# Syei0B54/QdgR4whfHvQ/qpCACsJTlJgAykXVgDPJNKnQ7wc17loLQutqF1JUcbO
# XKWt1AA9Zas5q30LDZzZeK4B56yojK68CTQXN7toSRFIuZDMKKDfIZpCX8cmbaaO
# DFOOu44/QWMv+Xc6+ISYGkrTTzqWhOqiXgLVBeXGn/WrOJJ8R29mZMneCpBesCLs
# YII1gCFOo7Vt6mvOKxAPQ3KhJYBFEHkp+GI65koaQkO2xv50iLS0+/j2YC66uviU
# MFe0JEOdXuE7Rn/OmWNSzQ+6kPNYDJQASQ974C3wUejJoMtGZEzoTbly/HufQTrd
# rhcL2aC69CxSN+idTXPLC9UT3xo4sFdOw+hXkmbXtoB1GDsd5p1TWFgRbnTXDkbM
# YMBWYVB6/Tk1bzwj4iTp4g0YrtB628FXPX/ko+JWZlv0Ea865S23w1uGlnDNVxIi
# +8oi74G5DM66Q6ENt6+3WoRGRrdoyE6uCh1haY+oPYSgumb0ozzrp8tw89TRKVrK
# KSXGBxlExEvjQ6dYwjGCGqowghqmAgEBMH0waTELMAkGA1UEBhMCVVMxFzAVBgNV
# BAoTDkRpZ2lDZXJ0LCBJbmMuMUEwPwYDVQQDEzhEaWdpQ2VydCBUcnVzdGVkIEc0
# IENvZGUgU2lnbmluZyBSU0E0MDk2IFNIQTM4NCAyMDIxIENBMQIQDDOrZz3oy4AP
# VlYV66EL0jANBglghkgBZQMEAgEFAKCBhDAYBgorBgEEAYI3AgEMMQowCKACgACh
# AoAAMBkGCSqGSIb3DQEJAzEMBgorBgEEAYI3AgEEMBwGCisGAQQBgjcCAQsxDjAM
# BgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJBDEiBCAWaj1WTYgvs8mYmcZfopGvIDt0
# IJHQuIKGoa9OucU+qDANBgkqhkiG9w0BAQEFAASCAgCSZOcTxajgfmgu2fBBywbD
# UyUw4iX5u2B70KAPcaNm7CeZfA4m2T3l6l0WKyFgAzxBnsSHWvP/3/RYXFP9mwW1
# 3JkM8o4xWL22JRVcNblZz9wAvDuSn4Tv+NQBTzruQILsoCg3Sc3+Sh+Ah4/FJins
# BtBZi6Q3+57i6OHtX67PRt965iI26sXPU4dIag+qZrBQ7jNL0B6fEFK0abomfpm3
# cmr/EFJN11O6NVJOJoAtw4mTCXi6lgvXgSoeY6+ONZ871OyGr3XniYRczlhT2C2o
# yXDn0qBCgCWAzWs6ZuKmaVSf+EQI6yoqF+acy5MCKMwxWZe9XkxgkSfxTaUTUGZd
# rGcJtVYrPlmDsScDjIHvoqvR4uhpFoWkKEKG5zApzy1ss9GFiq1nEz0wIru0B2Nk
# pAJ3H/k0fICMCInYAvw5ucydvjy7FnulMBmf5oelkjN+hGnRTeAyHDUXrfI55G6V
# pPZ/FA8sC8uYA2GhIMExwiTQjUKk3KDEyrlFrf5Nwzkey/WZCkQluRLWGckzki3L
# j8AgwEcsZQf+ZfXZu0veAmM49FMCd3VdEYoblpZVHHrYRFpJQFCoSkhoonvAOAhq
# EozL2eT/0g8GuB9qcJhwQMY5BSZHujZP5wqkKWQ6uRcJ78Q6IVV+l9O2FeS0EXV8
# 4anjiQXGWwVihS6MDogCa6GCF3cwghdzBgorBgEEAYI3AwMBMYIXYzCCF18GCSqG
# SIb3DQEHAqCCF1AwghdMAgEDMQ8wDQYJYIZIAWUDBAIBBQAweAYLKoZIhvcNAQkQ
# AQSgaQRnMGUCAQEGCWCGSAGG/WwHATAxMA0GCWCGSAFlAwQCAQUABCD6TEmgG+Q0
# Qvet04xnbbJlzOjVgQlERbnklhkTKxaT7gIRAKWMegOQwM4yoJ11v7Oy7U4YDzIw
# MjYxMDA4MTkxNDE4WqCCEzowggbtMIIE1aADAgECAhAIT9wzT35FTtvDD4/5khg1
# MA0GCSqGSIb3DQEBCwUAMGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2Vy
# dCwgSW5jLjFBMD8GA1UEAxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBUaW1lU3RhbXBp
# bmcgUlNBNDA5NiBTSEEyNTYgMjAyNSBDQTEwHhcNMjYwODA1MDAwMDAwWhcNMzcx
# MTA0MjM1OTU5WjBjMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIElu
# Yy4xOzA5BgNVBAMTMkRpZ2lDZXJ0IFNIQTI1NiBSU0E0MDk2IFRpbWVzdGFtcCBS
# ZXNwb25kZXIgMjAyNiAxMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA
# tnum8sn+zUr41JtMZbP9OMYw+HwJDpG5xkIu/lqcfNYmMX81YmsUiHLbh9ykpeWB
# GKTLhYBrAN9Tdg/QEzG32XcObmgIblnr0CoQ3WSAeDZ6nH6X6VkFyYkJw3QBJREw
# vm4UhLzSxmwPA7cFKRTEOMsmEEj6qJk/dqLEAL+oQYuOwE2UuiX1Vnul8YReIyWd
# 4kgLn9gq6LNXM0UplkR6jL/QHxmb6fMoGBJYbnaUI7XD6cKDpekK2SVMld4iDbze
# HDtOaaxldH5IxuNusQ69nd8/ZXEiB5Hbxj3RlK13cX1W4DlFXKdv/CEhM8Cj1vvl
# mvhNroyPdRGbbpBlgyf8Wdu5N6ByhFwURn0U6ozlPoxN22v+fviUhP+6DR547OZn
# pBMWDfei1f5sVGwiiW/KQTWOK97g+4RJpPzPNV4VYMAwO2jM2Aty2QYPVmOQTJm0
# msuXnJrSbl2gf9JylpkJlWXqk1Q4LJsxz+TELoQCZIljbgvTJgoPU2R12ydv8i1U
# qL/adelA0y7U9Pmmtbze9Xx3rtajC5SzQd1jgfwAwsa90v9YcSPdmeoyoBBA/27c
# CL237l5DTYYPDLQ4ON3OLTGWnvRb6jDrf/T75gMRfUzSLCBQfBusm9+mSWRlC/Df
# 6S/e9Q8i13CuhzOT2Jx+V/nlbXM4QoBwlUAhelwwJT0CAwEAAaOCAZUwggGRMAwG
# A1UdEwEB/wQCMAAwHQYDVR0OBBYEFBTJY4owLtRK+26U8+bjQH717M3iMB8GA1Ud
# IwQYMBaAFO9vU0rp5AZ8esrikFb2L9RJ7MtOMA4GA1UdDwEB/wQEAwIHgDAWBgNV
# HSUBAf8EDDAKBggrBgEFBQcDCDCBlQYIKwYBBQUHAQEEgYgwgYUwJAYIKwYBBQUH
# MAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBdBggrBgEFBQcwAoZRaHR0cDov
# L2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0VGltZVN0YW1w
# aW5nUlNBNDA5NlNIQTI1NjIwMjVDQTEuY3J0MF8GA1UdHwRYMFYwVKBSoFCGTmh0
# dHA6Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNFRpbWVTdGFt
# cGluZ1JTQTQwOTZTSEEyNTYyMDI1Q0ExLmNybDAgBgNVHSAEGTAXMAgGBmeBDAEE
# AjALBglghkgBhv1sBwEwDQYJKoZIhvcNAQELBQADggIBAI3FOmEenVIK35msCYB+
# fShAsWvSYvLBItoNdAgQ2jIqrGsVsluXMJU/+mRebBc52s6lbKAvOVPXaizmKkML
# LflEEKDZQx4CkS2t8aHPjkXha3hYZ010htFa3dhNgmalH5vuWvh3tTCf4frTS7gP
# tGc4Z/xaPhQ2AB1mR8eEe/WbH0RWHvVIl6VwQ3+g5FKNfN2N/DWJkf13w2H+2Gfq
# Efbd35Ww8CvoYBjLNIDTadcPWdgsjsiOaK/7EsKJgLjUNIVgvcaFOLLQ/GlrA+0Z
# HJoFUbOr5SJN8zykPspXIXlpDJY/gqFUZRROeab9GVgmhbdOJcD/63RhxPahFUGb
# ckRONqMe6DYAv6/mOG0pWd3cPStsdcS7buj5DyniwRY8yooMH6ptx5vpP/pZzBPB
# eZD2U4IsthyxB5Jaa8qrOkB5z160TXiM5ADMspZ0TfD9MJoq0tFpFPssKRFhWeED
# YPvcUuN7U7lvcdHl4ezQ3NT/7Ffs1sR1yh/LRbdZ3B3Vc6q2WmD8mDC0p9kzl2o7
# 3iVtS946IkEj7FkRsZGww1teYxERROC745xrtjvcw9ZyyUjHZWGRIpJeMNsPquCD
# f0fkyHtB+J4AiNZqCQk23rxh+KbpyMTNVKItJ5l92Svl20U9NbqMBOVYl1h54NEY
# LJq1/xHWFKPNK903zJZA9P2DMIIGtDCCBJygAwIBAgIQDcesVwX/IZkuQEMiDDpJ
# hjANBgkqhkiG9w0BAQsFADBiMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNl
# cnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdp
# Q2VydCBUcnVzdGVkIFJvb3QgRzQwHhcNMjUwNTA3MDAwMDAwWhcNMzgwMTE0MjM1
# OTU5WjBpMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/
# BgNVBAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQgVGltZVN0YW1waW5nIFJTQTQwOTYg
# U0hBMjU2IDIwMjUgQ0ExMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA
# tHgx0wqYQXK+PEbAHKx126NGaHS0URedTa2NDZS1mZaDLFTtQ2oRjzUXMmxCqvkb
# sDpz4aH+qbxeLho8I6jY3xL1IusLopuW2qftJYJaDNs1+JH7Z+QdSKWM06qchUP+
# AbdJgMQB3h2DZ0Mal5kYp77jYMVQXSZH++0trj6Ao+xh/AS7sQRuQL37QXbDhAkt
# VJMQbzIBHYJBYgzWIjk8eDrYhXDEpKk7RdoX0M980EpLtlrNyHw0Xm+nt5pnYJU3
# Gmq6bNMI1I7Gb5IBZK4ivbVCiZv7PNBYqHEpNVWC2ZQ8BbfnFRQVESYOszFI2Wv8
# 2wnJRfN20VRS3hpLgIR4hjzL0hpoYGk81coWJ+KdPvMvaB0WkE/2qHxJ0ucS638Z
# xqU14lDnki7CcoKCz6eum5A19WZQHkqUJfdkDjHkccpL6uoG8pbF0LJAQQZxst7V
# vwDDjAmSFTUms+wV/FbWBqi7fTJnjq3hj0XbQcd8hjj/q8d6ylgxCZSKi17yVp2N
# L+cnT6Toy+rN+nM8M7LnLqCrO2JP3oW//1sfuZDKiDEb1AQ8es9Xr/u6bDTnYCTK
# IsDq1BtmXUqEG1NqzJKS4kOmxkYp2WyODi7vQTCBZtVFJfVZ3j7OgWmnhFr4yUoz
# ZtqgPrHRVHhGNKlYzyjlroPxul+bgIspzOwbtmsgY1MCAwEAAaOCAV0wggFZMBIG
# A1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFO9vU0rp5AZ8esrikFb2L9RJ7MtO
# MB8GA1UdIwQYMBaAFOzX44LScV1kTN8uZz/nupiuHA9PMA4GA1UdDwEB/wQEAwIB
# hjATBgNVHSUEDDAKBggrBgEFBQcDCDB3BggrBgEFBQcBAQRrMGkwJAYIKwYBBQUH
# MAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBBBggrBgEFBQcwAoY1aHR0cDov
# L2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZFJvb3RHNC5jcnQw
# QwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lD
# ZXJ0VHJ1c3RlZFJvb3RHNC5jcmwwIAYDVR0gBBkwFzAIBgZngQwBBAIwCwYJYIZI
# AYb9bAcBMA0GCSqGSIb3DQEBCwUAA4ICAQAXzvsWgBz+Bz0RdnEwvb4LyLU0pn/N
# 0IfFiBowf0/Dm1wGc/Do7oVMY2mhXZXjDNJQa8j00DNqhCT3t+s8G0iP5kvN2n7J
# d2E4/iEIUBO41P5F448rSYJ59Ib61eoalhnd6ywFLerycvZTAz40y8S4F3/a+Z1j
# EMK/DMm/axFSgoR8n6c3nuZB9BfBwAQYK9FHaoq2e26MHvVY9gCDA/JYsq7pGdog
# P8HRtrYfctSLANEBfHU16r3J05qX3kId+ZOczgj5kjatVB+NdADVZKON/gnZruMv
# NYY2o1f4MXRJDMdTSlOLh0HCn2cQLwQCqjFbqrXuvTPSegOOzr4EWj7PtspIHBld
# NE2K9i697cvaiIo2p61Ed2p8xMJb82Yosn0z4y25xUbI7GIN/TpVfHIqQ6Ku/qjT
# Y6hc3hsXMrS+U0yy+GWqAXam4ToWd2UQ1KYT70kZjE4YtL8Pbzg0c1ugMZyZZd/B
# dHLiRu7hAWE6bTEm4XYRkA6Tl4KSFLFk43esaUeqGkH/wyW4N7OigizwJWeukcyI
# PbAvjSabnf7+Pu0VrFgoiovRDiyx3zEdmcif/sYQsfch28bZeUz2rtY/9TCA6TD8
# dC3JE3rYkrhLULy7Dc90G6e8BlqmyIjlgp2+VqsS9/wQD7yFylIz0scmbKvFoW2j
# NrbM1pD2T7m3XDCCBY0wggR1oAMCAQICEA6bGI750C3n79tQ4ghAGFowDQYJKoZI
# hvcNAQEMBQAwZTELMAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZ
# MBcGA1UECxMQd3d3LmRpZ2ljZXJ0LmNvbTEkMCIGA1UEAxMbRGlnaUNlcnQgQXNz
# dXJlZCBJRCBSb290IENBMB4XDTIyMDgwMTAwMDAwMFoXDTMxMTEwOTIzNTk1OVow
# YjELMAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQ
# d3d3LmRpZ2ljZXJ0LmNvbTEhMB8GA1UEAxMYRGlnaUNlcnQgVHJ1c3RlZCBSb290
# IEc0MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAv+aQc2jeu+RdSjww
# IjBpM+zCpyUuySE98orYWcLhKac9WKt2ms2uexuEDcQwH/MbpDgW61bGl20dq7J5
# 8soR0uRf1gU8Ug9SH8aeFaV+vp+pVxZZVXKvaJNwwrK6dZlqczKU0RBEEC7fgvMH
# hOZ0O21x4i0MG+4g1ckgHWMpLc7sXk7Ik/ghYZs06wXGXuxbGrzryc/NrDRAX7F6
# Zu53yEioZldXn1RYjgwrt0+nMNlW7sp7XeOtyU9e5TXnMcvak17cjo+A2raRmECQ
# ecN4x7axxLVqGDgDEI3Y1DekLgV9iPWCPhCRcKtVgkEy19sEcypukQF8IUzUvK4b
# A3VdeGbZOjFEmjNAvwjXWkmkwuapoGfdpCe8oU85tRFYF/ckXEaPZPfBaYh2mHY9
# WV1CdoeJl2l6SPDgohIbZpp0yt5LHucOY67m1O+SkjqePdwA5EUlibaaRBkrfsCU
# tNJhbesz2cXfSwQAzH0clcOP9yGyshG3u3/y1YxwLEFgqrFjGESVGnZifvaAsPvo
# ZKYz0YkH4b235kOkGLimdwHhD5QMIR2yVCkliWzlDlJRR3S+Jqy2QXXeeqxfjT/J
# vNNBERJb5RBQ6zHFynIWIgnffEx1P2PsIV/EIFFrb7GrhotPwtZFX50g/KEexcCP
# orF+CiaZ9eRpL5gdLfXZqbId5RsCAwEAAaOCATowggE2MA8GA1UdEwEB/wQFMAMB
# Af8wHQYDVR0OBBYEFOzX44LScV1kTN8uZz/nupiuHA9PMB8GA1UdIwQYMBaAFEXr
# oq/0ksuCMS1Ri6enIZ3zbcgPMA4GA1UdDwEB/wQEAwIBhjB5BggrBgEFBQcBAQRt
# MGswJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBDBggrBgEF
# BQcwAoY3aHR0cDovL2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJl
# ZElEUm9vdENBLmNydDBFBgNVHR8EPjA8MDqgOKA2hjRodHRwOi8vY3JsMy5kaWdp
# Y2VydC5jb20vRGlnaUNlcnRBc3N1cmVkSURSb290Q0EuY3JsMBEGA1UdIAQKMAgw
# BgYEVR0gADANBgkqhkiG9w0BAQwFAAOCAQEAcKC/Q1xV5zhfoKN0Gz22Ftf3v1cH
# vZqsoYcs7IVeqRq7IviHGmlUIu2kiHdtvRoU9BNKei8ttzjv9P+Aufih9/Jy3iS8
# UgPITtAq3votVs/59PesMHqai7Je1M/RQ0SbQyHrlnKhSLSZy51PpwYDE3cnRNTn
# f+hZqPC/Lwum6fI0POz3A8eHqNJMQBk1RmppVLC4oVaO7KTVPeix3P0c2PR3WlxU
# jG/voVA9/HYJaISfb8rbII01YBwCA8sgsKxYoA5AY8WYIsGyWfVVa88nq2x2zm8j
# LfR+cWojayL/ErhULSd+2DrZ8LaHlv1b0VysGMNNn3O3AamfV6peKOK5lDGCA3ww
# ggN4AgEBMH0waTELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMu
# MUEwPwYDVQQDEzhEaWdpQ2VydCBUcnVzdGVkIEc0IFRpbWVTdGFtcGluZyBSU0E0
# MDk2IFNIQTI1NiAyMDI1IENBMQIQCE/cM09+RU7bww+P+ZIYNTANBglghkgBZQME
# AgEFAKCB0TAaBgkqhkiG9w0BCQMxDQYLKoZIhvcNAQkQAQQwHAYJKoZIhvcNAQkF
# MQ8XDTI2MTAwODE5MTQxOFowKwYLKoZIhvcNAQkQAgwxHDAaMBgwFgQUUdmr2gNJ
# c9hPQmaspIJI5rNpxDkwLwYJKoZIhvcNAQkEMSIEIH0b0tAXFF+tZPhSttPreUH0
# AWVYg6RVj/xL1N/PDXKSMDcGCyqGSIb3DQEJEAIvMSgwJjAkMCIEIC2gnaf0Ex+f
# 5y22xebpyWVnVa8EPx6nQswNISDhQev8MA0GCSqGSIb3DQEBAQUABIICABRtcUVv
# keUCl/uD+Sov9pFUt2WV4JsOTPalD3jU5WpwOl4YS2CrWtzvSLpfpvkHXWd14JoJ
# vt+1TIkUBGlcO12rEnwi5kqzmlVtqhameyyNhAxBJbv1PQi7c9T9HwgfriVxI8ix
# l5ThW7+fV9Hd8bFCXfUSj+N1MZDkKbQmkqkrELwc0xT3xmo9JhXBdddoTL+NPi+/
# 1thaJyBs+K/3sRJu0Wk7djXhvv8c+uhIbVq4qRueO9gSXpLjzFTUhBusXQ0j0cYJ
# 85h4+Wem3nYZXlJbRHcPSlW8kUoGUeinTbjp2hJUzc3iKQPwito+FHBoozLc4gE6
# zEbBuD5/3Il2uQiAuCl7x0RxpJxGq/uaz1dldudkIX8ro2gtb5nHi7QiCEde980+
# aMsvXVc0HdakEYKg3FXMtwF0q4hAe9lGFOe3a1lcH6WsTxiRynHn3+GaSqMEYzXj
# DbWVlAEsoNCf+2b69yBIUXmGNzRE8D1kgy8Xh2nvvCHjTePw1v0+nKQNAwYkM89h
# cO5B3aximQUBAvaXN76JqIeeOh5MpFjj36ZQYw/Vd0Yeyy38ofYVqnaI4t+eD5n/
# BKB/85L80eWQ3W5qWPBW4UyeBfWWJtM9QUHeyd0Vb92bIS51x429O+W05kRgnAvD
# ybWhYd+25U5ubDQWnYpwyv+9Th/kLKf/pZ8G
# SIG # End signature block