Files
AWatch-rus/windows/email-outbound-collector.ps1
T
2026-05-08 00:41:00 +03:00

583 lines
22 KiB
PowerShell

<#
.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\AWatch-rus\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\AWatch-rus\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\AWatch-rus\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
}