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