Checks/EXO/MET-EXO003-SPF.ps1

[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'METCheckInfo',
    Justification = 'Check metadata. Read from the AST by Get-METCheck and never executed.')]
param()

$METCheckInfo = @{
    Name           = 'SPF'
    Severity       = 'High'
    Description    = 'Verifies an SPF record is present, does not use +all or ?all, prefers -all over ~all, and stays within the 10-lookup limit.'
    RequiresModule = @('ExchangeOnlineManagement')
}

# Use pre-fetched accepted domains from context when available; fall back to a live query.
$domains = $null
if ($METContext -and $METContext.AcceptedDomains.Count -gt 0) {
    $domains = @($METContext.AcceptedDomains | Where-Object { $_.Default -or $_.DomainType -eq 'Authoritative' })
}

if (-not $domains) {
    try {
        $domains = @(Get-AcceptedDomain -ErrorAction Stop | Where-Object { $_.Default -or $_.DomainType -eq 'Authoritative' })
    }
    catch {
        New-METCheckResult -CheckId 'MET-EXO003' -Category EXO -Name 'SPF' `
            -Result Fail -Severity High -AffectedObject 'Accepted Domains' `
            -Finding 'Unable to retrieve accepted domains' `
            -Recommendation 'Ensure the account has Exchange View-Only Recipients permission.' `
            -ReferenceUrl 'https://aka.ms/spf' -ErrorMessage $_.ToString()
        return
    }
}

function Measure-SpfLookups {
    param([string] $DomainName, [int] $Depth = 0, [System.Collections.Generic.HashSet[string]] $Visited = $null)

    # RFC 7208 4.6.4 caps a valid record at 10 DNS-querying mechanisms, so a chain
    # deeper than that is already over the limit. Complete=$false here (rather than
    # just stopping) is what stops a truncated walk from being reported as a fact.
    if ($Depth -gt 10) { return [PSCustomObject]@{ Count = 0; Complete = $false } }
    if (-not $Visited) { $Visited = [System.Collections.Generic.HashSet[string]]::new() }
    if (-not $Visited.Add($DomainName)) { return [PSCustomObject]@{ Count = 0; Complete = $true } }

    $count = 0
    $complete = $true
    try {
        $txt = Resolve-METDnsName -Name $DomainName -Type TXT |
            Where-Object { $_.Strings -match '^v=spf1' } |
            Select-Object -First 1

        if (-not $txt) { return [PSCustomObject]@{ Count = 0; Complete = $true } }

        $record = $txt.Strings -join ''
        $terms = $record -split '\s+' | Where-Object { $_ }

        foreach ($term in $terms) {
            if ($term -eq 'v=spf1') {
                continue
            }

            $normalized = $term -replace '^[\+\-\~\?]', ''

            if ($normalized -match '^include:([^\s]+)$') {
                $count += 1
                $nested = Measure-SpfLookups -DomainName $Matches[1] -Depth ($Depth + 1) -Visited $Visited
                $count += $nested.Count
                $complete = $complete -and $nested.Complete
                continue
            }

            if ($normalized -match '^redirect=([^\s]+)$') {
                $count += 1
                $nested = Measure-SpfLookups -DomainName $Matches[1] -Depth ($Depth + 1) -Visited $Visited
                $count += $nested.Count
                $complete = $complete -and $nested.Complete
                continue
            }

            if ($normalized -match '^(a|mx|ptr)([:/].*)?$' -or $normalized -match '^exists:([^\s]+)$') {
                $count += 1
            }
        }
    }
    catch {
        Write-Verbose "DNS lookup failed for '$DomainName' during SPF lookup count: $_"
        $complete = $false
    }

    return [PSCustomObject]@{ Count = $count; Complete = $complete }
}

foreach ($domain in $domains) {
    $spfRecord = $null
    $lookupError = $null

    try {
        $dns = Resolve-METDnsName -Name $domain.DomainName -Type TXT
        $spfRecord = $dns | Where-Object { $_.Strings -match '^v=spf1' } | Select-Object -First 1
    }
    catch {
        $lookupError = $_
        Write-Verbose "DNS lookup failed for '$($domain.DomainName)': $_"
    }

    if ($lookupError) {
        New-METCheckResult -CheckId 'MET-EXO003' -Category EXO -Name 'SPF' `
            -Result Warning -Severity High -AffectedObject $domain.DomainName `
            -Finding 'Unable to determine SPF status because the DNS lookup failed' `
            -Recommendation 'Restore DNS connectivity or install dig/nslookup, then rerun the assessment.' `
            -ReferenceUrl 'https://aka.ms/spf' -ErrorMessage $lookupError.ToString()
        continue
    }

    if (-not $spfRecord) {
        New-METCheckResult -CheckId 'MET-EXO003' -Category EXO -Name 'SPF' `
            -Result Fail -Severity High -AffectedObject $domain.DomainName `
            -Finding 'No SPF TXT record found' `
            -Recommendation "Publish an SPF record: 'v=spf1 include:spf.protection.outlook.com -all'" `
            -ReferenceUrl 'https://aka.ms/spf'
        continue
    }

    $record = $spfRecord.Strings -join ''
    $issues = [System.Collections.Generic.List[string]]::new()
    $terms = @($record -split '\s+' | Where-Object { $_ })

    # RFC 7208 5.1: 'all' is a mechanism term, so it is only an enforcement qualifier
    # when it stands as its own term. Matching the substring '-all' against the whole
    # record reads an include or hostname such as 'a:mail-all.contoso.com' as enforcement.
    $allTerm = $terms | Where-Object { $_ -match '^[+\-~?]?all$' } | Select-Object -First 1
    $allQualifier = $null
    if ($allTerm) {
        $allQualifier = if ($allTerm -match '^([+\-~?])') { $Matches[1] } else { '+' }
    }
    $redirectTerm = $terms | Where-Object { $_ -match '^redirect=(.+)$' } | Select-Object -First 1

    # RFC 7208 2.6.2: a neutral result and an absent 'all' term both fall back to
    # the same default the receiver applies when no SPF record exists at all - the
    # spec requires treating Neutral "exactly like the None result". Those two and
    # '+all' (explicit allow-all) are therefore the same amount of protection: none.
    $allForcesFail = $false
    if ($allQualifier -eq '+') {
        $issues.Add("SPF record uses '+all' (allow all) - any server can send as this domain")
        $allForcesFail = $true
    }
    elseif ($allQualifier -eq '?') {
        $issues.Add("SPF record uses '?all' (neutral) - RFC 7208 requires receivers to treat a neutral result exactly as if no SPF record were published")
        $allForcesFail = $true
    }
    elseif ($allQualifier -eq '~') {
        $issues.Add("SPF record uses '~all' (soft fail) - consider '-all' for strict enforcement")
    }
    elseif (-not $allQualifier -and $redirectTerm) {
        $issues.Add("SPF record has no 'all' mechanism and defers to $redirectTerm - enforcement is whatever that record declares and was not evaluated here")
    }
    elseif (-not $allQualifier) {
        $issues.Add("SPF record has no 'all' mechanism - unmatched senders get the default 'neutral' result, which receivers must treat as if no SPF record were published")
        $allForcesFail = $true
    }

    $lookupResult = Measure-SpfLookups -DomainName $domain.DomainName
    $lookupCount = $lookupResult.Count
    if ($lookupCount -gt 10) {
        $issues.Add("SPF record exceeds 10 DNS lookups ($lookupCount) - may cause SPF permerror")
    }
    elseif (-not $lookupResult.Complete) {
        $issues.Add("SPF lookup count could not be completed - at least $lookupCount DNS-querying mechanisms were counted before a nested lookup failed or the include chain was truncated, so the 10-lookup limit was not verified")
    }

    if ($issues.Count -gt 0) {
        $result = if ($allForcesFail) { 'Fail' } else { 'Warning' }
        New-METCheckResult -CheckId 'MET-EXO003' -Category EXO -Name 'SPF' `
            -Result $result -Severity High -AffectedObject $domain.DomainName `
            -Finding "$($issues -join '; ') | Record: $record" `
            -Recommendation "Use '-all' to strictly reject unauthorised senders. Reduce includes to stay within the 10-lookup limit." `
            -ReferenceUrl 'https://aka.ms/spf'
    }
    else {
        New-METCheckResult -CheckId 'MET-EXO003' -Category EXO -Name 'SPF' `
            -Result Pass -Severity High -AffectedObject $domain.DomainName `
            -Finding "SPF record is present and correctly configured ($lookupCount DNS lookups) | Record: $record" `
            -ReferenceUrl 'https://aka.ms/spf'
    }
}