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.
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.csvTienen 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
- Define con Recursos Humanos o Legal quién autoriza la revisión y qué rango de fechas necesitas.
- Ejecuta el script con -Usuario, -Desde, -Hasta y -RutaCsv, usando fechas en hora de México.
- En Excel, revisa IP, Operacion y Objeto; marca descargas masivas, reglas de correo e ingresos raros.
- 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
}