Cuentas de almacenamiento con configuración débil
Señala cuentas de almacenamiento que permiten acceso público a blobs, aceptan TLS anterior a 1.2 o permiten autenticarse con clave compartida.
Solo lecturaPide 1 permisoProbado con respuestas simuladas. Sin ejecutar en un tenant real.
Ejemplo en México
Aseguradora regional en Monterrey, 400 personas
Una revisión interna de seguridad pide confirmar que ninguna cuenta con pólizas escaneadas quede abierta.
.\get-almacenamiento-inseguro.ps1 -RutaCsv .\almacenamiento.csvIdentifica una cuenta de pruebas con acceso público y varias con clave compartida, y arma un plan para migrar a Microsoft Entra ID.
Cómo implementarlo
- Ejecuta el script y atiende primero AccesoPublicoBlob en Permitido.
- Sube el TLS mínimo a 1.2 en las cuentas marcadas, después de confirmar que tus aplicaciones lo soportan.
- Antes de bloquear la clave compartida, revisa qué aplicaciones usan claves o SAS firmadas con clave.
- Aplica los cambios por etapas y vuelve a ejecutar el reporte para dejar la evidencia.
Ver el código completo de get-almacenamiento-inseguro.ps1
<#
.SYNOPSIS
¿Qué cuentas de almacenamiento permiten acceso público, TLS bajo o clave compartida?
.DESCRIPTION
Revisa tres propiedades de cada cuenta y aplica la interpretación de Microsoft Learn cuando vienen vacías: TLS vacío equivale a 1.0 y clave compartida vacía equivale a permitida. Solo lectura: no cambia nada.
.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-almacenamiento-inseguro.ps1 -RutaCsv .\almacenamiento.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.
#>
#Requires -Version 7.0
[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.storage/storageaccounts" | project id, name, resourceGroup, subscriptionId, location, properties'
$filas = @(foreach ($cuenta in ConsultarGrafo $consulta) {
$p = $cuenta.properties
$blobPublico = $p.allowBlobPublicAccess -eq $true
$tls = if ($p.minimumTlsVersion) { $p.minimumTlsVersion } else { 'TLS1_0 (valor por omisión)' }
$tlsBajo = $tls -notin 'TLS1_2', 'TLS1_3'
$claveCompartida = $p.allowSharedKeyAccess -ne $false
if ($blobPublico -or $tlsBajo -or $claveCompartida) {
[pscustomobject]@{
Suscripcion = $nombres[$cuenta.subscriptionId]
GrupoRecursos = $cuenta.resourceGroup
Cuenta = $cuenta.name
Region = $cuenta.location
AccesoPublicoBlob = if ($blobPublico) { 'Permitido' } else { 'Bloqueado' }
TlsMinimo = $tls
TlsBajo = if ($tlsBajo) { 'Sí' } else { 'No' }
ClaveCompartida = if ($claveCompartida) { 'Permitida' } else { 'Bloqueada' }
Id = $cuenta.id
}
}
})
Write-Host "Cuentas con algún hallazgo: $($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
}