Fix CSV export, update columns to: Date, Username, Display Name, PC Name, DNS, IP

This commit is contained in:
2026-02-02 11:01:41 +11:00
parent 15f0ad43ca
commit b340c601c7

View File

@@ -1,13 +1,15 @@
#Requires -Modules ActiveDirectory #Requires -Modules ActiveDirectory
<# <#
.SYNOPSIS .SYNOPSIS
Exports a list of users and the last PC they logged into. Exports a CSV report of users and the last PC they logged into.
.DESCRIPTION .DESCRIPTION
Queries Domain Controller security event logs for logon events (4624) Queries Domain Controller security event logs for logon events (4624)
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.
CSV Report Columns: Date, Username, Display Name, PC Name, DNS, IP
PERFORMANCE: Uses regex-based XML extraction instead of DOM parsing PERFORMANCE: Uses regex-based XML extraction instead of DOM parsing
and bulk AD user preloading for significantly faster execution. and bulk AD user preloading for significantly faster execution.
@@ -54,6 +56,17 @@ param(
$ScriptStart = Get-Date $ScriptStart = Get-Date
# Validate output path exists
if (-not (Test-Path $OutputPath)) {
Write-Host "Output path '$OutputPath' does not exist. Creating it..." -ForegroundColor Yellow
try {
New-Item -Path $OutputPath -ItemType Directory -Force | Out-Null
} catch {
Write-Host "Error: Could not create output directory '$OutputPath'. $($_.Exception.Message)" -ForegroundColor Red
return
}
}
# Build logon types lookup (HashSet for fast contains-check) # Build logon types lookup (HashSet for fast contains-check)
$LogonTypesAllowed = [System.Collections.Generic.HashSet[string]]::new() $LogonTypesAllowed = [System.Collections.Generic.HashSet[string]]::new()
@('2', '10', '11') | ForEach-Object { [void]$LogonTypesAllowed.Add($_) } @('2', '10', '11') | ForEach-Object { [void]$LogonTypesAllowed.Add($_) }
@@ -76,10 +89,11 @@ $ExcludeDomains = [System.Collections.Generic.HashSet[string]]::new(
ForEach-Object { [void]$ExcludeDomains.Add($_) } 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 "UserLastLogonReport_$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: $($LogonTypesAllowed -join ', ')" -ForegroundColor Cyan Write-Host "Looking for logon types: $($LogonTypesAllowed -join ', ')" -ForegroundColor Cyan
Write-Host "CSV will be saved to: $CsvFile" -ForegroundColor Cyan
Write-Host "" Write-Host ""
$StartDate = (Get-Date).AddDays(-$DaysBack) $StartDate = (Get-Date).AddDays(-$DaysBack)
@@ -136,14 +150,6 @@ function Resolve-IPToDNSName {
} }
} }
# Logon type display labels
$LogonTypeLabels = @{
'2' = 'Interactive'
'3' = 'Network'
'10' = 'RDP'
'11' = 'Cached'
}
try { try {
# ========================================== # ==========================================
# PHASE 2: Retrieve events # PHASE 2: Retrieve events
@@ -163,16 +169,12 @@ try {
# ========================================== # ==========================================
# PHASE 3: Process events with regex (FAST) # 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 $SkippedCount = 0
$i = 0 $i = 0
# Pre-compiled regex patterns for extracting fields from raw XML # 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+)<' $rxLogonType = [regex]'Name="LogonType">(\d+)<'
$rxTargetUser = [regex]'Name="TargetUserName">([^<]+)<' $rxTargetUser = [regex]'Name="TargetUserName">([^<]+)<'
$rxTargetDomain = [regex]'Name="TargetDomainName">([^<]+)<' $rxTargetDomain = [regex]'Name="TargetDomainName">([^<]+)<'
@@ -185,7 +187,7 @@ try {
Write-Host " Processing event $i of $($Events.Count)... ($ProcessedCount matched, $SkippedCount skipped as already seen)" -ForegroundColor Gray Write-Host " Processing event $i of $($Events.Count)... ($ProcessedCount matched, $SkippedCount skipped as already seen)" -ForegroundColor Gray
} }
# Get raw XML string - DO NOT parse as [xml], just regex it # Get raw XML string - regex instead of [xml] DOM parsing
$xmlStr = $Event.ToXml() $xmlStr = $Event.ToXml()
# 1. Check logon type FIRST (cheapest filter) # 1. Check logon type FIRST (cheapest filter)
@@ -194,7 +196,7 @@ try {
$LogonType = $m.Groups[1].Value $LogonType = $m.Groups[1].Value
if (-not $LogonTypesAllowed.Contains($LogonType)) { continue } if (-not $LogonTypesAllowed.Contains($LogonType)) { continue }
# 2. Extract username and domain # 2. Extract username
$m = $rxTargetUser.Match($xmlStr) $m = $rxTargetUser.Match($xmlStr)
if (-not $m.Success) { continue } if (-not $m.Success) { continue }
$Username = $m.Groups[1].Value $Username = $m.Groups[1].Value
@@ -215,7 +217,7 @@ try {
continue continue
} }
# 5. Only NOW extract the expensive fields (workstation, IP) # 5. Extract workstation and IP
$m = $rxWorkstation.Match($xmlStr) $m = $rxWorkstation.Match($xmlStr)
$Workstation = if ($m.Success) { $m.Groups[1].Value } else { '' } $Workstation = if ($m.Success) { $m.Groups[1].Value } else { '' }
@@ -224,39 +226,40 @@ try {
$LogonTime = $Event.TimeCreated $LogonTime = $Event.TimeCreated
# Determine computer name and DNS name # Determine PC name and DNS name
$Computer = $null $PCName = $null
$DNSName = '' $DNSName = ''
if (-not $SkipIPResolve -and -not [string]::IsNullOrWhiteSpace($IPAddress) -and $IPAddress -ne '-') { if (-not $SkipIPResolve -and -not [string]::IsNullOrWhiteSpace($IPAddress) -and $IPAddress -ne '-') {
$resolved = Resolve-IPToDNSName -IP $IPAddress $resolved = Resolve-IPToDNSName -IP $IPAddress
if ($resolved.Short) { $Computer = $resolved.Short } if ($resolved.Short) { $PCName = $resolved.Short }
if ($resolved.FQDN) { $DNSName = $resolved.FQDN } if ($resolved.FQDN) { $DNSName = $resolved.FQDN }
} }
# Fall back to WorkstationName if IP didn't resolve # Fall back to WorkstationName if IP didn't resolve
if ([string]::IsNullOrWhiteSpace($Computer)) { if ([string]::IsNullOrWhiteSpace($PCName)) {
if (-not [string]::IsNullOrWhiteSpace($Workstation) -and $Workstation -ne '-') { if (-not [string]::IsNullOrWhiteSpace($Workstation) -and $Workstation -ne '-') {
$Computer = $Workstation.ToUpper() $PCName = $Workstation.ToUpper()
} else { } else {
$Computer = "Unknown" $PCName = 'Unknown'
} }
} }
# Clean up IP for display
$IPDisplay = if ($IPAddress -and $IPAddress -ne '-' -and $IPAddress -ne '::1' -and $IPAddress -ne '127.0.0.1') { $IPAddress } else { '' }
$ProcessedCount++ $ProcessedCount++
# Store result - display name from bulk preloaded cache # Store result with report column names
$DisplayName = if ($UserDisplayNames.ContainsKey($Username)) { $UserDisplayNames[$Username] } else { '' } $DisplayName = if ($UserDisplayNames.ContainsKey($Username)) { $UserDisplayNames[$Username] } else { '' }
$TypeLabel = if ($LogonTypeLabels.ContainsKey($LogonType)) { $LogonTypeLabels[$LogonType] } else { "Type $LogonType" }
$UserLogons[$UserKey] = [PSCustomObject]@{ $UserLogons[$UserKey] = [PSCustomObject]@{
Domain = $Domain 'Date' = $LogonTime.ToString('yyyy-MM-dd HH:mm:ss')
Username = $Username 'Username' = $Username
DisplayName = $DisplayName 'Display Name' = $DisplayName
Computer = $Computer 'PC Name' = $PCName
DNSName = $DNSName 'DNS' = $DNSName
LogonTime = $LogonTime 'IP' = $IPDisplay
LogonType = $TypeLabel
} }
} }
@@ -264,27 +267,46 @@ try {
Write-Host "Processed $ProcessedCount unique user logons ($SkippedCount duplicate events skipped)." -ForegroundColor Yellow Write-Host "Processed $ProcessedCount unique user logons ($SkippedCount duplicate events skipped)." -ForegroundColor Yellow
$Results = $UserLogons.Values | Sort-Object Domain, Username # ==========================================
# PHASE 4: Export CSV
# ==========================================
$Results = @($UserLogons.Values | Sort-Object Username)
if ($Results.Count -eq 0) { if ($Results.Count -eq 0) {
Write-Host "`nNo user logon events found matching criteria." -ForegroundColor Yellow Write-Host "`nNo user logon events found matching criteria." -ForegroundColor Yellow
return return
} }
# ========================================== # Write CSV
# PHASE 4: Export try {
# ========================================== $Results | Export-Csv -Path $CsvFile -NoTypeInformation -Encoding UTF8 -Force
$Results | Select-Object Domain, Username, DisplayName, Computer, DNSName, LogonTime, LogonType |
Export-Csv -Path $CsvFile -NoTypeInformation -Encoding UTF8 if (Test-Path $CsvFile) {
$FileSize = (Get-Item $CsvFile).Length
Write-Host "`n===== Report Saved =====" -ForegroundColor Green
Write-Host "Total users: $($Results.Count)"
Write-Host "File size: $([math]::Round($FileSize / 1KB, 1)) KB"
Write-Host "Saved to: $CsvFile" -ForegroundColor Green
Write-Host "Completed in: $($ProcessTime.Minutes)m $($ProcessTime.Seconds)s" -ForegroundColor Cyan
} else {
Write-Host "`nError: CSV file was not created at '$CsvFile'" -ForegroundColor Red
}
} catch {
Write-Host "`nError saving CSV: $($_.Exception.Message)" -ForegroundColor Red
Write-Host "Attempting to save to Desktop instead..." -ForegroundColor Yellow
$FallbackPath = Join-Path ([Environment]::GetFolderPath('Desktop')) "UserLastLogonReport_$Timestamp.csv"
try {
$Results | Export-Csv -Path $FallbackPath -NoTypeInformation -Encoding UTF8 -Force
Write-Host "Saved to fallback location: $FallbackPath" -ForegroundColor Green
} catch {
Write-Host "Error: Could not save CSV. $($_.Exception.Message)" -ForegroundColor Red
}
}
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 "" Write-Host ""
# Display summary table # Display summary table in console
$Results | Format-Table Domain, Username, DisplayName, Computer, DNSName, LogonTime, LogonType -AutoSize $Results | Format-Table -AutoSize
} catch [System.Exception] { } catch [System.Exception] {
if ($_.Exception.Message -match "No events were found") { if ($_.Exception.Message -match "No events were found") {