src/MailPolicyExplainer.psm1
<#
MailPolicyExplainer.psm1 -- source file for said module Copyright (C) 2018, 2020, 2023-2024 Colin Cogle. All Rights Reserved. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>. #> #region Helper functions # The following functions are used internally by MailPolicyExplainer and are not # exposed to the end user. Function Write-GoodNews { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification='Output colored text in a PS5-compatible manner.')] [OutputType([Void])] Param( [Parameter(Position=0)] [AllowNull()] [String] $Message ) Write-Host -ForegroundColor Green -Object "â `t$Message" } Function Write-BadPractice { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification='Output colored text in a PS5-compatible manner.')] [OutputType([Void])] Param( [Parameter(Position=0)] [AllowNull()] [String] $Message ) Write-Host -ForegroundColor Yellow -Object "🟨`t$Message" } Function Write-BadNews { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification='Output colored text in a PS5-compatible manner.')] [OutputType([Void])] Param( [Parameter(Position=0)] [AllowNull()] [String] $Message ) Write-Host -ForegroundColor Red -Object "â`t$Message" } Function Write-Informational { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification='Output colored text in a PS5-compatible manner.')] [OutputType([Void])] Param( [Parameter(Mandatory, Position=0)] [AllowNull()] [String] $Message ) Write-Host -ForegroundColor White -Object "âšī¸`t$Message" } Function Write-DnsLookups { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification='We are counting multiple lookups.')] [OutputType([String])] Param( [Parameter(Mandatory, Position=0)] [UInt32] $DnsLookups, [Switch] $Enabled ) If ($Enabled) { Return " ($DnsLookups/10 DNS lookups)" } } Function Get-RandomString { [OutputType([String])] Param() # We're going to return a random string of varying length to prevent passive # cryptanalysis attacks (which are extremely unlikely). 16 to 256 bytes of # added entropy should be sufficient, without pushing our packets too close # to the smallest-possible MTU of 576 bytes (for IPv4). $retvalLength = Get-Random -Minimum 16 -Maximum 256 # Per Google's advice, we will use random padding consisting of URL-safe # characters: A-Z, a-z, 0-9, period, underscore, hyphen, and tilde. # Because Get-Random removes an item after selecting it, we're "multiplying" # this string array by 30, so that we can pull up to $(2048 - 90) characters # in Invoke-GooglePublicDnsApi (in case someone decides to increase the # -Maximum value to the previous Get-Random call). $chars = [Char[]]('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789~._-' * 30) Return ((Get-Random -InputObject $chars -Count $retvalLength) -Join '') } Function Get-RSAPublicKeyLength { [OutputType([UInt16])] Param( [Parameter(Mandatory, Position=0)] [String] $PublicKey ) $rsa = [Security.Cryptography.RSACryptoServiceProvider]::new() # .NET 7 adds the ImportFromPem method to instances of the RSA class. # If it's available, use it. If ($null -ne (Get-Member -InputObject $rsa | Where-Object Name -eq 'ImportFromPem')) { $rsa.ImportFromPem("-----BEGIN PUBLIC KEY-----`r`n$PublicKey`r`n-----END PUBLIC KEY-----") Return $rsa.KeySize } # If we're using the older .NET Framework (Windows PowerShell), then we can # only guess on the key length by looking at the size of the encoded data. # If anyone knows a better way to make this work on .NET 6 and older, please # submit a pull request! Else { Write-Verbose 'Accurate DKIM key length detection requires PowerShell 7. We will do our best to guess.' Switch ($PublicKey.Length) { 392 {Return 2048} 216 {Return 1024} 168 {Return 768} 128 {Return 512} default {Return 'unknown'} } } } Function Test-IPv4Address { [CmdletBinding()] [OutputType([Bool])] Param( [Parameter(Mandatory, Position=0)] [ValidateNotNullOrEmpty()] [String] $HostName ) Return (Invoke-GooglePublicDnsApi $HostName -Type 'A').PSObject.Properties.Name -Match 'Answer' } Function Test-IPv6Address { [CmdletBinding()] [OutputType([Bool])] Param( [Parameter(Mandatory, Position=0)] [ValidateNotNullOrEmpty()] [String] $HostName ) Return (Invoke-GooglePublicDnsApi $HostName -Type 'AAAA').PSObject.Properties.Name -Match 'Answer' } #endregion Helper functions Function Invoke-GooglePublicDnsApi { [CmdletBinding()] [OutputType([PSObject])] Param( [Parameter(Position=0, Mandatory)] [ValidateNotNullOrEmpty()] [String] $InputObject, [Parameter(Position=1)] [ValidateSet('A', 'AAAA', 'CNAME', 'MX', 'SPF', 'TLSA', 'TXT')] [String] $Type = 'A', [Alias('CD', 'DnssecCD', 'NoDnssec', 'DisableDnssec')] [Switch] $DisableDnssecVerification ) $MaxLengthOfPadding = 1958 - $InputObject.Length - $Type.Length If ($DisableDnssecVerification) { $CD = 1 } Else { $CD = 0 } $ToSend = @{ 'name' = $InputObject 'type' = $Type 'ct' = 'application/x-javascript' 'cd' = $CD # enable DNSSEC validation (by default)... 'do' = 0 # ...but don't return RRSIGs. Trust the resolver. 'random_padding' = Get-RandomString -MaxLength $MaxLengthOfPadding -MinLength $MaxLengthOfPadding } Write-Verbose "Sending $($ToSend.random_padding.Length) characters of random padding." # DNS-over-HTTPS requests are supposed to use HTTP/2 or newer. However, # Invoke-RestMethod's -HttpVersion parameter was added in PowerShell 7.3. # Downlevel versions of PowerShell only used HTTP/1.1, which is thankfully # supported by the Google Public DNS API. # # Thus, our code will attempt to use HTTP/3 if it's available, and fall back # to the system default if not. $RequestParams = @{ 'Method' = 'GET' 'Uri' = 'https://dns.google/resolve' 'Body' = $ToSend 'Verbose' = $VerbosePreference } If ((Get-Command 'Invoke-RestMethod').Parameters.Keys -Contains 'HttpVersion') { $RequestParams += @{'HttpVersion' = '3.0'} } $result = Invoke-RestMethod @RequestParams Write-Debug $result Return $result } Function Test-IPVersions { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification='We are always testing both IP versions.')] [CmdletBinding()] [OutputType([Void])] Param( [Parameter(Mandatory, Position=0)] [ValidateNotNullOrEmpty()] [String] $HostName, [Parameter(DontShow)] [Switch] $IndentOutput ) $Indent = '' If ($IndentOutput) { $Indent = 'âââ' } If (Test-IPv4Address $HostName) { Write-GoodNews "${Indent}IP: The server $HostName has an IPv4 address." } Else { Write-BadPractice "${Indent}IP: The server $HostName has no IPv4 addresses. IPv4-only clients cannot reach this server." } If (Test-IPv6Address $HostName) { Write-GoodNews "${Indent}IP: The server $HostName has an IPv6 address." } Else { Write-BadPractice "${Indent}IP: The server $HostName has no IPv6 addresses. IPv6-only clients cannot reach this server!" } } Function Test-AdspRecord { [CmdletBinding()] [OutputType([Void])] Param( [Parameter(Mandatory, Position=0)] [ValidateNotNullOrEmpty()] [string] $DomainName, [Alias('CD', 'DnssecCD', 'NoDnssec', 'DisableDnssec')] [Switch] $DisableDnssecVerification ) $DnsLookup = Invoke-GooglePublicDnsApi "_adsp._domainkey.$DomainName" 'TXT' -Debug:$DebugPreference -DisableDnssecVerification:$DisableDnssecVerification $ADSPRecordFound = $DnsLookup.PSObject.Properties.Name -Contains 'Answer' -and $DnsLookup.Status -ne 3 #region DNSSEC check # Since DKIM ADSP is historic, I don't want the DNSSEC-authenticated denial # of existence to show up when using Test-MailPolicy. Only show the DNSSEC # information when calling this function directly, or if there is an ADSP # record to display. If (-Not $DisableDnssecVerification -and ($ADSPRecordFound -or ((Get-PSCallStack).Command)[1] -ne 'Test-MailPolicy')) { If ($DnsLookup.AD) { Write-GoodNews "DKIM ADSP: This DNS lookup is secure." } Else { Write-BadPractice "DKIM ADSP: This DNS lookup is insecure. Enable DNSSEC for this domain." } } #endregion If (-Not $ADSPRecordFound) { Write-Verbose 'DKIM ADSP: No ADSP record was found.' } Else { Write-BadPractice "DKIM ADSP: Author Domain Signing Practices is declared historic and should not be relied on." $AdspRecord = $DnsLookup.Answer.Data If ($AdspRecord -Eq "dkim=unknown") { Write-Informational "DKIM ADSP: This domain's ADSP is unknown; it may sign no, some, most, or all email with DKIM." } ElseIf ($AdspRecord -Eq "dkim=all") { Write-GoodNews "DKIM ADSP: ADSP says all email from this domain will have a DKIM signature." } ElseIf ($AdspRecord -Eq "dkim=discardable") { Write-GoodNews "DKIM ADSP: ADSP says all email from this domain will have a DKIM signature, and mail with a missing or bad signature should be discarded." } Else { Write-BadNews "DKIM ADSP: An invalid ADS practice was specified ($AdspRecord)." } } } Function Test-BimiSelector { [CmdletBinding()] [OutputType([Void])] [Alias('Test-BimiRecord')] Param( [Parameter(Mandatory, Position=0)] [ValidateNotNullOrEmpty()] [string] $DomainName, [Parameter(Position=1)] [Alias('Selector', 'SelectorName')] [string] $Name = 'default', [Alias('CD', 'DnssecCD', 'NoDnssec', 'DisableDnssec')] [Switch] $DisableDnssecVerification ) $DnsLookup = Invoke-GooglePublicDnsApi "$Name._bimi.$DomainName" 'TXT' -Debug:$DebugPreference -DisableDnssecVerification:$DisableDnssecVerification #region DNSSEC check If (-Not $DisableDnssecVerification) { If ($DnsLookup.AD) { Write-GoodNews "BIMI selector ${Selector}: This DNS lookup is secure." } Else { Write-BadPractice "BIMI selector ${Selector}: This DNS lookup is insecure. Enable DNSSEC for this domain." } } #endregion If ($DnsLookup.PSObject.Properties.Name -NotContains 'Answer' -or $DnsLookup.Status -eq 3) { Write-Informational "BIMI selector ${Selector}: Not found!" Return } $BimiRecord = ($DnsLookup.Answer | Where-Object type -eq 16).Data If ($null -eq $BimiRecord) { Write-BadNews "BIMI selector ${Selector}: A record exists with no valid data!" Return } ForEach ($token in ($BimiRecord -Split ';')) { $token = $token.Trim() If ($token -Eq "v=BIMI1") { Write-GoodNews "BIMI selector ${Selector}: This is a BIMI version 1 record." } # BIMI evidence document tag ElseIf ($token -Like "a=*") { $policy = $token -Replace 'a=' If ($null -ne $policy) { Write-GoodNews "BIMI selector ${Selector}: An authority evidence document can be found at $policy." } Else { Write-Informational 'BIMI selector ${Selector}: No authority evidence is available.' } } ElseIf ($token -Like "l=*") { $locationURI = $token -Replace 'l=' If ($null -eq $locationURI) { Write-Informational "BIMI selector ${Selector}: This domain does not participate in BIMI." } ElseIf ($locationURI -Like 'https://*') { Write-GoodNews "BIMI selector ${Selector}: The brand indicator is at $locationURI." } Else { Write-BadNews "BIMI selector ${Selector}: The brand indicator must be available over HTTPS! ($locationURI)" } } ElseIf ($token.Length -gt 0) { Write-BadNews "BIMI selector ${Selector}: An invalid tag was specified ($token)." } } } Function Test-DaneRecord { [CmdletBinding()] [OutputType([Void])] [Alias('Test-DaneRecords', 'Test-TlsaRecord', 'Test-TlsaRecords')] Param( [Parameter(Mandatory, Position=0)] [ValidateNotNullOrEmpty()] [String] $DomainName, [Parameter(DontShow)] [Alias('CD', 'DnssecCD', 'NoDnssec', 'DisableDnssec')] [Switch] $DisableDnssecVerification ) # Fetch all MX records for this domain. $MXServers = @() Invoke-GooglePublicDnsApi $DomainName 'MX' -Debug:$DebugPreference ` | Select-Object -ExpandProperty Answer ` | Where-Object type -eq 15 ` | Select-Object -ExpandProperty Data ` | ForEach-Object ` { $Preference, $Name = $_ -Split "\s+" $MXServers += @{'Preference'=[UInt16]$Preference; 'Server'=$Name} } If ($MXServers.Count -eq 1 -and $MXServers[0].Server -eq '.') { Write-Verbose 'DANE: This domain does not receive email.' Return } # Check for the confusing case where a domain has no MX servers, and does # not publish a null MX record. In that case, the domain's A and AAAA records # will be substituted as a mail exchanger with preference 0. (Really, that's # what it says to do in the RFC. Go look it up.) # # We're checking for a count of zero, or a count of one where the server # name is blank, just in case I add options for other DNS APIs in the future. # Google Public DNS's API returns the latter format. If ($MXServers.Count -eq 0) { $MXServers = @(@{'Preference'=0; 'Server'=$DomainName}) } $MXServers | Sort-Object Preference | ForEach-Object { # Strip the trailing dot, if present. This is done for display purposes. $MXName = $_.Server -Replace '\.$' $DnsLookup = Invoke-GooglePublicDnsApi "_25._tcp.$MXName" 'TLSA' -Debug:$DebugPreference $FoundDANERecords = ($DnsLookup.PSObject.Properties.Name -Contains 'Answer') -and ($DnsLookup.Status -ne 2) -and ($DnsLookup.Status -ne 3) #region DNSSEC check # Complain if the user attempted to disable DNSSEC checking. That's a # requirement for DANE. Politely refuse to honor the user's request and # check DNSSEC anyway. This will only happen if the user is entering # this function call via Test-MailPolicy. If ($FoundDANERecords) { If ($DisableDnssecVerification -and -not $ShowedDnssecWarning) { Write-Informational 'DANE: Records must be signed with DNSSEC. Validating DNSSEC anyway.' $ShowedDnssecWarning = $true } If ($DnsLookup.AD) { Write-GoodNews "DANE: ${MXName}: The DNS lookup is secure." } Else { Write-BadNews "DANE: ${MXName}: The DNS lookup is insecure; the DANE records cannot be used! Enable DNSSEC for this domain." Return } } #endregion If (-Not $FoundDANERecords) { Write-BadNews "DANE: DANE records are not present for ${MXName}, TCP port 25." Return } ($DnsLookup.Answer | Where-Object type -eq 52).Data | ForEach-Object { $Usage, $Selector, $Type, $CertData = $_ -Split '\s+' If ($Selector -NotIn 0,1) { Write-BadNews "DANE: ${MXName}: The DANE record is invalid! (Unknown Selector $Selector)" Continue } ElseIf ($Type -NotIn 0,1,2) { Write-BadNews "DANE: ${MXName}: The DANE record is invalid! (Unknown Type $Selector)" Continue } Switch ($Usage) { 0 { Write-BadPractice "DANE: ${MXName}: Found a PKIX-TA record, which is not supported for SMTP: $Usage $Selector $Type $CertData (not checked)" } 1 { Write-BadPractice "DANE: ${MXName}: Found a PKIX-EE record, which is not supported for SMTP: $Usage $Selector $Type $CertData (not checked)" } 2 { Write-GoodNews "DANE: ${MXName}: Found a DANE-TA record: $Usage $Selector $Type $CertData (not checked)" } 3 { Write-GoodNews "DANE: ${MXName}: Found a DANE-EE record: $Usage $Selector $Type $CertData (not checked)" } default { Write-BadNews "DANE: ${MXName}: The DANE record is invalid! (Unknown Usage $Usage)" } } } } } Function Test-DkimSelector { [CmdletBinding()] [OutputType([Void])] [Alias('Test-DkimRecord', 'Test-DomainKeysSelector', 'Test-DomainKeysRecord')] Param( [Parameter(Mandatory, Position=0)] [ValidateNotNullOrEmpty()] [string] $DomainName, [Parameter(Mandatory, Position=1)] [Alias('Selector', 'SelectorName', 'KeyName')] [string]$Name, [Alias('CD', 'DnssecCD', 'NoDnssec', 'DisableDnssec')] [Switch] $DisableDnssecVerification ) $DnsLookup = Invoke-GooglePublicDnsApi "$Name._domainkey.$DomainName" 'TXT' -Debug:$DebugPreference -DisableDnssecVerification:$DisableDnssecVerification $Name = " $Name" #region DNSSEC check If (-Not $DisableDnssecVerification) { If ($DnsLookup.AD) { Write-GoodNews "DKIM selector${Name}: This DNS lookup is secure." } Else { Write-BadPractice "DKIM selector${Name}: This DNS lookup is insecure. Enable DNSSEC for this domain." } } #endregion If ($DnsLookup.PSObject.Properties.Name -NotContains 'Answer' -or $DnsLookup.Status -eq 3) { Write-BadNews "DKIM selector${Name}: This selector was not found." Return } $DkimKeyRecord = ($DnsLookup.Answer | Where-Object type -eq 16).Data If ($null -eq $DkimKeyRecord) { Write-BadNews "DKIM selector${Name}: This selector was not found in DNS." Return } Else { Write-Verbose "DKIM selector${Name}: `"$DkimKeyRecord`"" } #region Check for default values. # If there is no "k=" token, it's assumed to be "k=rsa" (per the RFC). # Additionally, if there is no "v=" token, it's assumed to be "v=DKIM1". $VersionImplied = $false $KeyTypeImplied = $false If ($DkimKeyRecord -NotLike "*v=*") { $DkimKeyRecord = "v=DKIM1; $DkimKeyRecord" $VersionImplied = $true } If ($DkimKeyRecord -NotLike "*k=*") { $DkimKeyRecord = $DkimKeyRecord.Replace(';', ';k=rsa;', 1) $KeyTypeImplied = $true } #endregion ForEach ($token in ($DkimKeyRecord -Split ';')) { $token = $token.Trim() If ($token -Like "v=*") { $version = $token -Replace 'v=','' If ($VersionImplied) { Write-GoodNews "DKIM selector${Name}: This is implied to conform to DKIM version 1." } ElseIf ($version -Eq 'DKIM1') { Write-GoodNews "DKIM selector${Name}: This conforms to DKIM version 1." } Else { Write-BadNews "DKIM selector${Name}: This does not conform to DKIM version 1." Return } } ElseIf ($token -Like "s=*") { ForEach ($purpose in ($token -Replace 's=' -Split ':')) { $purpose = $purpose.Trim() If ($purpose -Eq '*') { Write-GoodNews "DKIM selector${Name}: This key is valid for all purposes." } ElseIf ($purpose -Eq 'email') { Write-GoodNews "DKIM selector${Name}: This key is valid for email." } Else { Write-BadPractice "DKIM selector${Name}: This key is valid for $purpose, which is not part of the DKIM specification." } } } ElseIf ($token -Like "k=*") { $algorithm = $token -Replace 'k=' If ($KeyTypeImplied) { Write-GoodNews "DKIM selector${Name}: This is implied to have an RSA key." } ElseIf ($algorithm -Eq 'rsa') { Write-GoodNews "DKIM selector${Name}: This has an RSA key." } ElseIf ($algorithm -eq 'ed25519') { Write-GoodNews "DKIM selector${Name}: This has an Ed25519 key. Not all verifiers can verify these newer keys." } Else { Write-BadNews "DKIM selector${Name}: This has an unknown key type ($algorithm)!" } } ElseIf ($token -Like "h=*") { ForEach ($algorithm in ($token -Replace 'h=' -Split ':')) { $algorithm = $algorithm.Trim() If ($algorithm -Eq 'sha1') { Write-BadPractice "DKIM selector${Name}: This key will sign only SHA-1 hashes, which are deprecated." } ElseIf ($algorithm -Eq 'sha256') { Write-GoodNews "DKIM selector${Name}: This key will sign only SHA-256 hashes." } Else { Write-BadNews "DKIM selector${Name}: This key will sign only $algorithm hashes, which are not part of the DKIM specification." } } } ElseIf ($token -Like 't=*') { ForEach ($flag in ($token -Replace 't=' -Split ':')) { $flag = $flag.Trim() If ($flag -Eq 'y') { Write-Informational "DKIM selector${Name}: This domain is testing DKIM; recipients should treat signed and unsigned messages identically." } ElseIf ($flag -Eq 's') { Write-GoodNews "DKIM selector${Name}: This selector is not valid for subdomains." } Else { Write-BadNews "DKIM selector${Name}: An unknown flag $flag was specified." } } } ElseIf ($token -Like "g=*") { $username = $token -Replace 'g=' Write-Informational "DKIM selector${Name}: This selector will only sign emails from the username $username." } ElseIf ($token -Like 'p=*') { $publickey = $token -Replace 'p=' If ($DkimKeyRecord -match 'k=ed25519') { Write-GoodNews "DKIM selector${Name}: The Ed25519 public key size is 256 bits." } ElseIf ($DkimKeyRecord -Match 'k=rsa') { $bits = Get-RSAPublicKeyLength $publickey If ($bits -gt 4096) { Write-BadPractice "DKIM selector${Name}: The RSA public key size is $bits bits. Verifiers may not support keys this large." } ElseIf ($bits -ge 2048) { Write-GoodNews "DKIM selector${Name}: The RSA public key size is $bits bits." } ElseIf ($bits -ge 1024) { Write-BadPractice "DKIM selector${Name}: The RSA public key size is only $bits bits. Upgrade to 2048 bits." } Else { Write-BadNews "DKIM selector${Name}: The RSA public key size is only $bits bits. This key is too small to be used. Replace it with an Ed25519 or 2048-bit RSA key!" } } } ElseIf ($token -Like 'n=*') { Write-Informational "DKIM selector${Name}: Some notes: $($token -Replace 'n=')" } ElseIf ($token.Length -gt 0) { Write-BadNews "DKIM selector${Name}: An invalid selector token was specified ($token)." } } } Function Test-DmarcRecord { [CmdletBinding()] [OutputType([Void])] Param( [Parameter(Mandatory, Position=0)] [ValidateNotNullOrEmpty()] [string] $DomainName, [Alias('CD', 'DnssecCD', 'NoDnssec', 'DisableDnssec')] [Switch] $DisableDnssecVerification ) $DnsLookup = Invoke-GooglePublicDnsApi "_dmarc.$DomainName" 'TXT' -Debug:$DebugPreference -DisableDnssecVerification:$DisableDnssecVerification #region DNSSEC check If (-Not $DisableDnssecVerification) { If ($DnsLookup.AD) { Write-GoodNews "DMARC: This DNS lookup is secure." } Else { Write-BadPractice "DMARC: This DNS lookup is insecure. Enable DNSSEC for this domain." } } #endregion If ($DnsLookup.PSObject.Properties.Name -NotContains 'Answer' -or $DnsLookup.Status -eq 3) { Write-BadNews "DMARC: Not found!" Return } $DmarcRecord = ($DnsLookup.Answer | Where-Object type -eq 16).Data If ($null -eq $DmarcRecord) { Write-BadNews "DMARC: A record exists with no valid data!" Return } ForEach ($token in ($DmarcRecord -Split ';')) { $token = $token.Trim() If ($token -Eq "v=DMARC1") { Write-GoodNews "DMARC: This is a DMARC version 1 record." } ElseIf ($token -Like "p=*") { $policy = $token -Replace 'p=' If ($policy -Eq 'none') { Write-Informational 'DMARC: Report but deliver messages that fail DMARC.' } ElseIf ($policy -Eq 'quarantine') { Write-GoodNews 'DMARC: Quarantine messages that fail DMARC.' } ElseIf ($policy -Eq 'reject') { Write-GoodNews 'DMARC: Reject messages that fail DMARC.' } Else { Write-BadNews "DMARC: An invalid policy was specified ($policy)." } } ElseIf ($token -Like "sp=*") { $subdomainpolicy = $token -Replace 'sp=' If ($subdomainpolicy -Eq 'none') { Write-Informational 'DMARC: Report but deliver messages from subdomains (without their own DMARC records) that fail DMARC.' } ElseIf ($subdomainpolicy -Eq 'quarantine') { Write-GoodNews 'DMARC: Quarantine messages from subdomains (without their own DMARC records) that fail DMARC.' } ElseIf ($subdomainpolicy -Eq 'reject') { Write-GoodNews 'DMARC: Reject messages from subdomains (without their own DMARC records) that fail DMARC.' } Else { Write-BadNews "DMARC: An invalid subdomain policy was specified ($subdomainpolicy)." } } ElseIf ($token -Like "pct=*") { $pct = [Byte]($token -Replace 'pct=') If ($pct -eq 100) { If ($DmarcPolicy -Match "reject") { Write-Informational "DMARC: Reject 100% of email that fails DMARC (default)." } ElseIf ($DmarcPolicy -Match 'quarantine') { Write-Informational "DMARC: Quarantine 100% of email that fails DMARC (default)." } } Else { If ($DmarcPolicy -Match "reject") { Write-Informational "DMARC: Only reject ${pct}% of unaligned email; the rest will be quarantined." } ElseIf ($DmarcPolicy -Match 'quarantine') { Write-BadPractice "DMARC: Only quarantine ${pct}% of unaligned email; the rest will be delivered." } } } ElseIf ($token -Like "aspf=*") { Switch ($token -Replace 'aspf=') { 's' { Write-Informational 'DMARC: SPF alignment is strict (From domain = MailFrom domain).' } 'r' { Write-Informational 'DMARC: SPF alignment is relaxed (From domain = MailFrom domain or a subdomain; default).' } Else { Write-BadNews "DMARC: An invalid SPF alignment was specified ($token)." } } } ElseIf ($token -Like "adkim=*") { Switch ($token -Replace 'adkim=') { 's' { Write-Informational 'DMARC: DKIM alignment is strict (domain = signing domain).' } 'r' { Write-Informational 'DMARC: DKIM alignment is relaxed (domain = signing domain or a subdomain; default).' } Else { Write-BadNews "DMARC: An invalid DKIM alignment was specified ($token)." } } } ElseIf ($token -Like 'fo=*') { If ($DmarcRecord -Match 'ruf=') { Switch ($token.Substring(3) -Split ':') { 0 { Write-Informational 'DMARC: Generate a forensic report if SPF and DKIM both fail (default).' } 1 { Write-Informational 'DMARC: Generate a forensic report if either SPF or DKIM fail.'} 'd' { Write-Informational 'DMARC: Generate a forensic report if DKIM fails, even if DMARC passes.' } 's' { Write-Informational 'DMARC: Generate a forensic report if SPF fails, even if DMARC passes.' } Else { Write-BadNews "DMARC: An invalid failure reporting tag was specified ($token)." } } } Else { Write-BadPractice 'DMARC: The failure reporting options will be ignored because a forensic report destination (ruf) was not specified.' } } ElseIf ($token -Like 'rf=*') { $formats = $token.Substring(3) -Split ':' ForEach ($format in $formats) { $format = $format.Trim() If ($format -eq 'afrf') { Write-Informational 'DMARC: Failure reports can be sent in AFRF format (default).' } Else { Write-BadNews "DMARC: The reporting format $format is not an allowed format. Mail receivers may ignore the entire DMARC record." } } } ElseIf ($token -Like 'ri=*') { $interval = [UInt32]($token -Replace 'ri=') If ($interval -Eq 86400) { Write-Informational "DMARC: Aggregate reports should (if possible) be sent no more than daily (default)." } Else { Write-Informational "DMARC: Aggregate reports should (if possible) be sent no more than every $interval seconds." } } ElseIf ($token -Like 'rua=*') { ForEach ($destination in ($token -Replace 'rua=' -Split ',')) { Write-Informational "DMARC: Aggregate reports will be sent to $destination." } } ElseIf ($token -Like 'ruf=*') { ForEach ($destination in ($token -Replace 'ruf=' -Split ',')) { Write-Informational "DMARC: Forensic reports will be sent to $destination (if the sender supports forensic reports)." } } ElseIf ($token.Length -gt 0) { Write-BadNews "DMARC: An invalid tag was specified ($token)." } } } Function Test-MailPolicy { [CmdletBinding()] [OutputType([Void])] Param( [Parameter(Mandatory, Position=0)] [String] $DomainName, [Alias('Recurse')] [Switch] $CountSpfDnsLookups, [String[]] $DkimSelectorsToCheck, [String[]] $BimiSelectorsToCheck, [Alias('CD', 'DnssecCD', 'NoDnssec', 'DisableDnssec')] [Switch] $DisableDnssecVerification ) Write-Output "Analyzing email records for $DomainName" Test-MXRecord $DomainName -DisableDnssecVerification:$DisableDnssecVerification Test-SpfRecord $DomainName -Recurse:$CountSpfDnsLookups -DisableDnssecVerification:$DisableDnssecVerification If ($DkimSelectorsToCheck.Count -gt 0) { $DkimSelectorsToCheck | ForEach-Object { Test-DkimSelector $DomainName -Name $_ -DisableDnssecVerification:$DisableDnssecVerification } } Test-ADSPRecord $DomainName -DisableDnssecVerification:$DisableDnssecVerification Test-DmarcRecord $DomainName -DisableDnssecVerification:$DisableDnssecVerification If ($BimiSelectorsToCheck.Count -gt 0) { $BimiSelectorsToCheck | ForEach-Object { Test-BimiSelector $DomainName -Name $_ -DisableDnssecVerification:$DisableDnssecVerification } } Test-MtaStsPolicy $DomainName -DisableDnssecVerification:$DisableDnssecVerification Test-SmtpTlsReportingPolicy $DomainName -DisableDnssecVerification:$DisableDnssecVerification Test-DaneRecord $DomainName -DisableDnssecVerification:$DisableDnssecVerification } Function Test-MtaStsPolicy { [CmdletBinding()] [OutputType([Void])] [Alias('Test-MtaStsRecord')] Param( [Parameter(Mandatory, Position=0)] [String] $DomainName, [Alias('CD', 'DnssecCD', 'NoDnssec', 'DisableDnssec')] [Switch] $DisableDnssecVerification ) $DnsLookup = Invoke-GooglePublicDnsApi "_mta-sts.$DomainName" 'TXT' -Debug:$DebugPreference -DisableDnssecVerification:$DisableDnssecVerification #region DNSSEC check If (-Not $DisableDnssecVerification) { If ($DnsLookup.AD) { Write-GoodNews "MTA-STS Record: This DNS lookup is secure." } Else { Write-BadPractice "MTA-STS Record: This DNS lookup is insecure. Enable DNSSEC for this domain." } } #endregion If ($DnsLookup.PSObject.Properties.Name -NotContains 'Answer' -or $DnsLookup.Status -eq 3) { Write-BadNews "MTA-STS Record: Not found! (Skipping policy test.)" Return } $MtaStsRecord = ($DnsLookup.Answer | Where-Object Type -eq 16).Data If ($null -eq $MtaStsRecord) { Write-BadNews "MTA-STS Record: A record exists with no valid data!" Return } $validSTSrecords = 0 ForEach ($token in ($MtaStsRecord -Split ';')) { $token = $token.Trim() If ($token -CLike "v=*") { If ($token -eq 'v=STSv1') { Write-GoodNews "MTA-STS Record: This domain's STS record is version 1." $validSTSrecords++ } Else { Write-BadNews "MTA-STS Record: This domain's STS record is an unsupported version ($($token -Replace 'v='))." } } ElseIf ($token -CLike 'id=*') { Write-Informational "MTA-STS Record: The domain's policy tag is $($token -Replace 'id=')." } ElseIf ($token.Length -gt 0) { Write-BadNews "MTA-STS Record: An unknown tag was found: $token" } } If ($validSTSrecords -ne 1) { Write-BadNews "MTA-STS Record: We did not find exactly one STS TXT record. We must assume MTA-STS is not supported!" Return } #region Fetch the MTA-STS policy file. # Connect to the remote server and download the file. We'll try with TLS 1.3 # first, then again with TLS 1.2. (TLS version support depends on the host # operating system and PowerShell version.) Test-IPVersions "mta-sts.$DomainName" $oldSP = [Net.ServicePointManager]::SecurityProtocol $ModuleInfo = (Get-Module 'MailPolicyExplainer') $iwrParams = @{ 'Method' = 'GET' 'Uri' = "https://mta-sts.$DomainName/.well-known/mta-sts.txt" 'UseBasicParsing' = $true 'UserAgent' = "Mozilla/5.0 ($($PSVersionTable.Platform); $($PSVersionTable.OS); $PSCulture) PowerShell/$($PSVersionTable.PSVersion) MailPolicyExplainer/$($ModuleInfo.Version)" 'ErrorAction' = 'Stop' } Try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls13 $policy = Invoke-WebRequest @iwrParams Write-GoodNews "MTA-STS Policy: Downloaded the policy file from mta-sts.$DomainName using TLS 1.3." } Catch { Try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $policy = Invoke-WebRequest @iwrParams Write-GoodNews "MTA-STS Policy: Downloaded the policy file from mta-sts.$DomainName using TLS 1.2." } Catch { Write-BadNews "MTA-STS Policy: Could not connect to mta-sts.$DomainName using TLS 1.2 or 1.3. Older TLS versions are not permitted." Return } } [Net.ServicePointManager]::SecurityProtocol = $oldSP #endregion #region Parse the downloaded file. # It must be a text/plain document. If (-Not ($policy.Headers.'Content-Type' -Match "^text/plain(;.*)?$")) { Write-BadNews "MTA-STS Policy: It was found, but was returned with the wrong content type ($($policy.Headers.'Content-Type'))." } Else { #region Make sure the file has the correct line endings. # The MTA-STS RFC says that they should end with CRLF (i.e., "`r`n"). # Split it up two different ways and see if we get the same results. # If not, then someone probably saved the file with UNIX ("`r") endings. # We're going to be strict and refuse to parse the file in this case. $lines = $policy.Content -Split "`r`n" $LFlines = $policy.Content -Split "`n" If ($lines.Count -ne $LFLines.Count) { Write-Debug "This file has $($lines.Count) CRLF-terminated lines and $($LFlines.Count) LF-terminated lines." Write-BadNews "MTA-STS Policy: The policy file does not have the correct CRLF line endings!" Return } #endregion $lines | ForEach-Object { $line = $_.Trim() If ($line -CLike 'version: *') { If (($line -Split ':')[1].Trim() -Eq 'STSv1') { Write-GoodNews "MTA-STS Policy: This domain's STS policy is version 1." } Else { Write-BadNews "MTA-STS Policy: This domain's STS policy has an undefined version ($line)." } } ElseIf ($line -CLike 'mode: *') { $mode = ($line -Split ':')[1].Trim() If ($mode -Eq 'enforce') { Write-GoodNews 'MTA-STS Policy: This domain enforces MTA-STS. Senders must not deliver mail to hosts that do not offer STARTTLS with a valid, trusted certificate.' } ElseIf ($mode -Eq 'testing') { Write-Informational 'MTA-STS Policy: This domain is in testing mode. MTA-STS failures will be reported, but the message will be delivered.' } ElseIf ($mode -Eq 'none') { Write-BadPractice 'MTA-STS Policy: This domain has no active policy.' } Else { Write-BadNews "MTA-STS Policy: The unknown mode $mode was specified." } } ElseIf ($line -CLike 'mx: *') { If ($line -Match '\*') { Write-GoodNews "MTA-STS Policy: This domain has MX hosts with STARTTLS and valid certificates matching $((($line -Split ':')[1]).Trim())." } Else { Write-GoodNews "MTA-STS Policy: The domain has an MX host with STARTTLS and a valid certificate at $((($line -Split ':')[1]).Trim())." } } ElseIf ($line -CLike 'max_age: *') { # RFC 8461 doesn't define a data type for max_age, only saying that it is a "plaintext non-negative # integer seconds" with a maximum of 31557600. The smallest type that can hold that is UInt32. $seconds = [UInt32]$(($line -Split ':')[1].Trim()) If ($seconds -gt 31557600) { Write-BadPractice "MTA-STS Policy: This policy should be cached for $seconds seconds, which is longer than the maximum of 31557600 seconds." } Else { Write-Informational "MTA-STS Policy: This policy should be cached for $seconds seconds." } } ElseIf ($line.Length -gt 0) { Write-BadNews "MTA-STS Policy: An unknown key/value pair was specified: $line" } } } #endregion } Function Test-MXRecord { [CmdletBinding()] [OutputType([Void])] [Alias('Test-MXRecords', 'Test-NullMXRecord')] Param( [Parameter(Mandatory, Position=0)] [ValidateNotNullOrEmpty()] [String] $DomainName, [Alias('CD', 'DnssecCD', 'NoDnssec', 'DisableDnssec')] [Switch] $DisableDnssecVerification ) $Results = @() $DnsLookup = Invoke-GooglePublicDnsApi $DomainName 'MX' -Debug:$DebugPreference -DisableDnssecVerification:$DisableDnssecVerification #region DNSSEC check If (-Not $DisableDnssecVerification) { If ($DnsLookup.AD) { Write-GoodNews "MX: This DNS lookup is secure." } Else { Write-BadPractice "MX: This DNS lookup is insecure. Enable DNSSEC for this domain." } } #endregion DNSSEC check #region Implied MX record check # Check to see if we should create an implied MX record from the root A/AAAA # records, or if there are proper MX records alread in place. If ($DnsLookup.PSObject.Properties.Name -NotContains 'Answer' -or $DnsLookup.Status -eq 3) { Write-BadPractice "MX: There are no MX records! This implies the domain will receive its own email." $Results += @{"Preference"=0; "Server"=$DomainName; "Implied"=$true} } ElseIf ($DnsLookup.Status -eq 0) { ($DnsLookup.Answer | Where-Object Type -eq 15).Data | ForEach-Object { $Pref, $Server = $_ -Split "\s+" $Results += @{"Preference"=[UInt16]$Pref; "Server"=$Server; "Implied"=$false} } } Else { Write-Error "MX: DNS lookup failed with status $($DnsLookup.Status)." } #endregion #region Null MX check If ($Results.Count -eq 1 -and $Results[0].Server -eq '.') { Write-Informational 'MX: This domain does not send or receive email.' Return } #endregion $Results | Sort-Object Preference | ForEach-Object { If ($_.Implied) { Write-GoodNews "MX: This domain is its own MX server." } Else { Write-GoodNews "MX: The server $($_.Server) can receive mail for this domain (at priority $($_.Preference))." } Test-IPVersions ($_.Server) -Indent } } Function Test-SmtpTlsReportingPolicy { [CmdletBinding()] [OutputType([Void])] Param( [Parameter(Mandatory, Position=0)] [ValidateNotNullOrEmpty()] [string] $DomainName, [Alias('CD', 'DnssecCD', 'NoDnssec', 'DisableDnssec')] [Switch] $DisableDnssecVerification ) $DnsLookup = Invoke-GooglePublicDnsApi "_smtp._tls.$DomainName" 'TXT' -Debug:$DebugPreference -DisableDnssecVerification:$DisableDnssecVerification #region DNSSEC check If (-Not $DisableDnssecVerification) { If ($DnsLookup.AD) { Write-GoodNews "TLSRPT: This DNS lookup is secure." } Else { Write-BadPractice "TLSRPT: This DNS lookup is insecure. Enable DNSSEC for this domain." } } #endregion DNSSEC check If ($DnsLookup.PSObject.Properties.Name -NotContains 'Answer' -or $DnsLookup.Status -eq 3) { Write-BadNews "TLSRPT: SMTP TLS Reporting is not enabled for this domain." Return } ElseIf (($DnsLookup.Answer | Where-Object type -eq 16).Count -gt 1) { Write-BadNews "TLSRPT: More than one DNS record was found. SMTP TLS Reporting must be assumed not to be supported!" Return } $TlsRptPolicy = ($DnsLookup.Answer | Where-Object type -eq 16).Data If ($null -eq $TlsRptPolicy) { Write-Verbose "TLSRPT: A policy record exists with no valid data!" Return } # The "rua" tag must appear at least once, and the "v" tag must appear # exactly once. We'll count how many times we see each one. $ruas = 0 $versions = 0 ForEach ($token in ($TlsRptPolicy -Split ';')) { $splits = $token -Split '=' $key = $splits[0].Trim() $value = '' If ($null -ne $splits[1]) { $value = $splits[1].Trim() } If ($key -eq 'v') { If ($value -eq 'TLSRPTv1') { Write-GoodNews 'TLSRPT: This is a version 1 policy.' $versions++ } Else { Write-BadNews "TLSRPT: This is an unsupported version ($value)!" } } ElseIf ($key -eq 'rua') { $ruas++ Write-Informational "TLSRPT: Aggregate information will be sent to: $value" } Else { Write-BadNews "TLSRPT: An invalid token was specified ($token)." } } If ($versions -ne 1) { Write-BadNews "TLSRPT: The required `"v`" tag did not appear exactly once. (It appeared $versions times.)" } If ($ruas -eq 0) { Write-BadNews 'TLSRPT: The required "rua" tag was not found!' } } Function Test-SpfRecord { [CmdletBinding()] [OutputType([Void])] [Alias('Test-SenderIdRecord')] Param( [Parameter(Mandatory, Position=0)] [ValidateNotNullOrEmpty()] [Alias('Name')] [String] $DomainName, [Alias('Recurse', 'CountSpfDnsLookups')] [Switch] $CountDnsLookups, [Alias('CD', 'DnssecCD', 'NoDnssec', 'DisableDnssec')] [Switch] $DisableDnssecVerification, [Parameter(DontShow)] [ref] $Recursions, [Parameter(DontShow)] [ref] $DnsLookups ) # This is a recursive function. We do not expect the user to specify the # $Recursions or $DnsLookups parameters themselves (in fact, they're hidden # from help because they're for internal use only). Thus, if an explicit # value is not specified, re-call this function with one. If ($CountDnsLookups) { If ($null -eq $Recursions) { $r = -1 # it will be incremented to zero on the first run. $d = 0 Test-SpfRecord -DomainName $DomainName -CountDnsLookups:$CountDnsLookups -DisableDnssecVerification:$DisableDnssecVerification -Recursions ([ref]$r) -DnsLookups ([ref]$d) Return # do not recurse } Else { # PowerShell requires us to use the Value property to get the content # of a variable passed by reference. $Recursions.Value++ } } #region Fetch the SPF record. # For historical reasons, we can also fetch Sender ID records. That was # Microsoft's failed attempt to make an "SPF 2.0". It can operate on either # of the two MailFrom headers, or both. It never really took off. Support # for Sender ID may be removed from this module in the future. $DnsLookup = Invoke-GooglePublicDnsApi "$DomainName" 'TXT' -Debug:$DebugPreference -DisableDnssecVerification:$DisableDnssecVerification $NoSPF = $false $SpfRecord = ($DnsLookup.Answer | Where-Object type -eq 16).Data | Where-Object {$_ -CLike "v=spf1 *" -or $_ -CLike "spf2.0/*"} If ($SpfRecord -CLike "v=spf1 *") { $RecordType = 'SPF' } ElseIf ($SpfRecord -CLike "spf2.0/*") { $RecordType = 'Sender ID' } Else { $RecordType = 'SPF' $NoSPF = $true } # Add indentation when doing recursive SPF lookups. If ($CountDnsLookups) { $RecordType = "$('âââ' * $Recursions.Value)$RecordType" } #endregion #region DNSSEC check If (-Not $DisableDnssecVerification) { If ($DnsLookup.AD) { Write-GoodNews "${RecordType}: This DNS lookup is secure." } Else { Write-BadPractice "${RecordType}: This DNS lookup is insecure. Enable DNSSEC for this domain." } } #endregion If ($DnsLookup.PSObject.Properties.Name -NotContains 'Answer' -or $DnsLookup.Status -eq 3) { Write-BadNews "${RecordType}: No TXT records were found for $DomainName!" Return } If ($NoSPF) { Write-BadNews "${RecordType}: A TXT record was found for $DomainName, but it is not an SPF record!" } Write-Verbose "Checking the $RecordType record: `"$SpfRecord`"" ForEach ($token in ($SpfRecord -Split ' ')) { #region Check SPF versions If ($token -Eq "v=spf1") { Write-GoodNews "${RecordType}: This is an SPF version 1 record." } ElseIf ($token -Eq "spf2.0/pra") { Write-BadPractice "${RecordType}: Sender ID records are historic and should be replaced with SPF TXT records." Write-GoodNews "${RecordType}: This is a Sender ID record checking the purported return address." } ElseIf ($token -Eq "spf2.0/mfrom") { Write-BadPractice "${RecordType}: Sender ID records are historic and should be replaced with SPF TXT records." Write-GoodNews "${RecordType}: This is a Sender ID record checking the mail From: address." } ElseIf ($token -Eq "spf2.0/pra,mfrom" -Or $token -Eq "spf2.0/mfrom,pra") { Write-BadPractice "${RecordType}: Sender ID records are historic and should be replaced with SPF TXT records." Write-GoodNews "${RecordType}: This is a Sender ID record checking the mail From: and purported return addresses (like SPF)." } #endregion #region Check redirect modifier. # If we're using the -CountDnsLookups/-Recurse parameter, this function # will be recursive and check the redirected SPF record. ElseIf ($token -Like 'redirect=*') { $Domain = ($token -Split '=')[1] If ($CountDnsLookups) { $DnsLookups.Value++ } Write-Informational "${RecordType}: Use the SPF record at $Domain instead.$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" If ($CountDnsLookups) { Test-SpfRecord $Domain -CountDnsLookups:$CountDnsLookups -DisableDnssecVerification:$DisableDnssecVerification -Recursions $Recursions -DnsLookups $DnsLookups } } #endregion #region Check A tokens. ElseIf ($token -Match '^[\+\-\?\~]?a([:/]*)' -and $token -NotMatch "all$") { If ($CountDnsLookups) { $DnsLookups.Value++ } If ($token -Match "^\+?a$") { Write-GoodNews "${RecordType}: Accept mail from $DomainName's IP address(es).$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Eq "-a") { Write-GoodNews "${RecordType}: Reject mail from $DomainName's IP address(es).$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Eq "~a") { Write-BadPractice "${RecordType}: Accept but mark mail from $DomainName's IP address(es).$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Eq "?a") { Write-BadPractice "${RecordType}: No opinion on mail from $DomainName's IP address(es).$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Match "^\+?a:*") { Write-GoodNews "${RecordType}: Accept mail from $($token -Replace '\+' -Replace 'a:')'s IP address(es).$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Like "-a:*") { Write-GoodNews "${RecordType}: Reject mail from $($token -Replace '-a:')'s IP address(es).$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Like "~a:*") { Write-BadPractice "${RecordType}: Accept but mark mail from $($token -Replace '?a:')'s IP address(es).$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Like "?a:*") { Write-BadPractice "${RecordType}: No opinion on mail from $($token -Replace '?a:')'s IP address(es).$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Match "/") { $mask = ($token -Split '/')[1] If ($token -Match "^\+?a/[0-3]?[0-9]$") { Write-GoodNews "${RecordType}: Accept mail from all hosts in the same IPv4 /$mask as $DomainName.$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Match "^-a/[0-3]?[0-9]$") { Write-GoodNews "${RecordType}: Reject mail from all hosts in the same IPv4 /$mask as $DomainName.$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Match "^~a/[0-3]?[0-9]$") { Write-BadPractice "${RecordType}: Accept but mark mail from all hosts in the same IPv4 /$mask as $DomainName.$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Match "^?a/[0-3]?[0-9]$") { Write-BadPractice "${RecordType}: No opinion on mail from all hosts in the same IPv4 /$mask as $DomainName.$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Match "^\+?a:*/[0-3]?[0-9]$") { Write-GoodNews "${RecordType}: Accept mail from all hosts in the same IPv4 /$mask as $($token -Replace '\+' -Replace 'a:' -Replace '/[0-3]?[0-9]').$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Match "^-a:*/[0-3]?[0-9]$") { Write-GoodNews "${RecordType}: Reject mail from all hosts in the same IPv4 /$mask as $($token -Replace '-a:' -Replace '/[0-3]?[0-9]').$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Match "^~a:*/[0-3]?[0-9]$") { Write-BadPractice "${RecordType}: Accept but mark mail from all hosts in the same IPv4 /$mask as $($token -Replace '~a:' -Replace '/[0-3]?[0-9]').$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Match "^?a:*/[0-3]?[0-9]$") { Write-BadPractice "${RecordType}: No opinion on mail from all hosts in the same IPv4 /$mask as $($token -Replace '?a:' -Replace '/[0-3]?[0-9]').$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } Else { Write-BadNews "${RecordType}: PermError while processing the A token $token." Return } } Else { Write-BadNews "${RecordType}: PermError while processing the A token $token." Return } } #endregion #region Check MX tokens. ElseIf ($token -Match '^[\+\-\?\~]?mx([:/]*)') { If ($CountDnsLookups) { $DnsLookups.Value++ } If ($token -Match "^\+?mx$") { Write-GoodNews "${RecordType}: Accept mail from $DomainName's MX servers.$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Eq "-mx") { Write-GoodNews "${RecordType}: Reject mail from $DomainName's MX servers.$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Eq "?mx") { Write-BadPractice "${RecordType}: No opinion on mail from $DomainName's MX servers.$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Eq "~mx") { Write-BadPractice "${RecordType}: Accept but mark mail from $DomainName's MX servers.$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Match "^\+?mx:.*$") { Write-GoodNews "${RecordType}: Accept mail from $($token -Replace '\+' -Replace 'mx:')'s MX servers.$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Like "-mx:.*$") { Write-GoodNews "${RecordType}: Reject mail from $($token -Replace '-mx:')'s MX servers.$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Like "~mx:.*$") { Write-BadPractice "${RecordType}: Accept but mark mail from $($token -Replace '?mx:')'s MX servers.$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Like "?mx:.*$") { Write-BadPractice "${RecordType}: No opinion on mail from $($token -Replace '?mx:')'s MX servers.$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Match "/") { $mask = ($token -Split '/')[1] If ($token -Match "^\+?mx/[0-3]?[0-9]$") { Write-GoodNews "${RecordType}: Accept mail from all hosts in the same IPv4 /$mask as $DomainName's MX records.$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Match "^-mx/[0-3]?[0-9]$") { Write-GoodNews "${RecordType}: Reject mail from all hosts in the same IPv4 /$mask as $DomainName's MX records.$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Match "^\?mx/[0-3]?[0-9]$") { Write-BadPractice "${RecordType}: No opinion on mail from all hosts in the same IPv4 /$mask as $DomainName's MX records.$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Match "^~mx/[0-3]?[0-9]$") { Write-BadPractice "${RecordType}: Accept but mark mail from all hosts in the same IPv4 /$mask as $DomainName's MX records.$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Match "^\+?mx:.*/[0-3]?[0-9]$") { Write-GoodNews "${RecordType}: Accept mail from all hosts in the same IPv4 /$mask as $($token -Replace '\+' -Replace 'mx:' -Replace '/[0-3]?[0-9]')'s MX records.$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Match "^-mx:.*/[0-3]?[0-9]$") { Write-GoodNews "${RecordType}: Reject mail from all hosts in the same IPv4 /$mask as $($token -Replace '-mx:' -Replace '/[0-3]?[0-9]')'s MX records.$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Match "^\?mx:.*/[0-3]?[0-9]$") { Write-BadPractice "${RecordType}: No opinion on mail from all hosts in the same IPv4 /$mask as $($token -Replace '?mx:' -Replace '/[0-3]?[0-9]')'s MX records.$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Match "^~mx:.*/[0-3]?[0-9]$") { Write-BadPractice "${RecordType}: Accept but mark mail from all hosts in the same IPv4 /$mask as $($token -Replace '?mx:' -Replace '/[0-3]?[0-9]')'s MX records.$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } Else { Write-BadNews "${RecordType}: PermError while processing the MX token $token." Return } } Else { Write-BadNews "${RecordType}: PermError while processing the MX token $token." Return } } #endregion #region Check exists tokens ElseIf ($token -Match "^[\+\-\?\~]?exists:.*") { If ($CountDnsLookups) { $DnsLookups.Value++ } If ($token -Match "^\+?exists:.*") { Write-GoodNews "${RecordType}: Accept mail if $($token -Replace '\+' -Replace 'exists:') resolves to an A record.$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Like "-exists:*") { Write-GoodNews "${RecordType}: Reject mail if $($token -Replace '-exists:') resolves to an A record.$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Like "~exists:*") { Write-BadPractice "${RecordType}: Accept but mark mail if $($token -Replace '~exists:') resolves to an A record.$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Like "?exists:*") { Write-BadPractice "${RecordType}: No opinion if $($token -Replace '?exists:') resolves to an A record.$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } Else { Write-BadNews "${RecordType}: PermError while processing the Exists token $token." Return } } #endregion #region Check ip4: and ip6: tokens ElseIf ($token -Match "^[\+\-\?\~]?ip4:*") { If ($token -Match "/" -And -Not ($token -Like "*/32")) { $ip4net = $token -Replace 'ip4:' If ($token -Match "^\+?ip4:.*") { Write-GoodNews "${RecordType}: Accept mail from the IPv4 subnet $ip4net." } ElseIf ($token -Like "-ip4:*") { Write-GoodNews "${RecordType}: Reject mail from the IPv4 subnet $ip4net." } ElseIf ($token -Like "~ip4:*") { Write-BadPractice "${RecordType}: Accept but mark mail from the IPv4 subnet $ip4net." } ElseIf ($token -Like "?ip4:*") { Write-BadPractice "${RecordType}: No opinion on mail from the IPv4 subnet $ip4net." } Else { Write-BadNews "${RecordType}: PermError while processing the IPv4 token $token." Return } } Else { $ip4addr = $token -Replace '[\+\-\~\?]?ip4:' -Replace '/32' If ($token -Match "^\+?ip4:.*") { Write-GoodNews "${RecordType}: Accept mail from the IPv4 address $ip4addr." } ElseIf ($token -Like "-ip4:*") { Write-GoodNews "${RecordType}: Reject mail from the IPv4 address $ip4addr." } ElseIf ($token -Like "~ip4:*") { Write-BadPractice "${RecordType}: Accept but mark mail from the IPv4 address $ip4addr." } ElseIf ($token -Like "?ip4:*") { Write-BadPractice "${RecordType}: No opinion on mail from the IPv4 address $ip4addr." } Else { Write-BadNews "${RecordType}: PermError: Could not parse the IPv4 token $token." Return } } } ElseIf ($token -Match "^[\+\-\?\~]?ip6:*") { If ($token -Match "/" -And -Not ($token -Like "*/128")) { $ip6net = $token -Replace 'ip6:' If ($token -Match "^\+?ip6:*") { Write-GoodNews "${RecordType}: Accept mail from the IPv6 subnet $ip6net." } ElseIf ($token -Like "-ip6:*") { Write-GoodNews "${RecordType}: Reject mail from the IPv6 subnet $ip6net." } ElseIf ($token -Like "~ip6:*") { Write-BadPractice "${RecordType}: Accept but mark mail from the IPv6 subnet $ip6net." } ElseIf ($token -Like "?ip6:*") { Write-BadPractice "${RecordType}: No opinion on mail from the IPv6 subnet $ip6net." } Else { Write-BadNews "${RecordType}: PermError while processing the IPv6 token $token." Return } } Else { $ip6addr = $token -Replace 'ip6:' -Replace '/128' If ($token -Match "^\+?ip6:*") { Write-GoodNews "${RecordType}: Accept mail from the IPv6 address $ip6addr." } ElseIf ($token -Like "-ip6:*") { Write-GoodNews "${RecordType}: Reject mail from the IPv6 address $ip6addr." } ElseIf ($token -Like "~ip6:*") { Write-BadPractice "${RecordType}: Accept but mark mail from the IPv6 address $ip6addr." } ElseIf ($token -Like "?ip6:*") { Write-BadPractice "${RecordType}: No opinion on mail from the IPv6 address $ip6addr." } Else { Write-BadNews "${RecordType}: PermError while processing the IPv6 token $token." Return } } } #endregion #region Check PTR tokens # The PTR mechanism is deprecated and should be avoided whenever possible. ElseIf ($token -Match "^[\+\-\?\~]?ptr(:.*)?") { If ($CountDnsLookups) { $DnsLookups.Value++ } If ($token -Match "^\+?ptr$") { Write-BadPractice "${RecordType}: Accept mail from IP's that have a reverse DNS record ending in $DomainName.$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Eq "-ptr") { Write-BadPractice "${RecordType}: Reject mail from IP's that have a reverse DNS record ending in $DomainName.$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Eq "~ptr") { Write-BadPractice "${RecordType}: Accept but mark mail from IP's that have a reverse DNS record ending in $DomainName.$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Eq "?ptr") { Write-BadPractice "${RecordType}: No opinion on mail from IP's that have a reverse DNS record ending in $DomainName.$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Match "^\+?ptr:.*") { Write-BadPractice "${RecordType}: Accept mail from IP's that have a reverse DNS record ending in $($token -Replace '\+' -Replace 'ptr:').$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Like "-ptr:*") { Write-BadPractice "${RecordType}: Reject mail from IP's that have a reverse DNS record ending in $($token -Replace '-ptr:').$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Like "~ptr:*") { Write-BadPractice "${RecordType}: Accept but mark mail from IP's that have a reverse DNS record ending in $($token -Replace '-ptr:').$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } ElseIf ($token -Like "?ptr:*") { Write-BadPractice "${RecordType}: No opinion on mail from IP's that have a reverse DNS record ending in $($token -Replace '\?ptr:').$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" } Else { Write-BadNews "${RecordType}: PermError while processing the PTR token $token." Return } } #endregion #region Check include: tokens # When running with the -CountDnsLookups/-Recurse parameter, the values # of the "include:" tokens will be checked recursively. ElseIf ($token -Match "^[\+\-\?\~]?include\:") { If ($CountDnsLookups) { $DnsLookups.Value++ } $NextRecord = $token -Replace '^[\+\-\~\?]?include:','' If ($token -Match "^\+?include:*") { Write-GoodNews "${RecordType}: Accept mail that passes the SPF record at $nextRecord.$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" If ($CountDnsLookups) { Test-SpfRecord -DomainName $NextRecord -CountDnsLookups:$CountDnsLookups -DisableDnssecVerification:$DisableDnssecVerification -Recursions $Recursions -DnsLookups $DnsLookups } } ElseIf ($token -Like "-include:*") { Write-GoodNews "${RecordType}: Reject mail that passes the SPF record at $NextRecord.$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" If ($CountDnsLookups) { Test-SpfRecord -DomainName $NextRecord -CountDnsLookups:$CountDnsLookups -DisableDnssecVerification:$DisableDnssecVerification -Recursions $Recursions -DnsLookups $DnsLookups } } ElseIf ($token -Like "~include:*") { Write-BadPractice "${RecordType}: Accept but mark mail that passes the SPF record at $NextRecord.$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" If ($CountDnsLookups) { Test-SpfRecord -DomainName $NextRecord -CountDnsLookups:$CountDnsLookups -DisableDnssecVerification:$DisableDnssecVerification -Recursions $Recursions -DnsLookups $DnsLookups } } ElseIf ($token -Like "?include:*") { Write-BadPractice "${RecordType}: No opinion on mail that passes the SPF record at $NextRecord.$(Write-DnsLookups $DnsLookups -Enabled:$CountDnsLookups)" If ($CountDnsLookups) { Test-SpfRecord -DomainName $NextRecord -CountDnsLookups:$CountDnsLookups -DisableDnssecVerification:$DisableDnssecVerification -Recursions $Recursions -DnsLookups $DnsLookups } } Else { Write-BadNews "${RecordType}: PermError while processing the Include token $token" Return } } #endregion #region Check for the "all" token. ElseIf ($token -Match "^[\+\-\?\~]?all") { If ($token -Match "^\+?all") { Write-BadPractice "${RecordType}: Accept all other mail." } ElseIf ($token -Eq "-all") { Write-GoodNews "${RecordType}: Reject all other mail." } ElseIf ($token -Eq "~all") { Write-BadPractice "${RecordType}: Accept but mark all other mail (this domain is likely testing SPF)." } ElseIf ($token -Eq "?all") { Write-BadPractice "${RecordType}: Do whatever with all other mail." } Else { Write-BadNews "${RecordType}: PermError while processing the All token $token" Return } } #endregion #region Check the exp= modifier # We will always attempt to resolve this and return the custom error # message. Note that this one does not count toward the ten DNS lookup # limit of SPF. ElseIf ($token -Like "exp=*") { $ExplanationRecord = $token -Replace 'exp=' $ExplanationMessage = ((Invoke-GooglePublicDnsApi $ExplanationRecord 'TXT').Answer | Where-Object Type -eq 16).Data Write-Informational "${RecordType}: Include this explanation with SPF failures: `"$ExplanationMessage`"" } #endregion ElseIf ($token.Length -gt 0) { Write-BadNews "${RecordType}: PermError while processing the unknown token $token" Return } } # If this is the first instance of Test-SpfRecord (that is, we are not in # the middle of some recursion), then print the number of DNS lookups and # remove the script-level counter variable. If ($CountDnsLookups) { If ($Recursions.Value -gt 0) { $Recursions.Value-- } ElseIf ($DnsLookups.Value -gt 10) { Write-BadNews "${RecordType}: PermError due to too many DNS lookups. $($DnsLookups.Value) lookups were required, but only 10 are allowed." } } Return } |