<# .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 }