public/cisa/exchange/Test-MtCisaSpfDirective.ps1

<#
.SYNOPSIS
    Checks state of SPF records for all exo domains

.DESCRIPTION
    An SPF policy SHALL be published for each domain, designating only these addresses as approved senders.

.EXAMPLE
    Test-MtCisaSpfDirective

    Returns true if SPF record exists and has at least one directive

.LINK
    https://maester.dev/docs/commands/Test-MtCisaSpfDirective
#>

function Test-MtCisaSpfDirective {
    [CmdletBinding()]
    [OutputType([bool])]
    param()

    if(!(Test-MtConnection ExchangeOnline)){
        Add-MtTestResultDetail -SkippedBecause NotConnectedExchange
        return $null
    }

    $acceptedDomains = Get-MtExo -Request AcceptedDomain
    $sendingDomains = $acceptedDomains | Where-Object {`
        -not $_.SendingFromDomainDisabled
    }

    $spfRecords = @()
    foreach($domain in $sendingDomains){
        $spfRecord = Get-MailAuthenticationRecord -DomainName $domain.DomainName -Records SPF
        $spfRecord | Add-Member -MemberType NoteProperty -Name "pass" -Value "Failed"
        $spfRecord | Add-Member -MemberType NoteProperty -Name "reason" -Value ""

        $directives = ($spfRecord.spfRecord.terms|Where-Object {`
            $_.mechanismTarget -ne ""
        }).directive

        $check = "include:spf.protection.outlook.com" -in $directives

        if(($directives|Measure-Object).Count -ge 1 -and $check){
            $spfRecord.pass = "Passed"
            $spfRecord.reason = "1+ mechanism targets"
        }elseif($domain.IsCoexistenceDomain){
            $spfRecord.pass = "Skipped"
            $spfRecord.reason = "coexistence domain"
        }elseif(($directives|Measure-Object).Count -ge 1 -and -not $check){
            $spfRecord.reason = "No EXO directive"
        }elseif($spfRecord.spfRecord -like "*not available"){
            $spfRecord.pass = "Skipped"
            $spfRecord.reason = $spfRecord.spfRecord
        }elseif($spfRecord.spfRecord.GetType().Name -eq "SPFRecord"){
            if($spfRecord.spfRecord.terms[-1].modifier -eq "redirect"){
                $spfRecord.pass = "Skipped"
                $spfRecord.reason = "Redirect modifier"
            }
        }else{
            #$spfRecord.reason = "No mechanism targets"
            $spfRecord.reason = "Failure to obtain record"
        }

        #Hacky sort, doesn't handle IPv6
        #$spfRecord.spfLookups.IPAddress|sort -Property {[system.version]($_ -replace "\/\d{1,3}$","")}
        #Proper but will need to update Resolve-SPFRecord
        #Too: https://learn.microsoft.com/en-us/dotnet/api/system.net.ipnetwork
        #[ipaddress]::HostToNetworkOrder(([ipaddress]$_).address)

        $spfRecords += $spfRecord
    }

    if("Failed" -in $spfRecords.pass){
        $testResult = $false
    }elseif("Failed" -notin $spfRecords.pass -and "Passed" -notin $spfRecords.pass){
        Add-MtTestResultDetail -SkippedBecause NotSupported
        return $null
    }else{
        $testResult = $true
    }

    if($testResult){
        $testResultMarkdown = "Well done. Your tenant's domains have at least 1 directives with specific mechanism targets, review authorized senders for accuracy.`n`n%TestResult%"
    }else{
        $testResultMarkdown = "Your tenant's domains do not restrict authorized senders with SPF fully. Ensure authorized senders are specified.`n`n%TestResult%"
    }

    $passResult = "✅ Pass"
    $failResult = "❌ Fail"
    $skipResult = "🗄️ Skip"
    $result = "| Domain | Result | Reason | Directives |`n"
    $result += "| --- | --- | --- | --- |`n"
    foreach ($item in $spfRecords | Sort-Object -Property domain) {
        switch($item.pass){
            "Passed" {$itemResult = $passResult}
            "Skipped" {$itemResult = $skipResult}
            "Failed" {$itemResult = $failResult}
        }
        $itemDirectives = ($item.spfRecord.terms|Where-Object {`
            $_.mechanismTarget -ne ""
        }).directive
        $itemDirectiveCount = ($itemDirectives|Measure-Object).Count
        switch($itemDirectiveCount){
            0 {
                $itemList = ""
            }
            1 {
                $itemList = "$($itemDirectives)"
            }
            2 {
                $itemList = "$($itemDirectives[0]), "
                $itemList += "$($itemDirectives[1])"
            }
            Default {
                $itemList = "$($itemDirectives[0]), "
                $itemList += "$($itemDirectives[1]), "
                $itemList += "& ...$($itemDirectiveCount) directives"
            }
        }
        $result += "| $($item.domain) | $($itemResult) | $($item.reason) | $($itemList) |`n"
    }

    $testResultMarkdown = $testResultMarkdown -replace "%TestResult%", $result

    Add-MtTestResultDetail -Result $testResultMarkdown

    return $testResult
}