Saltar al contenido
Microsoft scripts

Scripts de Azure

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.

Antes de correrloActivar la protección de purga es irreversible: después ya no se puede desactivar y lo eliminado no se purga antes de que termine la retención. Si un Key Vault está abierto, cerrarlo sin revisar puede cortar a las aplicaciones que lo usan.

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.csv

Detecta 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

  1. Corre el script y filtra la columna Hallazgos.
  2. Decide con el dueño de cada Key Vault si la protección de purga aplica.
  3. Limita el acceso de red con firewall o Private Link donde salga abierto.
  4. 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
}

Relacionados