Загрузка данных
<#
.SYNOPSIS
Инвентаризация использования сервисных учётных записей AD (v2 — с оптимизацией DC-логов).
#>
#region ===== Конфигурация =====
$DomainName = "mydomain.organization.ru"
$OUUserServices = "OU=ServiceAccounts,DC=mydomain,DC=organization,DC=ru"
$OUServers = "OU=Servers,DC=mydomain,DC=organization,DC=ru"
$ResultUserAccounts = "C:\Reports\UserAccounts.csv"
# Глубина ретроспективы журналов DC.
$LogRetentionDays = 30
# Таймаут на один DC (секунды). Если DC не ответит за это время — пропускается.
$DCTimeoutSeconds = 300
# Жёсткий лимит событий с одного DC (защита от OOM при очень больших журналах).
$MaxEventsPerDC = 100000
#endregion
#region ===== Утилита логирования =====
function Write-ScanLog {
<#
.SYNOPSIS
Единая точка вывода сообщений в консоль с временной меткой и цветом.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory, Position=0)][string]$Message,
[ValidateSet('Info','Warning','Error','Success','Header')][string]$Level = 'Info'
)
$ts = Get-Date -Format "HH:mm:ss"
$color = switch ($Level) {
'Info' { 'Gray' }
'Warning' { 'Yellow' }
'Error' { 'Red' }
'Success' { 'Green' }
'Header' { 'Cyan' }
}
if ($Level -eq 'Header') {
Write-Host ""
Write-Host ("=" * 78) -ForegroundColor $color
Write-Host "== $Message" -ForegroundColor $color
Write-Host ("=" * 78) -ForegroundColor $color
} else {
Write-Host "[$ts] $Message" -ForegroundColor $color
}
}
#endregion
#region ===== Функции сбора данных =====
function Get-TargetAccounts {
<#
.SYNOPSIS
Возвращает список сервисных учётных записей из OU (с рекурсией).
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$DomainName,
[Parameter(Mandatory)][string]$OUUserServices
)
Write-ScanLog "Чтение учётных записей из $OUUserServices ..."
try {
$acc = @(Get-ADUser -Server $DomainName -SearchBase $OUUserServices -SearchScope Subtree `
-Filter { Enabled -eq $true } `
-Properties DisplayName, SamAccountName, UserPrincipalName |
Select-Object @{n='DisplayName'; e={ if ($_.DisplayName) { $_.DisplayName } else { $_.SamAccountName } }},
SamAccountName, UserPrincipalName)
Write-ScanLog " Получено учётных записей: $($acc.Count)" -Level Success
return $acc
} catch {
Write-ScanLog " ОШИБКА: $_" -Level Error
return @()
}
}
function Get-TargetServers {
<#
.SYNOPSIS
Возвращает список DNS-имён серверов из OU (с рекурсией).
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$DomainName,
[Parameter(Mandatory)][string]$OUServers
)
Write-ScanLog "Чтение серверов из $OUServers ..."
try {
$srv = @(Get-ADComputer -Server $DomainName -SearchBase $OUServers -SearchScope Subtree `
-Filter { Enabled -eq $true } -Properties DNSHostName |
Select-Object -ExpandProperty DNSHostName -ErrorAction SilentlyContinue)
Write-ScanLog " Получено серверов: $($srv.Count)" -Level Success
return $srv
} catch {
Write-ScanLog " ОШИБКА: $_" -Level Error
return @()
}
}
function Resolve-AccountFromIdentity {
<#
.SYNOPSIS
Сопоставляет строку Identity (DOMAIN\user, user@domain, user) с объектом УЗ.
#>
[CmdletBinding()]
param([string]$Identity, [Parameter(Mandatory)][hashtable]$Lookup)
if ([string]::IsNullOrWhiteSpace($Identity)) { return $null }
$id = $Identity.Trim()
$short = (($id -split '\\')[-1] -split '@')[0].ToLower()
if ($Lookup.ContainsKey($short)) { return $Lookup[$short] }
if ($Lookup.ContainsKey($id.ToLower())) { return $Lookup[$id.ToLower()] }
return $null
}
function Get-ServerServices {
<#
.SYNOPSIS
Возвращает службы Windows с удалённого сервера (Win32_Service).
#>
[CmdletBinding()]
param([Parameter(Mandatory)][string]$ComputerName)
try {
Get-CimInstance -ClassName Win32_Service -ComputerName $ComputerName -ErrorAction Stop |
Select-Object Name, DisplayName, StartName
} catch {
Write-ScanLog " [службы] $ComputerName недоступен: $($_.Exception.Message)" -Level Warning
return $null
}
}
function Get-ServerScheduledTasks {
<#
.SYNOPSIS
Возвращает задачи планировщика с удалённого сервера (schtasks).
#>
[CmdletBinding()]
param([Parameter(Mandatory)][string]$ComputerName)
try {
$raw = schtasks /query /s $ComputerName /fo CSV /v 2>$null
if (-not $raw) { return $null }
$raw | ConvertFrom-Csv |
Where-Object { $_."Run As User" -and $_."Run As User" -ne 'N/A' } |
Select-Object TaskName, @{n='StartName'; e={ $_."Run As User" }}
} catch {
Write-ScanLog " [планировщик] $ComputerName недоступен: $($_.Exception.Message)" -Level Warning
return $null
}
}
function Get-ServerIISAppPools {
<#
.SYNOPSIS
Возвращает пулы приложений IIS с их identity.
#>
[CmdletBinding()]
param([Parameter(Mandatory)][string]$ComputerName)
try {
Invoke-Command -ComputerName $ComputerName -ErrorAction Stop -ScriptBlock {
Import-Module WebAdministration -ErrorAction Stop
Get-ChildItem IIS:\AppPools | ForEach-Object {
$identity = $_.processModel.userName
if ($identity) { [PSCustomObject]@{ Name = $_.Name; StartName = $identity } }
}
}
} catch {
# Молча пропускаем: сервер без IIS — нормальная ситуация.
return $null
}
}
#endregion
#region ===== Оптимизированный анализ журналов DC =====
function Get-DCLogonEvents {
<#
.SYNOPSIS
Ищет события 4624 (успешный вход) для заданных учётных записей на всех DC параллельно.
.DESCRIPTION
Ключевые отличия от первой версии:
* XPath-фильтр, который применяется САМИМ DC (server-side), а не в PowerShell:
- EventID = 4624
- TimeCreated >= <start UTC>
- TargetUserName IN (список)
- LogonType IN (3,4,5,8,9,10)
* Один запрос на DC для всех учётных записей (а не N×M).
* Запуск через Invoke-Command -AsJob на все DC параллельно.
* Глобальный Wait-Job -Timeout — если часть DC «залипла», через
$TimeoutSeconds + 120 с мы просто забираем результат с тех, кто ответил.
* Сбор через `Receive-Job` с ограничением MaxEventsPerDC.
Возвращает плоский список объектов { AccountSam, Server, WhereUsed }.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][string[]]$AccountSams,
[Parameter(Mandatory)][string]$DomainName,
[int]$Days = 30,
[int]$MaxEventsPerDC = 100000,
[int]$TimeoutSeconds = 300
)
$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
# --- Построение XPath (серверная фильтрация!) ---
$startTimeUtc = (Get-Date).AddDays(-$Days).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ")
$escapedUsers = $AccountSams | ForEach-Object { [System.Security.SecurityElement]::Escape($_) }
$userCond = ($escapedUsers | ForEach-Object { "Data[@Name='TargetUserName']='$_'" }) -join ' or '
$logonCond = (@('3','4','5','8','9','10') | ForEach-Object { "Data[@Name='LogonType']='$_'" }) -join ' or '
# ВАЖНО: оба условия находятся внутри предиката `*[...]` над корневым элементом Event.
$xpath = "*[System[(EventID=4624) and TimeCreated[@SystemTime>='$startTimeUtc']] " +
"and EventData[($userCond) and ($logonCond)]]"
Write-ScanLog " XPath готов: УЗ=$($AccountSams.Count), LogonType=3,4,5,8,9,10, с $startTimeUtc"
Write-ScanLog " Параллельный опрос $($dcs.Count) DC (таймаут ${TimeoutSeconds}с на операцию)..."
# --- PSSessionOption с таймаутами ---
$sessionOpt = New-PSSessionOption `
-OpenTimeout ($TimeoutSeconds * 1000) `
-OperationTimeoutSec $TimeoutSeconds `
-CancelTimeout ($TimeoutSeconds * 1000) `
-IdleTimeoutSec ($TimeoutSeconds * 2)
# --- Запуск параллельно на всех DC ---
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$jobs = @()
try {
$jobs = @(Invoke-Command -ComputerName $dcs -SessionOption $sessionOpt `
-ArgumentList $xpath, $MaxEventsPerDC `
-ScriptBlock {
param($xpath, $max)
# Серверная сторона: Get-WinEvent исполняется ЛОКАЛЬНО на DC,
# XPath фильтрует журнал САМИМ сервисом EventLog.
try {
$events = Get-WinEvent -LogName 'Security' -FilterXPath $xpath `
-MaxEvents $max -ErrorAction Stop
} catch {
# «События не найдены» — штатный случай, не считаем ошибкой.
if ($_.FullyQualifiedErrorId -match 'NoMatchingEventsFound') { return }
Write-Warning "[$env:COMPUTERNAME] $($_.Exception.Message)"
return
}
# Оставляем только нужные поля — меньше трафика через remoting.
$events | ForEach-Object {
[PSCustomObject]@{
TargetUser = $_.Properties[5].Value
LogonType = $_.Properties[8].Value
SourceHost = $_.Properties[18].Value
}
}
} -AsJob)
} catch {
Write-ScanLog " ОШИБКА запуска заданий: $_" -Level Error
$sw.Stop()
return $results
}
# --- Глобальный дедлайн: ждём не более TimeoutSeconds + 120 с ---
$globalTimeout = $TimeoutSeconds + 120
$null = Wait-Job -Job $jobs -Timeout $globalTimeout | Out-Null
$sw.Stop()
# --- Сбор результатов ---
$rawResults = @()
foreach ($job in $jobs) {
$dcName = if ($job.Name -match 'DC\s+(.+)$') { $Matches[1] } else { $job.Name }
if ($job.State -eq 'Completed') {
$dcOut = @(Receive-Job -Job $job -ErrorAction SilentlyContinue -ErrorVariable dcErr)
Write-ScanLog " $dcName : событий $($dcOut.Count) (готово)" -Level Success
$rawResults += $dcOut
foreach ($e in $dcErr) {
Write-ScanLog " $dcName : $($e.Exception.Message)" -Level Warning
}
} elseif ($job.State -eq 'Running') {
Write-ScanLog " $dcName : ТАЙМАУТ ($globalTimeout с) — пропущен" -Level Warning
} else {
Write-ScanLog " $dcName : состояние $($job.State)" -Level Warning
}
Remove-Job -Job $job -Force -ErrorAction SilentlyContinue
}
Write-ScanLog " Всего получено событий: $($rawResults.Count) за $($sw.Elapsed.TotalSeconds.ToString('0.0'))с"
# --- Преобразование в итоговые записи ---
$validSams = [System.Collections.Generic.HashSet[string]]::new(
[string[]]$AccountSams, [System.StringComparer]::OrdinalIgnoreCase)
foreach ($ev in $rawResults) {
if (-not $ev.TargetUser -or -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))"
})
}
return $results
}
#endregion
#region ===== Слияние с CSV =====
function Merge-ScanResults {
<#
.SYNOPSIS
Слияние новых результатов с существующим CSV (без дублей, с сохранением истории).
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$CsvPath,
[Parameter(Mandatory)][AllowEmptyCollection()][System.Collections.Generic.List[object]]$NewResults
)
$now = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$merged = @{}
if (Test-Path $CsvPath) {
Write-ScanLog "Загрузка существующего CSV: $CsvPath"
$existing = @(Import-Csv -Path $CsvPath -Encoding UTF8)
Write-ScanLog " Строк в файле: $($existing.Count)"
foreach ($row in $existing) {
$k = "$($row.'Логин')|$($row.'Имя сервера')|$($row.'Где используется')"
$merged[$k] = $row
}
} else {
Write-ScanLog "Существующий CSV отсутствует — создаётся новый."
}
$added = 0; $updated = 0
foreach ($newRow in $NewResults) {
$k = "$($newRow.'Логин')|$($newRow.'Имя сервера')|$($newRow.'Где используется')"
if ($merged.ContainsKey($k)) {
$merged[$k].'Дата получения информации' = $now
if ($newRow.'Имя учетной записи') {
$merged[$k].'Имя учетной записи' = $newRow.'Имя учетной записи'
}
$updated++
} else {
$merged[$k] = [PSCustomObject]@{
'Имя учетной записи' = $newRow.'Имя учетной записи'
'Логин' = $newRow.'Логин'
'Имя сервера' = $newRow.'Имя сервера'
'Где используется' = $newRow.'Где используется'
'Дата получения информации' = $now
}
$added++
}
}
$dir = Split-Path $CsvPath -Parent
if ($dir -and -not (Test-Path $dir)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
}
$merged.Values |
Sort-Object 'Логин', 'Имя сервера', 'Где используется' |
Export-Csv -Path $CsvPath -NoTypeInformation -Encoding UTF8
Write-ScanLog " Добавлено: $added; обновлено: $updated; всего строк: $($merged.Count)" -Level Success
}
#endregion
#region ===== Оркестратор =====
function Invoke-ServiceAccountScan {
<#
.SYNOPSIS
Запускает полный цикл сканирования и обновления CSV.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$DomainName,
[Parameter(Mandatory)][string]$OUUserServices,
[Parameter(Mandatory)][string]$OUServers,
[Parameter(Mandatory)][string]$ResultPath,
[int]$LogDays = 30,
[int]$DCTimeoutSeconds = 300,
[int]$MaxEventsPerDC = 100000,
[switch]$SkipDCScan
)
$total = [System.Diagnostics.Stopwatch]::StartNew()
Write-ScanLog "СТАРТ СКАНИРОВАНИЯ: $DomainName" -Level Header
# Этап 1 --- учётные записи ---
Write-ScanLog "ЭТАП 1/5: Учётные записи" -Level Header
$accounts = Get-TargetAccounts -DomainName $DomainName -OUUserServices $OUUserServices
if (-not $accounts -or $accounts.Count -eq 0) { Write-ScanLog "Нет учётных записей — выход." -Level Error; return }
# Этап 2 --- серверы ---
Write-ScanLog "ЭТАП 2/5: Серверы" -Level Header
$servers = Get-TargetServers -DomainName $DomainName -OUServers $OUServers
if (-not $servers -or $servers.Count -eq 0) { Write-ScanLog "Нет серверов — выход." -Level Error; return }
# Хэш-таблица для быстрого сопоставления
$lookup = @{}
foreach ($acc in $accounts) {
if ($acc.SamAccountName) { $lookup[$acc.SamAccountName.ToLower()] = $acc }
if ($acc.UserPrincipalName) { $lookup[$acc.UserPrincipalName.ToLower()] = $acc }
}
$results = New-Object System.Collections.Generic.List[object]
# Этап 3 --- обход серверов ---
Write-ScanLog "ЭТАП 3/5: Сканирование $($servers.Count) серверов (службы, задачи, IIS)" -Level Header
$i = 0
foreach ($server in $servers) {
$i++
$swSrv = [System.Diagnostics.Stopwatch]::StartNew()
Write-ScanLog " [$i/$($servers.Count)] $server ..."
# Службы
$svcCount = 0
foreach ($svc in (Get-ServerServices -ComputerName $server)) {
$acc = Resolve-AccountFromIdentity -Identity $svc.StartName -Lookup $lookup
if ($acc) {
$svcCount++
$results.Add([PSCustomObject]@{
'Имя учетной записи' = $acc.DisplayName
'Логин' = $acc.SamAccountName
'Имя сервера' = $server
'Где используется' = "Служба: $($svc.DisplayName) ($($svc.Name))"
})
}
}
# Задачи
$taskCount = 0
foreach ($t in (Get-ServerScheduledTasks -ComputerName $server)) {
$acc = Resolve-AccountFromIdentity -Identity $t.StartName -Lookup $lookup
if ($acc) {
$taskCount++
$results.Add([PSCustomObject]@{
'Имя учетной записи' = $acc.DisplayName
'Логин' = $acc.SamAccountName
'Имя сервера' = $server
'Где используется' = "Планировщик: $($t.TaskName)"
})
}
}
# IIS
$iisCount = 0
foreach ($p in (Get-ServerIISAppPools -ComputerName $server)) {
$acc = Resolve-AccountFromIdentity -Identity $p.StartName -Lookup $lookup
if ($acc) {
$iisCount++
$results.Add([PSCustomObject]@{
'Имя учетной записи' = $acc.DisplayName
'Логин' = $acc.SamAccountName
'Имя сервера' = $server
'Где используется' = "IIS пул: $($p.Name)"
})
}
}
$swSrv.Stop()
$summary = "служб=$svcCount, задач=$taskCount, IIS=$iisCount, время=$($swSrv.Elapsed.TotalSeconds.ToString('0.0'))с"
if (($svcCount + $taskCount + $iisCount) -gt 0) {
Write-ScanLog " найдено: $summary" -Level Success
} else {
Write-ScanLog " не найдено: $summary"
}
}
# Этап 4 --- журналы DC ---
Write-ScanLog "ЭТАП 4/5: Анализ журналов безопасности DC" -Level Header
if ($SkipDCScan) {
Write-ScanLog " Пропущен по флагу -SkipDCScan." -Level Warning
} else {
$swDC = [System.Diagnostics.Stopwatch]::StartNew()
$sams = $accounts | Select-Object -ExpandProperty SamAccountName
$dcEvents = Get-DCLogonEvents -AccountSams $sams -DomainName $DomainName `
-Days $LogDays -MaxEventsPerDC $MaxEventsPerDC `
-TimeoutSeconds $DCTimeoutSeconds
$swDC.Stop()
Write-ScanLog " Событий к обработке: $($dcEvents.Count). Время этапа: $($swDC.Elapsed.TotalSeconds.ToString('0.0'))с"
foreach ($ev in $dcEvents) {
$acc = Resolve-AccountFromIdentity -Identity $ev.AccountSam -Lookup $lookup
if ($acc) {
$results.Add([PSCustomObject]@{
'Имя учетной записи' = $acc.DisplayName
'Логин' = $acc.SamAccountName
'Имя сервера' = $ev.Server
'Где используется' = $ev.WhereUsed
})
}
}
}
# Этап 5 --- слияние и сохранение ---
Write-ScanLog "ЭТАП 5/5: Слияние и сохранение результатов" -Level Header
Merge-ScanResults -CsvPath $ResultPath -NewResults $results
$total.Stop()
Write-ScanLog "ГОТОВО. Общее время: $($total.Elapsed.ToString('hh\:mm\:ss'))" -Level Header
Write-ScanLog "Файл результата: $ResultPath" -Level Success
}
#endregion
#region ===== Точка входа =====
Invoke-ServiceAccountScan `
-DomainName $DomainName `
-OUUserServices $OUUserServices `
-OUServers $OUServers `
-ResultPath $ResultUserAccounts `
-LogDays $LogRetentionDays `
-DCTimeoutSeconds $DCTimeoutSeconds `
-MaxEventsPerDC $MaxEventsPerDC
#endregion