From 2bab84f9f9728a33874514495df3ed5d97ac9db5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 3 May 2026 20:21:39 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(dlp):=20add=20enforcement=20=E2=80=94?= =?UTF-8?q?=20USB=20write-block,=20print=20cancel,=20clipboard=20clear?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2.5: when DLP policy rule has action="block", the collector now actively prevents the action instead of just logging: - USB: Set-Disk -IsReadOnly via Get-Partition/Get-Disk pipeline - Print: Remove-CimInstance Win32_PrintJob for matching jobs - Clipboard: Set-Clipboard -Value $null to clear sensitive content Each enforcement adds enforced=true/false to incident telemetry. Windows balloon notification shown to user on every block action. Backward-compatible: existing action="alert" rules unchanged. Co-Authored-By: Fashion Lisa --- docs/dlp-enforcement.md | 126 ++++++++++++++++++ docs/dlp-gap-analysis.md | 9 ++ .../dlp-endpoint-signals-collector.ps1 | 120 ++++++++++++++++- windows/dlp-endpoint-signals-collector.ps1 | 120 ++++++++++++++++- 4 files changed, 369 insertions(+), 6 deletions(-) create mode 100644 docs/dlp-enforcement.md diff --git a/docs/dlp-enforcement.md b/docs/dlp-enforcement.md new file mode 100644 index 0000000..2751da0 --- /dev/null +++ b/docs/dlp-enforcement.md @@ -0,0 +1,126 @@ +# DLP Enforcement (action: "block") + +## Обзор + +Phase 2.5 расширяет DLP endpoint collector функциями **активного предотвращения** (enforcement). +При `action: "block"` в правиле DLP-политики коллектор не только регистрирует инцидент, но и выполняет блокирующее действие: + +| Канал | Действие при `block` | +|-----------|-----------------------------------------------------------| +| clipboard | Очистка буфера обмена (`Set-Clipboard -Value $null`) | +| usb | Перевод USB-диска в read-only (`Set-Disk -IsReadOnly`) | +| print | Отмена задания печати (`Remove-CimInstance Win32_PrintJob`)| + +Во всех случаях пользователь получает Windows-уведомление (balloon notification) с описанием причины блокировки. + +## Конфигурация политики + +Формат `dlp-policy.json` не изменился — поле `action` в правиле теперь поддерживает значение `"block"` наряду с `"alert"` (по умолчанию). + +### Пример: блокировка USB записи + +```json +{ + "defaults": { + "enabled": true, + "action": "alert", + "severity": "medium", + "cooldownSeconds": 300 + }, + "endpoint": { + "usb": [ + { + "id": "block-all-usb-write", + "action": "block", + "severity": "high", + "message": "Запись на USB-носитель заблокирована политикой DLP" + } + ], + "clipboard": [ + { + "id": "block-pdn-clipboard", + "action": "block", + "severity": "high", + "regexPatterns": [ + "\\b\\d{3}-\\d{3}-\\d{3}\\s?\\d{2}\\b", + "\\b\\d{4}\\s?\\d{6}\\b" + ], + "minLength": 8, + "message": "Буфер обмена очищен: обнаружены персональные данные (СНИЛС/паспорт)" + } + ], + "print": [ + { + "id": "block-confidential-print", + "action": "block", + "severity": "high", + "documentRegex": "(?i)(конфиденциально|секретно|confidential|restricted)", + "message": "Печать заблокирована: документ содержит метку конфиденциальности" + } + ] + } +} +``` + +### Пример: только мониторинг (без блокировки) + +```json +{ + "endpoint": { + "usb": [ + { + "id": "monitor-usb", + "action": "alert", + "severity": "medium", + "message": "Обнаружено подключение USB-носителя" + } + ] + } +} +``` + +## Телеметрия + +Каждый инцидент с enforcement записывается в bucket `aw-dlp-incidents_` с дополнительным полем: + +```json +{ + "ruleId": "block-all-usb-write", + "action": "block", + "severity": "high", + "signalType": "usb_insert", + "enforced": true, + "driveLetter": "E:", + "volumeName": "FLASH_DRIVE" +} +``` + +- `enforced: true` — блокировка выполнена успешно +- `enforced: false` — блокировка не удалась (недостаточно прав, устройство недоступно и т.д.) + +## Требования + +- **Clipboard block**: Не требует повышенных прав. +- **USB write-block**: Требует запуск от имени администратора (для `Set-Disk -IsReadOnly`). При запуске без прав блокировка не сработает, но инцидент будет зарегистрирован с `enforced: false`. +- **Print block**: Требует права на отмену заданий печати (обычно — SYSTEM или администратор принт-сервера). + +## Уведомления + +При каждой блокировке пользователю показывается Windows balloon notification: + +| Канал | Заголовок | +|-----------|--------------------------------------| +| clipboard | `DLP: буфер обмена очищен` | +| usb | `DLP: USB заблокирован для записи` | +| print | `DLP: печать заблокирована` | + +Текст уведомления берётся из поля `message` правила политики. + +## Rollback + +Для отключения enforcement без изменения кода — смените `action` с `"block"` на `"alert"` в `dlp-policy.json`. Все правила продолжат мониторинг без блокировки. + +Для USB, переведённого в read-only, восстановление: +```powershell +Get-Disk | Where-Object { $_.BusType -eq 'USB' -and $_.IsReadOnly } | Set-Disk -IsReadOnly $false +``` diff --git a/docs/dlp-gap-analysis.md b/docs/dlp-gap-analysis.md index cd0525e..4580910 100644 --- a/docs/dlp-gap-analysis.md +++ b/docs/dlp-gap-analysis.md @@ -32,6 +32,15 @@ - File-operation telemetry (create/copy/archive/upload hints) — в backlog. - Central incident aggregation/export — в backlog. +### Phase 2.5 — Enforcement (внедрено) + +- USB write-block (`Set-Disk -IsReadOnly`) при `action: "block"` — внедрено. +- Print job cancel (`Remove-CimInstance Win32_PrintJob`) при `action: "block"` — внедрено. +- Clipboard clear (`Set-Clipboard -Value $null`) при `action: "block"` — внедрено. +- Windows balloon notification пользователю при блокировке — внедрено. +- Телеметрия enforcement (`enforced: true/false` в incident heartbeat) — внедрено. +- Документация: `docs/dlp-enforcement.md`. + ### Phase 3 - Policy engine service (server-side), versioned policies, approval workflow. diff --git a/install-kit-awindows-20260427-211240/windows/dlp-endpoint-signals-collector.ps1 b/install-kit-awindows-20260427-211240/windows/dlp-endpoint-signals-collector.ps1 index 8ee44f5..de84fdf 100644 --- a/install-kit-awindows-20260427-211240/windows/dlp-endpoint-signals-collector.ps1 +++ b/install-kit-awindows-20260427-211240/windows/dlp-endpoint-signals-collector.ps1 @@ -215,6 +215,99 @@ function Capture-IncidentScreenshot { } } +# --------------------------------------------------------------------------- +# Enforcement functions (action = "block") +# --------------------------------------------------------------------------- + +function Show-EnforcementNotification { + param( + [Parameter(Mandatory = $true)][string]$Title, + [Parameter(Mandatory = $true)][string]$Body + ) + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue + $icon = New-Object System.Windows.Forms.NotifyIcon + $icon.Icon = [System.Drawing.SystemIcons]::Warning + $icon.BalloonTipTitle = $Title + $icon.BalloonTipText = $Body + $icon.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Warning + $icon.Visible = $true + $icon.ShowBalloonTip(5000) + Start-Sleep -Milliseconds 200 + $icon.Dispose() + } + catch { + Write-EndpointLog ("notification failed: {0}" -f $_.Exception.Message) + } +} + +function Invoke-ClipboardEnforcement { + [OutputType([bool])] + param() + try { + Set-Clipboard -Value $null -ErrorAction Stop + Write-EndpointLog "enforcement: clipboard cleared" + return $true + } + catch { + Write-EndpointLog ("enforcement: clipboard clear failed: {0}" -f $_.Exception.Message) + return $false + } +} + +function Invoke-UsbWriteBlockEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)][string]$DriveLetter + ) + try { + $partition = Get-Partition -DriveLetter ($DriveLetter.TrimEnd(':')) -ErrorAction Stop + $disk = Get-Disk -Number $partition.DiskNumber -ErrorAction Stop + if ($disk.BusType -ne 'USB') { + Write-EndpointLog ("enforcement: skip non-USB disk {0} bus={1}" -f $disk.Number, $disk.BusType) + return $false + } + if (-not $disk.IsReadOnly) { + Set-Disk -Number $disk.Number -IsReadOnly $true -ErrorAction Stop + Write-EndpointLog ("enforcement: USB disk {0} ({1}) set read-only" -f $disk.Number, $DriveLetter) + } + return $true + } + catch { + Write-EndpointLog ("enforcement: USB write-block failed drive={0}: {1}" -f $DriveLetter, $_.Exception.Message) + return $false + } +} + +function Invoke-PrintJobEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)][string]$PrinterName, + [string]$DocumentName, + [string]$Owner + ) + $cancelled = $false + try { + $jobs = Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue + foreach ($job in @($jobs)) { + $jobPrinter = [string]$job.Name + $jobOwner = [string]$job.Owner + $jobDoc = [string]$job.Document + $matchPrinter = ($jobPrinter -like "*$PrinterName*") + $matchOwner = (-not $Owner) -or ($jobOwner -like "*$Owner*") -or ($jobOwner -like "*$env:USERNAME*") + if ($matchPrinter -and $matchOwner) { + Remove-CimInstance -InputObject $job -ErrorAction Stop + Write-EndpointLog ("enforcement: print job cancelled id={0} printer={1} doc={2}" -f $job.JobId, $jobPrinter, $jobDoc) + $cancelled = $true + } + } + } + catch { + Write-EndpointLog ("enforcement: print cancel failed printer={0}: {1}" -f $PrinterName, $_.Exception.Message) + } + return $cancelled +} + function Get-StringHash { param([AllowNull()][string]$Value) if ($null -eq $Value) { return $null } @@ -321,11 +414,18 @@ function Evaluate-ClipboardRules { $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } $message = if ($rule.message) { [string]$rule.message } else { "Clipboard rule matched: $ruleId" } + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-ClipboardEnforcement + Show-EnforcementNotification -Title 'DLP: буфер обмена очищен' -Body $message + } + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'clipboard' -Data @{ clipboardHash = $ClipboardHash clipboardLength = $ClipboardText.Length + enforced = $enforced } - Write-EndpointLog ("incident clipboard rule={0} action={1} severity={2}" -f $ruleId, $action, $severity) + Write-EndpointLog ("incident clipboard rule={0} action={1} severity={2} enforced={3}" -f $ruleId, $action, $severity, $enforced) } } @@ -349,11 +449,18 @@ function Evaluate-UsbRules { $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } $message = if ($rule.message) { [string]$rule.message } else { "USB rule matched: $ruleId" } + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-UsbWriteBlockEnforcement -DriveLetter $DriveLetter + Show-EnforcementNotification -Title 'DLP: USB заблокирован для записи' -Body $message + } + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'usb_insert' -Data @{ driveLetter = $DriveLetter volumeName = $VolumeName + enforced = $enforced } - Write-EndpointLog ("incident usb rule={0} action={1} severity={2} drive={3}" -f $ruleId, $action, $severity, $DriveLetter) + Write-EndpointLog ("incident usb rule={0} action={1} severity={2} drive={3} enforced={4}" -f $ruleId, $action, $severity, $DriveLetter, $enforced) } } @@ -387,12 +494,19 @@ function Evaluate-PrintRules { $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } $message = if ($rule.message) { [string]$rule.message } else { "Print rule matched: $ruleId" } + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-PrintJobEnforcement -PrinterName $PrinterName -DocumentName $DocumentName -Owner $Owner + Show-EnforcementNotification -Title 'DLP: печать заблокирована' -Body $message + } + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'print_job' -Data @{ printerName = $PrinterName documentName = $DocumentName owner = $Owner + enforced = $enforced } - Write-EndpointLog ("incident print rule={0} action={1} severity={2} printer={3}" -f $ruleId, $action, $severity, $PrinterName) + Write-EndpointLog ("incident print rule={0} action={1} severity={2} printer={3} enforced={4}" -f $ruleId, $action, $severity, $PrinterName, $enforced) } } diff --git a/windows/dlp-endpoint-signals-collector.ps1 b/windows/dlp-endpoint-signals-collector.ps1 index 8ee44f5..de84fdf 100644 --- a/windows/dlp-endpoint-signals-collector.ps1 +++ b/windows/dlp-endpoint-signals-collector.ps1 @@ -215,6 +215,99 @@ function Capture-IncidentScreenshot { } } +# --------------------------------------------------------------------------- +# Enforcement functions (action = "block") +# --------------------------------------------------------------------------- + +function Show-EnforcementNotification { + param( + [Parameter(Mandatory = $true)][string]$Title, + [Parameter(Mandatory = $true)][string]$Body + ) + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue + $icon = New-Object System.Windows.Forms.NotifyIcon + $icon.Icon = [System.Drawing.SystemIcons]::Warning + $icon.BalloonTipTitle = $Title + $icon.BalloonTipText = $Body + $icon.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Warning + $icon.Visible = $true + $icon.ShowBalloonTip(5000) + Start-Sleep -Milliseconds 200 + $icon.Dispose() + } + catch { + Write-EndpointLog ("notification failed: {0}" -f $_.Exception.Message) + } +} + +function Invoke-ClipboardEnforcement { + [OutputType([bool])] + param() + try { + Set-Clipboard -Value $null -ErrorAction Stop + Write-EndpointLog "enforcement: clipboard cleared" + return $true + } + catch { + Write-EndpointLog ("enforcement: clipboard clear failed: {0}" -f $_.Exception.Message) + return $false + } +} + +function Invoke-UsbWriteBlockEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)][string]$DriveLetter + ) + try { + $partition = Get-Partition -DriveLetter ($DriveLetter.TrimEnd(':')) -ErrorAction Stop + $disk = Get-Disk -Number $partition.DiskNumber -ErrorAction Stop + if ($disk.BusType -ne 'USB') { + Write-EndpointLog ("enforcement: skip non-USB disk {0} bus={1}" -f $disk.Number, $disk.BusType) + return $false + } + if (-not $disk.IsReadOnly) { + Set-Disk -Number $disk.Number -IsReadOnly $true -ErrorAction Stop + Write-EndpointLog ("enforcement: USB disk {0} ({1}) set read-only" -f $disk.Number, $DriveLetter) + } + return $true + } + catch { + Write-EndpointLog ("enforcement: USB write-block failed drive={0}: {1}" -f $DriveLetter, $_.Exception.Message) + return $false + } +} + +function Invoke-PrintJobEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)][string]$PrinterName, + [string]$DocumentName, + [string]$Owner + ) + $cancelled = $false + try { + $jobs = Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue + foreach ($job in @($jobs)) { + $jobPrinter = [string]$job.Name + $jobOwner = [string]$job.Owner + $jobDoc = [string]$job.Document + $matchPrinter = ($jobPrinter -like "*$PrinterName*") + $matchOwner = (-not $Owner) -or ($jobOwner -like "*$Owner*") -or ($jobOwner -like "*$env:USERNAME*") + if ($matchPrinter -and $matchOwner) { + Remove-CimInstance -InputObject $job -ErrorAction Stop + Write-EndpointLog ("enforcement: print job cancelled id={0} printer={1} doc={2}" -f $job.JobId, $jobPrinter, $jobDoc) + $cancelled = $true + } + } + } + catch { + Write-EndpointLog ("enforcement: print cancel failed printer={0}: {1}" -f $PrinterName, $_.Exception.Message) + } + return $cancelled +} + function Get-StringHash { param([AllowNull()][string]$Value) if ($null -eq $Value) { return $null } @@ -321,11 +414,18 @@ function Evaluate-ClipboardRules { $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } $message = if ($rule.message) { [string]$rule.message } else { "Clipboard rule matched: $ruleId" } + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-ClipboardEnforcement + Show-EnforcementNotification -Title 'DLP: буфер обмена очищен' -Body $message + } + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'clipboard' -Data @{ clipboardHash = $ClipboardHash clipboardLength = $ClipboardText.Length + enforced = $enforced } - Write-EndpointLog ("incident clipboard rule={0} action={1} severity={2}" -f $ruleId, $action, $severity) + Write-EndpointLog ("incident clipboard rule={0} action={1} severity={2} enforced={3}" -f $ruleId, $action, $severity, $enforced) } } @@ -349,11 +449,18 @@ function Evaluate-UsbRules { $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } $message = if ($rule.message) { [string]$rule.message } else { "USB rule matched: $ruleId" } + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-UsbWriteBlockEnforcement -DriveLetter $DriveLetter + Show-EnforcementNotification -Title 'DLP: USB заблокирован для записи' -Body $message + } + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'usb_insert' -Data @{ driveLetter = $DriveLetter volumeName = $VolumeName + enforced = $enforced } - Write-EndpointLog ("incident usb rule={0} action={1} severity={2} drive={3}" -f $ruleId, $action, $severity, $DriveLetter) + Write-EndpointLog ("incident usb rule={0} action={1} severity={2} drive={3} enforced={4}" -f $ruleId, $action, $severity, $DriveLetter, $enforced) } } @@ -387,12 +494,19 @@ function Evaluate-PrintRules { $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } $message = if ($rule.message) { [string]$rule.message } else { "Print rule matched: $ruleId" } + $enforced = $false + if ($action -eq 'block') { + $enforced = Invoke-PrintJobEnforcement -PrinterName $PrinterName -DocumentName $DocumentName -Owner $Owner + Show-EnforcementNotification -Title 'DLP: печать заблокирована' -Body $message + } + Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'print_job' -Data @{ printerName = $PrinterName documentName = $DocumentName owner = $Owner + enforced = $enforced } - Write-EndpointLog ("incident print rule={0} action={1} severity={2} printer={3}" -f $ruleId, $action, $severity, $PrinterName) + Write-EndpointLog ("incident print rule={0} action={1} severity={2} printer={3} enforced={4}" -f $ruleId, $action, $severity, $PrinterName, $enforced) } } From f916764d5375bcc611fbe175a5e8a31690932492 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 3 May 2026 20:30:18 +0000 Subject: [PATCH 2/2] =?UTF-8?q?feat(dlp):=20add=20email=20outbound=20colle?= =?UTF-8?q?ctor=20=E2=80=94=20Outlook=20COM=20+=20SMTP=20monitor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two collection modes: - outlook: polls Sent Items via COM, extracts metadata (subject hash, recipients hash, attachment names, body length) - smtp: monitors SMTP connections (25/587/465/2525) via Get-NetTCPConnection DLP policy rules: endpoint.email[] with regex matching on subject, recipients, sender, attachments, externalOnly flag. Enforcement: action=block moves mail to Drafts (Outlook mode). Privacy: subject/recipients stored as SHA256, body never read. Co-Authored-By: Fashion Lisa --- docs/dlp-gap-analysis.md | 9 + docs/email-outbound-collector.md | 164 +++++ .../windows/email-outbound-collector.ps1 | 582 ++++++++++++++++++ windows/email-outbound-collector.ps1 | 582 ++++++++++++++++++ 4 files changed, 1337 insertions(+) create mode 100644 docs/email-outbound-collector.md create mode 100644 install-kit-awindows-20260427-211240/windows/email-outbound-collector.ps1 create mode 100644 windows/email-outbound-collector.ps1 diff --git a/docs/dlp-gap-analysis.md b/docs/dlp-gap-analysis.md index 4580910..c799ac7 100644 --- a/docs/dlp-gap-analysis.md +++ b/docs/dlp-gap-analysis.md @@ -41,6 +41,15 @@ - Телеметрия enforcement (`enforced: true/false` в incident heartbeat) — внедрено. - Документация: `docs/dlp-enforcement.md`. +### Phase 2.5 — Email Outbound Collector (внедрено) + +- Мониторинг исходящей почты через Outlook COM (Sent Items polling) — внедрено. +- SMTP network connection detection (порты 25/587/465/2525) — внедрено. +- DLP-правила `endpoint.email[]` (regex по теме, получателям, вложениям, externalOnly) — внедрено. +- Enforcement: перемещение в Drafts при `action: "block"` (Outlook mode) — внедрено. +- Приватность: тема/получатели как SHA256, тело не читается — внедрено. +- Документация: `docs/email-outbound-collector.md`. + ### Phase 3 - Policy engine service (server-side), versioned policies, approval workflow. diff --git a/docs/email-outbound-collector.md b/docs/email-outbound-collector.md new file mode 100644 index 0000000..25c79f9 --- /dev/null +++ b/docs/email-outbound-collector.md @@ -0,0 +1,164 @@ +# Email Outbound Collector + +## Обзор + +Мониторинг исходящей почты на Windows-эндпоинтах. Два режима работы: + +| Режим | Источник | Данные | +|----------|--------------------------------|-------------------------------------------------------| +| outlook | Outlook COM (Sent Items) | Subject, From, To/CC, вложения, размер тела | +| smtp | `Get-NetTCPConnection` | SMTP-соединения (порты 25/587/465/2525), процесс | + +По умолчанию `Mode = 'both'` — оба режима активны одновременно. + +## Запуск + +```powershell +# С deployment-config.json (штатный вариант) +.\email-outbound-collector.ps1 + +# С явными параметрами +.\email-outbound-collector.ps1 -ServerHost 10.10.10.13 -ServerPort 5600 -Mode outlook + +# Только SMTP мониторинг (без Outlook) +.\email-outbound-collector.ps1 -ServerHost 10.10.10.13 -Mode smtp +``` + +### Параметры + +| Параметр | По умолчанию | Описание | +|----------------|-----------------------------------------|---------------------------------| +| `-ConfigPath` | `C:\ProgramData\ActivityWatch\deployment-config.json` | Путь к конфигу | +| `-ServerHost` | из конфига | Адрес AW-сервера | +| `-ServerPort` | из конфига / 5600 | Порт AW-сервера | +| `-PolicyPath` | из конфига / `dlp-policy.json` | Путь к DLP-политике | +| `-Mode` | `both` | `outlook`, `smtp`, или `both` | +| `-PollSeconds` | из конфига / 10 | Интервал опроса | + +## AW Buckets + +- `aw-email-monitor_` — все email-события (signal heartbeats) +- `aw-dlp-incidents_` — инциденты при срабатывании DLP-правил + +## DLP-политика: секция `endpoint.email` + +Добавляется в существующий `dlp-policy.json`: + +```json +{ + "endpoint": { + "email": [ + { + "id": "block-external-attachments", + "action": "block", + "severity": "high", + "minAttachments": 1, + "externalOnly": true, + "internalDomain": "@company.ru", + "message": "Запрещена отправка вложений на внешние адреса" + }, + { + "id": "alert-confidential-subject", + "action": "alert", + "severity": "medium", + "subjectRegex": "(?i)(конфиденциально|секретно|для служебного пользования)", + "message": "Обнаружена отправка письма с пометкой конфиденциальности" + }, + { + "id": "alert-personal-data", + "action": "alert", + "severity": "high", + "recipientRegex": "(?i)(gmail\\.com|mail\\.ru|yandex\\.ru|yahoo\\.com)", + "minAttachments": 1, + "message": "Отправка вложений на личную почту" + } + ] + } +} +``` + +### Параметры правил + +| Поле | Тип | Описание | +|-------------------|--------|-----------------------------------------------------------| +| `id` | string | Уникальный ID правила (обязательно) | +| `action` | string | `alert` (по умолчанию) или `block` | +| `severity` | string | `low`, `medium`, `high`, `critical` | +| `subjectRegex` | string | Regex по теме письма | +| `recipientRegex` | string | Regex по списку получателей | +| `senderRegex` | string | Regex по адресу отправителя | +| `attachmentRegex` | string | Regex по именам вложений | +| `minAttachments` | int | Минимальное количество вложений для срабатывания | +| `minBodyLength` | int | Минимальная длина тела письма | +| `externalOnly` | bool | Срабатывать только на внешних получателей | +| `internalDomain` | string | Домен организации (используется с `externalOnly`) | +| `cooldownSeconds` | int | Cooldown между повторными инцидентами | +| `message` | string | Текст уведомления пользователю и в инцидент | + +## Enforcement (action: "block") + +**Outlook mode**: письмо перемещается из Sent Items в Drafts. Пользователь получает balloon notification. + +**SMTP mode**: только уведомление (перехват SMTP-соединения на сетевом уровне не реализуем из PowerShell). Инцидент записывается с `enforced: false`. + +## Телеметрия + +### Heartbeat `email_sent` (Outlook mode) +```json +{ + "signalType": "email_sent", + "subject": "", + "sender": "user@company.ru", + "recipientCount": 3, + "recipients": "", + "attachmentCount": 2, + "attachmentNames": "report.xlsx; data.csv", + "bodyLength": 1520, + "collectionMode": "outlook" +} +``` + +### Heartbeat `smtp_connection` (SMTP mode) +```json +{ + "signalType": "smtp_connection", + "remoteAddress": "74.125.205.108", + "remotePort": 587, + "processId": 12340, + "processName": "OUTLOOK", + "collectionMode": "smtp" +} +``` + +### Incident +```json +{ + "ruleId": "block-external-attachments", + "action": "block", + "severity": "high", + "signalType": "email_outbound", + "subject": "", + "attachmentCount": 2, + "enforced": true +} +``` + +## Приватность + +- Тема и получатели записываются как SHA256-хеш (не открытый текст). +- Тело письма не читается и не хранится — записывается только длина. +- Имена вложений записываются открытым текстом (для DLP-анализа). + +## Интеграция в ensemble + +Добавьте в `launch-watchers.ps1` или Task Scheduler: + +```powershell +Start-Process powershell.exe -ArgumentList '-ExecutionPolicy Bypass -File "C:\ProgramData\ActivityWatch\email-outbound-collector.ps1"' -WindowStyle Hidden +``` + +## Требования + +- **Outlook mode**: Microsoft Outlook установлен и настроен для текущего пользователя. +- **SMTP mode**: Не требует дополнительного ПО. Работает на уровне TCP-соединений. +- **Enforcement (block)**: Outlook mode — требует доступ к COM объекту Outlook. diff --git a/install-kit-awindows-20260427-211240/windows/email-outbound-collector.ps1 b/install-kit-awindows-20260427-211240/windows/email-outbound-collector.ps1 new file mode 100644 index 0000000..ff50fa5 --- /dev/null +++ b/install-kit-awindows-20260427-211240/windows/email-outbound-collector.ps1 @@ -0,0 +1,582 @@ +<# +.SYNOPSIS + DLP email outbound collector for AWatch-rus (Phase 2.5). + Monitors outgoing email via Outlook COM Sent Items polling + and/or SMTP network connection detection. + +.DESCRIPTION + Two collection modes (configurable, can run simultaneously): + - outlook : Polls Outlook Sent Items via COM for new messages. + - smtp : Monitors SMTP connections (ports 25/587/465) via + Get-NetTCPConnection for any process sending mail. + + Sends heartbeats to AW bucket `aw-email-monitor_`. + Evaluates DLP policy rules from `endpoint.email[]` section. + Supports enforcement: action="block" moves the email to Drafts + (Outlook mode) or logs with enforced=false (SMTP mode). +#> +[CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json', + [string]$ServerHost, + [int]$ServerPort, + [ValidateSet('http', 'https')] + [string]$ServerScheme, + [string]$PolicyPath, + [string]$LogPath, + [int]$PollSeconds, + [ValidateSet('outlook', 'smtp', 'both')] + [string]$Mode = 'both' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# --------------------------------------------------------------------------- +# Shared infrastructure (mirrors other collectors) +# --------------------------------------------------------------------------- + +function Get-DeploymentConfig { + param([string]$Path) + if ($Path -and (Test-Path -LiteralPath $Path)) { + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + } + return $null +} + +function Write-CollectorLog { + param([string]$Message) + if (-not $script:LocalAgentLogsEnabled) { return } + try { + Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { } +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [Parameter(Mandatory = $true)][string]$Json + ) + $bytes = [Text.Encoding]::UTF8.GetBytes($Json) + Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null +} + +function Ensure-Bucket { + param( + [string]$BucketId, + [string]$ClientName, + [string]$BucketType + ) + if ($script:KnownBuckets.ContainsKey($BucketId)) { return } + $body = @{ + client = $ClientName + type = $BucketType + hostname = $script:Hostname + } | ConvertTo-Json -Compress + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body + $script:KnownBuckets[$BucketId] = $true +} + +function Get-StringHash { + param([AllowNull()][string]$Value) + if ($null -eq $Value) { return $null } + $bytes = [Text.Encoding]::UTF8.GetBytes($Value) + $sha = [Security.Cryptography.SHA256]::Create() + try { + ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { $sha.Dispose() } +} + +function Send-EmailHeartbeat { + param( + [string]$SignalType, + [hashtable]$Data + ) + $bucketId = 'aw-email-monitor_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-email-monitor' -BucketType 'aw.dlp.email' + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + signalType = $SignalType + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'email-outbound-collector' + } + $Data + } | ConvertTo-Json -Depth 6 -Compress + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Send-EmailIncidentHeartbeat { + param( + [string]$RuleId, + [string]$Action, + [string]$Severity, + [string]$Message, + [hashtable]$Data + ) + $bucketId = 'aw-dlp-incidents_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-incidents' -BucketType 'aw.dlp.incident' + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + ruleId = $RuleId + action = $Action + severity = $Severity + message = $Message + signalType = 'email_outbound' + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'email-outbound-collector' + } + $Data + } | ConvertTo-Json -Depth 7 -Compress + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Show-EnforcementNotification { + param( + [Parameter(Mandatory = $true)][string]$Title, + [Parameter(Mandatory = $true)][string]$Body + ) + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue + $icon = New-Object System.Windows.Forms.NotifyIcon + $icon.Icon = [System.Drawing.SystemIcons]::Warning + $icon.BalloonTipTitle = $Title + $icon.BalloonTipText = $Body + $icon.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Warning + $icon.Visible = $true + $icon.ShowBalloonTip(5000) + Start-Sleep -Milliseconds 200 + $icon.Dispose() + } + catch { } +} + +# --------------------------------------------------------------------------- +# DLP Policy +# --------------------------------------------------------------------------- + +function Load-EmailPolicy { + param([string]$Path) + + $script:Policy = [ordered]@{ + defaults = [ordered]@{ + enabled = $true + cooldownSeconds = 300 + action = 'alert' + severity = 'medium' + } + endpoint = [ordered]@{ + email = @() + } + } + + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { + Write-CollectorLog ("policy not found, using defaults: {0}" -f $Path) + return + } + + try { + $raw = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + if ($raw.defaults) { + if ($raw.defaults.PSObject.Properties.Name -contains 'enabled') { $script:Policy.defaults.enabled = [bool]$raw.defaults.enabled } + if ($raw.defaults.cooldownSeconds) { $script:Policy.defaults.cooldownSeconds = [int]$raw.defaults.cooldownSeconds } + if ($raw.defaults.action) { $script:Policy.defaults.action = [string]$raw.defaults.action } + if ($raw.defaults.severity) { $script:Policy.defaults.severity = [string]$raw.defaults.severity } + } + if ($raw.endpoint -and $raw.endpoint.email) { + $script:Policy.endpoint.email = @($raw.endpoint.email) + } + } + catch { + Write-CollectorLog ("policy parse failed: {0}" -f $_.Exception.Message) + } +} + +function Should-EmitByCooldown { + param( + [string]$Fingerprint, + [int]$CooldownSeconds + ) + $now = (Get-Date).ToUniversalTime() + if ($script:Cooldown.ContainsKey($Fingerprint)) { + $last = [datetime]$script:Cooldown[$Fingerprint] + if ((New-TimeSpan -Start $last -End $now).TotalSeconds -lt $CooldownSeconds) { + return $false + } + } + $script:Cooldown[$Fingerprint] = $now + return $true +} + +# --------------------------------------------------------------------------- +# Email DLP rule evaluation +# --------------------------------------------------------------------------- + +function Evaluate-EmailRules { + param( + [string]$Subject, + [string]$RecipientsJoined, + [string]$SenderAddress, + [int]$AttachmentCount, + [string]$AttachmentNames, + [int]$BodyLength, + [string]$MessageId, + $OutlookMailItem + ) + + foreach ($rule in @($script:Policy.endpoint.email)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + + $matched = $true + + if ($rule.subjectRegex) { + $matched = $matched -and ($Subject -match [string]$rule.subjectRegex) + } + if ($rule.recipientRegex) { + $matched = $matched -and ($RecipientsJoined -match [string]$rule.recipientRegex) + } + if ($rule.senderRegex) { + $matched = $matched -and ($SenderAddress -match [string]$rule.senderRegex) + } + if ($rule.attachmentRegex -and $AttachmentNames) { + $matched = $matched -and ($AttachmentNames -match [string]$rule.attachmentRegex) + } + if ($rule.minAttachments) { + $matched = $matched -and ($AttachmentCount -ge [int]$rule.minAttachments) + } + if ($rule.minBodyLength) { + $matched = $matched -and ($BodyLength -ge [int]$rule.minBodyLength) + } + if ($rule.externalOnly -and [bool]$rule.externalOnly) { + $internalDomain = if ($rule.internalDomain) { [string]$rule.internalDomain } else { '' } + if ($internalDomain -and $RecipientsJoined -notmatch [regex]::Escape($internalDomain)) { + # all recipients are external — continue matching + } + elseif ($internalDomain) { + $matched = $false + } + } + + if (-not $matched) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $fingerprint = "email|$ruleId|$MessageId|$env:USERNAME" + if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue } + + $action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action } + $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } + $message = if ($rule.message) { [string]$rule.message } else { "Email rule matched: $ruleId" } + + $enforced = $false + if ($action -eq 'block' -and $null -ne $OutlookMailItem) { + $enforced = Invoke-EmailEnforcement -MailItem $OutlookMailItem -RuleId $ruleId + Show-EnforcementNotification -Title 'DLP: письмо перемещено в черновики' -Body $message + } + elseif ($action -eq 'block') { + Show-EnforcementNotification -Title 'DLP: обнаружена отправка письма' -Body $message + } + + Send-EmailIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -Data @{ + subject = (Get-StringHash -Value $Subject) + recipients = (Get-StringHash -Value $RecipientsJoined) + sender = $SenderAddress + attachmentCount = $AttachmentCount + attachmentNames = $AttachmentNames + bodyLength = $BodyLength + enforced = $enforced + } + Write-CollectorLog ("incident email rule={0} action={1} severity={2} enforced={3} subject_hash={4}" -f $ruleId, $action, $severity, $enforced, (Get-StringHash -Value $Subject)) + } +} + +function Invoke-EmailEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)]$MailItem, + [string]$RuleId + ) + try { + $draftsFolder = $script:OutlookNamespace.GetDefaultFolder(16) # olFolderDrafts + $MailItem.Move($draftsFolder) | Out-Null + Write-CollectorLog ("enforcement: email moved to Drafts rule={0} subject_hash={1}" -f $RuleId, (Get-StringHash -Value $MailItem.Subject)) + return $true + } + catch { + Write-CollectorLog ("enforcement: email move to Drafts failed rule={0}: {1}" -f $RuleId, $_.Exception.Message) + return $false + } +} + +# --------------------------------------------------------------------------- +# Outlook Sent Items polling +# --------------------------------------------------------------------------- + +function Initialize-OutlookCom { + try { + $script:OutlookApp = New-Object -ComObject Outlook.Application + $script:OutlookNamespace = $script:OutlookApp.GetNamespace('MAPI') + $script:SentFolder = $script:OutlookNamespace.GetDefaultFolder(5) # olFolderSentMail + Write-CollectorLog "Outlook COM initialized, Sent Items folder opened" + return $true + } + catch { + Write-CollectorLog ("Outlook COM init failed: {0}" -f $_.Exception.Message) + return $false + } +} + +function Get-OutlookSentItems { + param([datetime]$Since) + + $results = @() + try { + $items = $script:SentFolder.Items + $items.Sort('[SentOn]', $true) + + $filter = "[SentOn] >= '{0}'" -f $Since.ToString('MM/dd/yyyy HH:mm') + $restricted = $items.Restrict($filter) + + foreach ($item in $restricted) { + try { + if ($item.Class -ne 43) { continue } # olMail = 43 + + $recipients = @() + for ($i = 1; $i -le $item.Recipients.Count; $i++) { + $recip = $item.Recipients.Item($i) + $recipients += [string]$recip.Address + } + + $attachmentNames = @() + for ($i = 1; $i -le $item.Attachments.Count; $i++) { + $attachmentNames += [string]$item.Attachments.Item($i).FileName + } + + $results += [pscustomobject]@{ + EntryID = [string]$item.EntryID + Subject = [string]$item.Subject + SenderAddress = [string]$item.SenderEmailAddress + SenderName = [string]$item.SenderName + Recipients = $recipients + RecipientsJoined = ($recipients -join '; ') + AttachmentCount = [int]$item.Attachments.Count + AttachmentNames = ($attachmentNames -join '; ') + BodyLength = if ($item.Body) { $item.Body.Length } else { 0 } + SentOn = $item.SentOn + MailItem = $item + } + } + catch { } + } + } + catch { + Write-CollectorLog ("Outlook Sent Items scan failed: {0}" -f $_.Exception.Message) + } + return $results +} + +function Poll-OutlookSentItems { + $items = Get-OutlookSentItems -Since $script:OutlookLastPoll + + foreach ($item in $items) { + $entryId = $item.EntryID + if ($script:SeenEntryIds.ContainsKey($entryId)) { continue } + $script:SeenEntryIds[$entryId] = (Get-Date).ToUniversalTime() + + $subjectHash = Get-StringHash -Value $item.Subject + + Send-EmailHeartbeat -SignalType 'email_sent' -Data @{ + subject = $subjectHash + sender = [string]$item.SenderAddress + senderName = [string]$item.SenderName + recipientCount = $item.Recipients.Count + recipients = (Get-StringHash -Value $item.RecipientsJoined) + attachmentCount = [int]$item.AttachmentCount + attachmentNames = [string]$item.AttachmentNames + bodyLength = [int]$item.BodyLength + sentOn = if ($item.SentOn) { $item.SentOn.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') } else { '' } + collectionMode = 'outlook' + } + Write-CollectorLog ("email_sent outlook subject_hash={0} to={1} attachments={2}" -f $subjectHash, $item.Recipients.Count, $item.AttachmentCount) + + Evaluate-EmailRules ` + -Subject $item.Subject ` + -RecipientsJoined $item.RecipientsJoined ` + -SenderAddress $item.SenderAddress ` + -AttachmentCount $item.AttachmentCount ` + -AttachmentNames $item.AttachmentNames ` + -BodyLength $item.BodyLength ` + -MessageId $entryId ` + -OutlookMailItem $item.MailItem + } + + $script:OutlookLastPoll = (Get-Date).AddSeconds(-10) + + # Cleanup old entry IDs (keep last 24h) + $cleanupBefore = (Get-Date).ToUniversalTime().AddHours(-24) + foreach ($k in @($script:SeenEntryIds.Keys)) { + if ([datetime]$script:SeenEntryIds[$k] -lt $cleanupBefore) { + $script:SeenEntryIds.Remove($k) + } + } +} + +# --------------------------------------------------------------------------- +# SMTP network connection monitoring +# --------------------------------------------------------------------------- + +function Poll-SmtpConnections { + try { + $smtpPorts = @(25, 587, 465, 2525) + $connections = Get-NetTCPConnection -State Established -ErrorAction SilentlyContinue | + Where-Object { $smtpPorts -contains $_.RemotePort } + + foreach ($conn in @($connections)) { + $processId = [int]$conn.OwningProcess + $remoteAddr = [string]$conn.RemoteAddress + $remotePort = [int]$conn.RemotePort + $fingerprint = "{0}:{1}:{2}" -f $processId, $remoteAddr, $remotePort + if ($script:SeenSmtpConnections.ContainsKey($fingerprint)) { continue } + $script:SeenSmtpConnections[$fingerprint] = (Get-Date).ToUniversalTime() + + $processName = '' + try { + $proc = Get-Process -Id $processId -ErrorAction SilentlyContinue + $processName = [string]$proc.ProcessName + } + catch { } + + Send-EmailHeartbeat -SignalType 'smtp_connection' -Data @{ + remoteAddress = $remoteAddr + remotePort = $remotePort + processId = $processId + processName = $processName + localPort = [int]$conn.LocalPort + collectionMode = 'smtp' + } + Write-CollectorLog ("smtp_connection process={0}({1}) remote={2}:{3}" -f $processName, $processId, $remoteAddr, $remotePort) + + Evaluate-EmailRules ` + -Subject '' ` + -RecipientsJoined $remoteAddr ` + -SenderAddress $env:USERNAME ` + -AttachmentCount 0 ` + -AttachmentNames '' ` + -BodyLength 0 ` + -MessageId $fingerprint ` + -OutlookMailItem $null + } + + # Cleanup old SMTP connections (keep last 8h) + $cleanupBefore = (Get-Date).ToUniversalTime().AddHours(-8) + foreach ($k in @($script:SeenSmtpConnections.Keys)) { + if ([datetime]$script:SeenSmtpConnections[$k] -lt $cleanupBefore) { + $script:SeenSmtpConnections.Remove($k) + } + } + } + catch { + Write-CollectorLog ("SMTP poll error: {0}" -f $_.Exception.Message) + } +} + +# --------------------------------------------------------------------------- +# Initialization +# --------------------------------------------------------------------------- + +$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath +$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'ServerHost is required.' } +$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 } +$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' } +$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig -and $deploymentConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\ActivityWatch\dlp-policy.json' } +$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 10 } +$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\ActivityWatch\logs' } +$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("email-outbound-{0}.log" -f $env:USERNAME) } +$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true } + +if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) { + New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null +} + +$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort +$script:Hostname = $env:COMPUTERNAME +$script:SessionId = (Get-Process -Id $PID).SessionId +$script:KnownBuckets = @{} +$script:Cooldown = @{} +$script:SeenEntryIds = @{} +$script:SeenSmtpConnections = @{} +$script:PulseSeconds = [Math]::Max($resolvedPollSeconds * 3, 30) +$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled +$script:LogPath = $resolvedLogPath +$script:OutlookApp = $null +$script:OutlookNamespace = $null +$script:SentFolder = $null +$script:OutlookLastPoll = (Get-Date).AddMinutes(-5) + +Load-EmailPolicy -Path $resolvedPolicyPath +Write-CollectorLog ("email collector started mode={0} against {1}" -f $Mode, $script:ApiBase) + +$useOutlook = ($Mode -eq 'outlook' -or $Mode -eq 'both') +$useSmtp = ($Mode -eq 'smtp' -or $Mode -eq 'both') +$outlookReady = $false + +if ($useOutlook) { + $outlookReady = Initialize-OutlookCom + if (-not $outlookReady -and $Mode -eq 'outlook') { + Write-CollectorLog "Outlook COM not available, collector will retry" + } +} + +# --------------------------------------------------------------------------- +# Main loop +# --------------------------------------------------------------------------- + +while ($true) { + try { + if (-not $script:Policy.defaults.enabled) { + Start-Sleep -Seconds $resolvedPollSeconds + continue + } + + if ($useOutlook) { + if (-not $outlookReady) { + $outlookReady = Initialize-OutlookCom + } + if ($outlookReady) { + try { + Poll-OutlookSentItems + } + catch { + Write-CollectorLog ("outlook poll error: {0}" -f $_.Exception.Message) + $outlookReady = $false + $script:OutlookApp = $null + $script:OutlookNamespace = $null + $script:SentFolder = $null + } + } + } + + if ($useSmtp) { + try { + Poll-SmtpConnections + } + catch { + Write-CollectorLog ("smtp poll error: {0}" -f $_.Exception.Message) + } + } + } + catch { + Write-CollectorLog ("collector error: {0}" -f $_.Exception.Message) + } + + Start-Sleep -Seconds $resolvedPollSeconds +} diff --git a/windows/email-outbound-collector.ps1 b/windows/email-outbound-collector.ps1 new file mode 100644 index 0000000..ff50fa5 --- /dev/null +++ b/windows/email-outbound-collector.ps1 @@ -0,0 +1,582 @@ +<# +.SYNOPSIS + DLP email outbound collector for AWatch-rus (Phase 2.5). + Monitors outgoing email via Outlook COM Sent Items polling + and/or SMTP network connection detection. + +.DESCRIPTION + Two collection modes (configurable, can run simultaneously): + - outlook : Polls Outlook Sent Items via COM for new messages. + - smtp : Monitors SMTP connections (ports 25/587/465) via + Get-NetTCPConnection for any process sending mail. + + Sends heartbeats to AW bucket `aw-email-monitor_`. + Evaluates DLP policy rules from `endpoint.email[]` section. + Supports enforcement: action="block" moves the email to Drafts + (Outlook mode) or logs with enforced=false (SMTP mode). +#> +[CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json', + [string]$ServerHost, + [int]$ServerPort, + [ValidateSet('http', 'https')] + [string]$ServerScheme, + [string]$PolicyPath, + [string]$LogPath, + [int]$PollSeconds, + [ValidateSet('outlook', 'smtp', 'both')] + [string]$Mode = 'both' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# --------------------------------------------------------------------------- +# Shared infrastructure (mirrors other collectors) +# --------------------------------------------------------------------------- + +function Get-DeploymentConfig { + param([string]$Path) + if ($Path -and (Test-Path -LiteralPath $Path)) { + return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + } + return $null +} + +function Write-CollectorLog { + param([string]$Message) + if (-not $script:LocalAgentLogsEnabled) { return } + try { + Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { } +} + +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [Parameter(Mandatory = $true)][string]$Json + ) + $bytes = [Text.Encoding]::UTF8.GetBytes($Json) + Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null +} + +function Ensure-Bucket { + param( + [string]$BucketId, + [string]$ClientName, + [string]$BucketType + ) + if ($script:KnownBuckets.ContainsKey($BucketId)) { return } + $body = @{ + client = $ClientName + type = $BucketType + hostname = $script:Hostname + } | ConvertTo-Json -Compress + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body + $script:KnownBuckets[$BucketId] = $true +} + +function Get-StringHash { + param([AllowNull()][string]$Value) + if ($null -eq $Value) { return $null } + $bytes = [Text.Encoding]::UTF8.GetBytes($Value) + $sha = [Security.Cryptography.SHA256]::Create() + try { + ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) -join '' + } + finally { $sha.Dispose() } +} + +function Send-EmailHeartbeat { + param( + [string]$SignalType, + [hashtable]$Data + ) + $bucketId = 'aw-email-monitor_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-email-monitor' -BucketType 'aw.dlp.email' + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + signalType = $SignalType + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'email-outbound-collector' + } + $Data + } | ConvertTo-Json -Depth 6 -Compress + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Send-EmailIncidentHeartbeat { + param( + [string]$RuleId, + [string]$Action, + [string]$Severity, + [string]$Message, + [hashtable]$Data + ) + $bucketId = 'aw-dlp-incidents_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-incidents' -BucketType 'aw.dlp.incident' + $payload = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + ruleId = $RuleId + action = $Action + severity = $Severity + message = $Message + signalType = 'email_outbound' + username = $env:USERNAME + sessionId = $script:SessionId + hostname = $script:Hostname + source = 'email-outbound-collector' + } + $Data + } | ConvertTo-Json -Depth 7 -Compress + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload +} + +function Show-EnforcementNotification { + param( + [Parameter(Mandatory = $true)][string]$Title, + [Parameter(Mandatory = $true)][string]$Body + ) + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue + $icon = New-Object System.Windows.Forms.NotifyIcon + $icon.Icon = [System.Drawing.SystemIcons]::Warning + $icon.BalloonTipTitle = $Title + $icon.BalloonTipText = $Body + $icon.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Warning + $icon.Visible = $true + $icon.ShowBalloonTip(5000) + Start-Sleep -Milliseconds 200 + $icon.Dispose() + } + catch { } +} + +# --------------------------------------------------------------------------- +# DLP Policy +# --------------------------------------------------------------------------- + +function Load-EmailPolicy { + param([string]$Path) + + $script:Policy = [ordered]@{ + defaults = [ordered]@{ + enabled = $true + cooldownSeconds = 300 + action = 'alert' + severity = 'medium' + } + endpoint = [ordered]@{ + email = @() + } + } + + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { + Write-CollectorLog ("policy not found, using defaults: {0}" -f $Path) + return + } + + try { + $raw = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + if ($raw.defaults) { + if ($raw.defaults.PSObject.Properties.Name -contains 'enabled') { $script:Policy.defaults.enabled = [bool]$raw.defaults.enabled } + if ($raw.defaults.cooldownSeconds) { $script:Policy.defaults.cooldownSeconds = [int]$raw.defaults.cooldownSeconds } + if ($raw.defaults.action) { $script:Policy.defaults.action = [string]$raw.defaults.action } + if ($raw.defaults.severity) { $script:Policy.defaults.severity = [string]$raw.defaults.severity } + } + if ($raw.endpoint -and $raw.endpoint.email) { + $script:Policy.endpoint.email = @($raw.endpoint.email) + } + } + catch { + Write-CollectorLog ("policy parse failed: {0}" -f $_.Exception.Message) + } +} + +function Should-EmitByCooldown { + param( + [string]$Fingerprint, + [int]$CooldownSeconds + ) + $now = (Get-Date).ToUniversalTime() + if ($script:Cooldown.ContainsKey($Fingerprint)) { + $last = [datetime]$script:Cooldown[$Fingerprint] + if ((New-TimeSpan -Start $last -End $now).TotalSeconds -lt $CooldownSeconds) { + return $false + } + } + $script:Cooldown[$Fingerprint] = $now + return $true +} + +# --------------------------------------------------------------------------- +# Email DLP rule evaluation +# --------------------------------------------------------------------------- + +function Evaluate-EmailRules { + param( + [string]$Subject, + [string]$RecipientsJoined, + [string]$SenderAddress, + [int]$AttachmentCount, + [string]$AttachmentNames, + [int]$BodyLength, + [string]$MessageId, + $OutlookMailItem + ) + + foreach ($rule in @($script:Policy.endpoint.email)) { + if (-not $rule) { continue } + if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue } + $ruleId = [string]$rule.id + if (-not $ruleId) { continue } + + $matched = $true + + if ($rule.subjectRegex) { + $matched = $matched -and ($Subject -match [string]$rule.subjectRegex) + } + if ($rule.recipientRegex) { + $matched = $matched -and ($RecipientsJoined -match [string]$rule.recipientRegex) + } + if ($rule.senderRegex) { + $matched = $matched -and ($SenderAddress -match [string]$rule.senderRegex) + } + if ($rule.attachmentRegex -and $AttachmentNames) { + $matched = $matched -and ($AttachmentNames -match [string]$rule.attachmentRegex) + } + if ($rule.minAttachments) { + $matched = $matched -and ($AttachmentCount -ge [int]$rule.minAttachments) + } + if ($rule.minBodyLength) { + $matched = $matched -and ($BodyLength -ge [int]$rule.minBodyLength) + } + if ($rule.externalOnly -and [bool]$rule.externalOnly) { + $internalDomain = if ($rule.internalDomain) { [string]$rule.internalDomain } else { '' } + if ($internalDomain -and $RecipientsJoined -notmatch [regex]::Escape($internalDomain)) { + # all recipients are external — continue matching + } + elseif ($internalDomain) { + $matched = $false + } + } + + if (-not $matched) { continue } + + $cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds } + $fingerprint = "email|$ruleId|$MessageId|$env:USERNAME" + if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue } + + $action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action } + $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } + $message = if ($rule.message) { [string]$rule.message } else { "Email rule matched: $ruleId" } + + $enforced = $false + if ($action -eq 'block' -and $null -ne $OutlookMailItem) { + $enforced = Invoke-EmailEnforcement -MailItem $OutlookMailItem -RuleId $ruleId + Show-EnforcementNotification -Title 'DLP: письмо перемещено в черновики' -Body $message + } + elseif ($action -eq 'block') { + Show-EnforcementNotification -Title 'DLP: обнаружена отправка письма' -Body $message + } + + Send-EmailIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -Data @{ + subject = (Get-StringHash -Value $Subject) + recipients = (Get-StringHash -Value $RecipientsJoined) + sender = $SenderAddress + attachmentCount = $AttachmentCount + attachmentNames = $AttachmentNames + bodyLength = $BodyLength + enforced = $enforced + } + Write-CollectorLog ("incident email rule={0} action={1} severity={2} enforced={3} subject_hash={4}" -f $ruleId, $action, $severity, $enforced, (Get-StringHash -Value $Subject)) + } +} + +function Invoke-EmailEnforcement { + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)]$MailItem, + [string]$RuleId + ) + try { + $draftsFolder = $script:OutlookNamespace.GetDefaultFolder(16) # olFolderDrafts + $MailItem.Move($draftsFolder) | Out-Null + Write-CollectorLog ("enforcement: email moved to Drafts rule={0} subject_hash={1}" -f $RuleId, (Get-StringHash -Value $MailItem.Subject)) + return $true + } + catch { + Write-CollectorLog ("enforcement: email move to Drafts failed rule={0}: {1}" -f $RuleId, $_.Exception.Message) + return $false + } +} + +# --------------------------------------------------------------------------- +# Outlook Sent Items polling +# --------------------------------------------------------------------------- + +function Initialize-OutlookCom { + try { + $script:OutlookApp = New-Object -ComObject Outlook.Application + $script:OutlookNamespace = $script:OutlookApp.GetNamespace('MAPI') + $script:SentFolder = $script:OutlookNamespace.GetDefaultFolder(5) # olFolderSentMail + Write-CollectorLog "Outlook COM initialized, Sent Items folder opened" + return $true + } + catch { + Write-CollectorLog ("Outlook COM init failed: {0}" -f $_.Exception.Message) + return $false + } +} + +function Get-OutlookSentItems { + param([datetime]$Since) + + $results = @() + try { + $items = $script:SentFolder.Items + $items.Sort('[SentOn]', $true) + + $filter = "[SentOn] >= '{0}'" -f $Since.ToString('MM/dd/yyyy HH:mm') + $restricted = $items.Restrict($filter) + + foreach ($item in $restricted) { + try { + if ($item.Class -ne 43) { continue } # olMail = 43 + + $recipients = @() + for ($i = 1; $i -le $item.Recipients.Count; $i++) { + $recip = $item.Recipients.Item($i) + $recipients += [string]$recip.Address + } + + $attachmentNames = @() + for ($i = 1; $i -le $item.Attachments.Count; $i++) { + $attachmentNames += [string]$item.Attachments.Item($i).FileName + } + + $results += [pscustomobject]@{ + EntryID = [string]$item.EntryID + Subject = [string]$item.Subject + SenderAddress = [string]$item.SenderEmailAddress + SenderName = [string]$item.SenderName + Recipients = $recipients + RecipientsJoined = ($recipients -join '; ') + AttachmentCount = [int]$item.Attachments.Count + AttachmentNames = ($attachmentNames -join '; ') + BodyLength = if ($item.Body) { $item.Body.Length } else { 0 } + SentOn = $item.SentOn + MailItem = $item + } + } + catch { } + } + } + catch { + Write-CollectorLog ("Outlook Sent Items scan failed: {0}" -f $_.Exception.Message) + } + return $results +} + +function Poll-OutlookSentItems { + $items = Get-OutlookSentItems -Since $script:OutlookLastPoll + + foreach ($item in $items) { + $entryId = $item.EntryID + if ($script:SeenEntryIds.ContainsKey($entryId)) { continue } + $script:SeenEntryIds[$entryId] = (Get-Date).ToUniversalTime() + + $subjectHash = Get-StringHash -Value $item.Subject + + Send-EmailHeartbeat -SignalType 'email_sent' -Data @{ + subject = $subjectHash + sender = [string]$item.SenderAddress + senderName = [string]$item.SenderName + recipientCount = $item.Recipients.Count + recipients = (Get-StringHash -Value $item.RecipientsJoined) + attachmentCount = [int]$item.AttachmentCount + attachmentNames = [string]$item.AttachmentNames + bodyLength = [int]$item.BodyLength + sentOn = if ($item.SentOn) { $item.SentOn.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') } else { '' } + collectionMode = 'outlook' + } + Write-CollectorLog ("email_sent outlook subject_hash={0} to={1} attachments={2}" -f $subjectHash, $item.Recipients.Count, $item.AttachmentCount) + + Evaluate-EmailRules ` + -Subject $item.Subject ` + -RecipientsJoined $item.RecipientsJoined ` + -SenderAddress $item.SenderAddress ` + -AttachmentCount $item.AttachmentCount ` + -AttachmentNames $item.AttachmentNames ` + -BodyLength $item.BodyLength ` + -MessageId $entryId ` + -OutlookMailItem $item.MailItem + } + + $script:OutlookLastPoll = (Get-Date).AddSeconds(-10) + + # Cleanup old entry IDs (keep last 24h) + $cleanupBefore = (Get-Date).ToUniversalTime().AddHours(-24) + foreach ($k in @($script:SeenEntryIds.Keys)) { + if ([datetime]$script:SeenEntryIds[$k] -lt $cleanupBefore) { + $script:SeenEntryIds.Remove($k) + } + } +} + +# --------------------------------------------------------------------------- +# SMTP network connection monitoring +# --------------------------------------------------------------------------- + +function Poll-SmtpConnections { + try { + $smtpPorts = @(25, 587, 465, 2525) + $connections = Get-NetTCPConnection -State Established -ErrorAction SilentlyContinue | + Where-Object { $smtpPorts -contains $_.RemotePort } + + foreach ($conn in @($connections)) { + $processId = [int]$conn.OwningProcess + $remoteAddr = [string]$conn.RemoteAddress + $remotePort = [int]$conn.RemotePort + $fingerprint = "{0}:{1}:{2}" -f $processId, $remoteAddr, $remotePort + if ($script:SeenSmtpConnections.ContainsKey($fingerprint)) { continue } + $script:SeenSmtpConnections[$fingerprint] = (Get-Date).ToUniversalTime() + + $processName = '' + try { + $proc = Get-Process -Id $processId -ErrorAction SilentlyContinue + $processName = [string]$proc.ProcessName + } + catch { } + + Send-EmailHeartbeat -SignalType 'smtp_connection' -Data @{ + remoteAddress = $remoteAddr + remotePort = $remotePort + processId = $processId + processName = $processName + localPort = [int]$conn.LocalPort + collectionMode = 'smtp' + } + Write-CollectorLog ("smtp_connection process={0}({1}) remote={2}:{3}" -f $processName, $processId, $remoteAddr, $remotePort) + + Evaluate-EmailRules ` + -Subject '' ` + -RecipientsJoined $remoteAddr ` + -SenderAddress $env:USERNAME ` + -AttachmentCount 0 ` + -AttachmentNames '' ` + -BodyLength 0 ` + -MessageId $fingerprint ` + -OutlookMailItem $null + } + + # Cleanup old SMTP connections (keep last 8h) + $cleanupBefore = (Get-Date).ToUniversalTime().AddHours(-8) + foreach ($k in @($script:SeenSmtpConnections.Keys)) { + if ([datetime]$script:SeenSmtpConnections[$k] -lt $cleanupBefore) { + $script:SeenSmtpConnections.Remove($k) + } + } + } + catch { + Write-CollectorLog ("SMTP poll error: {0}" -f $_.Exception.Message) + } +} + +# --------------------------------------------------------------------------- +# Initialization +# --------------------------------------------------------------------------- + +$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath +$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'ServerHost is required.' } +$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 } +$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' } +$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig -and $deploymentConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\ActivityWatch\dlp-policy.json' } +$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 10 } +$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\ActivityWatch\logs' } +$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("email-outbound-{0}.log" -f $env:USERNAME) } +$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true } + +if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) { + New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null +} + +$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort +$script:Hostname = $env:COMPUTERNAME +$script:SessionId = (Get-Process -Id $PID).SessionId +$script:KnownBuckets = @{} +$script:Cooldown = @{} +$script:SeenEntryIds = @{} +$script:SeenSmtpConnections = @{} +$script:PulseSeconds = [Math]::Max($resolvedPollSeconds * 3, 30) +$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled +$script:LogPath = $resolvedLogPath +$script:OutlookApp = $null +$script:OutlookNamespace = $null +$script:SentFolder = $null +$script:OutlookLastPoll = (Get-Date).AddMinutes(-5) + +Load-EmailPolicy -Path $resolvedPolicyPath +Write-CollectorLog ("email collector started mode={0} against {1}" -f $Mode, $script:ApiBase) + +$useOutlook = ($Mode -eq 'outlook' -or $Mode -eq 'both') +$useSmtp = ($Mode -eq 'smtp' -or $Mode -eq 'both') +$outlookReady = $false + +if ($useOutlook) { + $outlookReady = Initialize-OutlookCom + if (-not $outlookReady -and $Mode -eq 'outlook') { + Write-CollectorLog "Outlook COM not available, collector will retry" + } +} + +# --------------------------------------------------------------------------- +# Main loop +# --------------------------------------------------------------------------- + +while ($true) { + try { + if (-not $script:Policy.defaults.enabled) { + Start-Sleep -Seconds $resolvedPollSeconds + continue + } + + if ($useOutlook) { + if (-not $outlookReady) { + $outlookReady = Initialize-OutlookCom + } + if ($outlookReady) { + try { + Poll-OutlookSentItems + } + catch { + Write-CollectorLog ("outlook poll error: {0}" -f $_.Exception.Message) + $outlookReady = $false + $script:OutlookApp = $null + $script:OutlookNamespace = $null + $script:SentFolder = $null + } + } + } + + if ($useSmtp) { + try { + Poll-SmtpConnections + } + catch { + Write-CollectorLog ("smtp poll error: {0}" -f $_.Exception.Message) + } + } + } + catch { + Write-CollectorLog ("collector error: {0}" -f $_.Exception.Message) + } + + Start-Sleep -Seconds $resolvedPollSeconds +}