Загрузка данных
function Get-DCLogonEvents {
<#
.SYNOPSIS
Ищет события 4624 для списка учётных записей: один XPath на одну УЗ,
до $ThrottleLimit параллельных запросов одновременно.
.DESCRIPTION
Каждая задача = (учётная запись × DC). Задача исполняется в отдельном
runspace из пула. Внутри runspace выполняется Get-WinEvent -ComputerName
-FilterXPath: фильтрация происходит на самом DC, клиенту передаются
только совпавшие события.
Задачи, превысившие $TimeoutSeconds, принудительно останавливаются
(PS.Stop()), результат по ним отбрасывается — чтобы не блокировать этап.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][string[]]$AccountSams,
[Parameter(Mandatory)][string]$DomainName,
[int]$Days = 30,
[int]$MaxEventsPerDC = 100000,
[int]$TimeoutSeconds = 300,
[int]$ThrottleLimit = 10
)
$results = New-Object System.Collections.Generic.List[object]
if (-not $AccountSams -or $AccountSams.Count -eq 0) {
Write-ScanLog " Нет учётных записей — этап пропущен." -Level Warning
return $results
}
# ---------- Список DC ----------
Write-ScanLog " Поиск DC для домена '$DomainName'..."
try {
$dcs = @(Get-ADDomainController -Server $DomainName -Filter * -ErrorAction Stop |
Select-Object -ExpandProperty Name)
} catch {
Write-ScanLog " ОШИБКА при получении списка DC: $_" -Level Error
return $results
}
if ($dcs.Count -eq 0) {
Write-ScanLog " DC не найдены — этап пропущен." -Level Warning
return $results
}
Write-ScanLog " DC ($($dcs.Count)): $($dcs -join ', ')" -Level Success
# ---------- Построение списка задач (account × DC) ----------
$startTimeUtc = (Get-Date).AddDays(-$Days).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ")
# LogonType — общий фильтр для всех задач (строковый литерал, не зависит от УЗ)
$logonCond = "Data[@Name='LogonType' and (Data='3' or Data='4' or Data='5' " +
"or Data='8' or Data='9' or Data='10')]"
$tasks = New-Object System.Collections.Generic.List[object]
foreach ($acc in $AccountSams) {
# В XPath одинарная кавычка экранируется удвоением (''), НЕ через SecurityElement
$escaped = $acc.Replace("'", "''")
$userCond = "Data[@Name='TargetUserName' and (Data='$escaped')]"
# Итоговый XPath короткий — только одна УЗ. Проблема с лимитом длины исчезает.
$xpath = "*[System[(EventID=4624) and TimeCreated[@SystemTime>='$startTimeUtc']] " +
"and EventData[$userCond and $logonCond]]"
foreach ($dc in $dcs) {
$tasks.Add([PSCustomObject]@{
Account = $acc
DC = $dc
XPath = $xpath
})
}
}
Write-ScanLog " Задач к выполнению: $($tasks.Count) ($($AccountSams.Count) УЗ × $($dcs.Count) DC)"
Write-ScanLog " Параллельно: $ThrottleLimit, таймаут на задачу: ${TimeoutSeconds}с"
# ---------- Runspace pool ----------
$pool = [runspacefactory]::CreateRunspacePool(1, $ThrottleLimit)
$pool.Open()
$jobs = New-Object System.Collections.Generic.List[object]
$sw = [System.Diagnostics.Stopwatch]::StartNew()
try {
# Отправляем все задачи в пул. Пул сам ограничит параллелизм до $ThrottleLimit.
$submitted = 0
foreach ($task in $tasks) {
$ps = [powershell]::Create()
$ps.RunspacePool = $pool
$null = $ps.AddScript({
param($dc, $xpath, $max)
# Выполняется внутри runspace. Get-WinEvent -ComputerName работает
# по RPC, фильтрация XPath — на самом DC, поэтому передаётся только
# то, что реально совпало. Это критично для скорости.
try {
$evts = Get-WinEvent -ComputerName $dc -LogName 'Security' `
-FilterXPath $xpath -MaxEvents $max -ErrorAction Stop
$evts | ForEach-Object {
[PSCustomObject]@{
TargetUser = $_.Properties[5].Value
LogonType = $_.Properties[8].Value
SourceHost = $_.Properties[18].Value
}
}
} catch {
# «События не найдены» — штатная ситуация, не считаем ошибкой
if ($_.FullyQualifiedErrorId -match 'NoMatchingEventsFound' -or
$_.Exception.Message -match 'No events were found') {
return
}
[PSCustomObject]@{ Error = $_.Exception.Message }
}
}).AddParameter('dc', $task.DC) `
.AddParameter('xpath', $task.XPath) `
.AddParameter('max', $MaxEventsPerDC)
$handle = $ps.BeginInvoke()
$jobs.Add([PSCustomObject]@{
PS = $ps
Handle = $handle
Task = $task
StartTime = [DateTime]::UtcNow
Stopped = $false
})
$submitted++
# Логируем первые несколько отправок, чтобы было видно старт этапа
if ($submitted -le 5) {
Write-ScanLog " → $($task.Account) @ $($task.DC)"
}
}
# ---------- Ожидание с прогрессом и таймаутами ----------
$total = $jobs.Count
$lastBeat = [DateTime]::UtcNow
while ($true) {
$now = [DateTime]::UtcNow
$done = 0
$running = 0
$timedOut = 0
foreach ($job in $jobs) {
if ($job.Handle.IsCompleted) { $done++; continue }
if ($job.Stopped) { $timedOut++; continue }
if (($now - $job.StartTime).TotalSeconds -gt $TimeoutSeconds) {
Write-ScanLog " ТАЙМАУТ: $($job.Task.Account) @ $($job.Task.DC) (>${TimeoutSeconds}с) — прервано" -Level Warning
try { $job.PS.Stop() } catch {}
$job.Stopped = $true
$timedOut++
continue
}
$running++
}
$finished = $done + $timedOut
Write-Progress -Activity "Анализ журналов DC" `
-Status ("Завершено {0}/{1} | активно {2}, таймаут {3}, прошло {4}с" -f `
$finished, $total, $running, $timedOut, [int]$sw.Elapsed.TotalSeconds) `
-PercentComplete ($(if ($total) { $finished / $total * 100 } else { 100 }))
# Heartbeat раз в 10 секунд — даже если ничего не завершилось,
# в консоли видно, что процесс идёт.
if (($now - $lastBeat).TotalSeconds -ge 10) {
Write-ScanLog " [heartbeat] завершено $finished / $total, активно $running, таймаут $timedOut, $([int]$sw.Elapsed.TotalSeconds)с"
$lastBeat = $now
}
if ($finished -ge $total) { break }
Start-Sleep -Milliseconds 500
}
Write-Progress -Activity "Анализ журналов DC" -Completed
} finally {
# ---------- Сбор результатов ----------
$validSams = [System.Collections.Generic.HashSet[string]]::new(
[string[]]$AccountSams,
[System.StringComparer]::OrdinalIgnoreCase)
$eventCount = 0
$errCount = 0
$tasksWithHit = 0
foreach ($job in $jobs) {
$label = "$($job.Task.Account) @ $($job.Task.DC)"
try {
if ($job.Stopped) {
Write-ScanLog " [$label] прервано по таймауту" -Level Warning
continue
}
$out = @($job.PS.EndInvoke($job.Handle))
$taskEvents = 0
foreach ($ev in $out) {
if ($ev.Error) {
Write-ScanLog " [$label] ОШИБКА: $($ev.Error)" -Level Warning
$errCount++
continue
}
if (-not $ev.TargetUser) { continue }
if (-not $validSams.Contains($ev.TargetUser)) { continue }
$src = $ev.SourceHost
if (-not $src -or $src -eq '-') { continue }
$src = $src -replace '^\\\\', ''
$results.Add([PSCustomObject]@{
AccountSam = $ev.TargetUser
Server = $src
WhereUsed = "Журнал входа DC (4624, logon type $($ev.LogonType))"
})
$taskEvents++
}
if ($taskEvents -gt 0) {
$tasksWithHit++
Write-ScanLog " [$label] +$taskEvents событий" -Level Success
}
$eventCount += $taskEvents
} catch {
# Возможно, задача была остановлена — не считаем это критичной ошибкой
Write-ScanLog " [$label] не удалось получить результат: $($_.Exception.Message)" -Level Warning
} finally {
try { $job.PS.Dispose() } catch {}
}
}
$pool.Close()
$pool.Dispose()
$sw.Stop()
Write-ScanLog " Сводка: событий $eventCount из $tasksWithHit задач с совпадениями, ошибок $errCount, время $([int]$sw.Elapsed.TotalSeconds)с"
}
# ---------- Дедупликация ----------
# Одна и та же УЗ могла залогиниться с одного сервера на нескольких DC
# (репликация) — оставляем одну строку.
$seen = @{}
$deduped = New-Object System.Collections.Generic.List[object]
foreach ($r in $results) {
$k = "$($r.AccountSam)|$($r.Server)|$($r.WhereUsed)"
if (-not $seen.ContainsKey($k)) {
$seen[$k] = $true
$deduped.Add($r)
}
}
Write-ScanLog " Уникальных записей после дедупликации: $($deduped.Count)"
return $deduped
}