Modules/MDOP2.Common.psm1
|
Set-StrictMode -Version Latest function ConvertTo-MDOP2ODataLiteral { <# .SYNOPSIS Returns a value as a quoted OData string literal. Single quotes are doubled so user input can't break out of the literal. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)][AllowEmptyString()][string]$Value ) if ($Value -match '[\x00-\x1F]') { throw 'Value contains control characters and cannot be used in an OData filter.' } "'" + $Value.Replace("'", "''") + "'" } function Get-MDOP2RetryDelay { <# .SYNOPSIS Works out how many seconds to wait before retrying a throttled or failed request. Uses Retry-After when the service sends it, otherwise exponential backoff capped at MaxSeconds. #> [CmdletBinding()] [OutputType([int])] param( [Parameter(Mandatory)][int]$Attempt, [string]$RetryAfter, [int]$BaseSeconds = 2, [int]$MaxSeconds = 120 ) if ($RetryAfter) { $seconds = 0 if ([int]::TryParse($RetryAfter.Trim(), [ref]$seconds) -and $seconds -ge 0) { return [Math]::Min([Math]::Max($seconds, 1), $MaxSeconds) } $date = [DateTimeOffset]::MinValue if ([DateTimeOffset]::TryParse($RetryAfter.Trim(), [ref]$date)) { $delta = [int][Math]::Ceiling(($date - [DateTimeOffset]::UtcNow).TotalSeconds) return [Math]::Min([Math]::Max($delta, 1), $MaxSeconds) } } $delay = $BaseSeconds * [Math]::Pow(2, [Math]::Max($Attempt - 1, 0)) [int][Math]::Min($delay, $MaxSeconds) } function Test-MDOP2RetryableStatus { [CmdletBinding()] [OutputType([bool])] param([Parameter(Mandatory)][int]$StatusCode) $StatusCode -in 429, 500, 502, 503, 504 } function Get-MDOP2DateRange { <# .SYNOPSIS Turns a range preset into a UTC start and end. 90d starts a few minutes late because message trace rejects anything older than 90 days. #> [CmdletBinding()] param( [Parameter(Mandatory)][ValidateSet('1d', '7d', '30d', '90d')][string]$Range, [datetime]$Now = [datetime]::UtcNow ) $end = $Now.ToUniversalTime() $start = $end.AddDays(-[int]$Range.TrimEnd('d')) if ($Range -eq '90d') { $start = $start.AddMinutes(10) } [pscustomobject]@{ Start = $start; End = $end } } function Split-MDOP2DateRange { <# .SYNOPSIS Splits a date range into windows of at most MaxDays, newest first. Get-MessageTraceV2 accepts 10 days per query. #> [CmdletBinding()] param( [Parameter(Mandatory)][datetime]$Start, [Parameter(Mandatory)][datetime]$End, [ValidateRange(1, 10)][int]$MaxDays = 10 ) if ($End -le $Start) { throw 'End must be after start.' } $windowEnd = $End while ($windowEnd -gt $Start) { $windowStart = $windowEnd.AddDays(-$MaxDays) if ($windowStart -lt $Start) { $windowStart = $Start } [pscustomobject]@{ Start = $windowStart; End = $windowEnd } $windowEnd = $windowStart } } function ConvertTo-MDOP2AddressFilter { <# .SYNOPSIS Parses sender or recipient input. Full addresses can go to message trace; domains (contoso.com or @contoso.com) are matched locally because message trace has no wildcards. #> [CmdletBinding()] param([AllowNull()][AllowEmptyCollection()][string[]]$Value) $addresses = [Collections.Generic.List[string]]::new() $domains = [Collections.Generic.List[string]]::new() foreach ($raw in @($Value)) { if ([string]::IsNullOrWhiteSpace($raw)) { continue } $v = $raw.Trim().ToLowerInvariant() if ($v -match '^[^\s@<>"*?]+@[a-z0-9.-]+\.[a-z]{2,}$') { $addresses.Add($v) } elseif ($v -match '^@?([a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,})$') { $domains.Add($Matches[1]) } else { throw "'$raw' is not an email address or domain." } } [pscustomobject]@{ Addresses = $addresses.ToArray() Domains = $domains.ToArray() IsEmpty = ($addresses.Count + $domains.Count) -eq 0 # Message trace can only filter server side when every value is a full address ServerSide = $domains.Count -eq 0 -and $addresses.Count -gt 0 } } function Test-MDOP2AddressMatch { <# .SYNOPSIS True when an address matches a filter from ConvertTo-MDOP2AddressFilter. An empty filter matches everything. #> [CmdletBinding()] [OutputType([bool])] param( [AllowNull()][AllowEmptyString()][string]$Address, [Parameter(Mandatory)][object]$Filter ) if ($Filter.IsEmpty) { return $true } if ([string]::IsNullOrEmpty($Address)) { return $false } $a = $Address.ToLowerInvariant() if ($a -in $Filter.Addresses) { return $true } foreach ($d in $Filter.Domains) { if ($a.EndsWith("@$d")) { return $true } } $false } function Get-MDOP2ThrottleDelay { <# .SYNOPSIS Seconds to wait before the next query so that no more than Limit queries run in any WindowSeconds period. #> [CmdletBinding()] [OutputType([int])] param( [AllowEmptyCollection()][datetime[]]$History = @(), [datetime]$Now = [datetime]::UtcNow, [int]$Limit = 100, [int]$WindowSeconds = 300 ) $recent = @($History | Where-Object { ($Now - $_).TotalSeconds -lt $WindowSeconds } | Sort-Object) if ($recent.Count -lt $Limit) { return 0 } $oldest = $recent[$recent.Count - $Limit] [int][Math]::Max(1, [Math]::Ceiling($WindowSeconds - ($Now - $oldest).TotalSeconds)) } function Group-MDOP2TraceResult { <# .SYNOPSIS Groups message trace rows by Message-ID into one entry per message, with its recipients and their delivery status. #> [CmdletBinding()] param([AllowNull()][AllowEmptyCollection()][object[]]$Rows) $groups = [ordered]@{} foreach ($r in @($Rows)) { if ($null -eq $r) { continue } $id = [string]$r.MessageId if (-not $id) { $id = "trace:$($r.MessageTraceId)" } if (-not $groups.Contains($id)) { $groups[$id] = [pscustomobject]@{ Key = $id InternetMessageId = [string]$r.MessageId Subject = [string]$r.Subject SenderAddress = [string]$r.SenderAddress FromIP = $(if ($r.PSObject.Properties['FromIP']) { [string]$r.FromIP } else { '' }) FirstReceived = $r.Received LastReceived = $r.Received RecipientCount = 0 Recipients = [Collections.Generic.List[object]]::new() StatusCounts = [ordered]@{} Index = @{} } } $g = $groups[$id] if ($r.Received -lt $g.FirstReceived) { $g.FirstReceived = $r.Received } if ($r.Received -gt $g.LastReceived) { $g.LastReceived = $r.Received } $addr = ([string]$r.RecipientAddress).ToLowerInvariant() if ($g.Index.ContainsKey($addr)) { # Same recipient seen more than once (retries, forwarding): keep the newest row $existing = $g.Index[$addr] if ($r.Received -gt $existing.Received) { $existing.Received = $r.Received $existing.Status = [string]$r.Status $existing.MessageTraceId = $r.MessageTraceId } continue } $rcpt = [pscustomobject]@{ Address = $addr Status = [string]$r.Status Received = $r.Received MessageTraceId = $r.MessageTraceId } $g.Index[$addr] = $rcpt $g.Recipients.Add($rcpt) } foreach ($g in $groups.Values) { $g.RecipientCount = $g.Recipients.Count foreach ($rc in $g.Recipients) { $g.StatusCounts[$rc.Status] = 1 + ($g.StatusCounts[$rc.Status] ?? 0) } $g.PSObject.Properties.Remove('Index') $g.Recipients = $g.Recipients.ToArray() $g } } function Get-MDOP2ResolveSkipReason { <# .SYNOPSIS Says why a recipient can't be searched with Graph, or returns nothing when it can. #> [CmdletBinding()] param( [AllowNull()][AllowEmptyString()][string]$TraceStatus, [AllowNull()][AllowEmptyString()][string]$RecipientTypeDetails ) if ($TraceStatus -in 'Failed', 'Quarantined', 'Expanded') { return "Trace status $TraceStatus, not in a mailbox" } if (-not $RecipientTypeDetails) { return 'Not a recipient in this tenant' } if ($RecipientTypeDetails -eq 'GroupMailbox') { return 'Microsoft 365 group mailbox, not supported' } if ($RecipientTypeDetails -notin 'UserMailbox', 'SharedMailbox', 'RoomMailbox', 'EquipmentMailbox', 'SchedulingMailbox') { return "Recipient type $RecipientTypeDetails has no mailbox in this tenant" } } function Get-MDOP2MailboxErrorState { <# .SYNOPSIS Maps a Graph error on a mailbox call to a short state the UI can show. #> [CmdletBinding()] param([int]$StatusCode, [string]$ErrorCode) switch ($StatusCode) { 403 { return 'AccessDenied' } 404 { if ($ErrorCode -in 'MailboxNotEnabledForRESTAPI', 'MailboxNotSupportedForRESTAPI', 'ResourceNotFound', 'ErrorInvalidUser') { return 'NoMailbox' } return 'NotFound' } default { return 'Error' } } } $script:ActionFolders = [ordered]@{ MoveToInbox = 'inbox' MoveToArchive = 'archive' MoveToJunk = 'junkemail' MoveToDeletedItems = 'deleteditems' SoftDelete = 'recoverableitemsdeletions' } function Get-MDOP2ActionFolder { <# .SYNOPSIS Returns the well-known Graph folder an action moves mail to. #> [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)][ValidateSet('MoveToInbox', 'MoveToArchive', 'MoveToJunk', 'MoveToDeletedItems', 'SoftDelete')][string]$Action) $script:ActionFolders[$Action] } function Get-MDOP2SameMailbox { <# .SYNOPSIS Message trace can list an alias (status Resolved) and the primary address of one mailbox as separate recipients of one message. Returns one entry per row: the address of the row that stands for the same mailbox, or $null when the row is that one. The delivered row is kept over the Resolved alias row. #> [CmdletBinding()] param([AllowEmptyCollection()][object[]]$Row = @()) $rows = @($Row) $keep = @{} foreach ($alias in $false, $true) { foreach ($r in $rows) { if (-not $r -or -not $r.UserId -or $keep.ContainsKey([string]$r.UserId)) { continue } $isAlias = $r.PSObject.Properties['TraceStatus'] -and $r.TraceStatus -eq 'Resolved' if ($isAlias -eq $alias) { $keep[[string]$r.UserId] = [string]$r.Address } } } , @(foreach ($r in $rows) { if ($r -and $r.UserId -and $keep[[string]$r.UserId] -ne [string]$r.Address) { $keep[[string]$r.UserId] } else { $null } }) } function New-MDOP2ActionPlan { <# .SYNOPSIS Turns resolve results (one row per recipient, from Resolve-MDOP2MessageGroup) into one plan item per message copy. Recipients where the message wasn't found or can't be searched become skipped items, so they still show up in the result. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$InternetMessageId, [AllowEmptyCollection()][object[]]$Resolved = @(), [Parameter(Mandatory)][ValidateSet('MoveToInbox', 'MoveToArchive', 'MoveToJunk', 'MoveToDeletedItems', 'SoftDelete')][string]$Action, # Allow moving copies out of Recoverable Items (restore). Only set when the admin chose a restore. [switch]$AllowRestore ) $destination = Get-MDOP2ActionFolder -Action $Action # Each mailbox copy is planned once, also when an alias and the primary address both received it $rows = @($Resolved) $sameAs = Get-MDOP2SameMailbox -Row $rows for ($i = 0; $i -lt $rows.Count; $i++) { $r = $rows[$i] if ($null -eq $r) { continue } $base = [ordered]@{ InternetMessageId = $InternetMessageId Address = [string]$r.Address UserId = [string]$r.UserId Action = $Action Destination = $destination AllowRestore = [bool]$AllowRestore } if ($r.State -eq 'Found' -and @($r.Locations).Count) { foreach ($loc in $r.Locations) { $item = [ordered]@{} + $base $item.GraphMessageId = [string]$loc.GraphMessageId $item.FolderId = [string]$loc.FolderId $item.FolderName = [string]$loc.FolderName $item.Plan = if ($sameAs[$i]) { 'Skip' } else { 'Move' } $item.Reason = if ($sameAs[$i]) { "Same mailbox as $($sameAs[$i])" } else { $null } [pscustomobject]$item } } else { $item = [ordered]@{} + $base $item.GraphMessageId = $null $item.FolderId = $null $item.FolderName = $null $item.Plan = 'Skip' $item.Reason = if ($r.Detail) { "$($r.State): $($r.Detail)" } else { [string]$r.State } [pscustomobject]$item } } } function Get-MDOP2MoveDecision { <# .SYNOPSIS Decides whether a message copy should move. Folder IDs are compared, not names, because names are localised. Copies already in Recoverable Items are only moved back out when the admin chose a restore (-AllowRestore). #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$CurrentFolderId, [Parameter(Mandatory)][string]$TargetFolderId, [string]$RecoverableItemsFolderId, [Parameter(Mandatory)][string]$Action, [switch]$AllowRestore ) if ($CurrentFolderId -eq $TargetFolderId) { return [pscustomobject]@{ Move = $false; Result = 'AlreadyThere'; Reason = 'Already in the target folder' } } if ($RecoverableItemsFolderId -and $CurrentFolderId -eq $RecoverableItemsFolderId -and $Action -ne 'SoftDelete' -and -not $AllowRestore) { return [pscustomobject]@{ Move = $false; Result = 'Skipped'; Reason = 'Already soft deleted; not moved back out of Recoverable Items' } } [pscustomobject]@{ Move = $true; Result = $null; Reason = $null } } function Get-MDOP2ActionSummary { <# .SYNOPSIS Counts what an action plan will touch, for the confirmation prompt. #> [CmdletBinding()] param([AllowEmptyCollection()][object[]]$Plan = @()) $move = @($Plan | Where-Object { $_ -and $_.Plan -eq 'Move' }) [pscustomobject]@{ Messages = @($move | ForEach-Object InternetMessageId | Sort-Object -Unique).Count Mailboxes = @($move | ForEach-Object UserId | Sort-Object -Unique).Count Copies = $move.Count Skipped = @($Plan | Where-Object { $_ -and $_.Plan -eq 'Skip' }).Count } } $script:AuditBlockedKey = '(?i)secret|token|password|credential|authorization|body|content|mime' function Remove-MDOP2SensitiveData { param([AllowNull()][object]$Value, [int]$Depth = 0) if ($null -eq $Value -or $Depth -gt 8) { return $Value } if ($Value -is [string] -or $Value -is [ValueType]) { return $Value } if ($Value -is [Security.SecureString]) { return '[removed]' } if ($Value -is [Collections.IDictionary]) { $out = [ordered]@{} foreach ($k in $Value.Keys) { if ([string]$k -match $script:AuditBlockedKey) { continue } $out[[string]$k] = Remove-MDOP2SensitiveData -Value $Value[$k] -Depth ($Depth + 1) } return $out } if ($Value -is [Collections.IEnumerable]) { return , @(foreach ($v in $Value) { Remove-MDOP2SensitiveData -Value $v -Depth ($Depth + 1) }) } $out = [ordered]@{} foreach ($p in $Value.PSObject.Properties) { if ($p.Name -match $script:AuditBlockedKey) { continue } $out[$p.Name] = Remove-MDOP2SensitiveData -Value $p.Value -Depth ($Depth + 1) } $out } function ConvertTo-MDOP2AuditLine { <# .SYNOPSIS Serialises an audit event to one JSON line. Keys that could hold secrets, tokens or message content are dropped at any depth. #> [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)][Collections.IDictionary]$Event) $clean = Remove-MDOP2SensitiveData -Value $Event # -Compress escapes line breaks inside strings, so one event is always one line ConvertTo-Json -InputObject $clean -Depth 10 -Compress } #region Message analysis (detail page) function Get-MDOP2AddressDomain { <# .SYNOPSIS Returns the lower case domain of an address (angle brackets allowed), or an empty string. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Address) if ($Address -match '@([^@\s<>]+?)>?\s*$') { return $Matches[1].ToLowerInvariant().TrimEnd('.') } '' } function Test-MDOP2DomainAligned { <# .SYNOPSIS Relaxed alignment as in DMARC: the domains are equal or one is a subdomain of the other. #> [CmdletBinding()] [OutputType([bool])] param([AllowEmptyString()][string]$A, [AllowEmptyString()][string]$B) if (-not $A -or -not $B) { return $true } $A = $A.ToLowerInvariant(); $B = $B.ToLowerInvariant() $A -eq $B -or $A.EndsWith(".$B") -or $B.EndsWith(".$A") } function Get-MDOP2HeaderValue { <# .SYNOPSIS Returns every value of a header (case-insensitive name) from Graph internetMessageHeaders, in message order. #> [CmdletBinding()] param([AllowNull()][AllowEmptyCollection()][object[]]$Headers, [Parameter(Mandatory)][string]$Name) , [string[]]@(foreach ($h in @($Headers)) { if ($h -and [string]$h.name -ieq $Name) { [string]$h.value } }) } function ConvertFrom-MDOP2AuthenticationResult { <# .SYNOPSIS Parses SPF, DKIM, DMARC and CompAuth from Authentication-Results headers. Uses the header Exchange Online added (the one with compauth), otherwise the topmost one. #> [CmdletBinding()] param([AllowNull()][AllowEmptyCollection()][string[]]$Value) $values = @($Value | Where-Object { $_ }) $out = [ordered]@{ spf = $null; dkim = $null; dmarc = $null; compauth = $null; raw = $null } if (-not $values.Count) { return [pscustomobject]$out } $pick = @($values | Where-Object { $_ -match '(?i)\bcompauth=' } | Select-Object -First 1) $raw = if ($pick.Count) { $pick[0] } else { $values[0] } $out.raw = $raw $flat = ($raw -replace '\s+', ' ').Trim() foreach ($part in $flat -split ';') { $part = $part.Trim() if ($part -notmatch '^(?i)(spf|dkim|dmarc|compauth)\s*=\s*([A-Za-z]+)\s*(.*)$') { continue } $method = $Matches[1].ToLowerInvariant() $entry = [pscustomobject]@{ result = $Matches[2].ToLowerInvariant(); detail = $Matches[3].Trim() } if ($null -eq $out[$method]) { $out[$method] = $entry } elseif ($method -eq 'dkim') { # Several signatures: pass if any passed, and keep every detail $cur = $out[$method] $result = if ($cur.result -eq 'pass' -or $entry.result -eq 'pass') { 'pass' } else { $cur.result } $out[$method] = [pscustomobject]@{ result = $result; detail = "$($cur.detail); $($entry.result) $($entry.detail)".Trim(' ', ';') } } } [pscustomobject]$out } function ConvertFrom-MDOP2AntispamReport { <# .SYNOPSIS Parses X-Forefront-Antispam-Report (CIP:1.2.3.4;CTRY:US;SCL:1;SFV:NSPM;...) into a dictionary with upper case keys. #> [CmdletBinding()] param([AllowNull()][AllowEmptyString()][string]$Value) $out = [ordered]@{} if (-not $Value) { return $out } foreach ($part in ($Value -replace '\s+', ' ') -split ';') { $i = $part.IndexOf(':') if ($i -lt 1) { continue } $k = $part.Substring(0, $i).Trim().ToUpperInvariant() if (-not $out.Contains($k)) { $out[$k] = $part.Substring($i + 1).Trim() } } $out } function ConvertFrom-MDOP2MailDate { <# .SYNOPSIS Parses an RFC 5322 date such as "Mon, 3 Nov 2026 10:11:12 +0100" to UTC. Returns nothing when it can't. #> [CmdletBinding()] param([AllowNull()][AllowEmptyString()][string]$Value) if (-not $Value) { return } if ($Value -notmatch '(\d{1,2})\s+([A-Za-z]{3})[a-z]*\s+(\d{4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?\s*([+-]\d{4})?') { return } $months = @{ jan = 1; feb = 2; mar = 3; apr = 4; may = 5; jun = 6; jul = 7; aug = 8; sep = 9; oct = 10; nov = 11; dec = 12 } $day = [int]$Matches[1]; $mon = $months[$Matches[2].ToLowerInvariant()]; $year = [int]$Matches[3] $hour = [int]$Matches[4]; $min = [int]$Matches[5] $sec = if ($Matches[6]) { [int]$Matches[6] } else { 0 } $zone = $Matches[7] if (-not $mon) { return } $offset = [timespan]::Zero if ($zone) { $offset = [timespan]::new([int]$zone.Substring(1, 2), [int]$zone.Substring(3, 2), 0) if ($zone[0] -eq '-') { $offset = $offset.Negate() } } try { [datetimeoffset]::new($year, $mon, $day, $hour, $min, $sec, $offset).UtcDateTime } catch { return } } function ConvertFrom-MDOP2ReceivedHeader { <# .SYNOPSIS Parses Received headers (topmost first, as in the message) into hops in the order the message travelled, with the delay since the previous hop. #> [CmdletBinding()] param([AllowNull()][AllowEmptyCollection()][string[]]$Value) $list = [string[]]@($Value | Where-Object { $_ }) [array]::Reverse($list) $prev = $null $hop = 0 , @(foreach ($v in $list) { $hop++ $flat = ($v -replace '\s+', ' ').Trim() $semi = $flat.LastIndexOf(';') $main = if ($semi -ge 0) { $flat.Substring(0, $semi) } else { $flat } $date = if ($semi -ge 0) { ConvertFrom-MDOP2MailDate $flat.Substring($semi + 1) } else { $null } $from = if ($main -match '(?i)(?:^|\s)from\s+(.+?)(?=\s+by\s|\s+with\s|$)') { $Matches[1].Trim() } else { '' } $by = if ($main -match '(?i)(?:^|\s)by\s+(.+?)(?=\s+with\s|\s+id\s|\s+via\s|\s+for\s|$)') { $Matches[1].Trim() } else { '' } $with = if ($main -match '(?i)(?:^|\s)with\s+(\S+)') { $Matches[1].Trim() } else { '' } $delay = if ($date -and $prev) { [int][Math]::Round(($date - $prev).TotalSeconds) } else { $null } if ($date) { $prev = $date } [pscustomobject]@{ Hop = $hop From = $from By = $by With = $with Date = $date DelaySeconds = $delay Raw = $flat } }) } function Get-MDOP2SuspicionFlag { <# .SYNOPSIS Simple checks for the detail page: sender mismatches, authentication failures and a display name that shows a different address. Returns items with Severity (High, Medium, Info) and Text. #> [CmdletBinding()] param( [AllowNull()][AllowEmptyString()][string]$FromAddress, [AllowNull()][AllowEmptyString()][string]$FromName, [AllowNull()][AllowEmptyString()][string]$ReturnPath, [AllowNull()][AllowEmptyCollection()][string[]]$ReplyTo, [AllowNull()][object]$Authentication ) $fromDomain = Get-MDOP2AddressDomain $FromAddress $flags = [Collections.Generic.List[object]]::new() $add = { param($sev, $text) $flags.Add([pscustomobject]@{ Severity = $sev; Text = $text }) } $rpDomain = Get-MDOP2AddressDomain $ReturnPath if ($rpDomain -and $fromDomain -and -not (Test-MDOP2DomainAligned $rpDomain $fromDomain)) { & $add 'Medium' "Return-Path domain ($rpDomain) differs from the From domain ($fromDomain)." } foreach ($r in @($ReplyTo | Where-Object { $_ })) { $d = Get-MDOP2AddressDomain $r if ($d -and $fromDomain -and -not (Test-MDOP2DomainAligned $d $fromDomain)) { & $add 'Medium' "Reply-To ($r) is in a different domain than From ($fromDomain)." } } if ($FromName -and $FromName -match '[\w.+-]+@[\w-]+(\.[\w-]+)+') { $shown = $Matches[0] if ($shown -ine $FromAddress) { & $add 'High' "Display name shows the address $shown, but the message is from $FromAddress." } } if ($Authentication) { $label = @{ spf = 'SPF'; dkim = 'DKIM'; dmarc = 'DMARC'; compauth = 'Composite authentication' } foreach ($m in 'spf', 'dkim', 'dmarc', 'compauth') { $r = $Authentication.$m if (-not $r) { continue } switch ($r.result) { { $_ -in 'fail', 'permerror' } { & $add 'High' "$($label[$m]) $($r.result)." } { $_ -in 'softfail', 'temperror' } { & $add 'Medium' "$($label[$m]) $($r.result)." } 'none' { if ($m -in 'spf', 'dmarc') { & $add 'Info' "No $($label[$m]) record for the sender domain." } } 'bestguesspass' { & $add 'Info' 'The sender domain has no DMARC record (best guess pass).' } } } if (-not ($Authentication.spf -or $Authentication.dkim -or $Authentication.dmarc -or $Authentication.compauth)) { & $add 'Info' 'No Authentication-Results header. The message may be internal.' } } , $flags.ToArray() } function Get-MDOP2Sha256 { <# .SYNOPSIS Lower case hex SHA-256 of a byte array, computed in memory. #> [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)][AllowEmptyCollection()][byte[]]$Bytes) [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($Bytes)).ToLowerInvariant() } function Get-MDOP2SafeFileName { <# .SYNOPSIS Builds a short, safe file name from a subject for the .eml download. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Subject, [string]$Extension = '.eml', [int]$MaxLength = 80) $name = ([string]$Subject) -replace '[\x00-\x1F\x7F<>:"/\\|?*;]', ' ' -replace '[^\x20-\x7E]', '_' -replace '\s+', ' ' $name = $name.Trim(' ', '.') if (-not $name) { $name = 'message' } if ($name.Length -gt $MaxLength) { $name = $name.Substring(0, $MaxLength).TrimEnd(' ', '.') } "$name$Extension" } #endregion function ConvertTo-MDOP2UtcTimestamp { <# .SYNOPSIS Reads a timestamp as a UTC date, or returns $null when it isn't one. Text without a time zone is UTC, as every time from the server is. #> [CmdletBinding()] param([AllowNull()][object]$Value) if ($null -eq $Value -or '' -eq $Value) { return $null } # ConvertFrom-Json turns ISO strings into DateTime itself; older lines may still be strings. The EXO module hands # message trace times back with Kind Unspecified. if ($Value -is [datetime]) { if ($Value.Kind -eq [DateTimeKind]::Unspecified) { return [datetime]::SpecifyKind($Value, [DateTimeKind]::Utc) } return $Value.ToUniversalTime() } $d = [datetime]::MinValue if ([datetime]::TryParse([string]$Value, [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]'AdjustToUniversal, AssumeUniversal', [ref]$d)) { return $d } $null } function ConvertTo-MDOP2IsoDate { <# .SYNOPSIS Formats a timestamp as UTC ISO 8601 text (yyyy-MM-ddTHH:mm:ssZ, invariant culture), or returns $null when it isn't one. #> [CmdletBinding()] param([AllowNull()][object]$Value) $d = ConvertTo-MDOP2UtcTimestamp -Value $Value if ($null -eq $d) { return $null } $d.ToString('yyyy-MM-ddTHH:mm:ssZ', [Globalization.CultureInfo]::InvariantCulture) } function Get-MDOP2FreePort { <# .SYNOPSIS Returns a free TCP port on the loopback address: the OS picks one, which is then released for the caller to use. #> $l = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0) $l.Start() try { $l.LocalEndpoint.Port } finally { $l.Stop() } } function Select-MDOP2ActionHistory { <# .SYNOPSIS Turns Action events from the audit log into history rows, newest first, filtered by date, dry run and session. #> [CmdletBinding()] param( [AllowNull()][AllowEmptyCollection()][object[]]$Events, [Nullable[datetime]]$Since, [switch]$IncludeDryRun, [string]$SessionId, [int]$Max = 5000 ) $p = { param($o, $n) if ($o.PSObject.Properties[$n]) { $o.$n } else { $null } } $rows = foreach ($e in @($Events)) { if ($null -eq $e -or (& $p $e 'type') -ne 'Action') { continue } $when = ConvertTo-MDOP2UtcTimestamp (& $p $e 'timestamp') if (-not $when) { continue } if ($Since -and $when -lt $Since.ToUniversalTime()) { continue } $dry = (& $p $e 'dryRun') -eq $true if ($dry -and -not $IncludeDryRun) { continue } $sid = [string](& $p $e 'sessionId') if ($SessionId -and $sid -ne $SessionId) { continue } [pscustomobject]@{ Timestamp = $when SessionId = $sid Admin = [string](& $p $e 'adminUpn') InternetMessageId = [string](& $p $e 'internetMessageId') Mailbox = [string](& $p $e 'mailbox') UserId = [string](& $p $e 'userId') Action = [string](& $p $e 'action') DryRun = $dry FromFolder = [string](& $p $e 'fromFolder') Result = [string](& $p $e 'result') Hold = [string](& $p $e 'hold') Error = [string](& $p $e 'error') } } $sorted = @($rows | Sort-Object Timestamp -Descending) [pscustomobject]@{ Rows = @($sorted | Select-Object -First $Max) Total = $sorted.Count Truncated = $sorted.Count -gt $Max } } function ConvertFrom-MDOP2SafeLink { <# .SYNOPSIS Returns the original URL behind a Defender Safe Links URL, or the input when it isn't one. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Url) if (-not $Url) { return '' } # Only the real Safe Links hosts, so a crafted link such as https://evil.example?safelinks.protection.outlook.com/?url=... # can't hide its own host from a Link search if ($Url -match '^https?://([a-z0-9-]+\.)*safelinks\.protection\.outlook\.com/' -and $Url -match '[?&]url=([^&]+)') { return [uri]::UnescapeDataString($Matches[1]) } $Url } function Get-MDOP2BodyLink { <# .SYNOPSIS Lists the links in a message body: href values in HTML plus bare http(s) URLs in the text. Safe Links are unwrapped. #> [CmdletBinding()] param([AllowNull()][AllowEmptyString()][string]$Body) if (-not $Body) { return , @() } $links = [Collections.Generic.List[string]]::new() foreach ($m in [regex]::Matches($Body, '(?i)\bhref\s*=\s*(?:"([^"]*)"|''([^'']*)''|([^\s>]+))')) { $v = if ($m.Groups[1].Success) { $m.Groups[1].Value } elseif ($m.Groups[2].Success) { $m.Groups[2].Value } else { $m.Groups[3].Value } $links.Add([Net.WebUtility]::HtmlDecode($v).Trim()) } foreach ($m in [regex]::Matches([Net.WebUtility]::HtmlDecode($Body), '(?i)\bhttps?://[^\s"''<>]+')) { $links.Add($m.Value) } , @($links | ForEach-Object { ConvertFrom-MDOP2SafeLink $_ } | Where-Object { $_ } | Select-Object -Unique) } function ConvertTo-MDOP2PlainText { <# .SYNOPSIS Turns an HTML body into plain text for matching: drops style and script blocks and tags, decodes entities, collapses whitespace. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Body) if (-not $Body) { return '' } $t = $Body -replace '(?is)<(style|script|head)\b.*?</\1\s*>', ' ' $t = $t -replace '(?s)<!--.*?-->', ' ' $t = $t -replace '<[^>]*>', ' ' $t = [Net.WebUtility]::HtmlDecode($t) ($t -replace '\s+', ' ').Trim() } function Test-MDOP2BodyMatch { <# .SYNOPSIS Checks a message body for text (Mode Text) or for a link containing the text (Mode Link). Case-insensitive substring match. #> [CmdletBinding()] [OutputType([bool])] param( [AllowNull()][AllowEmptyString()][string]$Body, [Parameter(Mandatory)][string]$Text, [ValidateSet('Text', 'Link')][string]$Mode = 'Text' ) $needle = ($Text -replace '\s+', ' ').Trim() if (-not $needle) { return $true } if ($Mode -eq 'Link') { foreach ($l in (Get-MDOP2BodyLink -Body $Body)) { if ($l.IndexOf($needle, [StringComparison]::OrdinalIgnoreCase) -ge 0) { return $true } } return $false } (ConvertTo-MDOP2PlainText -Body $Body).IndexOf($needle, [StringComparison]::OrdinalIgnoreCase) -ge 0 } Export-ModuleMember -Function ConvertFrom-MDOP2SafeLink, Get-MDOP2BodyLink, ConvertTo-MDOP2PlainText, Test-MDOP2BodyMatch, ConvertTo-MDOP2ODataLiteral, ConvertTo-MDOP2UtcTimestamp, ConvertTo-MDOP2IsoDate, Get-MDOP2FreePort, Select-MDOP2ActionHistory, Get-MDOP2RetryDelay, Test-MDOP2RetryableStatus, Get-MDOP2DateRange, Split-MDOP2DateRange, ConvertTo-MDOP2AddressFilter, Test-MDOP2AddressMatch, Get-MDOP2ThrottleDelay, Group-MDOP2TraceResult, Get-MDOP2ResolveSkipReason, Get-MDOP2MailboxErrorState, Get-MDOP2ActionFolder, Get-MDOP2SameMailbox, New-MDOP2ActionPlan, Get-MDOP2MoveDecision, Get-MDOP2ActionSummary, ConvertTo-MDOP2AuditLine, Get-MDOP2AddressDomain, Test-MDOP2DomainAligned, Get-MDOP2HeaderValue, ConvertFrom-MDOP2AuthenticationResult, ConvertFrom-MDOP2AntispamReport, ConvertFrom-MDOP2MailDate, ConvertFrom-MDOP2ReceivedHeader, Get-MDOP2SuspicionFlag, Get-MDOP2Sha256, Get-MDOP2SafeFileName |