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.
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user