Saltar al contenido
Microsoft scripts

Scripts de Azure

NSG con puertos sensibles abiertos a Internet

Encuentra reglas de entrada que permiten RDP, SSH, SMB o SQL desde cualquier origen de Internet, y dice si el NSG está asociado a una subred o NIC.

Antes de correrloSolo analiza cada regla por separado: no evalúa reglas de denegación con mayor prioridad, Azure Firewall ni si la VM tiene IP pública. Un origen como 0.0.0.0/1 no se detecta. Es una señal para revisar, no prueba de que el puerto se alcance desde Internet.

Ejemplo en México

Hotel en Cancún, 200 personas

Un técnico abrió RDP a todo Internet para dar soporte remoto durante una emergencia y nadie lo cerró.

.\get-nsg-puertos-expuestos.ps1 -RutaCsv .\nsg-expuestos.csv

Encuentra la regla abierta en el NSG del servidor de reservaciones, la limita a la IP de la oficina y programa el reporte cada mes.

Cómo implementarlo

  1. Corre el script; agrega más puertos con -Puertos si usas otros servicios, por ejemplo 3306 o 5432.
  2. Atiende primero las filas con Asociado en Sí: esas sí protegen una subred o NIC.
  3. Confirma en el portal si una regla de mayor prioridad bloquea el tráfico antes.
  4. Limita el origen a tus IP o usa Azure Bastion o VPN, y vuelve a correr el reporte.
Ver el código completo de get-nsg-puertos-expuestos.ps1
<#
.SYNOPSIS
    ¿Qué grupos de seguridad de red dejan abiertos a Internet mis puertos sensibles?

.DESCRIPTION
    Revisa las reglas de entrada de cada NSG y reporta las que permiten el tráfico desde cualquier origen a los puertos indicados, incluidos rangos. Solo lectura: no cambia reglas.

.PARAMETER Puertos
    Puertos a revisar. Por defecto: 22 (SSH), 3389 (RDP), 445 (SMB) y 1433 (SQL).

.PARAMETER Suscripciones
    IDs o nombres de suscripción. Si se omite, usa todas a las que tienes acceso.

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

.EXAMPLE
    .\get-nsg-puertos-expuestos.ps1 -RutaCsv .\nsg-expuestos.csv

.NOTES
    Permisos: Lector (Reader) en cada suscripción
    Rol mínimo: Lector (Reader)
    Licencia: Basta una suscripción de Azure; no requiere plan adicional.
    Licencia del código: MIT. Se entrega TAL CUAL, sin garantía. Pruébalo primero en una suscripción de pruebas.
#>
[CmdletBinding()]
param(
    [int[]]$Puertos = @(22, 3389, 445, 1433),
    [ValidateNotNullOrEmpty()][string[]]$Suscripciones,
    [string]$RutaCsv
)

$ErrorActionPreference = 'Stop'

if (-not (Get-Command Connect-AzAccount -ErrorAction SilentlyContinue)) {
    throw 'Falta el módulo. Instálalo con: Install-Module Az.Accounts -Scope CurrentUser'
}
if (-not (Get-AzContext)) { $null = Connect-AzAccount }

function InvocarAzure($Metodo, $Ruta, $Cuerpo) {
    $parametros = @{ Method = $Metodo }
    if ($Ruta -like 'http*') { $parametros.Uri = $Ruta } else { $parametros.Path = $Ruta }
    if ($Cuerpo) { $parametros.Payload = $Cuerpo | ConvertTo-Json -Depth 10 }
    # Invoke-AzRestMethod no lanza error con 4xx o 5xx. 429 es una limitación temporal: se reintenta.
    for ($intento = 1; ; $intento++) {
        $respuesta = Invoke-AzRestMethod @parametros
        if ($respuesta.StatusCode -ne 429 -or $intento -ge 4) { break }
        Start-Sleep -Seconds 30
    }
    if ($respuesta.StatusCode -ge 400) { throw "Azure respondió $($respuesta.StatusCode) en ${Ruta}: $($respuesta.Content)" }
    if ($respuesta.Content) { $respuesta.Content | ConvertFrom-Json -AsHashtable }
}

function ListarPaginado($Ruta) {
    while ($Ruta) {
        $pagina = InvocarAzure GET $Ruta
        $pagina.value
        $Ruta = $pagina.nextLink
    }
}

$activas = @(ListarPaginado '/subscriptions?api-version=2022-12-01' | Where-Object {
    $_.state -notin 'Disabled', 'Deleted' -and
    (-not $Suscripciones -or $_.subscriptionId -in $Suscripciones -or $_.displayName -in $Suscripciones)
})
if (-not $activas) { throw 'No hay suscripciones activas a las que tengas acceso con ese filtro.' }
$noEncontradas = @($Suscripciones | Where-Object { $_ -and $_ -notin $activas.subscriptionId -and $_ -notin $activas.displayName })
if ($noEncontradas) { throw "No se encontró o no está activa: $($noEncontradas -join ', '). Revisa -Suscripciones." }
$nombres = @{}
foreach ($s in $activas) { $nombres[$s.subscriptionId] = $s.displayName }

function ConsultarGrafo($Consulta) {
    $ids = @($activas.subscriptionId)
    # Resource Graph admite hasta 1,000 suscripciones por consulta.
    for ($i = 0; $i -lt $ids.Count; $i += 1000) {
        $lote = @($ids[$i..([Math]::Min($i + 999, $ids.Count - 1))])
        $cuerpo = @{ query = $Consulta; subscriptions = $lote; options = @{ '$top' = 1000 } }
        do {
            $pagina = InvocarAzure POST '/providers/Microsoft.ResourceGraph/resources?api-version=2024-04-01' $cuerpo
            $pagina.data
            $cuerpo.options['$skipToken'] = $pagina.'$skipToken'
        } while ($pagina.'$skipToken')
    }
}

$nombrePuerto = @{ 22 = 'SSH'; 3389 = 'RDP'; 445 = 'SMB'; 1433 = 'SQL' }
$origenesAbiertos = '*', 'Internet', '0.0.0.0/0', '::/0', 'Any'

function PuertosCubiertos($Regla) {
    $rangos = @($Regla.destinationPortRange) + @($Regla.destinationPortRanges) | Where-Object { $_ }
    foreach ($puerto in $Puertos) {
        foreach ($rango in $rangos) {
            $partes = "$rango" -split '-'
            if ($rango -eq '*' -or ($puerto -ge [int]$partes[0] -and $puerto -le [int]$partes[-1])) { $puerto; break }
        }
    }
}

$consulta = 'resources | where type =~ "microsoft.network/networksecuritygroups" | project id, name, resourceGroup, subscriptionId, location, properties'

$filas = @(foreach ($nsg in ConsultarGrafo $consulta) {
    $asociado = $nsg.properties.subnets -or $nsg.properties.networkInterfaces
    foreach ($regla in $nsg.properties.securityRules) {
        $p = $regla.properties
        if ($p.direction -ne 'Inbound' -or $p.access -ne 'Allow') { continue }
        $origenes = @($p.sourceAddressPrefix) + @($p.sourceAddressPrefixes) | Where-Object { $_ }
        if (-not ($origenes | Where-Object { $_ -in $origenesAbiertos })) { continue }
        $expuestos = @(PuertosCubiertos $p)
        if ($expuestos) {
            [pscustomobject]@{
                Suscripcion   = $nombres[$nsg.subscriptionId]
                GrupoRecursos = $nsg.resourceGroup
                NSG           = $nsg.name
                Regla         = $regla.name
                Prioridad     = $p.priority
                Puertos       = ($expuestos | ForEach-Object { if ($nombrePuerto[$_]) { "$_ ($($nombrePuerto[$_]))" } else { "$_" } }) -join '; '
                Origen        = $origenes -join '; '
                Asociado      = if ($asociado) { 'Sí' } else { 'No' }
                Id            = $nsg.id
            }
        }
    }
})

Write-Host "Reglas expuestas: $($filas.Count)"

if ($filas.Count -eq 0) {
    Write-Host 'Sin resultados.'
    # Sin esto, el CSV de una corrida anterior parecería el resultado de esta.
    if ($RutaCsv) { Set-Content -Path $RutaCsv -Value '' -Encoding utf8BOM }
} elseif ($RutaCsv) {
    # 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
}