From 15f0ad43cafe2f5923dcc3377279d2ac2fda7e07 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Mon, 2 Feb 2026 10:52:05 +1100 Subject: [PATCH] Major performance optimization: regex XML extraction, bulk AD preload, early-skip duplicates --- Get-UserLastLogonComputer.ps1 | 187 +++++++++++++++++++++------------- 1 file changed, 119 insertions(+), 68 deletions(-) diff --git a/Get-UserLastLogonComputer.ps1 b/Get-UserLastLogonComputer.ps1 index 6fbb89a..8d0c29a 100644 --- a/Get-UserLastLogonComputer.ps1 +++ b/Get-UserLastLogonComputer.ps1 @@ -8,6 +8,9 @@ to determine which computer each user last authenticated from. Resolves IP addresses to DNS names and looks up user display names. + PERFORMANCE: Uses regex-based XML extraction instead of DOM parsing + and bulk AD user preloading for significantly faster execution. + .PARAMETER OutputPath Path for the output CSV file. Defaults to current directory. @@ -49,25 +52,56 @@ param( [switch]$SkipIPResolve ) -# Build logon types list -$LogonTypes = @(2, 10, 11) # Interactive, RDP, Cached +$ScriptStart = Get-Date + +# Build logon types lookup (HashSet for fast contains-check) +$LogonTypesAllowed = [System.Collections.Generic.HashSet[string]]::new() +@('2', '10', '11') | ForEach-Object { [void]$LogonTypesAllowed.Add($_) } if (-not $ExcludeNetworkLogons) { - $LogonTypes += 3 # Network + [void]$LogonTypesAllowed.Add('3') } +# System accounts to exclude (HashSet for O(1) lookup) +$ExcludeUsers = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase +) +@('SYSTEM', 'LOCAL SERVICE', 'NETWORK SERVICE', 'DWM-1', 'DWM-2', 'DWM-3', 'DWM-4', + 'UMFD-0', 'UMFD-1', 'UMFD-2', 'UMFD-3', 'ANONYMOUS LOGON', '-') | + ForEach-Object { [void]$ExcludeUsers.Add($_) } + +$ExcludeDomains = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase +) +@('Window Manager', 'Font Driver Host', 'NT AUTHORITY') | + ForEach-Object { [void]$ExcludeDomains.Add($_) } + $Timestamp = Get-Date -Format "yyyyMMdd_HHmmss" $CsvFile = Join-Path $OutputPath "UserLastLogonComputer_$Timestamp.csv" Write-Host "`nQuerying security event logs for the last $DaysBack days..." -ForegroundColor Cyan -Write-Host "Looking for logon types: $($LogonTypes -join ', ')" -ForegroundColor Cyan +Write-Host "Looking for logon types: $($LogonTypesAllowed -join ', ')" -ForegroundColor Cyan Write-Host "" $StartDate = (Get-Date).AddDays(-$DaysBack) +# ========================================== +# PHASE 1: Bulk preload all AD users (one query) +# ========================================== +Write-Host "Pre-loading AD user display names (bulk query)..." -ForegroundColor Yellow +$UserDisplayNames = @{} +try { + Get-ADUser -Filter * -Properties DisplayName | ForEach-Object { + if ($_.DisplayName) { + $UserDisplayNames[$_.SamAccountName] = $_.DisplayName + } + } + Write-Host " Loaded $($UserDisplayNames.Count) user display names." -ForegroundColor Gray +} catch { + Write-Host " Warning: Could not bulk-load AD users. Display names will be empty." -ForegroundColor DarkYellow +} + # Cache for IP to DNS name resolution $DNSCache = @{} -# Cache for user display names -$UserCache = @{} function Resolve-IPToDNSName { param([string]$IP) @@ -81,7 +115,6 @@ function Resolve-IPToDNSName { } try { - # DNS reverse lookup returns FQDN $fqdn = [System.Net.Dns]::GetHostEntry($IP).HostName $shortName = ($fqdn -split '\.')[0].ToUpper() $result = @{ Short = $shortName; FQDN = $fqdn.ToLower() } @@ -103,28 +136,18 @@ function Resolve-IPToDNSName { } } -function Get-UserDisplayName { - param([string]$Username, [string]$Domain) - - $UserKey = "$Domain\$Username" - - if ($UserCache.ContainsKey($UserKey)) { - return $UserCache[$UserKey] - } - - try { - $User = Get-ADUser -Identity $Username -Properties DisplayName -ErrorAction SilentlyContinue - if ($User -and $User.DisplayName) { - $UserCache[$UserKey] = $User.DisplayName - return $User.DisplayName - } - } catch {} - - $UserCache[$UserKey] = '' - return '' +# Logon type display labels +$LogonTypeLabels = @{ + '2' = 'Interactive' + '3' = 'Network' + '10' = 'RDP' + '11' = 'Cached' } try { + # ========================================== + # PHASE 2: Retrieve events + # ========================================== Write-Host "Retrieving logon events from Security log..." -ForegroundColor Yellow $FilterHash = @{ @@ -135,35 +158,71 @@ try { $Events = Get-WinEvent -FilterHashtable $FilterHash -ErrorAction Stop - Write-Host "Found $($Events.Count) total 4624 events. Filtering..." -ForegroundColor Yellow + Write-Host "Found $($Events.Count) total 4624 events. Processing..." -ForegroundColor Yellow + + # ========================================== + # PHASE 3: Process events with regex (FAST) + # ========================================== + # Events come newest-first from Get-WinEvent, so the first time we see + # a user is already their most recent logon - we can skip all later entries. $UserLogons = @{} $ProcessedCount = 0 + $SkippedCount = 0 $i = 0 + # Pre-compiled regex patterns for extracting fields from raw XML + # This is ~10-20x faster than [xml] DOM parsing + Where-Object pipelines + $rxLogonType = [regex]'Name="LogonType">(\d+)<' + $rxTargetUser = [regex]'Name="TargetUserName">([^<]+)<' + $rxTargetDomain = [regex]'Name="TargetDomainName">([^<]+)<' + $rxWorkstation = [regex]'Name="WorkstationName">([^<]*)<' + $rxIPAddress = [regex]'Name="IpAddress">([^<]*)<' + foreach ($Event in $Events) { $i++ - if ($i % 5000 -eq 0) { - Write-Host " Processing event $i of $($Events.Count)..." -ForegroundColor Gray + if ($i % 10000 -eq 0) { + Write-Host " Processing event $i of $($Events.Count)... ($ProcessedCount matched, $SkippedCount skipped as already seen)" -ForegroundColor Gray } - $xml = [xml]$Event.ToXml() + # Get raw XML string - DO NOT parse as [xml], just regex it + $xmlStr = $Event.ToXml() - $LogonType = [int]($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'LogonType' }).'#text' + # 1. Check logon type FIRST (cheapest filter) + $m = $rxLogonType.Match($xmlStr) + if (-not $m.Success) { continue } + $LogonType = $m.Groups[1].Value + if (-not $LogonTypesAllowed.Contains($LogonType)) { continue } - if ($LogonType -notin $LogonTypes) { continue } + # 2. Extract username and domain + $m = $rxTargetUser.Match($xmlStr) + if (-not $m.Success) { continue } + $Username = $m.Groups[1].Value - $Username = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'TargetUserName' }).'#text' - $Domain = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'TargetDomainName' }).'#text' - $Workstation = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'WorkstationName' }).'#text' - $IPAddress = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq 'IpAddress' }).'#text' - $LogonTime = $Event.TimeCreated - - # Filter out computer accounts and system accounts - if ($Username -match '\$$') { continue } - if ($Username -in @('SYSTEM', 'LOCAL SERVICE', 'NETWORK SERVICE', 'DWM-1', 'DWM-2', 'DWM-3', 'DWM-4', 'UMFD-0', 'UMFD-1', 'UMFD-2', 'UMFD-3', 'ANONYMOUS LOGON', '-')) { continue } - if ($Domain -in @('Window Manager', 'Font Driver Host', 'NT AUTHORITY')) { continue } + # 3. Quick filters before extracting more fields if ([string]::IsNullOrWhiteSpace($Username)) { continue } + if ($Username.EndsWith('$')) { continue } + if ($ExcludeUsers.Contains($Username)) { continue } + + $m = $rxTargetDomain.Match($xmlStr) + $Domain = if ($m.Success) { $m.Groups[1].Value } else { '' } + if ($ExcludeDomains.Contains($Domain)) { continue } + + # 4. Already seen this user? Skip (events are newest-first) + $UserKey = "$Domain\$Username" + if ($UserLogons.ContainsKey($UserKey)) { + $SkippedCount++ + continue + } + + # 5. Only NOW extract the expensive fields (workstation, IP) + $m = $rxWorkstation.Match($xmlStr) + $Workstation = if ($m.Success) { $m.Groups[1].Value } else { '' } + + $m = $rxIPAddress.Match($xmlStr) + $IPAddress = if ($m.Success) { $m.Groups[1].Value } else { '' } + + $LogonTime = $Event.TimeCreated # Determine computer name and DNS name $Computer = $null @@ -184,37 +243,26 @@ try { } } - $UserKey = "$Domain\$Username" $ProcessedCount++ - # Keep only the most recent logon for each user - if (-not $UserLogons.ContainsKey($UserKey) -or $LogonTime -gt $UserLogons[$UserKey].LogonTime) { - $UserLogons[$UserKey] = [PSCustomObject]@{ - Domain = $Domain - Username = $Username - DisplayName = '' # Will populate after - Computer = $Computer - DNSName = $DNSName - LogonTime = $LogonTime - LogonType = switch ($LogonType) { - 2 { "Interactive" } - 3 { "Network" } - 10 { "RDP" } - 11 { "Cached" } - default { "Type $LogonType" } - } - } + # Store result - display name from bulk preloaded cache + $DisplayName = if ($UserDisplayNames.ContainsKey($Username)) { $UserDisplayNames[$Username] } else { '' } + $TypeLabel = if ($LogonTypeLabels.ContainsKey($LogonType)) { $LogonTypeLabels[$LogonType] } else { "Type $LogonType" } + + $UserLogons[$UserKey] = [PSCustomObject]@{ + Domain = $Domain + Username = $Username + DisplayName = $DisplayName + Computer = $Computer + DNSName = $DNSName + LogonTime = $LogonTime + LogonType = $TypeLabel } } - Write-Host "Processed $ProcessedCount matching logon events." -ForegroundColor Yellow - Write-Host "Looking up display names for $($UserLogons.Count) users..." -ForegroundColor Yellow + $ProcessTime = (Get-Date) - $ScriptStart - # Populate display names - foreach ($Key in $UserLogons.Keys) { - $User = $UserLogons[$Key] - $User.DisplayName = Get-UserDisplayName -Username $User.Username -Domain $User.Domain - } + Write-Host "Processed $ProcessedCount unique user logons ($SkippedCount duplicate events skipped)." -ForegroundColor Yellow $Results = $UserLogons.Values | Sort-Object Domain, Username @@ -223,13 +271,16 @@ try { return } - # Export to CSV + # ========================================== + # PHASE 4: Export + # ========================================== $Results | Select-Object Domain, Username, DisplayName, Computer, DNSName, LogonTime, LogonType | Export-Csv -Path $CsvFile -NoTypeInformation -Encoding UTF8 Write-Host "`n===== Results =====" -ForegroundColor Green Write-Host "Total users found: $($Results.Count)" Write-Host "Output saved to: $CsvFile" + Write-Host "Completed in: $($ProcessTime.Minutes)m $($ProcessTime.Seconds)s" -ForegroundColor Cyan Write-Host "" # Display summary table