Guías · Nivel 2

PowerShell: un informe semanal de Active Directory con cuentas inactivas, contraseñas a punto de caducar y cuentas deshabilitadas con grupos

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.

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.

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 →