8 hours ago8 hr Every AD we inherit has the same ghosts. There are computer accounts for laptops recycled years ago, users who left before the last audit, and "temp" accounts that quietly became permanent. A stale-object report is one of the first things we run in a new environment, and after that we schedule it monthly. Here's the script, plus the caveats that keep you from disabling the PC the front desk still logs into every morning.What you needA domain-joined machine with the RSAT Active Directory module (Import-Module ActiveDirectory).A normal domain user account. It can read these attributes, so you don't need Domain Admin just to report.A share or folder to save the reports to.Why lastLogonTimestamp lies a littleAD has two attributes that matter here.lastLogon is precise but not replicated. Each domain controller keeps its own value, so you'd have to query every DC and take the newest.lastLogonTimestamp is replicated, so one query gives a usable answer, but it's deliberately imprecise. It only updates when the existing value is older than ms-DS-Logon-Time-Sync-Interval (14 days by default) minus a random 0 to 5 days. In practice it can lag the real last logon by roughly 9 to 14 days.For a "not seen in 90 days" report that doesn't matter at all. For "did this user log in yesterday?" it's useless. Pick thresholds well above 14 days.Turning the number into a dateThe attribute is a Windows FILETIME (100-nanosecond intervals since 1 January 1601), and [DateTime]::FromFileTime() converts it to local time. Accounts that have never logged on have no value, and converting 0 gives you a date in 1601, so handle that explicitly. The AD module also exposes a ready-converted LastLogonDate property, which is fine to use. I convert manually so the behaviour is obvious to whoever reads the script after me.The script# Get-StaleADReport.ps1 Import-Module ActiveDirectory $Days = 90 $Cutoff = (Get-Date).AddDays(-$Days) $Stamp = Get-Date -Format 'yyyy-MM-dd' $OutDir = '\\fileserver.contoso.com\IT\Reports\AD' function Convert-LLT($value) { if ($value -and $value -gt 0) { [DateTime]::FromFileTime($value) } else { $null } } $computers = Get-ADComputer -Filter * -Properties LastLogonTimestamp, OperatingSystem, Enabled, Description | ForEach-Object { [pscustomobject]@{ Name = $_.Name Enabled = $_.Enabled OS = $_.OperatingSystem LastLogon = Convert-LLT $_.LastLogonTimestamp Description = $_.Description DN = $_.DistinguishedName } } | Where-Object { -not $_.LastLogon -or $_.LastLogon -lt $Cutoff } | Sort-Object LastLogon $users = Get-ADUser -Filter 'Enabled -eq $true' -Properties LastLogonTimestamp, whenCreated | ForEach-Object { [pscustomobject]@{ SamAccountName = $_.SamAccountName Name = $_.Name LastLogon = Convert-LLT $_.LastLogonTimestamp Created = $_.whenCreated DN = $_.DistinguishedName } } | Where-Object { (-not $_.LastLogon -and $_.Created -lt $Cutoff) -or ($_.LastLogon -and $_.LastLogon -lt $Cutoff) } | Sort-Object LastLogon $computers | Export-Csv "$OutDir\stale-computers-$Stamp.csv" -NoTypeInformation $users | Export-Csv "$OutDir\stale-users-$Stamp.csv" -NoTypeInformation "Stale (> $Days days): $($computers.Count) computers, $($users.Count) enabled users"Look at the user filter. An account that has never logged on but was created yesterday isn't stale, so for those we compare whenCreated instead.Getting the report to someoneWe save dated CSVs to a restricted share so we can compare month to month. If you want it emailed, know that Microsoft has marked Send-MailMessage obsolete and it doesn't handle modern authentication well. For Microsoft 365 tenants we send through Graph's sendMail using an app registration with Mail.Send, scoped with an application access policy so it can only send as the reporting mailbox. A relay you control works too. Either way, attach the CSV or link to the share. Nobody wants hundreds of rows pasted into an email body.Sanity checks before anyone disables anythingPick three machines from the report and look them up in your RMM or endpoint manager. If they've been online recently, something's off. Check whether they're really using a different computer account name.Pick one machine you know is used every day and confirm it's not on the report.Cross-check a handful of users against HR's leaver list.Then go in stages. Disable the object, move it to a "Pending Delete" OU with a note in the description, wait 30 days, and only then delete. Disabling is reversible. Deleting without the Recycle Bin is not.A matching bug that made a report look cleanThis one isn't about lastLogonTimestamp, but it came out of the same kind of cleanup. We were comparing a device export against a client list to find machines assigned to the wrong client. Using -like "*$client*" meant a client called "Riverton" also matched "North Riverton", so devices were silently counted under the wrong client and the report looked fine. Match names exactly, with -eq or a lookup set:# BAD: substring / wildcard match - "Riverton" also matches "North Riverton" $devices | Where-Object { $_.ClientName -like "*$client*" } $devices | Where-Object { $_.ClientName -match $client } # regex, same problem # GOOD: exact, case-insensitive comparison $devices | Where-Object { $_.ClientName -eq $client } # GOOD for large lists: a HashSet with a case-insensitive comparer $known = [System.Collections.Generic.HashSet[string]]::new( [string[]]$clientList.Name, [System.StringComparer]::OrdinalIgnoreCase) $unmatched = $devices | Where-Object { -not $known.Contains($_.ClientName.Trim()) }Accounts that look stale but aren'tService accounts and appliances often authenticate in ways that don't update lastLogonTimestamp the way you'd expect, or they log on rarely. Exclude a known service-account OU or review those by hand.Cluster and virtual objects (cluster name objects, some SQL listener accounts) can look stale while being critical. Read the description and DN before you disable one.Computer password age is a useful second opinion. Domain members change their machine password every 30 days by default, so a PasswordLastSet more than a few months old on a computer account is a strong sign the machine is gone.Hybrid-joined devices may also show up in Entra ID. Clean up both, or the stale device keeps reappearing in your Intune reports.
Create an account or sign in to comment