public/Get-MsecExchangeInboxRule.ps1
|
function Get-MsecExchangeInboxRule { <# .SYNOPSIS User-created inbox rules, flagging the ones that send mail out of the tenant or hide it from the person who owns the mailbox. .DESCRIPTION Mailbox forwarding is an admin setting and shows up in Get-MsecExchangeMailbox. An INBOX RULE is set by the user - or by whoever is holding the user's session - and is where business email compromise actually lives. The pattern is well worn: a rule that forwards anything matching 'invoice' or 'payment' to an outside address, then moves it to RSS Feeds and marks it read so the owner never sees the thread. THIS IS SLOW AND SCOPED ON PURPOSE. There is no bulk endpoint: Get-InboxRule takes one mailbox at a time, measured at roughly 1.7 seconds each, so the whole tenant is several minutes. -Mailbox exists so an investigation can read ten mailboxes in twenty seconds, and the default is UserMailbox rather than every recipient type for the same reason. A MAILBOX THAT COULD NOT BE READ GETS A ROW SAYING SO. Skipping it would make a mailbox whose rules are unreadable look exactly like a mailbox with no rules, and in an investigation those are opposite answers. A mailbox genuinely holding no rules emits nothing, which is a real measurement. EXTERNAL IS DECIDED AGAINST ACCEPTED DOMAINS, and if those cannot be read ForwardsExternally is $null rather than $false - claiming a forward stays inside the tenant when nothing checked is the one wrong answer that matters. WHAT COUNTS AS RISKY IS WRITTEN DOWN, not inferred: a rule that forwards or redirects outside the tenant, a rule that deletes, or a rule that both files mail away and marks it read. StopProcessingRules and a plain MoveToFolder are ordinary mail management and are reported but not flagged. INVALID RULES ARE REPORTED. Exchange returns rules it cannot parse with IsValid false and an ErrorType; they still exist and may still run, and a corrupt rule is as often a sign of tampering as of a broken client. .PARAMETER Mailbox Specific mailboxes, by primary SMTP address or UPN. Omit to sweep every mailbox of the types in -RecipientTypeDetails. .PARAMETER RecipientTypeDetails Which mailbox types to sweep when -Mailbox is not given. Defaults to UserMailbox. SharedMailbox is worth a separate pass: shared mailboxes carry rules too and nobody reads their inbox. .PARAMETER RiskyOnly Only rules that forward or redirect outside the tenant, delete, or hide mail. .EXAMPLE Get-MsecExchangeInboxRule -RiskyOnly Sweeps user mailboxes and returns only the rules worth reading. Several minutes. .EXAMPLE Get-MsecExchangeInboxRule -Mailbox alice@contoso.com, bob@contoso.com Every rule on two mailboxes, for an investigation. .EXAMPLE Get-MsecExchangeInboxRule -RecipientTypeDetails SharedMailbox -RiskyOnly The mailboxes nobody is watching. #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(ValueFromPipelineByPropertyName)] [Alias('PrimarySmtpAddress', 'UserPrincipalName')] [string[]] $Mailbox, [ValidateSet('UserMailbox', 'SharedMailbox', 'RoomMailbox', 'EquipmentMailbox', 'All')] [string[]] $RecipientTypeDetails = 'UserMailbox', [switch] $RiskyOnly ) begin { Initialize-MsecExoSession -Endpoint Exchange # Accepted domains decide what external means. Same contract as # Get-MsecExchangeMailbox: unreadable leaves the flag null, never false. $script:MsecInboxRuleDomains = $null try { $script:MsecInboxRuleDomains = @((Get-AcceptedDomain -ErrorAction Stop).DomainName | ForEach-Object { "$_".ToLower() }) } catch { Write-Warning "Could not read accepted domains, so ForwardsExternally is null rather than false on every rule: $($_.Exception.Message)" } # Folders a rule files mail into to keep it out of sight. Ordinary rules file into # project folders; these are the ones that show up in compromise after compromise. $concealing = @('RSS Feeds', 'RSS Subscriptions', 'Conversation History', 'Archive', 'Junk Email', 'Junk E-mail', 'Deleted Items', 'Notes') $collected = [System.Collections.Generic.List[string]]::new() } process { foreach ($m in @($Mailbox)) { if ($m) { $collected.Add($m) } } } end { $targets = $null if ($collected.Count) { $targets = @($collected | Select-Object -Unique | ForEach-Object { [pscustomobject]@{ Address = $_; Type = $null } }) } else { $params = @{ ResultSize = 'Unlimited' } if ($RecipientTypeDetails -notcontains 'All') { $params['RecipientTypeDetails'] = $RecipientTypeDetails } try { $targets = @(Get-EXOMailbox @params -ErrorAction Stop | ForEach-Object { [pscustomobject]@{ Address = [string] $_.PrimarySmtpAddress; Type = [string] $_.RecipientTypeDetails } }) } catch { throw "Could not list mailboxes to sweep: $($_.Exception.Message)" } Write-Verbose "Sweeping $($targets.Count) mailbox(es); Get-InboxRule is one call each, so expect roughly $([int]($targets.Count * 1.7)) seconds." } # Pulls SMTP addresses out of 'Display Name [SMTP:someone@example.com]'. An EX: entry is # a directory object and therefore internal by construction. $addressesOf = { param($Value) @(foreach ($entry in @($Value)) { $text = "$entry" foreach ($match in [regex]::Matches($text, '(?i)SMTP:([^\]\s;,]+)')) { $match.Groups[1].Value } }) } $i = 0 foreach ($target in $targets) { $i++ Write-Progress -Activity 'Reading inbox rules' -Status $target.Address ` -PercentComplete ([int](100 * $i / [math]::Max($targets.Count, 1))) $rules = $null try { $rules = @(Get-InboxRule -Mailbox $target.Address -ErrorAction Stop -WarningAction SilentlyContinue) } catch { Write-Warning "Could not read inbox rules for '$($target.Address)', so this mailbox is reported as unreadable rather than as having none: $($_.Exception.Message)" [PSCustomObject]@{ PSTypeName = 'MsecExchangeInboxRule' Mailbox = $target.Address; MailboxType = $target.Type RuleName = 'Unreadable'; Enabled = $null; Priority = $null ForwardTo = $null; RedirectTo = $null; ForwardAsAttachmentTo = $null ExternalTargets = $null; ForwardsExternally = $null DeleteMessage = $null; SoftDeleteMessage = $null; MoveToFolder = $null MarkAsRead = $null; StopProcessingRules = $null IsRisky = $null; RiskReasons = $null IsValid = $null; ErrorType = $null; Description = $null } continue } foreach ($rule in $rules) { $fwd = & $addressesOf $rule.ForwardTo $redirect = & $addressesOf $rule.RedirectTo $fwdAttach = & $addressesOf $rule.ForwardAsAttachmentTo $all = @($fwd + $redirect + $fwdAttach) $external = $null $externalTargets = @() if ($null -ne $script:MsecInboxRuleDomains) { $externalTargets = @($all | Where-Object { $domain = ($_ -split '@')[-1] $domain -and ($domain.ToLower() -notin $script:MsecInboxRuleDomains) }) $external = [bool] $externalTargets.Count } $moveTo = [string] $rule.MoveToFolder $deletes = [bool] ($rule.DeleteMessage -or $rule.SoftDeleteMessage) $marksRead = [bool] $rule.MarkAsRead $reasons = @() if ($external) { $reasons += 'forwards or redirects outside the tenant' } elseif ($null -eq $external -and $all.Count) { $reasons += 'forwards, but external could not be determined' } if ($deletes) { $reasons += 'deletes the message' } # ONLY a CONCEALING folder counts, not any folder. The first version of this # flagged "files somewhere and marks it read" outright, and measured across 265 # mailboxes that fired on 292 of 1373 rules - almost all of them people filing # study correspondence into per-study folders and marking it read, which is # exactly what mail rules are for. Flagging 21% of all rules buries the three # that forward outside the tenant. MarkAsRead is still a column either way. if ($moveTo -and ($moveTo -in $concealing)) { $reasons += if ($marksRead) { "files into '$moveTo' and marks it read" } else { "files into '$moveTo'" } } if ($rule.IsValid -eq $false) { $reasons += "rule is invalid ($([string] $rule.ErrorType))" } $isRisky = if ($null -eq $external -and $all.Count) { $null } else { [bool] $reasons.Count } if ($RiskyOnly -and $isRisky -ne $true) { continue } [PSCustomObject]@{ PSTypeName = 'MsecExchangeInboxRule' Mailbox = $target.Address MailboxType = $target.Type RuleName = [string] $rule.Name Enabled = $rule.Enabled Priority = $rule.Priority ForwardTo = ($fwd -join '; ') RedirectTo = ($redirect -join '; ') ForwardAsAttachmentTo = ($fwdAttach -join '; ') ExternalTargets = if ($null -eq $external) { $null } else { ($externalTargets -join '; ') } ForwardsExternally = $external DeleteMessage = [bool] $rule.DeleteMessage SoftDeleteMessage = [bool] $rule.SoftDeleteMessage MoveToFolder = $moveTo MarkAsRead = $marksRead StopProcessingRules = [bool] $rule.StopProcessingRules IsRisky = $isRisky RiskReasons = ($reasons -join '; ') IsValid = $rule.IsValid ErrorType = [string] $rule.ErrorType Description = (("$($rule.Description)") -replace '\s+', ' ').Trim() } } } Write-Progress -Activity 'Reading inbox rules' -Completed } } |