<# .SYNOPSIS Encuentra reglas de bandeja de entrada que reenvían, redirigen, borran o esconden correo, típicas de una cuenta comprometida. .DESCRIPTION Revisa las reglas de cada buzón, incluidas las ocultas, y deja solo las que reenvían o redirigen, las que borran y las que mueven correo a carpetas donde nadie mira (RSS, Notas, Correo no deseado, Historial de conversaciones, Elementos eliminados), también en subcarpetas, y las que mueven y marcan como leído. Marca como externo el destino cuyo dominio no es un dominio aceptado de tu organización; un destino que no se pueda clasificar queda en riesgo alto. Sin -Usuario recorre todos los buzones de uno en uno: en un tenant grande puede tardar horas. Solo lectura: no cambia nada en el tenant. .PARAMETER Usuario Uno o más buzones a revisar. Si se omite, revisa todos los del tipo indicado. .PARAMETER TipoBuzon Tipos de buzón a recorrer cuando no usas -Usuario. Por defecto, de usuario y compartidos. .PARAMETER RutaCsv Ruta del CSV de salida. Si se omite, solo se muestra en pantalla. .EXAMPLE .\get-reglas-bandeja-sospechosas.ps1 -Usuario ana@empresa.example -RutaCsv .\reglas.csv .EXAMPLE .\get-reglas-bandeja-sospechosas.ps1 -TipoBuzon UserMailbox -RutaCsv .\reglas-todos.csv .NOTES Permisos: ninguno de Graph; usa ExchangeOnlineManagement. Rol mínimo: Administrador de Exchange (Organization Management). Get-InboxRule no funciona con Lector global ni con View-Only Organization Management. Licencia: Exchange Online, 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( # Sin esta validación, un valor vacío haría que se recorrieran todos los buzones. [ValidateNotNullOrEmpty()][string[]]$Usuario, [ValidateSet('UserMailbox', 'SharedMailbox', 'RoomMailbox', 'EquipmentMailbox')][string[]]$TipoBuzon = @('UserMailbox', 'SharedMailbox'), [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' } # 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 } # Carpetas donde un atacante esconde las respuestas a sus correos, en inglés y en español. $carpetasRaras = '^(rss( feeds| subscriptions)?|suscripciones rss|fuentes rss|notes|notas|junk( e-?mail)?|correo no deseado|conversation history|historial de conversaciones|deleted items|elementos eliminados)$' Connect-ExchangeOnline -ShowBanner:$false try { $dominios = @(Get-AcceptedDomain -ResultSize Unlimited -ErrorAction Stop | ForEach-Object { "$($_.DomainName)".ToLowerInvariant() }) # Sin dominios aceptados no se puede decir qué reenvío es externo. if (-not $dominios) { throw 'No se pudieron leer los dominios aceptados del tenant.' } $objetivos = if ($Usuario) { $Usuario } else { @(Get-EXOMailbox -ResultSize Unlimited -RecipientTypeDetails $TipoBuzon | ForEach-Object { if ($_.UserPrincipalName) { "$($_.UserPrincipalName)" } elseif ($_.PrimarySmtpAddress) { "$($_.PrimarySmtpAddress)" } }) } $objetivos = @($objetivos) if (-not $Usuario) { Write-Warning "Se revisan $($objetivos.Count) buzones de uno en uno: puede tardar mucho en un tenant grande. Para uno solo usa -Usuario." } $n = 0 $filas = @(foreach ($buzon in $objetivos) { $n++ Write-Progress -Activity 'Revisando reglas de bandeja' -Status $buzon -PercentComplete ($n / $objetivos.Count * 100) try { $reglas = @(Get-InboxRule -Mailbox $buzon -IncludeHidden -ResultSize Unlimited -ErrorAction Stop) } catch { [pscustomobject]@{ Buzon = $buzon; Regla = 'No se pudo consultar'; Habilitada = ''; Riesgo = ''; Motivos = "$_"; ReenviaA = ''; ReenvioExterno = ''; CarpetaDestino = '' } continue } foreach ($r in $reglas) { $destinos = @(@($r.ForwardTo) + @($r.ForwardAsAttachmentTo) + @($r.RedirectTo) | Where-Object { $_ }) $direcciones = [System.Collections.Generic.List[string]]::new() $externos = 0 $sinDeterminar = 0 # Un destino llega como texto: "Ana" [SMTP:ana@dominio] o "Ana" [EX:/o=...]. Solo vale el último corchete: el nombre lo elige quien crea la regla. foreach ($d in $destinos) { $texto = "$d" $correo = if ($texto -match '\[SMTP:([^\]\s]+)\]\s*$') { $Matches[1] } elseif ($texto -match '^[^@\s]+@[^@\s]+$') { $texto } if ($correo) { $direcciones.Add($correo) if ($correo.Split('@')[-1].ToLowerInvariant() -notin $dominios) { $externos++ } continue } $direcciones.Add($texto) # [EX:] puede ser un contacto o invitado con correo externo: solo un buzón es interno con seguridad. $interno = $false if ($texto -match '\[EX:([^\]]+)\]\s*$') { $dn = $Matches[1] try { $destinatario = @(Get-EXORecipient -Identity $dn -Properties RecipientTypeDetails -ErrorAction Stop) $interno = $destinatario.Count -eq 1 -and "$($destinatario[0].RecipientTypeDetails)" -match 'Mailbox$' } catch { Write-Verbose "No se pudo resolver $dn : $_" } } if (-not $interno) { $sinDeterminar++ } } # MoveToFolder llega como "ana@dominio:\Carpeta\Subcarpeta"; cada nivel cuenta, porque se puede esconder en una subcarpeta. $segmentos = @(("$($r.MoveToFolder)" -replace '^[^\\]*:\\?', '') -split '\\' | ForEach-Object { $_.Trim() } | Where-Object { $_ -and $_ -ne '.' }) $carpeta = $segmentos -join '\' $motivos = @() if ($externos) { $motivos += 'Reenvía fuera de la organización' } elseif ($sinDeterminar) { $motivos += 'Reenvía a un destino que no se pudo clasificar' } elseif ($destinos) { $motivos += 'Reenvía dentro de la organización' } if ($r.PermanentDelete -eq $true) { $motivos += 'Borra de forma permanente' } elseif ($r.DeleteMessage -eq $true -or $r.SoftDeleteMessage -eq $true) { $motivos += 'Borra el correo' } $carpetaRara = [bool]($segmentos -match $carpetasRaras) if ($carpetaRara) { $motivos += "Mueve a la carpeta $carpeta" } elseif ($carpeta -and $r.MarkAsRead -eq $true) { $motivos += "Mueve a $carpeta y marca como leído" } if (-not $motivos) { continue } # La regla oculta de correo no deseado existe en casi todos los buzones. Su nombre lo puede copiar un atacante: la carpeta también debe coincidir. if ("$($r.Name)" -eq 'Junk E-mail Rule' -and $motivos.Count -eq 1 -and $carpeta -match '^(junk( e-?mail)?|correo no deseado)$') { continue } [pscustomobject]@{ Buzon = $buzon Regla = "$($r.Name)" Habilitada = if ($null -eq $r.Enabled) { 'No determinado' } elseif ($r.Enabled) { 'Sí' } else { 'No' } Riesgo = if ($externos -or $sinDeterminar -or $r.PermanentDelete -eq $true) { 'Alto' } else { 'Medio' } Motivos = $motivos -join '. ' ReenviaA = $direcciones -join '; ' ReenvioExterno = if ($externos) { 'Sí' } elseif ($sinDeterminar) { 'No determinado' } elseif ($destinos) { 'No' } else { '' } CarpetaDestino = $carpeta } } }) Write-Progress -Activity 'Revisando reglas de bandeja' -Completed } finally { Disconnect-ExchangeOnline -Confirm:$false } $fallas = @($filas | Where-Object { $_.Regla -eq 'No se pudo consultar' }) Write-Host "Buzones revisados: $($objetivos.Count). Reglas sospechosas: $($filas.Count - $fallas.Count). Buzones con falla: $($fallas.Count)" if ($RutaCsv) { # Excel ejecutaría como fórmula un nombre de regla que empiece con estos caracteres, y el nombre lo elige quien creó la regla. foreach ($f in $filas) { foreach ($p in $f.PSObject.Properties) { if ($p.Value -is [string] -and $p.Value -match '^[=+\-@\t\r]') { $p.Value = "'" + $p.Value } } } # UTF-8 con BOM para que Excel en español muestre bien los acentos. $filas | Export-Csv -Path $RutaCsv -NoTypeInformation -Encoding utf8BOM Write-Host "CSV guardado en $RutaCsv" } else { $filas | Format-Table -AutoSize } if ($fallas) { throw "No se pudieron consultar $($fallas.Count) buzones: $($fallas.Buzon -join ', ')" }