Cumplimiento de Azure Policy por suscripción
Resume por suscripción cuántos recursos y políticas no cumplen tus asignaciones de Azure Policy, con el porcentaje de cumplimiento.
Ejemplo en México
Maquiladora de autopartes en Querétaro, 800 personas
Una auditoría del corporativo pide evidencia del estado de las políticas en sus cuatro suscripciones.
.\get-cumplimiento-azure-policy.ps1 -RutaCsv .\policy.csvEntrega una tabla con el porcentaje por suscripción como evidencia y prioriza la que está más baja.
Cómo implementarlo
- Corre el script con una cuenta que tenga Lector en las suscripciones.
- Ordena el resultado por PorcentajeCumplimiento, de menor a mayor.
- En el portal, abre Azure Policy > Cumplimiento en la suscripción con menor porcentaje para ver qué asignación falla.
- Corrige los recursos o ajusta la asignación, y lleva el resultado a tu junta de gobierno de TI.
Ver el código completo de get-cumplimiento-azure-policy.ps1
<#
.SYNOPSIS
¿Qué tanto cumple cada suscripción con las asignaciones de Azure Policy?
.DESCRIPTION
Consulta el resumen de Policy Insights de cada suscripción y calcula el porcentaje de recursos compatibles. Solo lectura: no cambia nada; el POST de summarize es una consulta.
.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-cumplimiento-azure-policy.ps1 -RutaCsv .\policy.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 }
$sinConsultar = @()
$filas = @(foreach ($s in $activas) {
try {
$resumen = InvocarAzure POST "/subscriptions/$($s.subscriptionId)/providers/Microsoft.PolicyInsights/policyStates/latest/summarize?api-version=2024-10-01"
} catch {
Write-Warning "No se pudo consultar $($s.displayName): $_"
$sinConsultar += $s.displayName
continue
}
$resultados = @($resumen.value)[0].results
$conteo = @{}
foreach ($detalle in @($resultados.resourceDetails)) { if ($detalle) { $conteo[$detalle.complianceState] = $detalle.count } }
$compatibles = [int]$conteo['compliant']
$noCompatibles = [int]$conteo['noncompliant']
$evaluados = $compatibles + $noCompatibles
[pscustomobject]@{
Suscripcion = $s.displayName
SuscripcionId = $s.subscriptionId
RecursosCompatibles = $compatibles
RecursosNoCompatibles = [int]$resultados.nonCompliantResources
PoliticasNoCompatibles = [int]$resultados.nonCompliantPolicies
PorcentajeCumplimiento = if ($evaluados) { [Math]::Round(100 * $compatibles / $evaluados) } else { 'Sin recursos evaluados' }
}
})
Write-Host "Suscripciones revisadas: $($filas.Count)"
if ($sinConsultar) {
Write-Host " No se pudo consultar: $(@($sinConsultar).Count)"
$filas += @($sinConsultar | ForEach-Object { [pscustomobject]@{ Suscripcion = $_; SuscripcionId = ''; RecursosCompatibles = ''; RecursosNoCompatibles = ''; PoliticasNoCompatibles = ''; PorcentajeCumplimiento = 'No se pudo consultar' } })
}
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 ', ')" }