From b340c601c75923952121c0a66acd5e456a8ec49b Mon Sep 17 00:00:00 2001 From: jessikitty Date: Mon, 2 Feb 2026 11:01:41 +1100 Subject: [PATCH] Fix CSV export, update columns to: Date, Username, Display Name, PC Name, DNS, IP --- Get-UserLastLogonComputer.ps1 | 110 ++++++++++++++++++++-------------- 1 file changed, 66 insertions(+), 44 deletions(-) diff --git a/Get-UserLastLogonComputer.ps1 b/Get-UserLastLogonComputer.ps1 index 8d0c29a..5261a66 100644 --- a/Get-UserLastLogonComputer.ps1 +++ b/Get-UserLastLogonComputer.ps1 @@ -1,12 +1,14 @@ #Requires -Modules ActiveDirectory <# .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 Queries Domain Controller security event logs for logon events (4624) to determine which computer each user last authenticated from. 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 and bulk AD user preloading for significantly faster execution. @@ -54,6 +56,17 @@ param( $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) $LogonTypesAllowed = [System.Collections.Generic.HashSet[string]]::new() @('2', '10', '11') | ForEach-Object { [void]$LogonTypesAllowed.Add($_) } @@ -76,10 +89,11 @@ $ExcludeDomains = [System.Collections.Generic.HashSet[string]]::new( ForEach-Object { [void]$ExcludeDomains.Add($_) } $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 "Looking for logon types: $($LogonTypesAllowed -join ', ')" -ForegroundColor Cyan +Write-Host "CSV will be saved to: $CsvFile" -ForegroundColor Cyan Write-Host "" $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 { # ========================================== # PHASE 2: Retrieve events @@ -163,16 +169,12 @@ try { # ========================================== # 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">([^<]+)<' @@ -185,7 +187,7 @@ try { 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() # 1. Check logon type FIRST (cheapest filter) @@ -194,7 +196,7 @@ try { $LogonType = $m.Groups[1].Value if (-not $LogonTypesAllowed.Contains($LogonType)) { continue } - # 2. Extract username and domain + # 2. Extract username $m = $rxTargetUser.Match($xmlStr) if (-not $m.Success) { continue } $Username = $m.Groups[1].Value @@ -215,7 +217,7 @@ try { continue } - # 5. Only NOW extract the expensive fields (workstation, IP) + # 5. Extract workstation and IP $m = $rxWorkstation.Match($xmlStr) $Workstation = if ($m.Success) { $m.Groups[1].Value } else { '' } @@ -224,39 +226,40 @@ try { $LogonTime = $Event.TimeCreated - # Determine computer name and DNS name - $Computer = $null + # Determine PC name and DNS name + $PCName = $null $DNSName = '' if (-not $SkipIPResolve -and -not [string]::IsNullOrWhiteSpace($IPAddress) -and $IPAddress -ne '-') { $resolved = Resolve-IPToDNSName -IP $IPAddress - if ($resolved.Short) { $Computer = $resolved.Short } + if ($resolved.Short) { $PCName = $resolved.Short } if ($resolved.FQDN) { $DNSName = $resolved.FQDN } } # 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 '-') { - $Computer = $Workstation.ToUpper() + $PCName = $Workstation.ToUpper() } 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++ - # Store result - display name from bulk preloaded cache + # Store result with report column names $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 + 'Date' = $LogonTime.ToString('yyyy-MM-dd HH:mm:ss') + 'Username' = $Username + 'Display Name' = $DisplayName + 'PC Name' = $PCName + 'DNS' = $DNSName + 'IP' = $IPDisplay } } @@ -264,27 +267,46 @@ try { 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) { Write-Host "`nNo user logon events found matching criteria." -ForegroundColor Yellow return } - # ========================================== - # PHASE 4: Export - # ========================================== - $Results | Select-Object Domain, Username, DisplayName, Computer, DNSName, LogonTime, LogonType | - Export-Csv -Path $CsvFile -NoTypeInformation -Encoding UTF8 + # Write CSV + try { + $Results | Export-Csv -Path $CsvFile -NoTypeInformation -Encoding UTF8 -Force + + 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 "" - # Display summary table - $Results | Format-Table Domain, Username, DisplayName, Computer, DNSName, LogonTime, LogonType -AutoSize + # Display summary table in console + $Results | Format-Table -AutoSize } catch [System.Exception] { if ($_.Exception.Message -match "No events were found") {