diff --git a/Docs/exchange-mail-security.md b/Docs/exchange-mail-security.md index 532e1aa..fc13144 100644 --- a/Docs/exchange-mail-security.md +++ b/Docs/exchange-mail-security.md @@ -18,7 +18,7 @@ ### 1. Правила Inbox (`Get-InboxRule`) -Для **включённых** правил (`Enabled = $true`) проверяются: +Для **включённых** правил проверяются: - `ForwardTo` - `RedirectTo` @@ -26,7 +26,9 @@ Если целевой SMTP-адрес указывает на домен **не** из `Get-AcceptedDomain` (типы **Authoritative** и **InternalRelay**) и не в **whitelist** — фиксируется находка. -**Повышенная важность (Критическая):** правило также включает `DeleteMessage` или `MarkAsRead` (типичный паттерн скрытой пересылки при компрометации). +**Повышенная важность (Критическая):** включённое правило с `DeleteMessage` или `MarkAsRead`. + +**Отключённые правила** (`$ScanDisabledInboxRulesWithExternalForward = $true`): внешняя пересылка фиксируется с важностью **«Средняя (правило отключено)»**. ### 2. Пересылка на уровне ящика (`Get-Mailbox`) @@ -49,7 +51,6 @@ ### Что не отслеживается -- **Отключённые** Inbox rules с внешней пересылкой - Делегирование, Send As, скрытые EWS-правила без Inbox rule - Пересылка только между **внутренними** accepted domains - Чтение почты злоумышленником **без** настройки forward @@ -63,9 +64,25 @@ ## Оповещения и baseline - Каналы: **Telegram** и/или **Email** (модуль **`Notify-Common.ps1`**). -- Пересылка: по умолчанию **`$AlertOnlyOnNewForwardingFindings = $true`** — алерт только при **новой** находке относительно `Logs\exchange_forwarding_baseline.json`. -- **Первый** полный скан: baseline пуст → алерт по **всем** найденным внешним пересылкам (легитимные партнёры добавьте в whitelist заранее). -- **`$NotifyWhenForwardingScanClean = $false`** — не слать ежедневное «всё чисто» (можно включить). +- Пересылка: **`$AlertOnlyOnNewForwardingFindings = $true`** — алерт при **новой** находке (`Logs\exchange_forwarding_baseline.json`). +- **Первый скан:** **`$SuppressAlertsOnFirstBaselineRun = $true`** (по умолчанию) — существующие пересылки **только в baseline**, без всплеска алертов; одна **сводка** (`$SendInboxScanSummary`). +- Далее — алерт только при **новых** или **изменённых** пересылках (в т.ч. включили ранее отключённое правило). +- **`$NotifyWhenForwardingScanClean = $false`** — не слать «всё чисто» при нуле находок. + +## Режим VIP (пилот) + +В **`exchange_monitor.settings.ps1`**: + +```powershell +$VipMailboxesOnly = $true +$VipMailboxes = @('director@domain.ru', 'cfo@domain.ru') +$VipMailboxPatterns = @('*@domain.ru') # опционально +``` + +- **Список** — точные SMTP. +- **Шаблоны** — дополнительно обход `Get-Mailbox` и отбор по `-like`. +- Mailbox forwarding и Inbox rules — только в рамках VIP. +- Для полного домена: **`$VipMailboxesOnly = $false`**. ## Файлы на шаре NETLOGON @@ -180,7 +197,11 @@ schtasks /Query /TN "RDP-Exchange-MailSecurity-Watchdog" /V /FO LIST | `$ScanMailboxForwarding` | `$true` | Скан forwarding на ящике | | `$ScanTransportRules` | `$true` | Скан transport rules | | `$AlertOnlyOnNewForwardingFindings` | `$true` | Только новые находки | -| `$VipMailboxesOnly` | `$false` | Ограничить список ящиков | +| `$VipMailboxesOnly` | `$false` | Только VIP-ящики | +| `$VipMailboxPatterns` | `@()` | Wildcard для VIP | +| `$SuppressAlertsOnFirstBaselineRun` | `$true` | Первый скан без N алертов | +| `$ScanDisabledInboxRulesWithExternalForward` | `$true` | Отключённые правила с внешним forward | +| `$SendInboxScanSummary` | `$true` | Сводка после скана | | `$InboxScanBatchSize` | 50 | Пауза каждые N ящиков | | `$InboxScanBatchDelaySeconds` | 3 | Задержка между батчами | | `$ExcludeMailboxPatterns` | HealthMailbox*, … | Исключения | @@ -216,7 +237,7 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "\\B26\NETLOGON\RDP-logi |---------|----------| | «Не удалось загрузить Exchange Management Shell» | Запуск на mailbox/Exchange server; проверить snap-in / `$ExchangeServerFqdn` | | Нет алертов | Проверить `exchange_monitor.settings.ps1`, Telegram/SMTP, лог `exchange_mail_security.log` | -| Слишком много алертов в первый день | Заполнить `$ExternalDomainWhitelist`; дождаться baseline; или временно `$AlertOnlyOnNewForwardingFindings = $false` только для теста | +| Слишком много алертов в первый день | По умолчанию подавлены (`$SuppressAlertsOnFirstBaselineRun`); иначе whitelist + baseline | | Inbox scan долгий | `$VipMailboxesOnly = $true`; увеличить `$InboxScanBatchDelaySeconds` | | Ошибки Get-InboxRule на ящике | В логе будет «ошибка mailbox» — штатно, скан продолжается | diff --git a/Exchange-MailSecurity.ps1 b/Exchange-MailSecurity.ps1 index 0c582e6..338326b 100644 --- a/Exchange-MailSecurity.ps1 +++ b/Exchange-MailSecurity.ps1 @@ -25,7 +25,7 @@ $ErrorActionPreference = 'Stop' # КОНФИГУРАЦИЯ # ============================================ -$ScriptVersion = '1.6.0' +$ScriptVersion = '1.6.1' $script:InstallRoot = [System.IO.Path]::GetFullPath("$env:ProgramData\RDP-login-monitor") $script:CanonicalScriptName = 'Exchange-MailSecurity.ps1' $LogFile = Join-Path $script:InstallRoot 'Logs\exchange_mail_security.log' @@ -60,9 +60,14 @@ $AlertOnlyOnNewForwardingFindings = $true $ScanMailboxForwarding = $true $ScanInboxRules = $true $ScanTransportRules = $true +# VIP: только перечисленные ящики и/или шаблоны (пилот перед полным сканом). $VipMailboxesOnly = $false -$VipMailboxes = @() # user@domain.com +$VipMailboxes = @() # точные PrimarySmtpAddress: user@domain.com +$VipMailboxPatterns = @() # wildcard: *@kalinamall.ru, director*, finance* $ExcludeMailboxPatterns = @('HealthMailbox*', 'DiscoveryMailbox*', 'SystemMailbox*') +$ScanDisabledInboxRulesWithExternalForward = $true # отключённые правила с внешней пересылкой +$SuppressAlertsOnFirstBaselineRun = $true # первый скан: baseline без всплеска алертов +$SendInboxScanSummary = $true # краткая сводка после скана $InboxScanBatchSize = 50 $InboxScanBatchDelaySeconds = 3 $MaxMailboxesPerRun = 0 # 0 = без лимита @@ -170,15 +175,6 @@ function Register-ExchangeMonitorScheduledTasks { Write-ExchLog "InstallTasks: зарегистрированы $($script:ScheduledTaskQueues), $($script:ScheduledTaskInbox), $($script:ScheduledTaskWatchdog)" } -if ($InstallTasks) { - if (-not (Test-RunningElevated)) { - Write-Host 'InstallTasks: нужны права администратора.' - exit 1 - } - Register-ExchangeMonitorScheduledTasks - exit 0 -} - if ($Watchdog) { $Mode = 'Watchdog' } # Уведомления @@ -202,6 +198,31 @@ Initialize-NotifyCredentials -TelegramBotTokenProtectedB64 $TelegramBotTokenProt if ($TelegramBotToken -eq '') { $TelegramBotToken = '' } if ($TelegramChatID -eq '') { $TelegramChatID = '' } +function Send-ExchangeInstallNotification { + $scope = Get-ExchangeInboxScanScopeLabel + $vipNote = if ($VipMailboxesOnly) { "VIP: да ($scope)" } else { 'VIP: нет (полный скан)' } + $msg = "✅ Exchange Mail Security установлен`r`n" + $msg += "🖥️ $(ConvertTo-TelegramHtml $env:COMPUTERNAME) | v$ScriptVersion`r`n" + $msg += "📢 Каналы: $(ConvertTo-TelegramHtml (Get-NotifyChainHuman))`r`n" + $msg += "📬 Inbox/пересылка: $vipNote`r`n" + $msg += "📋 Очереди: порог $QueueMessageCountThreshold | Inbox 02:00 | Queues /10 мин`r`n" + $msg += "🛡️ Первый скан: $(if ($SuppressAlertsOnFirstBaselineRun) { 'baseline без всплеска алертов' } else { 'алерт по всем находкам' })" + Send-MonitorNotification -Message $msg -EmailSubject 'Exchange Mail Security: install' | Out-Null +} + +if ($InstallTasks) { + if (-not (Test-RunningElevated)) { + Write-Host 'InstallTasks: нужны права администратора.' + exit 1 + } + Register-ExchangeMonitorScheduledTasks + if ((Test-NotifyTelegramConfigured) -or (Test-NotifyEmailConfigured)) { + Send-ExchangeInstallNotification + Write-ExchLog 'InstallTasks: отправлено уведомление об установке' + } + exit 0 +} + # ============================================ # EXCHANGE EMS # ============================================ @@ -319,6 +340,41 @@ function Test-MailboxExcluded { return $false } +function Test-VipMailboxScopeConfigured { + if (-not $VipMailboxesOnly) { return $true } + $hasList = @($VipMailboxes | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }).Count -gt 0 + $hasPat = @($VipMailboxPatterns | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }).Count -gt 0 + return ($hasList -or $hasPat) +} + +function Test-MailboxInVipScope { + param([string]$PrimarySmtp) + + if (-not $VipMailboxesOnly) { return $true } + if (-not (Test-VipMailboxScopeConfigured)) { return $false } + + $smtp = $PrimarySmtp.Trim().ToLowerInvariant() + foreach ($v in $VipMailboxes) { + if ([string]::IsNullOrWhiteSpace($v)) { continue } + if ($smtp -eq $v.Trim().ToLowerInvariant()) { return $true } + } + foreach ($pat in $VipMailboxPatterns) { + if ([string]::IsNullOrWhiteSpace($pat)) { continue } + if ($smtp -like $pat) { return $true } + } + return $false +} + +function Get-ExchangeInboxScanScopeLabel { + if (-not $VipMailboxesOnly) { + if ($MaxMailboxesPerRun -gt 0) { return "все ящики (лимит $MaxMailboxesPerRun)" } + return 'все ящики' + } + $n = @($VipMailboxes | Where-Object { $_ }).Count + $p = @($VipMailboxPatterns | Where-Object { $_ }).Count + return "VIP: список=$n, шаблонов=$p" +} + # ============================================ # FINDINGS # ============================================ @@ -334,7 +390,11 @@ function New-ForwardingFinding { ) $rulePart = if ([string]::IsNullOrWhiteSpace($RuleName)) { '' } else { $RuleName } - $id = '{0}|{1}|{2}|{3}' -f $FindingType, $Mailbox.ToLowerInvariant(), $rulePart, $TargetAddress.ToLowerInvariant() + $enabledPart = 'na' + if ($Extra.ContainsKey('Enabled')) { + $enabledPart = if ($Extra['Enabled']) { 'on' } else { 'off' } + } + $id = '{0}|{1}|{2}|{3}|{4}' -f $FindingType, $Mailbox.ToLowerInvariant(), $rulePart, $TargetAddress.ToLowerInvariant(), $enabledPart return [pscustomobject]@{ Id = $id FindingType = $FindingType @@ -378,6 +438,9 @@ function Format-ForwardingFindingMessage { if ($Finding.Extra.ContainsKey('DeliverToMailboxAndForward')) { $msg += "📤 DeliverToMailboxAndForward: $(ConvertTo-TelegramHtml ([string]$Finding.Extra['DeliverToMailboxAndForward']))`r`n" } + if ($Finding.Extra.ContainsKey('Property')) { + $msg += "🔀 Поле правила: $(ConvertTo-TelegramHtml ([string]$Finding.Extra['Property']))`r`n" + } return $msg } @@ -483,8 +546,27 @@ function Invoke-ExchangeQueueScan { function Get-MailboxListForScan { Import-ExchangeManagementShell - if ($VipMailboxesOnly -and $VipMailboxes.Count -gt 0) { - return @($VipMailboxes | ForEach-Object { $_.Trim().ToLowerInvariant() } | Where-Object { $_ }) + + if ($VipMailboxesOnly) { + if (-not (Test-VipMailboxScopeConfigured)) { + throw 'VipMailboxesOnly=$true, но VipMailboxes и VipMailboxPatterns пусты — задайте список в exchange_monitor.settings.ps1' + } + $set = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($v in $VipMailboxes) { + if (-not [string]::IsNullOrWhiteSpace($v)) { $null = $set.Add($v.Trim().ToLowerInvariant()) } + } + if ($VipMailboxPatterns.Count -gt 0) { + $all = @(Get-Mailbox -ResultSize Unlimited -ErrorAction Stop) + foreach ($mb in $all) { + $smtp = [string]$mb.PrimarySmtpAddress + if ([string]::IsNullOrWhiteSpace($smtp)) { continue } + if (Test-MailboxExcluded -PrimarySmtp $smtp) { continue } + if (Test-MailboxInVipScope -PrimarySmtp $smtp) { + $null = $set.Add($smtp.ToLowerInvariant()) + } + } + } + return @($set) } $all = @(Get-Mailbox -ResultSize Unlimited -ErrorAction Stop) @@ -515,9 +597,7 @@ function Scan-MailboxForwardingSettings { foreach ($mb in $mbs) { $primary = [string]$mb.PrimarySmtpAddress if (Test-MailboxExcluded -PrimarySmtp $primary) { continue } - if ($VipMailboxesOnly -and $VipMailboxes.Count -gt 0) { - if ($VipMailboxes -notcontains $primary.ToLowerInvariant()) { continue } - } + if (-not (Test-MailboxInVipScope -PrimarySmtp $primary)) { continue } $targets = @() if ($mb.ForwardingSmtpAddress) { @@ -540,6 +620,43 @@ function Scan-MailboxForwardingSettings { } } +function Add-InboxRuleExternalForwardFindings { + param( + $Rule, + [string]$Mailbox, + [System.Collections.Generic.HashSet[string]]$InternalDomains, + [System.Collections.Generic.List[object]]$Findings + ) + + $props = @( + @{ Name = 'ForwardTo'; Value = $Rule.ForwardTo } + @{ Name = 'RedirectTo'; Value = $Rule.RedirectTo } + @{ Name = 'ForwardAsAttachmentTo'; Value = $Rule.ForwardAsAttachmentTo } + ) + + foreach ($p in $props) { + $addrs = Get-AddressesFromExchangeProperty -PropertyValue $p.Value + foreach ($addr in $addrs) { + if (-not (Test-IsExternalSmtpAddress -SmtpAddress $addr -InternalDomains $InternalDomains -WhitelistDomains $ExternalDomainWhitelist)) { + continue + } + $sev = 'Высокая' + if (-not $Rule.Enabled) { + $sev = 'Средняя (правило отключено)' + } elseif ($Rule.DeleteMessage -or $Rule.MarkAsRead) { + $sev = 'Критическая' + } + $Findings.Add((New-ForwardingFinding -FindingType 'InboxRule' -Mailbox $Mailbox ` + -RuleName [string]$Rule.Name -TargetAddress $addr -Severity $sev -Extra @{ + Enabled = [bool]$Rule.Enabled + DeleteMessage = $Rule.DeleteMessage + MarkAsRead = $Rule.MarkAsRead + Property = $p.Name + })) | Out-Null + } + } +} + function Scan-InboxRulesForMailbox { param( [string]$Mailbox, @@ -549,30 +666,10 @@ function Scan-InboxRulesForMailbox { $rules = @(Get-InboxRule -Mailbox $Mailbox -ErrorAction SilentlyContinue) foreach ($rule in $rules) { - if (-not $rule.Enabled) { continue } - - $props = @( - @{ Name = 'ForwardTo'; Value = $rule.ForwardTo } - @{ Name = 'RedirectTo'; Value = $rule.RedirectTo } - @{ Name = 'ForwardAsAttachmentTo'; Value = $rule.ForwardAsAttachmentTo } - ) - - foreach ($p in $props) { - $addrs = Get-AddressesFromExchangeProperty -PropertyValue $p.Value - foreach ($addr in $addrs) { - if (-not (Test-IsExternalSmtpAddress -SmtpAddress $addr -InternalDomains $InternalDomains -WhitelistDomains $ExternalDomainWhitelist)) { - continue - } - $sev = 'Высокая' - if ($rule.DeleteMessage -or $rule.MarkAsRead) { $sev = 'Критическая' } - $Findings.Add((New-ForwardingFinding -FindingType 'InboxRule' -Mailbox $Mailbox ` - -RuleName [string]$rule.Name -TargetAddress $addr -Severity $sev -Extra @{ - Enabled = $rule.Enabled - DeleteMessage = $rule.DeleteMessage - MarkAsRead = $rule.MarkAsRead - Property = $p.Name - })) | Out-Null - } + if ($rule.Enabled) { + Add-InboxRuleExternalForwardFindings -Rule $rule -Mailbox $Mailbox -InternalDomains $InternalDomains -Findings $Findings + } elseif ($ScanDisabledInboxRulesWithExternalForward) { + Add-InboxRuleExternalForwardFindings -Rule $rule -Mailbox $Mailbox -InternalDomains $InternalDomains -Findings $Findings } } } @@ -606,8 +703,32 @@ function Scan-TransportRulesExternalForward { } } +function Send-ExchangeInboxScanSummary { + param( + [int]$TotalFindings, + [int]$NewFindings, + [int]$MailboxCount, + [bool]$FirstBaselineSeeded, + [string]$ScopeLabel + ) + + if (-not $SendInboxScanSummary) { return } + + $hHost = ConvertTo-TelegramHtml $env:COMPUTERNAME + $msg = "📊 Exchange: итог скана пересылки`r`n" + $msg += "🖥️ $hHost | v$ScriptVersion`r`n" + $msg += "🔎 Охват: $(ConvertTo-TelegramHtml $ScopeLabel)`r`n" + $msg += "📬 Ящиков (Inbox rules): $MailboxCount`r`n" + $msg += "📋 Находок всего: $TotalFindings | новых: $NewFindings`r`n" + if ($FirstBaselineSeeded) { + $msg += "Первый baseline: алерты по существующим пересылкам подавлены (SuppressAlertsOnFirstBaselineRun).`r`n" + } + Send-MonitorNotification -Message $msg -EmailSubject 'Exchange: scan summary' | Out-Null +} + function Invoke-ExchangeInboxAndForwardingScan { - Write-ExchLog "Inbox/Forwarding scan v$ScriptVersion; каналы: $(Get-NotifyChainHuman)" + $scopeLabel = Get-ExchangeInboxScanScopeLabel + Write-ExchLog "Inbox/Forwarding scan v$ScriptVersion; охват: $scopeLabel; каналы: $(Get-NotifyChainHuman)" Import-ExchangeManagementShell $internalDomains = Get-InternalAcceptedDomainNames Write-ExchLog "Accepted domains (internal): $($internalDomains -join ', ')" @@ -616,9 +737,11 @@ function Invoke-ExchangeInboxAndForwardingScan { Scan-MailboxForwardingSettings -InternalDomains $internalDomains -Findings $findings + $mailboxCount = 0 if ($ScanInboxRules) { $mailboxes = @(Get-MailboxListForScan) - Write-ExchLog "Inbox rules: ящиков к скану: $($mailboxes.Count)" + $mailboxCount = $mailboxes.Count + Write-ExchLog "Inbox rules: ящиков к скану: $mailboxCount ($scopeLabel)" $idx = 0 foreach ($mb in $mailboxes) { $idx++ @@ -644,7 +767,15 @@ function Invoke-ExchangeInboxAndForwardingScan { $newFindings = @($findings | Where-Object { -not $prevSet.Contains($_.Id) }) Write-ExchLog "Forwarding: всего $($findings.Count), новых $($newFindings.Count)" + $isFirstBaseline = ($baseline.FindingIds.Count -eq 0) -and ($findings.Count -gt 0) + $firstBaselineSeeded = $false + $toAlert = if ($AlertOnlyOnNewForwardingFindings) { $newFindings } else { @($findings) } + if ($SuppressAlertsOnFirstBaselineRun -and $isFirstBaseline) { + Write-ExchLog 'Forwarding: первый baseline — алерты по находкам подавлены (SuppressAlertsOnFirstBaselineRun)' + $firstBaselineSeeded = $true + $toAlert = @() + } foreach ($f in $toAlert) { $body = Format-ForwardingFindingMessage -Finding $f Send-MonitorNotification -Message $body -EmailSubject 'Exchange: external forward' | Out-Null @@ -659,6 +790,8 @@ function Invoke-ExchangeInboxAndForwardingScan { Save-ForwardingBaseline -FindingIds $allIds Write-TextFileUtf8Bom -Path $InboxHeartbeatFile -Text (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + Send-ExchangeInboxScanSummary -TotalFindings $findings.Count -NewFindings $newFindings.Count ` + -MailboxCount $mailboxCount -FirstBaselineSeeded $firstBaselineSeeded -ScopeLabel $scopeLabel Write-ExchLog 'Inbox/Forwarding scan завершён' } diff --git a/exchange_monitor.settings.example.ps1 b/exchange_monitor.settings.example.ps1 index 1584e95..dedd4d8 100644 --- a/exchange_monitor.settings.example.ps1 +++ b/exchange_monitor.settings.example.ps1 @@ -26,9 +26,24 @@ $ExternalDomainWhitelist = @( # Очереди $QueueMessageCountThreshold = 150 -# Скан ящиков: только VIP (для первого пилота) +# --- Пилот VIP (рекомендуется для первого запуска) --- # $VipMailboxesOnly = $true -# $VipMailboxes = @('ceo@kalinamall.ru', 'cfo@kalinamall.ru') +# $VipMailboxes = @( +# 'director@kalinamall.ru', +# 'cfo@kalinamall.ru' +# ) +# $VipMailboxPatterns = @( +# '*@kalinamall.ru' # опционально: все ящики домена из Get-Mailbox +# ) + +# Первый ночной скан: не слать сотни алертов по уже существующим пересылкам +# $SuppressAlertsOnFirstBaselineRun = $true # по умолчанию в скрипте уже $true + +# Отключённые Inbox rules с внешней пересылкой (важность «Средняя») +# $ScanDisabledInboxRulesWithExternalForward = $true + +# Сводка в TG/Email после каждого скана +# $SendInboxScanSummary = $true # Удалённый EMS (если скрипт не на Exchange) # $ExchangeServerFqdn = 'fifth.kalinamall.ru' diff --git a/version.txt b/version.txt index dc1e644..9c6d629 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.6.0 +1.6.1