<# .SYNOPSIS ¿Quién borró recursos o cambió permisos en mis suscripciones en los últimos días? .DESCRIPTION Lee el registro de actividad de cada suscripción y deja las operaciones exitosas de borrado y de cambio de asignaciones o definiciones de roles. Las fechas se consultan en UTC y se muestran en -ZonaHoraria. Solo lectura: no cambia nada. .PARAMETER Dias Días hacia atrás a revisar, de 1 a 90. Azure conserva el registro de actividad 90 días. .PARAMETER ZonaHoraria Zona horaria para mostrar las fechas. Por defecto, Central Standard Time (Mexico). .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-cambios-criticos-actividad.ps1 -Dias 30 -RutaCsv .\cambios.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( [ValidateRange(1, 90)][int]$Dias = 7, [string]$ZonaHoraria = 'Central Standard Time (Mexico)', [ValidateNotNullOrEmpty()][string[]]$Suscripciones, [string]$RutaCsv ) $zona = [TimeZoneInfo]::FindSystemTimeZoneById($ZonaHoraria) $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 ConvertirFecha($Valor) { if (-not $Valor) { return $null } $utc = if ($Valor -is [datetime]) { $Valor.ToUniversalTime() } else { ([datetimeoffset]"$Valor").UtcDateTime } [TimeZoneInfo]::ConvertTimeFromUtc($utc, $zona).ToString('yyyy-MM-dd HH:mm') } $ahora = [datetime]::UtcNow # Un minuto de margen para no pedir más de los 90 días que conserva Azure. $desde = $ahora.AddDays(-$Dias).AddMinutes(1) $filtro = "eventTimestamp ge '$($desde.ToString('yyyy-MM-ddTHH:mm:ssZ'))' and eventTimestamp le '$($ahora.ToString('yyyy-MM-ddTHH:mm:ssZ'))'" $filtro = [uri]::EscapeDataString($filtro) $sinConsultar = @() $filas = @(foreach ($s in $activas) { try { $eventos = ListarPaginado "/subscriptions/$($s.subscriptionId)/providers/Microsoft.Insights/eventtypes/management/values?api-version=2015-04-01&%24filter=$filtro" } catch { Write-Warning "No se pudo consultar $($s.displayName): $_" $sinConsultar += $s.displayName continue } foreach ($evento in $eventos) { $operacion = "$($evento.operationName.value)" if ($evento.status.value -ne 'Succeeded') { continue } $tipo = if ($operacion -match '^Microsoft\.Authorization/(roleAssignments|roleDefinitions)/(write|delete)$') { 'Cambio de permisos' } elseif ($operacion -match '/delete$') { 'Borrado' } if ($tipo) { [pscustomobject]@{ Fecha = ConvertirFecha $evento.eventTimestamp Tipo = $tipo Operacion = $operacion Responsable = $evento.caller Suscripcion = $s.displayName GrupoRecursos = $evento.resourceGroupName Recurso = $evento.resourceId } } } }) $filas = @($filas | Sort-Object Fecha -Descending) Write-Host "Cambios críticos en $Dias días: $($filas.Count)" $filas | Group-Object Tipo | ForEach-Object { Write-Host " $($_.Name): $($_.Count)" } if ($sinConsultar) { Write-Host " No se pudo consultar: $(@($sinConsultar).Count)" $filas += @($sinConsultar | ForEach-Object { [pscustomobject]@{ Fecha = ''; Tipo = 'No se pudo consultar'; Operacion = ''; Responsable = ''; Suscripcion = $_; GrupoRecursos = ''; Recurso = '' } }) } 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 ', ')" }