Loading_
Loading_
Identifies dormant user and computer accounts, stages them through disable and OU move, then reports on the change — with a mandatory dry run.
#Requires -Modules ActiveDirectory<#.SYNOPSIS Stages dormant AD accounts through disable -> quarantine -> report. .DESCRIPTION Uses LastLogonTimestamp (replicated attribute) so a single DC query is accurate to within msDS-LogonTimeSyncInterval (default 14 days). Never deletes. Deletion is handled by Remove-QuarantinedAccounts.ps1 against objects whose quarantine period has elapsed. .PARAMETER InactiveDays Days since last logon before an account is considered stale. .PARAMETER QuarantineOU Distinguished name of the OU that staged accounts are moved into. .PARAMETER WhatIf Dry run. Strongly recommended for the first execution. .EXAMPLE .\Invoke-StaleAccountCleanup.ps1 -InactiveDays 120 -WhatIf .NOTES Author : AIInfraEngine Version: 3.2.0#>[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]param( [ValidateRange(30, 3650)] [int]$InactiveDays = 120, [Parameter(Mandatory)] [string]$QuarantineOU, [string[]]$ExcludedOUs = @('OU=Service Accounts,DC=corp,DC=local'), [string[]]$ExcludedAccounts = @('svc-backup', 'brk-glass-01', 'krbtgt'), [ValidateSet('User', 'Computer', 'Both')] [string]$ObjectType = 'Both', [string]$ReportPath = ".\stale-accounts-$(Get-Date -Format 'yyyyMMdd-HHmmss').csv") begin { $ErrorActionPreference = 'Stop' $cutoff = (Get-Date).AddDays(-$InactiveDays) $results = [System.Collections.Generic.List[object]]::new() Write-Verbose "Cutoff date: $cutoff" if (-not (Get-ADOrganizationalUnit -Identity $QuarantineOU -ErrorAction SilentlyContinue)) { throw "Quarantine OU '$QuarantineOU' does not exist. Create it before running." }} process { $targets = @() if ($ObjectType -in 'User', 'Both') { $targets += Get-ADUser -Filter { Enabled -eq $true -and LastLogonTimestamp -lt $cutoff } -Properties LastLogonTimestamp, Description, DistinguishedName, whenCreated | Select-Object *, @{ n = 'ObjectClass'; e = { 'user' } } } if ($ObjectType -in 'Computer', 'Both') { $targets += Get-ADComputer -Filter { Enabled -eq $true -and LastLogonTimestamp -lt $cutoff } -Properties LastLogonTimestamp, Description, DistinguishedName, whenCreated, OperatingSystem | Select-Object *, @{ n = 'ObjectClass'; e = { 'computer' } } } Write-Verbose "Found $($targets.Count) candidate object(s) before exclusions." foreach ($obj in $targets) { # --- Exclusions ------------------------------------------------- if ($ExcludedAccounts -contains $obj.SamAccountName.TrimEnd('$')) { Write-Verbose "Skipping $($obj.SamAccountName): on the account allow-list." continue } if ($ExcludedOUs | Where-Object { $obj.DistinguishedName -like "*$_" }) { Write-Verbose "Skipping $($obj.SamAccountName): protected OU." continue } # Guard against freshly-created objects that have never logged on if ($obj.whenCreated -gt $cutoff) { Write-Verbose "Skipping $($obj.SamAccountName): created after the cutoff." continue } $lastLogon = if ($obj.LastLogonTimestamp) { [DateTime]::FromFileTime($obj.LastLogonTimestamp) } else { $obj.whenCreated } $stamp = "Staged by AIInfraEngine $(Get-Date -Format 'yyyy-MM-dd'); last logon $($lastLogon.ToString('yyyy-MM-dd'))" if ($PSCmdlet.ShouldProcess($obj.DistinguishedName, 'Disable and move to quarantine')) { try { Set-ADObject -Identity $obj.DistinguishedName -Description $stamp Disable-ADAccount -Identity $obj.DistinguishedName Move-ADObject -Identity $obj.DistinguishedName -TargetPath $QuarantineOU $status = 'Staged' } catch { Write-Warning "Failed on $($obj.SamAccountName): $($_.Exception.Message)" $status = "Error: $($_.Exception.Message)" } } else { $status = 'WhatIf' } $results.Add([pscustomobject]@{ SamAccountName = $obj.SamAccountName ObjectClass = $obj.ObjectClass LastLogon = $lastLogon DaysInactive = [math]::Round(((Get-Date) - $lastLogon).TotalDays) OriginalOU = ($obj.DistinguishedName -split ',', 2)[1] Status = $status }) }} end { $results | Sort-Object DaysInactive -Descending | Export-Csv -Path $ReportPath -NoTypeInformation Write-Host "" Write-Host " Candidates : $($results.Count)" -ForegroundColor Cyan Write-Host " Staged : $(($results | Where-Object Status -eq 'Staged').Count)" -ForegroundColor Green Write-Host " Errors : $(($results | Where-Object Status -like 'Error*').Count)" -ForegroundColor Red Write-Host " Report : $ReportPath" -ForegroundColor Cyan Write-Host ""}Stale accounts are the quietest form of attack surface. This script finds them using LastLogonTimestamp (replicated, so it is safe to query a single DC) rather than LastLogon, and never deletes anything on the first pass.
Accounts are staged: disabled, stamped with the date and reason in the Description attribute, then moved to a quarantine OU. Deletion is a separate, deliberate run against objects that have already served their quarantine period.
Service accounts, break-glass accounts and anything in a protected OU are excluded via an explicit allow-list, because the cost of disabling the wrong account is far higher than leaving one stale account alive for another week.
| Name | Type | Required | Description |
|---|---|---|---|
InactiveDays | int | Optional | Days since last logon before an account is stale. Default 120. |
QuarantineOU | string | Required | DN of the OU staged accounts are moved to. |
ExcludedOUs | string[] | Optional | OU suffixes that are never touched. |
ObjectType | User|Computer|Both | Optional | Which object classes to process. |
The platform turns any script into a governed automation — versioned, gated, audited and reversible.