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.
What you'll get
Every Monday at 6:30, with nobody launching it, a folder with three CSV lists and an HTML page you can read in two minutes: users and computers with no activity in 90 days, passwords expiring in the next 14 days, and disabled accounts still in groups. It is run by a managed service account that can only read the directory and write to that folder.
Before you start
- A management server, here
srv-mgmt01, with the Active Directory module:Install-WindowsFeature RSAT-AD-PowerShell. The script works on PowerShell 5.1 and 7. - The gMSA
CONTOSO\gmsa-reports$created and installed on that server. How to do it is in the service accounts and gMSA on Windows Server guide. - The output folder
\\srv-files\Reports$\ADwith Modify for the gMSA and Read for whoever receives the report. - The basics from the remote inventory script: objects,
Select-ObjectandExport-Csv.
1. LastLogonTimestamp: what it tells you and what it doesn't
Active Directory keeps two sign-in dates:
- lastLogon: exact, but each domain controller has its own and it is not replicated. Using it means asking all of them.
- lastLogonTimestamp: replicated, but only updated when the stored value is more than 14 days old (the default interval, minus a random margin of up to 5 days). It works for "more than 90 days without signing in"; it does not work for "who signed in yesterday".
Two more traps:
- An account that has never signed in has no value. The script then uses the creation date (
whenCreated) so a starter from last week is not flagged as inactive. - A user who only uses Microsoft 365 from their phone, with cloud authentication, may not touch on-premises AD for months. Before disabling, compare with Entra ID sign-ins.
2. When a password expires
The msDS-UserPasswordExpiryTimeComputed attribute is calculated by the domain controller when asked, taking into account the domain policy and fine-grained password policies (PSOs). Get-ADUser does not return it unless you ask for it by name:
Get-ADUser lmartin -Properties msDS-UserPasswordExpiryTimeComputed, PasswordLastSet | Select-Object SamAccountName, PasswordLastSet, @{n='Expires';e={[datetime]::FromFileTime($_.'msDS-UserPasswordExpiryTimeComputed')}}SamAccountName PasswordLastSet Expires-------------- --------------- -------lmartin 19/08/2026 9:14:02 17/11/2026 9:14:02The value is a FILETIME. Two special values: 9223372036854775807 (the password never expires) and 0 (must change it at next sign-in). The script drops them before converting.
3. The script
#Requires -Version 5.1#Requires -Modules ActiveDirectory$ErrorActionPreference = 'Stop' $Base = 'OU=Contoso,DC=contoso,DC=local'$Output = '\\srv-files\Reports$\AD'$Date = Get-Date -Format 'yyyy-MM-dd'$Threshold = (Get-Date).AddDays(-90)$Warn = (Get-Date).AddDays(14)$NoExpiry = 9223372036854775807 Start-Transcript -Path "D:\Scripts\Logs\AD-Report-$Date.log"try { # Enabled users with what the first two lists need $Users = Get-ADUser -Filter 'Enabled -eq $true' -SearchBase $Base -Properties LastLogonTimestamp, whenCreated, PasswordNeverExpires, 'msDS-UserPasswordExpiryTimeComputed', Mail if ($Users.Count -lt 10) { throw "Only $($Users.Count) users in $Base, check the OU or the permissions" } $Inactive = $Users | Where-Object { if ($_.LastLogonTimestamp) { [datetime]::FromFileTime($_.LastLogonTimestamp) -lt $Threshold } else { $_.whenCreated -lt $Threshold } } | Select-Object Name, SamAccountName, whenCreated, @{n='LastSignIn';e={ if ($_.LastLogonTimestamp) { [datetime]::FromFileTime($_.LastLogonTimestamp) } else { 'Never' } }} $InactiveComputers = Get-ADComputer -Filter 'Enabled -eq $true' -SearchBase $Base -Properties LastLogonTimestamp, OperatingSystem | Where-Object { $_.LastLogonTimestamp -and [datetime]::FromFileTime($_.LastLogonTimestamp) -lt $Threshold } | Select-Object Name, OperatingSystem, @{n='LastSignIn';e={[datetime]::FromFileTime($_.LastLogonTimestamp)}} $Expiring = $Users | Where-Object { -not $_.PasswordNeverExpires -and $_.'msDS-UserPasswordExpiryTimeComputed' -notin 0, $NoExpiry } | Select-Object Name, SamAccountName, Mail, @{n='Expires';e={[datetime]::FromFileTime($_.'msDS-UserPasswordExpiryTimeComputed')}} | Where-Object { $_.Expires -gt (Get-Date) -and $_.Expires -lt $Warn } | Sort-Object Expires # Disabled with groups (MemberOf leaves out the primary group, Domain Users) $DisabledInGroups = Get-ADUser -Filter 'Enabled -eq $false' -SearchBase $Base -Properties MemberOf | Where-Object { $_.MemberOf.Count -gt 0 } | Select-Object Name, SamAccountName, @{n='Groups';e={ ($_.MemberOf | ForEach-Object { ($_ -split ',')[0] -replace '^CN=' }) -join '; ' }} $Lists = [ordered]@{ 'Inactive-users' = $Inactive; 'Inactive-computers' = $InactiveComputers; 'Passwords-expiring' = $Expiring; 'Disabled-in-groups' = $DisabledInGroups } $Html = foreach ($k in $Lists.Keys) { $Lists[$k] | Export-Csv -Path (Join-Path $Output "$Date-$k.csv") -NoTypeInformation -Encoding UTF8 $Lists[$k] | ConvertTo-Html -Fragment -PreContent "<h2>$k ($(@($Lists[$k]).Count))</h2>" } ConvertTo-Html -Title "AD report $Date" -Body $Html | Out-File -FilePath (Join-Path $Output "$Date-AD-Report.html") -Encoding UTF8}catch { Write-Error $_ Stop-Transcript exit 1}Stop-Transcript$ErrorActionPreference = 'Stop'and thetry/catchwithexit 1: an error stops the script and the task shows it as a failure, instead of leaving an empty file and a "success".- The
$Users.Countcheck is a cheap alarm: if the query returns almost nothing, something is wrong with the OU or the permissions. - Paths always absolute and UNC: the task has no mapped drives and not the working directory you have.
-Encoding UTF8in Windows PowerShell 5.1 writes the BOM, so Excel opens accented names correctly.
4. Test it by hand
First with your own account, which can read the directory too:
& D:\Scripts\AD-Report.ps1Transcript started, output file is D:\Scripts\Logs\AD-Report-2026-11-03.logTranscript stopped, output file is D:\Scripts\Logs\AD-Report-2026-11-03.logGet-ChildItem '\\srv-files\Reports$\AD' | Select-Object Name, LengthName Length---- ------2026-11-03-Inactive-users.csv 48122026-11-03-Inactive-computers.csv 22402026-11-03-Passwords-expiring.csv 15762026-11-03-Disabled-in-groups.csv 33052026-11-03-AD-Report.html 18920Open the HTML and check two or three names from each list against Users and Computers.
5. The scheduled task with the gMSA
$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-NoProfile -NonInteractive -ExecutionPolicy RemoteSigned -File "D:\Scripts\AD-Report.ps1"' -WorkingDirectory 'D:\Scripts'$when = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Monday -At 6:30$runas = New-ScheduledTaskPrincipal -UserId 'CONTOSO\gmsa-reports$' -LogonType PasswordRegister-ScheduledTask -TaskName 'Weekly AD report' -TaskPath '\Contoso\' -Action $action -Trigger $when -Principal $runasStart-ScheduledTask -TaskName 'Weekly AD report' -TaskPath '\Contoso\'-LogonType Passwordwith a gMSA: Task Scheduler fetches the password from AD, you never type it.- Least privilege: the gMSA is in no admin group. Any domain account can read users and computers. It needs the Log on as a batch job right on the server (through GPO, under User Rights Assignment), Modify on the output folder and on
D:\Scripts\Logs, and nothing else. No Run with highest privileges. - If gMSAs cannot be used in your environment, a regular service account with the same permissions and its password in the team's password manager; the gMSA saves you the ticket on the day it expires.
6. The ticket: "the task runs, but the report comes out empty"
Monday, 8:15. The security lead opens the HTML: only the headings, (0) on every list. The CSVs are 0 bytes. The task says it finished fine.
Get-ScheduledTaskInfo -TaskName 'Weekly AD report' -TaskPath '\Contoso\' | Select-Object LastRunTime, LastTaskResultLastRunTime LastTaskResult----------- --------------03/11/2026 6:30:01 0Get-Content D:\Scripts\Logs\AD-Report-2026-11-03.log | Select-String 'Error|not recognized'Get-ADUser : The term 'Get-ADUser' is not recognized as the name of a cmdlet, function, script file, or operable program.LastTaskResult 0 means powershell.exe finished, not that the script did its job. The transcript says the rest: the task was moved on Friday to srv-mgmt02, which does not have the AD module, and the copy of the script used there had no #Requires and no $ErrorActionPreference = 'Stop'. With no module, Get-ADUser fails, the lists stay empty and Export-Csv creates empty files.
The usual causes, most frequent first:
- The module is missing on the server running the task:
Get-Module -ListAvailable ActiveDirectory. - Relative paths or mapped drives: the task starts in
C:\Windows\System32and has noZ:; the real report is in another folder or never gets written. - The OU was renamed and
-SearchBasepoints at one that no longer exists. - Read permissions: an OU with inheritance blocked or an explicit deny returns fewer objects without an error. Compare the count from your account and from the gMSA.
The fix: Install-WindowsFeature RSAT-AD-PowerShell on srv-mgmt02, the version of the script with #Requires, Stop and the count check, and Start-ScheduledTask to confirm. From now on, if something fails, LastTaskResult will be 1 and the monitoring tool will see it.
Next steps
- AD starters and leavers from a CSV closes the loop: the report says who should go and that script handles the leaver with a review step.
- Keep the script in the team's repository and put every change through review.
- To warn each user on the password list, send the email through Microsoft Graph rather than
Send-MailMessage, which Microsoft marks as obsolete.
Is something missing, or different in your environment? Get in touch and I'll fix it.
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.Found this useful? Every two weeks I send one practical idea for IT professionals: LinkedIn, projects, networking and hiring processes.
Apúntate y llévate las 33 ideasSign up and get the 33 ideas →