Merge PR #14: feat(dlp) enforcement + email outbound collector
This commit is contained in:
@@ -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_<host>` с дополнительным полем:
|
||||
|
||||
```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
|
||||
```
|
||||
@@ -32,6 +32,24 @@
|
||||
- File-operation telemetry (create/delete/rename/archive hints) — прототип внедрён (`windows/file-operations-collector.ps1`).
|
||||
- Central incident aggregation/export — прототип внедрён (`scripts/aggregate_dlp_events.py`, `docs/dlp-aggregator.md`).
|
||||
|
||||
### 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 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.
|
||||
|
||||
@@ -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_<host>` — все email-события (signal heartbeats)
|
||||
- `aw-dlp-incidents_<host>` — инциденты при срабатывании 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": "<sha256 hash>",
|
||||
"sender": "user@company.ru",
|
||||
"recipientCount": 3,
|
||||
"recipients": "<sha256 hash>",
|
||||
"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": "<sha256>",
|
||||
"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.
|
||||
@@ -1,6 +1,6 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
|
||||
[string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json',
|
||||
[string]$ServerHost,
|
||||
[int]$ServerPort,
|
||||
[ValidateSet('http', 'https')]
|
||||
@@ -81,7 +81,7 @@ function Send-EndpointSignalHeartbeat {
|
||||
username = $env:USERNAME
|
||||
sessionId = $script:SessionId
|
||||
hostname = $script:Hostname
|
||||
source = 'endpoint-signals-awatch-rus'
|
||||
source = 'endpoint-signals-phase2'
|
||||
} + $Data
|
||||
} | ConvertTo-Json -Depth 6 -Compress
|
||||
|
||||
@@ -122,7 +122,7 @@ function Send-DlpIncidentHeartbeat {
|
||||
username = $env:USERNAME
|
||||
sessionId = $script:SessionId
|
||||
hostname = $script:Hostname
|
||||
source = 'endpoint-signals-awatch-rus'
|
||||
source = 'endpoint-signals-phase2'
|
||||
} + $Data + $captureData
|
||||
} | ConvertTo-Json -Depth 7 -Compress
|
||||
|
||||
@@ -210,11 +210,104 @@ function Capture-IncidentScreenshot {
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-EndpointLog ("не удалось сделать снимок инцидента: {0}" -f $_.Exception.Message)
|
||||
Write-EndpointLog ("screenshot capture failed: {0}" -f $_.Exception.Message)
|
||||
return @{}
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 }
|
||||
@@ -246,7 +339,7 @@ function Load-DlpPolicy {
|
||||
}
|
||||
|
||||
if (-not $Path -or -not (Test-Path -LiteralPath $Path)) {
|
||||
Write-EndpointLog ("DLP-политика не найдена, используются значения по умолчанию: {0}" -f $Path)
|
||||
Write-EndpointLog ("policy not found, using defaults: {0}" -f $Path)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -266,7 +359,7 @@ function Load-DlpPolicy {
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-EndpointLog ("не удалось разобрать DLP-политику: {0}" -f $_.Exception.Message)
|
||||
Write-EndpointLog ("policy parse failed: {0}" -f $_.Exception.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,13 +412,20 @@ function Evaluate-ClipboardRules {
|
||||
|
||||
$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 { "Сработало правило буфера обмена: $ruleId" }
|
||||
$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 ("инцидент буфера обмена правило={0} действие={1} важность={2}" -f $ruleId, $action, $severity)
|
||||
Write-EndpointLog ("incident clipboard rule={0} action={1} severity={2} enforced={3}" -f $ruleId, $action, $severity, $enforced)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,13 +447,20 @@ function Evaluate-UsbRules {
|
||||
|
||||
$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 { "Сработало правило USB-носителя: $ruleId" }
|
||||
$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 ("инцидент USB правило={0} действие={1} важность={2} диск={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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,14 +492,21 @@ function Evaluate-PrintRules {
|
||||
|
||||
$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 { "Сработало правило печати: $ruleId" }
|
||||
$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 ("инцидент печати правило={0} действие={1} важность={2} принтер={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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,52 +516,6 @@ function Test-LooksLikeMojibakeQuestionMarks {
|
||||
return $Value -match '\?{2,}'
|
||||
}
|
||||
|
||||
function Test-DocumentNameNeedsFallback {
|
||||
param([AllowNull()][string]$Value)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Value)) { return $true }
|
||||
$trimmed = $Value.Trim()
|
||||
if (Test-LooksLikeMojibakeQuestionMarks -Value $trimmed) { return $true }
|
||||
if ($trimmed -match '^[0-9]+$') { return $true }
|
||||
if ($trimmed -match '^(?i)(print document|document|local downlevel document)$') { return $true }
|
||||
return $false
|
||||
}
|
||||
|
||||
function Get-EventXmlValue {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][xml]$EventXml,
|
||||
[Parameter(Mandatory = $true)][string]$Name
|
||||
)
|
||||
|
||||
$node = $EventXml.Event.UserData.DocumentPrinted.$Name
|
||||
if ($null -ne $node) {
|
||||
return [string]$node
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function Get-PrintJobPrinterName {
|
||||
param(
|
||||
[AllowNull()][string]$JobName,
|
||||
[AllowNull()][string]$FallbackPrinterName
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($JobName)) {
|
||||
if (-not [string]::IsNullOrWhiteSpace($FallbackPrinterName)) {
|
||||
return $FallbackPrinterName.Trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
$parts = $JobName -split ',', 2
|
||||
if ($parts.Count -gt 0 -and -not [string]::IsNullOrWhiteSpace($parts[0])) {
|
||||
return $parts[0].Trim()
|
||||
}
|
||||
|
||||
return $JobName.Trim()
|
||||
}
|
||||
|
||||
function Normalize-OwnerForMatch {
|
||||
param([AllowNull()][string]$Value)
|
||||
if ([string]::IsNullOrWhiteSpace($Value)) { return '' }
|
||||
@@ -515,50 +583,13 @@ function Get-PrintServiceEventSummary {
|
||||
$propertyValues += [string]$prop.Value
|
||||
}
|
||||
|
||||
$xml = $null
|
||||
try {
|
||||
$xml = [xml]$Event.ToXml()
|
||||
}
|
||||
catch {
|
||||
}
|
||||
|
||||
$jobId = ''
|
||||
$documentName = ''
|
||||
$owner = ''
|
||||
$portName = ''
|
||||
$printerName = ''
|
||||
$sizeBytes = ''
|
||||
$pageCount = ''
|
||||
|
||||
if ($xml) {
|
||||
$jobId = Get-EventXmlValue -EventXml $xml -Name 'Param1'
|
||||
$documentName = Get-EventXmlValue -EventXml $xml -Name 'Param2'
|
||||
$owner = Get-EventXmlValue -EventXml $xml -Name 'Param3'
|
||||
$portName = Get-EventXmlValue -EventXml $xml -Name 'Param4'
|
||||
$printerName = Get-EventXmlValue -EventXml $xml -Name 'Param5'
|
||||
$sizeBytes = Get-EventXmlValue -EventXml $xml -Name 'Param7'
|
||||
$pageCount = Get-EventXmlValue -EventXml $xml -Name 'Param8'
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($jobId) -and $props.Count -ge 1) { $jobId = [string]$props[0].Value }
|
||||
if ([string]::IsNullOrWhiteSpace($documentName) -and $props.Count -ge 2) { $documentName = [string]$props[1].Value }
|
||||
if ([string]::IsNullOrWhiteSpace($owner) -and $props.Count -ge 3) { $owner = [string]$props[2].Value }
|
||||
if ([string]::IsNullOrWhiteSpace($portName) -and $props.Count -ge 4) { $portName = [string]$props[3].Value }
|
||||
if ([string]::IsNullOrWhiteSpace($printerName) -and $props.Count -ge 5) { $printerName = [string]$props[4].Value }
|
||||
if ([string]::IsNullOrWhiteSpace($sizeBytes) -and $props.Count -ge 7) { $sizeBytes = [string]$props[6].Value }
|
||||
if ([string]::IsNullOrWhiteSpace($pageCount) -and $props.Count -ge 8) { $pageCount = [string]$props[7].Value }
|
||||
|
||||
[pscustomobject]@{
|
||||
RecordId = [string]$Event.RecordId
|
||||
TimeCreated = if ($Event.TimeCreated) { $Event.TimeCreated.ToString('o') } else { '' }
|
||||
PropertyCount = $props.Count
|
||||
JobId = $jobId
|
||||
DocumentName = $documentName
|
||||
Owner = $owner
|
||||
PortName = $portName
|
||||
PrinterName = $printerName
|
||||
SizeBytes = $sizeBytes
|
||||
PageCount = $pageCount
|
||||
DocumentName = if ($props.Count -ge 1) { [string]$props[0].Value } else { '' }
|
||||
Owner = if ($props.Count -ge 2) { [string]$props[1].Value } else { '' }
|
||||
PrinterName = if ($props.Count -ge 4) { [string]$props[3].Value } else { '' }
|
||||
PropertyValues = $propertyValues
|
||||
}
|
||||
}
|
||||
@@ -571,7 +602,7 @@ function Get-PrintServiceDocumentFallback {
|
||||
)
|
||||
|
||||
$preferred = [string]$EventSummary.DocumentName
|
||||
if (-not (Test-DocumentNameNeedsFallback -Value $preferred)) {
|
||||
if (-not (Test-LooksLikeMojibakeQuestionMarks -Value $preferred) -and $preferred -notmatch '^[0-9]+$') {
|
||||
return $preferred
|
||||
}
|
||||
|
||||
@@ -582,10 +613,9 @@ function Get-PrintServiceDocumentFallback {
|
||||
$candidate = [string]$value
|
||||
if ([string]::IsNullOrWhiteSpace($candidate)) { continue }
|
||||
if ($candidate -eq $preferred) { continue }
|
||||
if ($EventSummary.JobId -and $candidate -eq [string]$EventSummary.JobId) { continue }
|
||||
if ($Owner -and $candidate -like "*$Owner*") { continue }
|
||||
if ($PrinterName -and $candidate -like "*$PrinterName*") { continue }
|
||||
if (Test-DocumentNameNeedsFallback -Value $candidate) { continue }
|
||||
if (Test-LooksLikeMojibakeQuestionMarks -Value $candidate) { continue }
|
||||
|
||||
if ($candidate -match '[\\/:]' -and $candidate -match '\.[A-Za-z0-9]{1,8}$') {
|
||||
$pathCandidates.Add($candidate)
|
||||
@@ -630,7 +660,7 @@ function Write-PrintServiceEventTrace {
|
||||
}
|
||||
|
||||
Write-EndpointLog (
|
||||
'printservice-307 этап={0} recordId={1} время={2} владелец={3} принтер={4} документ={5} итоговыйДокумент={6} свойства=[{7}] причина={8}' -f
|
||||
'printservice-307 phase={0} recordId={1} time={2} owner={3} printer={4} document={5} resolved={6} properties=[{7}] reason={8}' -f
|
||||
$Phase,
|
||||
$EventSummary.RecordId,
|
||||
$EventSummary.TimeCreated,
|
||||
@@ -645,7 +675,6 @@ function Write-PrintServiceEventTrace {
|
||||
|
||||
function Get-BetterDocumentNameFromPrintServiceEvents {
|
||||
param(
|
||||
[string]$JobId,
|
||||
[string]$Owner,
|
||||
[string]$PrinterName
|
||||
)
|
||||
@@ -663,41 +692,32 @@ function Get-BetterDocumentNameFromPrintServiceEvents {
|
||||
$summary = Get-PrintServiceEventSummary -Event $event
|
||||
$resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $Owner -PrinterName $PrinterName
|
||||
|
||||
$jobMatches = if ($JobId) { [string]$summary.JobId -eq [string]$JobId } else { $true }
|
||||
$ownerMatches = if ($Owner) { Test-OwnerLooseMatch -Expected $Owner -Actual $summary.Owner } else { $true }
|
||||
$printerMatches = if ($PrinterName) { Test-PrinterLooseMatch -Expected $PrinterName -Actual $summary.PrinterName } else { $true }
|
||||
|
||||
if ($pass -eq 'strict') {
|
||||
if ($JobId -and -not $jobMatches) {
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'несовпадение-jobid-strict' -ResolvedDocument $resolvedDocument
|
||||
continue
|
||||
}
|
||||
if ($Owner -and -not $ownerMatches) {
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'несовпадение-владельца-strict' -ResolvedDocument $resolvedDocument
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-mismatch-strict' -ResolvedDocument $resolvedDocument
|
||||
continue
|
||||
}
|
||||
if ($PrinterName -and -not $printerMatches) {
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'несовпадение-принтера-strict' -ResolvedDocument $resolvedDocument
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'printer-mismatch-strict' -ResolvedDocument $resolvedDocument
|
||||
continue
|
||||
}
|
||||
}
|
||||
else {
|
||||
if ($JobId -and (-not $jobMatches) -and $Owner -and $PrinterName -and -not $ownerMatches -and -not $printerMatches) {
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'несовпадение-владельца-и-принтера-relaxed' -ResolvedDocument $resolvedDocument
|
||||
continue
|
||||
}
|
||||
if ((-not $JobId) -and $Owner -and $PrinterName -and -not $ownerMatches -and -not $printerMatches) {
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'несовпадение-владельца-и-принтера-relaxed' -ResolvedDocument $resolvedDocument
|
||||
if ($Owner -and $PrinterName -and -not $ownerMatches -and -not $printerMatches) {
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-and-printer-mismatch-relaxed' -ResolvedDocument $resolvedDocument
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($resolvedDocument)) {
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason ('нет-кандидата-документа-' + $pass) -ResolvedDocument ''
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason ('no-document-candidate-' + $pass) -ResolvedDocument ''
|
||||
continue
|
||||
}
|
||||
|
||||
$matchReasonBase = if (Test-DocumentNameNeedsFallback -Value $summary.DocumentName) { 'использован-резервный-вариант' } else { 'напрямую' }
|
||||
$matchReasonBase = if (Test-LooksLikeMojibakeQuestionMarks -Value $summary.DocumentName) { 'fallback-used' } else { 'direct' }
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'selected' -MatchReason ($matchReasonBase + '-' + $pass) -ResolvedDocument $resolvedDocument
|
||||
return $resolvedDocument
|
||||
}
|
||||
@@ -710,15 +730,15 @@ function Get-BetterDocumentNameFromPrintServiceEvents {
|
||||
}
|
||||
|
||||
$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath
|
||||
$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'Укажите ServerHost или подготовьте deployment-config.json.' }
|
||||
$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\AWatch-rus\dlp-policy.json' }
|
||||
$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 { 5 }
|
||||
$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' }
|
||||
$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\ActivityWatch\logs' }
|
||||
$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("endpoint-signals-{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 }
|
||||
$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' }
|
||||
$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'ActivityWatch-Phase2\\incident-artifacts' }
|
||||
$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true }
|
||||
|
||||
if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) {
|
||||
@@ -742,7 +762,7 @@ $script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled
|
||||
$script:ScreenshotTypesLoaded = $false
|
||||
|
||||
Load-DlpPolicy -Path $resolvedPolicyPath
|
||||
Write-EndpointLog ("endpoint-коллектор запущен для {0}" -f $script:ApiBase)
|
||||
Write-EndpointLog ("endpoint collector started against {0}" -f $script:ApiBase)
|
||||
|
||||
while ($true) {
|
||||
try {
|
||||
@@ -803,13 +823,13 @@ while ($true) {
|
||||
if ($script:SeenPrintJob.ContainsKey($jobId)) { continue }
|
||||
$script:SeenPrintJob[$jobId] = (Get-Date).ToUniversalTime()
|
||||
|
||||
$printerName = Get-PrintJobPrinterName -JobName ([string]$job.Name) -FallbackPrinterName ([string]$job.DriverName)
|
||||
$printerName = [string]$job.Name
|
||||
$documentName = [string]$job.Document
|
||||
$owner = [string]$job.Owner
|
||||
$documentNameOriginal = $documentName
|
||||
|
||||
if (Test-DocumentNameNeedsFallback -Value $documentName) {
|
||||
$eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -JobId $jobId -Owner $owner -PrinterName $printerName
|
||||
if (Test-LooksLikeMojibakeQuestionMarks -Value $documentName) {
|
||||
$eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -Owner $owner -PrinterName $printerName
|
||||
if ($eventDocumentName) {
|
||||
$documentName = $eventDocumentName
|
||||
}
|
||||
@@ -820,7 +840,6 @@ while ($true) {
|
||||
documentName = $documentName
|
||||
documentNameOriginal = $documentNameOriginal
|
||||
owner = $owner
|
||||
printJobId = $jobId
|
||||
}
|
||||
Evaluate-PrintRules -PrinterName $printerName -DocumentName $documentName -Owner $owner
|
||||
}
|
||||
@@ -884,7 +903,7 @@ while ($true) {
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-EndpointLog ("ошибка коллектора: {0}" -f $_.Exception.Message)
|
||||
Write-EndpointLog ("collector error: {0}" -f $_.Exception.Message)
|
||||
}
|
||||
|
||||
Start-Sleep -Seconds $resolvedPollSeconds
|
||||
|
||||
@@ -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_<host>`.
|
||||
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
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
|
||||
[string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json',
|
||||
[string]$ServerHost,
|
||||
[int]$ServerPort,
|
||||
[ValidateSet('http', 'https')]
|
||||
@@ -13,9 +13,6 @@ param(
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
|
||||
Add-Type -AssemblyName System.Net.Http
|
||||
|
||||
function Get-DeploymentConfig {
|
||||
param([string]$Path)
|
||||
if ($Path -and (Test-Path -LiteralPath $Path)) {
|
||||
@@ -42,20 +39,8 @@ function Invoke-AwJsonPost {
|
||||
[Parameter(Mandatory = $true)][string]$Json
|
||||
)
|
||||
|
||||
$httpClient = New-Object System.Net.Http.HttpClient
|
||||
try {
|
||||
$content = New-Object System.Net.Http.StringContent($Json, [System.Text.Encoding]::UTF8, 'application/json')
|
||||
$response = $httpClient.PostAsync($Uri, $content).Result
|
||||
if (-not $response.IsSuccessStatusCode) {
|
||||
Write-EndpointLog ("POST {0} returned {1}" -f $Uri, [int]$response.StatusCode)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-EndpointLog ("POST error {0}: {1}" -f $Uri, $_.Exception.Message)
|
||||
}
|
||||
finally {
|
||||
$httpClient.Dispose()
|
||||
}
|
||||
$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 {
|
||||
@@ -96,7 +81,7 @@ function Send-EndpointSignalHeartbeat {
|
||||
username = $env:USERNAME
|
||||
sessionId = $script:SessionId
|
||||
hostname = $script:Hostname
|
||||
source = 'endpoint-signals-awatch-rus'
|
||||
source = 'endpoint-signals-phase2'
|
||||
} + $Data
|
||||
} | ConvertTo-Json -Depth 6 -Compress
|
||||
|
||||
@@ -137,7 +122,7 @@ function Send-DlpIncidentHeartbeat {
|
||||
username = $env:USERNAME
|
||||
sessionId = $script:SessionId
|
||||
hostname = $script:Hostname
|
||||
source = 'endpoint-signals-awatch-rus'
|
||||
source = 'endpoint-signals-phase2'
|
||||
} + $Data + $captureData
|
||||
} | ConvertTo-Json -Depth 7 -Compress
|
||||
|
||||
@@ -225,11 +210,104 @@ function Capture-IncidentScreenshot {
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-EndpointLog ("не удалось сделать снимок инцидента: {0}" -f $_.Exception.Message)
|
||||
Write-EndpointLog ("screenshot capture failed: {0}" -f $_.Exception.Message)
|
||||
return @{}
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 }
|
||||
@@ -261,7 +339,7 @@ function Load-DlpPolicy {
|
||||
}
|
||||
|
||||
if (-not $Path -or -not (Test-Path -LiteralPath $Path)) {
|
||||
Write-EndpointLog ("DLP-политика не найдена, используются значения по умолчанию: {0}" -f $Path)
|
||||
Write-EndpointLog ("policy not found, using defaults: {0}" -f $Path)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -281,7 +359,7 @@ function Load-DlpPolicy {
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-EndpointLog ("не удалось разобрать DLP-политику: {0}" -f $_.Exception.Message)
|
||||
Write-EndpointLog ("policy parse failed: {0}" -f $_.Exception.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,13 +412,20 @@ function Evaluate-ClipboardRules {
|
||||
|
||||
$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 { "Сработало правило буфера обмена: $ruleId" }
|
||||
$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 ("инцидент буфера обмена правило={0} действие={1} важность={2}" -f $ruleId, $action, $severity)
|
||||
Write-EndpointLog ("incident clipboard rule={0} action={1} severity={2} enforced={3}" -f $ruleId, $action, $severity, $enforced)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,13 +447,20 @@ function Evaluate-UsbRules {
|
||||
|
||||
$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 { "Сработало правило USB-носителя: $ruleId" }
|
||||
$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 ("инцидент USB правило={0} действие={1} важность={2} диск={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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,14 +492,21 @@ function Evaluate-PrintRules {
|
||||
|
||||
$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 { "Сработало правило печати: $ruleId" }
|
||||
$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 ("инцидент печати правило={0} действие={1} важность={2} принтер={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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,52 +516,6 @@ function Test-LooksLikeMojibakeQuestionMarks {
|
||||
return $Value -match '\?{2,}'
|
||||
}
|
||||
|
||||
function Test-DocumentNameNeedsFallback {
|
||||
param([AllowNull()][string]$Value)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Value)) { return $true }
|
||||
$trimmed = $Value.Trim()
|
||||
if (Test-LooksLikeMojibakeQuestionMarks -Value $trimmed) { return $true }
|
||||
if ($trimmed -match '^[0-9]+$') { return $true }
|
||||
if ($trimmed -match '^(?i)(print document|document|local downlevel document|печать документа)$') { return $true }
|
||||
return $false
|
||||
}
|
||||
|
||||
function Get-EventXmlValue {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][xml]$EventXml,
|
||||
[Parameter(Mandatory = $true)][string]$Name
|
||||
)
|
||||
|
||||
$node = $EventXml.Event.UserData.DocumentPrinted.$Name
|
||||
if ($null -ne $node) {
|
||||
return [string]$node
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function Get-PrintJobPrinterName {
|
||||
param(
|
||||
[AllowNull()][string]$JobName,
|
||||
[AllowNull()][string]$FallbackPrinterName
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($JobName)) {
|
||||
if (-not [string]::IsNullOrWhiteSpace($FallbackPrinterName)) {
|
||||
return $FallbackPrinterName.Trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
$parts = $JobName -split ',', 2
|
||||
if ($parts.Count -gt 0 -and -not [string]::IsNullOrWhiteSpace($parts[0])) {
|
||||
return $parts[0].Trim()
|
||||
}
|
||||
|
||||
return $JobName.Trim()
|
||||
}
|
||||
|
||||
function Normalize-OwnerForMatch {
|
||||
param([AllowNull()][string]$Value)
|
||||
if ([string]::IsNullOrWhiteSpace($Value)) { return '' }
|
||||
@@ -530,50 +583,13 @@ function Get-PrintServiceEventSummary {
|
||||
$propertyValues += [string]$prop.Value
|
||||
}
|
||||
|
||||
$xml = $null
|
||||
try {
|
||||
$xml = [xml]$Event.ToXml()
|
||||
}
|
||||
catch {
|
||||
}
|
||||
|
||||
$jobId = ''
|
||||
$documentName = ''
|
||||
$owner = ''
|
||||
$portName = ''
|
||||
$printerName = ''
|
||||
$sizeBytes = ''
|
||||
$pageCount = ''
|
||||
|
||||
if ($xml) {
|
||||
$jobId = Get-EventXmlValue -EventXml $xml -Name 'Param1'
|
||||
$documentName = Get-EventXmlValue -EventXml $xml -Name 'Param2'
|
||||
$owner = Get-EventXmlValue -EventXml $xml -Name 'Param3'
|
||||
$portName = Get-EventXmlValue -EventXml $xml -Name 'Param4'
|
||||
$printerName = Get-EventXmlValue -EventXml $xml -Name 'Param5'
|
||||
$sizeBytes = Get-EventXmlValue -EventXml $xml -Name 'Param7'
|
||||
$pageCount = Get-EventXmlValue -EventXml $xml -Name 'Param8'
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($jobId) -and $props.Count -ge 1) { $jobId = [string]$props[0].Value }
|
||||
if ([string]::IsNullOrWhiteSpace($documentName) -and $props.Count -ge 2) { $documentName = [string]$props[1].Value }
|
||||
if ([string]::IsNullOrWhiteSpace($owner) -and $props.Count -ge 3) { $owner = [string]$props[2].Value }
|
||||
if ([string]::IsNullOrWhiteSpace($portName) -and $props.Count -ge 4) { $portName = [string]$props[3].Value }
|
||||
if ([string]::IsNullOrWhiteSpace($printerName) -and $props.Count -ge 5) { $printerName = [string]$props[4].Value }
|
||||
if ([string]::IsNullOrWhiteSpace($sizeBytes) -and $props.Count -ge 7) { $sizeBytes = [string]$props[6].Value }
|
||||
if ([string]::IsNullOrWhiteSpace($pageCount) -and $props.Count -ge 8) { $pageCount = [string]$props[7].Value }
|
||||
|
||||
[pscustomobject]@{
|
||||
RecordId = [string]$Event.RecordId
|
||||
TimeCreated = if ($Event.TimeCreated) { $Event.TimeCreated.ToString('o') } else { '' }
|
||||
PropertyCount = $props.Count
|
||||
JobId = $jobId
|
||||
DocumentName = $documentName
|
||||
Owner = $owner
|
||||
PortName = $portName
|
||||
PrinterName = $printerName
|
||||
SizeBytes = $sizeBytes
|
||||
PageCount = $pageCount
|
||||
DocumentName = if ($props.Count -ge 1) { [string]$props[0].Value } else { '' }
|
||||
Owner = if ($props.Count -ge 2) { [string]$props[1].Value } else { '' }
|
||||
PrinterName = if ($props.Count -ge 4) { [string]$props[3].Value } else { '' }
|
||||
PropertyValues = $propertyValues
|
||||
}
|
||||
}
|
||||
@@ -586,7 +602,7 @@ function Get-PrintServiceDocumentFallback {
|
||||
)
|
||||
|
||||
$preferred = [string]$EventSummary.DocumentName
|
||||
if (-not (Test-DocumentNameNeedsFallback -Value $preferred)) {
|
||||
if (-not (Test-LooksLikeMojibakeQuestionMarks -Value $preferred) -and $preferred -notmatch '^[0-9]+$') {
|
||||
return $preferred
|
||||
}
|
||||
|
||||
@@ -597,10 +613,9 @@ function Get-PrintServiceDocumentFallback {
|
||||
$candidate = [string]$value
|
||||
if ([string]::IsNullOrWhiteSpace($candidate)) { continue }
|
||||
if ($candidate -eq $preferred) { continue }
|
||||
if ($EventSummary.JobId -and $candidate -eq [string]$EventSummary.JobId) { continue }
|
||||
if ($Owner -and $candidate -like "*$Owner*") { continue }
|
||||
if ($PrinterName -and $candidate -like "*$PrinterName*") { continue }
|
||||
if (Test-DocumentNameNeedsFallback -Value $candidate) { continue }
|
||||
if (Test-LooksLikeMojibakeQuestionMarks -Value $candidate) { continue }
|
||||
|
||||
if ($candidate -match '[\\/:]' -and $candidate -match '\.[A-Za-z0-9]{1,8}$') {
|
||||
$pathCandidates.Add($candidate)
|
||||
@@ -645,7 +660,7 @@ function Write-PrintServiceEventTrace {
|
||||
}
|
||||
|
||||
Write-EndpointLog (
|
||||
'printservice-307 этап={0} recordId={1} время={2} владелец={3} принтер={4} документ={5} итоговыйДокумент={6} свойства=[{7}] причина={8}' -f
|
||||
'printservice-307 phase={0} recordId={1} time={2} owner={3} printer={4} document={5} resolved={6} properties=[{7}] reason={8}' -f
|
||||
$Phase,
|
||||
$EventSummary.RecordId,
|
||||
$EventSummary.TimeCreated,
|
||||
@@ -660,7 +675,6 @@ function Write-PrintServiceEventTrace {
|
||||
|
||||
function Get-BetterDocumentNameFromPrintServiceEvents {
|
||||
param(
|
||||
[string]$JobId,
|
||||
[string]$Owner,
|
||||
[string]$PrinterName
|
||||
)
|
||||
@@ -678,41 +692,32 @@ function Get-BetterDocumentNameFromPrintServiceEvents {
|
||||
$summary = Get-PrintServiceEventSummary -Event $event
|
||||
$resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $Owner -PrinterName $PrinterName
|
||||
|
||||
$jobMatches = if ($JobId) { [string]$summary.JobId -eq [string]$JobId } else { $true }
|
||||
$ownerMatches = if ($Owner) { Test-OwnerLooseMatch -Expected $Owner -Actual $summary.Owner } else { $true }
|
||||
$printerMatches = if ($PrinterName) { Test-PrinterLooseMatch -Expected $PrinterName -Actual $summary.PrinterName } else { $true }
|
||||
|
||||
if ($pass -eq 'strict') {
|
||||
if ($JobId -and -not $jobMatches) {
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'несовпадение-jobid-strict' -ResolvedDocument $resolvedDocument
|
||||
continue
|
||||
}
|
||||
if ($Owner -and -not $ownerMatches) {
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'несовпадение-владельца-strict' -ResolvedDocument $resolvedDocument
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-mismatch-strict' -ResolvedDocument $resolvedDocument
|
||||
continue
|
||||
}
|
||||
if ($PrinterName -and -not $printerMatches) {
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'несовпадение-принтера-strict' -ResolvedDocument $resolvedDocument
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'printer-mismatch-strict' -ResolvedDocument $resolvedDocument
|
||||
continue
|
||||
}
|
||||
}
|
||||
else {
|
||||
if ($JobId -and (-not $jobMatches) -and $Owner -and $PrinterName -and -not $ownerMatches -and -not $printerMatches) {
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'несовпадение-владельца-и-принтера-relaxed' -ResolvedDocument $resolvedDocument
|
||||
continue
|
||||
}
|
||||
if ((-not $JobId) -and $Owner -and $PrinterName -and -not $ownerMatches -and -not $printerMatches) {
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'несовпадение-владельца-и-принтера-relaxed' -ResolvedDocument $resolvedDocument
|
||||
if ($Owner -and $PrinterName -and -not $ownerMatches -and -not $printerMatches) {
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-and-printer-mismatch-relaxed' -ResolvedDocument $resolvedDocument
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($resolvedDocument)) {
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason ('нет-кандидата-документа-' + $pass) -ResolvedDocument ''
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason ('no-document-candidate-' + $pass) -ResolvedDocument ''
|
||||
continue
|
||||
}
|
||||
|
||||
$matchReasonBase = if (Test-DocumentNameNeedsFallback -Value $summary.DocumentName) { 'использован-резервный-вариант' } else { 'напрямую' }
|
||||
$matchReasonBase = if (Test-LooksLikeMojibakeQuestionMarks -Value $summary.DocumentName) { 'fallback-used' } else { 'direct' }
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'selected' -MatchReason ($matchReasonBase + '-' + $pass) -ResolvedDocument $resolvedDocument
|
||||
return $resolvedDocument
|
||||
}
|
||||
@@ -725,15 +730,15 @@ function Get-BetterDocumentNameFromPrintServiceEvents {
|
||||
}
|
||||
|
||||
$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath
|
||||
$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'Укажите ServerHost или подготовьте deployment-config.json.' }
|
||||
$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\AWatch-rus\dlp-policy.json' }
|
||||
$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 { 5 }
|
||||
$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' }
|
||||
$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\ActivityWatch\logs' }
|
||||
$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("endpoint-signals-{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 }
|
||||
$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' }
|
||||
$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'ActivityWatch-Phase2\\incident-artifacts' }
|
||||
$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true }
|
||||
|
||||
if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) {
|
||||
@@ -757,7 +762,7 @@ $script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled
|
||||
$script:ScreenshotTypesLoaded = $false
|
||||
|
||||
Load-DlpPolicy -Path $resolvedPolicyPath
|
||||
Write-EndpointLog ("endpoint-коллектор запущен для {0}" -f $script:ApiBase)
|
||||
Write-EndpointLog ("endpoint collector started against {0}" -f $script:ApiBase)
|
||||
|
||||
while ($true) {
|
||||
try {
|
||||
@@ -818,17 +823,15 @@ while ($true) {
|
||||
if ($script:SeenPrintJob.ContainsKey($jobId)) { continue }
|
||||
$script:SeenPrintJob[$jobId] = (Get-Date).ToUniversalTime()
|
||||
|
||||
$printerName = Get-PrintJobPrinterName -JobName ([string]$job.Name) -FallbackPrinterName ([string]$job.DriverName)
|
||||
$printerName = [string]$job.Name
|
||||
$documentName = [string]$job.Document
|
||||
$owner = [string]$job.Owner
|
||||
$documentNameOriginal = $documentName
|
||||
$documentNameSource = 'win32-printjob'
|
||||
|
||||
if (Test-DocumentNameNeedsFallback -Value $documentName) {
|
||||
$eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -JobId $jobId -Owner $owner -PrinterName $printerName
|
||||
if (Test-LooksLikeMojibakeQuestionMarks -Value $documentName) {
|
||||
$eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -Owner $owner -PrinterName $printerName
|
||||
if ($eventDocumentName) {
|
||||
$documentName = $eventDocumentName
|
||||
$documentNameSource = 'printservice-307-fallback'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -836,9 +839,7 @@ while ($true) {
|
||||
printerName = $printerName
|
||||
documentName = $documentName
|
||||
documentNameOriginal = $documentNameOriginal
|
||||
documentNameSource = $documentNameSource
|
||||
owner = $owner
|
||||
printJobId = $jobId
|
||||
}
|
||||
Evaluate-PrintRules -PrinterName $printerName -DocumentName $documentName -Owner $owner
|
||||
}
|
||||
@@ -883,7 +884,6 @@ while ($true) {
|
||||
printerName = $printerName
|
||||
documentName = if ($resolvedDocument) { $resolvedDocument } else { $documentName }
|
||||
documentNameOriginal = $documentName
|
||||
documentNameSource = if ($resolvedDocument) { 'printservice-307-fallback' } else { 'printservice-307' }
|
||||
owner = $owner
|
||||
eventRecordId = $recordId
|
||||
eventSource = 'printservice-307'
|
||||
@@ -903,7 +903,7 @@ while ($true) {
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-EndpointLog ("ошибка коллектора: {0}" -f $_.Exception.Message)
|
||||
Write-EndpointLog ("collector error: {0}" -f $_.Exception.Message)
|
||||
}
|
||||
|
||||
Start-Sleep -Seconds $resolvedPollSeconds
|
||||
|
||||
@@ -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_<host>`.
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user