Saltar al contenido
Microsoft scripts

Scripts de Azure

Servidores SQL con firewall abierto

Encuentra servidores de Azure SQL con una regla de firewall abierta a todo Internet o a todos los servicios de Azure, incluidos los de otros clientes.

Antes de correrloSolo detecta los dos rangos exactos: un rango muy amplio, como 1.0.0.0-200.0.0.0, no aparece. Cubre Azure SQL Database y sus reglas a nivel de servidor; no revisa reglas a nivel de base de datos ni Azure SQL Managed Instance. Borrar una regla puede cortar a una aplicación que dependa de ella. Si algo no se puede consultar, queda marcado en el reporte como "No se pudo consultar" y el script termina con error para que no lo tomes por completo.

Ejemplo en México

Inmobiliaria en Tijuana, 70 personas

Un desarrollador externo pidió abrir todo el firewall para terminar una migración y no se sabe si se cerró.

.\get-sql-firewall-abierto.ps1 -RutaCsv .\sql-firewall.csv

Encuentra la regla abierta a todo Internet en el servidor de ventas y la cambia por la IP de la oficina.

Cómo implementarlo

  1. Corre el script y empieza por las filas con Apertura en "Todo Internet".
  2. Revisa AccesoRedPublica: si está en Disabled, esas reglas no permiten entrar por el punto de conexión público.
  3. Averigua qué aplicación necesita conectarse y limita la regla a sus IP, o usa Private Link.
  4. Borra la regla abierta en una ventana controlada y confirma que las aplicaciones siguen funcionando.
Ver el código completo de get-sql-firewall-abierto.ps1
<#
.SYNOPSIS
    ¿Qué servidores SQL permiten conexiones desde todo Internet o desde todos los servicios de Azure?

.DESCRIPTION
    Lista los servidores de Azure SQL con Resource Graph y revisa sus reglas de firewall. Reporta la regla 0.0.0.0-255.255.255.255 y la 0.0.0.0-0.0.0.0. Solo lectura: no cambia reglas.

.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-sql-firewall-abierto.ps1 -RutaCsv .\sql-firewall.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(
    [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')
    }
}

$consulta = 'resources | where type =~ "microsoft.sql/servers" | project id, name, resourceGroup, subscriptionId, location, accesoPublico = tostring(properties.publicNetworkAccess)'

$sinConsultar = @()
$filas = @(foreach ($servidor in ConsultarGrafo $consulta) {
    try {
        $reglas = @(ListarPaginado "$($servidor.id)/firewallRules?api-version=2025-01-01")
    } catch {
        Write-Warning "No se pudo consultar $($servidor.name): $_"
        $sinConsultar += $servidor.name
        continue
    }
    foreach ($regla in $reglas) {
        $inicio = $regla.properties.startIpAddress
        $fin = $regla.properties.endIpAddress
        $apertura = if ($inicio -eq '0.0.0.0' -and $fin -eq '255.255.255.255') { 'Todo Internet' }
                    elseif ($inicio -eq '0.0.0.0' -and $fin -eq '0.0.0.0') { 'Todos los servicios de Azure' }
        if ($apertura) {
            [pscustomobject]@{
                Suscripcion      = $nombres[$servidor.subscriptionId]
                GrupoRecursos    = $servidor.resourceGroup
                Servidor         = $servidor.name
                Regla            = $regla.name
                Apertura         = $apertura
                IpInicio         = $inicio
                IpFin            = $fin
                AccesoRedPublica = $servidor.accesoPublico
                Id               = $servidor.id
            }
        }
    }
})

Write-Host "Reglas abiertas: $($filas.Count)"
$filas | Group-Object Apertura | ForEach-Object { Write-Host "  $($_.Name): $($_.Count)" }

if ($sinConsultar) {
    Write-Host "  No se pudo consultar: $(@($sinConsultar).Count)"
    $filas += @($sinConsultar | ForEach-Object { [pscustomobject]@{ Suscripcion = ''; GrupoRecursos = ''; Servidor = $_; Regla = ''; Apertura = 'No se pudo consultar'; IpInicio = ''; IpFin = ''; AccesoRedPublica = ''; Id = '' } })
}
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
}

# El reporte ya quedó escrito; el error avisa que está incompleto.
if ($sinConsultar) { throw "Reporte incompleto, no se pudo consultar: $(@($sinConsultar) -join ', ')" }