GuíasGuides · Nivel 2Level 2

PowerShell: un informe semanal de Active Directory con cuentas inactivas, contraseñas a punto de caducar y cuentas deshabilitadas con gruposPowerShell: a weekly Active Directory report with inactive accounts, passwords about to expire and disabled accounts still in groups

Usuarios y equipos inactivos con LastLogonTimestamp y sus límites, contraseñas que caducan en 14 días con msDS-UserPasswordExpiryTimeComputed, cuentas deshabilitadas que siguen en grupos, CSV y HTML, una tarea programada con una gMSA de mínimo privilegio y el informe que sale vacío.Inactive users and computers with LastLogonTimestamp and its limits, passwords expiring within 14 days with msDS-UserPasswordExpiryTimeComputed, disabled accounts still in groups, CSV and HTML, a scheduled task with a least-privilege gMSA, and the report that comes out empty.

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$\AD con permiso de Modificar para la gMSA y de Lectura para quien recibe el informe.
  • Lo básico del script de inventario remoto: objetos, Select-Object y Export-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:

PowerShell · srv-gestion01
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:02

El 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

VS Code · D:\Scripts\Informe-AD.ps1
#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 el try/catch con exit 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.Count es 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 UTF8 en 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:

PowerShell · srv-gestion01
& 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                  18920

Abre 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

PowerShell (administrador) · srv-gestion01
$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 Password con 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.

PowerShell (administrador) · srv-gestion02
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\System32 y sin Z:; el informe bueno está en otra carpeta o no se escribe.
  • La OU ha cambiado de nombre y -SearchBase apunta 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$\AD with Modify for the gMSA and Read for whoever receives the report.
  • The basics from the remote inventory script: objects, Select-Object and Export-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:

PowerShell · srv-mgmt01
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:02

The 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

VS Code · D:\Scripts\AD-Report.ps1
#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 the try/catch with exit 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.Count check 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 UTF8 in 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:

PowerShell · srv-mgmt01
& 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                 18920

Open the HTML and check two or three names from each list against Users and Computers.

5. The scheduled task with the gMSA

PowerShell (administrator) · srv-mgmt01
$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 Password with 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.

PowerShell (administrator) · srv-mgmt02
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\System32 and has no Z:; the real report is in another folder or never gets written.
  • The OU was renamed and -SearchBase points 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 →