PowerShell: AD Workstation Cleanup Script version 2.0

Update (2010-08-25): I have posted a newer version of this script.

A little while ago, I posted a PowerShell script that detects old machine accounts in Active Directory, and disables or deletes them, based on certain ages (in days). I’ve continued work on this script, such that it now logs information to Excel about actions (disable or deletion) that it takes. This requires that Excel 2007 be installed on the computer which you are running it on; I have not tested the script with other versions of Excel. I haven’t really made the script very user friendly (eg. taking command-line parameters) yet, because I have pretty much been the sole user of it, so please keep this in mind.

1. Set the DisabledDn variable to an OU where you would like disabled accounts to be moved to
2. In the Main() function, near the bottom, set the LDAP DN of the root container you want to evaluate for old computer accounts

Warning: Use this script at your own risk. I take no responsibility for any negative effects this code may have. This code may delete or disable computer accounts in your Active Directory domain, which could cause service outages. Please make sure you understand what it is doing before use.

Note: This script currently only targets workstation accounts, not servers. This can easily be modified.

The Script

# Author: Trevor Sullivan
# Date: October 28th, 2009
# Lessons learned:
# 1. When using a DirectorySearcher object, property names are lower case
# 2. Must explicitly cast 64-bit integers from AD
# 3. The Excel API is terrible (already knew that)
# 4. DirectoryEntry property names are all lowercase?

${DisabledDn} = 'ou=Disabled,ou=Workstations,dc=subdomain,dc=domain,dc=com'

function DisableOldAccounts(${TargetDn}, ${DisableAge} = 60)
    ${Computers} = GetComputerList ${TargetDn}

    foreach (${Computer} in ${Computers})
        # PwdLastSet is a 64-bit integer that indicates the number of 100-nanosecond intervals since 12:00 AM January 1st, 1601
        # The FromFileTime method converts a 64-bit integer to datetime
        # http://www.rlmueller.net/Integer8Attributes.htm
        ${PwdLastSet} = [DateTime]::FromFileTime([Int64]"$(${Computer}.Properties['pwdlastset'])")
        ${CompAge} = ([DateTime]::Now - $PwdLastSet).Days

        if (${CompAge} -gt ${DisableAge})
            LogMessage "$($Computer.Properties['cn']) age is ${CompAge}. Account will be disabled" 2
            WriteDisabledEntry $Computer.Properties['cn'].Item(0) $CompAge $Computer.Properties['distinguishedname'].Item(0) $DisabledDn
            DisableAccount $Computer.Properties['distinguishedname'].Item(0)
            LogMessage "$($Computer.Properties['cn'].Item(0)) age is ${CompAge}, $($Computer.Properties['pwdlastset'].Item(0)), ${PwdLastSet}" 1

function GetComputerList($TargetDn)
    # Define the LDAP search syntax filter to locate workstation objects. See this link for info: http://msdn.microsoft.com/en-us/library/aa746475(VS.85).aspx
    ${tFilter} = '(&(objectClass=computer)(|(operatingSystem=Windows 2000 Professional)(operatingSystem=Windows XP*)(operatingSystem=Windows 7*)))'

    # Create a DirectorySearcher using filter defined above
    ${Searcher} = New-Object System.DirectoryServices.DirectorySearcher $tFilter
    # Set the search root to the distinguishedName specified in the function parameter
    ${Searcher}.SearchRoot = "LDAP://${TargetDn}"
    # Search current container and all subcontainers
    ${Searcher}.SearchScope = [System.DirectoryServices.SearchScope]::Subtree
    # See this link for info on why this next line is necessary: http://www.eggheadcafe.com/software/aspnet/32967284/searchall-in-ad-ldap-f.aspx
    ${Searcher}.PageSize = 1000
    $Results = $Searcher.FindAll()
    LogMessage "Found $($Results.Count) computer accounts to evaluate for disablement" 1
    return $Results

# Set description on computer account, disable it, and move it to the Disabled OU
function DisableAccount($dn)
    # LogMessage "DisableAccount method called with param: ${dn}" 1
    # Get a reference to the object at <distinguishedName>
    $comp = [adsi]"LDAP://${dn}"
    # Disable the account
    # LogMessage "userAccountControl ($($comp.Name)) is: $($comp.userAccountControl)"
    $comp.userAccountControl = $comp.userAccountControl.Value -bxor 2
    # Write the current date to the description field
    if ($comp.Description -ne '') { LogMessage "Description attribute of ($comp.Name) is set to: $($comp.Description)" 2 }
    $comp.Description = "$(([DateTime]::Now).ToShortDateString())"

    # Uncomment these lines to write changes to Active Directory
    # [Void] $comp.SetInfo()
    # $comp.psbase.MoveTo("LDAP://${DisabledDn}")

# Parameter ($DeleteAge): Days from disable date to delete computer account
function DeleteDisabledAccounts($DeleteAge)
    # Get reference to OU for disabled workstation accounts
    ${DisabledOu} = [adsi]"LDAP://${DisabledDn}"

    ${Searcher} = New-Object System.DirectoryServices.DirectorySearcher '(objectClass=computer)'
    ${Searcher}.SearchRoot = ${DisabledOu}
    ${Searcher}.SearchScope = [System.DirectoryServices.SearchScope]::Subtree
    # Page size is used to return result count > default size limit on domain controllers. See: http://geekswithblogs.net/mnf/archive/2005/12/20/63581.aspx
    ${Searcher}.PageSize = 1000
    LogMessage "Finding computers to evaluate for deletion in container: ${DisabledDn}" 1
    ${Computers} = ${Searcher}.FindAll()

    foreach (${Computer} in ${Computers})
        ${DisableDate} = [DateTime]::Parse(${Computer}.Properties['description'])
        trap {
            LogMessage "Couldn't parse date for $($Computer.Properties['cn'])" 3

        ${CurrentAge} = ([DateTime]::Now - ${DisableDate}).Days
        if (${CurrentAge} -gt ${DeleteAge})
            LogMessage "$(${Computer}.Properties['cn']) age is ${CurrentAge} and will be deleted" 2
            WriteDeletedEntry $Computer.Properties['cn'].Item(0) $CurrentAge $Computer.Properties['distinguishedname'].Item(0) "Note"
            #$DisabledOu.Delete('computer', 'CN=' + ${Computer}.Properties['cn'])
            LogMessage "$(${Computer}.Properties['cn']) age is ${CurrentAge} and will not be deleted" 1

function LogMessage(${tMessage}, ${Severity})
        1 {
            $LogPrefix = "INFO"
            $fgcolor = [ConsoleColor]::Blue
            $bgcolor = [ConsoleColor]::White
        2 {
            $LogPrefix = "WARNING"
            $fgcolor = [ConsoleColor]::Black
            $bgcolor = [ConsoleColor]::Yellow
        3 {
            $LogPrefix = "ERROR"
            $fgcolor = [ConsoleColor]::Yellow
            $bgcolor = [ConsoleColor]::Red
        default {
            $LogPrefix = "DEFAULT"
            $fgcolor = [ConsoleColor]::Black
            $bgcolor = [ConsoleColor]::White

    Add-Content -Path "AD-Workstation-Cleanup.log" -Value "$((Get-Date).ToString()) ${LogPrefix}: ${tMessage}"
    Write-Host -ForegroundColor $fgcolor -BackgroundColor $bgcolor -Object "$((Get-Date).ToString()) ${LogPrefix}: ${tMessage}"

function SetupExcel()
    LogMessage "Setting up Excel logging" 1
    $Global:Excel = New-Object Microsoft.Office.Interop.Excel.ApplicationClass
    $Excel.Visible = $true
    $Global:Workbook = $Excel.Workbooks.Add()

    # Delete 3rd worksheet, cuz we don't really need it

    # Setup worksheet for disabled accounts
    $Global:DisabledLog = $Workbook.Worksheets.Item("Sheet2")
    $DisabledLog.Name = "Disabled"
    $DisabledLog.Cells.Item(1, 1).Value2 = "Date"
    $DisabledLog.Cells.Item(1, 2).Value2 = "Name"
    $DisabledLog.Cells.Item(1, 3).Value2 = "Age"
    $DisabledLog.Cells.Item(1, 4).Value2 = "Source DN"
    $DisabledLog.Cells.Item(1, 5).Value2 = "Destination DN"
    $Global:tDisabledRow = 2

    # Setup worksheet for deleted accounts log
    $Global:DeletedLog = $Workbook.Worksheets.Item("Sheet1")
    $DeletedLog.Name = "Deleted"
    $DeletedLog.Cells.Item(1, 1).Value2 = "Date"
    $DeletedLog.Cells.Item(1, 2).Value2 = "Name"
    $DeletedLog.Cells.Item(1, 3).Value2 = "Age"
    $DeletedLog.Cells.Item(1, 4).Value2 = "DN"
    $DeletedLog.Cells.Item(1, 5).Value2 = "Note"
    $Global:tDeletedRow = 2

# Writes an entry to the global variable used to reference the log for disabled accounts
function WriteDisabledEntry($tName, $tAge, $tSourceDn, $tDestinationDn)
    LogMessage "Writing disabled computer to Excel log: $tName" 1
    Write-Host "Value of tname is $tName"
    Write-Host "Value of tage is $tAge"
    Write-Host "Value of tsourcedn is $tSourceDn"
    Write-Host "Value of tDestinationDn is $tDestinationDn"
    $DisabledLog.Cells.Item($tDisabledRow, 1).Value2 = [DateTime]::Now.ToString()
    $DisabledLog.Cells.Item($tDisabledRow, 2).Value2 = $tName
    $DisabledLog.Cells.Item($tDisabledRow, 3).Value2 = $tAge
    $DisabledLog.Cells.Item($tDisabledRow, 4).Value2 = $tSourceDn
    $DisabledLog.Cells.Item($tDisabledRow, 5).Value2 = $tDestinationDn

# Writes an entry to the global variable used to reference the log for deleted accounts
function WriteDeletedEntry($tName, $tAge, $tDN, $tNote)
    LogMessage "Writing deleted computer to Excel log: $tName" 1
    Write-Host "Value of tName is $tName"
    Write-Host "Value of tAge is $tAge"
    Write-Host "Value of tDN is $tDN"
    Write-Host "Value of tNote is $tNote"
    $DeletedLog.Cells.Item($tDeletedRow,1).Value2 = [DateTime]::Now.ToString()
    $DeletedLog.Cells.Item($tDeletedRow,2).Value2 = $tName.ToString()
    $DeletedLog.Cells.Item($tDeletedRow,3).Value2 = $tAge.ToString()
    $DeletedLog.Cells.Item($tDeletedRow,4).Value2 = $tDN.ToString()
    $DeletedLog.Cells.Item($tDeletedRow,5).Value2 = $tNote.ToString()

function CloseExcel()
    LogMessage "Saving and closing Excel workbook" 1
    $Global:Workbook.SaveAs("AD Workstation Cleanup.xlsx")

function Main()
    LogMessage "Beginning workstation account cleanup script" 1

    # Delete accounts that have been disabled for X days
    #DeleteDisabledAccounts 30

    # Disable accounts that are older than X days
    DisableOldAccounts "dc=subdomain,dc=domain,dc=com" 60

    LogMessage "Completed workstation account cleanup script" 1