From d75856e886fb83274b0ddd1ab0821c2b306eea9d Mon Sep 17 00:00:00 2001 From: Rephl3x Date: Wed, 15 Apr 2026 13:24:05 +1200 Subject: [PATCH] Rewrite Binoculars with DC tracking and watch UI --- Binoculars.ps1 | 2051 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 2008 insertions(+), 43 deletions(-) diff --git a/Binoculars.ps1 b/Binoculars.ps1 index 2e8c7c6..8d115e1 100644 --- a/Binoculars.ps1 +++ b/Binoculars.ps1 @@ -1,51 +1,2016 @@ -## Created by Zak Bearman - Intel - Datacom - For use on any domain that WinRM is enabled and supported for remote log searching## -cls -# Define the username you are searching for -$username = read-host "Please enter users account" # Replace with the username of the locked-out user -# Get the PDC Emulator -$pdcemulator = (Get-ADDomain).PDCEmulator -$DomainControllers = Get-ADDomainController $pdcemulator | Select-Object -ExpandProperty HostName +[CmdletBinding()] +param( + [string]$UserName = '', + [ValidateRange(5, 1440)] + [int]$LookbackMinutes = 30, + [switch]$NoGui, + [string]$ExportPath +) -# Define log file path -$logFile = "C:\Temp\AccountLockoutLog.txt" - -# Create the log file if it doesn't exist -if (-not (Test-Path $logFile)) { - New-Item -Path $logFile -ItemType File -Force +$script:RelevantEventIds = @(4624, 4625, 4634, 4647, 4740, 4767, 4768, 4769, 4770, 4771, 4776) +$script:PollOverlapSeconds = 120 +$script:DefaultThrottleLimit = 8 +$script:EventCatalog = @{ + 4624 = @{ Name = 'Successful Logon'; Category = 'Logon'; DefaultOutcome = 'Success' } + 4625 = @{ Name = 'Failed Logon'; Category = 'Logon'; DefaultOutcome = 'Failure' } + 4634 = @{ Name = 'Logoff'; Category = 'Logoff'; DefaultOutcome = 'Logoff' } + 4647 = @{ Name = 'User-Initiated Logoff'; Category = 'Logoff'; DefaultOutcome = 'Logoff' } + 4740 = @{ Name = 'Account Lockout'; Category = 'Lockout'; DefaultOutcome = 'Lockout' } + 4767 = @{ Name = 'Account Unlock'; Category = 'Unlock'; DefaultOutcome = 'Unlock' } + 4768 = @{ Name = 'Kerberos TGT Request'; Category = 'Kerberos'; DefaultOutcome = 'Success' } + 4769 = @{ Name = 'Kerberos Service Ticket'; Category = 'Kerberos'; DefaultOutcome = 'Success' } + 4770 = @{ Name = 'Kerberos Ticket Renewal'; Category = 'Kerberos'; DefaultOutcome = 'Success' } + 4771 = @{ Name = 'Kerberos Pre-Authentication Failure'; Category = 'Kerberos'; DefaultOutcome = 'Failure' } + 4776 = @{ Name = 'NTLM Credential Validation'; Category = 'NTLM'; DefaultOutcome = 'Success' } +} +$script:LogonTypeCatalog = @{ + 2 = 'Interactive' + 3 = 'Network' + 4 = 'Batch' + 5 = 'Service' + 7 = 'Unlock' + 8 = 'NetworkCleartext' + 9 = 'NewCredentials' + 10 = 'RemoteInteractive' + 11 = 'CachedInteractive' + 12 = 'CachedRemoteInteractive' + 13 = 'CachedUnlock' +} +$script:StatusCatalog = @{ + '0X0' = 'Success' + '0X00000000' = 'Success' + '0XC0000064' = 'Unknown user' + '0XC000006A' = 'Bad password' + '0XC000006D' = 'Bad username or authentication information' + '0XC000006E' = 'Account restriction' + '0XC000006F' = 'Invalid logon hours' + '0XC0000070' = 'Workstation restriction' + '0XC0000071' = 'Password expired' + '0XC0000072' = 'Account disabled' + '0XC0000193' = 'Account expired' + '0XC0000224' = 'Password must change' + '0XC0000234' = 'Account locked out' + '0X6' = 'Client not found in Kerberos database' + '0X12' = 'Account disabled or expired (Kerberos)' + '0X17' = 'Password expired (Kerberos)' + '0X18' = 'Pre-authentication failed or bad password (Kerberos)' } -# Loop indefinitely every 5 minutes -while ($true) { - foreach ($DC in $DomainControllers) { - Write-Host "Searching on: $DC" - Add-Content -Path $logFile -Value "Searching on: $DC - $(Get-Date)" +function Write-BinocularsStatus { + param( + [Parameter(Mandatory)] + [string]$Message, + [object]$StatusTextBox + ) - # Use Invoke-Command to remotely query the domain controller - Invoke-Command -ComputerName $DC -ScriptBlock { - param ($username) + $line = '[{0}] {1}' -f (Get-Date -Format 'HH:mm:ss'), $Message + if ($null -ne $StatusTextBox) { + $StatusTextBox.AppendText($line + [Environment]::NewLine) + $StatusTextBox.SelectionStart = $StatusTextBox.TextLength + $StatusTextBox.ScrollToCaret() + [System.Windows.Forms.Application]::DoEvents() + } + else { + Write-Host $line + } +} - # Query the Security event log for Event ID 4625 (Failed Login Attempt) - $events4625 = Get-EventLog -LogName "Security" -InstanceId 4625 -Newest 1000 | Where-Object { $_.Message -like "*$username*" } - foreach ($event in $events4625) { - $timeGenerated = $event.TimeGenerated - $message = $event.Message +function Stop-BinocularsPowerShellInstance { + param( + [Parameter(Mandatory)] + [object]$PowerShellInstance + ) - Write-Host "Failed login attempt: $message at $timeGenerated" - Add-Content -Path $using:logFile -Value "Failed login attempt: $message at $timeGenerated" - } - - # Query the Security event log for Event ID 4740 (Account Lockout) - $events4740 = Get-EventLog -LogName "Security" -InstanceId 4740 -Newest 1000 | Where-Object { $_.Message -like "*$username*" } - foreach ($event in $events4740) { - $timeGenerated = $event.TimeGenerated - $message = $event.Message - - Write-Host "Account locked out: $message at $timeGenerated" - Add-Content -Path $using:logFile -Value "Account locked out: $message at $timeGenerated" - } - } -ArgumentList $username + if ($null -eq $PowerShellInstance) { + return } - # Wait for 10 seconds (10 seconds) - Start-Sleep -Seconds 10 -} \ No newline at end of file + try { + $asyncStop = $PowerShellInstance.BeginStop($null, $null) + if ($asyncStop) { + $PowerShellInstance.EndStop($asyncStop) + } + } + catch { + try { + $PowerShellInstance.Stop() + } + catch { + } + } +} + +function ConvertTo-BinocularsSafeFileName { + param( + [Parameter(Mandatory)] + [string]$Value + ) + + $safeValue = $Value + foreach ($character in [System.IO.Path]::GetInvalidFileNameChars()) { + $safeValue = $safeValue.Replace($character, '_') + } + + return $safeValue +} + +function Get-BinocularsArchiveRoot { + $candidates = @() + + if ($env:ProgramData) { + $candidates += (Join-Path $env:ProgramData 'Binoculars') + } + if ($env:LOCALAPPDATA) { + $candidates += (Join-Path $env:LOCALAPPDATA 'Binoculars') + } + if ($env:TEMP) { + $candidates += (Join-Path $env:TEMP 'Binoculars') + } + + foreach ($candidate in $candidates | Select-Object -Unique) { + try { + if (-not (Test-Path -Path $candidate)) { + New-Item -Path $candidate -ItemType Directory -Force | Out-Null + } + + return $candidate + } + catch { + } + } + + throw 'Unable to create a local Binoculars archive folder.' +} + +function Test-BinocularsComputerNameMatch { + param( + [string]$Left, + [string]$Right + ) + + if ([string]::IsNullOrWhiteSpace($Left) -or [string]::IsNullOrWhiteSpace($Right)) { + return $false + } + + if ($Left -ieq $Right) { + return $true + } + + return (($Left -split '\.')[0] -ieq ($Right -split '\.')[0]) +} + +function Get-BinocularsDomainControllerInventory { + $domain = Get-ADDomain -ErrorAction Stop + $pdcEmulator = $domain.PDCEmulator + $controllers = Get-ADDomainController -Filter * -ErrorAction Stop | + Where-Object { -not $_.IsReadOnly -and $_.HostName } | + Sort-Object -Property HostName + + if (-not $controllers) { + throw 'No writable domain controllers were found in the current Active Directory domain.' + } + + return @( + foreach ($controller in $controllers) { + $isPdc = Test-BinocularsComputerNameMatch -Left $controller.HostName -Right $pdcEmulator + [pscustomobject]@{ + HostName = $controller.HostName + Site = $controller.Site + IsPdcEmulator = $isPdc + Role = if ($isPdc) { 'PDC Emulator' } else { '' } + IsReachable = $null + Status = 'Unknown' + StatusIconKey = 'unknown' + SkipSearch = $false + LastChecked = $null + LastError = '' + } + } + ) +} + +function Set-BinocularsDomainControllerState { + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]]$Inventory, + [Parameter(Mandatory)] + [string]$HostName, + [Parameter(Mandatory)] + [bool]$Reachable, + [string]$Message, + [datetime]$CheckedAt = (Get-Date) + ) + + $entry = @($Inventory | Where-Object { Test-BinocularsComputerNameMatch -Left $_.HostName -Right $HostName } | Select-Object -First 1) + if (-not $entry) { + return + } + + $entry = $entry[0] + $entry.IsReachable = $Reachable + $entry.SkipSearch = -not $Reachable + $entry.Status = if ($Reachable) { 'Reachable' } else { 'Unreachable' } + $entry.StatusIconKey = if ($Reachable) { 'green' } else { 'red' } + $entry.LastChecked = $CheckedAt + $entry.LastError = if ($Reachable) { '' } else { $Message } +} + +function Get-BinocularsSearchableDomainControllers { + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]]$Inventory + ) + + return @( + $Inventory | + Where-Object { $_.IsReachable -ne $false } | + Select-Object -ExpandProperty HostName + ) +} + +function Invoke-BinocularsParallelDomainControllerHealthCheck { + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]]$Inventory, + [switch]$ForceRecheck, + [object]$StatusTextBox + ) + + $targets = if ($ForceRecheck) { + @($Inventory) + } + else { + @($Inventory | Where-Object { $null -eq $_.IsReachable }) + } + + if (-not $targets) { + return [pscustomobject]@{ + Inventory = @($Inventory) + CheckedControllers = @() + Cancelled = $false + } + } + + Write-BinocularsStatus -Message "Checking health of $($targets.Count) writable DCs." -StatusTextBox $StatusTextBox + $runspacePool = [runspacefactory]::CreateRunspacePool(1, [Math]::Min([Math]::Max(1, $script:DefaultThrottleLimit), $targets.Count)) + $runspacePool.Open() + + $jobs = @() + foreach ($target in $targets) { + $powerShell = [powershell]::Create() + $powerShell.RunspacePool = $runspacePool + [void]$powerShell.AddScript({ + param( + [string]$ComputerName + ) + + try { + Get-WinEvent -ComputerName $ComputerName -ListLog Security -ErrorAction Stop | Out-Null + [pscustomobject]@{ + ComputerName = $ComputerName + Reachable = $true + Error = $null + } + } + catch { + [pscustomobject]@{ + ComputerName = $ComputerName + Reachable = $false + Error = $_.Exception.Message + } + } + }).AddArgument($target.HostName) + + $jobs += [pscustomobject]@{ + DomainController = $target.HostName + PowerShell = $powerShell + Handle = $powerShell.BeginInvoke() + Processed = $false + } + } + + $cancelled = $false + $checkedControllers = New-Object System.Collections.Generic.List[string] + + try { + do { + $remainingJobs = 0 + + foreach ($job in $jobs) { + if ($job.Processed) { + continue + } + + if ($script:cancelSearchRequested) { + $cancelled = $true + break + } + + if ($job.Handle.IsCompleted) { + try { + $healthResult = @($job.PowerShell.EndInvoke($job.Handle) | Select-Object -First 1) + if ($healthResult) { + $healthResult = $healthResult[0] + $checkedControllers.Add($job.DomainController) | Out-Null + Set-BinocularsDomainControllerState -Inventory $Inventory -HostName $job.DomainController -Reachable $healthResult.Reachable -Message $healthResult.Error + + if ($healthResult.Reachable) { + Write-BinocularsStatus -Message "DC reachable: $($job.DomainController)" -StatusTextBox $StatusTextBox + } + else { + Write-BinocularsStatus -Message "DC unreachable: $($job.DomainController) - $($healthResult.Error)" -StatusTextBox $StatusTextBox + } + } + } + finally { + $job.PowerShell.Dispose() + $job.Processed = $true + } + } + else { + $remainingJobs++ + } + } + + if ($cancelled) { + foreach ($job in $jobs | Where-Object { -not $_.Processed }) { + try { + Stop-BinocularsPowerShellInstance -PowerShellInstance $job.PowerShell + } + finally { + $job.PowerShell.Dispose() + $job.Processed = $true + } + } + break + } + + if ($remainingJobs -gt 0) { + if ($StatusTextBox) { + [System.Windows.Forms.Application]::DoEvents() + } + Start-Sleep -Milliseconds 150 + } + } while ($remainingJobs -gt 0) + } + finally { + foreach ($job in $jobs) { + if (-not $job.Processed) { + $job.PowerShell.Dispose() + } + } + + $runspacePool.Close() + $runspacePool.Dispose() + } + + return [pscustomobject]@{ + Inventory = @($Inventory) + CheckedControllers = @($checkedControllers | Select-Object -Unique) + Cancelled = $cancelled + } +} + +function Resolve-BinocularsIdentity { + param( + [Parameter(Mandatory)] + [string]$UserInput + ) + + $trimmedInput = $UserInput.Trim() + if ([string]::IsNullOrWhiteSpace($trimmedInput)) { + throw 'Enter a user account to search for.' + } + + $domain = Get-ADDomain -ErrorAction Stop + $lookupValues = New-Object System.Collections.Generic.List[string] + $lookupValues.Add($trimmedInput) + + if ($trimmedInput.Contains('\')) { + $lookupValues.Add($trimmedInput.Split('\')[-1]) + } + if ($trimmedInput.Contains('@')) { + $lookupValues.Add($trimmedInput.Split('@')[0]) + } + + $candidateUser = $null + foreach ($lookupValue in $lookupValues | Select-Object -Unique) { + try { + $candidateUser = Get-ADUser -Identity $lookupValue -Properties DisplayName, UserPrincipalName, SID, SamAccountName -ErrorAction Stop + } + catch { + try { + $escapedLookup = $lookupValue.Replace("'", "''") + $candidateUser = Get-ADUser -Filter "SamAccountName -eq '$escapedLookup'" -Properties DisplayName, UserPrincipalName, SID, SamAccountName -ErrorAction Stop + } + catch { + try { + $candidateUser = Get-ADUser -Filter "UserPrincipalName -eq '$escapedLookup'" -Properties DisplayName, UserPrincipalName, SID, SamAccountName -ErrorAction Stop + } + catch { + $candidateUser = $null + } + } + } + + if ($candidateUser) { + $candidateUser = @($candidateUser) | Select-Object -First 1 + break + } + } + + if (-not $candidateUser) { + throw "Unable to resolve '$trimmedInput' in Active Directory." + } + + $searchTerms = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + $candidateTerms = @( + $trimmedInput + $candidateUser.SamAccountName + $candidateUser.UserPrincipalName + $candidateUser.SID.Value + "$($domain.NetBIOSName)\$($candidateUser.SamAccountName)" + "$($domain.DNSRoot)\$($candidateUser.SamAccountName)" + "$($candidateUser.SamAccountName)@$($domain.DNSRoot)" + ) + + foreach ($candidateTerm in $candidateTerms) { + if (-not [string]::IsNullOrWhiteSpace($candidateTerm)) { + [void]$searchTerms.Add($candidateTerm.Trim().ToLowerInvariant()) + } + } + + if ($trimmedInput.Contains('\')) { + [void]$searchTerms.Add($trimmedInput.Split('\')[-1].ToLowerInvariant()) + } + if ($trimmedInput.Contains('@')) { + [void]$searchTerms.Add($trimmedInput.Split('@')[0].ToLowerInvariant()) + } + + return [pscustomobject]@{ + Input = $trimmedInput + SamAccountName = $candidateUser.SamAccountName + UserPrincipalName = $candidateUser.UserPrincipalName + Sid = $candidateUser.SID.Value + DisplayName = $candidateUser.DisplayName + DomainDnsName = $domain.DNSRoot + DomainNetBIOSName = $domain.NetBIOSName + SearchTerms = $searchTerms + } +} + +function Get-BinocularsEventDataMap { + param( + [Parameter(Mandatory)] + [string]$Xml + ) + + $dataMap = [ordered]@{} + [xml]$document = $Xml + $index = 0 + + foreach ($dataNode in @($document.Event.EventData.Data)) { + if ($null -eq $dataNode) { + continue + } + + $index++ + $name = [string]$dataNode.Name + if ([string]::IsNullOrWhiteSpace($name)) { + $name = 'Data{0}' -f $index + } + + $value = [string]$dataNode.'#text' + if ($null -eq $value) { + $value = '' + } + + if ($dataMap.Contains($name)) { + $suffix = 2 + while ($dataMap.Contains('{0}_{1}' -f $name, $suffix)) { + $suffix++ + } + $name = '{0}_{1}' -f $name, $suffix + } + + $dataMap[$name] = $value.Trim() + } + + if ($document.Event.UserData) { + foreach ($rootNode in $document.Event.UserData.ChildNodes) { + foreach ($childNode in $rootNode.ChildNodes) { + if ($childNode.NodeType -ne [System.Xml.XmlNodeType]::Element) { + continue + } + + $name = [string]$childNode.LocalName + $value = [string]$childNode.InnerText + if ([string]::IsNullOrWhiteSpace($name) -or [string]::IsNullOrWhiteSpace($value)) { + continue + } + + if ($dataMap.Contains($name)) { + continue + } + + $dataMap[$name] = $value.Trim() + } + } + } + + return $dataMap +} + +function Get-BinocularsValue { + param( + [Parameter(Mandatory)] + [System.Collections.IDictionary]$DataMap, + [Parameter(Mandatory)] + [string[]]$Names + ) + + foreach ($name in $Names) { + if ($DataMap.Contains($name)) { + $value = [string]$DataMap[$name] + if (-not [string]::IsNullOrWhiteSpace($value) -and $value -ne '-') { + return $value.Trim() + } + } + } + + return $null +} + +function Test-BinocularsPrincipalMatch { + param( + [Parameter(Mandatory)] + [System.Collections.IDictionary]$DataMap, + [Parameter(Mandatory)] + [psobject]$Identity + ) + + $candidateValues = New-Object System.Collections.Generic.HashSet[string] ([System.StringComparer]::OrdinalIgnoreCase) + $priorityKeys = @( + 'TargetUserName', + 'SubjectUserName', + 'AccountName', + 'UserName', + 'TargetSid', + 'SubjectUserSid' + ) + + foreach ($priorityKey in $priorityKeys) { + $value = Get-BinocularsValue -DataMap $DataMap -Names @($priorityKey) + if (-not [string]::IsNullOrWhiteSpace($value)) { + [void]$candidateValues.Add($value.Trim().ToLowerInvariant()) + } + } + + foreach ($key in $DataMap.Keys) { + if ($key -match 'User|Sid|Account') { + $value = [string]$DataMap[$key] + if (-not [string]::IsNullOrWhiteSpace($value) -and $value -ne '-') { + [void]$candidateValues.Add($value.Trim().ToLowerInvariant()) + } + } + } + + foreach ($candidateValue in $candidateValues) { + if ($Identity.SearchTerms.Contains($candidateValue)) { + return $true + } + + if ($candidateValue.Contains('\')) { + $shortName = $candidateValue.Split('\')[-1] + if ($Identity.SearchTerms.Contains($shortName)) { + return $true + } + } + + if ($candidateValue.Contains('@')) { + $samLikeValue = $candidateValue.Split('@')[0] + if ($Identity.SearchTerms.Contains($samLikeValue)) { + return $true + } + } + } + + return $false +} + +function Normalize-BinocularsStatusCode { + param( + [string]$StatusCode + ) + + if ([string]::IsNullOrWhiteSpace($StatusCode) -or $StatusCode -eq '-') { + return $null + } + + $normalized = $StatusCode.Trim() + if ($normalized -match '^(0x)?[0-9a-fA-F]+$') { + if ($normalized -notmatch '^0x') { + $normalized = '0x{0}' -f $normalized + } + + return $normalized.ToUpperInvariant() + } + + return $normalized +} + +function Get-BinocularsStatusText { + param( + [string]$StatusCode, + [System.Collections.IDictionary]$DataMap + ) + + $normalizedStatus = Normalize-BinocularsStatusCode -StatusCode $StatusCode + if ($normalizedStatus -and $script:StatusCatalog.ContainsKey($normalizedStatus)) { + return $script:StatusCatalog[$normalizedStatus] + } + + $failureReason = Get-BinocularsValue -DataMap $DataMap -Names @('FailureReason', 'ErrorMessage') + if ($failureReason) { + return $failureReason + } + + return $normalizedStatus +} + +function Get-BinocularsLogonTypeText { + param( + [System.Collections.IDictionary]$DataMap + ) + + $logonType = Get-BinocularsValue -DataMap $DataMap -Names @('LogonType') + if (-not $logonType) { + return $null + } + + $parsedType = 0 + if ([int]::TryParse($logonType, [ref]$parsedType) -and $script:LogonTypeCatalog.ContainsKey($parsedType)) { + return '{0} ({1})' -f $parsedType, $script:LogonTypeCatalog[$parsedType] + } + + return $logonType +} + +function Resolve-BinocularsOutcome { + param( + [Parameter(Mandatory)] + [int]$EventId, + [System.Collections.IDictionary]$DataMap + ) + + $defaultOutcome = $script:EventCatalog[$EventId].DefaultOutcome + $statusCode = Normalize-BinocularsStatusCode -StatusCode (Get-BinocularsValue -DataMap $DataMap -Names @('Status', 'FailureCode', 'SubStatus')) + + switch ($EventId) { + 4768 { + if ($statusCode -and $statusCode -notin @('0X0', '0X00000000')) { + return 'Failure' + } + } + 4769 { + if ($statusCode -and $statusCode -notin @('0X0', '0X00000000')) { + return 'Failure' + } + } + 4770 { + if ($statusCode -and $statusCode -notin @('0X0', '0X00000000')) { + return 'Failure' + } + } + 4776 { + if ($statusCode -and $statusCode -notin @('0X0', '0X00000000')) { + return 'Failure' + } + } + } + + return $defaultOutcome +} + +function New-BinocularsSummary { + param( + [Parameter(Mandatory)] + [int]$EventId, + [Parameter(Mandatory)] + [string]$Outcome, + [System.Collections.IDictionary]$DataMap + ) + + $logonType = Get-BinocularsLogonTypeText -DataMap $DataMap + $sourceHost = Get-BinocularsValue -DataMap $DataMap -Names @('WorkstationName', 'Workstation', 'CallerComputerName', 'ClientName') + $sourceIp = Get-BinocularsValue -DataMap $DataMap -Names @('IpAddress', 'ClientAddress', 'SourceNetworkAddress') + $statusCode = Normalize-BinocularsStatusCode -StatusCode (Get-BinocularsValue -DataMap $DataMap -Names @('Status', 'FailureCode', 'SubStatus')) + $statusText = Get-BinocularsStatusText -StatusCode $statusCode -DataMap $DataMap + $serviceName = Get-BinocularsValue -DataMap $DataMap -Names @('ServiceName', 'TargetServerName') + + $summaryParts = New-Object System.Collections.Generic.List[string] + + switch ($EventId) { + 4624 { $summaryParts.Add('Successful logon') } + 4625 { $summaryParts.Add('Failed logon') } + 4634 { $summaryParts.Add('Session logoff recorded on the DC') } + 4647 { $summaryParts.Add('User initiated logoff recorded on the DC') } + 4740 { $summaryParts.Add('Account lockout') } + 4767 { $summaryParts.Add('Account unlock') } + 4768 { + if ($Outcome -eq 'Failure') { + $summaryParts.Add('Kerberos TGT request failed') + } + else { + $summaryParts.Add('Kerberos TGT issued') + } + } + 4769 { + if ($Outcome -eq 'Failure') { + $summaryParts.Add('Kerberos service ticket request failed') + } + else { + $summaryParts.Add('Kerberos service ticket issued') + } + } + 4770 { $summaryParts.Add('Kerberos ticket renewed') } + 4771 { $summaryParts.Add('Kerberos pre-authentication failed') } + 4776 { + if ($Outcome -eq 'Failure') { + $summaryParts.Add('NTLM credential validation failed') + } + else { + $summaryParts.Add('NTLM credential validation succeeded') + } + } + default { $summaryParts.Add($script:EventCatalog[$EventId].Name) } + } + + if ($logonType) { + $summaryParts.Add('Logon type: {0}' -f $logonType) + } + if ($serviceName) { + $summaryParts.Add('Service: {0}' -f $serviceName) + } + if ($sourceHost) { + $summaryParts.Add('Host: {0}' -f $sourceHost) + } + if ($sourceIp) { + $summaryParts.Add('IP: {0}' -f $sourceIp) + } + if ($statusText -and $statusText -ne 'Success') { + $summaryParts.Add($statusText) + } + + return ($summaryParts | Where-Object { $_ }) -join ' | ' +} + +function ConvertTo-BinocularsRecord { + param( + [Parameter(Mandatory)] + [psobject]$RawEvent, + [Parameter(Mandatory)] + [psobject]$Identity + ) + + $eventData = Get-BinocularsEventDataMap -Xml $RawEvent.Xml + if (-not (Test-BinocularsPrincipalMatch -DataMap $eventData -Identity $Identity)) { + return $null + } + + $eventId = [int]$RawEvent.Id + if (-not $script:EventCatalog.ContainsKey($eventId)) { + return $null + } + + $statusCode = Normalize-BinocularsStatusCode -StatusCode (Get-BinocularsValue -DataMap $eventData -Names @('Status', 'FailureCode', 'SubStatus')) + $statusText = Get-BinocularsStatusText -StatusCode $statusCode -DataMap $eventData + $outcome = Resolve-BinocularsOutcome -EventId $eventId -DataMap $eventData + $domainController = if ($RawEvent.ComputerName) { $RawEvent.ComputerName } else { $RawEvent.PSComputerName } + $rawFields = ($eventData.GetEnumerator() | Sort-Object -Property Name | ForEach-Object { + '{0}={1}' -f $_.Key, $_.Value + }) -join '; ' + + return [pscustomobject]@{ + TimeCreated = [datetime]$RawEvent.TimeCreated + DomainController = $domainController + EventId = $eventId + EventName = $script:EventCatalog[$eventId].Name + Category = $script:EventCatalog[$eventId].Category + Outcome = $outcome + User = Get-BinocularsValue -DataMap $eventData -Names @('TargetUserName', 'SubjectUserName', 'AccountName', 'UserName') + UserDomain = Get-BinocularsValue -DataMap $eventData -Names @('TargetDomainName', 'SubjectDomainName', 'AccountDomain') + SourceHost = Get-BinocularsValue -DataMap $eventData -Names @('WorkstationName', 'Workstation', 'CallerComputerName', 'ClientName') + SourceIP = Get-BinocularsValue -DataMap $eventData -Names @('IpAddress', 'ClientAddress', 'SourceNetworkAddress') + LogonType = Get-BinocularsLogonTypeText -DataMap $eventData + Status = $statusCode + StatusText = $statusText + Summary = New-BinocularsSummary -EventId $eventId -Outcome $outcome -DataMap $eventData + RecordId = [long]$RawEvent.RecordId + Fingerprint = '{0}|{1}|{2}' -f $domainController, $RawEvent.RecordId, $eventId + RawFields = $rawFields + } +} + +function Invoke-BinocularsParallelEventQuery { + param( + [Parameter(Mandatory)] + [string[]]$DomainControllers, + [Parameter(Mandatory)] + [datetime]$StartTime, + [Parameter(Mandatory)] + [datetime]$EndTime, + [int[]]$EventIds = $script:RelevantEventIds, + [int]$ThrottleLimit = $script:DefaultThrottleLimit, + [object]$StatusTextBox + ) + + if (-not $DomainControllers) { + return [pscustomobject]@{ + RawEvents = @() + Errors = @() + SuccessfulDomainControllers = @() + Cancelled = $false + } + } + + $effectiveThrottle = [Math]::Min([Math]::Max(1, $ThrottleLimit), [Math]::Max(1, $DomainControllers.Count)) + $runspacePool = [runspacefactory]::CreateRunspacePool(1, $effectiveThrottle) + $runspacePool.Open() + + $jobs = @() + foreach ($domainController in $DomainControllers) { + $powerShell = [powershell]::Create() + $powerShell.RunspacePool = $runspacePool + [void]$powerShell.AddScript({ + param( + [string]$ComputerName, + [datetime]$StartTime, + [datetime]$EndTime, + [int[]]$EventIds + ) + + try { + $filter = @{ + LogName = 'Security' + ID = $EventIds + StartTime = $StartTime + EndTime = $EndTime + } + + Get-WinEvent -ComputerName $ComputerName -FilterHashtable $filter -ErrorAction Stop | + ForEach-Object { + [pscustomobject]@{ + ComputerName = $ComputerName + Id = [int]$_.Id + RecordId = [long]$_.RecordId + TimeCreated = $_.TimeCreated + Xml = $_.ToXml() + Error = $null + } + } + } + catch { + [pscustomobject]@{ + ComputerName = $ComputerName + Id = $null + RecordId = $null + TimeCreated = $null + Xml = $null + Error = $_.Exception.Message + } + } + }).AddArgument($domainController).AddArgument($StartTime).AddArgument($EndTime).AddArgument($EventIds) + + $jobs += [pscustomobject]@{ + DomainController = $domainController + PowerShell = $powerShell + Handle = $powerShell.BeginInvoke() + Processed = $false + } + } + + $rawEvents = New-Object System.Collections.Generic.List[object] + $errors = New-Object System.Collections.Generic.List[object] + $successfulDomainControllers = New-Object System.Collections.Generic.List[string] + $cancelled = $false + + try { + do { + $remainingJobs = 0 + + foreach ($job in $jobs) { + if ($job.Processed) { + continue + } + + if ($script:cancelSearchRequested) { + $cancelled = $true + break + } + + if ($job.Handle.IsCompleted) { + try { + $jobResults = $job.PowerShell.EndInvoke($job.Handle) + $jobRows = @($jobResults) + + if ($jobRows.Count -eq 1 -and $jobRows[0].Error) { + $errors.Add([pscustomobject]@{ + DomainController = $job.DomainController + ErrorType = 'Query' + Message = $jobRows[0].Error + }) + Write-BinocularsStatus -Message "Failed to query $($job.DomainController): $($jobRows[0].Error)" -StatusTextBox $StatusTextBox + } + else { + $successfulDomainControllers.Add($job.DomainController) | Out-Null + foreach ($row in $jobRows) { + $rawEvents.Add($row) + } + + Write-BinocularsStatus -Message "Queried $($job.DomainController): $($jobRows.Count) candidate events returned." -StatusTextBox $StatusTextBox + } + } + catch { + $errors.Add([pscustomobject]@{ + DomainController = $job.DomainController + ErrorType = 'Query' + Message = $_.Exception.Message + }) + Write-BinocularsStatus -Message "Failed to finalize query for $($job.DomainController): $($_.Exception.Message)" -StatusTextBox $StatusTextBox + } + finally { + $job.PowerShell.Dispose() + $job.Processed = $true + } + } + else { + $remainingJobs++ + } + } + + if ($cancelled) { + foreach ($job in $jobs | Where-Object { -not $_.Processed }) { + try { + Stop-BinocularsPowerShellInstance -PowerShellInstance $job.PowerShell + } + finally { + $job.PowerShell.Dispose() + $job.Processed = $true + } + } + break + } + + if ($remainingJobs -gt 0) { + if ($StatusTextBox) { + [System.Windows.Forms.Application]::DoEvents() + } + Start-Sleep -Milliseconds 150 + } + } while ($remainingJobs -gt 0) + } + finally { + foreach ($job in $jobs) { + if (-not $job.Processed) { + $job.PowerShell.Dispose() + } + } + + $runspacePool.Close() + $runspacePool.Dispose() + } + + return [pscustomobject]@{ + RawEvents = @($rawEvents) + Errors = @($errors) + SuccessfulDomainControllers = @($successfulDomainControllers | Select-Object -Unique) + Cancelled = $cancelled + } +} + +function Invoke-BinocularsSearch { + param( + [Parameter(Mandatory)] + [psobject]$Identity, + [Parameter(Mandatory)] + [datetime]$StartTime, + [Parameter(Mandatory)] + [datetime]$EndTime, + [AllowEmptyCollection()] + [string[]]$DomainControllers, + [object]$StatusTextBox + ) + + if (-not $DomainControllers) { + throw 'No reachable writable DCs are currently available. Use Refresh DCs to recheck unreachable servers.' + } + + Write-BinocularsStatus -Message "Searching $($DomainControllers.Count) writable DCs in $($Identity.DomainDnsName)." -StatusTextBox $StatusTextBox + Write-BinocularsStatus -Message "Query window: $($StartTime.ToString('yyyy-MM-dd HH:mm:ss')) to $($EndTime.ToString('yyyy-MM-dd HH:mm:ss'))." -StatusTextBox $StatusTextBox + + $rawQuery = Invoke-BinocularsParallelEventQuery -DomainControllers $domainControllers -StartTime $StartTime -EndTime $EndTime -StatusTextBox $StatusTextBox + $seenFingerprints = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + $records = New-Object System.Collections.Generic.List[object] + + foreach ($rawEvent in $rawQuery.RawEvents) { + if (-not $rawEvent.Xml) { + continue + } + + try { + $record = ConvertTo-BinocularsRecord -RawEvent $rawEvent -Identity $Identity + if ($record -and $seenFingerprints.Add($record.Fingerprint)) { + $records.Add($record) + } + } + catch { + $rawQuery.Errors += [pscustomobject]@{ + DomainController = if ($rawEvent.ComputerName) { $rawEvent.ComputerName } else { 'Unknown' } + ErrorType = 'Parse' + Message = "Failed to parse event $($rawEvent.Id): $($_.Exception.Message)" + } + } + } + + $sortedRecords = $records | Sort-Object -Property TimeCreated -Descending + if ($rawQuery.Cancelled) { + Write-BinocularsStatus -Message 'Search cancelled before all DC queries finished.' -StatusTextBox $StatusTextBox + } + Write-BinocularsStatus -Message "Matched $($sortedRecords.Count) events for $($Identity.SamAccountName)." -StatusTextBox $StatusTextBox + + return [pscustomobject]@{ + Records = @($sortedRecords) + Errors = @($rawQuery.Errors) + SuccessfulDomainControllers = @($rawQuery.SuccessfulDomainControllers) + DomainControllers = @($domainControllers) + Identity = $Identity + StartTime = $StartTime + EndTime = $EndTime + QueriedAt = Get-Date + Cancelled = $rawQuery.Cancelled + } +} + +function Write-BinocularsArchive { + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]]$Records, + [Parameter(Mandatory)] + [psobject]$Identity + ) + + if (-not $Records) { + return $null + } + + $archiveRoot = Get-BinocularsArchiveRoot + $fileStamp = Get-Date -Format 'yyyyMMdd' + $safeUser = ConvertTo-BinocularsSafeFileName -Value ('{0}-{1}' -f $Identity.DomainDnsName, $Identity.SamAccountName) + $archivePath = Join-Path $archiveRoot ('Binoculars-{0}-{1}.csv' -f $safeUser, $fileStamp) + $exportRows = $Records | Select-Object TimeCreated, DomainController, EventId, EventName, Category, Outcome, User, UserDomain, SourceHost, SourceIP, LogonType, Status, StatusText, Summary, RecordId, Fingerprint, RawFields + + if (Test-Path -Path $archivePath) { + $exportRows | Export-Csv -Path $archivePath -NoTypeInformation -Append + } + else { + $exportRows | Export-Csv -Path $archivePath -NoTypeInformation + } + + return $archivePath +} + +function Format-BinocularsRecordDetails { + param( + [Parameter(Mandatory)] + [psobject]$Record + ) + + $lines = @( + 'Time: {0}' -f $Record.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss') + 'Domain Controller: {0}' -f $Record.DomainController + 'Event ID: {0}' -f $Record.EventId + 'Event Name: {0}' -f $Record.EventName + 'Category: {0}' -f $Record.Category + 'Outcome: {0}' -f $Record.Outcome + 'User: {0}' -f $Record.User + 'User Domain: {0}' -f $Record.UserDomain + 'Source Host: {0}' -f $Record.SourceHost + 'Source IP: {0}' -f $Record.SourceIP + 'Logon Type: {0}' -f $Record.LogonType + 'Status: {0}' -f $Record.Status + 'Status Text: {0}' -f $Record.StatusText + 'Record ID: {0}' -f $Record.RecordId + 'Fingerprint: {0}' -f $Record.Fingerprint + '' + 'Summary' + '-------' + $Record.Summary + '' + 'Raw Fields' + '----------' + (($Record.RawFields -split '; ') -join [Environment]::NewLine) + ) + + return ($lines -join [Environment]::NewLine) +} + +function New-BinocularsGridTable { + $table = New-Object System.Data.DataTable + + $columns = @( + 'TimeCreated', + 'DomainController', + 'EventId', + 'EventName', + 'Outcome', + 'User', + 'SourceHost', + 'SourceIP', + 'LogonType', + 'Status', + 'Summary' + ) + + foreach ($column in $columns) { + [void]$table.Columns.Add($column) + } + + return $table +} + +function Set-BinocularsGridData { + param( + [Parameter(Mandatory)] + [object]$Grid, + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]]$Records + ) + + $table = New-BinocularsGridTable + foreach ($record in $Records) { + $row = $table.NewRow() + $row.TimeCreated = $record.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss') + $row.DomainController = $record.DomainController + $row.EventId = [string]$record.EventId + $row.EventName = $record.EventName + $row.Outcome = $record.Outcome + $row.User = $record.User + $row.SourceHost = $record.SourceHost + $row.SourceIP = $record.SourceIP + $row.LogonType = $record.LogonType + $row.Status = $record.Status + $row.Summary = $record.Summary + [void]$table.Rows.Add($row) + } + + $Grid.DataSource = $table + + foreach ($column in $Grid.Columns) { + $column.SortMode = [System.Windows.Forms.DataGridViewColumnSortMode]::NotSortable + } + + if ($Grid.Columns['Summary']) { + $Grid.Columns['Summary'].AutoSizeMode = [System.Windows.Forms.DataGridViewAutoSizeColumnMode]::Fill + } + + if ($Grid.Columns['TimeCreated']) { + $Grid.Columns['TimeCreated'].Width = 150 + } + if ($Grid.Columns['DomainController']) { + $Grid.Columns['DomainController'].Width = 170 + } + if ($Grid.Columns['EventId']) { + $Grid.Columns['EventId'].Width = 70 + } + if ($Grid.Columns['Outcome']) { + $Grid.Columns['Outcome'].Width = 80 + } + + foreach ($row in $Grid.Rows) { + switch ([string]$row.Cells['Outcome'].Value) { + 'Failure' { + $row.DefaultCellStyle.BackColor = [System.Drawing.Color]::MistyRose + } + 'Lockout' { + $row.DefaultCellStyle.BackColor = [System.Drawing.Color]::Moccasin + } + 'Success' { + $row.DefaultCellStyle.BackColor = [System.Drawing.Color]::Honeydew + } + 'Logoff' { + $row.DefaultCellStyle.BackColor = [System.Drawing.Color]::WhiteSmoke + } + 'Unlock' { + $row.DefaultCellStyle.BackColor = [System.Drawing.Color]::AliceBlue + } + } + } +} + +function New-BinocularsStatusImageList { + $imageList = New-Object System.Windows.Forms.ImageList + $imageList.ColorDepth = [System.Windows.Forms.ColorDepth]::Depth32Bit + $imageList.ImageSize = New-Object System.Drawing.Size(16, 16) + + $definitions = @( + @{ Key = 'green'; Color = [System.Drawing.Color]::ForestGreen } + @{ Key = 'red'; Color = [System.Drawing.Color]::Firebrick } + @{ Key = 'unknown'; Color = [System.Drawing.Color]::DarkGoldenrod } + ) + + foreach ($definition in $definitions) { + $bitmap = New-Object System.Drawing.Bitmap 16, 16 + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + $brush = New-Object System.Drawing.SolidBrush $definition.Color + $pen = New-Object System.Drawing.Pen ([System.Drawing.Color]::DimGray) + + try { + $graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias + $graphics.Clear([System.Drawing.Color]::Transparent) + $graphics.FillEllipse($brush, 2, 2, 11, 11) + $graphics.DrawEllipse($pen, 2, 2, 11, 11) + [void]$imageList.Images.Add($definition.Key, $bitmap) + } + finally { + $graphics.Dispose() + $brush.Dispose() + $pen.Dispose() + } + } + + return $imageList +} + +function Set-BinocularsDcListData { + param( + [Parameter(Mandatory)] + [object]$ListView, + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]]$Inventory + ) + + $ListView.BeginUpdate() + $ListView.Items.Clear() + + foreach ($domainController in @($Inventory | Sort-Object -Property HostName)) { + $stateText = switch ($domainController.IsReachable) { + $true { 'Green' } + $false { 'Red' } + default { 'Unknown' } + } + + $lastChecked = if ($domainController.LastChecked) { + $domainController.LastChecked.ToString('yyyy-MM-dd HH:mm:ss') + } + else { + '' + } + + $notes = if ($domainController.IsReachable -eq $false) { + $domainController.LastError + } + elseif ($domainController.IsReachable -eq $true) { + 'Included in searches' + } + else { + 'Awaiting health check' + } + + $item = New-Object System.Windows.Forms.ListViewItem($domainController.HostName) + $item.ImageKey = if ($domainController.StatusIconKey) { $domainController.StatusIconKey } else { 'unknown' } + [void]$item.SubItems.Add($stateText) + [void]$item.SubItems.Add($domainController.Role) + [void]$item.SubItems.Add($lastChecked) + [void]$item.SubItems.Add($notes) + $item.Tag = $domainController + + if ($domainController.IsPdcEmulator) { + $item.Font = New-Object System.Drawing.Font($ListView.Font, [System.Drawing.FontStyle]::Bold) + } + + if ($domainController.IsReachable -eq $false) { + $item.BackColor = [System.Drawing.Color]::MistyRose + } + elseif ($domainController.IsReachable -eq $true) { + $item.BackColor = [System.Drawing.Color]::Honeydew + } + else { + $item.BackColor = [System.Drawing.Color]::LemonChiffon + } + + [void]$ListView.Items.Add($item) + } + + $ListView.EndUpdate() +} + +function Export-BinocularsResults { + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]]$Records, + [string]$SuggestedFileName + ) + + if (-not $Records) { + throw 'There are no results to export.' + } + + $saveDialog = New-Object System.Windows.Forms.SaveFileDialog + $saveDialog.Filter = 'CSV files (*.csv)|*.csv' + $saveDialog.InitialDirectory = Get-BinocularsArchiveRoot + $saveDialog.FileName = $SuggestedFileName + + if ($saveDialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { + $Records | + Select-Object TimeCreated, DomainController, EventId, EventName, Category, Outcome, User, UserDomain, SourceHost, SourceIP, LogonType, Status, StatusText, Summary, RecordId, Fingerprint, RawFields | + Export-Csv -Path $saveDialog.FileName -NoTypeInformation + + return $saveDialog.FileName + } + + return $null +} + +function Start-BinocularsHeadless { + param( + [Parameter(Mandatory)] + [string]$UserName, + [Parameter(Mandatory)] + [int]$LookbackMinutes, + [string]$ExportPath + ) + + $identity = Resolve-BinocularsIdentity -UserInput $UserName + $inventory = @(Get-BinocularsDomainControllerInventory) + $script:cancelSearchRequested = $false + [void](Invoke-BinocularsParallelDomainControllerHealthCheck -Inventory $inventory -ForceRecheck) + $searchableControllers = @(Get-BinocularsSearchableDomainControllers -Inventory $inventory) + if (-not $searchableControllers) { + throw 'No reachable writable DCs are currently available.' + } + + $endTime = Get-Date + $startTime = $endTime.AddMinutes(-$LookbackMinutes) + $result = Invoke-BinocularsSearch -Identity $identity -StartTime $startTime -EndTime $endTime -DomainControllers $searchableControllers + + if ($result.Records.Count -gt 0) { + $result.Records | + Select-Object TimeCreated, DomainController, EventId, EventName, Outcome, SourceHost, SourceIP, LogonType, Summary | + Format-Table -AutoSize + } + else { + Write-Host "No matching events found for $($identity.SamAccountName)." + } + + $archivePath = Write-BinocularsArchive -Records $result.Records -Identity $identity + if ($archivePath) { + Write-Host "Archived to $archivePath" + } + + if ($ExportPath) { + $result.Records | + Select-Object TimeCreated, DomainController, EventId, EventName, Category, Outcome, User, UserDomain, SourceHost, SourceIP, LogonType, Status, StatusText, Summary, RecordId, Fingerprint, RawFields | + Export-Csv -Path $ExportPath -NoTypeInformation + Write-Host "Exported to $ExportPath" + } + + if ($result.Errors.Count -gt 0) { + Write-Warning ("{0} DC query/parsing errors were recorded." -f $result.Errors.Count) + $result.Errors | Select-Object DomainController, Message | Format-Table -AutoSize + } +} + +function Show-BinocularsGui { + Add-Type -AssemblyName System.Windows.Forms + Add-Type -AssemblyName System.Drawing + + $script:sessionRecords = @() + $script:displayedRecords = @() + $script:sessionFingerprints = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + $script:lastArchivePath = $null + $script:lastPollEnd = $null + $script:watchMode = $false + $script:queryInProgress = $false + $script:activeIdentity = $null + $script:cancelSearchRequested = $false + $script:domainControllerInventory = @() + + $form = New-Object System.Windows.Forms.Form + $form.Text = 'Binoculars' + $form.StartPosition = 'CenterScreen' + $form.Size = New-Object System.Drawing.Size(1520, 920) + $form.MinimumSize = New-Object System.Drawing.Size(1340, 780) + $form.KeyPreview = $true + + $topPanel = New-Object System.Windows.Forms.Panel + $topPanel.Dock = 'Top' + $topPanel.Height = 120 + + $titleLabel = New-Object System.Windows.Forms.Label + $titleLabel.Text = 'Binoculars - Writable DC Authentication Tracker' + $titleLabel.Font = New-Object System.Drawing.Font('Segoe UI', 12, [System.Drawing.FontStyle]::Bold) + $titleLabel.Location = New-Object System.Drawing.Point(12, 10) + $titleLabel.AutoSize = $true + $topPanel.Controls.Add($titleLabel) + + $userLabel = New-Object System.Windows.Forms.Label + $userLabel.Text = 'User' + $userLabel.Location = New-Object System.Drawing.Point(15, 45) + $userLabel.AutoSize = $true + $topPanel.Controls.Add($userLabel) + + $userTextBox = New-Object System.Windows.Forms.TextBox + $userTextBox.Location = New-Object System.Drawing.Point(60, 42) + $userTextBox.Size = New-Object System.Drawing.Size(220, 23) + $userTextBox.Text = $UserName + $topPanel.Controls.Add($userTextBox) + + $lookbackLabel = New-Object System.Windows.Forms.Label + $lookbackLabel.Text = 'Lookback (min)' + $lookbackLabel.Location = New-Object System.Drawing.Point(300, 45) + $lookbackLabel.AutoSize = $true + $topPanel.Controls.Add($lookbackLabel) + + $lookbackUpDown = New-Object System.Windows.Forms.NumericUpDown + $lookbackUpDown.Location = New-Object System.Drawing.Point(395, 42) + $lookbackUpDown.Minimum = 5 + $lookbackUpDown.Maximum = 1440 + $lookbackUpDown.Value = [decimal]$LookbackMinutes + $lookbackUpDown.Size = New-Object System.Drawing.Size(70, 23) + $topPanel.Controls.Add($lookbackUpDown) + + $intervalLabel = New-Object System.Windows.Forms.Label + $intervalLabel.Text = 'Refresh (sec)' + $intervalLabel.Location = New-Object System.Drawing.Point(485, 45) + $intervalLabel.AutoSize = $true + $topPanel.Controls.Add($intervalLabel) + + $intervalUpDown = New-Object System.Windows.Forms.NumericUpDown + $intervalUpDown.Location = New-Object System.Drawing.Point(575, 42) + $intervalUpDown.Minimum = 15 + $intervalUpDown.Maximum = 900 + $intervalUpDown.Value = 60 + $intervalUpDown.Size = New-Object System.Drawing.Size(70, 23) + $topPanel.Controls.Add($intervalUpDown) + + $searchButton = New-Object System.Windows.Forms.Button + $searchButton.Text = 'Search Now' + $searchButton.Location = New-Object System.Drawing.Point(660, 40) + $searchButton.Size = New-Object System.Drawing.Size(90, 28) + $topPanel.Controls.Add($searchButton) + + $stopSearchButton = New-Object System.Windows.Forms.Button + $stopSearchButton.Text = 'Stop Search' + $stopSearchButton.Location = New-Object System.Drawing.Point(755, 40) + $stopSearchButton.Size = New-Object System.Drawing.Size(90, 28) + $stopSearchButton.Enabled = $false + $topPanel.Controls.Add($stopSearchButton) + + $watchButton = New-Object System.Windows.Forms.Button + $watchButton.Text = 'Start Watch' + $watchButton.Location = New-Object System.Drawing.Point(850, 40) + $watchButton.Size = New-Object System.Drawing.Size(90, 28) + $topPanel.Controls.Add($watchButton) + + $stopWatchButton = New-Object System.Windows.Forms.Button + $stopWatchButton.Text = 'Stop Watch' + $stopWatchButton.Location = New-Object System.Drawing.Point(945, 40) + $stopWatchButton.Size = New-Object System.Drawing.Size(90, 28) + $stopWatchButton.Enabled = $false + $topPanel.Controls.Add($stopWatchButton) + + $exportButton = New-Object System.Windows.Forms.Button + $exportButton.Text = 'Export CSV' + $exportButton.Location = New-Object System.Drawing.Point(1040, 40) + $exportButton.Size = New-Object System.Drawing.Size(90, 28) + $exportButton.Enabled = $false + $topPanel.Controls.Add($exportButton) + + $refreshDcButton = New-Object System.Windows.Forms.Button + $refreshDcButton.Text = 'Refresh DCs' + $refreshDcButton.Location = New-Object System.Drawing.Point(1135, 40) + $refreshDcButton.Size = New-Object System.Drawing.Size(95, 28) + $topPanel.Controls.Add($refreshDcButton) + + $archiveButton = New-Object System.Windows.Forms.Button + $archiveButton.Text = 'Open Archive' + $archiveButton.Location = New-Object System.Drawing.Point(1235, 40) + $archiveButton.Size = New-Object System.Drawing.Size(100, 28) + $topPanel.Controls.Add($archiveButton) + + $summaryLabel = New-Object System.Windows.Forms.Label + $summaryLabel.Text = 'No results loaded.' + $summaryLabel.Location = New-Object System.Drawing.Point(15, 76) + $summaryLabel.Size = New-Object System.Drawing.Size(1460, 18) + $topPanel.Controls.Add($summaryLabel) + + $infoLabel = New-Object System.Windows.Forms.Label + $infoLabel.Text = 'DCs are strongest for auth, failed auth and lockout events. Logoff visibility is limited to sessions the DC itself records.' + $infoLabel.Location = New-Object System.Drawing.Point(15, 96) + $infoLabel.Size = New-Object System.Drawing.Size(1460, 18) + $infoLabel.ForeColor = [System.Drawing.Color]::DimGray + $topPanel.Controls.Add($infoLabel) + + $mainSplit = New-Object System.Windows.Forms.SplitContainer + $mainSplit.Dock = 'Fill' + $mainSplit.Orientation = 'Horizontal' + $mainSplit.SplitterDistance = 560 + + $eventSplit = New-Object System.Windows.Forms.SplitContainer + $eventSplit.Dock = 'Fill' + $eventSplit.Orientation = 'Vertical' + $eventSplit.SplitterDistance = 1030 + + $grid = New-Object System.Windows.Forms.DataGridView + $grid.Dock = 'Fill' + $grid.ReadOnly = $true + $grid.AllowUserToAddRows = $false + $grid.AllowUserToDeleteRows = $false + $grid.AllowUserToResizeRows = $false + $grid.MultiSelect = $false + $grid.SelectionMode = 'FullRowSelect' + $grid.RowHeadersVisible = $false + $grid.AutoSizeRowsMode = 'AllCells' + $grid.BackgroundColor = [System.Drawing.Color]::White + $eventSplit.Panel1.Controls.Add($grid) + + $dcPanel = New-Object System.Windows.Forms.Panel + $dcPanel.Dock = 'Fill' + + $dcHeaderPanel = New-Object System.Windows.Forms.Panel + $dcHeaderPanel.Dock = 'Top' + $dcHeaderPanel.Height = 56 + $dcPanel.Controls.Add($dcHeaderPanel) + + $dcHeaderLabel = New-Object System.Windows.Forms.Label + $dcHeaderLabel.Text = 'Writable Domain Controllers' + $dcHeaderLabel.Location = New-Object System.Drawing.Point(3, 3) + $dcHeaderLabel.Size = New-Object System.Drawing.Size(300, 18) + $dcHeaderLabel.Font = New-Object System.Drawing.Font('Segoe UI', 9, [System.Drawing.FontStyle]::Bold) + $dcHeaderPanel.Controls.Add($dcHeaderLabel) + + $dcInfoLabel = New-Object System.Windows.Forms.Label + $dcInfoLabel.Text = 'Green = queried, Red = skipped until Refresh DCs, Bold = PDC emulator' + $dcInfoLabel.Location = New-Object System.Drawing.Point(3, 24) + $dcInfoLabel.Size = New-Object System.Drawing.Size(360, 28) + $dcInfoLabel.ForeColor = [System.Drawing.Color]::DimGray + $dcInfoLabel.AutoEllipsis = $true + $dcHeaderPanel.Controls.Add($dcInfoLabel) + + $dcImageList = New-BinocularsStatusImageList + $dcListView = New-Object System.Windows.Forms.ListView + $dcListView.Dock = 'Fill' + $dcListView.View = 'Details' + $dcListView.FullRowSelect = $true + $dcListView.GridLines = $true + $dcListView.HideSelection = $false + $dcListView.SmallImageList = $dcImageList + [void]$dcListView.Columns.Add('DC', 170) + [void]$dcListView.Columns.Add('State', 70) + [void]$dcListView.Columns.Add('Role', 95) + [void]$dcListView.Columns.Add('Checked', 125) + [void]$dcListView.Columns.Add('Notes', 250) + $dcPanel.Controls.Add($dcListView) + $dcListView.BringToFront() + + $eventSplit.Panel2.Controls.Add($dcPanel) + $mainSplit.Panel1.Controls.Add($eventSplit) + + $bottomSplit = New-Object System.Windows.Forms.SplitContainer + $bottomSplit.Dock = 'Fill' + $bottomSplit.Orientation = 'Vertical' + $bottomSplit.SplitterDistance = 850 + + $detailsTextBox = New-Object System.Windows.Forms.TextBox + $detailsTextBox.Dock = 'Fill' + $detailsTextBox.Multiline = $true + $detailsTextBox.ScrollBars = 'Both' + $detailsTextBox.ReadOnly = $true + $detailsTextBox.Font = New-Object System.Drawing.Font('Consolas', 9) + $bottomSplit.Panel1.Controls.Add($detailsTextBox) + + $statusTextBox = New-Object System.Windows.Forms.TextBox + $statusTextBox.Dock = 'Fill' + $statusTextBox.Multiline = $true + $statusTextBox.ScrollBars = 'Vertical' + $statusTextBox.ReadOnly = $true + $statusTextBox.Font = New-Object System.Drawing.Font('Consolas', 9) + $bottomSplit.Panel2.Controls.Add($statusTextBox) + + $mainSplit.Panel2.Controls.Add($bottomSplit) + + $statusStrip = New-Object System.Windows.Forms.StatusStrip + $statusLabel = New-Object System.Windows.Forms.ToolStripStatusLabel + $statusLabel.Text = 'Ready' + [void]$statusStrip.Items.Add($statusLabel) + + $timer = New-Object System.Windows.Forms.Timer + + $setBusyState = { + param([bool]$Busy) + + $script:queryInProgress = $Busy + $searchButton.Enabled = -not $Busy -and -not $script:watchMode + $stopSearchButton.Enabled = $Busy + $watchButton.Enabled = -not $Busy -and -not $script:watchMode + $stopWatchButton.Enabled = $script:watchMode + $exportButton.Enabled = (-not $Busy) -and ($script:sessionRecords.Count -gt 0) + $refreshDcButton.Enabled = -not $Busy -and -not $script:watchMode + $userTextBox.Enabled = -not $script:watchMode -and -not $Busy + $lookbackUpDown.Enabled = -not $script:watchMode -and -not $Busy + $intervalUpDown.Enabled = -not $script:watchMode -and -not $Busy + $archiveButton.Enabled = -not $Busy + + if ($Busy) { + $form.Cursor = [System.Windows.Forms.Cursors]::WaitCursor + $statusLabel.Text = 'Query in progress...' + } + else { + $form.Cursor = [System.Windows.Forms.Cursors]::Default + if ($script:watchMode) { + $statusLabel.Text = 'Watch mode active' + } + else { + $statusLabel.Text = 'Ready' + } + } + } + + $refreshDcInventory = { + param( + [bool]$ForceRecheck + ) + + if (-not $script:domainControllerInventory -or $script:domainControllerInventory.Count -eq 0) { + $script:domainControllerInventory = @(Get-BinocularsDomainControllerInventory) + Set-BinocularsDcListData -ListView $dcListView -Inventory $script:domainControllerInventory + } + + $healthResult = Invoke-BinocularsParallelDomainControllerHealthCheck -Inventory $script:domainControllerInventory -ForceRecheck:$ForceRecheck -StatusTextBox $statusTextBox + Set-BinocularsDcListData -ListView $dcListView -Inventory $script:domainControllerInventory + + if ($healthResult.Cancelled) { + Write-BinocularsStatus -Message 'DC health check cancelled.' -StatusTextBox $statusTextBox + return $false + } + + return $true + } + + $updateSummary = { + param( + [psobject]$Result + ) + + $records = @($script:sessionRecords) + $inventory = @($script:domainControllerInventory) + $successCount = @($records | Where-Object { $_.Outcome -eq 'Success' }).Count + $failureCount = @($records | Where-Object { $_.Outcome -eq 'Failure' }).Count + $lockoutCount = @($records | Where-Object { $_.Category -eq 'Lockout' }).Count + $unlockCount = @($records | Where-Object { $_.Category -eq 'Unlock' }).Count + $logoffCount = @($records | Where-Object { $_.Category -eq 'Logoff' }).Count + $errorCount = if ($Result) { @($Result.Errors).Count } else { 0 } + $reachableCount = @($inventory | Where-Object { $_.IsReachable -eq $true }).Count + $unreachableCount = @($inventory | Where-Object { $_.IsReachable -eq $false }).Count + $unknownCount = @($inventory | Where-Object { $null -eq $_.IsReachable }).Count + $pdcEntry = @($inventory | Where-Object { $_.IsPdcEmulator } | Select-Object -First 1) + $resolvedUser = if ($script:activeIdentity) { "$($script:activeIdentity.DisplayName) [$($script:activeIdentity.SamAccountName)]" } else { 'No user selected' } + $windowText = if ($Result) { + '{0} -> {1}' -f $Result.StartTime.ToString('yyyy-MM-dd HH:mm:ss'), $Result.EndTime.ToString('yyyy-MM-dd HH:mm:ss') + } + else { + 'N/A' + } + $pdcText = if ($pdcEntry) { $pdcEntry[0].HostName } else { 'N/A' } + $searchedDcCount = if ($Result) { $Result.DomainControllers.Count } else { $reachableCount } + + $summaryLabel.Text = 'User: {0} | Search DCs: {1} | Green: {2} | Red: {3} | Unknown: {4} | PDC: {5} | Events: {6} | Success: {7} | Failure: {8} | Lockout: {9} | Unlock: {10} | Logoff: {11} | Errors: {12} | Window: {13}' -f ` + $resolvedUser, ` + $searchedDcCount, ` + $reachableCount, ` + $unreachableCount, ` + $unknownCount, ` + $pdcText, ` + $records.Count, ` + $successCount, ` + $failureCount, ` + $lockoutCount, ` + $unlockCount, ` + $logoffCount, ` + $errorCount, ` + $windowText + } + + $refreshGridSelection = { + if ($grid.Rows.Count -gt 0) { + $grid.Rows[0].Selected = $true + $detailsTextBox.Text = Format-BinocularsRecordDetails -Record $script:displayedRecords[0] + } + else { + $detailsTextBox.Clear() + } + } + + $resetSession = { + $script:sessionRecords = @() + $script:displayedRecords = @() + $script:sessionFingerprints = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + $script:lastArchivePath = $null + $script:lastPollEnd = $null + $script:activeIdentity = $null + $script:cancelSearchRequested = $false + Set-BinocularsGridData -Grid $grid -Records @() + $detailsTextBox.Clear() + $exportButton.Enabled = $false + $summaryLabel.Text = 'No results loaded.' + } + + $runSearch = { + param( + [bool]$Incremental + ) + + if ($script:queryInProgress) { + return $false + } + + try { + & $setBusyState $true + $script:cancelSearchRequested = $false + $statusTextBox.Clear() + + if (-not $Incremental) { + & $resetSession + } + + $identity = if ($Incremental -and $script:activeIdentity) { + $script:activeIdentity + } + else { + Resolve-BinocularsIdentity -UserInput $userTextBox.Text + } + + $script:activeIdentity = $identity + $endTime = Get-Date + if ($Incremental -and $script:lastPollEnd) { + $startTime = $script:lastPollEnd.AddSeconds(-1 * $script:PollOverlapSeconds) + } + else { + $startTime = $endTime.AddMinutes(-1 * [int]$lookbackUpDown.Value) + } + + Write-BinocularsStatus -Message "Tracking $($identity.DisplayName) [$($identity.SamAccountName)]" -StatusTextBox $statusTextBox + if (-not (& $refreshDcInventory $false)) { + & $updateSummary $null + return $false + } + + $searchableControllers = @(Get-BinocularsSearchableDomainControllers -Inventory $script:domainControllerInventory) + $skippedControllers = @($script:domainControllerInventory | Where-Object { $_.IsReachable -eq $false } | Select-Object -ExpandProperty HostName) + $pdcEntry = @($script:domainControllerInventory | Where-Object { $_.IsPdcEmulator } | Select-Object -First 1) + + if ($pdcEntry) { + Write-BinocularsStatus -Message "PDC emulator: $($pdcEntry[0].HostName)" -StatusTextBox $statusTextBox + } + if ($skippedControllers.Count -gt 0) { + Write-BinocularsStatus -Message "Skipping $($skippedControllers.Count) red DCs: $($skippedControllers -join ', ')" -StatusTextBox $statusTextBox + } + if (-not $searchableControllers) { + throw 'All writable DCs are currently marked unreachable. Use Refresh DCs to recheck them.' + } + + $result = Invoke-BinocularsSearch -Identity $identity -StartTime $startTime -EndTime $endTime -DomainControllers $searchableControllers -StatusTextBox $statusTextBox + foreach ($successfulDc in $result.SuccessfulDomainControllers) { + Set-BinocularsDomainControllerState -Inventory $script:domainControllerInventory -HostName $successfulDc -Reachable $true + } + foreach ($queryError in @($result.Errors | Where-Object { $_.ErrorType -eq 'Query' })) { + Set-BinocularsDomainControllerState -Inventory $script:domainControllerInventory -HostName $queryError.DomainController -Reachable $false -Message $queryError.Message + } + Set-BinocularsDcListData -ListView $dcListView -Inventory $script:domainControllerInventory + $newRecords = New-Object System.Collections.Generic.List[object] + + foreach ($record in $result.Records) { + if ($script:sessionFingerprints.Add($record.Fingerprint)) { + $newRecords.Add($record) + } + } + + if ($newRecords.Count -gt 0) { + $script:sessionRecords = @($script:sessionRecords + $newRecords) | Sort-Object -Property TimeCreated -Descending + $script:displayedRecords = @($script:sessionRecords) + Set-BinocularsGridData -Grid $grid -Records $script:displayedRecords + & $refreshGridSelection + $script:lastArchivePath = Write-BinocularsArchive -Records @($newRecords) -Identity $identity + Write-BinocularsStatus -Message "Archived $($newRecords.Count) new events to $script:lastArchivePath" -StatusTextBox $statusTextBox + } + else { + Write-BinocularsStatus -Message 'No new matching events found in this window.' -StatusTextBox $statusTextBox + if (-not $Incremental) { + Set-BinocularsGridData -Grid $grid -Records @() + } + } + + if ($result.Errors.Count -gt 0) { + foreach ($errorRecord in $result.Errors) { + $errorPrefix = if ($errorRecord.ErrorType -eq 'Parse') { 'Parse error' } else { 'DC error' } + Write-BinocularsStatus -Message "$errorPrefix [$($errorRecord.DomainController)]: $($errorRecord.Message)" -StatusTextBox $statusTextBox + } + } + + if (-not $result.Cancelled) { + $script:lastPollEnd = $result.EndTime + } + & $updateSummary $result + $exportButton.Enabled = $script:sessionRecords.Count -gt 0 + return (-not $result.Cancelled) + } + catch { + if ($script:cancelSearchRequested) { + Write-BinocularsStatus -Message 'Search cancelled after stop request.' -StatusTextBox $statusTextBox + } + elseif ($script:watchMode) { + $timer.Stop() + $script:watchMode = $false + $stopWatchButton.Enabled = $false + Write-BinocularsStatus -Message 'Watch mode stopped because the search failed.' -StatusTextBox $statusTextBox + } + else { + [System.Windows.Forms.MessageBox]::Show($_.Exception.Message, 'Binoculars', [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Error) | Out-Null + } + Write-BinocularsStatus -Message "Search failed: $($_.Exception.Message)" -StatusTextBox $statusTextBox + Set-BinocularsDcListData -ListView $dcListView -Inventory $script:domainControllerInventory + & $updateSummary $null + return $false + } + finally { + & $setBusyState $false + } + } + + $grid.Add_SelectionChanged({ + if ($grid.SelectedRows.Count -eq 0) { + return + } + + $selectedIndex = $grid.SelectedRows[0].Index + if ($selectedIndex -ge 0 -and $selectedIndex -lt $script:displayedRecords.Count) { + $detailsTextBox.Text = Format-BinocularsRecordDetails -Record $script:displayedRecords[$selectedIndex] + } + }) + + $searchButton.Add_Click({ + if ($script:watchMode) { + return + } + + & $runSearch $false + }) + + $stopSearchButton.Add_Click({ + if (-not $script:queryInProgress) { + return + } + + $script:cancelSearchRequested = $true + $stopSearchButton.Enabled = $false + $statusLabel.Text = 'Cancelling search...' + Write-BinocularsStatus -Message 'Stop requested. Cancelling the active search.' -StatusTextBox $statusTextBox + }) + + $watchButton.Add_Click({ + if ($script:queryInProgress) { + return + } + + $timer.Interval = [int]$intervalUpDown.Value * 1000 + $script:watchMode = $true + $stopWatchButton.Enabled = $true + $statusLabel.Text = 'Watch mode active' + Write-BinocularsStatus -Message "Watch mode started. Polling every $([int]$intervalUpDown.Value) seconds." -StatusTextBox $statusTextBox + $initialRunSucceeded = & $runSearch $false + if ($initialRunSucceeded) { + $timer.Start() + } + else { + $script:watchMode = $false + $stopWatchButton.Enabled = $false + & $setBusyState $false + } + }) + + $stopWatchButton.Add_Click({ + $timer.Stop() + $script:watchMode = $false + $stopWatchButton.Enabled = $false + Write-BinocularsStatus -Message 'Watch mode stopped.' -StatusTextBox $statusTextBox + & $setBusyState $false + }) + + $refreshDcButton.Add_Click({ + if ($script:queryInProgress -or $script:watchMode) { + return + } + + try { + & $setBusyState $true + $script:cancelSearchRequested = $false + $statusTextBox.Clear() + if (& $refreshDcInventory $true) { + Write-BinocularsStatus -Message 'DC health refresh completed.' -StatusTextBox $statusTextBox + } + & $updateSummary $null + } + catch { + [System.Windows.Forms.MessageBox]::Show($_.Exception.Message, 'Refresh DCs', [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Error) | Out-Null + Write-BinocularsStatus -Message "DC refresh failed: $($_.Exception.Message)" -StatusTextBox $statusTextBox + } + finally { + & $setBusyState $false + } + }) + + $timer.Add_Tick({ + if ($script:queryInProgress) { + return + } + + & $runSearch $true + }) + + $exportButton.Add_Click({ + try { + $defaultName = 'Binoculars-{0}-{1}.csv' -f $script:activeIdentity.SamAccountName, (Get-Date -Format 'yyyyMMdd-HHmmss') + $exportedPath = Export-BinocularsResults -Records $script:sessionRecords -SuggestedFileName $defaultName + if ($exportedPath) { + Write-BinocularsStatus -Message "Exported results to $exportedPath" -StatusTextBox $statusTextBox + } + } + catch { + [System.Windows.Forms.MessageBox]::Show($_.Exception.Message, 'Export', [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Error) | Out-Null + } + }) + + $archiveButton.Add_Click({ + $archiveRoot = Get-BinocularsArchiveRoot + Start-Process -FilePath 'explorer.exe' -ArgumentList "`"$archiveRoot`"" + }) + + $form.Add_FormClosing({ + if ($script:queryInProgress) { + $script:cancelSearchRequested = $true + } + $timer.Stop() + }) + + $form.Add_KeyDown({ + if ($_.KeyCode -eq [System.Windows.Forms.Keys]::Enter -and -not $script:watchMode -and -not $script:queryInProgress) { + & $runSearch $false + } + }) + + $form.Controls.Add($mainSplit) + $form.Controls.Add($topPanel) + $form.Controls.Add($statusStrip) + + Set-BinocularsGridData -Grid $grid -Records @() + try { + $script:domainControllerInventory = @(Get-BinocularsDomainControllerInventory) + Set-BinocularsDcListData -ListView $dcListView -Inventory $script:domainControllerInventory + & $updateSummary $null + } + catch { + Write-BinocularsStatus -Message "Unable to load DC inventory: $($_.Exception.Message)" -StatusTextBox $statusTextBox + } + [void]$form.ShowDialog() +} + +try { + Import-Module ActiveDirectory -ErrorAction Stop +} +catch { + if (-not $NoGui) { + Add-Type -AssemblyName System.Windows.Forms + [System.Windows.Forms.MessageBox]::Show('The ActiveDirectory PowerShell module is required for Binoculars.', 'Binoculars', [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Error) | Out-Null + } + + throw +} + +if ($NoGui) { + if ([string]::IsNullOrWhiteSpace($UserName)) { + throw 'Use -UserName when running Binoculars.ps1 with -NoGui.' + } + + Start-BinocularsHeadless -UserName $UserName -LookbackMinutes $LookbackMinutes -ExportPath $ExportPath +} +else { + Show-BinocularsGui +}