Saltar al contenido
Microsoft scripts

Scripts de Purview

Actividad de auditoría de un usuario

Reúne en un CSV lo que registró la auditoría de un usuario en un rango de fechas. Útil cuando alguien deja la empresa o sospechas de una vulneración.

Antes de correrloEste reporte contiene datos personales de un empleado: limita quién lo ve y consulta a Legal. No es prueba completa: sin licencia E5 los eventos se conservan 180 días, los de buzón de usuarios sin E5 pueden no aparecer, y la búsqueda puede omitir registros sin -PriorizarCompletitud. El tope es de 50,000 registros: si lo alcanza, avisa y debes dividir el rango. Si la auditoría está apagada, el script se detiene con error en vez de mostrar cero. Sin eventos, avisa: un UPN mal escrito también da cero. Una fecha -Hasta sin hora incluye ese día completo.

Ejemplo en México

Maquiladora en Tijuana, 1,200 personas

Un supervisor del segundo turno renunció y creen que su cuenta se usó desde una IP desconocida. Necesitan revisar dos semanas de actividad.

.\search-actividad-usuario.ps1 -Usuario supervisor@empresa.example -Desde 2026-10-01 -Hasta 2026-10-15 -RutaCsv .\actividad.csv

Tienen una línea de tiempo ordenada para decidir si es una vulneración. Si lo es, les ayuda a atenderla y documentarla conforme a la LFPDPPP publicada en el DOF el 20 de marzo de 2025; la autoridad es la Secretaría Anticorrupción y Buen Gobierno.

Cómo implementarlo

  1. Define con Recursos Humanos o Legal quién autoriza la revisión y qué rango de fechas necesitas.
  2. Ejecuta el script con -Usuario, -Desde, -Hasta y -RutaCsv, usando fechas en hora de México.
  3. En Excel, revisa IP, Operacion y Objeto; marca descargas masivas, reglas de correo e ingresos raros.
  4. Guarda el CSV en un lugar con acceso restringido y anexa tus hallazgos al expediente del caso.
Ver el código completo de search-actividad-usuario.ps1
<#
.SYNOPSIS
    Reúne toda la actividad de auditoría de un usuario en un rango de fechas.

.DESCRIPTION
    Usa Search-UnifiedAuditLog con -UserIds y paginación por sesión (ReturnLargeSet, hasta 50,000 registros).
    Sirve para revisar a alguien que deja la empresa o una posible vulneración. Las fechas se interpretan en
    -ZonaHoraria y se consultan en UTC. Solo lectura: no cambia nada en el tenant.

.PARAMETER Usuario
    Cuenta del usuario (UserPrincipalName).

.PARAMETER Desde
    Inicio del rango, en la zona horaria indicada.

.PARAMETER Hasta
    Fin del rango, en la zona horaria indicada. Una fecha sin hora incluye ese día completo. Por defecto, ahora.

.PARAMETER PriorizarCompletitud
    Agrega -HighCompleteness: resultados más completos, pero más lentos. Microsoft lo marca en versión preliminar.

.PARAMETER ZonaHoraria
    Zona horaria de las fechas de entrada y de las que se muestran.

.PARAMETER RutaCsv
    Ruta del CSV de salida. Si se omite, solo se muestra en pantalla.

.EXAMPLE
    .\search-actividad-usuario.ps1 -Usuario ana@empresa.example -Desde 2026-10-01 -Hasta 2026-10-31 -RutaCsv .\actividad.csv

.NOTES
    Permisos: ninguno de Graph; usa ExchangeOnlineManagement.
    Rol mínimo: Audit Logs o View-Only Audit Logs en Exchange Online.
    Licencia: Microsoft Purview Audit (Standard): retención de 180 días; un año para Exchange, SharePoint, OneDrive y Microsoft Entra con usuarios E5 o complemento equivalente.
    Licencia del código: MIT. Se entrega TAL CUAL, sin garantía. Pruébalo primero en un tenant de pruebas.
#>
[CmdletBinding()]
param(
    [Parameter(Mandatory)]
    [ValidateNotNullOrEmpty()]
    [string]$Usuario,
    [Parameter(Mandatory)]
    [datetime]$Desde,
    [datetime]$Hasta,
    [switch]$PriorizarCompletitud,
    [string]$ZonaHoraria = 'Central Standard Time (Mexico)',
    [string]$RutaCsv
)

$ErrorActionPreference = 'Stop'
$zona = [TimeZoneInfo]::FindSystemTimeZoneById($ZonaHoraria)
$limite = 50000

if (-not (Get-Command Connect-ExchangeOnline -ErrorAction SilentlyContinue)) {
    throw 'Falta el módulo. Instálalo con: Install-Module ExchangeOnlineManagement -Scope CurrentUser'
}

function AUtc([datetime]$fecha) {
    [TimeZoneInfo]::ConvertTimeToUtc([datetime]::SpecifyKind($fecha, 'Unspecified'), $zona)
}

function HoraLocal($valor) {
    if (-not $valor) { return '' }
    $utc = if ($valor -is [datetime]) {
        if ($valor.Kind -eq 'Unspecified') { [datetime]::SpecifyKind($valor, 'Utc') } else { $valor.ToUniversalTime() }
    } else {
        [datetimeoffset]::Parse("$valor", [cultureinfo]::InvariantCulture, 'AssumeUniversal').UtcDateTime
    }
    [TimeZoneInfo]::ConvertTimeFromUtc($utc, $zona).ToString('yyyy-MM-dd HH:mm')
}

$inicioUtc = AUtc $Desde
$ahoraUtc = (Get-Date).ToUniversalTime()
# -Hasta 2026-10-31 se lee como "hasta ese día", no como la medianoche con la que empieza.
if ($PSBoundParameters.ContainsKey('Hasta') -and $Hasta.TimeOfDay -eq [timespan]::Zero) { $Hasta = $Hasta.AddDays(1) }
$finUtc = if ($PSBoundParameters.ContainsKey('Hasta')) { AUtc $Hasta } else { $ahoraUtc }
if ($finUtc -gt $ahoraUtc) { $finUtc = $ahoraUtc }
if ($inicioUtc -ge $finUtc) { throw '-Desde debe ser anterior a -Hasta.' }
if (((Get-Date).ToUniversalTime() - $inicioUtc).TotalDays -gt 180) { Write-Warning 'Sin licencia E5 o complemento equivalente, la auditoría solo conserva 180 días.' }

$filtro = @{ StartDate = $inicioUtc; EndDate = $finUtc; UserIds = $Usuario }
if ($PriorizarCompletitud) { $filtro.HighCompleteness = $true }

Connect-ExchangeOnline -ShowBanner:$false
$sesion = [guid]::NewGuid().ToString()
$vistos = [System.Collections.Generic.HashSet[string]]::new()
$registros = [System.Collections.Generic.List[object]]::new()
try {
    # Con la auditoría apagada Search-UnifiedAuditLog no devuelve nada, y cero parecería un resultado limpio.
    if (Get-Command Get-AdminAuditLogConfig -ErrorAction SilentlyContinue) {
        if ((Get-AdminAuditLogConfig).UnifiedAuditLogIngestionEnabled -ne $true) { throw 'La auditoría unificada está apagada: este reporte no puede ver nada. Actívala y vuelve a correrlo.' }
    } else {
        Write-Warning 'Tu rol no permite comprobar si la auditoría está activa. Un resultado vacío podría no significar nada: confírmalo con get-estado-auditoria.'
    }
    # Se pide hasta que no llegue nada nuevo; siempre el mismo SessionCommand para no bajar el tope a 10,000.
    do {
        $pagina = @(Search-UnifiedAuditLog @filtro -SessionId $sesion -SessionCommand ReturnLargeSet -ResultSize 5000)
        $nuevos = @($pagina | Where-Object { $vistos.Add([string]$_.Identity) })
        foreach ($r in $nuevos) { $registros.Add($r) }
    } while ($nuevos.Count -and $registros.Count -lt $limite)
} finally {
    Disconnect-ExchangeOnline -Confirm:$false
}
if ($registros.Count -ge $limite) { Write-Warning "Se alcanzó el tope de $limite registros: pueden faltar eventos. Divide el rango de fechas." }

$filas = @(
    foreach ($r in $registros) {
        $a = try { $r.AuditData | ConvertFrom-Json } catch { $null }
        [pscustomobject]@{
            Fecha     = if ($a.CreationTime) { HoraLocal $a.CreationTime } else { "$($r.CreationDate)" }
            Usuario   = $r.UserIds
            Operacion = if ($a.Operation) { $a.Operation } else { $r.Operations }
            Servicio  = $a.Workload
            Objeto    = $a.ObjectId
            IP        = $a.ClientIP
            Resultado = $a.ResultStatus
            Registro  = $r.Identity
        }
    }
) | Sort-Object Fecha

Write-Host "Eventos de $Usuario : $(@($filas).Count)."
if (-not $registros.Count) { Write-Warning "Sin eventos en ese rango. Revisa que $Usuario esté bien escrito: un UPN inexistente también da cero." }
if ($RutaCsv) {
    # Destinatarios y nombres de archivo vienen de fuera: Excel ejecutaría como fórmula los que empiecen así.
    foreach ($f in $filas) { foreach ($p in $f.PSObject.Properties) { if ($p.Value -is [string] -and $p.Value -match '^[=+\-@\t\r]') { $p.Value = "'" + $p.Value } } }
    # 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
}

Relacionados