<# .SYNOPSIS Collects Windows security-related events for a time window, builds JSON, optional HTTP POST. .NOTES PowerShell 5.1+, no third-party modules. Windows Server 2016/2019/2022. Does not log ApiKey. Aggregated report only (no raw event dump). Russian alert titles/descriptions in generated JSON — save this .ps1 as UTF-8 (BOM acceptable on Windows). Uses ASCII elsewhere where practical. Pass OrganizationName (any language) via parameters when needed. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$OrganizationName, [Parameter(Mandatory = $true)] [string]$ServerUrl, [Parameter(Mandatory = $true)] [string]$ApiKey, [Parameter(Mandatory = $false)] [ValidateRange(1, 720)] [int]$HoursBack = 48, [Parameter(Mandatory = $false)] [switch]$SendReport, [Parameter(Mandatory = $false)] [string]$OutputPath, # Label for grouping hosts in the web UI (e.g. branch / filial). Optional. [Parameter(Mandatory = $false)] [string]$HostGroup = '' ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' if ([string]::IsNullOrWhiteSpace($OutputPath)) { $OutputPath = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).ProviderPath } } # --- Helpers ----------------------------------------------------------------- function Get-MachineGuidValue { $p = Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Cryptography' -Name MachineGuid -ErrorAction Stop return ([string]$p.MachineGuid).Trim() } function Write-Log { param( [ValidateSet('INFO', 'WARN', 'ERROR')] [string]$Level = 'INFO', [Parameter(Mandatory = $true)] [string]$Message ) $ts = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss') $line = "[$ts] [$Level] $Message" if ($script:LogFilePath) { try { Add-Content -LiteralPath $script:LogFilePath -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue } catch { } } switch ($Level) { 'ERROR' { Write-Error $Message } 'WARN' { Write-Warning $Message } default { Write-Host $line } } } function Get-EventDataMap { param([System.Diagnostics.Eventing.Reader.EventRecord]$EventRecord) $map = @{} try { [xml]$xmlDoc = $EventRecord.ToXml() foreach ($node in $xmlDoc.Event.EventData.Data) { if ($node.Name) { $map[$node.Name] = [string]$node.'#text' } } } catch { # ignore malformed XML } return $map } function Get-DmValue { param( [hashtable]$Dm, [string]$Key, [string]$Default = '' ) if ($Dm.ContainsKey($Key)) { return [string]$Dm[$Key] } return $Default } function Join-DomainBackslashUser { param( [hashtable]$Dm, [string]$DomainKey, [string]$UserKey ) $d = (Get-DmValue -Dm $Dm -Key $DomainKey).Trim() $u = (Get-DmValue -Dm $Dm -Key $UserKey).Trim() if (-not $u) { return '' } if (-not $d) { return $u } return '{0}\{1}' -f $d, $u } function Test-IsDangerousAdminGroup { param( [string]$GroupName, [string]$TargetSid ) # Russian localized names via UTF-8 bytes (no non-ASCII chars in this .ps1 file) $enc = [System.Text.Encoding]::UTF8 $ruAdministrators = $enc.GetString([byte[]]( 0xD0, 0xB0, 0xD0, 0xB4, 0xD0, 0xBC, 0xD0, 0xB8, 0xD0, 0xBD, 0xD0, 0xB8, 0xD1, 0x81, 0xD1, 0x82, 0xD1, 0x80, 0xD0, 0xB0, 0xD1, 0x82, 0xD0, 0xBE, 0xD1, 0x80, 0xD1, 0x8B)) $ruDomainAdmins = $enc.GetString([byte[]]( 0xD0, 0xB0, 0xD0, 0xB4, 0xD0, 0xBC, 0xD0, 0xB8, 0xD0, 0xBD, 0xD0, 0xB8, 0xD1, 0x81, 0xD1, 0x82, 0xD1, 0x80, 0xD0, 0xB0, 0xD1, 0x82, 0xD0, 0xBE, 0xD1, 0x80, 0xD1, 0x8B, 0x20, 0xD0, 0xB4, 0xD0, 0xBE, 0xD0, 0xBC, 0xD0, 0xB5, 0xD0, 0xBD, 0xD0, 0xB0)) $patterns = @( '(?i)\badministrators\b', '(?i)\bdomain admins\b', '(?i)\benterprise admins\b', '(?i)\bschema admins\b', '(?i)\b' + [regex]::Escape($ruAdministrators) + '\b', '(?i)\b' + [regex]::Escape($ruDomainAdmins) + '\b' ) foreach ($p in $patterns) { if ($GroupName -match $p) { return $true } } # Builtin Administrators S-1-5-32-544; Domain Admins RID -512, Enterprise -519, Schema -518 if ($TargetSid -match '-544$|-512$|-519$|-518$') { return $true } return $false } function Test-NightHour { param([DateTime]$LocalTime) $h = $LocalTime.Hour return ($h -ge 22 -or $h -lt 7) } function Test-SuspiciousPowerShellScript { param([string]$ScriptText) if ([string]::IsNullOrWhiteSpace($ScriptText)) { return $false } $t = $ScriptText.ToLowerInvariant() $needles = @( 'encodedcommand', '-enc ', '-e ', 'invoke-webrequest', 'iwr ', 'invoke-restmethod', 'downloadstring', 'downloadfile', '\bnet\s+user\b', 'add-mppreference', 'set-mppreference', '\brundll32\b', '\bregsvr32\b', '\bcertutil\b' ) foreach ($n in $needles) { if ($t -match $n) { return $true } } return $false } function Test-SuspiciousPowerShellDanger { param([string]$ScriptText) if ([string]::IsNullOrWhiteSpace($ScriptText)) { return $false } $t = $ScriptText.ToLowerInvariant() # Stronger match -> DANGER per requirements if ($t -match 'encodedcommand|-enc\s|-e\s') { return $true } if ($t -match 'downloadstring|downloadfile') { return $true } if ($t -match '\bcertutil\b') { return $true } return $false } function Add-Sample { param( [ref]$ListRef, [int]$MaxSamples, [hashtable]$Item ) if ($ListRef.Value.Count -lt $MaxSamples) { [void]$ListRef.Value.Add($Item) } } function Test-IsGetWinEventNoMatchingRows { param([System.Management.Automation.ErrorRecord]$Err) if (-not $Err) { return $false } if ($Err.FullyQualifiedErrorId -like 'NoMatchingEventsFound*') { return $true } $t = $Err.Exception.Message if ($t -match '(?i)No events were found|specified selection criteria') { return $true } # RU: fragment "условию выбора" from default localized message (ASCII-only source file) $enc = [System.Text.Encoding]::UTF8 $needle = $enc.GetString([byte[]]( 0xD1, 0x83, 0xD1, 0x81, 0xD0, 0xBB, 0xD0, 0xBE, 0xD0, 0xB2, 0xD0, 0xB8, 0xD1, 0x8E, 0x20, 0xD0, 0xB2, 0xD1, 0x8B, 0xD0, 0xB1, 0xD0, 0xBE, 0xD1, 0x80, 0xD0, 0xB0)) if ($needle -and ($t.IndexOf($needle, [System.StringComparison]::Ordinal) -ge 0)) { return $true } return $false } # --- Initialization ---------------------------------------------------------- if (-not (Test-Path -LiteralPath $OutputPath)) { try { New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null } catch { Write-Log -Level ERROR -Message "Cannot create OutputPath directory: $OutputPath" throw } } $script:LogFilePath = Join-Path $OutputPath ('security_report_' + $env:COMPUTERNAME + '_script.log') Write-Log -Level INFO -Message "Report start, last $HoursBack hours." # Local time window for Get-WinEvent (matches TimeCreated) $endLocal = Get-Date $startLocal = $endLocal.AddHours(-1 * $HoursBack) $securityIds = @(4625, 4624, 4672, 4720, 4722, 4728, 4732, 4740, 4648, 4698, 1102) $countsById = @{} foreach ($id in ($securityIds + @(7045, 4104))) { $countsById[$id] = 0 } $failedLogonByIp = @{} $failedLogonByUser = @{} $adminLogonSamples = New-Object System.Collections.Generic.List[hashtable] $newUserSamples = New-Object System.Collections.Generic.List[hashtable] $groupChangeSamples = New-Object System.Collections.Generic.List[hashtable] $newServiceSamples = New-Object System.Collections.Generic.List[hashtable] $scheduledTaskSamples = New-Object System.Collections.Generic.List[hashtable] $suspiciousPsSamples = New-Object System.Collections.Generic.List[hashtable] $blockedAccountsCount = 0 $events4625 = New-Object System.Collections.Generic.List[hashtable] $events4624 = New-Object System.Collections.Generic.List[hashtable] $suspiciousPsTotal = 0 $suspiciousPsDangerPatterns = 0 $auditLogCleared = $false $adminGroupAddsTotal = 0 $nightAdminTotal = 0 function Invoke-SafeGetWinEvents { param( [string]$LogName, [int[]]$Ids, [datetime]$StartTime ) $list = New-Object System.Collections.Generic.List[System.Diagnostics.Eventing.Reader.EventRecord] try { # LogName + StartTime (+ optional IDs) $filter = @{ LogName = $LogName; StartTime = $StartTime } if ($Ids -and $Ids.Count -gt 0) { $filter['ID'] = $Ids } $evts = Get-WinEvent -FilterHashtable $filter -ErrorAction Stop -Oldest foreach ($e in $evts) { $list.Add($e) } } catch { $errRec = $_ if ($errRec -is [System.Management.Automation.ErrorRecord]) { if (Test-IsGetWinEventNoMatchingRows -Err $errRec) { return $list } } $msg = if ($errRec -is [System.Management.Automation.ErrorRecord]) { $errRec.Exception.Message } else { [string]$errRec } Write-Log -Level WARN -Message "Log unavailable or error reading $LogName ($msg)" } return $list } # --- Security ---------------------------------------------------------------- try { $secEvents = Invoke-SafeGetWinEvents -LogName 'Security' -Ids $securityIds -StartTime $startLocal foreach ($evt in $secEvents) { $id = [int]$evt.Id if (-not $countsById.ContainsKey($id)) { $countsById[$id] = 0 } $countsById[$id]++ $dm = Get-EventDataMap -EventRecord $evt $localTime = $evt.TimeCreated switch ($id) { 4625 { $ip = if ($dm.ContainsKey('IpAddress')) { $dm['IpAddress'] } else { '' } $user = if ($dm.ContainsKey('TargetUserName')) { $dm['TargetUserName'] } else { '' } if (-not $ip -or $ip -eq '-') { $ip = '(none)' } if (-not $user -or $user -eq '-') { $user = '(unknown)' } if (-not $failedLogonByIp.ContainsKey($ip)) { $failedLogonByIp[$ip] = 0 } $failedLogonByIp[$ip]++ $userKey = Join-DomainBackslashUser -Dm $dm -DomainKey 'TargetDomainName' -UserKey 'TargetUserName' if (-not $userKey) { $userKey = $user } if (-not $failedLogonByUser.ContainsKey($userKey)) { $failedLogonByUser[$userKey] = 0 } $failedLogonByUser[$userKey]++ [void]$events4625.Add(@{ TimeCreated = $localTime IpAddress = $ip User = $user LogonType = (Get-DmValue -Dm $dm -Key 'LogonType') EventRecordId = $evt.RecordId }) } 4624 { $ip = if ($dm.ContainsKey('IpAddress')) { $dm['IpAddress'] } else { '' } $user = if ($dm.ContainsKey('TargetUserName')) { $dm['TargetUserName'] } else { '' } if (-not $ip -or $ip -eq '-') { $ip = '(none)' } [void]$events4624.Add(@{ TimeCreated = $localTime IpAddress = $ip User = $user LogonType = (Get-DmValue -Dm $dm -Key 'LogonType') EventRecordId = $evt.RecordId }) } 4672 { $isNight = Test-NightHour -LocalTime $localTime if ($isNight) { $nightAdminTotal++ } Add-Sample ([ref]$adminLogonSamples) 8 @{ time = $localTime.ToString('yyyy-MM-ddTHH:mm:ss') subject = (Join-DomainBackslashUser -Dm $dm -DomainKey 'SubjectDomainName' -UserKey 'SubjectUserName') recordId = $evt.RecordId nightTime = $isNight } } 4720 { Add-Sample ([ref]$newUserSamples) 8 @{ time = $localTime.ToString('yyyy-MM-ddTHH:mm:ss') newUser = (Join-DomainBackslashUser -Dm $dm -DomainKey 'TargetDomainName' -UserKey 'TargetUserName') subject = (Join-DomainBackslashUser -Dm $dm -DomainKey 'SubjectDomainName' -UserKey 'SubjectUserName') recordId = $evt.RecordId } } 4722 { Add-Sample ([ref]$newUserSamples) 8 @{ time = $localTime.ToString('yyyy-MM-ddTHH:mm:ss') enabledUser = (Join-DomainBackslashUser -Dm $dm -DomainKey 'TargetDomainName' -UserKey 'TargetUserName') subject = (Join-DomainBackslashUser -Dm $dm -DomainKey 'SubjectDomainName' -UserKey 'SubjectUserName') recordId = $evt.RecordId note = 'User enabled (4722)' } } { $_ -in 4728, 4732 } { $gName = if ($dm.ContainsKey('GroupName')) { $dm['GroupName'] } elseif ($dm.ContainsKey('TargetUserName')) { $dm['TargetUserName'] } else { '' } $tSid = if ($dm.ContainsKey('TargetSid')) { $dm['TargetSid'] } else { '' } $member = if ($dm.ContainsKey('MemberName')) { $dm['MemberName'] } elseif ($dm.ContainsKey('MemberSid')) { $dm['MemberSid'] } else { '' } $dangerGroup = Test-IsDangerousAdminGroup -GroupName $gName -TargetSid $tSid if ($dangerGroup) { $adminGroupAddsTotal++ } Add-Sample ([ref]$groupChangeSamples) 10 @{ time = $localTime.ToString('yyyy-MM-ddTHH:mm:ss') eventId = $id group = $gName member = $member targetSid = $tSid adminGroup = $dangerGroup recordId = $evt.RecordId } } 4740 { $blockedAccountsCount++ } 4698 { $taskName = if ($dm.ContainsKey('TaskName')) { $dm['TaskName'] } else { '' } $subject = Join-DomainBackslashUser -Dm $dm -DomainKey 'SubjectDomainName' -UserKey 'SubjectUserName' Add-Sample ([ref]$scheduledTaskSamples) 8 @{ time = $localTime.ToString('yyyy-MM-ddTHH:mm:ss') taskName = $taskName subject = $subject recordId = $evt.RecordId } } 1102 { $auditLogCleared = $true } 4648 { # 4648 counted in totals only } } } } catch { Write-Log -Level ERROR -Message "Security log processing error: $($_.Exception.Message)" } # --- System 7045 ------------------------------------------------------------- try { $sys7045 = Invoke-SafeGetWinEvents -LogName 'System' -Ids @(7045) -StartTime $startLocal foreach ($evt in $sys7045) { $countsById[7045]++ $dm = Get-EventDataMap -EventRecord $evt $svc = if ($dm.ContainsKey('ServiceName')) { $dm['ServiceName'] } else { '' } $path = if ($dm.ContainsKey('ImagePath')) { $dm['ImagePath'] } else { '' } Add-Sample ([ref]$newServiceSamples) 8 @{ time = $evt.TimeCreated.ToString('yyyy-MM-ddTHH:mm:ss') serviceName = $svc imagePath = $path recordId = $evt.RecordId } } } catch { Write-Log -Level WARN -Message "System log error: $($_.Exception.Message)" } # --- PowerShell Operational 4104 -------------------------------------------- try { $ps4104 = Invoke-SafeGetWinEvents -LogName 'Microsoft-Windows-PowerShell/Operational' -Ids @(4104) -StartTime $startLocal foreach ($evt in $ps4104) { $countsById[4104]++ $dm = Get-EventDataMap -EventRecord $evt $scriptBlock = '' if ($dm.ContainsKey('ScriptBlockText')) { $scriptBlock = $dm['ScriptBlockText'] } if (Test-SuspiciousPowerShellScript -ScriptText $scriptBlock) { $suspiciousPsTotal++ if (Test-SuspiciousPowerShellDanger -ScriptText $scriptBlock) { $suspiciousPsDangerPatterns++ } $snippet = $scriptBlock if ($snippet.Length -gt 400) { $snippet = $snippet.Substring(0, 400) + '...' } Add-Sample ([ref]$suspiciousPsSamples) 8 @{ time = $evt.TimeCreated.ToString('yyyy-MM-ddTHH:mm:ss') recordId = $evt.RecordId path = (Get-DmValue -Dm $dm -Key 'Path') snippet = $snippet dangerPattern = (Test-SuspiciousPowerShellDanger -ScriptText $scriptBlock) } } } } catch { Write-Log -Level WARN -Message "PowerShell Operational log unavailable: $($_.Exception.Message)" } # --- Correlation: successful logon after many failures (same IP) ------------- $bruteSuccessSamples = New-Object System.Collections.Generic.List[hashtable] $thresholdBruteIp = 10 foreach ($ipKey in $failedLogonByIp.Keys) { if ($ipKey -eq '(none)') { continue } $fc = $failedLogonByIp[$ipKey] if ($fc -lt $thresholdBruteIp) { continue } $succ = @($events4624 | Where-Object { $_.IpAddress -eq $ipKey }) if ($succ.Count -gt 0) { $firstFailEvt = $events4625 | Where-Object { $_.IpAddress -eq $ipKey } | Sort-Object TimeCreated | Select-Object -First 1 $firstFail = if ($firstFailEvt) { $firstFailEvt.TimeCreated } else { $null } $firstSucc = ($succ | Sort-Object TimeCreated | Select-Object -First 1) Add-Sample ([ref]$bruteSuccessSamples) 6 @{ ip = $ipKey failedAttempts = $fc firstFailTime = if ($firstFail) { $firstFail.ToString('yyyy-MM-ddTHH:mm:ss') } else { $null } successTime = $firstSucc.TimeCreated.ToString('yyyy-MM-ddTHH:mm:ss') successUser = $firstSucc.User recordId = $firstSucc.EventRecordId } } } # --- Aggregate metrics -------------------------------------------------------- $failedTotal = [int]$countsById[4625] $successTotal = [int]$countsById[4624] $adminTotal = [int]$countsById[4672] $newUsersTotal = [int]$countsById[4720] + [int]$countsById[4722] $groupAddsTotal = [int]$countsById[4728] + [int]$countsById[4732] $adminGroupAdds = $adminGroupAddsTotal $newServicesTotal = [int]$countsById[7045] $tasksTotal = [int]$countsById[4698] $topFailedIps = @($failedLogonByIp.GetEnumerator() | Sort-Object Value -Descending | Select-Object -First 15 | ForEach-Object { [ordered]@{ ip = $_.Key; count = $_.Value } }) $topFailedUsers = @($failedLogonByUser.GetEnumerator() | Sort-Object Value -Descending | Select-Object -First 15 | ForEach-Object { [ordered]@{ user = $_.Key; count = $_.Value } }) $totalEvents = ($countsById.Values | Measure-Object -Sum).Sum $maxFailedPerIp = 0 if ($failedLogonByIp.Count -gt 0) { $maxFailedPerIp = ($failedLogonByIp.Values | Measure-Object -Maximum).Maximum } $nightAdminCount = $nightAdminTotal # --- Risk scoring and alerts -------------------------------------------------- $alerts = New-Object System.Collections.Generic.List[hashtable] $riskScore = 5 $warningHit = $false $dangerHit = $false function Add-Alert { param([Parameter(Mandatory)][hashtable]$R) $det = if ($null -ne $R['details']) { $R['details'] } else { @{} } [void]$script:alerts.Add([ordered]@{ severity = [string]$R['severity'] type = [string]$R['type'] title = [string]$R['title'] description = [string]$R['body'] details = $det }) } if ($failedTotal -gt 100) { $dangerHit = $true $riskScore += 35 Add-Alert @{ severity = 'danger'; type = 'failed_logon_volume'; title = 'Большой объём неудачных входов (4625)' body = "Зафиксировано $failedTotal неудачных попыток входа за период." details = @{ failedTotal = $failedTotal } } } elseif ($failedTotal -gt 20) { $warningHit = $true $riskScore += 15 Add-Alert @{ severity = 'warning'; type = 'failed_logon_volume'; title = 'Повышенное число неудачных входов' body = "Зафиксировано $failedTotal неудачных попыток входа за период." details = @{ failedTotal = $failedTotal } } } if ($maxFailedPerIp -gt 50) { $dangerHit = $true $riskScore += 30 Add-Alert @{ severity = 'danger'; type = 'failed_logon_single_ip'; title = 'Много неудачных входов с одного IP' body = "Максимум неудачных попыток с одного IP: $maxFailedPerIp." details = @{ maxPerIp = $maxFailedPerIp; sampleTop = ($topFailedIps | Select-Object -First 5) } } } elseif ($maxFailedPerIp -gt 10) { $warningHit = $true $riskScore += 12 Add-Alert @{ severity = 'warning'; type = 'failed_logon_single_ip'; title = 'Неудачные входы сконцентрированы на одном IP' body = "Максимум неудачных попыток с одного IP: $maxFailedPerIp." details = @{ maxPerIp = $maxFailedPerIp } } } if ($bruteSuccessSamples.Count -gt 0) { $dangerHit = $true $riskScore += 40 Add-Alert @{ severity = 'danger'; type = 'logon_after_bruteforce'; title = 'Успешный вход после множества неудач (тот же IP)' body = 'Успешные интерактивные или сетевые входы (4624) с IP-адресов, с которых до этого было много неудачных попыток (4625).' details = @{ examples = @($bruteSuccessSamples) } } } if ($nightAdminCount -gt 0) { $warningHit = $true $riskScore += 10 Add-Alert @{ severity = 'warning'; type = 'admin_logon_night'; title = 'Привилегированная активность входа ночью' body = "События 4672 за период с 22:00 по 07:00: $nightAdminCount." details = @{ count = $nightAdminCount; samples = @($adminLogonSamples | Where-Object { $_.nightTime -eq $true } | Select-Object -First 5) } } } if ($blockedAccountsCount -gt 0) { $warningHit = $true $riskScore += 10 Add-Alert @{ severity = 'warning'; type = 'account_locked'; title = 'Блокировки учётных записей' body = "События блокировки учётной записи (4740): $blockedAccountsCount." details = @{ count = $blockedAccountsCount } } } if ($suspiciousPsTotal -gt 0) { $warningHit = $true $riskScore += 12 Add-Alert @{ severity = 'warning'; type = 'suspicious_powershell'; title = 'Подозрительные блоки сценариев PowerShell' body = "Подозрительные блоки сценариев (4104): $suspiciousPsTotal." details = @{ count = $suspiciousPsTotal } } } if ($suspiciousPsDangerPatterns -gt 0) { $dangerHit = $true $riskScore += 25 Add-Alert @{ severity = 'danger'; type = 'suspicious_powershell_critical'; title = 'Высокорискованные паттерны в PowerShell' body = 'В блоках зафиксированы признаки: EncodedCommand, DownloadString/DownloadFile или Certutil.' details = @{ count = $suspiciousPsDangerPatterns } } } if ($countsById[4720] -gt 0 -or $countsById[4722] -gt 0) { # User created or enabled -> DANGER per requirements $dangerHit = $true $riskScore += 25 Add-Alert @{ severity = 'danger'; type = 'user_lifecycle_change'; title = 'Создание или включение учётной записи' body = "Событий 4720: $($countsById[4720]), 4722: $($countsById[4722])." details = @{ samples = @($newUserSamples) } } } if ($adminGroupAdds -gt 0) { $dangerHit = $true $riskScore += 35 Add-Alert @{ severity = 'danger'; type = 'admin_group_membership'; title = 'Изменение членства в привилегированной группе' body = "Добавление в привилегированные группы по событиям 4728/4732: $adminGroupAdds." details = @{ samples = @($groupChangeSamples | Where-Object { $_.adminGroup }) } } } if ($auditLogCleared) { $dangerHit = $true $riskScore = 100 Add-Alert @{ severity = 'danger'; type = 'audit_log_cleared'; title = 'Очищен журнал аудита безопасности' body = 'Событие 1102: журнал аудита был очищен.' details = @{} } } if ($newServicesTotal -gt 0) { $dangerHit = $true $riskScore += 20 Add-Alert @{ severity = 'danger'; type = 'new_windows_service'; title = 'Установлены новые службы Windows' body = "События установки службы (7045): $newServicesTotal." details = @{ samples = @($newServiceSamples) } } } if ($tasksTotal -gt 0) { $dangerHit = $true $riskScore += 18 Add-Alert @{ severity = 'danger'; type = 'scheduled_task_created'; title = 'Созданы задания планировщика' body = "Создание заданий по событию 4698: $tasksTotal." details = @{ samples = @($scheduledTaskSamples) } } } $riskScore = [Math]::Min(100, [int]$riskScore) $riskLevel = 'OK' if ($dangerHit) { $riskLevel = 'DANGER' } elseif ($warningHit) { $riskLevel = 'WARNING' } # --- Build report JSON -------------------------------------------------------- $machineGuid = Get-MachineGuidValue $report = [ordered]@{ organization = $OrganizationName host = $env:COMPUTERNAME machineGuid = $machineGuid period = @{ from = $startLocal.ToString('yyyy-MM-ddTHH:mm:ss') to = $endLocal.ToString('yyyy-MM-ddTHH:mm:ss') hours = $HoursBack } countsByEventId = @{ '4625' = [int]$countsById[4625] '4624' = [int]$countsById[4624] '4672' = [int]$countsById[4672] '4720' = [int]$countsById[4720] '4722' = [int]$countsById[4722] '4728' = [int]$countsById[4728] '4732' = [int]$countsById[4732] '4740' = [int]$countsById[4740] '4648' = [int]$countsById[4648] '4698' = [int]$countsById[4698] '1102' = [int]$countsById[1102] '7045' = [int]$countsById[7045] '4104' = [int]$countsById[4104] } summary = @{ riskLevel = $riskLevel riskScore = $riskScore totalEvents = [int]$totalEvents failedLogons = $failedTotal successfulLogons = $successTotal adminLogons = $adminTotal newUsers = $newUsersTotal groupChanges = $groupAddsTotal adminGroupChanges = $adminGroupAdds auditLogCleared = [bool]$auditLogCleared newServices = $newServicesTotal scheduledTasks = $tasksTotal suspiciousPowerShell = $suspiciousPsTotal blockedAccounts = $blockedAccountsCount logonAfterBruteforceCases = $bruteSuccessSamples.Count } alerts = @($alerts) topFailedLogonIps = @($topFailedIps) topFailedLogonUsers = @($topFailedUsers) logonAfterBruteforce = @($bruteSuccessSamples) adminLogons = @($adminLogonSamples) newUsers = @($newUserSamples) groupChanges = @($groupChangeSamples) newServices = @($newServiceSamples) scheduledTasks = @($scheduledTaskSamples) suspiciousPowerShell = @($suspiciousPsSamples) } if (-not [string]::IsNullOrWhiteSpace($HostGroup)) { $report['hostGroup'] = $HostGroup.Trim() } try { $jsonBody = $report | ConvertTo-Json -Depth 12 -Compress:$false } catch { Write-Log -Level ERROR -Message "JSON serialization failed: $($_.Exception.Message)" throw } $fileStamp = (Get-Date).ToString('yyyyMMdd_HHmmss') $fileName = "security_report_$env:COMPUTERNAME`_$fileStamp.json" $fullOut = Join-Path $OutputPath $fileName try { $utf8NoBom = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($fullOut, $jsonBody, $utf8NoBom) Write-Log -Level INFO -Message "Local report saved: $fullOut" } catch { Write-Log -Level ERROR -Message "Failed to write JSON file: $($_.Exception.Message)" throw } if ($SendReport) { try { $headers = @{ Authorization = 'Bearer {0}' -f $ApiKey 'Content-Type' = 'application/json; charset=utf-8' } Write-Log -Level INFO -Message 'Sending report (Invoke-RestMethod POST)...' # TLS 1.2+ required by most modern HTTPS sites; PS 5.1 may default to older protocols only. if ($ServerUrl -match '(?i)^https://') { $secProto = [Net.SecurityProtocolType]::Tls12 try { $secProto = $secProto -bor [Enum]::Parse([Net.SecurityProtocolType], 'Tls13') } catch { } try { [Net.ServicePointManager]::SecurityProtocol = $secProto } catch { Write-Log -Level WARN -Message "Could not set SecurityProtocol for HTTPS (continuing anyway): $($_.Exception.Message)" } } Invoke-RestMethod -Uri $ServerUrl -Method Post -Body $jsonBody -Headers $headers -TimeoutSec 120 -ErrorAction Stop | Out-Null Write-Log -Level INFO -Message 'Report uploaded successfully.' } catch { Write-Log -Level ERROR -Message ('Report upload failed: ' + $_.Exception.Message) # Local file already saved above Write-Log -Level WARN -Message "Report file kept at: $fullOut" } } Write-Log -Level INFO -Message "Done. Risk: $riskLevel ($riskScore). Events counted: $totalEvents."