Checks/EXO/MET-EXO001-DMARC.ps1

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

$METCheckInfo = @{
    Name           = 'DMARC'
    Severity       = 'High'
    Description    = 'Verifies a DMARC record is present, its policy is quarantine or reject rather than none, and rua reporting is configured.'
    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-EXO001' -Category EXO -Name 'DMARC' `
            -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/dmarc' -ErrorMessage $_.ToString()
        return
    }
}

function Get-METDmarcRecommendation {
    param(
        [Parameter(Mandatory)] [string] $DomainName,
        [Parameter(Mandatory)] [bool] $IsOnMicrosoftDomain
    )

    if ($IsOnMicrosoftDomain) {
        return "Add a DMARC TXT record for $DomainName in Microsoft 365 admin center (Settings > Domains > $DomainName > DNS records). Recommended value: 'v=DMARC1; p=reject; rua=mailto:dmarc-reports@$DomainName'."
    }

    return "Publish a DMARC TXT record at _dmarc.$DomainName with at minimum 'v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@$DomainName'."
}

foreach ($domain in $domains) {
    $domainName = [string]$domain.DomainName
    $isMailOnMicrosoft = $domainName -match '(?i)\.mail\.onmicrosoft\.com$'
    $isOnMicrosoftDomain = $domainName -match '(?i)\.onmicrosoft\.com$'

    if ($isMailOnMicrosoft) {
        New-METCheckResult -CheckId 'MET-EXO001' -Category EXO -Name 'DMARC' `
            -Result NotApplicable -Severity Informational -AffectedObject $domainName `
            -Finding 'mail.onmicrosoft.com service domain is Microsoft-managed and not intended for customer DMARC DNS management.' `
            -Recommendation 'No action needed unless Microsoft guidance for this service domain changes.' `
            -ReferenceUrl 'https://aka.ms/dmarc'
        continue
    }

    $dmarcRecord = $null
    try {
        $dns = Resolve-METDnsName -Name "_dmarc.$domainName" -Type TXT
        $dmarcRecord = $dns | Where-Object { $_.Strings -match '^v=DMARC1' } | Select-Object -First 1
    }
    catch {
        New-METCheckResult -CheckId 'MET-EXO001' -Category EXO -Name 'DMARC' `
            -Result Warning -Severity High -AffectedObject $domainName `
            -Finding 'Unable to determine DMARC status because the DNS lookup failed' `
            -Recommendation 'Restore DNS connectivity or install dig/nslookup, then rerun the assessment.' `
            -ReferenceUrl 'https://aka.ms/dmarc' -ErrorMessage $_.ToString()
        continue
    }

    if (-not $dmarcRecord) {
        New-METCheckResult -CheckId 'MET-EXO001' -Category EXO -Name 'DMARC' `
            -Result Fail -Severity High -AffectedObject $domainName `
            -Finding 'No DMARC TXT record found' `
            -Recommendation (Get-METDmarcRecommendation -DomainName $domainName -IsOnMicrosoftDomain $isOnMicrosoftDomain) `
            -ReferenceUrl 'https://aka.ms/dmarc'
        continue
    }

    $record = ($dmarcRecord.Strings -join '') -join ''
    $issues = [System.Collections.Generic.List[string]]::new()

    # Tags are matched as ; -delimited name=value pairs, not substrings of the whole
    # record, so sp=/np= (subdomain / non-existent-subdomain policy) can never be
    # misread as p= (the domain's own policy) - a plain 'p=none' regex matches inside
    # both. RFC 7489 6.4 also allows whitespace around '='.
    $tags = @{}
    foreach ($part in $record -split ';') {
        if ($part -match '^\s*([A-Za-z][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$') {
            $tagName = $Matches[1].ToLowerInvariant()
            if (-not $tags.ContainsKey($tagName)) {
                $tags[$tagName] = $Matches[2]
            }
        }
    }

    if (-not $tags.ContainsKey('p')) {
        $issues.Add('DMARC record has no policy (p=) tag - the record is not valid and receivers will ignore it')
    }
    elseif ($tags['p'] -eq 'none') {
        $issues.Add("DMARC policy is 'none' - no enforcement; emails failing DMARC are not quarantined or rejected")
    }
    elseif ($tags['p'] -notin @('quarantine', 'reject')) {
        $issues.Add('DMARC policy is not set to quarantine or reject')
    }

    if (-not $tags.ContainsKey('rua') -or [string]::IsNullOrWhiteSpace($tags['rua'])) {
        $issues.Add('No aggregate reporting address (rua=) configured - DMARC reports will not be received')
    }

    if ($tags.ContainsKey('sp') -and $tags['sp'] -eq 'none' -and $tags.ContainsKey('p') -and $tags['p'] -in @('quarantine', 'reject')) {
        $issues.Add("Subdomain policy is 'none' (sp=none) - subdomains of this domain are unprotected even though the domain policy enforces")
    }

    if ($issues.Count -gt 0) {
        New-METCheckResult -CheckId 'MET-EXO001' -Category EXO -Name 'DMARC' `
            -Result Fail -Severity High -AffectedObject $domainName `
            -Finding "$($issues -join '; ') | Record: $record" `
            -Recommendation "Update DMARC policy to 'quarantine' or 'reject' and add an rua= reporting address." `
            -ReferenceUrl 'https://aka.ms/dmarc'
    }
    else {
        New-METCheckResult -CheckId 'MET-EXO001' -Category EXO -Name 'DMARC' `
            -Result Pass -Severity High -AffectedObject $domainName `
            -Finding "DMARC record present with enforcement policy and reporting configured | Record: $record" `
            -ReferenceUrl 'https://aka.ms/dmarc'
    }
}