Get-OldComputers

Getting a List of Old Computers in Active Directory (AD)

In the code below, I (essentially) define an "old computer" as one that hasn't changed it's AD password or logged-in to AD in the last 12 - 18 months. Change the code to match your definition.

If you don't want to keep entering your AD Domain every script execution, I'd change the Domain parameter too.

If you allow delegated admins in your Domain, or if you just want to concentrate on a specific organization unit (OU), you can supply the OU parameter in standard LDAP distinguishedName format.

If you are unfamiliar with using the .Net DataSet and DataTable for an in-memory database, you may find some of this code instructive. Certainly, using the DataTable's Select() member function was far faster than using a PowerShell Where-Object or a Contains. By the way, putting the [void] in front of many of the actions in the code prevents it from emitting whatever return value/object that action normally returns (I didn't want the return value anyway).

  1<#
  2.SYNOPSIS
  3    Generate a report on computers that haven't changed their passwords or logged in the last 12 - 18 months.
  4
  5.DESCRIPTION
  6    In an effort not to require the AD module, this code uses DirectoryServices.
  7    The code uses your current AD identity to query AD.
  8
  9    ASSUMPTIONS:
 10        - You are using a Domain joined machine in the Domain you want to query
 11        - You are logged into the Domain you want to query with proper credentials
 12        - The dsquery.exe EXE is in your path
 13        - If a computer has logged in recently and pwdLastSet is out of date, they must have some 
 14          reason not to change their password
 15
 16.PARAMETER Domain
 17    Defaults to your.domain (TODO: change it!)
 18
 19.PARAMETER OU
 20    The search root instead of the top of the directory
 21
 22.PARAMETER IncludeBitlocker
 23    If you have security rights to view Bitlocker information in your Domain, you can use this switch.
 24    Otherwise, it will not generate an error and return the computer object data without bitlocker info.
 25
 26.EXAMPLE
 27    Get-OldComputers.ps1 | Export-Csv -NotypeInformation -Encoding utf8 -Path .\computers.csv
 28    Get-OldComputers.ps1 | Out-GridView
 29
 30.INPUTS
 31    None
 32
 33.OUTPUTS
 34    An array of DataRow objects that you can export to a CSV or whatever you want.
 35
 36.LINK
 37    None
 38
 39.NOTES
 40    Ver     Date        Who     What
 41    ---     ----        ---     ----
 42    1.0     2024-02-14  Ray     Initial
 43    1.1     2024-02-15  Ray     Added BitLocker and in-memory dataset/datatable
 44    1.2     2024-02-15  Ray     Modified documentation
 45#>
 46[CmdletBinding()]
 47param (
 48    [Parameter(Mandatory=$false)]
 49    [string]$Domain='your.domain',
 50    [Parameter(Mandatory=$false,HelpMessage='Enter something like: *OU=sub_ou,OU=parent_ou,DC=your,DC=domain')]
 51    [string]$OU='',
 52    [Parameter(Mandatory=$false,HelpMessage='Add this switch if you have security rights and wish to know')]
 53    [switch]$IncludeBitlocker
 54)
 55
 56Add-Type -AssemblyName System.DirectoryServices
 57Add-Type -AssemblyName System.Data
 58
 59function GetBitLockerInfo {
 60    # find all the bitlocker records
 61    [string]$BitlockerFilter = '(&(&(objectClass=msFVE-RecoveryInformation)' + 
 62        '(msFVE-RecoveryPassword=*)))'
 63    try {
 64        $script:searcher.Filter = $BitlockerFilter
 65        $script:searcher.SearchRoot = ''
 66        $script:searcher.PropertiesToLoad.Clear()
 67        # The msDS-ParentDistName contains the owner computer's distinguishedName
 68        [void]$script:searcher.PropertiesToLoad.Add('msDS-ParentDistName')
 69        [System.DirectoryServices.SearchResultCollection]$bits = $script:searcher.FindAll()
 70        if ($null -ne $bits -and $bits.Count -gt 0) {
 71            # Many computers have more than one bitlocker key.
 72            # We only need to know if they have one or not.
 73            [string]$select = [string]::Empty
 74            foreach ($b in $bits) {
 75                $select = "distinguishedName = '"
 76                # The data may contain a "'" in it--replace it just in case.
 77                # Since the AD query might contain several objects relating to the same computer
 78                # I created this select query to ignore duplicates.
 79                # TODO: Change this if you want to evaluate the bitlocker keys in more detail
 80                $select += $b.Properties.Item('msds-parentdistname')[0].ToString().Replace("'","''")
 81                $select += "' AND hasbitlocker = FALSE"
 82                $row = $script:ds.Tables[$script:tbl].Select($select)
 83                # Since the distinguishedName is the primary key (and unique), we'll never have more than one record
 84                if ($row.Count -eq 1) {
 85                    $row.BeginEdit()
 86                    $row.Item(0).hasbitlocker = $true
 87                    $row.EndEdit()
 88                }
 89            }
 90        }
 91        else {
 92            Write-Warning 'No BitLocker data found. Do you have permission to read it?'
 93        }
 94    }
 95    catch {
 96        throw
 97    }
 98    finally {
 99        if ($bits) {
100            $bits.Dispose()
101        }
102    }
103}
104
105New-Variable -Name PAGE_SIZE -Value 100 -Option Constant
106[string[]]$PropertiesToLoad = @('distinguishedName','lastLogonTimeStamp','name','operatingSystem','pwdLastSet','sAMAccountName','whenCreated')
107
108
109[datetime]$Jan1970 = Get-Date -Year 1970 -Month 1 -Hour 0 -Minute 0 -Second 0 -Millisecond 0
110[datetime]$Midnight = Get-Date -Hour 0 -Minute 0 -Second 0 -Millisecond 0
111[datetime]$TwelveMonthsAgo = $Midnight.AddMonths(-12)
112[datetime]$EightteenMonthsAgo = $Midnight.AddMonths(-18)
113# TODO: These are arbitrary time limits. Change them to meet your requirements
114# Find all enabled computers with a password last set and a lastlogontimestamp
115# >= 12 months ago and less then 18 months ago
116[string]$TwelveMonthFilter = "(&" +
117    "(objectCategory=Computer)(objectClass=Computer)" +
118    "(!useraccountcontrol:1.2.840.113556.1.4.803:=2)" +
119    "(&" +
120      "(pwdLastSet<=$($TwelveMonthsAgo.ToFileTime()))" +
121      "(pwdLastSet>=$($EightteenMonthsAgo.AddSeconds(-1).ToFileTime()))" +
122    ")" +
123    "(&" +
124      "(lastLogonTimeStamp<=$($TwelveMonthsAgo.ToFileTime()))" +
125      "(lastLogonTimeStamp>=$($EightteenMonthsAgo.AddSeconds(-1).ToFileTime()))" +
126    ")" +
127  ")"
128
129# connect to the domain and create a directory searcher
130$RootDE = New-Object System.DirectoryServices.DirectoryEntry -ArgumentList "LDAP://$($Domain)"
131$script:searcher = New-Object System.DirectoryServices.DirectorySearcher -ArgumentList $RootDE
132try {
133    $script:searcher.Filter = $TwelveMonthFilter
134    $script:searcher.PageSize = $PAGE_SIZE
135    $script:searcher.SearchScope = 'Subtree' # search all sub OUs
136    $script:searcher.PropertiesToLoad.AddRange($PropertiesToLoad) # we only need a few properties
137    if ($OU -ne '') {
138        $dsq = dsquery.exe ou "$($OU)" -scope base
139        if ($null -eq $dsq) {
140            Write-Error "I could not find the OU '($OU)' supplied in the '$($Domain) Domain"
141            throw 'OU not found!'
142        }
143        else {
144            $script:searcher.SearchRoot = $OU
145        }
146    }
147    [System.DirectoryServices.SearchResultCollection]$SearchResult = $script:searcher.FindAll()
148    if ($SearchResult) {
149        [string]$tbl = 'comps'
150        $script:ds = New-Object System.Data.DataSet
151        [void]$script:ds.Tables.Add($tbl)
152        [void]$script:ds.Tables[$tbl].Columns.Add('distinguishedName', [string])
153        [void]$script:ds.Tables[$tbl].Columns.Add('lastLogonTimeStamp', [datetime])
154        [void]$script:ds.Tables[$tbl].Columns.Add('name', [string])
155        [void]$script:ds.Tables[$tbl].Columns.Add('operatingSystem', [string])
156        [void]$script:ds.Tables[$tbl].Columns.Add('ou', [string])
157        [void]$script:ds.Tables[$tbl].Columns.Add('pwdLastSet', [datetime])
158        [void]$script:ds.Tables[$tbl].Columns.Add('sAMAccountName', [string])
159        [void]$script:ds.Tables[$tbl].Columns.Add('whenCreated', [datetime])
160        [void]$script:ds.Tables[$tbl].Columns.Add('hasbitlocker', [bool])
161        $script:ds.Tables[$tbl].Columns['hasbitlocker'].DefaultValue = $false
162        $script:ds.Tables[$tbl].PrimaryKey = $script:ds.Tables[$tbl].Columns['distinguishedName']
163        Write-Information "Number of Computer Objects to possibly disable: $($SearchResult.Count)"
164        [int16]$si = -1
165        $o = New-Object PSObject -Property @{distinguishedName='';lastLogonTimeStamp=$Jan1970;name='';
166            operatingSystem='';ou='';pwdLastSet=$Jan1970;sAMAccountName='';whenCreated=$Jan1970;}
167        foreach ($result in $SearchResult) {
168            $o.distinguishedName = ''
169            $o.lastLogonTimeStamp = $Jan1970
170            $o.name = ''
171            $o.operatingSystem = ''
172            $o.ou = ''
173            $o.pwdLastSet = $Jan1970
174            $o.sAMAccountName = ''
175            $o.whenCreated = $Jan1970
176            $o.distinguishedName = $result.Properties.distinguishedname[0]
177            if ($null -ne $result.Properties.Item('lastlogontimestamp')) {
178                $o.lastLogonTimeStamp = [datetime]::FromFileTime($result.Properties.Item('lastlogontimestamp')[0])
179            }
180            $o.name = $result.Properties.Item('name')[0]
181            if ($result.Properties.Item('operatingsystem')) {
182                $o.operatingSystem = $result.Properties.Item('operatingsystem')[0]
183            }
184            
185            # TODO: change this if your environment differs
186            $si = $result.path.IndexOf('OU=')
187            if ($si -eq -1) {
188                $si = $result.path.IndexOf('CN=Computers,')
189            }
190            $o.ou = $result.path.Substring($si)
191
192            if ($null -ne $result.Properties.Item('pwdlastset')) {
193                $o.pwdLastSet = [datetime]::FromFileTime($result.Properties.Item('pwdlastset')[0])
194            }
195            $o.sAMAccountName = $result.Properties.Item('samaccountname')[0]
196            $o.whenCreated = $result.Properties.Item('whencreated')[0]
197
198            # add the data to the in-memory database
199            $nr = $script:ds.Tables[$tbl].NewRow()
200            $nr['distinguishedName'] = $o.distinguishedName
201            $nr['lastLogonTimeStamp'] = $o.lastLogonTimeStamp
202            $nr['name'] = $o.name
203            $nr['operatingSystem'] = $o.operatingSystem
204            $nr['ou'] = $o.ou
205            $nr['pwdLastSet'] = $o.pwdLastSet
206            $nr['sAMAccountName'] = $o.sAMAccountName
207            $nr['whenCreated'] = $o.whenCreated
208            [void]$script:ds.Tables[$tbl].Rows.Add($nr)
209        }
210        if ($IncludeBitlocker) {
211            GetBitLockerInfo
212        }
213        # Return all the rows
214        $script:ds.Tables[$tbl].Select()
215    }
216}
217catch {
218    throw
219}
220finally {
221    if ($script:ds) {
222        $script:ds.Dispose()
223    }
224    # release our resources
225    if ($RootDE) {
226        $RootDE.Close()
227        $RootDE.Dispose()
228    }
229    if ($script:searcher) {
230        $script:searcher.Dispose()
231    }
232}