feat(dlp): add email outbound collector — Outlook COM + SMTP monitor

Two collection modes:
- outlook: polls Sent Items via COM, extracts metadata (subject hash,
  recipients hash, attachment names, body length)
- smtp: monitors SMTP connections (25/587/465/2525) via Get-NetTCPConnection

DLP policy rules: endpoint.email[] with regex matching on subject,
recipients, sender, attachments, externalOnly flag.

Enforcement: action=block moves mail to Drafts (Outlook mode).
Privacy: subject/recipients stored as SHA256, body never read.
Co-Authored-By: Fashion Lisa <igor04091968@gmail.com>
This commit is contained in:
Devin AI
2026-05-03 20:30:18 +00:00
co-authored by Fashion Lisa
parent 2bab84f9f9
commit f916764d53
4 changed files with 1337 additions and 0 deletions
+9
View File
@@ -41,6 +41,15 @@
- Телеметрия enforcement (`enforced: true/false` в incident heartbeat) — внедрено. - Телеметрия enforcement (`enforced: true/false` в incident heartbeat) — внедрено.
- Документация: `docs/dlp-enforcement.md`. - Документация: `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 ### Phase 3
- Policy engine service (server-side), versioned policies, approval workflow. - Policy engine service (server-side), versioned policies, approval workflow.
+164
View File
@@ -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.
@@ -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
}
+582
View File
@@ -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
}