Configuración de protección de Key Vault
Revisa en cada Key Vault la eliminación temporal, la protección de purga y el acceso de red público, para saber cuáles podrían perder secretos o están expuestos.
Ejemplo en México
Fintech en Ciudad de México, 150 personas
Antes de una auditoría, seguridad quiere confirmar que los certificados de producción no se puedan purgar por accidente.
.\get-key-vault-configuracion.ps1 -RutaCsv .\key-vault.csvDetecta dos Key Vault sin protección de purga y uno abierto a todas las redes, y los corrige antes de la visita.
Cómo implementarlo
- Corre el script y filtra la columna Hallazgos.
- Decide con el dueño de cada Key Vault si la protección de purga aplica.
- Limita el acceso de red con firewall o Private Link donde salga abierto.
- Guarda el CSV como evidencia y repite la revisión cada trimestre.
Ver el código completo de get-key-vault-configuracion.ps1
<#
.SYNOPSIS
¿Mis Key Vault tienen eliminación temporal, protección de purga y acceso público restringido?
.DESCRIPTION
Lista todos los Key Vault y marca lo que conviene corregir. Solo lectura: no cambia la configuración ni lee secretos.
.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-key-vault-configuracion.ps1 -RutaCsv .\key-vault.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.keyvault/vaults" | project id, name, resourceGroup, subscriptionId, location, properties'
$filas = @(foreach ($boveda in ConsultarGrafo $consulta) {
$p = $boveda.properties
$eliminacionTemporal = $p.enableSoftDelete -ne $false
$proteccionPurga = $p.enablePurgeProtection -eq $true
$accesoPublico = if ($p.publicNetworkAccess -eq 'Disabled') { 'Deshabilitado' }
elseif ($p.networkAcls.defaultAction -eq 'Deny') { 'Limitado por firewall' }
else { 'Abierto a todas las redes' }
$hallazgos = @(
if (-not $eliminacionTemporal) { 'Sin eliminación temporal' }
if (-not $proteccionPurga) { 'Sin protección de purga' }
if ($accesoPublico -eq 'Abierto a todas las redes') { 'Acceso público abierto' }
)
[pscustomobject]@{
Suscripcion = $nombres[$boveda.subscriptionId]
GrupoRecursos = $boveda.resourceGroup
KeyVault = $boveda.name
Region = $boveda.location
EliminacionTemporal = if ($eliminacionTemporal) { 'Sí' } else { 'No' }
ProteccionPurga = if ($proteccionPurga) { 'Sí' } else { 'No' }
AccesoPublico = $accesoPublico
Hallazgos = if ($hallazgos) { $hallazgos -join '; ' } else { 'Sin hallazgos' }
Id = $boveda.id
}
})
Write-Host "Key Vault revisados: $($filas.Count)"
Write-Host "Con hallazgos: $(@($filas | Where-Object Hallazgos -NE 'Sin hallazgos').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
}