2319 lines
83 KiB
PowerShell
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
|
|
}
|