From 747e519760590f5de1cac16a91e6e18548c150b6 Mon Sep 17 00:00:00 2001 From: Rephl3x Date: Wed, 15 Apr 2026 14:46:03 +1200 Subject: [PATCH] Add Binoculars query diagnostics and parked note --- Binoculars.ps1 | 223 ++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 194 insertions(+), 29 deletions(-) diff --git a/Binoculars.ps1 b/Binoculars.ps1 index 7c2d8d2..2f6fab2 100644 --- a/Binoculars.ps1 +++ b/Binoculars.ps1 @@ -7,6 +7,11 @@ param( [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 @@ -1030,37 +1035,172 @@ function Invoke-BinocularsParallelEventQuery { [int[]]$EventIds ) - try { + $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 = $EventIds + ID = $selectedIds 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() - Message = $_.Message - Error = $null - } - } + Get-WinEvent -ComputerName $ComputerName -FilterHashtable $filter -ErrorAction Stop } - catch { - [pscustomobject]@{ - ComputerName = $ComputerName - Id = $null - RecordId = $null - TimeCreated = $null - Xml = $null - Message = $null - Error = $_.Exception.Message + 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) @@ -1095,22 +1235,36 @@ function Invoke-BinocularsParallelEventQuery { 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 ($jobRows.Count -eq 1 -and $jobRows[0].Error) { + 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 = $jobRows[0].Error + Message = $errorRow.Error }) - Write-BinocularsStatus -Message "Failed to query $($job.DomainController): $($jobRows[0].Error)" -StatusTextBox $StatusTextBox + Write-BinocularsStatus -Message "Failed to query $($job.DomainController): $($errorRow.Error)" -StatusTextBox $StatusTextBox } else { $successfulDomainControllers.Add($job.DomainController) | Out-Null - foreach ($row in $jobRows) { + foreach ($row in $eventRows) { $rawEvents.Add($row) } - Write-BinocularsStatus -Message "Queried $($job.DomainController): $($jobRows.Count) candidate events returned." -StatusTextBox $StatusTextBox + 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 { @@ -1197,6 +1351,7 @@ function Invoke-BinocularsSearch { 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] @@ -1221,6 +1376,13 @@ function Invoke-BinocularsSearch { } $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 } @@ -1232,6 +1394,7 @@ function Invoke-BinocularsSearch { SuccessfulDomainControllers = @($rawQuery.SuccessfulDomainControllers) DomainControllers = @($domainControllers) EventIds = @($EventIds | Sort-Object -Unique) + RawEventCount = $candidateCount Identity = $Identity StartTime = $StartTime EndTime = $EndTime @@ -1934,8 +2097,9 @@ function Show-BinocularsGui { } $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} | Events: {7} | Success: {8} | Failure: {9} | Lockout: {10} | Unlock: {11} | Logoff: {12} | Errors: {13} | Window: {14}' -f ` + $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, ` @@ -1943,6 +2107,7 @@ function Show-BinocularsGui { $unreachableCount, ` $unknownCount, ` $pdcText, ` + $rawEventCount, ` $records.Count, ` $successCount, ` $failureCount, `