Checks/Test-HygieneMailForwarding.ps1
|
<# .SYNOPSIS Finds inbox rules that forward or redirect mail outside the tenant. .DESCRIPTION A rule that quietly forwards mail to an outside address is one of the oldest and most reliable signs of a compromised mailbox: the attacker keeps reading after the password is reset, and the victim sees nothing. Every enabled rule on every reachable mailbox is examined for forwardTo, forwardAsAttachmentTo and redirectTo actions, and each recipient is checked against the tenant's own verified domains. A rule that hides its traces -- deleting or marking messages as read alongside the forward -- is treated as more severe, because that combination is characteristic of an attacker rather than a user who set up a convenience rule. Read the delegated-access note below before treating a clean result as good news. .PARAMETER Context Shared audit context from New-HygieneAuditContext. .OUTPUTS M365TenantHygiene.Finding objects. .NOTES Graph endpoint : GET /users/{id}/mailFolders/inbox/messageRules Scopes required: MailboxSettings.Read, User.Read.All (delegated) THE COVERAGE LIMIT THAT MATTERS: with delegated permissions, Graph grants the intersection of the application's consent and what the signed-in user can already do. Being a Global Administrator does not by itself grant the right to open another person's mailbox. In practice this check reaches the signed-in user's own mailbox plus any mailbox they hold Full Access on, and Graph returns 403 for the rest. Rather than counting those refusals as clean, the check tracks them: if every mailbox it tried was denied, it reports Skipped, and if only some were denied it reports the number it could not read. Full tenant coverage needs application permissions, which is a different consent model than the one this module is built around -- see the Limitations section of the README. Scope note: this reads inbox RULES. Mailbox-level forwarding configured through Exchange (ForwardingSmtpAddress) is not exposed by this Graph endpoint and is not covered. #> function Test-HygieneMailForwarding { [CmdletBinding()] [OutputType([pscustomobject])] param([Parameter(Mandatory)][hashtable] $Context) $meta = (Get-HygieneCheckRegistry)['MailForwarding'] if (-not $Context.VerifiedDomains -or $Context.VerifiedDomains.Count -eq 0) { return New-HygieneCheckResult ` -CheckId $meta.Id -CheckName $meta.Name -Category $meta.Category ` -Status 'Skipped' ` -RequiredScopes $meta.RequiredScopes ` -Reason 'The tenant''s verified domains could not be read, so there is no way to tell an internal recipient from an external one. Calling every forward external would invent findings; calling none external would hide them.' } $users = @(Get-HygieneUser -Context $Context) $candidates = @( $users | Where-Object { $_.accountEnabled -and $_.userType -ne 'Guest' -and $_.userPrincipalName } | Sort-Object userPrincipalName ) $limit = if ($Context.ContainsKey('MailboxLimit') -and $Context.MailboxLimit -gt 0) { $Context.MailboxLimit } else { $candidates.Count } $targets = @($candidates | Select-Object -First $limit) if ($targets.Count -lt $candidates.Count) { Write-HygieneLog -Level Warning -Message "Mailbox scan capped at $($targets.Count) of $($candidates.Count) mailboxes by -MaxMailbox. The remaining $($candidates.Count - $targets.Count) were not examined." } $findings = [System.Collections.Generic.List[object]]::new() $inspected = 0 $denied = 0 $notFound = 0 foreach ($user in $targets) { $rules = $null try { $rules = Invoke-HygieneGraphRequest ` -Uri "users/$($user.id)/mailFolders/inbox/messageRules" ` -MaxPages 5 } catch { $kind = $_.Exception.Data['Kind'] # Deliberately if/elseif rather than switch: in PowerShell, `continue` # inside a switch ends the switch, not the enclosing loop, so a denied # mailbox would fall through and be counted as examined -- which is how # an audit that saw nothing ends up reporting that it saw nothing wrong. if ($kind -eq 'Permission' -or $kind -eq 'Authentication') { $denied++ continue } elseif ($kind -eq 'NotFound') { # No Exchange Online mailbox: an unlicensed or on-premises account. $notFound++ continue } else { throw } } $inspected++ foreach ($rule in $rules) { $actions = $rule.actions if (-not $actions) { continue } $recipients = @( @($actions.forwardTo) + @($actions.forwardAsAttachmentTo) + @($actions.redirectTo) | Where-Object { $_ -and $_.emailAddress -and $_.emailAddress.address } | ForEach-Object { $_.emailAddress.address } ) if ($recipients.Count -eq 0) { continue } $external = @($recipients | Where-Object { Test-HygieneExternalAddress -Address $_ -VerifiedDomain $Context.VerifiedDomains }) if ($external.Count -eq 0) { continue } $covertActions = @() if ($actions.delete) { $covertActions += 'deletes the message' } if ($actions.markAsRead) { $covertActions += 'marks the message as read' } if ($actions.permanentDelete) { $covertActions += 'permanently deletes the message' } if ($actions.moveToFolder) { $covertActions += 'moves the message to another folder' } $covert = $covertActions.Count -gt 0 $enabled = ($rule.isEnabled -ne $false) $severity = if (-not $enabled) { 'Low' } elseif ($covert) { 'Critical' } else { 'High' } $detail = "The inbox rule '$($rule.displayName)' sends mail to $($external -join ', '), which is outside every verified domain of this tenant." if ($covert) { $detail += " The same rule also $($covertActions -join ' and '), so the mailbox owner does not see the messages being forwarded. That combination is characteristic of a compromised mailbox rather than a convenience rule." } if (-not $enabled) { $detail += ' The rule is currently disabled, so it is not forwarding today, but it remains in place and can be re-enabled.' } $findings.Add((New-HygieneFinding ` -CheckId $meta.Id -CheckName $meta.Name -Category $meta.Category ` -Severity $severity ` -Title $(if ($covert) { 'Inbox rule forwards externally and hides the evidence' } else { 'Inbox rule forwards mail outside the tenant' }) ` -ObjectType 'Mailbox' ` -ObjectId $user.id ` -ObjectName $user.userPrincipalName ` -Detail $detail ` -Recommendation 'Confirm with the mailbox owner that they created this rule. If they did not, treat the mailbox as compromised: revoke sessions, reset credentials, review the sign-in logs, and remove the rule. Consider blocking automatic external forwarding tenant-wide through an outbound spam policy.' ` -Reference 'https://learn.microsoft.com/defender-office-365/outbound-spam-policies-external-email-forwarding' ` -Evidence @{ ruleId = $rule.id ruleName = $rule.displayName ruleEnabled = $enabled externalRecipients = $external allRecipients = $recipients concealmentActions = $covertActions sequence = $rule.sequence })) } } if ($inspected -eq 0 -and $denied -gt 0) { return New-HygieneCheckResult ` -CheckId $meta.Id -CheckName $meta.Name -Category $meta.Category ` -Status 'Skipped' ` -ObjectsEvaluated 0 ` -RequiredScopes $meta.RequiredScopes ` -Reason ("Microsoft Graph refused access to all {0} mailbox(es) tried. With delegated permissions the module can only read mailboxes the signed-in user can already open; an administrative role does not by itself grant mailbox access. No conclusion about external forwarding can be drawn from this run." -f $denied) } $reason = $null if ($denied -gt 0 -or $notFound -gt 0 -or $targets.Count -lt $candidates.Count) { $parts = @("Examined $inspected of $($candidates.Count) mailbox(es).") if ($denied -gt 0) { $parts += "$denied were denied by Microsoft Graph and were NOT examined." } if ($notFound -gt 0) { $parts += "$notFound have no Exchange Online mailbox." } if ($targets.Count -lt $candidates.Count) { $parts += "$($candidates.Count - $targets.Count) were skipped by the -MaxMailbox cap." } $reason = $parts -join ' ' Write-HygieneLog -Level Warning -Message $reason } New-HygieneCheckResult ` -CheckId $meta.Id -CheckName $meta.Name -Category $meta.Category ` -Status 'Completed' ` -Findings @($findings) ` -ObjectsEvaluated $inspected ` -Reason $reason ` -RequiredScopes $meta.RequiredScopes } |