Exchange-Online/Get-DnsSecurityConfig.ps1

<#
.SYNOPSIS
    Evaluates DNS authentication records (SPF, DKIM, DMARC) against CIS requirements.
.DESCRIPTION
    Checks all authoritative accepted domains for proper SPF, DKIM, and DMARC
    configuration. Produces pass/fail verdicts via Add-Setting for each protocol.

    Requires an active Exchange Online connection for Get-AcceptedDomain and
    Get-DkimSigningConfig cmdlets, unless pre-cached data is provided via parameters.
.PARAMETER OutputPath
    Optional path to export results as CSV. If not specified, results are returned to the pipeline.
.PARAMETER AcceptedDomains
    Pre-cached accepted domain objects from the orchestrator. When provided,
    skips the Get-AcceptedDomain call (avoids EXO session timeout issues).
.PARAMETER DkimConfigs
    Pre-cached DKIM signing configuration objects from the orchestrator. When provided,
    skips the Get-DkimSigningConfig call (EXO may be disconnected during deferred DNS checks).
.EXAMPLE
    PS> . .\Common\Connect-Service.ps1
    PS> Connect-Service -Service ExchangeOnline
    PS> .\Exchange-Online\Get-DnsSecurityConfig.ps1

    Displays DNS security evaluation results.
.EXAMPLE
    PS> .\Exchange-Online\Get-DnsSecurityConfig.ps1 -OutputPath '.\dns-security-config.csv'

    Exports the DNS evaluation to CSV.
.NOTES
    Author: Daren9m
    Settings checked are aligned with CIS Microsoft 365 Foundations Benchmark v6.0.1 recommendations.
#>

[CmdletBinding()]
param(
    [Parameter()]
    [ValidateNotNullOrEmpty()]
    [string]$OutputPath,

    [Parameter()]
    [object[]]$AcceptedDomains,

    [Parameter()]
    [object[]]$DkimConfigs
)

# Stop on errors: API failures should halt this collector rather than produce partial results.
$ErrorActionPreference = 'Stop'

# Load cross-platform DNS resolver (Resolve-DnsName on Windows, dig on macOS/Linux)
$dnsHelperPath = Join-Path -Path $PSScriptRoot -ChildPath '..\Common\Resolve-DnsRecord.ps1'
if (Test-Path -Path $dnsHelperPath) { . $dnsHelperPath }

# Load shared security-config helpers
$_scriptDir = if ($MyInvocation.MyCommand.Path) { Split-Path -Parent $MyInvocation.MyCommand.Path } else { $PSScriptRoot }
. (Join-Path -Path $_scriptDir -ChildPath '..\Common\SecurityConfigHelper.ps1')

$ctx = Initialize-SecurityConfig
$settings = $ctx.Settings
$checkIdCounter = $ctx.CheckIdCounter

function Add-Setting {
    param(
        [string]$Category, [string]$Setting, [string]$CurrentValue,
        [string]$RecommendedValue, [string]$Status,
        [string]$CheckId = '', [string]$Remediation = ''
    )
    $p = @{
        Settings         = $settings
        CheckIdCounter   = $checkIdCounter
        Category         = $Category
        Setting          = $Setting
        CurrentValue     = $CurrentValue
        RecommendedValue = $RecommendedValue
        Status           = $Status
        CheckId          = $CheckId
        Remediation      = $Remediation
    }
    Add-SecuritySetting @p
}

# ------------------------------------------------------------------
# Fetch authoritative domains
# ------------------------------------------------------------------
$authDomains = @()
if ($AcceptedDomains -and $AcceptedDomains.Count -gt 0) {
    # Use pre-cached domains passed by the orchestrator
    Write-Verbose "Using $($AcceptedDomains.Count) pre-cached accepted domain(s)"
    $authDomains = @($AcceptedDomains | Where-Object {
        $_.DomainType -eq 'Authoritative' -and $_.DomainName -notlike '*.onmicrosoft.com'
    })
}
else {
    try {
        Write-Verbose "Fetching accepted domains..."
        $allDomains = Get-AcceptedDomain -ErrorAction Stop
        $authDomains = @($allDomains | Where-Object {
            $_.DomainType -eq 'Authoritative' -and $_.DomainName -notlike '*.onmicrosoft.com'
        })
    }
    catch {
        Write-Warning "Could not retrieve accepted domains: $_"
    }
}
if ($authDomains.Count -gt 0) {
    Write-Verbose "Found $($authDomains.Count) authoritative domain(s)"
}

if ($authDomains.Count -eq 0) {
    $settingParams = @{
        Category         = 'DNS Authentication'
        Setting          = 'SPF Records'
        CurrentValue     = 'No authoritative domains found'
        RecommendedValue = 'SPF for all domains'
        Status           = 'Review'
        CheckId          = 'DNS-SPF-001'
        Remediation      = 'Connect to Exchange Online and verify accepted domains.'
    }
    Add-Setting @settingParams
    $settingParams = @{
        Category         = 'DNS Authentication'
        Setting          = 'DKIM Signing'
        CurrentValue     = 'No authoritative domains found'
        RecommendedValue = 'DKIM for all domains'
        Status           = 'Review'
        CheckId          = 'DNS-DKIM-001'
        Remediation      = 'Connect to Exchange Online and verify accepted domains.'
    }
    Add-Setting @settingParams
    $settingParams = @{
        Category         = 'DNS Authentication'
        Setting          = 'DMARC Records'
        CurrentValue     = 'No authoritative domains found'
        RecommendedValue = 'DMARC for all domains'
        Status           = 'Review'
        CheckId          = 'DNS-DMARC-001'
        Remediation      = 'Connect to Exchange Online and verify accepted domains.'
    }
    Add-Setting @settingParams
}
else {
    # DNS checks use Continue to prevent non-terminating errors (e.g., from
    # Resolve-DnsName SOA fallback records) from escalating under the script-level Stop.
    $ErrorActionPreference = 'Continue'

    # ---- Tracking collections used across all DNS checks -------------------------
    # Domains whose zones return SERVFAIL: skipped in all checks, DNS-ZONE-001 emitted.
    $servfailDomains = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    # Domains with null/defensive SPF (v=spf1 -all): excluded from DKIM evaluation.
    $spfNullDomains = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    # Domains with RFC 7505 null MX (0 .): treated as Pass in MX check.
    $nullMxDomains = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    # Domains with enforcing DMARC (p=reject or p=quarantine): used for lockdown detection.
    $dmarcEnforcingDomains = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)

    # -- SERVFAIL pre-pass: probe each zone before evaluating records ---------------
    # Guarded by Get-Command so test environments that mock Get-Command to return $null
    # for unknown names automatically skip this block without any test changes.
    if (Get-Command -Name Test-DnsZoneAvailable -ErrorAction SilentlyContinue) {
        foreach ($domain in $authDomains) {
            $domainName = $domain.DomainName
            if (-not (Test-DnsZoneAvailable -Name $domainName)) {
                $null = $servfailDomains.Add($domainName)
                Write-Verbose "DNS SERVFAIL detected for zone: $domainName"
            }
        }
    }
    if ($servfailDomains.Count -gt 0) {
        $settingParams = @{
            Category         = 'DNS Authentication'
            Setting          = 'DNS Zone Health'
            CurrentValue     = "SERVFAIL: $($servfailDomains -join ', ')"
            RecommendedValue = 'All accepted domain zones must respond to DNS queries'
            Status           = 'Fail'
            CheckId          = 'DNS-ZONE-001'
            Remediation      = "Investigate DNS zone failures for: $($servfailDomains -join ', '). Contact your DNS provider -- the authoritative nameservers are not responding. SPF, DKIM, DMARC, and MX checks for these domains are suppressed to avoid false positives."
        }
        Add-Setting @settingParams
    }

    # ------------------------------------------------------------------
    # 1. SPF Records (CIS 2.1.8)
    # ------------------------------------------------------------------
    try {
        Write-Verbose "Checking SPF records..."
        $spfMissing = @()
        $spfPresent = @()
        foreach ($domain in $authDomains) {
            $domainName = $domain.DomainName
            if ($servfailDomains.Contains($domainName)) { continue }
            $txtRecords = @(Resolve-DnsRecord -Name $domainName -Type TXT -ErrorAction SilentlyContinue)
            $spfRecord = $txtRecords | Where-Object { $_.Strings -and $_.Strings -match '^v=spf1' }
            if ($spfRecord) {
                $spfPresent += $domainName
                # Detect null/defensive SPF (v=spf1 -all with no mechanisms): domain is a
                # non-sending domain and should be excluded from the DKIM check.
                $spfFull = ($spfRecord.Strings -join '')
                if ($spfFull -match '^v=spf1\s+-all\s*$') {
                    $null = $spfNullDomains.Add($domainName)
                }
            }
            else { $spfMissing += $domainName }
        }

        $spfTotal = $spfPresent.Count + $spfMissing.Count
        if ($spfMissing.Count -eq 0) {
            $settingParams = @{
                Category         = 'DNS Authentication'
                Setting          = 'SPF Records'
                CurrentValue     = "$($spfPresent.Count)/$spfTotal domains have SPF"
                RecommendedValue = 'SPF for all domains'
                Status           = 'Pass'
                CheckId          = 'DNS-SPF-001'
                Remediation      = 'No action needed.'
            }
            Add-Setting @settingParams
        }
        else {
            $settingParams = @{
                Category         = 'DNS Authentication'
                Setting          = 'SPF Records'
                CurrentValue     = "$($spfPresent.Count)/$spfTotal domains -- missing: $($spfMissing -join ', ')"
                RecommendedValue = 'SPF for all domains'
                Status           = 'Fail'
                CheckId          = 'DNS-SPF-001'
                Remediation      = "Add SPF TXT records for: $($spfMissing -join ', '). Example: v=spf1 include:spf.protection.outlook.com -all"
            }
            Add-Setting @settingParams
        }
    }
    catch {
        Write-Warning "Could not check SPF records: $_"
    }

    # ------------------------------------------------------------------
    # 2. DKIM Signing (CIS 2.1.9)
    # ------------------------------------------------------------------
    try {
        Write-Verbose "Checking DKIM configuration..."
        # Use pre-cached DKIM data when available (orchestrator caches before EXO disconnect).
        # Fall back to direct cmdlet call with try/catch for standalone execution.
        if (-not $DkimConfigs) {
            $DkimConfigs = @(Get-DkimSigningConfig -ErrorAction Stop)
        }
        $dkimMissing = @()
        $dkimEnabled = @()
        foreach ($domain in $authDomains) {
            $domainName = $domain.DomainName
            if ($servfailDomains.Contains($domainName)) { continue }
            # Non-sending domains (v=spf1 -all) do not send email: DKIM is not applicable.
            if ($spfNullDomains.Contains($domainName)) { continue }
            $config = $DkimConfigs | Where-Object { $_.Domain -eq $domainName }
            if ($config -and $config.Enabled) { $dkimEnabled += $domainName }
            else { $dkimMissing += $domainName }
        }

        $dkimTotal = $dkimEnabled.Count + $dkimMissing.Count
        if ($dkimMissing.Count -eq 0) {
            $settingParams = @{
                Category         = 'DNS Authentication'
                Setting          = 'DKIM Signing'
                CurrentValue     = "$($dkimEnabled.Count)/$dkimTotal domains have DKIM enabled"
                RecommendedValue = 'DKIM for all sending domains'
                Status           = 'Pass'
                CheckId          = 'DNS-DKIM-001'
                Remediation      = 'No action needed.'
            }
            Add-Setting @settingParams
        }
        else {
            $settingParams = @{
                Category         = 'DNS Authentication'
                Setting          = 'DKIM Signing'
                CurrentValue     = "$($dkimEnabled.Count)/$dkimTotal domains -- missing: $($dkimMissing -join ', ')"
                RecommendedValue = 'DKIM for all sending domains'
                Status           = 'Fail'
                CheckId          = 'DNS-DKIM-001'
                Remediation      = "Enable DKIM for: $($dkimMissing -join ', '). Run: New-DkimSigningConfig -DomainName <domain> -Enabled `$true. Microsoft 365 Defender > Email & collaboration > Policies > DKIM."
            }
            Add-Setting @settingParams
        }
    }
    catch [System.Management.Automation.CommandNotFoundException] {
        $settingParams = @{
            Category         = 'DNS Authentication'
            Setting          = 'DKIM Signing'
            CurrentValue     = 'Get-DkimSigningConfig cmdlet not available'
            RecommendedValue = 'DKIM for all sending domains'
            Status           = 'Review'
            CheckId          = 'DNS-DKIM-001'
            Remediation      = 'Connect to Exchange Online PowerShell to check DKIM configuration.'
        }
        Add-Setting @settingParams
    }
    catch {
        Write-Warning "Could not check DKIM configuration: $_"
    }

    # ------------------------------------------------------------------
    # 3. DMARC Records (CIS 2.1.10)
    # ------------------------------------------------------------------
    try {
        Write-Verbose "Checking DMARC records..."
        $dmarcMissing    = @()
        $dmarcNone       = @()   # p=none — monitoring only, no enforcement
        $dmarcQuarantine = @()   # p=quarantine — staged rollout in progress
        $dmarcReject     = @()   # p=reject — fully enforced
        foreach ($domain in $authDomains) {
            $domainName = $domain.DomainName
            if ($servfailDomains.Contains($domainName)) { continue }
            $dmarcRecords = @(Resolve-DnsRecord -Name "_dmarc.$domainName" -Type TXT -ErrorAction SilentlyContinue)
            $dmarcRecord = $dmarcRecords | Where-Object { $_.Strings -and $_.Strings -match '^v=DMARC1' }
            if (-not $dmarcRecord) {
                $dmarcMissing += $domainName
            }
            else {
                $policy = ($dmarcRecord.Strings | Select-Object -First 1)
                if ($policy -match 'p=reject') {
                    $dmarcReject += $domainName
                    $null = $dmarcEnforcingDomains.Add($domainName)
                }
                elseif ($policy -match 'p=quarantine') {
                    $dmarcQuarantine += $domainName
                    $null = $dmarcEnforcingDomains.Add($domainName)
                }
                else {
                    $dmarcNone += $domainName
                }
            }
        }

        $totalDomains = $dmarcReject.Count + $dmarcQuarantine.Count + $dmarcNone.Count + $dmarcMissing.Count
        if ($dmarcMissing.Count -eq 0 -and $dmarcNone.Count -eq 0 -and $dmarcQuarantine.Count -eq 0) {
            # All domains at p=reject
            $settingParams = @{
                Category         = 'DNS Authentication'
                Setting          = 'DMARC Records'
                CurrentValue     = "$($dmarcReject.Count)/$totalDomains domains at p=reject"
                RecommendedValue = 'DMARC p=reject for all domains'
                Status           = 'Pass'
                CheckId          = 'DNS-DMARC-001'
                Remediation      = 'No action needed.'
            }
            Add-Setting @settingParams
        }
        elseif ($dmarcMissing.Count -eq 0 -and $dmarcNone.Count -eq 0) {
            # All domains at quarantine or reject — staged rollout in progress
            $settingParams = @{
                Category         = 'DNS Authentication'
                Setting          = 'DMARC Records'
                CurrentValue     = "$($dmarcReject.Count)/$totalDomains at p=reject; $($dmarcQuarantine.Count) at p=quarantine (staged): $($dmarcQuarantine -join ', ')"
                RecommendedValue = 'DMARC p=reject for all domains'
                Status           = 'Warning'
                CheckId          = 'DNS-DMARC-001'
                Remediation      = "Advance p=quarantine domains to p=reject once DMARC reports confirm no legitimate mail is failing: $($dmarcQuarantine -join ', ')"
            }
            Add-Setting @settingParams
        }
        else {
            $issues = @()
            if ($dmarcMissing.Count -gt 0) { $issues += "missing: $($dmarcMissing -join ', ')" }
            if ($dmarcNone.Count -gt 0) { $issues += "p=none: $($dmarcNone -join ', ')" }
            if ($dmarcQuarantine.Count -gt 0) { $issues += "p=quarantine (staged): $($dmarcQuarantine -join ', ')" }
            $settingParams = @{
                Category         = 'DNS Authentication'
                Setting          = 'DMARC Records'
                CurrentValue     = "$($dmarcReject.Count)/$totalDomains at p=reject -- $($issues -join '; ')"
                RecommendedValue = 'DMARC p=reject for all domains'
                Status           = 'Fail'
                CheckId          = 'DNS-DMARC-001'
                Remediation      = "Add/update DMARC for: $($issues -join '; '). Start with p=none + rua= to gather reports, then advance to p=quarantine, then p=reject. Example: v=DMARC1; p=reject; rua=mailto:dmarc@yourdomain.com"
            }
            Add-Setting @settingParams
        }
    }
    catch {
        Write-Warning "Could not check DMARC records: $_"
    }

    # ------------------------------------------------------------------
    # 4. MX Records (DNS-MX-001)
    # ------------------------------------------------------------------
    try {
        Write-Verbose "Checking MX records..."
        $mxPass    = @()   # domains routed to Exchange Online
        $mxNullMx  = @()   # domains with RFC 7505 null MX (0 .): intentional non-sending
        $mxWarning = @()   # domains with third-party relay MX
        $mxFail    = @()   # domains with no MX record

        foreach ($domain in $authDomains) {
            $domainName = $domain.DomainName
            if ($servfailDomains.Contains($domainName)) { continue }
            $mxRecords = @(Resolve-DnsRecord -Name $domainName -Type MX -ErrorAction SilentlyContinue)

            if (-not $mxRecords -or $mxRecords.Count -eq 0) {
                $mxFail += $domainName
                continue
            }

            # RFC 7505 null MX: NameExchange is '.' — explicit declaration that the domain
            # accepts no mail. Treat as Pass; non-sending domain lockdown is intentional.
            $isNullMx = $mxRecords | Where-Object { $_.NameExchange -eq '.' -or $_.NameExchange -eq '' }
            if ($isNullMx) {
                $mxNullMx += $domainName
                $null = $nullMxDomains.Add($domainName)
                continue
            }

            $pointsToExo = $mxRecords | Where-Object { $_.NameExchange -like '*.mail.protection.outlook.com' }
            if ($pointsToExo) { $mxPass += $domainName }
            else { $mxWarning += "$domainName ($($mxRecords[0].NameExchange))" }
        }

        $total = $mxPass.Count + $mxNullMx.Count + $mxWarning.Count + $mxFail.Count
        if ($mxFail.Count -eq 0 -and $mxWarning.Count -eq 0) {
            $nullNote = if ($mxNullMx.Count -gt 0) { "; $($mxNullMx.Count) null MX (non-sending)" } else { '' }
            $settingParams = @{
                Category         = 'DNS Authentication'
                Setting          = 'MX Records'
                CurrentValue     = "$($mxPass.Count)/$total domains route to Exchange Online$nullNote"
                RecommendedValue = 'MX pointing to *.mail.protection.outlook.com for all sending domains'
                Status           = 'Pass'
                CheckId          = 'DNS-MX-001'
                Remediation      = 'No action needed.'
            }
            Add-Setting @settingParams
        }
        elseif ($mxFail.Count -gt 0) {
            $details = @()
            if ($mxPass.Count -gt 0)    { $details += "$($mxPass.Count) EXO" }
            if ($mxNullMx.Count -gt 0)  { $details += "$($mxNullMx.Count) null MX" }
            if ($mxWarning.Count -gt 0) { $details += "$($mxWarning.Count) third-party" }
            if ($mxFail.Count -gt 0)    { $details += "missing: $($mxFail -join ', ')" }
            $settingParams = @{
                Category         = 'DNS Authentication'
                Setting          = 'MX Records'
                CurrentValue     = "$($mxPass.Count)/$total to EXO -- $($details -join '; ')"
                RecommendedValue = 'MX pointing to *.mail.protection.outlook.com for all sending domains'
                Status           = 'Fail'
                CheckId          = 'DNS-MX-001'
                Remediation      = "Add MX records for: $($mxFail -join ', '). Required value: <domain>-com.mail.protection.outlook.com"
            }
            Add-Setting @settingParams
        }
        else {
            # All domains have MX but some point to third-party relays (Proofpoint, Mimecast, etc.)
            $settingParams = @{
                Category         = 'DNS Authentication'
                Setting          = 'MX Records'
                CurrentValue     = "$($mxPass.Count)/$total to EXO; third-party relay: $($mxWarning -join '; ')"
                RecommendedValue = 'MX pointing to *.mail.protection.outlook.com for all sending domains'
                Status           = 'Warning'
                CheckId          = 'DNS-MX-001'
                Remediation      = 'Verify third-party relay is intentional (e.g. Proofpoint, Mimecast). If not, update MX to <domain>-com.mail.protection.outlook.com.'
            }
            Add-Setting @settingParams
        }
    }
    catch {
        Write-Warning "Could not check MX records: $_"
    }

    # -- Defensive lockdown Info: domains with full non-sending lockdown pattern ----
    # Requires all three signals: null SPF (v=spf1 -all) + null MX (RFC 7505) +
    # enforcing DMARC (p=reject or p=quarantine). Missing any one signal means the
    # domain is only partially protected.
    $lockdownDomains = @($authDomains | Where-Object {
        $spfNullDomains.Contains($_.DomainName) -and
        $nullMxDomains.Contains($_.DomainName) -and
        $dmarcEnforcingDomains.Contains($_.DomainName)
    } | ForEach-Object { $_.DomainName })
    if ($lockdownDomains.Count -gt 0) {
        $settingParams = @{
            Category         = 'DNS Authentication'
            Setting          = 'Non-Sending Domain Lockdown'
            CurrentValue     = "$($lockdownDomains.Count) domain(s) fully locked down: $($lockdownDomains -join ', ')"
            RecommendedValue = 'v=spf1 -all, null MX (0 . per RFC 7505), DMARC p=reject for non-sending domains'
            Status           = 'Pass'
            CheckId          = 'DNS-LOCKDOWN-001'
            Remediation      = 'No action needed.'
        }
        Add-Setting @settingParams
    }

    $ErrorActionPreference = 'Stop'
}

# ------------------------------------------------------------------
# Output
# ------------------------------------------------------------------
Export-SecurityConfigReport -Settings $settings -OutputPath $OutputPath -ServiceLabel 'DNS Authentication'