Private/Invoke-HeaderAnalysis.ps1
|
# Core analysis: builds the analysis object from parsed fields. No DOM, no network. # Header fields RFC 5322 section 3.6 limits to at most one instance. $script:SingletonFields = @('From', 'Sender', 'Reply-To', 'To', 'Cc', 'Bcc', 'Subject', 'Date', 'Message-ID', 'Return-Path', 'In-Reply-To', 'References') # Upper bound for evaluated Received lines. A real delivery needs fewer than 30 # stations; RFC 5321 section 6.3 recommends 100 as loop protection. Counted from # the top, i.e. from delivery: the youngest stations are the reliable ones. $script:MaxHops = 200 $script:CompAuthReasonMeaning = @{ '000' = 'failed explicit authentication: DMARC fail with a reject or quarantine policy' '001' = 'failed implicit authentication: the sending domain publishes no authentication records' '002' = 'the organization has a policy for the sender/domain pair that prohibits spoofed messages' '010' = 'DMARC fail with reject or quarantine, and the sending domain is one of your accepted domains' '1' = 'passed authentication' '2' = 'soft-passed implicit authentication' '3' = 'not checked for composite authentication' '4' = 'bypassed composite authentication (for example intra-organization or allow list)' '6' = 'failed implicit authentication, and the sending domain is one of your accepted domains' '7' = 'passed authentication' '9' = 'bypassed composite authentication' } function Get-CompAuthReasonMeaning { [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Reason) if (-not $Reason) { return $null } if ($script:CompAuthReasonMeaning.ContainsKey($Reason)) { return $script:CompAuthReasonMeaning[$Reason] } $bucket = $Reason.Substring(0, 1) if ($script:CompAuthReasonMeaning.ContainsKey($bucket)) { return $script:CompAuthReasonMeaning[$bucket] } return $null } function New-Finding { [CmdletBinding()] [OutputType([pscustomobject])] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Creates an in-memory object only.')] param( [Parameter(Mandatory)][ValidateSet('Info', 'Warning', 'Fail')][string]$Severity, [Parameter(Mandatory)][string]$Code, [Parameter(Mandatory)][string]$Message ) [pscustomobject]@{ PSTypeName = 'MailHeaderAnalyzer.Finding' Severity = $Severity Code = $Code Message = $Message } } function Invoke-HeaderAnalysis { [CmdletBinding()] [OutputType('MailHeaderAnalyzer.Analysis')] param( [Parameter(Mandatory)][AllowEmptyString()][string]$Text, [string]$Source = 'Text' ) $split = Split-MailHeader -Text $Text $fields = @($split.Fields) $findings = New-Object System.Collections.Generic.List[object] # Hops: in the header the last one is on top; chronologically it is the other way round. $received = @(Get-HeaderField -Fields $fields -Name 'Received') $hopOverflow = [math]::Max(0, $received.Count - $script:MaxHops) $hopList = New-Object System.Collections.Generic.List[object] $limit = [math]::Min($received.Count, $script:MaxHops) for ($i = $limit - 1; $i -ge 0; $i--) { $hopList.Add((ConvertFrom-ReceivedHeader -Value $received[$i].Value -Raw $received[$i].Raw)) } $hops = $hopList.ToArray() for ($i = 0; $i -lt $hops.Count; $i++) { $hops[$i].Index = $i + 1 # Only the line added last comes from the receiving system. $hops[$i].Attested = ($i -eq $hops.Count - 1) } $hasSkew = $false $slowestIndex = -1 $slowest = -1 for ($i = 1; $i -lt $hops.Count; $i++) { $a = $hops[$i - 1].Date $b = $hops[$i].Date if ($null -ne $a -and $null -ne $b) { $seconds = [math]::Round(($b - $a).TotalSeconds) $hops[$i].Delay = [timespan]::FromSeconds($seconds) if ($seconds -lt 0) { $hasSkew = $true } if ($seconds -gt $slowest) { $slowest = $seconds; $slowestIndex = $i } } } $dated = @($hops | Where-Object { $null -ne $_.Date }) $totalDuration = $null if ($dated.Count -ge 2) { $totalDuration = [timespan]::FromSeconds([math]::Round(($dated[-1].Date - $dated[0].Date).TotalSeconds)) } $authResults = @( @(Get-HeaderField -Fields $fields -Name 'Authentication-Results') + @(Get-HeaderField -Fields $fields -Name 'Authentication-Results-Original') ) | ForEach-Object { ConvertFrom-AuthenticationResults -Field $_ } $authResults = @($authResults) $spfField = Get-HeaderField -Fields $fields -Name 'Received-SPF' -First $receivedSpf = $null if ($null -ne $spfField) { $receivedSpf = ConvertFrom-ReceivedSpf -Field $spfField } # Check the origin of verification lines against the delivery chain: a line # whose authserv-id never appears as "by" host can be written by anyone. $byHosts = @($hops | ForEach-Object { ConvertTo-NormalizedDomain -Domain $_.ByHost } | Where-Object { $_ }) $deliveredBy = $null if ($hops.Count -gt 0) { $deliveredBy = ConvertTo-NormalizedDomain -Domain $hops[-1].ByHost } foreach ($set in $authResults) { $set.Trust = Get-AuthTrust -AuthServId $set.AuthServId -ByHosts $byHosts } # If there is a verified line, only that one counts (RFC 8601 section 5). $trusted = @($authResults | Where-Object { $_.Trust -eq 'Matched' }) $ranked = $authResults if ($trusted.Count -gt 0) { $ranked = $trusted } $summary = [ordered]@{ spf = $null; dkim = $null; dmarc = $null; arc = $null; compauth = $null } foreach ($set in $ranked) { foreach ($m in $set.Methods) { if ($summary.Contains($m.Method) -and $null -eq $summary[$m.Method]) { $summary[$m.Method] = $m } } } $authoritative = $null foreach ($set in $ranked) { if ($set.Methods.Count -gt 0) { $authoritative = $set; break } } $withMethods = @($authResults | Where-Object { $_.Methods.Count -gt 0 }) $origins = @($withMethods | ForEach-Object { $n = ConvertTo-NormalizedDomain -Domain $_.AuthServId; if ($n) { $n } else { '' } } | Sort-Object -Unique) $authMixedOrigins = $origins.Count -gt 1 $receivedSpfTrust = 'None' if ($null -ne $receivedSpf) { $receiver = $null if ($receivedSpf.Properties.Contains('receiver')) { $receiver = $receivedSpf.Properties['receiver'] } $receivedSpfTrust = Get-AuthTrust -AuthServId $receiver -ByHosts $byHosts } $spfFromReceivedSpf = ($null -eq $summary['spf'] -and $null -ne $receivedSpf) if ($spfFromReceivedSpf) { $summary['spf'] = [pscustomobject]@{ PSTypeName = 'MailHeaderAnalyzer.AuthMethod' Method = 'spf' Result = $receivedSpf.Result Properties = $receivedSpf.Properties Comments = $receivedSpf.Comments } } # DKIM signatures; receiver result matched via header.d (and header.s if present). $dkimResults = @($ranked | ForEach-Object { $_.Methods } | Where-Object { $_.Method -eq 'dkim' }) $usedResults = New-Object System.Collections.Generic.List[object] $now = [datetime]::UtcNow $epoch = New-Object DateTime (1970, 1, 1, 0, 0, 0, [DateTimeKind]::Utc) $dkimSignatures = @(Get-HeaderField -Fields $fields -Name 'DKIM-Signature') | ForEach-Object { $tags = ConvertFrom-TagList -Value $_.Value $d = $null if ($tags.Contains('d')) { $d = $tags['d'].ToLowerInvariant() } $s = $null if ($tags.Contains('s')) { $s = $tags['s'].ToLowerInvariant() } $match = $null foreach ($r in $dkimResults) { if ($usedResults.Contains($r)) { continue } $rd = $null if ($r.Properties.Contains('header.d')) { $rd = $r.Properties['header.d'].ToLowerInvariant() } $rs = $null if ($r.Properties.Contains('header.s')) { $rs = $r.Properties['header.s'].ToLowerInvariant() } if (-not $rd -or $rd -ne $d) { continue } if ($rs -and $s -and $rs -ne $s) { continue } $match = $r break } $receiverResult = $null if ($null -ne $match) { $usedResults.Add($match) if ($match.Result -eq 'pass') { $receiverResult = 'pass' } else { $receiverResult = 'fail' } } $expires = $null if ($tags.Contains('x') -and $tags['x'] -match '^\d+$') { $expires = $epoch.AddSeconds([double]$tags['x']) } $timestamp = $null if ($tags.Contains('t') -and $tags['t'] -match '^\d+$') { $timestamp = $epoch.AddSeconds([double]$tags['t']) } $signedHeaders = @() if ($tags.Contains('h')) { $signedHeaders = @($tags['h'] -split ':' | ForEach-Object { $_.Trim().ToLowerInvariant() } | Where-Object { $_ }) } $algorithm = $null if ($tags.Contains('a')) { $algorithm = $tags['a'] } $canonicalization = $null if ($tags.Contains('c')) { $canonicalization = $tags['c'] } $bodyLength = $null if ($tags.Contains('l')) { $bodyLength = $tags['l'] } [pscustomobject]@{ PSTypeName = 'MailHeaderAnalyzer.DkimSignature' Domain = $d Selector = $s Algorithm = $algorithm Canonicalization = $canonicalization SignedHeaders = $signedHeaders BodyLength = $bodyLength Timestamp = $timestamp Expires = $expires ReceiverResult = $receiverResult Tags = $tags Raw = $_.Raw } } $dkimSignatures = @($dkimSignatures) # ARC chain grouped from the seals. $arcMap = @{} foreach ($seal in @(Get-HeaderField -Fields $fields -Name 'ARC-Seal')) { $t = ConvertFrom-TagList -Value $seal.Value $i = 0 if ($t.Contains('i') -and $t['i'] -match '^\d+$') { $i = [int]$t['i'] } $sealDomain = $null if ($t.Contains('d')) { $sealDomain = $t['d'] } $cv = $null if ($t.Contains('cv')) { $cv = $t['cv'].ToLowerInvariant() } $arcMap[$i] = [pscustomobject]@{ PSTypeName = 'MailHeaderAnalyzer.ArcInstance' Instance = $i SealDomain = $sealDomain ChainValidation = $cv Results = $null Methods = @() } } foreach ($aar in @(Get-HeaderField -Fields $fields -Name 'ARC-Authentication-Results')) { $im = [regex]::Match($aar.Value, '^\s*i\s*=\s*(\d+)') $i = 0 if ($im.Success) { $i = [int]$im.Groups[1].Value } if ($arcMap.ContainsKey($i)) { $inner = [regex]::Replace($aar.Value, '^\s*i\s*=\s*\d+\s*;\s*', '') $arcMap[$i].Results = $inner $parsed = ConvertFrom-AuthenticationResults -Field ([pscustomobject]@{ Name = $aar.Name; Value = $inner; Raw = $aar.Raw }) $arcMap[$i].Methods = @($parsed.Methods) } } $arc = @($arcMap.Values | Sort-Object Instance) $arcValid = $null if ($arc.Count -gt 0) { $arcValid = $true foreach ($inst in $arc) { if ($inst.Instance -eq 1) { if ($inst.ChainValidation -ne 'none' -and $inst.ChainValidation -ne 'pass') { $arcValid = $false } } elseif ($inst.ChainValidation -ne 'pass') { $arcValid = $false } } } $fromField = Get-HeaderField -Fields $fields -Name 'From' -First $from = $null if ($null -ne $fromField) { $from = ConvertFrom-MailAddress -Value $fromField.Value } $replyToField = Get-HeaderField -Fields $fields -Name 'Reply-To' -First $replyTo = $null if ($null -ne $replyToField) { $replyTo = ConvertFrom-MailAddress -Value $replyToField.Value } $returnPathField = Get-HeaderField -Fields $fields -Name 'Return-Path' -First $returnPath = $null if ($null -ne $returnPathField) { $returnPath = ConvertFrom-MailAddress -Value $returnPathField.Value } $subjectField = Get-HeaderField -Fields $fields -Name 'Subject' -First $subject = $null if ($null -ne $subjectField) { $subject = ConvertFrom-EncodedWord -Text $subjectField.Value } $mailFromDomain = $null if ($null -ne $summary['spf'] -and $summary['spf'].Properties.Contains('smtp.mailfrom')) { $mailFromDomain = ($summary['spf'].Properties['smtp.mailfrom'] -split '@')[-1].ToLowerInvariant() } elseif ($null -ne $returnPath) { $mailFromDomain = $returnPath.Domain } $bidiFields = @($fields | Where-Object { Test-BidiControl -Text $_.Raw } | ForEach-Object { $_.Name } | Sort-Object -Unique) $duplicates = @(foreach ($name in $script:SingletonFields) { $values = @(Get-HeaderField -Fields $fields -Name $name | ForEach-Object { $_.Value }) if ($values.Count -gt 1) { [pscustomobject]@{ Name = $name; Values = $values } } }) $fromDomain = $null if ($null -ne $from) { $fromDomain = $from.Domain } $dkimDomain = $null if ($null -ne $summary['dkim'] -and $summary['dkim'].Properties.Contains('header.d')) { $dkimDomain = $summary['dkim'].Properties['header.d'] } $spfAlignment = Get-DmarcAlignment -A $fromDomain -B $mailFromDomain $dkimAlignment = Get-DmarcAlignment -A $fromDomain -B $dkimDomain $exchange = Get-ExchangeClassification -Fields $fields $spam = Get-SpamAssessment -Fields $fields $listUnsubscribe = Get-HeaderValue -Fields $fields -Name 'List-Unsubscribe' $listPost = Get-HeaderValue -Fields $fields -Name 'List-Unsubscribe-Post' $listId = Get-HeaderValue -Fields $fields -Name 'List-Id' $list = $null if ($listUnsubscribe -or $listId) { $list = [pscustomobject]@{ PSTypeName = 'MailHeaderAnalyzer.ListInfo' ListId = $listId Unsubscribe = $listUnsubscribe OneClick = ($null -ne $listPost -and $listPost -match 'One-Click') } } $result = { param($key) if ($null -ne $summary[$key]) { return $summary[$key].Result }; return $null } $authTrust = 'None' if ($null -ne $authoritative) { $authTrust = $authoritative.Trust } # ---- Findings ------------------------------------------------------- foreach ($dup in $duplicates) { $findings.Add((New-Finding -Severity Warning -Code 'DuplicateField' -Message ('The header field {0} occurs {1} times. RFC 5322 section 3.6 allows exactly one; several lines are a known spoofing pattern because mail clients and filters may pick different ones.' -f $dup.Name, $dup.Values.Count))) } if ($bidiFields.Count -gt 0) { $findings.Add((New-Finding -Severity Warning -Code 'BidiControls' -Message ('Unicode direction controls in these header fields: {0}. Such characters reverse the reading direction and make, for example, "fdp.exe" appear as "exe.pdf".' -f ($bidiFields -join ', ')))) } if ($hopOverflow -gt 0) { $findings.Add((New-Finding -Severity Warning -Code 'HopOverflow' -Message ('{0} Received lines beyond the limit of {1} were not evaluated.' -f $hopOverflow, $script:MaxHops))) } if ($authTrust -eq 'Unmatched') { $findings.Add((New-Finding -Severity Warning -Code 'AuthUnverified' -Message ('The verification results carry the identifier {0}, which does not appear anywhere in the delivery chain. A sender can prepend such a line themselves; the results are not evidence.' -f $authoritative.AuthServId))) } if ($authMixedOrigins) { $findings.Add((New-Finding -Severity Warning -Code 'AuthMixedOrigins' -Message 'Verification results of several origins are present. Only the line of the receiving organization is authoritative.')) } if ($receivedSpfTrust -eq 'Unmatched') { $findings.Add((New-Finding -Severity Warning -Code 'ReceivedSpfForeign' -Message ('The Received-SPF line names {0} as the checking server; that host does not appear in the delivery chain.' -f $receivedSpf.Properties['receiver']))) } if ($spfFromReceivedSpf) { $findings.Add((New-Finding -Severity Info -Code 'ReceivedSpfOnly' -Message 'The SPF result comes only from a Received-SPF line, not from an Authentication-Results line of the receiving server.')) } if ($null -eq $summary['spf'] -and $null -eq $summary['dkim'] -and $null -eq $summary['dmarc']) { $findings.Add((New-Finding -Severity Info -Code 'NoAuthResults' -Message 'No Authentication-Results found: the receiving server left no verification results in the header.')) } $dmarcResult = & $result 'dmarc' if ($dmarcResult -eq 'fail') { $findings.Add((New-Finding -Severity Fail -Code 'DmarcFail' -Message 'DMARC failed according to the receiving server: depending on the policy the message could have been rejected or quarantined.')) } $spfResult = & $result 'spf' if ($spfResult -in @('fail', 'softfail', 'permerror', 'temperror')) { $findings.Add((New-Finding -Severity Warning -Code 'SpfNotPass' -Message ('SPF result: {0}.' -f $spfResult))) } $dkimResult = & $result 'dkim' if ($dkimResult -in @('fail', 'permerror', 'temperror')) { $arcWitness = $false foreach ($inst in $arc) { foreach ($m in $inst.Methods) { if ($m.Method -eq 'dkim' -and $m.Result -eq 'pass' -and $m.Properties.Contains('header.d') -and (Test-SameDomain -A $m.Properties['header.d'] -B $dkimDomain)) { $arcWitness = $true } } } if ($arcWitness) { $findings.Add((New-Finding -Severity Info -Code 'DkimBrokenAfterForward' -Message 'DKIM failed at the receiver, but an ARC seal attests that the signature was valid earlier: typical for a forwarding or mailing list that modified the message.')) } else { $findings.Add((New-Finding -Severity Warning -Code 'DkimNotPass' -Message ('DKIM result: {0}.' -f $dkimResult))) } } foreach ($sig in $dkimSignatures) { if ($sig.Algorithm -and $sig.Algorithm -match 'sha1') { $findings.Add((New-Finding -Severity Warning -Code 'DkimWeakHash' -Message ('DKIM signature of {0} uses {1}: SHA-1 is deprecated (RFC 8301), receivers may ignore the signature.' -f $sig.Domain, $sig.Algorithm))) } if ($null -ne $sig.BodyLength) { $findings.Add((New-Finding -Severity Warning -Code 'DkimBodyLength' -Message ('DKIM signature of {0} limits the signed body length (l={1}): content appended afterwards is not covered.' -f $sig.Domain, $sig.BodyLength))) } if ($null -ne $sig.Expires -and $sig.Expires -lt $now) { $findings.Add((New-Finding -Severity Warning -Code 'DkimExpired' -Message ('DKIM signature of {0} expired on {1:u}.' -f $sig.Domain, $sig.Expires))) } if ($sig.SignedHeaders.Count -gt 0 -and $sig.SignedHeaders -notcontains 'from') { $findings.Add((New-Finding -Severity Warning -Code 'DkimFromUnsigned' -Message ('DKIM signature of {0} does not cover the From field (RFC 6376 requires it).' -f $sig.Domain))) } } if ($hasSkew) { $findings.Add((New-Finding -Severity Info -Code 'ClockSkew' -Message 'At least one hop carries an earlier timestamp than its predecessor: clock skew between the servers, the delays are only approximate.')) } if ($null -ne $replyTo -and $null -ne $from -and $replyTo.Domain -and $from.Domain -and -not (Test-SameDomain -A $replyTo.Domain -B $from.Domain)) { $findings.Add((New-Finding -Severity Info -Code 'ReplyToMismatch' -Message ('Reply-To domain ({0}) differs from the From domain ({1}). Replies go elsewhere; common with newsletters, also a phishing pattern.' -f $replyTo.Domain, $from.Domain))) } if ($spfAlignment -eq 'None') { $findings.Add((New-Finding -Severity Info -Code 'SpfNotAligned' -Message ('The envelope sender domain ({0}) is not aligned with the From domain ({1}): SPF cannot contribute to DMARC.' -f $mailFromDomain, $fromDomain))) } if ($null -ne $exchange) { if ($exchange.WrongTenantAttribution) { $findings.Add((New-Finding -Severity Warning -Code 'ExchangeWrongTenant' -Message 'The message was attributed to a different tenant (X-MS-Exchange-CrossTenant-OriginalAttributedTenantConnectingIp). Classic cause: another tenant''s inbound connector uses the same TLS certificate or sender IPs.')) } if ($exchange.CrossPremisesHeadersFiltered) { $findings.Add((New-Finding -Severity Warning -Code 'ExchangeHeadersFiltered' -Message 'The send connector stripped the cross-premises headers: the message loses its internal classification (see KB3212872).')) } if ($exchange.AuthMechanism -and $exchange.AuthMechanism -match '^0*10$') { $findings.Add((New-Finding -Severity Warning -Code 'ExchangeExternallySecured' -Message 'AuthMechanism 10: the message entered through an "externally secured" receive connector and bypassed EOP filtering as Internal.')) } } if ($null -ne $spam) { if ($spam.Category -and $spam.Category -ne 'NONE') { $findings.Add((New-Finding -Severity Warning -Code 'SpamCategory' -Message ('Microsoft classified the message as {0} ({1}).' -f $spam.Category, $spam.CategoryMeaning))) } if ($spam.Scl -and [int]$spam.Scl -ge 5) { $findings.Add((New-Finding -Severity Warning -Code 'SpamConfidence' -Message ('Spam confidence level {0}: {1}.' -f $spam.Scl, $spam.SclMeaning))) } } $compAuth = $summary['compauth'] $compAuthReason = $null if ($null -ne $compAuth -and $compAuth.Properties.Contains('reason')) { $compAuthReason = $compAuth.Properties['reason'] } [pscustomobject]@{ PSTypeName = 'MailHeaderAnalyzer.Analysis' Source = $Source Subject = $subject From = $from ReplyTo = $replyTo ReturnPath = $returnPath Date = (ConvertFrom-MailDate -Text (Get-HeaderValue -Fields $fields -Name 'Date')) MessageId = (Get-HeaderValue -Fields $fields -Name 'Message-ID') MailFromDomain = $mailFromDomain Spf = (& $result 'spf') Dkim = (& $result 'dkim') Dmarc = (& $result 'dmarc') Arc = (& $result 'arc') CompAuth = (& $result 'compauth') CompAuthReason = $compAuthReason CompAuthReasonMeaning = (Get-CompAuthReasonMeaning -Reason $compAuthReason) AuthTrust = $authTrust AuthServId = $(if ($null -ne $authoritative) { $authoritative.AuthServId } else { $null }) AuthenticationResults = $authResults ReceivedSpf = $receivedSpf SpfAlignment = $spfAlignment DkimAlignment = $dkimAlignment Hops = $hops HopCount = $hops.Count TotalDuration = $totalDuration SlowestHopIndex = $(if ($slowestIndex -ge 0) { $slowestIndex + 1 } else { $null }) HasClockSkew = $hasSkew DeliveredBy = $deliveredBy DkimSignatures = $dkimSignatures ArcChain = $arc ArcValid = $arcValid Exchange = $exchange Spam = $spam List = $list Findings = $findings.ToArray() Fields = $fields HadBody = $split.HadBody } } |