Buzones con reenvío de correo
Encuentra los buzones que reenvían correo y marca los que lo mandan fuera de tus dominios aceptados. Un reenvío externo es señal de cuenta comprometida o de fuga de datos.
Solo lecturaSin permisos de GraphProbado con respuestas simuladas. Sin ejecutar en un tenant real.
Ejemplo en México
Distribuidora de materiales en Tijuana, 90 personas
Un cliente recibió una factura con otra cuenta bancaria. Sospechan que alguien lee el correo de cobranza desde fuera de la empresa.
.\get-reenvios-de-correo.ps1 -RutaCsv .\reenvios.csvObtiene un reenvío de cobranza hacia una cuenta externa sin copia en el buzón, lo quita y abre la investigación de esa cuenta.
Cómo implementarlo
- Instala el módulo si no lo tienes: Install-Module ExchangeOnlineManagement -Scope CurrentUser.
- Ejecuta el script con -RutaCsv e inicia sesión con una cuenta que tenga el rol indicado.
- Filtra la columna Alcance por Externo y revisa también la columna Alertas.
- Pregunta a cada usuario si conoce ese reenvío. Si no lo reconoce, trátalo como incidente: cambia la contraseña, cierra las sesiones y quita el reenvío.
Ver el código completo de get-reenvios-de-correo.ps1
<#
.SYNOPSIS
Responde qué buzones reenvían correo y cuáles lo mandan fuera de tu organización.
.DESCRIPTION
Revisa ForwardingAddress, ForwardingSmtpAddress y DeliverToMailboxAndForward de cada buzón y marca como externos
los destinos fuera de tus dominios aceptados, y como "Revisar destino" los que no son un buzón. Un reenvío externo es señal de cuenta comprometida o de fuga de datos.
Si un destino no se puede resolver, queda en el reporte y el script termina con error al final, después de guardar el CSV.
Solo lectura: no cambia nada en el tenant.
.PARAMETER Tipo
Tipo de buzón: UserMailbox, SharedMailbox, RoomMailbox, EquipmentMailbox o Todos (por defecto).
.PARAMETER RutaCsv
Ruta del CSV de salida. Si se omite, solo se muestra en pantalla.
.EXAMPLE
.\get-reenvios-de-correo.ps1 -RutaCsv .\reenvios.csv
.NOTES
Permisos: ninguno de Graph; usa ExchangeOnlineManagement.
Rol mínimo: Lector global (View-Only Organization Management en Exchange Online).
Licencia: Exchange Online Plan 1 o superior, incluido en Microsoft 365 Business Premium.
Licencia del código: MIT. Se entrega TAL CUAL, sin garantía. Pruébalo primero en un tenant de pruebas.
#>
#Requires -Version 7.0
[CmdletBinding()]
param(
[ValidateSet('UserMailbox', 'SharedMailbox', 'RoomMailbox', 'EquipmentMailbox', 'Todos')]
[string]$Tipo = 'Todos',
[string]$RutaCsv
)
$ErrorActionPreference = 'Stop'
if (-not (Get-Command Connect-ExchangeOnline -ErrorAction SilentlyContinue)) {
throw 'Falta el módulo. Instálalo con: Install-Module ExchangeOnlineManagement -Scope CurrentUser'
}
function Limpiar-Fila($fila) {
foreach ($p in $fila.PSObject.Properties) {
if ($p.Value -is [string] -and $p.Value -match '^[=+\-@\t\r]') { $p.Value = "'$($p.Value)" }
}
$fila
}
function Test-Externo($correo, $dominios) {
$dominio = ("$correo" -split '@')[-1]
-not ($dominios | Where-Object { $dominio -like $_ })
}
# El CSV se vacía desde el inicio: si algo truena o no hay resultados, no queda uno viejo que parezca actual.
if ($RutaCsv) { Set-Content -LiteralPath $RutaCsv -Value $null -Encoding utf8BOM }
$tipos = if ($Tipo -eq 'Todos') { 'UserMailbox', 'SharedMailbox', 'RoomMailbox', 'EquipmentMailbox' } else { $Tipo }
$filas = [System.Collections.Generic.List[object]]::new()
$fallas = [System.Collections.Generic.List[string]]::new()
Connect-ExchangeOnline -ShowBanner:$false
try {
$dominios = @(Get-AcceptedDomain -ErrorAction Stop | ForEach-Object { "$($_.DomainName)" })
# Sin dominios aceptados todo destino parecería externo: mejor detenerse que dar una falsa alarma.
if (-not $dominios) { throw 'No se pudieron leer los dominios aceptados del tenant.' }
$buzones = Get-EXOMailbox -RecipientTypeDetails $tipos -ResultSize Unlimited -Properties ForwardingAddress, ForwardingSmtpAddress, DeliverToMailboxAndForward -ErrorAction Stop |
Where-Object { $_.ForwardingAddress -or $_.ForwardingSmtpAddress }
foreach ($b in @($buzones)) {
$smtp = "$($b.ForwardingSmtpAddress)" -replace '^smtp:'
$tipoDestino = ''
$destino = $smtp
$alcance = $null
$alertas = @()
try {
if ($b.ForwardingAddress) {
# ForwardingAddress manda sobre ForwardingSmtpAddress, y puede apuntar a un contacto con correo externo.
$destinatario = @(Get-EXORecipient -Identity "$($b.ForwardingAddress)" -Properties PrimarySmtpAddress, RecipientTypeDetails -ErrorAction Stop)
if ($destinatario.Count -ne 1) { throw "El destino $($b.ForwardingAddress) no corresponde a un solo destinatario" }
$destino = "$($destinatario[0].PrimarySmtpAddress)"
if (-not $destino) { throw "No se pudo resolver el destinatario $($b.ForwardingAddress)" }
$tipoDestino = "$($destinatario[0].RecipientTypeDetails)"
if ($smtp -and (Test-Externo $smtp $dominios)) { $alertas += "ForwardingSmtpAddress externo sin efecto hoy ($smtp): investiga quién lo puso" }
}
$alcance = if (Test-Externo $destino $dominios) { 'Externo' }
# Un usuario de correo o una lista con dirección interna pueden entregar fuera de la organización.
elseif ($b.ForwardingAddress -and $tipoDestino -notmatch 'Mailbox$') { 'Revisar destino' }
else { 'Interno' }
if ($alcance -eq 'Revisar destino') { $alertas += "El destino no es un buzón ($tipoDestino) y puede entregar fuera de la organización" }
} catch {
$fallas.Add("$($b.PrimarySmtpAddress)")
$alcance = "No se pudo consultar: $_"
}
if ($alcance -eq 'Externo') { $alertas += 'Reenvío externo: revisa si la cuenta está comprometida o si hay fuga de datos' }
if ($b.DeliverToMailboxAndForward -ne $true) { $alertas += 'No deja copia en el buzón' }
$filas.Add([pscustomobject]@{
Buzon = $b.DisplayName
Correo = $b.PrimarySmtpAddress
Tipo = $b.RecipientTypeDetails
DestinoEfectivo = $destino
Alcance = $alcance
GuardaCopia = if ($b.DeliverToMailboxAndForward -eq $true) { 'Sí' } else { 'No' }
ForwardingAddress = "$($b.ForwardingAddress)"
ForwardingSmtpAddress = "$($b.ForwardingSmtpAddress)"
Alertas = $alertas -join '; '
})
}
} finally {
Disconnect-ExchangeOnline -Confirm:$false
}
$salida = @($filas | Sort-Object Alcance, Correo)
Write-Host "Buzones con reenvío: $($salida.Count). Externos: $(@($salida | Where-Object Alcance -EQ 'Externo').Count)."
if ($RutaCsv) {
if ($salida.Count) {
# UTF-8 con BOM para que Excel en español muestre bien los acentos.
$salida | ForEach-Object { Limpiar-Fila $_ } | Export-Csv -LiteralPath $RutaCsv -NoTypeInformation -Encoding utf8BOM
}
Write-Host "CSV guardado en $RutaCsv"
} else {
$salida | Format-Table -AutoSize
}
if ($fallas.Count) { throw "No se pudieron resolver $($fallas.Count) reenvíos: $($fallas -join ', '). Revisa las filas marcadas en el reporte." }