DNSHealth.psm1
#Region './Private/Get-DomainMacros.ps1' 0 function Get-DomainMacros { [CmdletBinding()] Param( [Parameter(Mandatory = $true)] [string]$Domain, [Parameter(Mandatory = $true)] [string]$MacroExpand ) $Macros = @{ '%{d}' = $Domain '%{o}' = $Domain '%{h}' = $Domain '%{l}' = 'postmaster' '%{s}' = 'postmaster@{0}' -f $Domain '%{i}' = '1.2.3.4' } foreach ($Macro in $Macros.Keys) { $MacroExpand = $MacroExpand -replace $Macro, $Macros.$Macro } $MacroExpand } #EndRegion './Private/Get-DomainMacros.ps1' 26 #Region './Private/Get-RsaPublicKeyInfo.ps1' 0 function Get-RsaPublicKeyInfo { <# .SYNOPSIS Gets RSA public key info from Base64 string .DESCRIPTION Decodes RSA public key information for validation. Uses a c# library to decode base64 data. .PARAMETER EncodedString Base64 encoded public key string .EXAMPLE PS> Get-RsaPublicKeyInfo -EncodedString <base64 string> LegalKeySizes KeyExchangeAlgorithm SignatureAlgorithm KeySize ------------- -------------------- ------------------ ------- {System.Security.Cryptography.KeySizes} RSA RSA 2048 .NOTES Obtained C# code from https://github.com/sevenTiny/Bamboo/blob/b5503b5597383ca6085ceb4aa5fa054918a4bd73/10-Code/SevenTiny.Bantina/Security/RSACommon.cs #> Param( [Parameter(Mandatory = $true)] $EncodedString ) $source = @' /********************************************************* * CopyRight: 7TINY CODE BUILDER. * Version: 5.0.0 * Author: 7tiny * Address: Earth * Create: 2018-04-08 21:54:19 * Modify: 2018-04-08 21:54:19 * E-mail: dong@7tiny.com | sevenTiny@foxmail.com * GitHub: https://github.com/sevenTiny * Personal web site: http://www.7tiny.com * Technical WebSit: http://www.cnblogs.com/7tiny/ * Description: * Thx , Best Regards ~ *********************************************************/ using System; using System.IO; using System.Security.Cryptography; using System.Text; namespace SevenTiny.Bantina.Security { public static class RSACommon { public static RSA CreateRsaProviderFromPublicKey(string publicKeyString) { // encoded OID sequence for PKCS #1 rsaEncryption szOID_RSA_RSA = "1.2.840.113549.1.1.1" byte[] seqOid = { 0x30, 0x0D, 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01, 0x05, 0x00 }; byte[] seq = new byte[15]; var x509Key = Convert.FromBase64String(publicKeyString); // --------- Set up stream to read the asn.1 encoded SubjectPublicKeyInfo blob ------ using (MemoryStream mem = new MemoryStream(x509Key)) { using (BinaryReader binr = new BinaryReader(mem)) //wrap Memory Stream with BinaryReader for easy reading { byte bt = 0; ushort twobytes = 0; twobytes = binr.ReadUInt16(); if (twobytes == 0x8130) //data read as little endian order (actual data order for Sequence is 30 81) binr.ReadByte(); //advance 1 byte else if (twobytes == 0x8230) binr.ReadInt16(); //advance 2 bytes else return null; seq = binr.ReadBytes(15); //read the Sequence OID if (!CompareBytearrays(seq, seqOid)) //make sure Sequence for OID is correct return null; twobytes = binr.ReadUInt16(); if (twobytes == 0x8103) //data read as little endian order (actual data order for Bit String is 03 81) binr.ReadByte(); //advance 1 byte else if (twobytes == 0x8203) binr.ReadInt16(); //advance 2 bytes else return null; bt = binr.ReadByte(); if (bt != 0x00) //expect null byte next return null; twobytes = binr.ReadUInt16(); if (twobytes == 0x8130) //data read as little endian order (actual data order for Sequence is 30 81) binr.ReadByte(); //advance 1 byte else if (twobytes == 0x8230) binr.ReadInt16(); //advance 2 bytes else return null; twobytes = binr.ReadUInt16(); byte lowbyte = 0x00; byte highbyte = 0x00; if (twobytes == 0x8102) //data read as little endian order (actual data order for Integer is 02 81) lowbyte = binr.ReadByte(); // read next bytes which is bytes in modulus else if (twobytes == 0x8202) { highbyte = binr.ReadByte(); //advance 2 bytes lowbyte = binr.ReadByte(); } else return null; byte[] modint = { lowbyte, highbyte, 0x00, 0x00 }; //reverse byte order since asn.1 key uses big endian order int modsize = BitConverter.ToInt32(modint, 0); int firstbyte = binr.PeekChar(); if (firstbyte == 0x00) { //if first byte (highest order) of modulus is zero, don't include it binr.ReadByte(); //skip this null byte modsize -= 1; //reduce modulus buffer size by 1 } byte[] modulus = binr.ReadBytes(modsize); //read the modulus bytes if (binr.ReadByte() != 0x02) //expect an Integer for the exponent data return null; int expbytes = (int)binr.ReadByte(); // should only need one byte for actual exponent data (for all useful values) byte[] exponent = binr.ReadBytes(expbytes); // ------- create RSACryptoServiceProvider instance and initialize with public key ----- var rsa = System.Security.Cryptography.RSA.Create(); RSAParameters rsaKeyInfo = new RSAParameters { Modulus = modulus, Exponent = exponent }; rsa.ImportParameters(rsaKeyInfo); return rsa; } } } private static bool CompareBytearrays(byte[] a, byte[] b) { if (a.Length != b.Length) return false; int i = 0; foreach (byte c in a) { if (c != b[i]) return false; i++; } return true; } } } '@ try { if (!('SevenTiny.Bantina.Security.RSACommon' -as [type])) { Add-Type -TypeDefinition $source -Language CSharp } } catch { Write-Verbose $_.Exception.Message } # Return RSA Public Key information [SevenTiny.Bantina.Security.RSACommon]::CreateRsaProviderFromPublicKey($EncodedString) } #EndRegion './Private/Get-RsaPublicKeyInfo.ps1' 166 #Region './Private/Get-ServerCertificateValidation.ps1' 0 function Get-ServerCertificateValidation { <# .SYNOPSIS Get HTTPS certificate and chain information for Url .DESCRIPTION Obtains certificate data from .Net HttpClient and builds certificate chain to verify validity and revocation status .PARAMETER Url Url to check .PARAMETER FollowRedirect Follow HTTP redirects .EXAMPLE PS> Get-ServerCertificateValidation -Url https://expired.badssl.com #> Param( [Parameter(Mandatory = $true)] $Url, [switch]$FollowRedirect ) $source = @' using System; using System.Net.Http; using System.Net.Security; using System.Security.Cryptography.X509Certificates; using System.Threading.Tasks; namespace CyberDrain.CIPP { public class CertValidation { public HttpResponseMessage HttpResponse; public X509Certificate2 Certificate; public X509Chain Chain; public SslPolicyErrors SslErrors; } public static class CertificateCheck { public static CertValidation GetServerCertificate(string url, bool allowredirect=false) { CertValidation certvalidation = new CertValidation(); var httpClientHandler = new HttpClientHandler { AllowAutoRedirect = allowredirect, ServerCertificateCustomValidationCallback = (requestMessage, cert, chain, sslErrors) => { X509Chain ch = new X509Chain(); ch.ChainPolicy.RevocationFlag = X509RevocationFlag.EntireChain; ch.ChainPolicy.RevocationMode = X509RevocationMode.Online; ch.ChainPolicy.VerificationFlags = X509VerificationFlags.AllFlags; //ch.ChainPolicy.DisableCertificateDownloads = true; certvalidation.Certificate = new X509Certificate2(cert.GetRawCertData()); ch.Build(cert); certvalidation.Chain = ch; certvalidation.SslErrors = sslErrors; return true; } }; var httpClient = new HttpClient(httpClientHandler); HttpResponseMessage HttpResponse = Task.Run(async() => await httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, url))).Result; certvalidation.HttpResponse = HttpResponse; return certvalidation; } } } '@ try { if (!('CyberDrain.CIPP.CertificateCheck' -as [type])) { Add-Type -TypeDefinition $source -Language CSharp } } catch { Write-Verbose $_.Exception.Message } [CyberDrain.CIPP.CertificateCheck]::GetServerCertificate($Url, $FollowRedirect) } #EndRegion './Private/Get-ServerCertificateValidation.ps1' 81 #Region './Public/Policies/Read-DmarcPolicy.ps1' 0 function Read-DmarcPolicy { <# .SYNOPSIS Resolve and validate DMARC policy .DESCRIPTION Query domain for DMARC policy (_dmarc.domain.com) and parse results. Record is checked for issues. .PARAMETER Domain Domain to process DMARC policy .EXAMPLE PS> Read-DmarcPolicy -Domain gmail.com Domain : gmail.com Record : v=DMARC1; p=none; sp=quarantine; rua=mailto:mailauth-reports@google.com Version : DMARC1 Policy : none SubdomainPolicy : quarantine Percent : 100 DkimAlignment : r SpfAlignment : r ReportFormat : afrf ReportInterval : 86400 ReportingEmails : {mailauth-reports@google.com} ForensicEmails : {} FailureReport : 0 ValidationPasses : {Aggregate reports are being sent} ValidationWarns : {Policy is not being enforced, Subdomain policy is only partially enforced with quarantine, Failure report option 0 will only generate a report on both SPF and DKIM misalignment. It is recommended to set this value to 1} ValidationFails : {} #> [CmdletBinding()] Param( [Parameter(Mandatory = $true)] [string]$Domain ) # Initialize object $DmarcAnalysis = [PSCustomObject]@{ Domain = $Domain Record = '' Version = '' Policy = '' SubdomainPolicy = '' Percent = 100 DkimAlignment = 'r' SpfAlignment = 'r' ReportFormat = 'afrf' ReportInterval = 86400 ReportingEmails = [System.Collections.Generic.List[string]]::new() ForensicEmails = [System.Collections.Generic.List[string]]::new() FailureReport = '' ValidationPasses = [System.Collections.Generic.List[string]]::new() ValidationWarns = [System.Collections.Generic.List[string]]::new() ValidationFails = [System.Collections.Generic.List[string]]::new() } # Validation lists $ValidationPasses = [System.Collections.Generic.List[string]]::new() $ValidationWarns = [System.Collections.Generic.List[string]]::new() $ValidationFails = [System.Collections.Generic.List[string]]::new() # Email report domains $ReportDomains = [System.Collections.Generic.List[string]]::new() # Validation ranges $PolicyValues = @('none', 'quarantine', 'reject') $FailureReportValues = @('0', '1', 'd', 's') $ReportFormatValues = @('afrf') $RecordCount = 0 $DnsQuery = @{ RecordType = 'TXT' Domain = "_dmarc.$Domain" } # Resolve DMARC record $Query = Resolve-DnsHttpsQuery @DnsQuery -ErrorAction Stop $RecordCount = 0 $Query.Answer | Where-Object { $_.data -match '^v=DMARC1' } | ForEach-Object { $DmarcRecord = $_.data $DmarcAnalysis.Record = $DmarcRecord $RecordCount++ } if ($Query.Status -eq 2 -and $Query.AD -eq $false) { $ValidationFails.Add('DNSSEC validation failed.') | Out-Null } elseif ($Query.Status -ne 0 -or $RecordCount -eq 0) { $ValidationFails.Add('This domain does not have a DMARC record.') | Out-Null } elseif (($Query.Answer | Measure-Object).Count -eq 1 -and $RecordCount -eq 0) { $ValidationFails.Add("The record must begin with 'v=DMARC1'.") | Out-Null } elseif ($RecordCount -gt 1) { $ValidationFails.Add('This domain has multiple records. The policy evaluation will fail.') | Out-Null } # Split DMARC record into name/value pairs $TagList = [System.Collections.Generic.List[object]]::new() Foreach ($Element in ($DmarcRecord -split ';').trim()) { $Name, $Value = $Element -split '=' $TagList.Add( [PSCustomObject]@{ Name = $Name Value = $Value } ) | Out-Null } # Loop through name/value pairs and set object properties $x = 0 foreach ($Tag in $TagList) { switch ($Tag.Name) { 'v' { # REQUIRED: Version $DmarcAnalysis.Version = $Tag.Value } 'p' { # REQUIRED: Policy $DmarcAnalysis.Policy = $Tag.Value } 'sp' { # Subdomain policy, defaults to policy record $DmarcAnalysis.SubdomainPolicy = $Tag.Value } 'rua' { # Aggregate report emails $ReportingEmails = $Tag.Value -split ', ' $ReportEmailsSet = $false foreach ($MailTo in $ReportingEmails) { if ($MailTo -notmatch '^mailto:') { $ValidationFails.Add("Aggregate report email addresses must begin with 'mailto:', multiple addresses must be separated by commas.") | Out-Null } else { $ReportEmailsSet = $true if ($MailTo -match '^mailto:(?<Email>.+@(?<Domain>[^!]+?))(?:!(?<SizeLimit>[0-9]+[kmgt]?))?$') { if ($ReportDomains -notcontains $Matches.Domain -and $Matches.Domain -ne $Domain) { $ReportDomains.Add($Matches.Domain) | Out-Null } $DmarcAnalysis.ReportingEmails.Add($Matches.Email) | Out-Null } } } if (!$DmarcAnalysis.ReportingEmails) { $DmarcAnalysis.ReportingEmails.Add($null) } if ($ReportEmailsSet) { $ValidationPasses.Add('Aggregate reports are being sent.') | Out-Null } else { $ValidationWarns.Add('Aggregate reports are not being sent.') | Out-Null } } 'ruf' { # Forensic reporting emails foreach ($MailTo in ($Tag.Value -split ', ')) { if ($MailTo -notmatch '^mailto:') { $ValidationFails.Add("Forensic report email must begin with 'mailto:', multiple addresses must be separated by commas - found $($Tag.Value)") | Out-Null } else { if ($MailTo -match '^mailto:(?<Email>.+@(?<Domain>[^!]+?))(?:!(?<SizeLimit>[0-9]+[kmgt]?))?$') { if ($ReportDomains -notcontains $Matches.Domain -and $Matches.Domain -ne $Domain) { $ReportDomains.Add($Matches.Domain) | Out-Null } $DmarcAnalysis.ForensicEmails.Add($Matches.Email) | Out-Null } } } } 'fo' { # Failure reporting options $DmarcAnalysis.FailureReport = $Tag.Value } 'pct' { # Percentage of email to check $DmarcAnalysis.Percent = [int]$Tag.Value } 'adkim' { # DKIM Alignmenet $DmarcAnalysis.DkimAlignment = $Tag.Value } 'aspf' { # SPF Alignment $DmarcAnalysis.SpfAlignment = $Tag.Value } 'rf' { # Report Format $DmarcAnalysis.ReportFormat = $Tag.Value } 'ri' { # Report Interval $DmarcAnalysis.ReportInterval = $Tag.Value } } $x++ } if ($RecordCount -gt 0) { # Check report domains for DMARC reporting record $ReportDomainCount = $ReportDomains | Measure-Object | Select-Object -ExpandProperty Count if ($ReportDomainCount -gt 0) { $ReportDomainsPass = $true foreach ($ReportDomain in $ReportDomains) { $ReportDomainQuery = "$Domain._report._dmarc.$ReportDomain" $DnsQuery['Domain'] = $ReportDomainQuery $ReportDmarcQuery = Resolve-DnsHttpsQuery @DnsQuery -ErrorAction Stop $ReportDmarcRecord = $ReportDmarcQuery.Answer.data if ($null -eq $ReportDmarcQuery -or $ReportDmarcQuery.Status -ne 0) { $ValidationWarns.Add("Report DMARC policy for $Domain is missing from $ReportDomain, reports will not be delivered. Expected record: '$Domain._report._dmarc.$ReportDomain' - Expected value: 'v=DMARC1;'") | Out-Null $ReportDomainsPass = $false } elseif ($ReportDmarcRecord -notmatch '^v=DMARC1') { $ValidationWarns.Add("Report DMARC policy for $Domain is missing from $ReportDomain, reports will not be delivered. Expected record: '$Domain._report._dmarc.$ReportDomain' - Expected value: 'v=DMARC1;'.") | Out-Null $ReportDomainsPass = $false } } if ($ReportDomainsPass) { $ValidationPasses.Add('All external reporting domains allow this domain to send DMARC reports.') | Out-Null } } # Check for missing record tags and set defaults if ($DmarcAnalysis.Policy -eq '') { $ValidationFails.Add('The policy tag (p=) is missing from this record. Set this to none, quarantine or reject.') | Out-Null } if ($DmarcAnalysis.SubdomainPolicy -eq '') { $DmarcAnalysis.SubdomainPolicy = $DmarcAnalysis.Policy } # Check policy for errors and best practice if ($PolicyValues -notcontains $DmarcAnalysis.Policy) { $ValidationFails.Add("The policy must be one of the following: none, quarantine or reject. Found $($Tag.Value)") | Out-Null } if ($DmarcAnalysis.Policy -eq 'reject') { $ValidationPasses.Add('The domain policy is set to reject, this is best practice.') | Out-Null } if ($DmarcAnalysis.Policy -eq 'quarantine') { $ValidationWarns.Add('The domain policy is only partially enforced with quarantine. Set this to reject to be fully compliant.') | Out-Null } if ($DmarcAnalysis.Policy -eq 'none') { $ValidationFails.Add('The domain policy is not being enforced.') | Out-Null } # Check subdomain policy if ($PolicyValues -notcontains $DmarcAnalysis.SubdomainPolicy) { $ValidationFails.Add("The subdomain policy must be one of the following: none, quarantine or reject. Found $($DmarcAnalysis.SubdomainPolicy)") | Out-Null } if ($DmarcAnalysis.SubdomainPolicy -eq 'reject') { $ValidationPasses.Add('The subdomain policy is set to reject, this is best practice.') | Out-Null } if ($DmarcAnalysis.SubdomainPolicy -eq 'quarantine') { $ValidationWarns.Add('The subdomain policy is only partially enforced with quarantine. Set this to reject to be fully compliant.') | Out-Null } if ($DmarcAnalysis.SubdomainPolicy -eq 'none') { $ValidationFails.Add('The subdomain policy is not being enforced.') | Out-Null } # Check percentage - validate range and ensure 100% if ($DmarcAnalysis.Percent -lt 100 -and $DmarcAnalysis.Percent -ge 0) { $ValidationWarns.Add('Not all emails will be processed by the DMARC policy.') | Out-Null } if ($DmarcAnalysis.Percent -gt 100 -or $DmarcAnalysis.Percent -lt 0) { $ValidationFails.Add('The percentage tag (pct=) must be between 0 and 100.') | Out-Null } # Check report format if ($ReportFormatValues -notcontains $DmarcAnalysis.ReportFormat) { $ValidationFails.Add("The report format '$($DmarcAnalysis.ReportFormat)' is not supported.") | Out-Null } # Check forensic reports and failure options $ForensicCount = ($DmarcAnalysis.ForensicEmails | Measure-Object | Select-Object -ExpandProperty Count) if ($ForensicCount -eq 0 -and $DmarcAnalysis.FailureReport -ne '') { $ValidationWarns.Add('Forensic email reports recipients are not defined and failure report options are set. No reports will be sent. This is not an issue unless you are expecting forensic reports.') | Out-Null } if ($DmarcAnalysis.FailureReport -eq '' -and $null -ne $DmarcRecord) { $DmarcAnalysis.FailureReport = '0' } if ($ForensicCount -gt 0) { $ReportOptions = $DmarcAnalysis.FailureReport -split ':' foreach ($ReportOption in $ReportOptions) { if ($FailureReportValues -notcontains $ReportOption) { $ValidationFails.Add("Failure report option '$ReportOption' is not a valid choice.") | Out-Null } if ($ReportOption -eq '1') { $ValidationPasses.Add('Failure report option 1 generates forensic reports on SPF or DKIM misalignment.') | Out-Null } if ($ReportOption -eq '0' -and $ReportOptions -notcontains '1') { $ValidationWarns.Add('Failure report option 0 will only generate a forensic report on both SPF and DKIM misalignment. It is recommended to set this value to 1.') | Out-Null } if ($ReportOption -eq 'd' -and $ReportOptions -notcontains '1') { $ValidationWarns.Add('Failure report option d will only generate a forensic report on failed DKIM evaluation. It is recommended to set this value to 1.') | Out-Null } if ($ReportOption -eq 's' -and $ReportOptions -notcontains '1') { $ValidationWarns.Add('Failure report option s will only generate a forensic report on failed SPF evaluation. It is recommended to set this value to 1.') | Out-Null } } } } # Add the validation lists $DmarcAnalysis.ValidationPasses = @($ValidationPasses) $DmarcAnalysis.ValidationWarns = @($ValidationWarns) $DmarcAnalysis.ValidationFails = @($ValidationFails) # Return DMARC analysis $DmarcAnalysis } #EndRegion './Public/Policies/Read-DmarcPolicy.ps1' 274 #Region './Public/Policies/Read-MtaStsPolicy.ps1' 0 function Read-MtaStsPolicy { <# .SYNOPSIS Resolve and validate MTA-STS policy .DESCRIPTION Retrieve mta-sts.txt from .well-known directory on domain .PARAMETER Domain Domain to process MTA-STS policy .EXAMPLE PS> Read-MtaStsPolicy -Domain gmail.com #> [CmdletBinding()] Param( [Parameter(Mandatory = $true)] [string]$Domain ) $StsPolicyAnalysis = [PSCustomObject]@{ Domain = $Domain Version = '' Mode = '' Mx = [System.Collections.Generic.List[string]]::new() MaxAge = '' IsValid = $false HasWarnings = $false ValidationPasses = [System.Collections.Generic.List[string]]::new() ValidationWarns = [System.Collections.Generic.List[string]]::new() ValidationFails = [System.Collections.Generic.List[string]]::new() } $ValidationPasses = [System.Collections.Generic.List[string]]::new() $ValidationWarns = [System.Collections.Generic.List[string]]::new() $ValidationFails = [System.Collections.Generic.List[string]]::new() # Valid policy modes $StsPolicyModes = @('testing', 'enforce') # Request policy file from domain, only accept text/plain results $RequestParams = @{ Uri = ('https://mta-sts.{0}/.well-known/mta-sts.txt' -f $Domain) Headers = @{ Accept = 'text/plain' } } $PolicyExists = $false try { $wr = Invoke-WebRequest @RequestParams -ErrorAction Stop $PolicyExists = $true } catch { $ValidationFails.Add(('MTA-STS policy does not exist for {0}' -f $Domain)) | Out-Null } # Policy file is key value pairs split on new lines $StsPolicyEntries = [System.Collections.Generic.List[object]]::new() $Entries = $wr.Content -split "`r?`n" foreach ($Entry in $Entries) { if ($null -ne $Entry) { try { $Name, $Value = $Entry -split ':' $StsPolicyEntries.Add( [PSCustomObject]@{ Name = $Name.trim() Value = $Value.trim() } ) | Out-Null } catch { Write-Verbose $_.Exception.Message } } } foreach ($StsPolicyEntry in $StsPolicyEntries) { switch ($StsPolicyEntry.Name) { 'version' { # REQUIRED: Version $StsPolicyAnalysis.Version = $StsPolicyEntry.Value } 'mode' { $StsPolicyAnalysis.Mode = $StsPolicyEntry.Value } 'mx' { $StsPolicyAnalysis.Mx.Add($StsPolicyEntry.Value) | Out-Null } 'max_age' { $StsPolicyAnalysis.MaxAge = $StsPolicyEntry.Value } } } # Check policy for issues if ($PolicyExists) { if ($StsPolicyAnalysis.Version -ne 'STSv1') { $ValidationFails.Add("Version must be STSv1 - found $($StsPolicyEntry.Value)") | Out-Null } if ($StsPolicyAnalysis.Version -eq '') { $ValidationFails.Add('Version is missing from policy') | Out-Null } if ($StsPolicyModes -notcontains $StsPolicyAnalysis.Mode) { $ValidationFails.Add(('Policy mode "{0}" is not valid. (Options: {1})' -f $StsPolicyAnalysis.Mode, $StsPolicyModes -join ', ')) } if ($StsPolicyAnalysis.Mode -eq 'Testing') { $ValidationWarns.Add('MTA-STS policy is in testing mode, no action will be taken') | Out-Null $StsPolicyAnalysis.HasWarnings = $true } $ValidationFailCount = ($ValidationFails | Measure-Object).Count if ($ValidationFailCount -eq 0) { $ValidationPasses.Add('MTA-STS policy is valid') $StsPolicyAnalysis.IsValid = $true } } # Aggregate validation results $StsPolicyAnalysis.ValidationPasses = @($ValidationPasses) $StsPolicyAnalysis.ValidationWarns = @($ValidationWarns) $StsPolicyAnalysis.ValidationFails = @($ValidationFails) $StsPolicyAnalysis } #EndRegion './Public/Policies/Read-MtaStsPolicy.ps1' 126 #Region './Public/Tests/Test-DNSSEC.ps1' 0 function Test-DNSSEC { <# .SYNOPSIS Test Domain for DNSSEC validation .DESCRIPTION Requests dnskey record from DNS and checks response validation (AD=True) .PARAMETER Domain Domain to check .EXAMPLE PS> Test-DNSSEC -Domain example.com Domain : example.com ValidationPasses : {example.com - DNSSEC enabled and validated} ValidationFails : {} Keys : {...} #> [CmdletBinding()] Param( [Parameter(Mandatory = $true)] [string]$Domain ) $DSResults = [PSCustomObject]@{ Domain = $Domain ValidationPasses = [System.Collections.Generic.List[string]]::new() ValidationWarns = [System.Collections.Generic.List[string]]::new() ValidationFails = [System.Collections.Generic.List[string]]::new() Keys = [System.Collections.Generic.List[string]]::new() } $ValidationPasses = [System.Collections.Generic.List[string]]::new() $ValidationFails = [System.Collections.Generic.List[string]]::new() $DnsQuery = @{ RecordType = 'dnskey' Domain = $Domain } $Result = Resolve-DnsHttpsQuery @DnsQuery -ErrorAction Stop if ($Result.Status -eq 2 -and $Result.AD -eq $false) { $ValidationFails.Add('DNSSEC Validation failed.') | Out-Null } else { $RecordCount = ($Result.Answer.data | Measure-Object).Count if ($null -eq $Result) { $ValidationFails.Add('DNSSEC is not set up for this domain.') | Out-Null } else { if ($Result.Status -eq 3) { $ValidationFails.Add('DNSSEC is not set up for this domain.') | Out-Null } elseif ($RecordCount -gt 0) { if ($Result.AD -eq $false) { $ValidationFails.Add('DNSSEC is enabled, but the DNS query response was not validated. Ensure DNSSEC has been enabled on your domain provider.') | Out-Null } else { $ValidationPasses.Add('DNSSEC is enabled and validated for this domain.') | Out-Null } $DSResults.Keys = $Result.answer.data } else { $ValidationFails.Add('DNSSEC is not set up for this domain.') | Out-Null } } } $DSResults.ValidationPasses = $ValidationPasses $DSResults.ValidationFails = $ValidationFails $DSResults } #EndRegion './Public/Tests/Test-DNSSEC.ps1' 78 #Region './Public/Tests/Test-HttpsCertificate.ps1' 0 function Test-HttpsCertificate { <# .SYNOPSIS Test HTTPS certificate for Domain .DESCRIPTION This function aggregates test results for a domain and subdomains in regards to HTTPS certificates .PARAMETER Domain Domain to check .PARAMETER Subdomains List of subdomains .EXAMPLE PS> Test-HttpsCertificate -Domain badssl.com -Subdomains expired, revoked #> [CmdletBinding()] Param( [Parameter(Mandatory = $true)] [string]$Domain, [string[]]$Subdomains = @() ) $CertificateTests = [PSCustomObject]@{ Domain = $Domain UrlsToTest = [System.Collections.Generic.List[string]]::new() Tests = [System.Collections.Generic.List[object]]::new() ValidationPasses = [System.Collections.Generic.List[string]]::new() ValidationWarns = [System.Collections.Generic.List[string]]::new() ValidationFails = [System.Collections.Generic.List[string]]::new() } $Urls = [System.Collections.Generic.List[string]]::new() $Urls.Add(('https://{0}' -f $Domain)) | Out-Null if (($Subdomains | Measure-Object).Count -gt 0) { foreach ($Subdomain in $Subdomains) { $Urls.Add(('https://{0}.{1}' -f $Subdomain, $Domain)) | Out-Null } } $CertificateTests.UrlsToTest = $Urls $CertificateTests.Tests = foreach ($Url in $Urls) { $Test = [PSCustomObject]@{ Hostname = '' Certificate = '' Chain = '' HttpResponse = '' ValidityDays = 0 ValidationPasses = [System.Collections.Generic.List[string]]::new() ValidationWarns = [System.Collections.Generic.List[string]]::new() ValidationFails = [System.Collections.Generic.List[string]]::new() Errors = [System.Collections.Generic.List[string]]::new() } try { # Parse URL and extract hostname $ParsedUrl = [System.Uri]::new($Url) $Hostname = $ParsedUrl.Host # Valdiations $ValidationPasses = [System.Collections.Generic.List[string]]::new() $ValidationWarns = [System.Collections.Generic.List[string]]::new() $ValidationFails = [System.Collections.Generic.List[string]]::new() # Grab certificate data $Validation = Get-ServerCertificateValidation -Url $Url $Certificate = $Validation.Certificate | Select-Object FriendlyName, IssuerName, NotBefore, NotAfter, SerialNumber, SignatureAlgorithm, SubjectName, Thumbprint, Issuer, Subject, DnsNameList $HttpResponse = $Validation.HttpResponse $Chain = $Validation.Chain $CurrentDate = Get-Date $TimeSpan = New-TimeSpan -Start $CurrentDate -End $Certificate.NotAfter # Check to see if certificate is contained in the DNS name list if ($Certificate.DnsNameList -contains $Hostname -or $Certificate.DnsNameList -eq "*.$Domain") { $ValidationPasses.Add(('{0} - Certificate DNS name list contains hostname.' -f $Hostname)) | Out-Null } else { $ValidationFails.Add(('{0} - Certificate DNS name list does not contain hostname' -f $Hostname)) | Out-Null } # Check certificate validity if ($Certificate.NotBefore -ge $CurrentDate) { # NotBefore is in the future $ValidationFails.Add(('{0} - Certificate is not yet valid.' -f $Hostname)) | Out-Null } elseif ($Certificate.NotAfter -le $CurrentDate) { # NotAfter is in the past $ValidationFails.Add(('{0} - Certificate expired {1} day(s) ago.' -f $Hostname, [Math]::Abs($TimeSpan.Days))) | Out-Null } elseif ($Certificate.NotAfter -ge $CurrentDate -and $TimeSpan.Days -lt 30) { # NotAfter is under 30 days away $ValidationWarns.Add(('{0} - Certificate will expire in {1} day(s).' -f $Hostname, $TimeSpan.Days)) | Out-Null } else { # Certificate is valid and not expired $ValidationPasses.Add(('{0} - Certificate is valid for the next {1} days.' -f $Hostname, $TimeSpan.Days)) | Out-Null } # Certificate chain errors if (($Chain.ChainStatus | Measure-Object).Count -gt 0) { foreach ($Status in $Chain.ChainStatus) { $ValidationFails.Add(('{0} - {1}' -f $Hostname, $Status.StatusInformation)) | Out-Null } } # Website status errorr if ([int]$HttpResponse.StatusCode -ge 400) { $ValidationFails.Add(('{0} - Website responded with: {1}' -f $Hostname, $HttpResponse.ReasonPhrase)) } # Set values and return Test object $Test.Hostname = $Hostname $Test.Certificate = $Certificate $Test.Chain = $Chain $Test.HttpResponse = $HttpResponse $Test.ValidityDays = $TimeSpan.Days $Test.ValidationPasses = @($ValidationPasses) $Test.ValidationWarns = @($ValidationWarns) $Test.ValidationFails = @($ValidationFails) # Return test $Test } catch { Write-Verbose $_.Exception.Message } } # Aggregate validation results foreach ($Test in $CertificateTests.Tests) { $ValidationPassCount = ($Test.ValidationPasses | Measure-Object).Count $ValidationWarnCount = ($Test.ValidationWarns | Measure-Object).Count $ValidationFailCount = ($Test.ValidationFails | Measure-Object).Count if ($ValidationFailCount -gt 0) { $CertificateTests.ValidationFails.Add(('{0} - Failure on {1} check(s)' -f $Test.Hostname, $ValidationFailCount)) | Out-Null } if ($ValidationWarnCount -gt 0) { $CertificateTests.ValidationWarns.Add(('{0} - Warning on {1} check(s)' -f $Test.Hostname, $ValidationWarnCount)) | Out-Null } if ($ValidationPassCount -gt 0) { $CertificateTests.ValidationPasses.Add(('{0} - Pass on {1} check(s)' -f $Test.Hostname, $ValidationPassCount)) | Out-Null } } # Return tests $CertificateTests } #EndRegion './Public/Tests/Test-HttpsCertificate.ps1' 160 #Region './Public/Tests/Test-MtaSts.ps1' 0 function Test-MtaSts { <# .SYNOPSIS Perform MTA-STS and TLSRPT checks .DESCRIPTION Retrieve MTA-STS record, policy and TLSRPT record .PARAMETER Domain Domain to process .EXAMPLE PS> Test-MtaSts -Domain gmail.com #> [CmdletBinding()] Param( [Parameter(Mandatory = $true)] [string]$Domain ) # MTA-STS test object $MtaSts = [PSCustomObject]@{ Domain = $Domain StsRecord = (Read-MtaStsRecord -Domain $Domain) StsPolicy = (Read-MtaStsPolicy -Domain $Domain) TlsRptRecord = (Read-TlsRptRecord -Domain $Domain) ValidationPasses = [System.Collections.Generic.List[string]]::new() ValidationWarns = [System.Collections.Generic.List[string]]::new() ValidationFails = [System.Collections.Generic.List[string]]::new() } # Validation lists $ValidationPasses = [System.Collections.Generic.List[string]]::new() $ValidationWarns = [System.Collections.Generic.List[string]]::new() $ValidationFails = [System.Collections.Generic.List[string]]::new() # Check results for each test if ($MtaSts.StsRecord.IsValid) { $ValidationPasses.Add('MTA-STS Record is valid') | Out-Null } else { $ValidationFails.Add('MTA-STS Record is not valid') | Out-Null } if ($MtaSts.StsRecord.HasWarnings) { $ValidationWarns.Add('MTA-STS Record has warnings') | Out-Null } if ($MtaSts.StsPolicy.IsValid) { $ValidationPasses.Add('MTA-STS Policy is valid') | Out-Null } else { $ValidationFails.Add('MTA-STS Policy is not valid') | Out-Null } if ($MtaSts.StsPolicy.HasWarnings) { $ValidationWarns.Add('MTA-STS Policy has warnings') | Out-Null } if ($MtaSts.TlsRptRecord.IsValid) { $ValidationPasses.Add('TLSRPT Record is valid') | Out-Null } else { $ValidationFails.Add('TLSRPT Record is not valid') | Out-Null } if ($MtaSts.TlsRptRecord.HasWarnings) { $ValidationWarns.Add('TLSRPT Record has warnings') | Out-Null } # Aggregate validation results $MtaSts.ValidationPasses = $ValidationPasses $MtaSts.ValidationWarns = $ValidationWarns $MtaSts.ValidationFails = $ValidationFails $MtaSts } #EndRegion './Public/Tests/Test-MtaSts.ps1' 58 #Region './Public/Resolver/Resolve-DnsHttpsQuery.ps1' 0 function Resolve-DnsHttpsQuery { <# .SYNOPSIS Resolves DNS record using DoH JSON query .DESCRIPTION This function uses Google or Cloudflare DoH REST APIs to resolve DNS records .PARAMETER Domain Domain to query .PARAMETER RecordType Type of record - Examples: A, CNAME, MX, TXT .EXAMPLE PS> Resolve-DnsHttpsQuery -Domain google.com -RecordType A name type TTL data ---- ---- --- ---- google.com. 1 30 142.250.80.110 #> [cmdletbinding()] Param( [Parameter(Mandatory = $true)] [string]$Domain, [string]$MacroExpand, [Parameter()] [string]$RecordType = 'A' ) if (!$script:DnsResolver) { Set-DnsResolver } $Resolver = $script:DnsResolver.Resolver $BaseUri = $script:DnsResolver.BaseUri $QueryTemplate = $script:DnsResolver.QueryTemplate $Headers = @{ 'accept' = 'application/dns-json' } if ($MacroExpand) { $Domain = Get-DomainMacros -MacroExpand $MacroExpand -Domain $Domain Write-Verbose "Macro expand: $Domain" } $Uri = $QueryTemplate -f $BaseUri, $Domain, $RecordType $x = 0 do { $x++ try { $Results = Invoke-RestMethod -Uri $Uri -Headers $Headers -ErrorAction Stop } catch { Start-Sleep -Milliseconds 300 } } while (-not $Results -and $x -le 3) if (!$Results) { throw $_ } if ($RecordType -eq 'txt' -and $Results.Answer) { if ($Resolver -eq 'Cloudflare' -or $Resolver -eq 'Quad9') { $Results.Answer | ForEach-Object { $_.data = $_.data -replace '"' -replace '\s+', ' ' } } $Results.Answer = $Results.Answer | Where-Object { $_.type -eq 16 } } return $Results } #EndRegion './Public/Resolver/Resolve-DnsHttpsQuery.ps1' 78 #Region './Public/Resolver/Set-DnsResolver.ps1' 0 function Set-DnsResolver { [CmdletBinding(SupportsShouldProcess)] Param( [Parameter()] [ValidateSet('Google', 'Cloudflare', 'Quad9')] [string]$Resolver = 'Google' ) if ($PSCmdlet.ShouldProcess($Resolver)) { $script:DnsResolver = switch ($Resolver) { 'Google' { [PSCustomObject]@{ Resolver = $Resolver BaseUri = 'https://dns.google/resolve' QueryTemplate = '{0}?name={1}&type={2}' } } 'CloudFlare' { [PSCustomObject]@{ Resolver = $Resolver BaseUri = 'https://cloudflare-dns.com/dns-query' QueryTemplate = '{0}?name={1}&type={2}' } } 'Quad9' { [PSCustomObject]@{ Resolver = $Resolver BaseUri = 'https://dns.quad9.net:5053/dns-query' QueryTemplate = '{0}?name={1}&type={2}' } } } } } #EndRegion './Public/Resolver/Set-DnsResolver.ps1' 35 #Region './Public/Records/Read-DkimRecord.ps1' 0 function Read-DkimRecord { <# .SYNOPSIS Read DKIM record from DNS .DESCRIPTION Validates DKIM records on a domain a selector .PARAMETER Domain Domain to check .PARAMETER Selectors Selector records to check .PARAMETER MxLookup Lookup record based on MX .EXAMPLE PS> Read-DkimRecord -Domain example.com -Selector test #> [CmdletBinding()] Param( [Parameter(Mandatory = $true)] [string]$Domain, [Parameter()] [System.Collections.Generic.List[string]]$Selectors = @() ) $MXRecord = $null $MinimumSelectorPass = 0 $SelectorPasses = 0 $DkimAnalysis = [PSCustomObject]@{ Domain = $Domain Selectors = $Selectors MailProvider = '' Records = [System.Collections.Generic.List[object]]::new() ValidationPasses = [System.Collections.Generic.List[string]]::new() ValidationWarns = [System.Collections.Generic.List[string]]::new() ValidationFails = [System.Collections.Generic.List[string]]::new() } $ValidationPasses = [System.Collections.Generic.List[string]]::new() $ValidationWarns = [System.Collections.Generic.List[string]]::new() $ValidationFails = [System.Collections.Generic.List[string]]::new() # MX lookup, check for defined selectors try { $MXRecord = Read-MXRecord -Domain $Domain foreach ($Selector in $MXRecord.Selectors) { try { $Selectors.Add($Selector) | Out-Null } catch { Write-Verbose $_.Exception.Message } } $DkimAnalysis.MailProvider = $MXRecord.MailProvider if ($MXRecord.MailProvider.PSObject.Properties.Name -contains 'MinimumSelectorPass') { $MinimumSelectorPass = $MXRecord.MailProvider.MinimumSelectorPass } $DkimAnalysis.Selectors = $Selectors } catch { Write-Verbose $_.Exception.Message } # Get unique selectors $Selectors = $Selectors | Sort-Object -Unique if (($Selectors | Measure-Object | Select-Object -ExpandProperty Count) -gt 0) { foreach ($Selector in $Selectors) { if (![string]::IsNullOrEmpty($Selector)) { # Initialize object $DkimRecord = [PSCustomObject]@{ Selector = '' Record = '' Version = '' PublicKey = '' PublicKeyInfo = '' KeyType = '' Flags = '' Notes = '' HashAlgorithms = '' ServiceType = '' Granularity = '' UnrecognizedTags = [System.Collections.Generic.List[object]]::new() } $DnsQuery = @{ RecordType = 'TXT' Domain = "$Selector._domainkey.$Domain" } try { $QueryResults = Resolve-DnsHttpsQuery @DnsQuery -ErrorAction Stop } catch { $Message = "{0}`r`n{1}" -f $_.Exception.Message, ($DnsQuery | ConvertTo-Json) throw $Message } if ([string]::IsNullOrEmpty($Selector)) { continue } if ($QueryResults.Status -eq 2 -and $QueryResults.AD -eq $false) { $ValidationFails.Add('DNSSEC validation failed.') | Out-Null } if ($QueryResults -eq '' -or $QueryResults.Status -ne 0) { if ($QueryResults.Status -eq 3) { if ($MinimumSelectorPass -eq 0) { $ValidationFails.Add("$Selector - The selector record does not exist for this domain.") | Out-Null } } else { $ValidationFails.Add("$Selector - DKIM record is missing, check the selector and try again") | Out-Null } $Record = '' } else { $QueryData = ($QueryResults.Answer).data | Where-Object { $_ -match '(v=|k=|t=|p=)' } if (( $QueryData | Measure-Object).Count -gt 1) { $Record = $QueryData[-1] } else { $Record = $QueryData } } $DkimRecord.Selector = $Selector if ($null -eq $Record) { $Record = '' } $DkimRecord.Record = $Record # Split DKIM record into name/value pairs $TagList = [System.Collections.Generic.List[object]]::new() Foreach ($Element in ($Record -split ';')) { if ($Element -ne '') { $Name, $Value = $Element.trim() -split '=' $TagList.Add( [PSCustomObject]@{ Name = $Name Value = $Value } ) | Out-Null } } # Loop through name/value pairs and set object properties $x = 0 foreach ($Tag in $TagList) { if ($x -eq 0 -and $Tag.Value -ne 'DKIM1') { $ValidationFails.Add("$Selector - The record must being with 'v=DKIM1'.") | Out-Null } switch ($Tag.Name) { 'v' { # REQUIRED: Version if ($x -ne 0) { $ValidationFails.Add("$Selector - The record must being with 'v=DKIM1'.") | Out-Null } $DkimRecord.Version = $Tag.Value } 'p' { # REQUIRED: Public Key if ($Tag.Value -ne '') { $DkimRecord.PublicKey = "-----BEGIN PUBLIC KEY-----`n {0}`n-----END PUBLIC KEY-----" -f $Tag.Value $DkimRecord.PublicKeyInfo = Get-RsaPublicKeyInfo -EncodedString $Tag.Value } else { if ($MXRecord.MailProvider.Name -eq 'Null') { $ValidationPasses.Add("$Selector - DKIM configuration is valid for a Null MX record configuration.") | Out-Null } else { $ValidationFails.Add("$Selector - There is no public key specified for this DKIM record or the key is revoked.") | Out-Null } } } 'k' { $DkimRecord.KeyType = $Tag.Value } 't' { $DkimRecord.Flags = $Tag.Value } 'n' { $DkimRecord.Notes = $Tag.Value } 'h' { $DkimRecord.HashAlgorithms = $Tag.Value } 's' { $DkimRecord.ServiceType = $Tag.Value } 'g' { $DkimRecord.Granularity = $Tag.Value } default { $DkimRecord.UnrecognizedTags.Add($Tag) | Out-Null } } $x++ } if ($Record -ne '') { if ($DkimRecord.KeyType -eq '') { $DkimRecord.KeyType = 'rsa' } if ($DkimRecord.HashAlgorithms -eq '') { $DkimRecord.HashAlgorithms = 'all' } $UnrecognizedTagCount = $UnrecognizedTags | Measure-Object | Select-Object -ExpandProperty Count if ($UnrecognizedTagCount -gt 0) { $TagString = ($UnrecognizedTags | ForEach-Object { '{0}={1}' -f $_.Tag, $_.Value }) -join ', ' $ValidationWarns.Add("$Selector - $UnrecognizedTagCount urecognized tag(s) were detected in the DKIM record. This can cause issues with some mailbox providers. Tags: $TagString") } if ($DkimRecord.Flags -eq 'y') { $ValidationWarns.Add("$Selector - The flag 't=y' indicates that this domain is testing mode currently. If DKIM is fully deployed, this flag should be changed to t=s unless subdomaining is required.") | Out-Null } if ($DkimRecord.PublicKeyInfo.SignatureAlgorithm -ne $DkimRecord.KeyType -and $MXRecord.MailProvider.Name -ne 'Null') { $ValidationWarns.Add("$Selector - Key signature algorithm $($DkimRecord.PublicKeyInfo.SignatureAlgorithm) does not match $($DkimRecord.KeyType)") | Out-Null } if ($DkimRecord.PublicKeyInfo.KeySize -lt 1024 -and $MXRecord.MailProvider.Name -ne 'Null') { $ValidationFails.Add("$Selector - Key size is less than 1024 bit, found $($DkimRecord.PublicKeyInfo.KeySize).") | Out-Null } else { if ($MXRecord.MailProvider.Name -ne 'Null') { $ValidationPasses.Add("$Selector - DKIM key validation succeeded.") | Out-Null } $SelectorPasses++ } if (($ValidationFails | Measure-Object | Select-Object -ExpandProperty Count) -eq 0) { $ValidationPasses.Add("$Selector - No errors detected with DKIM record.") | Out-Null } } ($DkimAnalysis.Records).Add($DkimRecord) | Out-Null } } } if (($DkimAnalysis.Records | Measure-Object | Select-Object -ExpandProperty Count) -eq 0 -and [string]::IsNullOrEmpty($DkimAnalysis.Selectors)) { $ValidationWarns.Add('No DKIM selectors provided, set them in the domain options.') | Out-Null } if ($MinimumSelectorPass -gt 0 -and $SelectorPasses -eq 0) { $ValidationFails.Add(('{0} DKIM record(s) found. The minimum number of valid records ({1}) was not met.' -f $SelectorPasses, $MinimumSelectorPass)) | Out-Null } elseif ($MinimumSelectorPass -gt 0 -and $SelectorPasses -ge $MinimumSelectorPass) { $ValidationPasses.Add(('Minimum number of valid DKIM records were met {0}/{1}.' -f $SelectorPasses, $MinimumSelectorPass)) } # Collect validation results $DkimAnalysis.ValidationPasses = @($ValidationPasses) $DkimAnalysis.ValidationWarns = @($ValidationWarns) $DkimAnalysis.ValidationFails = @($ValidationFails) # Return analysis $DkimAnalysis } #EndRegion './Public/Records/Read-DkimRecord.ps1' 260 #Region './Public/Records/Read-MtaStsRecord.ps1' 0 function Read-MtaStsRecord { <# .SYNOPSIS Resolve and validate MTA-STS record .DESCRIPTION Query domain for DMARC policy (_mta-sts.domain.com) and parse results. Record is checked for issues. .PARAMETER Domain Domain to process MTA-STS record .EXAMPLE PS> Read-MtaStsRecord -Domain gmail.com #> [CmdletBinding()] Param( [Parameter(Mandatory = $true)] [string]$Domain ) # Initialize object $StsAnalysis = [PSCustomObject]@{ Domain = $Domain Record = '' Version = '' Id = '' IsValid = $false HasWarnings = $false ValidationPasses = [System.Collections.Generic.List[string]]::new() ValidationWarns = [System.Collections.Generic.List[string]]::new() ValidationFails = [System.Collections.Generic.List[string]]::new() } # Validation lists $ValidationPasses = [System.Collections.Generic.List[string]]::new() $ValidationWarns = [System.Collections.Generic.List[string]]::new() $ValidationFails = [System.Collections.Generic.List[string]]::new() # Validation ranges $RecordCount = 0 $DnsQuery = @{ RecordType = 'TXT' Domain = "_mta-sts.$Domain" } # Resolve DMARC record $Query = Resolve-DnsHttpsQuery @DnsQuery -ErrorAction Stop $RecordCount = 0 $Query.Answer | Where-Object { $_.data -match '^v=STSv1' } | ForEach-Object { $StsRecord = $_.data $StsAnalysis.Record = $StsRecord $RecordCount++ } if ($Query.Status -eq 2 -and $Query.AD -eq $false) { $ValidationFails.Add('DNSSEC validation failed.') | Out-Null } elseif ($Query.Status -ne 0 -or $RecordCount -eq 0) { if ($Query.Status -eq 3) { $ValidationFails.Add('Record does not exist (NXDOMAIN)') | Out-Null } else { $ValidationFails.Add("$Domain does not have an MTA-STS record") | Out-Null } } elseif ($RecordCount -gt 1) { $ValidationFails.Add("$Domain has multiple MTA-STS records") | Out-Null } # Split DMARC record into name/value pairs $TagList = [System.Collections.Generic.List[object]]::new() Foreach ($Element in ($StsRecord -split ';').trim()) { $Name, $Value = $Element -split '=' $TagList.Add( [PSCustomObject]@{ Name = $Name Value = $Value } ) | Out-Null } # Loop through name/value pairs and set object properties $x = 0 foreach ($Tag in $TagList) { switch ($Tag.Name) { 'v' { # REQUIRED: Version if ($x -ne 0) { $ValidationFails.Add('v=STSv1 must be at the beginning of the record') | Out-Null } if ($Tag.Value -ne 'STSv1') { $ValidationFails.Add("Version must be STSv1 - found $($Tag.Value)") | Out-Null } $StsAnalysis.Version = $Tag.Value } 'id' { # REQUIRED: Id $StsAnalysis.Id = $Tag.Value } } $x++ } if ($RecordCount -gt 0) { # Check for missing record tags and set defaults if ($StsAnalysis.Id -eq '') { $ValidationFails.Add('Id record is missing') | Out-Null } elseif ($StsAnalysis.Id -notmatch '^[A-Za-z0-9]+$') { $ValidationFails.Add('STS Record ID must be alphanumeric') | Out-Null } if ($RecordCount -gt 1) { $ValidationWarns.Add('Multiple MTA-STS records detected, this may cause unexpected behavior.') | Out-Null $StsAnalysis.HasWarnings = $true } $ValidationWarnCount = ($Test.ValidationWarns | Measure-Object).Count $ValidationFailCount = ($Test.ValidationFails | Measure-Object).Count if ($ValidationFailCount -eq 0 -and $ValidationWarnCount -eq 0) { $ValidationPasses.Add('MTA-STS record is valid') | Out-Null $StsAnalysis.IsValid = $true } } # Add the validation lists $StsAnalysis.ValidationPasses = @($ValidationPasses) $StsAnalysis.ValidationWarns = @($ValidationWarns) $StsAnalysis.ValidationFails = @($ValidationFails) # Return MTA-STS analysis $StsAnalysis } #EndRegion './Public/Records/Read-MtaStsRecord.ps1' 136 #Region './Public/Records/Read-MXRecord.ps1' 0 function Read-MXRecord { <# .SYNOPSIS Reads MX records for domain .DESCRIPTION Queries DNS servers to get MX records and returns in PSCustomObject list with Preference and Hostname .PARAMETER Domain Domain to query .EXAMPLE PS> Read-MXRecord -Domain gmail.com Preference Hostname ---------- -------- 5 gmail-smtp-in.l.google.com. 10 alt1.gmail-smtp-in.l.google.com. 20 alt2.gmail-smtp-in.l.google.com. 30 alt3.gmail-smtp-in.l.google.com. 40 alt4.gmail-smtp-in.l.google.com. #> [CmdletBinding()] Param( [Parameter(Mandatory = $true)] [string]$Domain ) $MXResults = [PSCustomObject]@{ Domain = '' Records = [System.Collections.Generic.List[object]]::new() ValidationPasses = [System.Collections.Generic.List[string]]::new() ValidationWarns = [System.Collections.Generic.List[string]]::new() ValidationFails = [System.Collections.Generic.List[string]]::new() MailProvider = '' ExpectedInclude = '' Selectors = '' } $ValidationPasses = [System.Collections.Generic.List[string]]::new() $ValidationFails = [System.Collections.Generic.List[string]]::new() $DnsQuery = @{ RecordType = 'mx' Domain = $Domain } $NoMxValidation = 'There are no mail exchanger records for this domain. If you do not want to receive mail for this domain use a Null MX record of . with a priority 0 (RFC 7505).' $MXResults.Domain = $Domain try { $Result = Resolve-DnsHttpsQuery @DnsQuery -ErrorAction Stop } catch { $Result = $null } if ($Result.Status -eq 2 -and $Result.AD -eq $false) { $ValidationFails.Add('DNSSEC validation failed.') | Out-Null } elseif ($Result.Status -ne 0 -or -not ($Result.Answer)) { if ($Result.Status -eq 3) { $ValidationFails.Add($NoMxValidation) | Out-Null $MXResults.MailProvider = Get-Content "$PSScriptRoot\MailProviders\Null.json" | ConvertFrom-Json $MXResults.Selectors = $MXRecords.MailProvider.Selectors } else { $ValidationFails.Add($NoMxValidation) | Out-Null $MXResults.MailProvider = Get-Content "$PSScriptRoot\MailProviders\Null.json" | ConvertFrom-Json $MXResults.Selectors = $MXRecords.MailProvider.Selectors } $MXRecords = $null } else { $MXRecords = $Result.Answer | ForEach-Object { $Priority, $Hostname = $_.Data.Split(' ') try { [PSCustomObject]@{ Priority = [int]$Priority Hostname = $Hostname } } catch { Write-Verbose $_.Exception.Message } } $ValidationPasses.Add('Mail exchanger records record(s) are present for this domain.') | Out-Null $MXRecords = $MXRecords | Sort-Object -Property Priority # Attempt to identify mail provider based on MX record if (Test-Path "$PSScriptRoot\MailProviders") { $ReservedVariables = @{ 'DomainNameDashNotation' = $Domain -replace '\.', '-' } if ($MXRecords.Hostname -eq '') { $ValidationFails.Add($NoMxValidation) | Out-Null $MXResults.MailProvider = Get-Content "$PSScriptRoot\MailProviders\Null.json" | ConvertFrom-Json } else { $ProviderList = Get-ChildItem "$PSScriptRoot\MailProviders" -Exclude '_template.json' | ForEach-Object { try { Get-Content $_ | ConvertFrom-Json -ErrorAction Stop } catch { Write-Verbose $_.Exception.Message } } foreach ($Record in $MXRecords) { $ProviderMatched = $false foreach ($Provider in $ProviderList) { try { if ($Record.Hostname -match $Provider.MxMatch) { $MXResults.MailProvider = $Provider if (($Provider.SpfReplace | Measure-Object | Select-Object -ExpandProperty Count) -gt 0) { $ReplaceList = [System.Collections.Generic.List[string]]::new() foreach ($Var in $Provider.SpfReplace) { if ($ReservedVariables.Keys -contains $Var) { $ReplaceList.Add($ReservedVariables.$Var) | Out-Null } else { $ReplaceList.Add($Matches.$Var) | Out-Null } } $ExpectedInclude = $Provider.SpfInclude -f ($ReplaceList -join ', ') } else { $ExpectedInclude = $Provider.SpfInclude } # Set ExpectedInclude and Selector fields based on provider details $MXResults.ExpectedInclude = $ExpectedInclude $MXResults.Selectors = $Provider.Selectors $ProviderMatched = $true break } } catch { Write-Verbose $_.Exception.Message } } if ($ProviderMatched) { break } } } } $MXResults.Records = $MXRecords } $MXResults.ValidationPasses = @($ValidationPasses) $MXResults.ValidationFails = @($ValidationFails) $MXResults.Records = @($MXResults.Records) $MXResults } #EndRegion './Public/Records/Read-MXRecord.ps1' 153 #Region './Public/Records/Read-NSRecord.ps1' 0 function Read-NSRecord { <# .SYNOPSIS Reads NS records for domain .DESCRIPTION Queries DNS servers to get NS records and returns in PSCustomObject list .PARAMETER Domain Domain to query .EXAMPLE PS> Read-NSRecord -Domain gmail.com #> [CmdletBinding()] Param( [Parameter(Mandatory = $true)] [string]$Domain ) $NSResults = [PSCustomObject]@{ Domain = '' Records = [System.Collections.Generic.List[string]]::new() ValidationPasses = [System.Collections.Generic.List[string]]::new() ValidationWarns = [System.Collections.Generic.List[string]]::new() ValidationFails = [System.Collections.Generic.List[string]]::new() NameProvider = '' } $ValidationPasses = [System.Collections.Generic.List[string]]::new() $ValidationFails = [System.Collections.Generic.List[string]]::new() $DnsQuery = @{ RecordType = 'ns' Domain = $Domain } $NSResults.Domain = $Domain try { $Result = Resolve-DnsHttpsQuery @DnsQuery -ErrorAction Stop } catch { $Result = $null } if ($Result.Status -eq 2 -and $Result.AD -eq $false) { $ValidationFails.Add('DNSSEC Validation failed.') | Out-Null } elseif ($Result.Status -ne 0 -or -not ($Result.Answer)) { $ValidationFails.Add('No nameservers found for this domain.') | Out-Null $NSRecords = $null } else { $NSRecords = $Result.Answer.data $ValidationPasses.Add('Nameserver record is present.') | Out-Null $NSResults.Records = @($NSRecords) } $NSResults.ValidationPasses = $ValidationPasses $NSResults.ValidationFails = $ValidationFails $NSResults } #EndRegion './Public/Records/Read-NSRecord.ps1' 63 #Region './Public/Records/Read-SPFRecord.ps1' 0 function Read-SpfRecord { <# .SYNOPSIS Reads SPF record for specified domain .DESCRIPTION Uses Get-GoogleDNSQuery to obtain TXT records for domain, searching for v=spf1 at the beginning of the record Also parses include records and obtains their SPF as well .PARAMETER Domain Domain to obtain SPF record for .EXAMPLE PS> Read-SpfRecord -Domain gmail.com Domain : gmail.com Record : v=spf1 redirect=_spf.google.com RecordCount : 1 LookupCount : 4 AllMechanism : ~ ValidationPasses : {Expected SPF record was included, No PermError detected in SPF record} ValidationWarns : {} ValidationFails : {SPF record should end in -all to prevent spamming} RecordList : {@{Domain=_spf.google.com; Record=v=spf1 include:_netblocks.google.com include:_netblocks2.google.com include:_netblocks3.google.com ~all; RecordCount=1; LookupCount=4; AllMechanism=~; ValidationPasses=System.Collections.ArrayList; ValidationWarns=System.Collections.ArrayList; ValidationFails=System.Collections.ArrayList; RecordList=System.Collections.ArrayList; TypeLookups=System.Collections.ArrayList; IPAddresses=System.Collections.ArrayList; PermError=False}} TypeLookups : {} IPAddresses : {} PermError : False .NOTES Author: John Duprey #> [CmdletBinding(DefaultParameterSetName = 'Lookup')] Param( [Parameter(Mandatory = $true, ParameterSetName = 'Lookup')] [Parameter(ParameterSetName = 'Manual')] [string]$Domain, [Parameter(Mandatory = $true, ParameterSetName = 'Manual')] [string]$Record, [Parameter(ParameterSetName = 'Lookup')] [Parameter(ParameterSetName = 'Manual')] [string]$Level = 'Parent', [Parameter(ParameterSetName = 'Lookup')] [Parameter(ParameterSetName = 'Manual')] [string]$ExpectedInclude = '' ) $SpfResults = [PSCustomObject]@{ Domain = '' Record = '' RecordCount = 0 LookupCount = 0 AllMechanism = '' ValidationPasses = [System.Collections.Generic.List[string]]::new() ValidationWarns = [System.Collections.Generic.List[string]]::new() ValidationFails = [System.Collections.Generic.List[string]]::new() RecordList = [System.Collections.Generic.List[object]]::new() TypeLookups = [System.Collections.Generic.List[object]]::new() Recommendations = [System.Collections.Generic.List[object]]::new() RecommendedRecord = '' IPAddresses = [System.Collections.Generic.List[string]]::new() MailProvider = '' Explanation = '' Status = '' } # Initialize lists to hold all records $RecordList = [System.Collections.Generic.List[object]]::new() $ValidationFails = [System.Collections.Generic.List[string]]::new() $ValidationPasses = [System.Collections.Generic.List[string]]::new() $ValidationWarns = [System.Collections.Generic.List[string]]::new() $Recommendations = [System.Collections.Generic.List[object]]::new() $LookupCount = 0 $AllMechanism = '' $Status = '' $RecommendedRecord = '' $TypeLookups = [System.Collections.Generic.List[object]]::new() $IPAddresses = [System.Collections.Generic.List[string]]::new() $DnsQuery = @{ RecordType = 'TXT' Domain = $Domain } $NoSpfValidation = 'No SPF record was detected for this domain.' # Query DNS for SPF Record try { switch ($PSCmdlet.ParameterSetName) { 'Lookup' { if ($Domain -eq 'Not Specified') { # don't perform lookup if domain is not specified } else { $Query = Resolve-DnsHttpsQuery @DnsQuery -ErrorAction Stop if ($Query.Status -eq 2 -and $Query.AD -eq $false) { $ValidationFails.Add('DNSSEC validation failed.') | Out-Null } elseif ($Query.Status -ne 0) { if ($Query.Status -eq 3) { $ValidationFails.Add($NoSpfValidation) | Out-Null $Status = 'permerror' } else { #Write-Host $Query $ValidationFails.Add($NoSpfValidation) | Out-Null $Status = 'temperror' } } else { $Answer = ($Query.answer | Where-Object { $_.data -match '^v=spf1' }) $RecordCount = ($Answer.data | Measure-Object).count $Record = $Answer.data if ($RecordCount -eq 0) { $ValidationFails.Add($NoSpfValidation) | Out-Null $Status = 'permerror' } # Check for the correct number of records elseif ($RecordCount -gt 1 -and $Level -eq 'Parent') { $ValidationFails.Add("There must only be one SPF record per domain, we found $RecordCount.") | Out-Null $Recommendations.Add([pscustomobject]@{ Message = 'Delete one of the records beginning with v=spf1' Match = '' }) | Out-Null $Status = 'permerror' $Record = $Answer.data[0] } } } } 'Manual' { if ([string]::IsNullOrEmpty($Domain)) { $Domain = 'Not Specified' } $RecordCount = 1 } } $SpfResults.Domain = $Domain if ($Record -ne '' -and $RecordCount -gt 0) { # Split records and parse if ($Record -match '^v=spf1(:?\s+(?<Terms>(?![+-~?]all).+?))?(:?\s+(?<AllMechanism>[+-~?]all)(:?\s+(?<Discard>(?!all).+))?)?$') { if ($Matches.Terms) { $RecordTerms = $Matches.Terms -split '\s+' } else { $RecordTerms = @() } Write-Verbose "########### RECORD: $Record" if ($Level -eq 'Parent' -or $Level -eq 'Redirect') { $AllMechanism = $Matches.AllMechanism } if ($null -ne $Matches.Discard) { if ($Matches.Discard -notmatch '^exp=(?<Domain>.+)$') { $ValidationWarns.Add("The terms '$($Matches.Discard)' are past the all mechanism and will be discarded.") | Out-Null $Recommendations.Add([pscustomobject]@{ Message = 'Remove entries following all'; Match = $Matches.Discard Replace = '' }) | Out-Null } } foreach ($Term in $RecordTerms) { Write-Verbose "TERM $Term" # Redirect modifier if ($Term -match 'redirect=(?<Domain>.+)') { Write-Verbose '-----REDIRECT-----' $LookupCount++ if ($Record -match '(?<Qualifier>[+-~?])all') { $ValidationFails.Add('A record with a redirect modifier must not contain an all mechanism. This will result in a failure.') | Out-Null $Status = 'permerror' $Recommendations.Add([pscustomobject]@{ Message = "Remove the 'all' mechanism from this record."; Match = '{0}all' -f $Matches.Qualifier Replace = '' }) | Out-Null } else { # Follow redirect modifier $RedirectedLookup = Read-SpfRecord -Domain $Matches.Domain -Level 'Redirect' if (($RedirectedLookup | Measure-Object).Count -eq 0) { $ValidationFails.Add("$Domain Redirected lookup does not contain a SPF record, this will result in a failure.") | Out-Null $Status = 'permerror' } else { $RecordList.Add($RedirectedLookup) | Out-Null $AllMechanism = $RedirectedLookup.AllMechanism $ValidationFails.AddRange([string[]]$RedirectedLookup.ValidationFails) | Out-Null $ValidationWarns.AddRange([string[]]$RedirectedLookup.ValidationWarns) | Out-Null $ValidationPasses.AddRange([string[]]$RedirectedLookup.ValidationPasses) | Out-Null $IPAddresses.AddRange([string[]]$RedirectedLookup.IPAddresses) | Out-Null } } # Record has been redirected, stop evaluating terms break } # Include mechanism elseif ($Term -match '^(?<Qualifier>[+-~?])?include:(?<Value>.+)$') { $LookupCount++ Write-Verbose '-----INCLUDE-----' Write-Verbose "Looking up include $($Matches.Value)" $IncludeLookup = Read-SpfRecord -Domain $Matches.Value -Level 'Include' if ([string]::IsNullOrEmpty($IncludeLookup.Record) -and $Level -eq 'Parent') { Write-Verbose '-----END INCLUDE (SPF MISSING)-----' $ValidationFails.Add("Include lookup for $($Matches.Value) does not contain a SPF record, this will result in a failure.") | Out-Null $Status = 'permerror' } else { Write-Verbose '-----END INCLUDE (SPF FOUND)-----' $RecordList.Add($IncludeLookup) | Out-Null $ValidationFails.AddRange([string[]]$IncludeLookup.ValidationFails) | Out-Null $ValidationWarns.AddRange([string[]]$IncludeLookup.ValidationWarns) | Out-Null $ValidationPasses.AddRange([string[]]$IncludeLookup.ValidationPasses) | Out-Null $IPAddresses.AddRange([string[]]$IncludeLookup.IPAddresses) | Out-Null } } # Exists mechanism elseif ($Term -match '^(?<Qualifier>[+-~?])?exists:(?<Value>.+)$') { $LookupCount++ } # ip4/ip6 mechanism elseif ($Term -match '^(?<Qualifier>[+-~?])?ip[4,6]:(?<Value>.+)$') { if (-not ($Matches.Qualifier) -or $Matches.Qualifier -eq '+') { $IPAddresses.Add($Matches.Value) | Out-Null } } # Remaining type mechanisms a,mx,ptr elseif ($Term -match '^(?<Qualifier>[+-~?])?(?<RecordType>(?:a|mx|ptr))(?:[:](?<TypeDomain>.+))?$') { $LookupCount++ if ($Matches.TypeDomain) { $TypeDomain = $Matches.TypeDomain } else { $TypeDomain = $Domain } if ($TypeDomain -ne 'Not Specified') { try { $TypeQuery = @{ Domain = $TypeDomain; RecordType = $Matches.RecordType } Write-Verbose "Looking up $($TypeQuery.Domain)" $TypeResult = Resolve-DnsHttpsQuery @TypeQuery -ErrorAction Stop if ($Matches.RecordType -eq 'mx') { $MxCount = 0 if ($TypeResult.Answer) { foreach ($mx in $TypeResult.Answer.data) { $MxCount++ $Preference, $MxDomain = $mx -replace '\.$' -split '\s+' try { Write-Verbose "MX: Lookup $MxDomain" $MxQuery = Resolve-DnsHttpsQuery -Domain $MxDomain -ErrorAction Stop $MxIps = $MxQuery.Answer.data foreach ($MxIp in $MxIps) { $IPAddresses.Add($MxIp) | Out-Null } if ($MxCount -gt 10) { $ValidationWarns.Add("$Domain - Mechanism 'mx' lookup for $MxDomain has exceeded the 10 A or AAAA record lookup limit (RFC 7208, Section 4.6.4).") | Out-Null $TypeResult = $null break } } catch { Write-Verbose $_.Exception.Message $TypeResult = $null } } } else { $ValidationWarns.Add("$Domain - Mechanism 'mx' lookup for $($TypeQuery.Domain) did not have any records") | Out-Null } } elseif ($Matches.RecordType -eq 'ptr') { $ValidationWarns.Add("$Domain - The mechanism 'ptr' should not be published in an SPF record (RFC 7208, Section 5.5)") } } catch { $TypeResult = $null } if ($null -eq $TypeResult -or $TypeResult.Status -ne 0) { $Message = "$Domain - Type lookup for the mechanism '$($TypeQuery.RecordType)' did not return any results." switch ($Level) { 'Parent' { $ValidationFails.Add("$Message") | Out-Null $Status = 'permerror' } 'Include' { $ValidationWarns.Add("$Message") | Out-Null } } $Result = $false } else { if ($TypeResult.Answer) { if ($TypeQuery.RecordType -match 'mx') { $Result = $TypeResult.Answer | ForEach-Object { #$LookupCount++ $_.Data.Split(' ')[1] } } else { $Result = $TypeResult.answer.data } } } $TypeLookups.Add( [PSCustomObject]@{ Domain = $TypeQuery.Domain RecordType = $TypeQuery.RecordType Result = $Result } ) | Out-Null } else { $ValidationWarns.Add("No domain was specified and mechanism '$Term' does not have one defined. Specify a domain to perform a lookup on this record.") | Out-Null } } elseif ($null -ne $Term) { $ValidationWarns.Add("$Domain - Unknown term specified '$Term'") | Out-Null } } # Explanation modifier if ($Record -match 'exp=(?<MacroExpand>.+)$') { Write-Verbose '-----EXPLAIN-----' $ExpQuery = @{ Domain = $Domain; MacroExpand = $Matches.MacroExpand; RecordType = 'TXT' } $ExpResult = Resolve-DnsHttpsQuery @ExpQuery -ErrorAction Stop if ($ExpResult.Status -eq 0 -and $ExpResult.Answer.Type -eq 16) { $Explain = @{ Record = $ExpResult.Answer.data Example = Get-DomainMacros -Domain $Domain -MacroExpand $ExpResult.Answer.data } } } else { $Explain = @{ Example = ''; Record = '' } } } } } catch { Write-Verbose "EXCEPTION: $($_.InvocationInfo.ScriptLineNumber) $($_.Exception.Message)" } # Lookup MX record for expected include information if not supplied if ($Level -eq 'Parent' -and $ExpectedInclude -eq '') { try { #Write-Information $Domain $MXRecord = Read-MXRecord -Domain $Domain $SpfResults.MailProvider = $MXRecord.MailProvider if ($MXRecord.ExpectedInclude -ne '') { $ExpectedInclude = $MXRecord.ExpectedInclude } if ($MXRecord.MailProvider.Name -eq 'Null') { if ($Record -eq 'v=spf1 -all') { $ValidationPasses.Add('This SPF record is valid for a Null MX configuration') | Out-Null } else { $ValidationFails.Add('This SPF record is not valid for a Null MX configuration. Expected record: "v=spf1 -all"') | Out-Null } } if ($TypeLookups.RecordType -contains 'mx') { $Recommendations.Add([pscustomobject]@{ Message = "Remove the 'mx' modifier from your record. Check the mail provider documentation for the correct SPF include."; Match = '\s*([+-~?]?mx)\s+' Replace = ' ' }) | Out-Null } } catch { Write-Verbose $_.Exception.Message } } # Look for expected include record and report pass or fail if ($ExpectedInclude -ne '') { if ($RecordList.Domain -notcontains $ExpectedInclude) { $ExpectedIncludeSpf = Read-SpfRecord -Domain $ExpectedInclude -Level ExpectedInclude $ExpectedIPCount = $ExpectedIncludeSpf.IPAddresses | Measure-Object | Select-Object -ExpandProperty Count $FoundIPCount = Compare-Object $IPAddresses $ExpectedIncludeSpf.IPAddresses -IncludeEqual | Where-Object -Property SideIndicator -EQ '==' | Measure-Object | Select-Object -ExpandProperty Count if ($ExpectedIPCount -eq $FoundIPCount) { $ValidationPasses.Add('The expected mail provider IP address ranges were found.') | Out-Null } else { $ValidationFails.Add('The expected mail provider entry was not found in the record.') | Out-Null $Recommendations.Add([pscustomobject]@{ Message = ("Add 'include:{0} to your record." -f $ExpectedInclude) Match = '^v=spf1 (.+?)([-~?+]all)?$' Replace = "v=spf1 include:$ExpectedInclude `$1 `$2" }) | Out-Null } } else { $ValidationPasses.Add('The expected mail provider entry is part of the record.') | Out-Null } } # Count total lookups $LookupCount = $LookupCount + ($RecordList | Measure-Object -Property LookupCount -Sum).Sum if ($Domain -ne 'Not Specified') { # Check legacy SPF type $LegacySpfType = Resolve-DnsHttpsQuery -Domain $Domain -RecordType 'SPF' -ErrorAction Stop if ($null -ne $LegacySpfType -and $LegacySpfType -eq 0) { $ValidationWarns.Add("The record type 'SPF' was detected, this is legacy and should not be used. It is recommeded to delete this record (RFC 7208 Section 14.1).") | Out-Null } } if ($Level -eq 'Parent' -and $RecordCount -gt 0) { # Check for the correct all mechanism if ($AllMechanism -eq '' -and $Record -ne '') { $ValidationFails.Add("The 'all' mechanism is missing from SPF record, the default is a neutral qualifier (?all).") | Out-Null $AllMechanism = '?all' } if ($AllMechanism -eq '-all') { $ValidationPasses.Add('The SPF record ends with a hard fail qualifier (-all). This is best practice and will instruct recipients to discard unauthorized senders.') | Out-Null } elseif ($Record -ne '') { $ValidationFails.Add('The SPF record should end in -all to prevent spamming.') | Out-Null $Recommendations.Add([PSCustomObject]@{ Message = "Replace '{0}' with '-all' to make a SPF failure result in a hard fail." -f $AllMechanism Match = [regex]::escape($AllMechanism) Replace = '-all' }) | Out-Null } # SPF lookup count if ($LookupCount -ge 9) { $SpecificLookupsFound = $false foreach ($SpfRecord in $RecordList) { if ($SpfRecord.LookupCount -ge 5) { $SpecificLookupsFound = $true $IncludeLookupCount = $SpfRecord.LookupCount + 1 $Match = ('[+-~?]?include:{0}' -f $SpfRecord.Domain) $Recommendations.Add([PSCustomObject]@{ Message = ("Remove the include modifier for domain '{0}', this adds {1} lookups towards the max of 10. Alternatively, reduce the number of lookups inside this record if you are able to." -f $SpfRecord.Domain, $IncludeLookupCount) Match = $Match Replace = '' }) | Out-Null } } if (!($SpecificLookupsFound)) { $Recommendations.Add([PSCustomObject]@{ Message = 'Review include modifiers to ensure that your lookup count stays below 10.' Match = '' }) | Out-Null } } if ($LookupCount -gt 10) { $ValidationFails.Add("Lookup count: $LookupCount/10. The SPF evaluation will fail with a permanent error (RFC 7208 Section 4.6.4).") | Out-Null $Status = 'permerror' } elseif ($LookupCount -ge 9 -and $LookupCount -le 10) { $ValidationWarns.Add("Lookup count: $LookupCount/10. Excessive lookups can cause the SPF evaluation to fail (RFC 7208 Section 4.6.4).") | Out-Null } else { $ValidationPasses.Add("Lookup count: $LookupCount/10.") | Out-Null } # Report pass if no PermErrors are found if ($Status -ne 'permerror') { $ValidationPasses.Add('No permanent errors detected in the SPF record.') | Out-Null } # Report pass if no errors are found if (($ValidationFails | Measure-Object | Select-Object -ExpandProperty Count) -eq 0) { $ValidationPasses.Add('All validation checks passed.') | Out-Null } } # Check recommendations for replacement regexes if (($Recommendations | Measure-Object).Count -gt 0) { $RecommendedRecord = $Record foreach ($Rec in $Recommendations) { if ($Rec.Match -ne '') { # Replace item in record with recommended $RecommendedRecord = $RecommendedRecord -replace $Rec.Match, $Rec.Replace } } # Cleanup extra spaces $RecommendedRecord = $RecommendedRecord -replace '\s+', ' ' } # Set SPF result object $SpfResults.Record = $Record $SpfResults.RecordCount = $RecordCount $SpfResults.LookupCount = $LookupCount $SpfResults.AllMechanism = $AllMechanism $SpfResults.ValidationPasses = @($ValidationPasses) $SpfResults.ValidationWarns = @($ValidationWarns) $SpfResults.ValidationFails = @($ValidationFails) $SpfResults.RecordList = @($RecordList) $SpfResults.Recommendations = @($Recommendations) $SpfResults.RecommendedRecord = $RecommendedRecord $SpfResults.TypeLookups = @($TypeLookups) $SpfResults.IPAddresses = @($IPAddresses) $SpfResults.Explanation = $Explain $SpfResults.Status = $Status Write-Verbose "-----END SPF RECORD ($Level)-----" # Output SpfResults object $SpfResults } #EndRegion './Public/Records/Read-SPFRecord.ps1' 549 #Region './Public/Records/Read-TlsRptRecord.ps1' 0 function Read-TlsRptRecord { <# .SYNOPSIS Resolve and validate TLSRPT record .DESCRIPTION Query domain for TLSRPT record (_smtp._tls.domain.com) and parse results. Record is checked for issues. .PARAMETER Domain Domain to process TLSRPT record .EXAMPLE PS> Read-TlsRptRecord -Domain gmail.com #> [CmdletBinding()] Param( [Parameter(Mandatory = $true)] [string]$Domain ) # Initialize object $TlsRptAnalysis = [PSCustomObject]@{ Domain = $Domain Record = '' Version = '' RuaEntries = [System.Collections.Generic.List[string]]::new() IsValid = $false HasWarnings = $false ValidationPasses = [System.Collections.Generic.List[string]]::new() ValidationWarns = [System.Collections.Generic.List[string]]::new() ValidationFails = [System.Collections.Generic.List[string]]::new() } $ValidRuaProtocols = @( '^(?<Rua>https:.+)$' '^mailto:(?<Rua>.+)$' ) # Validation lists $ValidationPasses = [System.Collections.Generic.List[string]]::new() $ValidationWarns = [System.Collections.Generic.List[string]]::new() $ValidationFails = [System.Collections.Generic.List[string]]::new() # Validation ranges $RecordCount = 0 $DnsQuery = @{ RecordType = 'TXT' Domain = "_smtp._tls.$Domain" } # Resolve DMARC record $Query = Resolve-DnsHttpsQuery @DnsQuery -ErrorAction Stop $RecordCount = 0 $Query.Answer | Where-Object { $_.data -match '^v=TLSRPTv1' } | ForEach-Object { $TlsRtpRecord = $_.data $TlsRptAnalysis.Record = $TlsRtpRecord $RecordCount++ } if ($Query.Status -eq 2 -and $Query.AD -eq $false) { $ValidationFails.Add('DNSSEC validation failed.') | Out-Null } if ($Query.Status -ne 0 -or $RecordCount -eq 0) { if ($Query.Status -eq 3) { $ValidationFails.Add('Record does not exist (NXDOMAIN)') | Out-Null } else { $ValidationFails.Add("$Domain does not have an TLSRPT record") | Out-Null } } elseif ($RecordCount -gt 1) { $ValidationFails.Add("$Domain has multiple TLSRPT records") | Out-Null } # Split DMARC record into name/value pairs $TagList = [System.Collections.Generic.List[object]]::new() Foreach ($Element in ($TlsRtpRecord -split ';').trim()) { $Name, $Value = $Element -split '=' $TagList.Add( [PSCustomObject]@{ Name = $Name Value = $Value } ) | Out-Null } # Loop through name/value pairs and set object properties $x = 0 foreach ($Tag in $TagList) { switch ($Tag.Name) { 'v' { # REQUIRED: Version if ($x -ne 0) { $ValidationFails.Add('v=TLSRPTv1 must be at the beginning of the record') | Out-Null } if ($Tag.Value -ne 'TLSRPTv1') { $ValidationFails.Add("Version must be TLSRPTv1 - found $($Tag.Value)") | Out-Null } $TlsRptAnalysis.Version = $Tag.Value } 'rua' { $RuaMatched = $false $RuaEntries = $Tag.Value -split ',' foreach ($RuaEntry in $RuaEntries) { foreach ($Protocol in $ValidRuaProtocols) { if ($RuaEntry -match $Protocol) { $TlsRptAnalysis.RuaEntries.Add($Matches.Rua) | Out-Null $RuaMatched = $true } } } if ($RuaMatched) { $ValidationPasses.Add('Aggregate reports are being sent') | Out-Null } else { $ValidationWarns.Add('Aggregate reports are not being sent') | Out-Null $TlsRptAnalysis.HasWarnings = $true } } } $x++ } if ($RecordCount -gt 0) { # Check for missing record tags and set defaults if ($RecordCount -gt 1) { $ValidationWarns.Add('Multiple TLSRPT records detected, this may cause unexpected behavior.') | Out-Null $TlsRptAnalysis.HasWarnings = $true } $ValidationWarnCount = ($Test.ValidationWarns | Measure-Object).Count $ValidationFailCount = ($Test.ValidationFails | Measure-Object).Count if ($ValidationFailCount -eq 0 -and $ValidationWarnCount -eq 0) { $ValidationPasses.Add('TLSRPT record is valid') | Out-Null $TlsRptAnalysis.IsValid = $true } } # Add the validation lists $TlsRptAnalysis.ValidationPasses = $ValidationPasses $TlsRptAnalysis.ValidationWarns = $ValidationWarns $TlsRptAnalysis.ValidationFails = $ValidationFails # Return MTA-STS analysis $TlsRptAnalysis } #EndRegion './Public/Records/Read-TlsRptRecord.ps1' 151 #Region './Public/Records/Read-WhoisRecord.ps1' 0 function Read-WhoisRecord { <# .SYNOPSIS Reads Whois record data for queried information .DESCRIPTION Connects to top level registrar servers (IANA, ARIN) and performs recursion to find Whois data .PARAMETER Query Whois query to perform (e.g. microsoft.com) .PARAMETER Server Whois server to query, defaults to whois.iana.org .PARAMETER Port Whois server port, default 43 .EXAMPLE PS> Read-WhoisRecord -Query microsoft.com #> [CmdletBinding()] param ( [Parameter (Position = 0, Mandatory = $true)] [String]$Query, [String]$Server = 'whois.iana.org', $Port = 43 ) $HasReferral = $false # Top level referring servers, IANA, ARIN and AUDA $TopLevelReferrers = @('whois.iana.org', 'whois.arin.net', 'whois.auda.org.au') # Record Pattern Matching $ServerPortRegex = '(?<refsvr>[^:\r\n]+)(:(?<port>\d+))?' $ReferralMatch = @{ 'ReferralServer' = "whois://$ServerPortRegex" 'Whois Server' = $ServerPortRegex 'Registrar Whois Server' = $ServerPortRegex 'refer' = $ServerPortRegex 'remarks' = '(?<refsvr>whois\.[0-9a-z\-\.]+\.[a-z]{2,})(:(?<port>\d+))?' } # List of properties for Registrars $RegistrarProps = @( 'Registrar', 'Registrar Name' ) # Whois parser, generic Property: Value format with some multi-line support and comment handlers $WhoisRegex = '^(?!(?:%|>>>|-+|#|[*]))[^\S\n]*(?<PropName>.+?):(?:[\r\n]+)?(:?(?!([0-9]|[/]{2}))[^\S\r\n]*(?<PropValue>.+))?$' # TCP Client for Whois $Client = New-Object System.Net.Sockets.TcpClient($Server, 43) try { # Open TCP connection and send query $Stream = $Client.GetStream() $ReferralServers = [System.Collections.Generic.List[string]]::new() $ReferralServers.Add($Server) | Out-Null # WHOIS query to send $Data = [System.Text.Encoding]::Ascii.GetBytes("$Query`r`n") $Stream.Write($Data, 0, $data.length) # Read response from stream $Reader = New-Object System.IO.StreamReader $Stream, [System.Text.Encoding]::ASCII $Raw = $Reader.ReadToEnd() # Split comments and parse raw whois results $data, $comment = $Raw -split '(>>>|\n\s+--)' $PropMatches = [regex]::Matches($data, $WhoisRegex, ([System.Text.RegularExpressions.RegexOptions]::MultiLine, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)) # Hold property count in hashtable for auto increment $PropertyCounts = @{} # Create ordered list for properties $Results = [ordered]@{} foreach ($PropMatch in $PropMatches) { $PropName = $PropMatch.Groups['PropName'].value if ($Results.Contains($PropName)) { $PropertyCounts.$PropName++ $PropName = '{0}{1}' -f $PropName, $PropertyCounts.$PropName $Results[$PropName] = $PropMatch.Groups['PropValue'].value.trim() } else { $Results[$PropName] = $PropMatch.Groups['PropValue'].value.trim() $PropertyCounts.$PropName = 0 } } foreach ($RegistrarProp in $RegistrarProps) { if ($Results.Contains($RegistrarProp)) { $Results._Registrar = $Results.$RegistrarProp if ($Results.$RegistrarProp -eq 'Registrar') { break # Means we always favour Registrar if it exists, or keep looking } } } # Store raw results and query metadata $Results._Raw = $Raw $Results._ReferralServers = [System.Collections.Generic.List[string]]::new() $Results._Query = $Query $LastResult = $Results # Loop through keys looking for referral server match foreach ($Key in $ReferralMatch.Keys) { if ([bool]($Results.Keys -match $Key)) { if ($Results.$Key -match $ReferralMatch.$Key) { $ReferralServer = $Matches.refsvr if ($Server -ne $ReferralServer) { if ($Matches.port) { $Port = $Matches.port } else { $Port = 43 } $HasReferral = $true break } } } } # Recurse through referrals if ($HasReferral) { if ($Server -ne $ReferralServer) { $LastResult = $Results $Results = Read-WhoisRecord -Query $Query -Server $ReferralServer -Port $Port if ($Results._Raw -Match '(No match|Not Found|No Data|The queried object does not exist)' -and $TopLevelReferrers -notcontains $Server) { $Results = $LastResult } else { foreach ($s in $Results._ReferralServers) { $ReferralServers.Add($s) | Out-Null } } } } else { if ($Results._Raw -Match '(No match|Not Found|No Data)') { $first, $newquery = ($Query -split '\.') if (($newquery | Measure-Object).Count -gt 1) { $Query = $newquery -join '.' $Results = Read-WhoisRecord -Query $Query -Server $Server -Port $Port foreach ($s in $Results._ReferralServers) { $ReferralServers.Add($s) | Out-Null } } } } } catch { Write-Error $_.Exception.Message } finally { IF ($Stream) { $Stream.Close() $Stream.Dispose() } } # Collect referral server list $Results._ReferralServers = $ReferralServers # Convert to json and back to preserve object order $WhoisResults = $Results | ConvertTo-Json | ConvertFrom-Json # Return Whois results as PSObject $WhoisResults } #EndRegion './Public/Records/Read-WhoisRecord.ps1' 173 |