[CmdletBinding()] param( [string]$UserName = '', [ValidateRange(5, 1440)] [int]$LookbackMinutes = 30, [switch]$NoGui, [string]$ExportPath ) # Note [2026-04-15]: # Binoculars auth collection is still not returning usable events in the target environment. # The current build includes fallback DC query strategies and extra diagnostics, but the issue is parked for follow-up. # Start the next round with All Users + Lockouts and Failures and review the status pane candidate counts per DC. $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:EventFilterGroups = [ordered]@{ 'Successful logon (4624)' = @(4624) 'Failed logon (4625)' = @(4625) 'Lockout (4740)' = @(4740) 'Unlock (4767)' = @(4767) 'Logoff (4634, 4647)' = @(4634, 4647) 'Kerberos tickets (4768, 4769, 4770)' = @(4768, 4769, 4770) 'Kerberos pre-auth failure (4771)' = @(4771) 'NTLM validation (4776)' = @(4776) } $script:EventProfiles = [ordered]@{ 'All Authentication' = @($script:RelevantEventIds) 'Lockouts and Failures' = @(4625, 4740, 4767, 4771, 4776) 'Successful Sign-Ins' = @(4624, 4768, 4769, 4770) 'Session Trail' = @(4624, 4625, 4634, 4647, 4740, 4767) 'Kerberos Focus' = @(4768, 4769, 4770, 4771) 'NTLM Focus' = @(4776) 'Custom' = @() } $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)' } function Write-BinocularsStatus { param( [Parameter(Mandatory)] [string]$Message, [object]$StatusTextBox ) $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 } } function Stop-BinocularsPowerShellInstance { param( [Parameter(Mandatory)] [object]$PowerShellInstance ) if ($null -eq $PowerShellInstance) { return } 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-BinocularsEventIdSetKey { param( [AllowEmptyCollection()] [int[]]$EventIds ) return ((@($EventIds | Sort-Object -Unique)) -join ',') } function Set-BinocularsEventFilterCheckBoxes { param( [Parameter(Mandatory)] [System.Collections.IDictionary]$CheckBoxMap, [AllowEmptyCollection()] [int[]]$EventIds ) $selectedIds = @($EventIds | Sort-Object -Unique) foreach ($entry in $CheckBoxMap.GetEnumerator()) { $checkBox = $entry.Value $groupIds = @($checkBox.Tag) $checkBox.Checked = (@($groupIds | Where-Object { $_ -in $selectedIds }).Count -eq $groupIds.Count) } } function Get-BinocularsSelectedEventIds { param( [Parameter(Mandatory)] [System.Collections.IDictionary]$CheckBoxMap ) $selectedIds = New-Object System.Collections.Generic.List[int] foreach ($checkBox in $CheckBoxMap.Values) { if (-not $checkBox.Checked) { continue } foreach ($eventId in @($checkBox.Tag)) { if (-not $selectedIds.Contains([int]$eventId)) { $selectedIds.Add([int]$eventId) | Out-Null } } } return @($selectedIds | Sort-Object) } function Get-BinocularsMatchingProfileName { param( [AllowEmptyCollection()] [int[]]$EventIds ) $targetKey = Get-BinocularsEventIdSetKey -EventIds $EventIds foreach ($profileName in $script:EventProfiles.Keys) { if ($profileName -eq 'Custom') { continue } if ((Get-BinocularsEventIdSetKey -EventIds $script:EventProfiles[$profileName]) -eq $targetKey) { return $profileName } } return 'Custom' } function Get-BinocularsEventFilterSummary { param( [AllowEmptyCollection()] [int[]]$EventIds ) $profileName = Get-BinocularsMatchingProfileName -EventIds $EventIds if ($profileName -ne 'Custom') { return $profileName } return 'Custom [{0}]' -f ((@($EventIds | Sort-Object -Unique)) -join ',') } 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)] [AllowEmptyString()] [string]$UserInput ) $trimmedInput = $UserInput.Trim() $domain = Get-ADDomain -ErrorAction Stop if ([string]::IsNullOrWhiteSpace($trimmedInput)) { return [pscustomobject]@{ Input = '' SamAccountName = 'All Users' UserPrincipalName = '' Sid = '' DisplayName = 'All Users' DomainDnsName = $domain.DNSRoot DomainNetBIOSName = $domain.NetBIOSName SearchTerms = (New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)) SearchAllUsers = $true } } $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 SearchAllUsers = $false } } 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, [string]$RawMessage ) if ($Identity.SearchAllUsers) { return $true } $candidateValues = New-Object System.Collections.Generic.HashSet[string] ([System.StringComparer]::OrdinalIgnoreCase) $priorityKeys = @( 'TargetUserName', 'TargetAccountName', 'TargetOutboundUserName', 'TargetSid', 'TargetUserSid', 'LogonAccount', 'MappedAccountName', 'SamAccountName', 'MemberName', 'MemberSid', 'SubjectUserName', 'SubjectAccountName', 'AccountName', 'UserName', 'SubjectUserSid', 'UserSid' ) 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 } } } $looseNeedles = @( $Identity.SamAccountName, $Identity.UserPrincipalName, $Identity.Sid, "$($Identity.DomainNetBIOSName)\$($Identity.SamAccountName)", "$($Identity.SamAccountName)@$($Identity.DomainDnsName)" ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique $combinedTextParts = New-Object System.Collections.Generic.List[string] foreach ($value in $candidateValues) { if (-not [string]::IsNullOrWhiteSpace($value)) { $combinedTextParts.Add($value) | Out-Null } } foreach ($value in $DataMap.Values) { if (-not [string]::IsNullOrWhiteSpace([string]$value)) { $combinedTextParts.Add(([string]$value).Trim().ToLowerInvariant()) | Out-Null } } if (-not [string]::IsNullOrWhiteSpace($RawMessage)) { $combinedTextParts.Add($RawMessage.Trim().ToLowerInvariant()) | Out-Null } $combinedText = ($combinedTextParts | Select-Object -Unique) -join ' ' foreach ($needle in $looseNeedles) { $normalizedNeedle = $needle.Trim().ToLowerInvariant() if ([string]::IsNullOrWhiteSpace($normalizedNeedle)) { continue } if ($combinedText -like "*$normalizedNeedle*") { return $true } if ($normalizedNeedle.Contains('\')) { $shortNeedle = $normalizedNeedle.Split('\')[-1] if ($combinedText -like "*$shortNeedle*") { return $true } } if ($normalizedNeedle.Contains('@')) { $samLikeNeedle = $normalizedNeedle.Split('@')[0] if ($combinedText -like "*$samLikeNeedle*") { 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 -RawMessage $RawEvent.Message)) { 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 RawMessage = $RawEvent.Message } } 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 ) $emitRow = { param( [Parameter(Mandatory)] $EventRecord, [Parameter(Mandatory)] [string]$QueryStrategy ) $xmlText = $null try { $xmlText = $EventRecord.ToXml() } catch { $xmlText = $null } $messageText = $null try { $messageText = $EventRecord.FormatDescription() } catch { try { $messageText = $EventRecord.Message } catch { $messageText = $null } } return [pscustomobject]@{ ComputerName = $ComputerName Id = [int]$EventRecord.Id RecordId = [long]$EventRecord.RecordId TimeCreated = $EventRecord.TimeCreated Xml = $xmlText Message = $messageText QueryStrategy = $QueryStrategy Diagnostics = $null IsMeta = $false Error = $null } } $executeStrategy = { param( [Parameter(Mandatory)] [string]$StrategyName, [Parameter(Mandatory)] [scriptblock]$Body ) $rows = New-Object System.Collections.Generic.List[object] try { foreach ($eventRecord in @(& $Body)) { if ($null -eq $eventRecord) { continue } $rows.Add((& $emitRow -EventRecord $eventRecord -QueryStrategy $StrategyName)) | Out-Null } return [pscustomobject]@{ Strategy = $StrategyName Rows = @($rows) Error = $null } } catch { return [pscustomobject]@{ Strategy = $StrategyName Rows = @() Error = $_.Exception.Message } } } $diagnostics = New-Object System.Collections.Generic.List[string] $selectedIds = @($EventIds | Sort-Object -Unique) $startUtcText = $StartTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') $endUtcText = $EndTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') $idPredicates = (($selectedIds | ForEach-Object { "EventID=$_"} ) -join ' or ') $hashtableResult = & $executeStrategy -StrategyName 'FilterHashtable' -Body { $filter = @{ LogName = 'Security' ID = $selectedIds StartTime = $StartTime EndTime = $EndTime } Get-WinEvent -ComputerName $ComputerName -FilterHashtable $filter -ErrorAction Stop } if ($hashtableResult.Error) { $diagnostics.Add("FilterHashtable failed: $($hashtableResult.Error)") | Out-Null } elseif ($hashtableResult.Rows.Count -gt 0) { return @($hashtableResult.Rows) } else { $diagnostics.Add('FilterHashtable returned 0 rows.') | Out-Null } $filterXmlText = @" "@ $filterXmlResult = & $executeStrategy -StrategyName 'FilterXml' -Body { Get-WinEvent -ComputerName $ComputerName -FilterXml ([xml]$filterXmlText) -ErrorAction Stop } if ($filterXmlResult.Error) { $diagnostics.Add("FilterXml failed: $($filterXmlResult.Error)") | Out-Null } elseif ($filterXmlResult.Rows.Count -gt 0) { return @($filterXmlResult.Rows) } else { $diagnostics.Add('FilterXml returned 0 rows.') | Out-Null } $broadResult = & $executeStrategy -StrategyName 'TimeWindowLocalIdFilter' -Body { $broadFilter = @{ LogName = 'Security' StartTime = $StartTime EndTime = $EndTime } foreach ($eventRecord in @(Get-WinEvent -ComputerName $ComputerName -FilterHashtable $broadFilter -ErrorAction Stop)) { if ([int]$eventRecord.Id -in $selectedIds) { $eventRecord } } } if ($broadResult.Error) { $diagnostics.Add("TimeWindowLocalIdFilter failed: $($broadResult.Error)") | Out-Null return [pscustomobject]@{ ComputerName = $ComputerName Id = $null RecordId = $null TimeCreated = $null Xml = $null Message = $null QueryStrategy = 'Error' Diagnostics = ($diagnostics -join ' ') IsMeta = $true Error = $broadResult.Error } } if ($broadResult.Rows.Count -gt 0) { return @($broadResult.Rows) } $diagnostics.Add('TimeWindowLocalIdFilter returned 0 rows.') | Out-Null return [pscustomobject]@{ ComputerName = $ComputerName Id = $null RecordId = $null TimeCreated = $null Xml = $null Message = $null QueryStrategy = 'NoResults' Diagnostics = ($diagnostics -join ' ') IsMeta = $true Error = $null } }).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) $errorRows = @($jobRows | Where-Object { $_.Error }) $eventRows = @($jobRows | Where-Object { -not $_.Error -and -not $_.IsMeta }) $metaRows = @($jobRows | Where-Object { $_.IsMeta }) if ($errorRows.Count -gt 0 -and $eventRows.Count -eq 0) { $errorRow = @($errorRows | Select-Object -First 1)[0] $errors.Add([pscustomobject]@{ DomainController = $job.DomainController ErrorType = 'Query' Message = $errorRow.Error }) Write-BinocularsStatus -Message "Failed to query $($job.DomainController): $($errorRow.Error)" -StatusTextBox $StatusTextBox } else { $successfulDomainControllers.Add($job.DomainController) | Out-Null foreach ($row in $eventRows) { $rawEvents.Add($row) } if ($eventRows.Count -gt 0) { $strategyName = @($eventRows | Select-Object -First 1 -ExpandProperty QueryStrategy) Write-BinocularsStatus -Message "Queried $($job.DomainController): $($eventRows.Count) candidate events returned via $strategyName." -StatusTextBox $StatusTextBox } else { $diagnosticsText = @($metaRows | Select-Object -First 1 -ExpandProperty Diagnostics) if ([string]::IsNullOrWhiteSpace($diagnosticsText)) { $diagnosticsText = 'All query strategies returned 0 rows.' } Write-BinocularsStatus -Message "Queried $($job.DomainController): 0 candidate events returned. $diagnosticsText" -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, [AllowEmptyCollection()] [int[]]$EventIds = $script:RelevantEventIds, [object]$StatusTextBox ) if (-not $DomainControllers) { throw 'No reachable writable DCs are currently available. Use Refresh DCs to recheck unreachable servers.' } if (-not $EventIds) { throw 'Select at least one event type to search.' } 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 -EventIds $EventIds -StatusTextBox $StatusTextBox $candidateCount = @($rawQuery.RawEvents).Count $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 ($candidateCount -eq 0) { Write-BinocularsStatus -Message 'No candidate events were returned from the DCs before user matching. This usually points to event selection, time window, or auditing on the DCs.' -StatusTextBox $StatusTextBox } elseif (-not $Identity.SearchAllUsers -and $sortedRecords.Count -eq 0) { Write-BinocularsStatus -Message "DCs returned $candidateCount candidate events, but none matched the current user filter." -StatusTextBox $StatusTextBox } 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) EventIds = @($EventIds | Sort-Object -Unique) RawEventCount = $candidateCount 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, RawMessage 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 Message' '-----------' $Record.RawMessage '' '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, RawMessage | 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, RawMessage | 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:activeEventIds = @($script:RelevantEventIds) $script:activeEventFilterSummary = Get-BinocularsEventFilterSummary -EventIds $script:activeEventIds $script:cancelSearchRequested = $false $script:domainControllerInventory = @() $script:updatingEventFilters = $false $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 = 170 $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) $eventProfileLabel = New-Object System.Windows.Forms.Label $eventProfileLabel.Text = 'Event Profile' $eventProfileLabel.Location = New-Object System.Drawing.Point(15, 80) $eventProfileLabel.AutoSize = $true $topPanel.Controls.Add($eventProfileLabel) $eventProfileComboBox = New-Object System.Windows.Forms.ComboBox $eventProfileComboBox.Location = New-Object System.Drawing.Point(90, 76) $eventProfileComboBox.Size = New-Object System.Drawing.Size(185, 23) $eventProfileComboBox.DropDownStyle = 'DropDownList' foreach ($profileName in $script:EventProfiles.Keys) { [void]$eventProfileComboBox.Items.Add($profileName) } $topPanel.Controls.Add($eventProfileComboBox) $eventFilterFlowPanel = New-Object System.Windows.Forms.FlowLayoutPanel $eventFilterFlowPanel.Location = New-Object System.Drawing.Point(290, 72) $eventFilterFlowPanel.Size = New-Object System.Drawing.Size(1170, 36) $eventFilterFlowPanel.WrapContents = $true $eventFilterFlowPanel.AutoScroll = $true $eventFilterFlowPanel.FlowDirection = 'LeftToRight' $eventFilterFlowPanel.Padding = New-Object System.Windows.Forms.Padding(0) $eventFilterFlowPanel.Margin = New-Object System.Windows.Forms.Padding(0) $topPanel.Controls.Add($eventFilterFlowPanel) $eventFilterCheckBoxes = [ordered]@{} foreach ($groupName in $script:EventFilterGroups.Keys) { $checkBox = New-Object System.Windows.Forms.CheckBox $checkBox.Text = $groupName $checkBox.AutoSize = $true $checkBox.Margin = New-Object System.Windows.Forms.Padding(0, 6, 14, 0) $checkBox.Tag = @($script:EventFilterGroups[$groupName]) $eventFilterFlowPanel.Controls.Add($checkBox) $eventFilterCheckBoxes[$groupName] = $checkBox } $summaryLabel = New-Object System.Windows.Forms.Label $summaryLabel.Text = 'No results loaded.' $summaryLabel.Location = New-Object System.Drawing.Point(15, 118) $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, 140) $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 $eventProfileComboBox.Enabled = -not $script:watchMode -and -not $Busy $archiveButton.Enabled = -not $Busy foreach ($filterCheckBox in $eventFilterCheckBoxes.Values) { $filterCheckBox.Enabled = -not $script:watchMode -and -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) { if ($script:activeIdentity.SearchAllUsers) { 'All Users' } else { "$($script:activeIdentity.DisplayName) [$($script:activeIdentity.SamAccountName)]" } } else { 'No user selected' } $filterText = if ($script:activeEventFilterSummary) { $script:activeEventFilterSummary } else { 'Default' } $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 } $rawEventCount = if ($Result) { [int]$Result.RawEventCount } else { 0 } $summaryLabel.Text = 'User: {0} | Filter: {1} | Search DCs: {2} | Green: {3} | Red: {4} | Unknown: {5} | PDC: {6} | Candidates: {7} | Events: {8} | Success: {9} | Failure: {10} | Lockout: {11} | Unlock: {12} | Logoff: {13} | Errors: {14} | Window: {15}' -f ` $resolvedUser, ` $filterText, ` $searchedDcCount, ` $reachableCount, ` $unreachableCount, ` $unknownCount, ` $pdcText, ` $rawEventCount, ` $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:activeEventIds = @(Get-BinocularsSelectedEventIds -CheckBoxMap $eventFilterCheckBoxes) $script:activeEventFilterSummary = Get-BinocularsEventFilterSummary -EventIds $script:activeEventIds $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 } $selectedEventIds = @(Get-BinocularsSelectedEventIds -CheckBoxMap $eventFilterCheckBoxes) if (-not $selectedEventIds) { throw 'Select at least one event type before searching.' } $script:activeIdentity = $identity $script:activeEventIds = @($selectedEventIds) $script:activeEventFilterSummary = Get-BinocularsEventFilterSummary -EventIds $script:activeEventIds $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 Write-BinocularsStatus -Message "Event filter: $($script:activeEventFilterSummary)" -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 -EventIds $selectedEventIds -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 } } $syncEventFilterProfile = { $selectedIds = @(Get-BinocularsSelectedEventIds -CheckBoxMap $eventFilterCheckBoxes) $matchedProfile = Get-BinocularsMatchingProfileName -EventIds $selectedIds $script:updatingEventFilters = $true $eventProfileComboBox.SelectedItem = $matchedProfile $script:updatingEventFilters = $false $script:activeEventIds = @($selectedIds) $script:activeEventFilterSummary = Get-BinocularsEventFilterSummary -EventIds $selectedIds if (-not $script:queryInProgress) { & $updateSummary $null } } $eventProfileComboBox.Add_SelectedIndexChanged({ if ($script:updatingEventFilters) { return } $selectedProfile = [string]$eventProfileComboBox.SelectedItem if ([string]::IsNullOrWhiteSpace($selectedProfile) -or $selectedProfile -eq 'Custom') { return } $script:updatingEventFilters = $true Set-BinocularsEventFilterCheckBoxes -CheckBoxMap $eventFilterCheckBoxes -EventIds $script:EventProfiles[$selectedProfile] $script:updatingEventFilters = $false $script:activeEventIds = @($script:EventProfiles[$selectedProfile] | Sort-Object -Unique) $script:activeEventFilterSummary = $selectedProfile if (-not $script:queryInProgress) { & $updateSummary $null } }) foreach ($filterCheckBox in $eventFilterCheckBoxes.Values) { $filterCheckBox.Add_CheckedChanged({ if ($script:updatingEventFilters) { return } & $syncEventFilterProfile }) } $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) $script:updatingEventFilters = $true Set-BinocularsEventFilterCheckBoxes -CheckBoxMap $eventFilterCheckBoxes -EventIds $script:RelevantEventIds $eventProfileComboBox.SelectedItem = 'All Authentication' $script:updatingEventFilters = $false $script:activeEventIds = @($script:RelevantEventIds) $script:activeEventFilterSummary = 'All Authentication' 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) { Start-BinocularsHeadless -UserName $UserName -LookbackMinutes $LookbackMinutes -ExportPath $ExportPath } else { Show-BinocularsGui }