Saltar al contenido
Microsoft scripts

Scripts de Azure

Borrados y cambios de permisos en el registro de actividad

Revisa el registro de actividad de Azure y te dice quién borró recursos o cambió permisos en los últimos días, hasta el máximo de 90 que Azure conserva.

Antes de correrloAzure conserva el registro de actividad 90 días: lo anterior solo existe si lo exportaste. Solo registra el plano de administración, no el acceso a datos. Una suscripción con mucha actividad puede tardar varios minutos, porque se descargan todos los eventos y se filtran en tu computadora. Si algo no se puede consultar, queda marcado en el reporte como "No se pudo consultar" y el script termina con error para que no lo tomes por completo.

Ejemplo en México

Corporativo de transporte en Toluca, 600 personas

Desapareció un grupo de recursos de un fin de semana a otro y nadie admite haberlo borrado.

.\get-cambios-criticos-actividad.ps1 -Dias 30 -RutaCsv .\cambios.csv

Ve quién y a qué hora borró el grupo, y aprovecha para revisar los permisos que se dieron ese mes.

Cómo implementarlo

  1. Corre el script con -Dias según lo que quieras revisar; el máximo es 90.
  2. Filtra por Tipo y empieza por "Cambio de permisos".
  3. Confirma con cada Responsable que el cambio corresponde a una solicitud aprobada.
  4. Si algo no cuadra, abre un incidente; si necesitas más de 90 días, envía el registro a un área de trabajo de Log Analytics.
Ver el código completo de get-cambios-criticos-actividad.ps1
<#
.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 ', ')" }