Recomendaciones no saludables de Defender for Cloud
Lista los recursos con recomendaciones no saludables de Microsoft Defender for Cloud, de mayor a menor severidad. Las de Foundational CSPM, que es gratis, no requieren plan de pago.
Ejemplo en México
Distribuidora de alimentos en Culiacán, 220 personas
No tienen presupuesto para planes de pago este año, pero quieren saber qué corregir primero con lo que ya incluye Azure.
.\get-recomendaciones-defender-for-cloud.ps1 -Severidad High -RutaCsv .\defender.csvSaca la lista de severidad alta de Foundational CSPM y la atiende por grupo de recursos sin contratar nada.
Cómo implementarlo
- Confirma que Defender for Cloud esté habilitado en las suscripciones.
- Corre el script con -Severidad High para empezar por lo más grave.
- Abre la Liga de cada fila para ver cómo corregirla en el portal.
- Asigna responsables y fechas, y vuelve a correr el reporte para medir el avance.
Ver el código completo de get-recomendaciones-defender-for-cloud.ps1
<#
.SYNOPSIS
¿Qué recomendaciones de Microsoft Defender for Cloud tengo sin atender y cuáles son más graves?
.DESCRIPTION
Lee las evaluaciones de Defender for Cloud en Resource Graph y deja las de estado Unhealthy. Solo lectura: no aplica correcciones.
.PARAMETER Severidad
Severidades que quieres ver. Si se omite, muestra todas.
.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-recomendaciones-defender-for-cloud.ps1 -Severidad High, Medium -RutaCsv .\defender.csv
.NOTES
Permisos: Lector de seguridad (Security Reader) en cada suscripción
Rol mínimo: Lector de seguridad (Security Reader)
Licencia: Basta una suscripción de Azure con Foundational CSPM, que es gratis, en la experiencia del portal de Azure. Las recomendaciones específicas de un plan aparecen solo con ese plan de pago.
Licencia del código: MIT. Se entrega TAL CUAL, sin garantía. Pruébalo primero en una suscripción de pruebas.
#>
[CmdletBinding()]
param(
[ValidateSet('High', 'Medium', 'Low')][string[]]$Severidad,
[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')
}
}
$severidades = @{ High = 'Alta'; Medium = 'Media'; Low = 'Baja' }
$orden = @{ High = 0; Medium = 1; Low = 2 }
$consulta = 'securityresources | where type =~ "microsoft.security/assessments" | project id, name, resourceGroup, subscriptionId, properties'
$filas = @(ConsultarGrafo $consulta |
Where-Object {
$_.properties.status.code -eq 'Unhealthy' -and
(-not $Severidad -or $_.properties.metadata.severity -in $Severidad)
} |
Sort-Object { if ($orden.ContainsKey("$($_.properties.metadata.severity)")) { $orden["$($_.properties.metadata.severity)"] } else { 3 } } |
ForEach-Object {
$p = $_.properties
[pscustomobject]@{
Severidad = if ($severidades["$($p.metadata.severity)"]) { $severidades["$($p.metadata.severity)"] } else { $p.metadata.severity }
Recomendacion = $p.displayName
Suscripcion = $nombres[$_.subscriptionId]
GrupoRecursos = $_.resourceGroup
Recurso = ($_.id -split '/providers/Microsoft\.Security/assessments/')[0]
Liga = $p.links.azurePortal
}
})
Write-Host "Recomendaciones no saludables: $($filas.Count)"
$filas | Group-Object Severidad | ForEach-Object { Write-Host " $($_.Name): $($_.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
}