Response/Send-SiemEvent.ps1
|
function Send-SiemEvent { <# .SYNOPSIS Streams security findings to SIEM HTTP endpoints or a stored SIEM profile. .DESCRIPTION Converts findings to NDJSON or CEF and sends them in batches. Direct parameter calls preserve Splunk HEC, legacy Microsoft Sentinel, and generic webhook behavior. SentinelLogsIngestion uses Entra OAuth2 client credentials and the Azure Monitor Logs Ingestion API. Profile calls resolve connection metadata and DPAPI secrets without returning or logging secret material. Legacy Sentinel uses the deprecated Log Analytics HTTP Data Collector API, whose support ended September 14, 2026. It remains only as a compatibility path and emits a warning. SentinelLogsIngestion requires a configured Entra application, DCR, ingestion endpoint, table/schema, and Monitoring Metrics Publisher access on the DCR. DCR schema changes can require downstream KQL and parser migration. Logs Ingestion requests are JSON arrays limited by both BatchSize and actual UTF-8 byte count. MaxBatchBytes defaults to 900000, below the service 1 MB request limit. OAuth tokens are cached in module memory with a five-minute expiry margin. The first ingestion HTTP 401 causes one token refresh and retry; HTTP 429/5xx use exponential backoff. HTTP 400/403 configuration failures are not retried. .PARAMETER InputObject Finding object(s) to stream. Accepts pipeline input. .PARAMETER Format Event format: NDJSON (one compact JSON object per line) or CEF (CEF:0 string). .PARAMETER Destination Direct target type: SplunkHEC, Sentinel, SentinelLogsIngestion, or GenericWebhook. .PARAMETER Uri Direct endpoint URL. Logs Ingestion requires /dataCollectionRules/{dcrId}/streams/{stream}?api-version={version}. .PARAMETER Token Splunk HEC token or legacy Sentinel shared key. Not used for OAuth ingestion. .PARAMETER Headers Additional HTTP headers (hashtable), intended for GenericWebhook endpoints. .PARAMETER WorkspaceId Legacy Log Analytics workspace ID. Optional when it can be parsed from the Sentinel URI. .PARAMETER LogType Legacy Log Analytics custom log type. Defaults to WindowsSecurityAudit. .PARAMETER ProfileName Stored profile name. Cannot be combined with direct destination, URI, or credentials. .PARAMETER ProfileScope Profile lookup scope. Auto checks CurrentUser then LocalMachine and rejects duplicates. .PARAMETER TenantId Entra tenant GUID or domain for direct SentinelLogsIngestion calls. .PARAMETER ClientId Entra application client ID for direct SentinelLogsIngestion calls. .PARAMETER ClientSecret Entra client secret as a SecureString for direct SentinelLogsIngestion calls. .PARAMETER MonitorScope Entra resource scope. Defaults to https://monitor.azure.com/.default. .PARAMETER MaxBatchBytes Maximum UTF-8 JSON-array bytes per Logs Ingestion request. Defaults to 900000. .PARAMETER HostName Host value embedded in each event. Defaults to the local machine name. .PARAMETER BatchSize Maximum number of events per HTTP POST. Defaults to 100. .PARAMETER MaxRetries Maximum transient-failure retries per batch or token request. Defaults to 3. .PARAMETER ThrottleMs Base delay in milliseconds between batches and exponential retry backoff. .EXAMPLE $report.Findings | Send-SiemEvent -Destination SplunkHEC -Uri 'https://splunk.contoso.com:8088/services/collector' -Token $hecToken .EXAMPLE $findings | Send-SiemEvent -ProfileName sentinel-prod -ProfileScope LocalMachine .EXAMPLE $findings | Send-SiemEvent -Destination SentinelLogsIngestion -Uri $ingestionUri -TenantId $tenantId -ClientId $clientId -ClientSecret $secureSecret .EXAMPLE $findings | Send-SiemEvent -Destination GenericWebhook -Uri 'https://siem.contoso.com/ingest' -Format CEF -Headers @{ 'X-Api-Key' = $apiKey } .OUTPUTS PSCustomObject with BatchesSent, EventsSent, Failed, and DurationSec properties. #> [CmdletBinding(DefaultParameterSetName = 'Direct')] param( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [AllowNull()] [object]$InputObject, [Parameter()] [ValidateSet('NDJSON', 'CEF')] [string]$Format = 'NDJSON', [Parameter(ParameterSetName = 'Direct', Mandatory = $true)] [ValidateSet('SplunkHEC', 'Sentinel', 'SentinelLogsIngestion', 'GenericWebhook')] [string]$Destination, [Parameter(ParameterSetName = 'Direct', Mandatory = $true)] [string]$Uri, [Parameter(ParameterSetName = 'Direct')] [string]$Token, [Parameter(ParameterSetName = 'Direct')] [hashtable]$Headers, [Parameter(ParameterSetName = 'Direct')] [string]$WorkspaceId, [Parameter(ParameterSetName = 'Direct')] [string]$LogType = 'WindowsSecurityAudit', [Parameter(ParameterSetName = 'Profile', Mandatory = $true)] [string]$ProfileName, [Parameter(ParameterSetName = 'Profile')] [ValidateSet('Auto', 'CurrentUser', 'LocalMachine')] [string]$ProfileScope = 'Auto', [Parameter(ParameterSetName = 'Direct')] [string]$TenantId, [Parameter(ParameterSetName = 'Direct')] [string]$ClientId, [Parameter(ParameterSetName = 'Direct')] [SecureString]$ClientSecret, [Parameter(ParameterSetName = 'Direct')] [string]$MonitorScope = 'https://monitor.azure.com/.default', [Parameter()] [ValidateRange(1, 1000000)] [int]$MaxBatchBytes = 900000, [Parameter()] [string]$HostName = [Environment]::MachineName, [Parameter()] [ValidateRange(1, 10000)] [int]$BatchSize = 100, [Parameter()] [ValidateRange(0, 10)] [int]$MaxRetries = 3, [Parameter()] [ValidateRange(0, 60000)] [int]$ThrottleMs = 250 ) begin { function Get-UnixEpochSeconds { param([AllowNull()][object]$Timestamp) $unixEpoch = [DateTimeOffset]'1970-01-01T00:00:00Z' $parsed = $null if ($null -ne $Timestamp) { try { $parsed = [DateTimeOffset]::Parse([string]$Timestamp, [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::AssumeUniversal) } catch { $parsed = $null } } if ($null -eq $parsed) { $parsed = [DateTimeOffset]::UtcNow } return [long][math]::Floor(($parsed.ToUniversalTime() - $unixEpoch).TotalSeconds) } function New-SentinelSharedKeySignature { param( [Parameter(Mandatory = $true)][string]$WorkspaceId, [Parameter(Mandatory = $true)][string]$SharedKey, [Parameter(Mandatory = $true)][int]$ContentLength, [Parameter(Mandatory = $true)][string]$Rfc1123Date ) $stringToSign = "POST`n$ContentLength`napplication/json`nx-ms-date:$Rfc1123Date`n/api/logs" $keyBytes = [Convert]::FromBase64String($SharedKey) $hmac = New-Object System.Security.Cryptography.HMACSHA256 -ArgumentList (, $keyBytes) try { $signatureBytes = $hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes($stringToSign)) } finally { $hmac.Dispose() } $signature = [Convert]::ToBase64String($signatureBytes) return "SharedKey ${WorkspaceId}:$signature" } $uriWasProtected = $false if ($PSCmdlet.ParameterSetName -eq 'Profile') { $resolved = Resolve-SiemProfile -Name $ProfileName -Scope $ProfileScope $profile = $resolved.Profile $Destination = [string](Get-SiemObjectProperty $profile 'type') $TenantId = [string](Get-SiemObjectProperty $profile 'tenantId') $ClientId = [string](Get-SiemObjectProperty $profile 'clientId') $MonitorScope = [string](Get-SiemObjectProperty $profile 'monitorScope') $WorkspaceId = [string](Get-SiemObjectProperty $profile 'workspaceId') $LogType = [string](Get-SiemObjectProperty $profile 'logType') if ($Destination -eq 'Sentinel' -and [string]::IsNullOrWhiteSpace($LogType)) { $LogType = 'WindowsSecurityAudit' } $uriRef = [string](Get-SiemObjectProperty $profile 'uriRef') if (-not [string]::IsNullOrWhiteSpace($uriRef)) { $Uri = Unprotect-SiemSecret -SecretRef $uriRef -Name $ProfileName -Scope $resolved.Scope -Purpose Uri $uriWasProtected = $true } else { $Uri = [string](Get-SiemObjectProperty $profile 'uri') } $secretRef = [string](Get-SiemObjectProperty $profile 'secretRef') if (-not [string]::IsNullOrWhiteSpace($secretRef)) { $plainSecret = Unprotect-SiemSecret -SecretRef $secretRef -Name $ProfileName -Scope $resolved.Scope -Purpose Secret if ($Destination -eq 'SentinelLogsIngestion') { $ClientSecret = ConvertTo-SiemSecureString -PlainText $plainSecret } else { $Token = $plainSecret } $plainSecret = $null } } if ($Destination -eq 'Sentinel') { Write-Warning "The 'Sentinel' destination uses the deprecated Log Analytics HTTP Data Collector API (support ended September 14, 2026). Migrate to 'SentinelLogsIngestion'." } try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } catch { Write-Verbose "Could not enforce TLS 1.2: $($_.Exception.GetType().Name)" } $requireHttps = $Destination -in @('SplunkHEC', 'Sentinel', 'SentinelLogsIngestion') -or ($Destination -eq 'GenericWebhook' -and (-not [string]::IsNullOrWhiteSpace($Token) -or $uriWasProtected)) $validatedUri = Assert-SiemEndpointUri -Uri $Uri -Destination $Destination -RequireHttps:$requireHttps $safeEndpoint = Get-SiemSafeEndpoint -Uri $validatedUri if ($validatedUri.Scheme -eq 'http') { Write-Warning "Endpoint $safeEndpoint uses cleartext HTTP; event data and custom headers will be sent unencrypted." } if ($Destination -eq 'SplunkHEC' -and [string]::IsNullOrWhiteSpace($Token)) { throw 'SplunkHEC requires -Token for the Authorization header.' } if ($Destination -eq 'Sentinel') { if ([string]::IsNullOrWhiteSpace($Token)) { throw 'Sentinel requires -Token containing the Log Analytics shared key.' } if ([string]::IsNullOrWhiteSpace($WorkspaceId)) { $workspaceMatch = [regex]::Match($Uri, '^https?://([A-Za-z0-9-]+)\.ods\.opinsights\.azure\.') if ($workspaceMatch.Success) { $WorkspaceId = $workspaceMatch.Groups[1].Value } } if ([string]::IsNullOrWhiteSpace($WorkspaceId)) { throw 'Sentinel requires -WorkspaceId or a workspace data collector URI.' } } if ($Destination -eq 'SentinelLogsIngestion') { if ([string]::IsNullOrWhiteSpace($TenantId) -or [string]::IsNullOrWhiteSpace($ClientId) -or $null -eq $ClientSecret) { throw 'SentinelLogsIngestion requires TenantId, ClientId, and ClientSecret, either directly or from a profile.' } if ($ClientSecret.Length -eq 0) { throw 'ClientSecret cannot be empty.' } if (-not [string]::IsNullOrWhiteSpace($Token)) { throw 'SentinelLogsIngestion uses ClientSecret for OAuth, not the legacy Token parameter.' } Assert-SiemTenantId -TenantId $TenantId Assert-SiemMonitorScope -MonitorScope $MonitorScope } Write-Host "Streaming findings to $Destination endpoint..." -ForegroundColor Cyan Write-Host " Endpoint: $safeEndpoint" -ForegroundColor Yellow Write-Host " Format: $Format | Batch size: $BatchSize | Max retries: $MaxRetries" -ForegroundColor Yellow if (-not [string]::IsNullOrWhiteSpace($Token) -or $null -ne $ClientSecret) { Write-Host ' Credential: provided (not logged)' -ForegroundColor Yellow } if ([string]::IsNullOrWhiteSpace($HostName)) { $HostName = 'unknown-host' } $staticHeaders = @{} if ($null -ne $Headers) { foreach ($headerName in @($Headers.Keys)) { $staticHeaders[$headerName] = $Headers[$headerName] } } if ($Destination -eq 'SplunkHEC') { $staticHeaders['Authorization'] = "Splunk $Token" } $contentType = 'application/x-ndjson; charset=utf-8' if ($Destination -in @('SplunkHEC', 'Sentinel', 'SentinelLogsIngestion')) { $contentType = 'application/json' } elseif ($Destination -eq 'GenericWebhook' -and $Format -eq 'CEF') { $contentType = 'text/plain; charset=utf-8' } $pending = @() $batchesSent = 0 $eventsSent = 0 $failedEvents = 0 $startTime = Get-Date } process { $pending += $InputObject } end { if (@($pending).Count -eq 0) { Write-Warning 'No findings received; nothing to stream.' return [PSCustomObject]@{ BatchesSent = 0; EventsSent = 0; Failed = 0; DurationSec = 0 } } $eventLines = @($pending | ConvertTo-SiemEvent -Format $Format -HostName $HostName) $totalEvents = @($eventLines).Count if ($totalEvents -eq 0) { Write-Warning "No findings could be converted to $Format events; nothing to stream." return [PSCustomObject]@{ BatchesSent = 0; EventsSent = 0; Failed = 0; DurationSec = 0 } } if ($Destination -eq 'SentinelLogsIngestion' -and $Format -eq 'CEF') { $jsonLines = @() foreach ($line in $eventLines) { $jsonLines += ([ordered]@{ timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') host = $HostName message = $line } | ConvertTo-Json -Compress -Depth 5) } $eventLines = @($jsonLines) } $batches = New-Object 'System.Collections.Generic.List[object]' if ($Destination -eq 'SentinelLogsIngestion') { $currentLines = New-Object 'System.Collections.Generic.List[string]' $currentBytes = 2 foreach ($line in $eventLines) { $lineBytes = [Text.Encoding]::UTF8.GetByteCount([string]$line) $commaBytes = $(if ($currentLines.Count -gt 0) { 1 } else { 0 }) $wouldExceed = $currentBytes + $lineBytes + $commaBytes -gt $MaxBatchBytes if ($currentLines.Count -gt 0 -and ($currentLines.Count -ge $BatchSize -or $wouldExceed)) { $batches.Add([object]$currentLines.ToArray()) $currentLines = New-Object 'System.Collections.Generic.List[string]' $currentBytes = 2 $commaBytes = 0 } if ($currentLines.Count -eq 0 -and 2 + $lineBytes -gt $MaxBatchBytes) { $failedEvents++ Write-Warning "One event exceeds the $MaxBatchBytes-byte Logs Ingestion budget and was skipped." continue } $commaBytes = $(if ($currentLines.Count -gt 0) { 1 } else { 0 }) $currentLines.Add([string]$line) $currentBytes += $lineBytes + $commaBytes } if ($currentLines.Count -gt 0) { $batches.Add([object]$currentLines.ToArray()) } } else { for ($rangeStart = 0; $rangeStart -lt $totalEvents; $rangeStart += $BatchSize) { $rangeEnd = [math]::Min($rangeStart + $BatchSize, $totalEvents) - 1 $batches.Add([object]@($eventLines[$rangeStart..$rangeEnd])) } } $totalBatches = $batches.Count Write-Host "Sending $totalEvents event(s) in $totalBatches batch(es)..." -ForegroundColor Yellow for ($batchIndex = 0; $batchIndex -lt $totalBatches; $batchIndex++) { $batchNumber = $batchIndex + 1 $batchLines = @($batches[$batchIndex]) $batchCount = $batchLines.Count $body = $null $bodyBytes = $null $requestHeaders = @{} foreach ($headerName in @($staticHeaders.Keys)) { $requestHeaders[$headerName] = $staticHeaders[$headerName] } try { switch ($Destination) { 'SplunkHEC' { $hecLines = @() foreach ($line in $batchLines) { $eventObject = $null $eventTime = $null $eventHost = $HostName if ($Format -eq 'NDJSON') { try { $eventObject = ($line | ConvertFrom-Json -ErrorAction Stop) } catch { $eventObject = $null } } if ($null -ne $eventObject) { $timestampProperty = $eventObject.PSObject.Properties['timestamp'] if ($null -ne $timestampProperty -and $null -ne $timestampProperty.Value) { $eventTime = $timestampProperty.Value } $hostProperty = $eventObject.PSObject.Properties['host'] if ($null -ne $hostProperty -and -not [string]::IsNullOrWhiteSpace([string]$hostProperty.Value)) { $eventHost = [string]$hostProperty.Value } $hecLines += ([ordered]@{ time = (Get-UnixEpochSeconds -Timestamp $eventTime) host = $eventHost sourcetype = 'windowsecurityaudit:finding' event = $eventObject } | ConvertTo-Json -Compress -Depth 10) } else { $hecLines += ([ordered]@{ time = (Get-UnixEpochSeconds -Timestamp $null) host = $eventHost sourcetype = 'windowsecurityaudit:finding' event = $line } | ConvertTo-Json -Compress -Depth 10) } } $body = ($hecLines -join "`n") } 'Sentinel' { if ($Format -eq 'NDJSON') { $body = '[' + ($batchLines -join ',') + ']' } else { $cefObjects = @() foreach ($line in $batchLines) { $cefObjects += ([ordered]@{ timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') host = $HostName message = $line } | ConvertTo-Json -Compress -Depth 5) } $body = '[' + ($cefObjects -join ',') + ']' } $bodyBytes = [Text.Encoding]::UTF8.GetBytes($body) $rfc1123Date = [DateTime]::UtcNow.ToString('r', [Globalization.CultureInfo]::InvariantCulture) $requestHeaders['Authorization'] = New-SentinelSharedKeySignature -WorkspaceId $WorkspaceId -SharedKey $Token -ContentLength $bodyBytes.Length -Rfc1123Date $rfc1123Date $requestHeaders['x-ms-date'] = $rfc1123Date $requestHeaders['Log-Type'] = $LogType } 'SentinelLogsIngestion' { $body = '[' + ($batchLines -join ',') + ']' $bodyBytes = [Text.Encoding]::UTF8.GetBytes($body) } 'GenericWebhook' { $body = ($batchLines -join "`n") } } } catch { Write-Error "Failed to build $Destination request payload ($($_.Exception.GetType().Name))." throw "Failed to build $Destination request payload." } $accessToken = $null if ($Destination -eq 'SentinelLogsIngestion') { try { $accessToken = Get-SiemAccessToken -TenantId $TenantId -ClientId $ClientId -ClientSecret $ClientSecret -MonitorScope $MonitorScope -MaxRetries $MaxRetries -ThrottleMs $ThrottleMs $requestHeaders['Authorization'] = "Bearer $accessToken" } catch { $failedEvents += $batchCount Write-Warning "Batch $batchNumber of $totalBatches could not acquire an Entra token and was skipped." continue } } $requestParams = @{ Uri = $Uri Method = 'Post' ContentType = $contentType TimeoutSec = 100 UseBasicParsing = $true ErrorAction = 'Stop' } if ($null -ne $bodyBytes) { $requestParams['Body'] = $bodyBytes } else { $requestParams['Body'] = $body } if ($requestHeaders.Count -gt 0) { $requestParams['Headers'] = $requestHeaders } if ($requestHeaders.ContainsKey('Content-Type')) { $requestParams.Remove('ContentType') } $requestAttempts = 0 $transientRetries = 0 $reauthenticated = $false $delivered = $false $lastFailure = 'Unknown transport error.' while (-not $delivered) { $requestAttempts++ try { $null = Invoke-RestMethod @requestParams $delivered = $true } catch { $statusCode = Get-SiemHttpStatusCode -Exception $_.Exception $lastFailure = $(if ($null -ne $statusCode) { "HTTP $statusCode" } else { "Transport exception: $($_.Exception.GetType().Name)" }) if ($Destination -eq 'SentinelLogsIngestion' -and $statusCode -eq 401 -and -not $reauthenticated) { $reauthenticated = $true Write-Warning "Batch $batchNumber received HTTP 401; refreshing the Entra token and retrying once." try { $accessToken = Get-SiemAccessToken -TenantId $TenantId -ClientId $ClientId -ClientSecret $ClientSecret -MonitorScope $MonitorScope -MaxRetries $MaxRetries -ThrottleMs $ThrottleMs -ForceRefresh $requestHeaders['Authorization'] = "Bearer $accessToken" } catch { $lastFailure = 'Entra token refresh failed.' break } continue } $isTransient = ($null -ne $statusCode) -and (($statusCode -eq 429) -or ($statusCode -ge 500 -and $statusCode -le 599)) if ($isTransient -and $transientRetries -lt $MaxRetries) { $transientRetries++ $backoffMs = $ThrottleMs * [int][math]::Pow(2, $transientRetries) Write-Warning "Batch $batchNumber transient failure (HTTP $statusCode); retry $transientRetries of $MaxRetries in $backoffMs ms." if ($backoffMs -gt 0) { Start-Sleep -Milliseconds $backoffMs } continue } break } } if ($delivered) { $batchesSent++ $eventsSent += $batchCount Write-Verbose "Batch $batchNumber of $totalBatches delivered ($batchCount event(s))." } else { $failedEvents += $batchCount Write-Warning "Batch $batchNumber of $totalBatches failed after $requestAttempts attempt(s) and was skipped: $lastFailure" } $accessToken = $null if ($batchIndex -lt ($totalBatches - 1) -and $ThrottleMs -gt 0) { Start-Sleep -Milliseconds $ThrottleMs } } $durationSec = [math]::Round(((Get-Date) - $startTime).TotalSeconds, 2) Write-Host "SIEM stream complete: $eventsSent event(s) sent in $batchesSent batch(es); $failedEvents event(s) failed ($durationSec s)." -ForegroundColor Green return [PSCustomObject]@{ BatchesSent = $batchesSent EventsSent = $eventsSent Failed = $failedEvents DurationSec = $durationSec } } } |