Files
Work-Tools/Binoculars.ps1
T

2319 lines
83 KiB
PowerShell

[CmdletBinding()]
param(
[string]$UserName = '',
[ValidateRange(5, 1440)]
[int]$LookbackMinutes = 30,
[switch]$NoGui,
[string]$ExportPath
)
$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
)
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()
Message = $_.Message
Error = $null
}
}
}
catch {
[pscustomobject]@{
ComputerName = $ComputerName
Id = $null
RecordId = $null
TimeCreated = $null
Xml = $null
Message = $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,
[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
$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)
EventIds = @($EventIds | Sort-Object -Unique)
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 }
$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 `
$resolvedUser, `
$filterText, `
$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: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
}