Fix CSV export, update columns to: Date, Username, Display Name, PC Name, DNS, IP
This commit is contained in:
@@ -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
|
|
||||||
|
|
||||||
Write-Host "`n===== Results =====" -ForegroundColor Green
|
if (Test-Path $CsvFile) {
|
||||||
Write-Host "Total users found: $($Results.Count)"
|
$FileSize = (Get-Item $CsvFile).Length
|
||||||
Write-Host "Output saved to: $CsvFile"
|
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
|
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 ""
|
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") {
|
||||||
|
|||||||
Reference in New Issue
Block a user