src/Private/Get-AdAccountSignals.ps1
|
function Get-AdAccountSignals { <# .SYNOPSIS Collects read-only lifecycle signals for user accounts from on-premises Active Directory. .DESCRIPTION Uses .NET System.DirectoryServices.DirectorySearcher directly - NO RSAT ActiveDirectory module required - so it runs on a stock domain-joined machine or DC. Read-only: it only issues an LDAP search and never writes. Emits one object per enabled-or-disabled user account with the attributes needed to decide whether the account is "dead" (disabled or stale). Service/computer accounts are excluded by the (objectCategory=person)(objectClass=user) filter. Note: lastLogonTimestamp is replicated with up to ~14 days of latency by design, which is immaterial for a staleness window measured in months but is documented in docs/permissions.md. .OUTPUTS PSCustomObject: SamAccountName, UserPrincipalName, DisplayName, ObjectGuid, ObjectSid, DistinguishedName, Domain (DNS of the searched domain), Enabled, UserAccountControl, LastLogonTimestamp (DateTime?), WhenChanged (DateTime?), WhenCreated (DateTime?) #> [CmdletBinding()] param( # LDAP search base, e.g. 'OU=Users,DC=contoso,DC=com'. Defaults to the current domain root. [string] $SearchBase, # Optional explicit server / domain to bind to. Defaults to the machine's domain (RootDSE). [string] $Server, # Page size for the directory search. [int] $PageSize = 1000 ) # Must bind RootDSE (not the bare server) to read defaultNamingContext: 'LDAP://<server>' alone # binds the domain object, which doesn't carry that constructed attribute. Get-AdRootDsePath adds # the required '/RootDSE' suffix - critical for forest mode, where every call names a -Server. $rootPath = Get-AdRootDsePath -Server $Server if (-not $SearchBase) { $rootDse = New-Object System.DirectoryServices.DirectoryEntry($rootPath) $SearchBase = [string]$rootDse.Properties['defaultNamingContext'].Value } # DNS name of the domain actually being searched (from the DC= components of the search base). # Stamped on every emitted account so forest scans can show which domain each reclaim candidate came from. $domainDns = ConvertTo-DomainDns -DistinguishedName $SearchBase $entryPath = if ($Server) { "LDAP://$Server/$SearchBase" } else { "LDAP://$SearchBase" } $root = New-Object System.DirectoryServices.DirectoryEntry($entryPath) $searcher = New-Object System.DirectoryServices.DirectorySearcher($root) $searcher.Filter = '(&(objectCategory=person)(objectClass=user))' $searcher.PageSize = $PageSize $searcher.SearchScope = 'Subtree' foreach ($attr in 'sAMAccountName','userPrincipalName','displayName','objectGUID','objectSid', 'distinguishedName','userAccountControl','lastLogonTimestamp','whenChanged','whenCreated') { [void] $searcher.PropertiesToLoad.Add($attr) } $UF_ACCOUNTDISABLE = 0x2 # Declared before the try so the finally block can reference it even if FindAll() throws # (under Set-StrictMode -Version Latest, touching an unassigned variable would otherwise throw # and mask the original error). $results = $null try { # Safe single-value read: returns $null when the attribute is absent/empty rather than # indexing [0] on an empty collection (which can throw under Set-StrictMode). $first = { param($coll) if ($coll -and $coll.Count -gt 0) { $coll[0] } else { $null } } $results = $searcher.FindAll() foreach ($r in $results) { $p = $r.Properties $uac = if ($p['useraccountcontrol'].Count) { [int]$p['useraccountcontrol'][0] } else { 0 } $lastLogon = $null if ($p['lastlogontimestamp'].Count -and [long]$p['lastlogontimestamp'][0] -gt 0) { $lastLogon = [DateTime]::FromFileTimeUtc([long]$p['lastlogontimestamp'][0]) } $whenChanged = if ($p['whenchanged'].Count) { [DateTime]$p['whenchanged'][0] } else { $null } $whenCreated = if ($p['whencreated'].Count) { [DateTime]$p['whencreated'][0] } else { $null } $guid = if ($p['objectguid'].Count) { (New-Object Guid (,[byte[]]$p['objectguid'][0])).ToString() } else { $null } # objectSid is the synced anchor used by Entra (onPremisesSecurityIdentifier). It is the # most reliable correlation key: independent of which sourceAnchor the tenant configured. $sid = if ($p['objectsid'].Count) { (New-Object System.Security.Principal.SecurityIdentifier([byte[]]$p['objectsid'][0], 0)).Value } else { $null } [pscustomobject]@{ SamAccountName = [string](& $first $p['samaccountname']) UserPrincipalName = [string](& $first $p['userprincipalname']) DisplayName = [string](& $first $p['displayname']) ObjectGuid = $guid ObjectSid = $sid DistinguishedName = [string](& $first $p['distinguishedname']) Domain = $domainDns Enabled = -not ($uac -band $UF_ACCOUNTDISABLE) UserAccountControl = $uac LastLogonTimestamp = $lastLogon WhenChanged = $whenChanged WhenCreated = $whenCreated } } } finally { if ($results) { $results.Dispose() } $searcher.Dispose() } } |