Major performance optimization: regex XML extraction, bulk AD preload, early-skip duplicates
This commit is contained in:
@@ -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) {
|
||||
# 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 = '' # Will populate after
|
||||
DisplayName = $DisplayName
|
||||
Computer = $Computer
|
||||
DNSName = $DNSName
|
||||
LogonTime = $LogonTime
|
||||
LogonType = switch ($LogonType) {
|
||||
2 { "Interactive" }
|
||||
3 { "Network" }
|
||||
10 { "RDP" }
|
||||
11 { "Cached" }
|
||||
default { "Type $LogonType" }
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user