<# .SYNOPSIS Enumerate all members of the local Administrators group and flag unexpected accounts. .DESCRIPTION Queries the local Administrators group via ADSI to capture all member types: local accounts, domain accounts/groups, and SID-only (unresolvable) entries. Every member is logged with its account type and source domain/host. Exit codes (Script Check compatible): 0 = Only well-known built-in accounts found (Administrator, Domain Admins) 1 = One or more non-built-in accounts detected -- review required 2 = Enumeration failed (access error or group not found) Built-ins always skipped in the flag evaluation: - BUILTIN\Administrator (local built-in admin account) - BUILTIN\Domain Admins (default domain group entry) All members are logged regardless; exit code reflects non-built-in presence. .EXAMPLE .\Win_User_LocalAdminAudit.ps1 .NOTES v1.0 2026-06-24 -- Initial release. Enumerates all local, domain, and SID-only Administrators group members via ADSI WinNT provider. No allowlist required -- pure enumeration audit. Exit 1 on any non-built-in member detected. v1.1 2026-06-24 -- Fixed LocalUser/LocalGroup type label on workgroup machines (WORKGROUP source was misclassified as domain). Fixed SID resolution to use objectSid bytes via ADSI as primary path (NTAccount translation fails on workgroup); fixed SID regex patterns (glob * corrected to regex .+). #> [CmdletBinding()] param() #region --- Helpers ----------------------------------------------------------- function Get-MemberType { param([string]$SchemaClassName) switch ($SchemaClassName) { 'User' { 'LocalUser' } 'Group' { 'LocalGroup' } default { 'Unknown' } } } #endregion #region --- Init -------------------------------------------------------------- $hostname = $env:COMPUTERNAME $groupName = 'Administrators' $exitCode = 0 $builtinSids = @( 'S-1-5-32-544', # BUILTIN\Administrators (the group itself, not a member) 'S-1-5-21-.+-500', # Local Administrator account (RID 500) 'S-1-5-21-.+-512' # Domain Admins group (RID 512) ) # Well-known built-in names used as a secondary match when SID lookup is unavailable $builtinNames = @( 'Administrator', 'Domain Admins' ) $members = [System.Collections.Generic.List[PSCustomObject]]::new() $nonBuiltins = [System.Collections.Generic.List[PSCustomObject]]::new() #endregion #region --- Enumerate --------------------------------------------------------- Write-Output "=== Win_User_LocalAdminAudit | $hostname | $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') ===" Write-Output "" try { $group = [ADSI]"WinNT://$hostname/$groupName,group" $rawMembers = @($group.Invoke('Members')) } catch { Write-Output "[ERROR] Failed to query local Administrators group: $_" Write-Output "" Write-Output "=== Summary: Enumeration FAILED -- exit 2 ===" exit 2 } if ($rawMembers.Count -eq 0) { Write-Output "[WARN] Administrators group returned zero members. This is abnormal." Write-Output "" Write-Output "=== Summary: 0 members found | exit 1 (abnormal) ===" exit 1 } Write-Output ("{0,-40} {1,-15} {2,-30} {3}" -f 'Account', 'Type', 'Source', 'SID') Write-Output ("{0,-40} {1,-15} {2,-30} {3}" -f ('=' * 39), ('=' * 14), ('=' * 29), ('=' * 45)) foreach ($rawMember in $rawMembers) { try { $memberAdsi = [ADSI]$rawMember.GetType().InvokeMember('ADsPath', 'GetProperty', $null, $rawMember, $null) $name = $rawMember.GetType().InvokeMember('Name', 'GetProperty', $null, $rawMember, $null) $schema = $rawMember.GetType().InvokeMember('Class', 'GetProperty', $null, $rawMember, $null) $adspath = $rawMember.GetType().InvokeMember('ADsPath', 'GetProperty', $null, $rawMember, $null) } catch { # SID-only fallback -- member exists but properties are unresolvable $name = '[Unresolvable]' $schema = 'Unknown' $adspath = 'N/A' } # Derive source: WinNT://DOMAIN/Name or WinNT://HOST/Name $source = 'Unknown' if ($adspath -match 'WinNT://([^/]+)/') { $source = $Matches[1] } # Attempt SID resolution -- prefer direct objectSid from ADSI (works on workgroup), # fall back to NTAccount translation (works on domain-joined when DC reachable) $sidString = 'Unresolvable' try { $sidBytes = $rawMember.GetType().InvokeMember('objectSid', 'GetProperty', $null, $rawMember, $null) if ($sidBytes) { $sidObj = New-Object System.Security.Principal.SecurityIdentifier($sidBytes, 0) $sidString = $sidObj.Value } } catch { # objectSid not available; try NTAccount translation (domain members) try { $ntAcct = New-Object System.Security.Principal.NTAccount($source, $name) $sidString = $ntAcct.Translate([System.Security.Principal.SecurityIdentifier]).Value } catch { $sidString = 'Unresolvable' } } # Determine account type label # WORKGROUP is returned by the WinNT provider on non-domain machines for local accounts $isDomainSource = ($source -ne $hostname -and $source -ne 'BUILTIN' -and $source -ne 'NT AUTHORITY' -and $source -ne 'WORKGROUP') $typeLabel = switch ($schema) { 'User' { if ($isDomainSource) { 'DomainUser' } else { 'LocalUser' } } 'Group' { if ($isDomainSource) { 'DomainGroup' } else { 'LocalGroup' } } default { 'SID-Only' } } $entry = [PSCustomObject]@{ Name = $name Type = $typeLabel Source = $source SID = $sidString AdsPath = $adspath } $members.Add($entry) Write-Output ("{0,-40} {1,-15} {2,-30} {3}" -f $name, $typeLabel, $source, $sidString) # Flag check: is this a well-known built-in we expect on every machine? $isBuiltin = $false # Match by SID pattern (most reliable) foreach ($pattern in $builtinSids) { if ($sidString -match "^$pattern$") { $isBuiltin = $true break } } # Fallback: match by bare name when SID unresolvable if (-not $isBuiltin) { foreach ($bname in $builtinNames) { if ($name -eq $bname) { $isBuiltin = $true break } } } if (-not $isBuiltin) { $nonBuiltins.Add($entry) } } #endregion #region --- Summary ----------------------------------------------------------- Write-Output "" Write-Output "--- Member count: $($members.Count) total ---" Write-Output "" if ($nonBuiltins.Count -gt 0) { Write-Output "[FLAG] $($nonBuiltins.Count) non-built-in account(s) detected in Administrators group:" foreach ($nb in $nonBuiltins) { Write-Output (" {0} | {1} | {2} | SID: {3}" -f $nb.Name, $nb.Type, $nb.Source, $nb.SID) } Write-Output "" Write-Output "=== Summary: REVIEW REQUIRED -- $($nonBuiltins.Count) unexpected member(s) | exit 1 ===" $exitCode = 1 } else { Write-Output "[OK] Only well-known built-in accounts found. No action required." Write-Output "" Write-Output "=== Summary: PASS -- no unexpected members | exit 0 ===" $exitCode = 0 } #endregion exit $exitCode