Qué vas a conseguir
Cada lunes a las 6:30, sin que nadie lo lance, una carpeta con tres listas en CSV y un HTML para leer en dos minutos: usuarios y equipos sin actividad en 90 días, contraseñas que caducan en los próximos 14 días y cuentas deshabilitadas que siguen en grupos. Lo ejecuta una cuenta de servicio administrada que solo puede leer el directorio y escribir en esa carpeta.
Antes de empezar
- Un servidor de gestión, aquí
srv-gestion01, con el módulo de Active Directory:Install-WindowsFeature RSAT-AD-PowerShell. El script vale en PowerShell 5.1 y 7. - La gMSA
CONTOSO\gmsa-informes$creada e instalada en ese servidor. Cómo se hace está en la guía de cuentas de servicio y gMSA en Windows Server. - La carpeta de salida
\\srv-ficheros\Informes$\ADcon permiso de Modificar para la gMSA y de Lectura para quien recibe el informe. - Lo básico del script de inventario remoto: objetos,
Select-ObjectyExport-Csv.
1. LastLogonTimestamp: lo que dice y lo que no
Active Directory guarda dos fechas de inicio de sesión:
- lastLogon: exacta, pero cada controlador de dominio tiene la suya y no se replica. Para usarla habría que preguntar a todos.
- lastLogonTimestamp: se replica, pero solo se actualiza cuando el valor guardado tiene más de 14 días (el intervalo por defecto, menos un margen aleatorio de hasta 5 días). Sirve para "más de 90 días sin entrar"; no sirve para "quién entró ayer".
Dos trampas más:
- Una cuenta que nunca ha iniciado sesión no tiene valor. El script usa entonces la fecha de creación (
whenCreated) para no marcar como inactiva una alta de la semana pasada. - Un usuario que solo usa Microsoft 365 desde el móvil, con autenticación en la nube, puede no tocar el AD local en meses. Antes de deshabilitar, compara con los inicios de sesión de Entra ID.
2. Cuándo caduca una contraseña
El atributo msDS-UserPasswordExpiryTimeComputed lo calcula el controlador de dominio al pedirlo, teniendo en cuenta la directiva del dominio y las directivas de contraseña específicas (PSO). No viene en Get-ADUser si no lo pides por su nombre:
Get-ADUser lmartin -Properties msDS-UserPasswordExpiryTimeComputed, PasswordLastSet | Select-Object SamAccountName, PasswordLastSet, @{n='Caduca';e={[datetime]::FromFileTime($_.'msDS-UserPasswordExpiryTimeComputed')}}SamAccountName PasswordLastSet Caduca-------------- --------------- ------lmartin 19/08/2026 9:14:02 17/11/2026 9:14:02El valor es un FILETIME. Dos valores especiales: 9223372036854775807 (la contraseña no caduca) y 0 (debe cambiarla en el próximo inicio de sesión). El script los descarta antes de convertir.
3. El script
#Requires -Version 5.1#Requires -Modules ActiveDirectory$ErrorActionPreference = 'Stop' $Base = 'OU=Contoso,DC=contoso,DC=local'$Salida = '\\srv-ficheros\Informes$\AD'$Fecha = Get-Date -Format 'yyyy-MM-dd'$Limite = (Get-Date).AddDays(-90)$Aviso = (Get-Date).AddDays(14)$NoCaduca = 9223372036854775807 Start-Transcript -Path "D:\Scripts\Logs\Informe-AD-$Fecha.log"try { # Usuarios activos con lo que necesitan las dos primeras listas $Usuarios = Get-ADUser -Filter 'Enabled -eq $true' -SearchBase $Base -Properties LastLogonTimestamp, whenCreated, PasswordNeverExpires, 'msDS-UserPasswordExpiryTimeComputed', Mail if ($Usuarios.Count -lt 10) { throw "Solo $($Usuarios.Count) usuarios en $Base, revisa la OU o los permisos" } $Inactivos = $Usuarios | Where-Object { if ($_.LastLogonTimestamp) { [datetime]::FromFileTime($_.LastLogonTimestamp) -lt $Limite } else { $_.whenCreated -lt $Limite } } | Select-Object Name, SamAccountName, whenCreated, @{n='UltimoInicio';e={ if ($_.LastLogonTimestamp) { [datetime]::FromFileTime($_.LastLogonTimestamp) } else { 'Nunca' } }} $EquiposInactivos = Get-ADComputer -Filter 'Enabled -eq $true' -SearchBase $Base -Properties LastLogonTimestamp, OperatingSystem | Where-Object { $_.LastLogonTimestamp -and [datetime]::FromFileTime($_.LastLogonTimestamp) -lt $Limite } | Select-Object Name, OperatingSystem, @{n='UltimoInicio';e={[datetime]::FromFileTime($_.LastLogonTimestamp)}} $Caducan = $Usuarios | Where-Object { -not $_.PasswordNeverExpires -and $_.'msDS-UserPasswordExpiryTimeComputed' -notin 0, $NoCaduca } | Select-Object Name, SamAccountName, Mail, @{n='Caduca';e={[datetime]::FromFileTime($_.'msDS-UserPasswordExpiryTimeComputed')}} | Where-Object { $_.Caduca -gt (Get-Date) -and $_.Caduca -lt $Aviso } | Sort-Object Caduca # Deshabilitadas con grupos (MemberOf no incluye el grupo principal, Domain Users) $DeshabConGrupos = Get-ADUser -Filter 'Enabled -eq $false' -SearchBase $Base -Properties MemberOf | Where-Object { $_.MemberOf.Count -gt 0 } | Select-Object Name, SamAccountName, @{n='Grupos';e={ ($_.MemberOf | ForEach-Object { ($_ -split ',')[0] -replace '^CN=' }) -join '; ' }} $Listas = [ordered]@{ 'Usuarios-inactivos' = $Inactivos; 'Equipos-inactivos' = $EquiposInactivos; 'Contrasenas-caducan' = $Caducan; 'Deshabilitados-con-grupos' = $DeshabConGrupos } $Html = foreach ($k in $Listas.Keys) { $Listas[$k] | Export-Csv -Path (Join-Path $Salida "$Fecha-$k.csv") -NoTypeInformation -Encoding UTF8 $Listas[$k] | ConvertTo-Html -Fragment -PreContent "<h2>$k ($(@($Listas[$k]).Count))</h2>" } ConvertTo-Html -Title "Informe AD $Fecha" -Body $Html | Out-File -FilePath (Join-Path $Salida "$Fecha-Informe-AD.html") -Encoding UTF8}catch { Write-Error $_ Stop-Transcript exit 1}Stop-Transcript$ErrorActionPreference = 'Stop'y eltry/catchconexit 1: un error para el script y la tarea lo muestra como fallo, en lugar de dejar un fichero vacío y un "correcto".- La comprobación de
$Usuarios.Countes una alarma barata: si la consulta devuelve casi nada, algo va mal con la OU o con los permisos. - Rutas siempre absolutas y en UNC: la tarea no tiene unidades asignadas ni el directorio de trabajo que tienes tú.
-Encoding UTF8en Windows PowerShell 5.1 escribe la marca BOM y Excel abre bien las tildes.
4. Probarlo a mano
Primero con tu cuenta, que también puede leer el directorio:
& D:\Scripts\Informe-AD.ps1Transcripción iniciada, el archivo de salida es D:\Scripts\Logs\Informe-AD-2026-11-03.logTranscripción detenida, el archivo de salida es D:\Scripts\Logs\Informe-AD-2026-11-03.logGet-ChildItem '\\srv-ficheros\Informes$\AD' | Select-Object Name, LengthName Length---- ------2026-11-03-Usuarios-inactivos.csv 48122026-11-03-Equipos-inactivos.csv 22402026-11-03-Contrasenas-caducan.csv 15762026-11-03-Deshabilitados-con-grupos.csv 33052026-11-03-Informe-AD.html 18920Abre el HTML y comprueba dos o tres nombres de cada lista contra la consola de usuarios y equipos.
5. La tarea programada con la gMSA
$accion = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-NoProfile -NonInteractive -ExecutionPolicy RemoteSigned -File "D:\Scripts\Informe-AD.ps1"' -WorkingDirectory 'D:\Scripts'$cuando = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Monday -At 6:30$quien = New-ScheduledTaskPrincipal -UserId 'CONTOSO\gmsa-informes$' -LogonType PasswordRegister-ScheduledTask -TaskName 'Informe AD semanal' -TaskPath '\Contoso\' -Action $accion -Trigger $cuando -Principal $quienStart-ScheduledTask -TaskName 'Informe AD semanal' -TaskPath '\Contoso\'-LogonType Passwordcon una gMSA: el Programador de tareas pide la contraseña a AD, tú no la escribes nunca.- Mínimo privilegio: la gMSA no está en ningún grupo de administración. Leer usuarios y equipos lo puede hacer cualquier cuenta del dominio. Necesita el derecho Iniciar sesión como proceso por lotes en el servidor (por GPO, en Asignación de derechos de usuario), Modificar en la carpeta de salida y en
D:\Scripts\Logs, y nada más. Sin Ejecutar con los privilegios más altos. - Si en tu entorno no se pueden usar gMSA, una cuenta de servicio normal con los mismos permisos y la contraseña guardada en el gestor de contraseñas del equipo; la gMSA evita el ticket del día que caduca.
6. El ticket: "la tarea se ejecuta, pero el informe sale vacío"
Lunes, 8:15. El responsable de seguridad abre el HTML: solo los títulos, (0) en cada lista. Los CSV pesan 0 bytes. La tarea dice que terminó bien.
Get-ScheduledTaskInfo -TaskName 'Informe AD semanal' -TaskPath '\Contoso\' | Select-Object LastRunTime, LastTaskResultLastRunTime LastTaskResult----------- --------------03/11/2026 6:30:01 0Get-Content D:\Scripts\Logs\Informe-AD-2026-11-03.log | Select-String 'Error|no se reconoce|not recognized'Get-ADUser : El término 'Get-ADUser' no se reconoce como nombre de un cmdlet, función, archivo de script o programa ejecutable.LastTaskResult 0 significa que powershell.exe terminó, no que el script hiciera lo que debía. La transcripción dice el resto: la tarea se movió el viernes a srv-gestion02, que no tiene el módulo de AD, y la copia del script que se usó allí no tenía #Requires ni $ErrorActionPreference = 'Stop'. Sin módulo, Get-ADUser falla, las listas quedan vacías y Export-Csv crea ficheros vacíos.
Las causas típicas, de más a menos frecuente:
- Falta el módulo en el servidor donde corre la tarea:
Get-Module -ListAvailable ActiveDirectory. - Rutas relativas o unidades asignadas: la tarea arranca en
C:\Windows\System32y sinZ:; el informe bueno está en otra carpeta o no se escribe. - La OU ha cambiado de nombre y
-SearchBaseapunta a una que ya no existe. - Permisos de lectura: una OU con la herencia cortada o una denegación explícita devuelve menos objetos sin dar error. Compara el recuento con tu cuenta y con la gMSA.
El arreglo: Install-WindowsFeature RSAT-AD-PowerShell en srv-gestion02, la versión del script con #Requires, Stop y la comprobación de recuento, y Start-ScheduledTask para comprobar. A partir de ahora, si algo falla, LastTaskResult será 1 y la herramienta de monitorización lo verá.
Siguientes pasos
- Altas y bajas en Active Directory desde CSV cierra el círculo: el informe dice quién sobra y ese script lo da de baja con revisión.
- Guarda el script en el repositorio del equipo y que cada cambio pase por revisión.
- Para avisar a cada usuario de la lista de contraseñas, envía el correo con Microsoft Graph y no con
Send-MailMessage, que Microsoft marca como obsoleto.
¿Falta algo o en tu entorno es distinto? Escríbeme y lo corrijo.
Tech Career Lab · newsletter
¿Te ha servido? Cada dos semanas envío una idea práctica para perfiles IT: LinkedIn, proyectos, networking y procesos de selección.
Apúntate y llévate las 33 ideas →