342 lines
13 KiB
PowerShell
342 lines
13 KiB
PowerShell
#Requires -Modules ActiveDirectory
|
|
<#
|
|
.SYNOPSIS
|
|
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 event .Properties[] index access (no XML parsing)
|
|
and bulk AD user preloading for fast execution.
|
|
|
|
.PARAMETER OutputPath
|
|
Path for the output CSV file. Defaults to current directory.
|
|
|
|
.PARAMETER DaysBack
|
|
Number of days of event logs to search. Default is 7.
|
|
|
|
.PARAMETER ExcludeNetworkLogons
|
|
Exclude Type 3 (Network) logons. Default includes them.
|
|
|
|
.PARAMETER SkipIPResolve
|
|
Skip IP to hostname resolution. Faster but less useful.
|
|
|
|
.EXAMPLE
|
|
.\Get-UserLastLogonComputer.ps1
|
|
|
|
.EXAMPLE
|
|
.\Get-UserLastLogonComputer.ps1 -OutputPath "C:\Reports" -DaysBack 30
|
|
|
|
.NOTES
|
|
Must be run on a Domain Controller with appropriate permissions to read Security logs.
|
|
Logon Type 2 = Interactive (console)
|
|
Logon Type 3 = Network (file shares, etc.)
|
|
Logon Type 10 = RemoteInteractive (RDP)
|
|
Logon Type 11 = CachedInteractive
|
|
#>
|
|
|
|
[CmdletBinding()]
|
|
param(
|
|
[Parameter()]
|
|
[string]$OutputPath = (Get-Location).Path,
|
|
|
|
[Parameter()]
|
|
[int]$DaysBack = 7,
|
|
|
|
[Parameter()]
|
|
[switch]$ExcludeNetworkLogons,
|
|
|
|
[Parameter()]
|
|
[switch]$SkipIPResolve
|
|
)
|
|
|
|
$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 using int)
|
|
$LogonTypesAllowed = [System.Collections.Generic.HashSet[int]]::new()
|
|
@(2, 10, 11) | ForEach-Object { [void]$LogonTypesAllowed.Add($_) }
|
|
if (-not $ExcludeNetworkLogons) {
|
|
[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 "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)
|
|
|
|
# ==========================================
|
|
# 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 = @{}
|
|
|
|
function Resolve-IPToDNSName {
|
|
param([string]$IP)
|
|
|
|
if ([string]::IsNullOrWhiteSpace($IP) -or $IP -eq '-' -or $IP -eq '::1' -or $IP -eq '127.0.0.1') {
|
|
return @{ Short = $null; FQDN = $null }
|
|
}
|
|
|
|
if ($DNSCache.ContainsKey($IP)) {
|
|
return $DNSCache[$IP]
|
|
}
|
|
|
|
try {
|
|
$fqdn = [System.Net.Dns]::GetHostEntry($IP).HostName
|
|
$shortName = ($fqdn -split '\.')[0].ToUpper()
|
|
$result = @{ Short = $shortName; FQDN = $fqdn.ToLower() }
|
|
$DNSCache[$IP] = $result
|
|
return $result
|
|
} catch {
|
|
try {
|
|
$computer = Get-ADComputer -Filter "IPv4Address -eq '$IP'" -Properties DNSHostName, Name -ErrorAction SilentlyContinue
|
|
if ($computer) {
|
|
$result = @{ Short = $computer.Name; FQDN = if ($computer.DNSHostName) { $computer.DNSHostName.ToLower() } else { '' } }
|
|
$DNSCache[$IP] = $result
|
|
return $result
|
|
}
|
|
} catch {}
|
|
|
|
$result = @{ Short = $null; FQDN = $null }
|
|
$DNSCache[$IP] = $result
|
|
return $result
|
|
}
|
|
}
|
|
|
|
try {
|
|
# ==========================================
|
|
# PHASE 2: Retrieve events
|
|
# ==========================================
|
|
Write-Host "Retrieving logon events from Security log..." -ForegroundColor Yellow
|
|
|
|
$FilterHash = @{
|
|
LogName = 'Security'
|
|
ID = 4624
|
|
StartTime = $StartDate
|
|
}
|
|
|
|
$Events = Get-WinEvent -FilterHashtable $FilterHash -ErrorAction Stop
|
|
|
|
Write-Host "Found $($Events.Count) total 4624 events. Processing..." -ForegroundColor Yellow
|
|
|
|
# ==========================================
|
|
# PHASE 3: Process events using Properties[] index
|
|
# ==========================================
|
|
# Event 4624 Properties index map:
|
|
# [0] SubjectUserSid [1] SubjectUserName
|
|
# [2] SubjectDomainName [3] SubjectLogonId
|
|
# [4] TargetUserSid [5] TargetUserName
|
|
# [6] TargetDomainName [7] TargetLogonId
|
|
# [8] LogonType [9] LogonProcessName
|
|
# [10] AuthenticationPackageName
|
|
# [11] WorkstationName [12] LogonGuid
|
|
# [13] TransmittedServices [14] LmPackageName
|
|
# [15] KeyLength [16] ProcessId
|
|
# [17] ProcessName [18] IpAddress
|
|
# [19] IpPort
|
|
#
|
|
# Using .Properties[n].Value is MUCH faster than XML parsing
|
|
|
|
# Dump first event to verify field mapping
|
|
$firstEvent = $Events[0]
|
|
$props = $firstEvent.Properties
|
|
Write-Host "`n DEBUG: First event field check:" -ForegroundColor DarkGray
|
|
Write-Host " [5] TargetUserName = '$($props[5].Value)'" -ForegroundColor DarkGray
|
|
Write-Host " [6] TargetDomainName = '$($props[6].Value)'" -ForegroundColor DarkGray
|
|
Write-Host " [8] LogonType = '$($props[8].Value)'" -ForegroundColor DarkGray
|
|
Write-Host " [11] WorkstationName = '$($props[11].Value)'" -ForegroundColor DarkGray
|
|
Write-Host " [18] IpAddress = '$($props[18].Value)'" -ForegroundColor DarkGray
|
|
Write-Host " Total properties count = $($props.Count)" -ForegroundColor DarkGray
|
|
Write-Host ""
|
|
|
|
$UserLogons = @{}
|
|
$ProcessedCount = 0
|
|
$SkippedCount = 0
|
|
$FilteredCount = 0
|
|
$i = 0
|
|
|
|
foreach ($Event in $Events) {
|
|
$i++
|
|
if ($i % 10000 -eq 0) {
|
|
Write-Host " Processing event $i of $($Events.Count)... ($ProcessedCount matched, $SkippedCount already seen, $FilteredCount filtered)" -ForegroundColor Gray
|
|
}
|
|
|
|
$props = $Event.Properties
|
|
|
|
# Safety check - skip events with unexpected property count
|
|
if ($props.Count -lt 19) {
|
|
$FilteredCount++
|
|
continue
|
|
}
|
|
|
|
# 1. Check logon type FIRST (cheapest filter)
|
|
$LogonType = [int]$props[8].Value
|
|
if (-not $LogonTypesAllowed.Contains($LogonType)) {
|
|
$FilteredCount++
|
|
continue
|
|
}
|
|
|
|
# 2. Extract username
|
|
$Username = [string]$props[5].Value
|
|
|
|
# 3. Quick filters
|
|
if ([string]::IsNullOrWhiteSpace($Username)) { $FilteredCount++; continue }
|
|
if ($Username.EndsWith('$')) { $FilteredCount++; continue }
|
|
if ($ExcludeUsers.Contains($Username)) { $FilteredCount++; continue }
|
|
|
|
$Domain = [string]$props[6].Value
|
|
if ($ExcludeDomains.Contains($Domain)) { $FilteredCount++; continue }
|
|
|
|
# 4. Already seen this user? Skip (events are newest-first)
|
|
$UserKey = "$Domain\$Username"
|
|
if ($UserLogons.ContainsKey($UserKey)) {
|
|
$SkippedCount++
|
|
continue
|
|
}
|
|
|
|
# 5. Extract workstation and IP (only for first occurrence)
|
|
$Workstation = [string]$props[11].Value
|
|
$IPAddress = [string]$props[18].Value
|
|
$LogonTime = $Event.TimeCreated
|
|
|
|
# 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) { $PCName = $resolved.Short }
|
|
if ($resolved.FQDN) { $DNSName = $resolved.FQDN }
|
|
}
|
|
|
|
# Fall back to WorkstationName if IP didn't resolve
|
|
if ([string]::IsNullOrWhiteSpace($PCName)) {
|
|
if (-not [string]::IsNullOrWhiteSpace($Workstation) -and $Workstation -ne '-') {
|
|
$PCName = $Workstation.ToUpper()
|
|
} else {
|
|
$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 with report column names
|
|
$DisplayName = if ($UserDisplayNames.ContainsKey($Username)) { $UserDisplayNames[$Username] } else { '' }
|
|
|
|
$UserLogons[$UserKey] = [PSCustomObject]@{
|
|
'Date' = $LogonTime.ToString('yyyy-MM-dd HH:mm:ss')
|
|
'Username' = $Username
|
|
'Display Name' = $DisplayName
|
|
'PC Name' = $PCName
|
|
'DNS' = $DNSName
|
|
'IP' = $IPDisplay
|
|
}
|
|
}
|
|
|
|
$ProcessTime = (Get-Date) - $ScriptStart
|
|
|
|
Write-Host "`nProcessed $ProcessedCount unique user logons." -ForegroundColor Yellow
|
|
Write-Host " $SkippedCount duplicate events skipped (already seen user)." -ForegroundColor Gray
|
|
Write-Host " $FilteredCount events filtered (system/computer accounts, wrong logon type)." -ForegroundColor Gray
|
|
|
|
# ==========================================
|
|
# 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
|
|
}
|
|
|
|
# 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 ""
|
|
|
|
# Display summary table in console
|
|
$Results | Format-Table -AutoSize
|
|
|
|
} catch [System.Exception] {
|
|
if ($_.Exception.Message -match "No events were found") {
|
|
Write-Host "`nNo logon events (Event ID 4624) found in the last $DaysBack days." -ForegroundColor Yellow
|
|
} elseif ($_.Exception.Message -match "Access is denied") {
|
|
Write-Host "`nError: Access denied. Run PowerShell as Administrator." -ForegroundColor Red
|
|
} else {
|
|
Write-Host "`nError: $($_.Exception.Message)" -ForegroundColor Red
|
|
}
|
|
}
|