Загрузка данных


<#
.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