Private/AD/Core/Get-ADStaleObjects.ps1
|
# PSGuerrilla - Jim Tyler, Microsoft MVP - CC BY 4.0 # https://github.com/jimrtyler/PSGuerrilla | https://creativecommons.org/licenses/by/4.0/ # AI/LLM use: see AI-USAGE.md for required attribution function Get-ADStaleObjects { [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$Connection, [int]$InactiveDays = 90, [int]$PasswordAgeDays = 365, [switch]$Quiet ) $result = @{ InactiveUsers = @() InactiveComputers = @() DisabledWithGroups = @() ExpiredNotDisabled = @() ObsoleteOSComputers = @() UnsupportedOSComputers = @() OrphanedFSPs = @() OrphanedSIDHistory = @() AbandonedOUs = @() PrinterObjects = @() StaleDNSRecords = @() TotalUsers = 0 TotalComputers = 0 TotalDisabled = 0 Errors = @{} } $domainDN = $Connection.DomainDN $now = [datetime]::UtcNow $inactiveThreshold = $now.AddDays(-$InactiveDays) $passwordAgeThreshold = $now.AddDays(-$PasswordAgeDays) # Convert thresholds to Windows FileTime for LDAP comparison $inactiveFileTime = $inactiveThreshold.ToFileTimeUtc() $passwordFileTime = $passwordAgeThreshold.ToFileTimeUtc() # Common properties for user queries $userProperties = @( 'samaccountname', 'distinguishedname', 'useraccountcontrol', 'lastlogontimestamp', 'pwdlastset', 'whencreated', 'memberof', 'description' ) # Common properties for computer queries $computerProperties = @( 'samaccountname', 'distinguishedname', 'useraccountcontrol', 'lastlogontimestamp', 'pwdlastset', 'whencreated', 'operatingsystem', 'operatingsystemversion', 'description', 'dnshostname' ) # ── Helper: Convert LDAP user result to output hashtable ──────────── function ConvertTo-UserObject { param([hashtable]$Obj) $uac = [int]($Obj['useraccountcontrol'] ?? 0) $memberOf = @() if ($Obj.ContainsKey('memberof')) { $raw = $Obj['memberof'] $memberOf = if ($raw -is [array]) { @($raw) } else { @($raw) } } @{ SamAccountName = $Obj['samaccountname'] ?? '' DN = $Obj['distinguishedname'] ?? '' LastLogon = $Obj['lastlogontimestamp'] PwdLastSet = $Obj['pwdlastset'] WhenCreated = if ($Obj.ContainsKey('whencreated')) { $Obj['whencreated'] } else { $null } MemberOf = $memberOf Enabled = ($uac -band 0x0002) -eq 0 Description = if ($Obj.ContainsKey('description')) { $Obj['description'] } else { '' } } } function ConvertTo-ComputerObject { param([hashtable]$Obj) $uac = [int]($Obj['useraccountcontrol'] ?? 0) @{ SamAccountName = $Obj['samaccountname'] ?? '' DN = $Obj['distinguishedname'] ?? '' LastLogon = $Obj['lastlogontimestamp'] PwdLastSet = $Obj['pwdlastset'] WhenCreated = if ($Obj.ContainsKey('whencreated')) { $Obj['whencreated'] } else { $null } OperatingSystem = if ($Obj.ContainsKey('operatingsystem')) { $Obj['operatingsystem'] } else { '' } OSVersion = if ($Obj.ContainsKey('operatingsystemversion')) { $Obj['operatingsystemversion'] } else { '' } DNSHostName = if ($Obj.ContainsKey('dnshostname')) { $Obj['dnshostname'] } else { '' } Enabled = ($uac -band 0x0002) -eq 0 Description = if ($Obj.ContainsKey('description')) { $Obj['description'] } else { '' } } } # ── 1. Total counts ───────────────────────────────────────────────── if (-not $Quiet) { Write-ProgressLine -Phase RECON -Message 'Counting domain objects' } try { $searchRoot = New-LdapSearchRoot -Connection $Connection -SearchBase $domainDN # Total users $totalUsers = Invoke-LdapQuery -SearchRoot $searchRoot ` -Filter '(&(objectCategory=person)(objectClass=user))' ` -Properties @('distinguishedname') $result.TotalUsers = $totalUsers.Count # Total computers $totalComputers = Invoke-LdapQuery -SearchRoot $searchRoot ` -Filter '(objectCategory=computer)' ` -Properties @('distinguishedname') $result.TotalComputers = $totalComputers.Count # Total disabled $totalDisabled = Invoke-LdapQuery -SearchRoot $searchRoot ` -Filter '(&(|(objectCategory=person)(objectCategory=computer))(objectClass=user)(userAccountControl:1.2.840.113556.1.4.803:=2))' ` -Properties @('distinguishedname') $result.TotalDisabled = $totalDisabled.Count if (-not $Quiet) { Write-ProgressLine -Phase RECON -Message "Domain totals: $($result.TotalUsers) users, $($result.TotalComputers) computers, $($result.TotalDisabled) disabled" } } catch { Write-Warning "Failed to count domain objects: $_" $result.Errors['TotalCounts'] = $_.Exception.Message } # ── 2. Inactive users ─────────────────────────────────────────────── if (-not $Quiet) { Write-ProgressLine -Phase RECON -Message "Finding users inactive for $InactiveDays+ days" } try { $searchRoot = New-LdapSearchRoot -Connection $Connection -SearchBase $domainDN # Enabled users with lastLogonTimestamp older than threshold # The LDAP filter excludes disabled accounts (bit 0x2 NOT set) $inactiveUserResults = Invoke-LdapQuery -SearchRoot $searchRoot ` -Filter "(&(objectCategory=person)(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2))(lastLogonTimestamp<=$inactiveFileTime))" ` -Properties $userProperties $inactiveUsers = [System.Collections.Generic.List[hashtable]]::new() foreach ($user in $inactiveUserResults) { $inactiveUsers.Add((ConvertTo-UserObject -Obj $user)) } # Also catch users who have NEVER logged in (no lastLogonTimestamp) $neverLoggedIn = Invoke-LdapQuery -SearchRoot $searchRoot ` -Filter '(&(objectCategory=person)(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2))(!(lastLogonTimestamp=*)))' ` -Properties $userProperties foreach ($user in $neverLoggedIn) { # Only include if account was created before the threshold $created = if ($user.ContainsKey('whencreated')) { $user['whencreated'] } else { $null } if ($null -ne $created -and $created -is [datetime] -and $created -lt $inactiveThreshold) { $inactiveUsers.Add((ConvertTo-UserObject -Obj $user)) } } $result.InactiveUsers = @($inactiveUsers) if (-not $Quiet) { Write-ProgressLine -Phase RECON -Message "Found $($inactiveUsers.Count) inactive user(s)" } } catch { Write-Warning "Failed to query inactive users: $_" $result.Errors['InactiveUsers'] = $_.Exception.Message } # ── 3. Inactive computers ─────────────────────────────────────────── if (-not $Quiet) { Write-ProgressLine -Phase RECON -Message "Finding computers inactive for $InactiveDays+ days" } try { $searchRoot = New-LdapSearchRoot -Connection $Connection -SearchBase $domainDN $inactiveComputerResults = Invoke-LdapQuery -SearchRoot $searchRoot ` -Filter "(&(objectCategory=computer)(!(userAccountControl:1.2.840.113556.1.4.803:=2))(lastLogonTimestamp<=$inactiveFileTime))" ` -Properties $computerProperties $inactiveComputers = [System.Collections.Generic.List[hashtable]]::new() foreach ($comp in $inactiveComputerResults) { $inactiveComputers.Add((ConvertTo-ComputerObject -Obj $comp)) } # Computers that have never checked in $neverCheckedIn = Invoke-LdapQuery -SearchRoot $searchRoot ` -Filter '(&(objectCategory=computer)(!(userAccountControl:1.2.840.113556.1.4.803:=2))(!(lastLogonTimestamp=*)))' ` -Properties $computerProperties foreach ($comp in $neverCheckedIn) { $created = if ($comp.ContainsKey('whencreated')) { $comp['whencreated'] } else { $null } if ($null -ne $created -and $created -is [datetime] -and $created -lt $inactiveThreshold) { $inactiveComputers.Add((ConvertTo-ComputerObject -Obj $comp)) } } $result.InactiveComputers = @($inactiveComputers) if (-not $Quiet) { Write-ProgressLine -Phase RECON -Message "Found $($inactiveComputers.Count) inactive computer(s)" } } catch { Write-Warning "Failed to query inactive computers: $_" $result.Errors['InactiveComputers'] = $_.Exception.Message } # ── 4. Disabled accounts with group memberships ───────────────────── if (-not $Quiet) { Write-ProgressLine -Phase RECON -Message 'Finding disabled accounts with group memberships' } try { $searchRoot = New-LdapSearchRoot -Connection $Connection -SearchBase $domainDN $disabledWithMemberships = Invoke-LdapQuery -SearchRoot $searchRoot ` -Filter '(&(|(objectCategory=person)(objectCategory=computer))(objectClass=user)(userAccountControl:1.2.840.113556.1.4.803:=2)(memberOf=*))' ` -Properties @('samaccountname', 'distinguishedname', 'useraccountcontrol', 'memberof', 'objectcategory') $disabledWithGroups = [System.Collections.Generic.List[hashtable]]::new() foreach ($obj in $disabledWithMemberships) { $memberOf = @() if ($obj.ContainsKey('memberof')) { $raw = $obj['memberof'] $memberOf = if ($raw -is [array]) { @($raw) } else { @($raw) } } # Filter out Domain Users (everyone is a member) — check by CN $significantGroups = @($memberOf | Where-Object { $_ -notmatch '^CN=Domain Users,' -and $_ -notmatch '^CN=Domain Computers,' }) if ($significantGroups.Count -gt 0) { $disabledWithGroups.Add(@{ SamAccountName = $obj['samaccountname'] ?? '' DN = $obj['distinguishedname'] ?? '' GroupCount = $significantGroups.Count Groups = $significantGroups ObjectCategory = if ($obj.ContainsKey('objectcategory')) { $obj['objectcategory'] } else { '' } }) } } $result.DisabledWithGroups = @($disabledWithGroups) if (-not $Quiet) { Write-ProgressLine -Phase RECON -Message "Found $($disabledWithGroups.Count) disabled account(s) with group memberships" } } catch { Write-Warning "Failed to query disabled accounts with groups: $_" $result.Errors['DisabledWithGroups'] = $_.Exception.Message } # ── 5. Expired passwords (not disabled) ───────────────────────────── if (-not $Quiet) { Write-ProgressLine -Phase RECON -Message "Finding accounts with passwords older than $PasswordAgeDays days" } try { $searchRoot = New-LdapSearchRoot -Connection $Connection -SearchBase $domainDN # Enabled users whose password was last set before the threshold # Exclude accounts with password-never-expires (0x10000) $oldPasswordResults = Invoke-LdapQuery -SearchRoot $searchRoot ` -Filter "(&(objectCategory=person)(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2))(!(userAccountControl:1.2.840.113556.1.4.803:=65536))(pwdLastSet<=$passwordFileTime)(pwdLastSet>=1))" ` -Properties $userProperties $expiredNotDisabled = [System.Collections.Generic.List[hashtable]]::new() foreach ($user in $oldPasswordResults) { $expiredNotDisabled.Add((ConvertTo-UserObject -Obj $user)) } $result.ExpiredNotDisabled = @($expiredNotDisabled) if (-not $Quiet) { Write-ProgressLine -Phase RECON -Message "Found $($expiredNotDisabled.Count) account(s) with passwords older than $PasswordAgeDays days" } } catch { Write-Warning "Failed to query expired password accounts: $_" $result.Errors['ExpiredNotDisabled'] = $_.Exception.Message } # ── 6. Obsolete and unsupported OS computers ──────────────────────── if (-not $Quiet) { Write-ProgressLine -Phase RECON -Message 'Identifying computers with obsolete operating systems' } try { $searchRoot = New-LdapSearchRoot -Connection $Connection -SearchBase $domainDN # Query all computers with an operatingSystem attribute $allOSComputers = Invoke-LdapQuery -SearchRoot $searchRoot ` -Filter '(&(objectCategory=computer)(operatingSystem=*))' ` -Properties $computerProperties # Obsolete OS patterns (severely outdated - pre-2012 R2) $obsoletePatterns = @( 'Windows XP' 'Windows Vista' 'Windows 7' 'Windows 8' 'Windows Server 2000' 'Windows Server 2003' 'Windows Server 2008' 'Windows 2000' 'Windows 2003' ) # Unsupported OS patterns (end of extended support - 2012 R2 and older, includes all obsolete) $unsupportedPatterns = $obsoletePatterns + @( 'Windows Server 2012' 'Windows 8\.1' ) $obsoleteComputers = [System.Collections.Generic.List[hashtable]]::new() $unsupportedComputers = [System.Collections.Generic.List[hashtable]]::new() foreach ($comp in $allOSComputers) { $os = if ($comp.ContainsKey('operatingsystem')) { $comp['operatingsystem'] } else { '' } if ([string]::IsNullOrWhiteSpace($os)) { continue } $compObj = ConvertTo-ComputerObject -Obj $comp # Check obsolete (worst offenders) $isObsolete = $false foreach ($pattern in $obsoletePatterns) { if ($os -match $pattern) { $isObsolete = $true break } } # Check unsupported (includes obsolete + 2012/2012R2 + Windows 8.1) $isUnsupported = $false foreach ($pattern in $unsupportedPatterns) { if ($os -match $pattern) { $isUnsupported = $true break } } if ($isObsolete) { $obsoleteComputers.Add($compObj) } if ($isUnsupported) { $unsupportedComputers.Add($compObj) } } $result.ObsoleteOSComputers = @($obsoleteComputers) $result.UnsupportedOSComputers = @($unsupportedComputers) if (-not $Quiet) { Write-ProgressLine -Phase RECON -Message "Found $($obsoleteComputers.Count) obsolete OS, $($unsupportedComputers.Count) unsupported OS computer(s)" } } catch { Write-Warning "Failed to query OS information: $_" $result.Errors['OSComputers'] = $_.Exception.Message } # ── 7. Orphaned Foreign Security Principals ───────────────────────── if (-not $Quiet) { Write-ProgressLine -Phase RECON -Message 'Checking for orphaned foreign security principals' } try { $fspDN = "CN=ForeignSecurityPrincipals,$domainDN" $fspRoot = New-LdapSearchRoot -Connection $Connection -SearchBase $fspDN $lookupRoot = New-LdapSearchRoot -Connection $Connection -SearchBase $domainDN $fspResults = Invoke-LdapQuery -SearchRoot $fspRoot ` -Filter '(objectClass=foreignSecurityPrincipal)' ` -Properties @('cn', 'distinguishedName', 'objectSid') ` -Scope OneLevel $orphanedFSPs = [System.Collections.Generic.List[hashtable]]::new() foreach ($fsp in $fspResults) { $sid = $fsp['cn'] ?? '' if ([string]::IsNullOrWhiteSpace($sid)) { continue } # Try to resolve the SID $resolved = Resolve-ADSid -SidString $sid -SearchRoot $lookupRoot # If the resolved name is still the SID string, it is orphaned if ($resolved -eq $sid) { $orphanedFSPs.Add(@{ SID = $sid DN = $fsp['distinguishedname'] ?? '' }) } } $result.OrphanedFSPs = @($orphanedFSPs) if (-not $Quiet) { Write-ProgressLine -Phase RECON -Message "Found $($orphanedFSPs.Count) orphaned FSP(s) out of $($fspResults.Count) total" } } catch { Write-Verbose "Failed to check foreign security principals: $_" $result.Errors['OrphanedFSPs'] = $_.Exception.Message } # ── 8. Orphaned SID History ───────────────────────────────────────── if (-not $Quiet) { Write-ProgressLine -Phase RECON -Message 'Checking for orphaned SID history entries' } try { $searchRoot = New-LdapSearchRoot -Connection $Connection -SearchBase $domainDN $lookupRoot = New-LdapSearchRoot -Connection $Connection -SearchBase $domainDN $sidHistoryResults = Invoke-LdapQuery -SearchRoot $searchRoot ` -Filter '(sIDHistory=*)' ` -Properties @('samaccountname', 'distinguishedname', 'sidhistory', 'objectclass') $orphanedSIDHistory = [System.Collections.Generic.List[hashtable]]::new() foreach ($obj in $sidHistoryResults) { $sidHistoryValues = @() if ($obj.ContainsKey('sidhistory')) { $raw = $obj['sidhistory'] $sidHistoryValues = if ($raw -is [array]) { @($raw) } else { @($raw) } } $orphanedEntries = [System.Collections.Generic.List[string]]::new() foreach ($sidValue in $sidHistoryValues) { # SID may already be a string from Convert-LdapValue $sidString = if ($sidValue -is [string]) { $sidValue } else { $sidValue.ToString() } $resolved = Resolve-ADSid -SidString $sidString -SearchRoot $lookupRoot if ($resolved -eq $sidString) { $orphanedEntries.Add($sidString) } } if ($orphanedEntries.Count -gt 0) { $orphanedSIDHistory.Add(@{ SamAccountName = $obj['samaccountname'] ?? '' DN = $obj['distinguishedname'] ?? '' OrphanedSIDs = @($orphanedEntries) TotalSIDHistory = $sidHistoryValues.Count }) } } $result.OrphanedSIDHistory = @($orphanedSIDHistory) if (-not $Quiet) { Write-ProgressLine -Phase RECON -Message "Found $($orphanedSIDHistory.Count) object(s) with orphaned SID history" } } catch { Write-Warning "Failed to check SID history: $_" $result.Errors['OrphanedSIDHistory'] = $_.Exception.Message } # ── 9. Abandoned OUs ──────────────────────────────────────────────── if (-not $Quiet) { Write-ProgressLine -Phase RECON -Message 'Checking for abandoned (empty) OUs' } try { $searchRoot = New-LdapSearchRoot -Connection $Connection -SearchBase $domainDN $allOUs = Invoke-LdapQuery -SearchRoot $searchRoot ` -Filter '(objectClass=organizationalUnit)' ` -Properties @('distinguishedName', 'ou', 'description', 'whenCreated') $abandonedOUs = [System.Collections.Generic.List[hashtable]]::new() foreach ($ou in $allOUs) { $ouDN = $ou['distinguishedname'] ?? '' if ([string]::IsNullOrWhiteSpace($ouDN)) { continue } try { $ouRoot = New-LdapSearchRoot -Connection $Connection -SearchBase $ouDN $childResults = Invoke-LdapQuery -SearchRoot $ouRoot ` -Filter '(objectClass=*)' ` -Properties @('distinguishedname') ` -Scope OneLevel ` -SizeLimit 1 if ($childResults.Count -eq 0) { $abandonedOUs.Add(@{ DN = $ouDN Name = if ($ou.ContainsKey('ou')) { $ou['ou'] } else { '' } Description = if ($ou.ContainsKey('description')) { $ou['description'] } else { '' } WhenCreated = if ($ou.ContainsKey('whencreated')) { $ou['whencreated'] } else { $null } }) } } catch { Write-Verbose "Failed to check children of OU $ouDN`: $_" } } $result.AbandonedOUs = @($abandonedOUs) if (-not $Quiet) { Write-ProgressLine -Phase RECON -Message "Found $($abandonedOUs.Count) abandoned OU(s) out of $($allOUs.Count) total" } } catch { Write-Warning "Failed to query OUs: $_" $result.Errors['AbandonedOUs'] = $_.Exception.Message } # ── 10. Printer objects ───────────────────────────────────────────── if (-not $Quiet) { Write-ProgressLine -Phase RECON -Message 'Enumerating printer objects in AD' } try { $searchRoot = New-LdapSearchRoot -Connection $Connection -SearchBase $domainDN $printerResults = Invoke-LdapQuery -SearchRoot $searchRoot ` -Filter '(objectClass=printQueue)' ` -Properties @( 'cn', 'distinguishedName', 'printerName', 'serverName', 'uNCName', 'portName', 'driverName', 'whenCreated' ) $printerObjects = [System.Collections.Generic.List[hashtable]]::new() foreach ($printer in $printerResults) { $printerObjects.Add(@{ Name = $printer['cn'] ?? '' DN = $printer['distinguishedname'] ?? '' PrinterName = if ($printer.ContainsKey('printername')) { $printer['printername'] } else { '' } ServerName = if ($printer.ContainsKey('servername')) { $printer['servername'] } else { '' } UNCName = if ($printer.ContainsKey('uncname')) { $printer['uncname'] } else { '' } PortName = if ($printer.ContainsKey('portname')) { $printer['portname'] } else { '' } DriverName = if ($printer.ContainsKey('drivername')) { $printer['drivername'] } else { '' } WhenCreated = if ($printer.ContainsKey('whencreated')) { $printer['whencreated'] } else { $null } }) } $result.PrinterObjects = @($printerObjects) if (-not $Quiet) { Write-ProgressLine -Phase RECON -Message "Found $($printerObjects.Count) printer object(s)" } } catch { Write-Warning "Failed to enumerate printer objects: $_" $result.Errors['PrinterObjects'] = $_.Exception.Message } # ── 11. Stale DNS records ─────────────────────────────────────────── if (-not $Quiet) { Write-ProgressLine -Phase RECON -Message 'Checking for stale DNS records' } try { $domainName = ($domainDN -replace '^DC=', '' -replace ',DC=', '.').ToLower() $forestDN = $Connection.ForestDN # Try AD-integrated DNS zone containers $dnsContainers = @( "DC=$domainName,CN=MicrosoftDNS,DC=DomainDnsZones,$domainDN" "CN=MicrosoftDNS,DC=DomainDnsZones,$domainDN" ) $staleDNSRecords = [System.Collections.Generic.List[hashtable]]::new() foreach ($dnsContainer in $dnsContainers) { try { $dnsRoot = New-LdapSearchRoot -Connection $Connection -SearchBase $dnsContainer $dnsRecords = Invoke-LdapQuery -SearchRoot $dnsRoot ` -Filter '(&(objectClass=dnsNode)(!(dc=@))(!(dc=_*)))' ` -Properties @('dc', 'distinguishedName', 'dnsRecord', 'whenChanged', 'dNSTombstoned') ` -Scope Subtree foreach ($record in $dnsRecords) { $recordName = if ($record.ContainsKey('dc')) { $record['dc'] } else { '' } $whenChanged = if ($record.ContainsKey('whenchanged')) { $record['whenchanged'] } else { $null } $tombstoned = if ($record.ContainsKey('dnstombstoned')) { $record['dnstombstoned'] } else { $false } # Stale if not changed within InactiveDays or tombstoned $isStale = $false if ($tombstoned -eq $true) { $isStale = $true } elseif ($null -ne $whenChanged -and $whenChanged -is [datetime]) { $isStale = $whenChanged -lt $inactiveThreshold } if ($isStale) { $staleDNSRecords.Add(@{ Name = $recordName DN = $record['distinguishedname'] ?? '' WhenChanged = $whenChanged Tombstoned = [bool]$tombstoned Container = $dnsContainer }) } } # If we successfully queried one container, break out if ($dnsRecords.Count -ge 0) { break } } catch { Write-Verbose "DNS container not accessible: $dnsContainer - $_" continue } } $result.StaleDNSRecords = @($staleDNSRecords) if (-not $Quiet) { Write-ProgressLine -Phase RECON -Message "Found $($staleDNSRecords.Count) stale DNS record(s)" } } catch { Write-Verbose "Failed to check stale DNS records: $_" $result.Errors['StaleDNSRecords'] = $_.Exception.Message } # ── Summary ───────────────────────────────────────────────────────── if (-not $Quiet) { $summary = "Stale object analysis complete: " + "$($result.InactiveUsers.Count) inactive users, " + "$($result.InactiveComputers.Count) inactive computers, " + "$($result.ObsoleteOSComputers.Count) obsolete OS, " + "$($result.AbandonedOUs.Count) empty OUs" if ($result.Errors.Count -gt 0) { $summary += " ($($result.Errors.Count) error(s))" } Write-ProgressLine -Phase RECON -Message $summary } return $result } |