<# .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 ', ')" }