Exchange Online PowerShell script -WhatIf safe

Get-EXOExternalForwardingReport.ps1

Finds mail leaving the tenant through mailbox forwarding or inbox rules, and optionally removes it.

Overview#

Builds the list of accepted domains with Get-AcceptedDomain and treats every other domain as external. For each mailbox it checks (a) mailbox-level forwarding (ForwardingSmtpAddress and ForwardingAddress, the latter resolved with Get-EXORecipient so mail contacts and mail users that point outside are detected) and (b) inbox rules (Get-InboxRule) that forward, forward as attachment or redirect to external recipients - the classic business email compromise (BEC) persistence technique. One row per finding is written to CSV. With -Remediate (and confirmation) offending inbox rules are disabled and mailbox-level forwarding is cleared; the default is a read-only report.

Safety: Makes changes only when you pass an explicit switch; supports -WhatIf and -Confirm. Run Get-Help .\Get-EXOExternalForwardingReport.ps1 -Full for the complete help text.

Parameters#

ParameterWhat it does
-IdentityOne or more mailbox identities (UPN, primary SMTP address, alias or GUID). When omitted, all user and shared mailboxes are scanned.
-IncludeInternalForwardingAlso report forwarding to recipients inside the tenant (IsExternal = False). Internal rows are never remediated.
-RemediateDisable each external forwarding inbox rule (Disable-InboxRule) and clear mailbox-level forwarding (Set-Mailbox). Every change is wrapped in ShouldProcess, so -WhatIf and -Confirm work.
-OutputPathPath of the CSV report. Defaults to .\Reports\EXOExternalForwarding_yyyyMMdd-HHmm.csv.
-PassThruAlso emit the finding objects to the pipeline.

Examples#

PowerShell
PS> .\Get-EXOExternalForwardingReport.ps1

Scans all user and shared mailboxes and writes every external forward to .\Reports\EXOExternalForwarding_<timestamp>.csv.

PowerShell
PS> .\Get-EXOExternalForwardingReport.ps1 -Identity finance@contoso.com -IncludeInternalForwarding -PassThru

Lists every forward (internal and external) configured on one mailbox and returns the rows to the pipeline.

PowerShell
PS> .\Get-EXOExternalForwardingReport.ps1 -Remediate -WhatIf

Shows which inbox rules would be disabled and which mailboxes would have forwarding cleared, without changing anything.

Permissions, modules and notes#

Author : Omer Eltayeb Blog : https://www.oeltayeb.com GitHub : https://github.com/omer-eltayeb Version : 1.0.0 Requires : PowerShell 5.1 or 7.x, ExchangeOnlineManagement 3.x Permissions : View-Only Recipients for the report; Exchange Administrator (or Mail Recipients role) for -Remediate Notes : Get-InboxRule is a per-mailbox call and is the slow part - expect 1-3 seconds per mailbox, so run large tenants during off-hours. Changing inbox rules through PowerShell deletes any rules the user previously turned off in Outlook, which is why Disable-InboxRule is called with -Force. Prevention beats detection: block automatic forwarding in the Defender for Office 365 outbound spam policy (Set-HostedOutboundSpamFilterPolicy -AutoForwardingMode Off), or use a mail flow (transport) rule that rejects auto-forwarded messages sent to recipients outside the organization.

Full source#

PowerShell · Get-EXOExternalForwardingReport.ps1
<#
.SYNOPSIS
    Finds mail leaving the tenant through mailbox forwarding or inbox rules, and optionally removes it.
.DESCRIPTION
    Builds the list of accepted domains with Get-AcceptedDomain and treats every other domain as external.
    For each mailbox it checks (a) mailbox-level forwarding (ForwardingSmtpAddress and ForwardingAddress, the
    latter resolved with Get-EXORecipient so mail contacts and mail users that point outside are detected) and
    (b) inbox rules (Get-InboxRule) that forward, forward as attachment or redirect to external recipients - the
    classic business email compromise (BEC) persistence technique.
    One row per finding is written to CSV. With -Remediate (and confirmation) offending inbox rules are
    disabled and mailbox-level forwarding is cleared; the default is a read-only report.
.PARAMETER Identity
    One or more mailbox identities (UPN, primary SMTP address, alias or GUID). When omitted, all user and
    shared mailboxes are scanned.
.PARAMETER IncludeInternalForwarding
    Also report forwarding to recipients inside the tenant (IsExternal = False). Internal rows are never remediated.
.PARAMETER Remediate
    Disable each external forwarding inbox rule (Disable-InboxRule) and clear mailbox-level forwarding
    (Set-Mailbox). Every change is wrapped in ShouldProcess, so -WhatIf and -Confirm work.
.PARAMETER OutputPath
    Path of the CSV report. Defaults to .\Reports\EXOExternalForwarding_yyyyMMdd-HHmm.csv.
.PARAMETER PassThru
    Also emit the finding objects to the pipeline.
.EXAMPLE
    PS> .\Get-EXOExternalForwardingReport.ps1
    Scans all user and shared mailboxes and writes every external forward to .\Reports\EXOExternalForwarding_<timestamp>.csv.
.EXAMPLE
    PS> .\Get-EXOExternalForwardingReport.ps1 -Identity finance@contoso.com -IncludeInternalForwarding -PassThru
    Lists every forward (internal and external) configured on one mailbox and returns the rows to the pipeline.
.EXAMPLE
    PS> .\Get-EXOExternalForwardingReport.ps1 -Remediate -WhatIf
    Shows which inbox rules would be disabled and which mailboxes would have forwarding cleared, without changing anything.
.NOTES
    Author      : Omer Eltayeb
    Blog        : https://www.oeltayeb.com
    GitHub      : https://github.com/omer-eltayeb
    Version     : 1.0.0
    Requires    : PowerShell 5.1 or 7.x, ExchangeOnlineManagement 3.x
    Permissions : View-Only Recipients for the report; Exchange Administrator (or Mail Recipients role) for -Remediate
    Notes       : Get-InboxRule is a per-mailbox call and is the slow part - expect 1-3 seconds per mailbox, so run
                  large tenants during off-hours. Changing inbox rules through PowerShell deletes any rules the user
                  previously turned off in Outlook, which is why Disable-InboxRule is called with -Force.
                  Prevention beats detection: block automatic forwarding in the Defender for Office 365 outbound spam
                  policy (Set-HostedOutboundSpamFilterPolicy -AutoForwardingMode Off), or use a mail flow (transport)
                  rule that rejects auto-forwarded messages sent to recipients outside the organization.
.LINK
    https://learn.microsoft.com/defender-office-365/outbound-spam-policies-external-email-forwarding
.LINK
    https://learn.microsoft.com/powershell/module/exchange/get-inboxrule
#>
#Requires -Version 5.1
#Requires -Modules ExchangeOnlineManagement

[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
param(
    [Parameter()]
    [string[]]$Identity,

    [Parameter()]
    [switch]$IncludeInternalForwarding,

    [Parameter()]
    [switch]$Remediate,

    [Parameter()]
    [string]$OutputPath,

    [Parameter()]
    [switch]$PassThru
)

$ErrorActionPreference = 'Stop'

#region Helpers
function Connect-ExchangeIfNeeded {
    <# Connects to Exchange Online (or Security & Compliance PowerShell) only when no live session exists. #>
    [CmdletBinding()]
    param(
        [Parameter()]
        [switch]$Compliance
    )
    $connections = @(Get-ConnectionInformation -ErrorAction SilentlyContinue)
    if ($Compliance) {
        $active = @($connections | Where-Object { $_.ConnectionUri -like '*compliance*' -and $_.State -eq 'Connected' })
        if ($active.Count -eq 0) {
            Write-Verbose 'Connecting to Security & Compliance PowerShell.'
            Connect-IPPSSession -ErrorAction Stop
        }
    }
    else {
        $active = @($connections | Where-Object { $_.ConnectionUri -notlike '*compliance*' -and $_.State -eq 'Connected' })
        if ($active.Count -eq 0) {
            Write-Verbose 'Connecting to Exchange Online.'
            Connect-ExchangeOnline -ShowBanner:$false -ErrorAction Stop
        }
    }
}

function Resolve-ForwardingTarget {
    <# Turns a forwarding entry - "Name" [SMTP:addr], "Name" [EX:legacyDN], smtp:addr or a recipient identity - into an SMTP address.
       Directory recipients are resolved through a cached Get-EXORecipient lookup so mail contacts and mail users expose their external address. #>
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Entry
    )
    $text = $Entry.Trim() -replace '^smtp:', ''
    if ($text -match '\[SMTP:([^\]]+)\]') { return $Matches[1] }
    if ($text -match '^[^\s"@\[\]]+@[^\s"@\[\]]+$') { return $text }

    $lookupKey = $text
    if ($text -match '\[EX:([^\]]+)\]') { $lookupKey = $Matches[1] }
    if (-not $script:RecipientCache.ContainsKey($lookupKey)) {
        $resolved = $text
        try {
            $recipientParams = @{ Properties = 'PrimarySmtpAddress', 'ExternalEmailAddress'; ErrorAction = 'Stop' }
            if ($lookupKey -like '/o=*') {
                $recipientParams['Filter'] = "LegacyExchangeDN -eq '$lookupKey'"
            }
            else {
                $recipientParams['Identity'] = $lookupKey
            }
            $recipient = Get-EXORecipient @recipientParams | Select-Object -First 1
            if ($null -ne $recipient) {
                if (-not [string]::IsNullOrWhiteSpace($recipient.ExternalEmailAddress)) {
                    $resolved = ([string]$recipient.ExternalEmailAddress -replace '^smtp:', '')
                }
                elseif (-not [string]::IsNullOrWhiteSpace($recipient.PrimarySmtpAddress)) {
                    $resolved = [string]$recipient.PrimarySmtpAddress
                }
            }
        }
        catch {
            Write-Verbose "Could not resolve forwarding recipient '$lookupKey': $($_.Exception.Message)"
        }
        $script:RecipientCache[$lookupKey] = $resolved
    }
    return $script:RecipientCache[$lookupKey]
}

function Test-ExternalAddress {
    <# True when the value is an SMTP address whose domain is not an accepted domain of the tenant. #>
    [CmdletBinding()]
    param(
        [Parameter()]
        [AllowNull()]
        [AllowEmptyString()]
        [string]$Address
    )
    if ([string]::IsNullOrWhiteSpace($Address)) { return $false }
    if ($Address -match '^[^\s"@\[\]]+@([^\s"@\[\]]+)$') {
        return ($script:AcceptedDomains -notcontains $Matches[1].ToLowerInvariant())
    }
    # Unresolved directory entries ("Name" [EX:...]) stay inside the tenant and are not flagged.
    return $false
}
#endregion Helpers

#region Main
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
    $reportFolder = Join-Path -Path (Get-Location).Path -ChildPath 'Reports'
    $OutputPath = Join-Path -Path $reportFolder -ChildPath ('EXOExternalForwarding_{0}.csv' -f (Get-Date -Format 'yyyyMMdd-HHmm'))
}
$outputFolder = Split-Path -Path $OutputPath -Parent
if (-not [string]::IsNullOrWhiteSpace($outputFolder) -and -not (Test-Path -Path $outputFolder)) {
    New-Item -Path $outputFolder -ItemType Directory -Force | Out-Null
}

try {
    Connect-ExchangeIfNeeded
}
catch {
    throw "Unable to connect to Exchange Online: $($_.Exception.Message)"
}

$script:RecipientCache = @{}
try {
    $script:AcceptedDomains = @(Get-AcceptedDomain -ErrorAction Stop | ForEach-Object { ([string]$_.DomainName).ToLowerInvariant() })
}
catch {
    throw "Failed to read accepted domains: $($_.Exception.Message)"
}
if ($script:AcceptedDomains.Count -eq 0) { throw 'Get-AcceptedDomain returned no domains; external recipients cannot be classified.' }
Write-Verbose "Accepted domains: $($script:AcceptedDomains -join ', ')"

$mailboxProperties = @('DisplayName', 'UserPrincipalName', 'PrimarySmtpAddress', 'RecipientTypeDetails', 'ForwardingAddress', 'ForwardingSmtpAddress', 'DeliverToMailboxAndForward')
$mailboxes = New-Object -TypeName System.Collections.Generic.List[object]
if ($PSBoundParameters.ContainsKey('Identity')) {
    foreach ($id in $Identity) {
        try {
            $mailboxes.Add((Get-EXOMailbox -Identity $id -Properties $mailboxProperties -ErrorAction Stop))
        }
        catch {
            Write-Warning "Mailbox '$id' was not found or is not accessible: $($_.Exception.Message)"
        }
    }
}
else {
    try {
        foreach ($mailbox in (Get-EXOMailbox -ResultSize Unlimited -RecipientTypeDetails UserMailbox, SharedMailbox -Properties $mailboxProperties -ErrorAction Stop)) {
            $mailboxes.Add($mailbox)
        }
    }
    catch {
        throw "Failed to retrieve mailboxes: $($_.Exception.Message)"
    }
}
Write-Verbose "Scanning $($mailboxes.Count) mailbox(es)."

# Inbox-rule properties that send mail elsewhere, mapped to the FindingType they produce.
$ruleTargetTypes = @{ ForwardTo = 'InboxRuleForward'; ForwardAsAttachmentTo = 'InboxRuleForwardAsAttachment'; RedirectTo = 'InboxRuleRedirect' }
$findings = New-Object -TypeName System.Collections.Generic.List[object]
$index = 0
foreach ($mailbox in $mailboxes) {
    $index++
    $status = '{0} of {1} - {2} ({3} findings so far)' -f $index, $mailboxes.Count, $mailbox.UserPrincipalName, $findings.Count
    Write-Progress -Activity 'Checking forwarding and inbox rules' -Status $status -PercentComplete (($index / $mailboxes.Count) * 100)

    $candidates = New-Object -TypeName System.Collections.Generic.List[object]
    if (-not [string]::IsNullOrWhiteSpace($mailbox.ForwardingSmtpAddress)) {
        $candidates.Add(@{ Type = 'MailboxForwardingSmtp'; Rule = $null; Entries = @([string]$mailbox.ForwardingSmtpAddress) })
    }
    if (-not [string]::IsNullOrWhiteSpace($mailbox.ForwardingAddress)) {
        $candidates.Add(@{ Type = 'MailboxForwardingRecipient'; Rule = $null; Entries = @([string]$mailbox.ForwardingAddress) })
    }

    try {
        $rules = @(Get-InboxRule -Mailbox $mailbox.UserPrincipalName -ErrorAction Stop)
    }
    catch {
        Write-Warning "Could not read inbox rules for '$($mailbox.UserPrincipalName)': $($_.Exception.Message)"
        $rules = @()
    }
    foreach ($rule in $rules) {
        foreach ($propertyName in $ruleTargetTypes.Keys) {
            $entries = @($rule.$propertyName | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } | ForEach-Object { [string]$_ })
            if ($entries.Count -gt 0) {
                $candidates.Add(@{ Type = $ruleTargetTypes[$propertyName]; Rule = $rule; Entries = $entries })
            }
        }
    }

    foreach ($candidate in $candidates) {
        $addresses = @(foreach ($entry in $candidate.Entries) { Resolve-ForwardingTarget -Entry $entry })
        $external = @($addresses | Where-Object { Test-ExternalAddress -Address $_ })
        if ($external.Count -eq 0 -and -not $IncludeInternalForwarding) { continue }

        $rule = $candidate.Rule
        $description = $null
        if ($null -ne $rule -and -not [string]::IsNullOrWhiteSpace($rule.Description)) {
            $description = ([string]$rule.Description -replace '\s+', ' ').Trim()
            if ($description.Length -gt 300) { $description = $description.Substring(0, 300) }
        }
        $findings.Add([PSCustomObject]@{
                Mailbox                    = $mailbox.DisplayName
                UserPrincipalName          = $mailbox.UserPrincipalName
                FindingType                = $candidate.Type
                RuleName                   = $(if ($null -ne $rule) { [string]$rule.Name } else { $null })
                RuleIdentity               = $(if ($null -ne $rule) { [string]$rule.RuleIdentity } else { $null })
                RuleEnabled                = $(if ($null -ne $rule) { [bool]$rule.Enabled } else { $null })
                IsExternal                 = ($external.Count -gt 0)
                ExternalRecipients         = ($external -join ';')
                AllRecipients              = ($addresses -join ';')
                DeliverToMailboxAndForward = [bool]$mailbox.DeliverToMailboxAndForward
                RuleDescription            = $description
                Remediated                 = $false
            })
    }
}
Write-Progress -Activity 'Checking forwarding and inbox rules' -Completed

if ($Remediate) {
    $clearedMailboxes = @{}
    foreach ($finding in @($findings | Where-Object { $_.IsExternal })) {
        try {
            if ($finding.FindingType -like 'InboxRule*') {
                if ($finding.RuleEnabled -and $PSCmdlet.ShouldProcess($finding.UserPrincipalName, "Disable inbox rule '$($finding.RuleName)' forwarding to $($finding.ExternalRecipients)")) {
                    Disable-InboxRule -Identity $finding.RuleIdentity -Mailbox $finding.UserPrincipalName -Force -Confirm:$false -ErrorAction Stop
                    $finding.Remediated = $true
                }
            }
            elseif (-not $clearedMailboxes.ContainsKey($finding.UserPrincipalName)) {
                if ($PSCmdlet.ShouldProcess($finding.UserPrincipalName, "Clear mailbox forwarding to $($finding.ExternalRecipients)")) {
                    Set-Mailbox -Identity $finding.UserPrincipalName -ForwardingSmtpAddress $null -ForwardingAddress $null -DeliverToMailboxAndForward $false -ErrorAction Stop
                    $clearedMailboxes[$finding.UserPrincipalName] = $true
                    $finding.Remediated = $true
                }
            }
            else {
                # Both ForwardingSmtpAddress and ForwardingAddress were set; a single Set-Mailbox already cleared them.
                $finding.Remediated = $true
            }
        }
        catch {
            Write-Warning "Remediation failed for '$($finding.UserPrincipalName)' ($($finding.FindingType)): $($_.Exception.Message)"
        }
    }
}

if ($findings.Count -gt 0) {
    $findings | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
}
else {
    Write-Verbose 'No forwarding was found; no CSV written.'
}

$externalFindings = @($findings | Where-Object { $_.IsExternal })
$affectedMailboxes = @($externalFindings | Select-Object -ExpandProperty UserPrincipalName -Unique)
$externalRules = @($externalFindings | Where-Object { $_.FindingType -like 'InboxRule*' })
$externalMailboxLevel = @($externalFindings | Where-Object { $_.FindingType -like 'MailboxForwarding*' })

Write-Host ''
Write-Host 'External forwarding summary' -ForegroundColor Cyan
Write-Host ('  Mailboxes scanned                  : {0}' -f $mailboxes.Count)
Write-Host ('  Mailboxes with external forwarding : {0}' -f $affectedMailboxes.Count) -ForegroundColor $(if ($affectedMailboxes.Count -gt 0) { 'Yellow' } else { 'Green' })
Write-Host ('  External inbox rules               : {0}' -f $externalRules.Count)
Write-Host ('  Mailbox-level external forwards    : {0}' -f $externalMailboxLevel.Count)
if ($IncludeInternalForwarding) {
    Write-Host ('  Internal forwards (informational)  : {0}' -f @($findings | Where-Object { -not $_.IsExternal }).Count)
}
if ($Remediate) {
    Write-Host ('  Remediated                         : {0}' -f @($findings | Where-Object { $_.Remediated }).Count) -ForegroundColor Green
}
if ($findings.Count -gt 0) {
    Write-Host ('  Report                             : {0}' -f $OutputPath)
}

if ($PassThru) {
    $findings
}
#endregion Main

Scripts are provided as-is under the MIT licence. Review the permissions a script requests, test in a non-production tenant, and use -WhatIf before letting any script change anything.

Found a bug or have an improvement? Open an issue on GitHub or email me.