Major performance optimization: regex XML extraction, bulk AD preload, early-skip duplicates

This commit is contained in:
2026-02-02 10:52:05 +11:00
parent 2aece2fab7
commit 15f0ad43ca

View File

@@ -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