Private/ConvertFrom-ReceivedHeader.ps1
|
# Received chain: tokenizer (comment-aware), one hop per Received field. function Split-ReceivedSegment { <# .SYNOPSIS Splits a Received value into words and parenthesised comments (comments may nest). #> [CmdletBinding()] param([Parameter(Mandatory)][AllowEmptyString()][string]$Value) $segments = New-Object System.Collections.Generic.List[object] $depth = 0 $word = '' $comment = '' foreach ($ch in $Value.ToCharArray()) { if ($depth -eq 0) { if ($ch -eq '(') { if ($word) { $segments.Add(@{ Kind = 'word'; Text = $word }); $word = '' } $depth = 1 $comment = '' } elseif ([char]::IsWhiteSpace($ch)) { if ($word) { $segments.Add(@{ Kind = 'word'; Text = $word }); $word = '' } } else { $word += $ch } } else { if ($ch -eq '(') { $depth++; $comment += $ch } elseif ($ch -eq ')') { $depth-- if ($depth -eq 0) { $segments.Add(@{ Kind = 'comment'; Text = ([regex]::Replace($comment, '\s+', ' ')).Trim() }) } else { $comment += $ch } } else { $comment += $ch } } } if ($word) { $segments.Add(@{ Kind = 'word'; Text = $word }) } if ($depth -gt 0 -and $comment.Trim()) { $segments.Add(@{ Kind = 'comment'; Text = ([regex]::Replace($comment, '\s+', ' ')).Trim() }) } return ,$segments } function Get-LastTopLevelSemicolon { <# .SYNOPSIS Position of the last semicolon outside parentheses (the date separator), or -1. #> [CmdletBinding()] [OutputType([int])] param([Parameter(Mandatory)][AllowEmptyString()][string]$Text) $depth = 0 for ($i = $Text.Length - 1; $i -ge 0; $i--) { $ch = $Text[$i] if ($ch -eq ')') { $depth++ } elseif ($ch -eq '(') { $depth = [math]::Max(0, $depth - 1) } elseif ($ch -eq ';' -and $depth -eq 0) { return $i } } return -1 } function Test-PrivateIPAddress { [CmdletBinding()] [OutputType([bool])] param([Parameter(Mandatory)][string]$IPAddress) $v4 = [regex]::Match($IPAddress, '^(\d+)\.(\d+)\.') if ($v4.Success) { $a = [int]$v4.Groups[1].Value $b = [int]$v4.Groups[2].Value return ($a -eq 10 -or $a -eq 127 -or ($a -eq 172 -and $b -ge 16 -and $b -le 31) -or ($a -eq 192 -and $b -eq 168) -or ($a -eq 169 -and $b -eq 254) -or ($a -eq 100 -and $b -ge 64 -and $b -le 127)) } $low = $IPAddress.ToLowerInvariant() return ($low -eq '::1' -or $low.StartsWith('fe80:') -or $low.StartsWith('fc') -or $low.StartsWith('fd')) } function ConvertTo-TlsVersionLabel { [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)][string]$Version) $v = [regex]::Replace($Version, '^TLSv?', 'TLS ', 'IgnoreCase') $v = $v -replace '_', '.' $v = [regex]::Replace($v, '^TLS \.', 'TLS 1.') $v = ([regex]::Replace($v, '\s+', ' ')).Trim() return [regex]::Replace($v, '^TLS ?1\.?([0-3])$', 'TLS 1.$1') } function ConvertFrom-ReceivedHeader { <# .SYNOPSIS Parses one Received field into a hop object. #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)][AllowEmptyString()][string]$Value, [Parameter(Mandatory)][AllowEmptyString()][string]$Raw ) $semi = Get-LastTopLevelSemicolon -Text $Value $dateRaw = '' $rest = $Value if ($semi -ge 0) { $dateRaw = $Value.Substring($semi + 1).Trim() $rest = $Value.Substring(0, $semi) } $keywords = @('from', 'by', 'via', 'with', 'id', 'for') $groups = @{} $current = $null foreach ($segment in (Split-ReceivedSegment -Value $rest)) { $low = $segment.Text.ToLowerInvariant() $emptyCurrent = ($null -ne $current -and $groups[$current].Count -eq 0) if ($segment.Kind -eq 'word' -and $keywords -contains $low -and -not $emptyCurrent) { $current = $low if (-not $groups.ContainsKey($current)) { $groups[$current] = New-Object System.Collections.Generic.List[object] } } elseif ($null -ne $current) { $groups[$current].Add($segment) } } $words = { param($key) if (-not $groups.ContainsKey($key)) { return @() } return @($groups[$key] | Where-Object { $_.Kind -eq 'word' } | ForEach-Object { $_.Text }) } $comments = { param($key) if (-not $groups.ContainsKey($key)) { return @() } return @($groups[$key] | Where-Object { $_.Kind -eq 'comment' } | ForEach-Object { $_.Text }) } $fromWords = @(& $words 'from') $fromHost = $null if ($fromWords.Count -gt 0) { $fromHost = $fromWords[0] } $fromComments = (& $comments 'from') -join ' ' $byWords = @(& $words 'by') $byHost = $null if ($byWords.Count -gt 0) { $byHost = $byWords[0] -replace '\.$', '' } $software = (& $comments 'by') -join ' ' if (-not $software) { $software = $null } $protocol = (& $words 'with') -join ' ' if (-not $protocol) { $protocol = $null } $id = (& $words 'id') -join ' ' if (-not $id) { $id = $null } $forWords = @(& $words 'for') $forAddress = $null if ($forWords.Count -gt 0) { $forAddress = $forWords[0] -replace '[<>]', '' } $via = (& $words 'via') -join ' ' if (-not $via) { $via = $null } # IP: preferably from square brackets in the from comment, otherwise a bare # IPv4, otherwise an IP literal as HELO name. $ip = $null $ipMatch = [regex]::Match($fromComments, '\[(?:IPv6:)?([0-9a-fA-F:.]+)\]') if ($ipMatch.Success) { $ip = $ipMatch.Groups[1].Value } else { $v4Match = [regex]::Match($fromComments, '\b((?:\d{1,3}\.){3}\d{1,3})\b') if ($v4Match.Success) { $ip = $v4Match.Groups[1].Value } } if (-not $ip -and $fromHost) { $literal = [regex]::Match($fromHost, '^\[(?:IPv6:)?([0-9a-fA-F:.]+)\]$') if ($literal.Success) { $ip = $literal.Groups[1].Value } elseif ($fromHost -match '^(?:\d{1,3}\.){3}\d{1,3}$') { $ip = $fromHost } } # rDNS: first host name in the from comment that is not the IP itself. $reverseDns = $null $rd = [regex]::Match($fromComments, '(?:^|[\s(])([a-z0-9][a-z0-9._-]*\.[a-z][a-z0-9-]*)\.?(?=\s*\[)', 'IgnoreCase') if ($rd.Success -and $rd.Groups[1].Value.ToLowerInvariant() -ne 'unknown') { $reverseDns = $rd.Groups[1].Value.ToLowerInvariant() } $helo = $fromHost $heloMatch = [regex]::Match($fromComments, '\bhelo=([^\s)]+)', 'IgnoreCase') if ($heloMatch.Success) { $helo = $heloMatch.Groups[1].Value } # TLS details from the whole line (Microsoft, Postfix and Exim spellings). $rawOne = [regex]::Replace($Raw, '\s+', ' ') $tlsVersion = $null $tlsCipher = $null $msTls = [regex]::Match($rawOne, 'version=([A-Za-z0-9_.]+)[,\s)]+cipher=([A-Za-z0-9_-]+)') $pfTls = [regex]::Match($rawOne, 'using\s+(TLSv?[0-9._]+)(?:\s+with\s+cipher\s+([A-Z0-9_-]+))?', 'IgnoreCase') $anyTls = [regex]::Match($rawOne, '\b(TLSv?1[._][0-3])\b') if ($msTls.Success) { $tlsVersion = ConvertTo-TlsVersionLabel -Version $msTls.Groups[1].Value; $tlsCipher = $msTls.Groups[2].Value } elseif ($pfTls.Success) { $tlsVersion = ConvertTo-TlsVersionLabel -Version $pfTls.Groups[1].Value if ($pfTls.Groups[2].Success) { $tlsCipher = $pfTls.Groups[2].Value } } elseif ($anyTls.Success) { $tlsVersion = ConvertTo-TlsVersionLabel -Version $anyTls.Groups[1].Value } if (-not $tlsCipher) { $cipherMatch = [regex]::Match($rawOne, '\bcipher[= ]([A-Z0-9_-]{8,})', 'IgnoreCase') if ($cipherMatch.Success) { $tlsCipher = $cipherMatch.Groups[1].Value } } # Protocol class (RFC 3848 "with" values plus Microsoft variants). $p = '' if ($protocol) { $p = $protocol.ToUpperInvariant() } $protocolClass = $null if ($p) { if ($p.Contains('HTTP')) { $protocolClass = 'Http' } elseif ($p.Contains('MAPI')) { $protocolClass = 'Mapi' } elseif ($p.Contains('LOCAL')) { $protocolClass = 'Local' } elseif ($p -match 'SMTPSA$|LMTPSA$') { $protocolClass = 'TlsAuthenticated' } elseif ($p -match 'SMTPS$|LMTPS$') { $protocolClass = 'Tls' } elseif ($p -match 'SMTPA$|LMTPA$') { $protocolClass = 'Authenticated' } elseif ($p.Contains('SMTP') -or $p.Contains('LMTP')) { if ($tlsVersion) { $protocolClass = 'Tls' } else { $protocolClass = 'Plain' } } } elseif ($tlsVersion) { $protocolClass = 'Tls' } $hostsForProvider = @($reverseDns, $fromHost, $byHost) | Where-Object { $_ } | ForEach-Object { $_.ToLowerInvariant() } $isPrivate = $false if ($ip) { $isPrivate = Test-PrivateIPAddress -IPAddress $ip } [pscustomobject]@{ PSTypeName = 'MailHeaderAnalyzer.Hop' Index = 0 FromHost = $fromHost Helo = $helo ReverseDns = $reverseDns IPAddress = $ip IsPrivateIP = $isPrivate ByHost = $byHost Software = $software Protocol = $protocol ProtocolClass = $protocolClass TlsVersion = $tlsVersion TlsCipher = $tlsCipher Id = $id For = $forAddress Via = $via Date = (ConvertFrom-MailDate -Text $dateRaw) Delay = $null Provider = (Get-MailProvider -Hosts @($hostsForProvider)) # Written by the receiving system itself. Only the topmost Received line # qualifies; every line below was already in the message and is an # unverified claim of the sender. Attested = $false Raw = $Raw } } |