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. to determine which computer each user last authenticated from.
Resolves IP addresses to DNS names and looks up user display names. 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 .PARAMETER OutputPath
Path for the output CSV file. Defaults to current directory. Path for the output CSV file. Defaults to current directory.
@@ -49,25 +52,56 @@ param(
[switch]$SkipIPResolve [switch]$SkipIPResolve
) )
# Build logon types list $ScriptStart = Get-Date
$LogonTypes = @(2, 10, 11) # Interactive, RDP, Cached
# 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) { 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" $Timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$CsvFile = Join-Path $OutputPath "UserLastLogonComputer_$Timestamp.csv" $CsvFile = Join-Path $OutputPath "UserLastLogonComputer_$Timestamp.csv"
Write-Host "`nQuerying security event logs for the last $DaysBack days..." -ForegroundColor Cyan 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 "" Write-Host ""
$StartDate = (Get-Date).AddDays(-$DaysBack) $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 # Cache for IP to DNS name resolution
$DNSCache = @{} $DNSCache = @{}
# Cache for user display names
$UserCache = @{}
function Resolve-IPToDNSName { function Resolve-IPToDNSName {
param([string]$IP) param([string]$IP)
@@ -81,7 +115,6 @@ function Resolve-IPToDNSName {
} }
try { try {
# DNS reverse lookup returns FQDN
$fqdn = [System.Net.Dns]::GetHostEntry($IP).HostName $fqdn = [System.Net.Dns]::GetHostEntry($IP).HostName
$shortName = ($fqdn -split '\.')[0].ToUpper() $shortName = ($fqdn -split '\.')[0].ToUpper()
$result = @{ Short = $shortName; FQDN = $fqdn.ToLower() } $result = @{ Short = $shortName; FQDN = $fqdn.ToLower() }
@@ -103,28 +136,18 @@ function Resolve-IPToDNSName {
} }
} }
function Get-UserDisplayName { # Logon type display labels
param([string]$Username, [string]$Domain) $LogonTypeLabels = @{
'2' = 'Interactive'
$UserKey = "$Domain\$Username" '3' = 'Network'
'10' = 'RDP'
if ($UserCache.ContainsKey($UserKey)) { '11' = 'Cached'
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 ''
} }
try { try {
# ==========================================
# PHASE 2: Retrieve events
# ==========================================
Write-Host "Retrieving logon events from Security log..." -ForegroundColor Yellow Write-Host "Retrieving logon events from Security log..." -ForegroundColor Yellow
$FilterHash = @{ $FilterHash = @{
@@ -135,35 +158,71 @@ try {
$Events = Get-WinEvent -FilterHashtable $FilterHash -ErrorAction Stop $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 = @{} $UserLogons = @{}
$ProcessedCount = 0 $ProcessedCount = 0
$SkippedCount = 0
$i = 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) { foreach ($Event in $Events) {
$i++ $i++
if ($i % 5000 -eq 0) { if ($i % 10000 -eq 0) {
Write-Host " Processing event $i of $($Events.Count)..." -ForegroundColor Gray 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' # 3. Quick filters before extracting more fields
$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 }
if ([string]::IsNullOrWhiteSpace($Username)) { continue } 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 # Determine computer name and DNS name
$Computer = $null $Computer = $null
@@ -184,37 +243,26 @@ try {
} }
} }
$UserKey = "$Domain\$Username"
$ProcessedCount++ $ProcessedCount++
# Keep only the most recent logon for each user # Store result - display name from bulk preloaded cache
if (-not $UserLogons.ContainsKey($UserKey) -or $LogonTime -gt $UserLogons[$UserKey].LogonTime) { $DisplayName = if ($UserDisplayNames.ContainsKey($Username)) { $UserDisplayNames[$Username] } else { '' }
$UserLogons[$UserKey] = [PSCustomObject]@{ $TypeLabel = if ($LogonTypeLabels.ContainsKey($LogonType)) { $LogonTypeLabels[$LogonType] } else { "Type $LogonType" }
Domain = $Domain
Username = $Username $UserLogons[$UserKey] = [PSCustomObject]@{
DisplayName = '' # Will populate after Domain = $Domain
Computer = $Computer Username = $Username
DNSName = $DNSName DisplayName = $DisplayName
LogonTime = $LogonTime Computer = $Computer
LogonType = switch ($LogonType) { DNSName = $DNSName
2 { "Interactive" } LogonTime = $LogonTime
3 { "Network" } LogonType = $TypeLabel
10 { "RDP" }
11 { "Cached" }
default { "Type $LogonType" }
}
}
} }
} }
Write-Host "Processed $ProcessedCount matching logon events." -ForegroundColor Yellow $ProcessTime = (Get-Date) - $ScriptStart
Write-Host "Looking up display names for $($UserLogons.Count) users..." -ForegroundColor Yellow
# Populate display names Write-Host "Processed $ProcessedCount unique user logons ($SkippedCount duplicate events skipped)." -ForegroundColor Yellow
foreach ($Key in $UserLogons.Keys) {
$User = $UserLogons[$Key]
$User.DisplayName = Get-UserDisplayName -Username $User.Username -Domain $User.Domain
}
$Results = $UserLogons.Values | Sort-Object Domain, Username $Results = $UserLogons.Values | Sort-Object Domain, Username
@@ -223,13 +271,16 @@ try {
return return
} }
# Export to CSV # ==========================================
# PHASE 4: Export
# ==========================================
$Results | Select-Object Domain, Username, DisplayName, Computer, DNSName, LogonTime, LogonType | $Results | Select-Object Domain, Username, DisplayName, Computer, DNSName, LogonTime, LogonType |
Export-Csv -Path $CsvFile -NoTypeInformation -Encoding UTF8 Export-Csv -Path $CsvFile -NoTypeInformation -Encoding UTF8
Write-Host "`n===== Results =====" -ForegroundColor Green Write-Host "`n===== Results =====" -ForegroundColor Green
Write-Host "Total users found: $($Results.Count)" Write-Host "Total users found: $($Results.Count)"
Write-Host "Output saved to: $CsvFile" Write-Host "Output saved to: $CsvFile"
Write-Host "Completed in: $($ProcessTime.Minutes)m $($ProcessTime.Seconds)s" -ForegroundColor Cyan
Write-Host "" Write-Host ""
# Display summary table # Display summary table