Saltar al contenido
MS Scripts

Scripts de Exchange

DKIM, SPF, DMARC y MX por dominio

Revisa cada dominio aceptado: si DKIM está activo y qué publican sus registros DNS MX, SPF y DMARC, con los problemas en una columna. Un dominio mal configurado es fácil de suplantar y su correo llega peor.

Solo lecturaSin permisos de GraphProbado con respuestas simuladas. Sin ejecutar en un tenant real.

Antes de ejecutarloSolo funciona en Windows: Resolve-DnsName no existe en macOS ni en Linux. Consulta el DNS de tu computadora salvo que uses -ServidorDns, y una red con DNS interno puede dar resultados distintos a los públicos. "Sin registro" solo sale cuando el DNS respondió que no existe; un error de consulta sale como "No se pudo consultar" y el script termina con error después de guardar el CSV. Un subdominio sin DMARC hereda el del dominio padre, aunque aquí salga Sin registro. El dominio onmicrosoft.com lo administra Microsoft: su DMARC se agrega en el centro de administración.

Ejemplo en México

Hotel en Cancún, 150 personas

Las confirmaciones de reservación les llegan a los huéspedes como correo no deseado, y un huésped recibió un cobro falso que parecía del hotel.

.\get-seguridad-dominios.ps1 -ServidorDns 1.1.1.1 -RutaCsv .\dominios.csv

El dominio principal tiene dos registros SPF y DMARC en p=none, y un dominio de promociones no tiene DKIM. Unen el SPF, activan DKIM y suben DMARC poco a poco.

Cómo implementarlo

  1. En una computadora con Windows, instala el módulo si no lo tienes: Install-Module ExchangeOnlineManagement -Scope CurrentUser.
  2. Ejecuta el script con -RutaCsv; agrega -ServidorDns 1.1.1.1 si tu red tiene DNS interno para estos dominios.
  3. Lee la columna Problemas de cada dominio.
  4. Si algo dice "No se pudo consultar", repite la prueba desde otra red: ese resultado no significa que falte el registro.
  5. Corrige en tu proveedor de DNS y activa DKIM en el portal de Microsoft Defender; luego corre de nuevo el script.
Ver el código completo de get-seguridad-dominios.ps1
<#
.SYNOPSIS
    Revisa por cada dominio aceptado el DKIM y los registros DNS públicos MX, SPF y DMARC, e indica los problemas.

.DESCRIPTION
    Un dominio sin SPF, DKIM o DMARC es fácil de suplantar y su correo llega peor a otros servidores.
    Si una consulta falla, el resultado es "No se pudo consultar"; solo dice "Sin registro" cuando el DNS respondió que no existe.
    Solo lectura: no cambia nada en el tenant ni en el DNS. Solo funciona en Windows, porque Resolve-DnsName no existe en macOS ni en Linux.

.PARAMETER ServidorDns
    Servidores DNS a consultar, por ejemplo 1.1.1.1. Úsalo si tu DNS interno resuelve estos dominios distinto que internet.
    Si se omite, usa el DNS de tu computadora.

.PARAMETER RutaCsv
    Ruta del CSV de salida. Si se omite, solo se muestra en pantalla.

.EXAMPLE
    .\get-seguridad-dominios.ps1 -RutaCsv .\dominios.csv

.EXAMPLE
    .\get-seguridad-dominios.ps1 -ServidorDns 1.1.1.1 -RutaCsv .\dominios.csv

.NOTES
    Permisos: ninguno de Graph; usa ExchangeOnlineManagement.
    Rol mínimo: Lector global (o View-Only Organization Management en Exchange Online).
    Licencia: Exchange Online. Las consultas DNS no requieren licencia.
    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(
    [string[]]$ServidorDns,
    [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'
}
if (-not (Get-Command Resolve-DnsName -ErrorAction SilentlyContinue)) {
    throw 'Falta Resolve-DnsName: solo existe en Windows. Corre este script en una computadora con Windows.'
}

# 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 }

# Devuelve $null si no se pudo consultar y un arreglo, quizá vacío, si el DNS respondió.
function Get-RegistrosDns {
    param([string]$Nombre, [string]$Tipo)
    $extras = @{}
    if ($ServidorDns) { $extras.Server = $ServidorDns }
    try {
        # Sin datos, Resolve-DnsName responde con un registro SOA o CNAME: por eso se filtra por tipo.
        , @($Nombre | Resolve-DnsName -Type $Tipo -DnsOnly -ErrorAction Stop @extras | Where-Object { "$($_.Type)" -eq $Tipo })
    } catch {
        # NXDOMAIN es una respuesta del DNS: el nombre no existe. Cualquier otro error no permite afirmar nada.
        if ($_.FullyQualifiedErrorId -like 'DNS_ERROR_RCODE_NAME_ERROR*') { , @() } else { $null }
    }
}

$noConsultado = 'No se pudo consultar'

Connect-ExchangeOnline -ShowBanner:$false
try {
    $dominios = @(Get-AcceptedDomain -ResultSize Unlimited -ErrorAction Stop)
    if (-not $dominios) { throw 'No se pudieron leer los dominios aceptados del tenant.' }
    $filas = @(foreach ($d in $dominios) {
        $dominio = "$($d.DomainName)"
        $problemas = @()

        # DKIM
        $dkim = $noConsultado
        $estadoDkim = ''
        if ($dominio) {
            try {
                $config = Get-DkimSigningConfig -Identity $dominio -ErrorAction Stop
                $dkim = if ($config.Enabled -eq $true) { 'Activo' } else { 'Inactivo' }
                $estadoDkim = "$($config.Status)"
                if ($dkim -eq 'Inactivo') { $problemas += 'DKIM inactivo' }
            } catch {
                if ("$_" -match "couldn.t be found|ManagementObjectNotFound") {
                    $dkim = 'Sin configurar'
                    $problemas += 'DKIM sin configurar'
                } else {
                    $problemas += "DKIM: no se pudo consultar ($_)"
                }
            }
        } else { $problemas += 'El dominio no trae nombre' }

        # MX
        $mx = $noConsultado
        $apuntaAMicrosoft = $false
        $registros = Get-RegistrosDns $dominio 'MX'
        if ($null -eq $registros) { $problemas += 'MX: no se pudo consultar' }
        elseif (-not $registros) { $mx = 'Sin registro'; $problemas += 'Sin registro MX: el dominio no recibe correo' }
        else {
            $hosts = @($registros | Sort-Object { [int]$_.Preference } | ForEach-Object { "$($_.NameExchange)".TrimEnd('.') })
            $mx = $hosts -join '; '
            $apuntaAMicrosoft = [bool]($hosts | Where-Object { $_ -match '\.mail\.protection\.(outlook\.com|office365\.us)$|\.mx\.microsoft$' })
            if (-not $apuntaAMicrosoft) { $problemas += 'El MX no apunta a Exchange Online; puede ser correcto si usas una puerta de enlace' }
            if ($hosts.Count -gt 1) { $problemas += 'Más de un registro MX' }
        }

        # SPF
        $spf = $noConsultado
        $registros = Get-RegistrosDns $dominio 'TXT'
        if ($null -eq $registros) { $problemas += 'SPF: no se pudo consultar' }
        else {
            $valores = @($registros | ForEach-Object { @($_.Strings) -join '' } | Where-Object { $_ -match '^v=spf1(\s|$)' })
            if (-not $valores) { $spf = 'Sin registro'; $problemas += 'Sin registro SPF' }
            else {
                $spf = $valores -join ' | '
                if ($valores.Count -gt 1) { $problemas += 'Más de un registro SPF: da error permanente (permerror)' }
                foreach ($v in $valores) {
                    if ($v -match '\s\+?all(\s|$)') { $problemas += 'SPF con +all: cualquiera puede enviar como este dominio' }
                    elseif ($v -match '\s\?all(\s|$)') { $problemas += 'SPF con ?all: no protege; Microsoft recomienda -all' }
                    elseif ($v -match '\s~all(\s|$)') { $problemas += 'SPF con ~all: Microsoft recomienda -all' }
                    elseif ($v -notmatch '\s-all(\s|$)' -and $v -notmatch '\sredirect=') { $problemas += 'SPF sin regla all' }
                }
                if ($apuntaAMicrosoft -and -not ($valores -match 'include:spf\.protection\.(outlook\.com|office365\.us)')) { $problemas += 'SPF no incluye directamente spf.protection.outlook.com: confirma que esté anidado en otro include' }
            }
        }

        # DMARC
        $dmarc = $noConsultado
        $registros = Get-RegistrosDns "_dmarc.$dominio" 'TXT'
        if ($null -eq $registros) { $problemas += 'DMARC: no se pudo consultar' }
        else {
            $valores = @($registros | ForEach-Object { @($_.Strings) -join '' } | Where-Object { $_ -match '^v=DMARC1\s*(;|$)' })
            if (-not $valores) { $dmarc = 'Sin registro'; $problemas += 'Sin registro DMARC' }
            else {
                $dmarc = $valores -join ' | '
                if ($valores.Count -gt 1) { $problemas += 'Más de un registro DMARC' }
                if ($valores[0] -match '(^|;)\s*p=\s*(\w+)') {
                    if ($Matches[2] -eq 'none') { $problemas += 'DMARC con p=none: solo monitorea, no protege' }
                    elseif ($Matches[2] -notin 'quarantine', 'reject') { $problemas += "DMARC con política inválida: p=$($Matches[2])" }
                } else { $problemas += 'DMARC sin política p=' }
                if ($valores[0] -match '(^|;)\s*sp=\s*(\w+)') {
                    if ($Matches[2] -eq 'none') { $problemas += 'DMARC con sp=none: los subdominios no quedan protegidos' }
                    elseif ($Matches[2] -notin 'quarantine', 'reject') { $problemas += "DMARC con política inválida: sp=$($Matches[2])" }
                }
                if ($valores[0] -match '(^|;)\s*pct=\s*(\d+)' -and [int]$Matches[2] -lt 100) { $problemas += "DMARC con pct=$($Matches[2]): solo aplica a una parte del correo" }
            }
        }

        [pscustomobject]@{
            Dominio    = $dominio
            Tipo       = "$($d.DomainType)"
            DKIM       = $dkim
            EstadoDKIM = $estadoDkim
            MX         = $mx
            SPF        = $spf
            DMARC      = $dmarc
            Problemas  = $problemas -join '. '
        }
    })
} finally {
    Disconnect-ExchangeOnline -Confirm:$false
}

$fallas = @($filas | Where-Object { $_.DKIM -eq $noConsultado -or $_.MX -eq $noConsultado -or $_.SPF -eq $noConsultado -or $_.DMARC -eq $noConsultado })
Write-Host "Dominios revisados: $($filas.Count). Con problemas: $(@($filas | Where-Object { $_.Problemas }).Count). Con alguna consulta fallida: $($fallas.Count)"
if ($RutaCsv) {
    # Excel ejecutaría como fórmula un valor que empiece con estos caracteres, y el DNS lo controla quien administra el dominio.
    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-List
}
if ($fallas) { throw "No se pudo consultar todo en $($fallas.Count) dominios: $($fallas.Dominio -join ', ')" }

Relacionados