Response/Send-SiemEvent.ps1

function Send-SiemEvent {
    <#
    .SYNOPSIS
        Streams security findings directly to SIEM HTTP endpoints
    .DESCRIPTION
        Converts finding objects to NDJSON or CEF via ConvertTo-SiemEvent and streams them
        in batches to a SIEM HTTP endpoint: Splunk HTTP Event Collector (HEC), Microsoft
        Sentinel (Log Analytics Data Collector API), or a generic webhook.
 
        SplunkHEC wraps each NDJSON event one JSON object per line with
        {"time":<epoch>,"host":<host>,"sourcetype":"windowsecurityaudit:finding","event":{...}}
        and authenticates with the 'Splunk <token>' Authorization header. CEF events are
        delivered as the raw string value of the 'event' field.
 
        Sentinel posts a JSON array to the Log Analytics Data Collector API and builds the
        documented HMAC-SHA256 SharedKey signature from the string-to-sign
        "POST\n<contentLength>\napplication/json\nx-ms-date:<rfc1123date>\n/api/logs".
        -Token supplies the shared key; the workspace ID comes from -WorkspaceId or is
        parsed from the data collector endpoint in -Uri.
 
        GenericWebhook posts the raw NDJSON/CEF body with optional custom -Headers.
 
        Transient failures (HTTP 429 or 5xx) are retried per batch with exponential
        backoff up to -MaxRetries times; a batch that still fails is counted in Failed and
        streaming continues with the next batch. Tokens are never written to output in
        full: console output shows only the first four characters followed by '...'.
        Cleartext http:// endpoints are rejected when -Token is supplied, and warned
        about otherwise, so credentials are never sent unencrypted.
 
        Event timestamps reflect the UTC time of conversion (produced by
        ConvertTo-SiemEvent), not the time the finding was originally detected.
    .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
        Target endpoint type: SplunkHEC, Sentinel, or GenericWebhook.
    .PARAMETER Uri
        Endpoint URL. For Sentinel, either the full data collector URL
        (https://<workspaceId>.ods.opinsights.azure.com/api/logs?api-version=2016-04-01)
        or use -WorkspaceId with the same base URL.
    .PARAMETER Token
        Credential for the endpoint: the HEC token for SplunkHEC, the shared key for
        Sentinel. Sent only in request headers, never logged in full.
    .PARAMETER Headers
        Additional HTTP headers (hashtable), intended for GenericWebhook endpoints.
    .PARAMETER WorkspaceId
        Log Analytics workspace ID for Sentinel. Optional when the workspace ID can be
        parsed from -Uri.
    .PARAMETER LogType
        Log Analytics custom log type (Log-Type header) for Sentinel. Defaults to
        'WindowsSecurityAudit'.
    .PARAMETER HostName
        Host value embedded in each event. Defaults to the local machine name.
    .PARAMETER BatchSize
        Number of events per HTTP POST. Defaults to 100.
    .PARAMETER MaxRetries
        Maximum retries per batch on transient errors (HTTP 429/5xx) with exponential
        backoff. Defaults to 3.
    .PARAMETER ThrottleMs
        Base delay in milliseconds between batches; also the base of the exponential
        retry backoff (ThrottleMs * 2^attempt). Defaults to 250.
    .EXAMPLE
        $report.Findings | Send-SiemEvent -Destination SplunkHEC -Uri "https://splunk.contoso.com:8088/services/collector" -Token $hecToken
    .EXAMPLE
        Find-SuspiciousProcesses | Send-SiemEvent -Destination Sentinel -Uri "https://<workspaceId>.ods.opinsights.azure.com/api/logs?api-version=2016-04-01" -Token $sharedKey
    .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()]
    param(
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [AllowNull()]
        [object]$InputObject,

        [Parameter()]
        [ValidateSet('NDJSON', 'CEF')]
        [string]$Format = 'NDJSON',

        [Parameter(Mandatory = $true)]
        [ValidateSet('SplunkHEC', 'Sentinel', 'GenericWebhook')]
        [string]$Destination,

        [Parameter(Mandatory = $true)]
        [string]$Uri,

        [Parameter()]
        [string]$Token,

        [Parameter()]
        [hashtable]$Headers,

        [Parameter()]
        [string]$WorkspaceId,

        [Parameter()]
        [string]$LogType = 'WindowsSecurityAudit',

        [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 ConvertTo-MaskedToken {
            param([AllowNull()][string]$Secret)
            if ([string]::IsNullOrEmpty($Secret)) { return '(not provided)' }
            if ($Secret.Length -le 4) { return '****' }
            return ($Secret.Substring(0, 4) + '...')
        }

        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, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AssumeUniversal)
                }
                catch {
                    $parsed = $null
                }
            }
            if ($null -eq $parsed) { $parsed = [DateTimeOffset]::UtcNow }
            return [long][math]::Floor(($parsed.ToUniversalTime() - $unixEpoch).TotalSeconds)
        }

        function Get-HttpStatusCodeFromException {
            param([AllowNull()][object]$Exception)
            if ($null -eq $Exception) { return $null }
            $responseProp = $Exception.PSObject.Properties['Response']
            if ($null -eq $responseProp -or $null -eq $responseProp.Value) { return $null }
            $statusProp = $responseProp.Value.PSObject.Properties['StatusCode']
            if ($null -eq $statusProp -or $null -eq $statusProp.Value) { return $null }
            try {
                return [int]$statusProp.Value
            }
            catch {
                return $null
            }
        }

        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([System.Text.Encoding]::UTF8.GetBytes($stringToSign))
            }
            finally {
                $hmac.Dispose()
            }
            $signature = [Convert]::ToBase64String($signatureBytes)
            return "SharedKey ${WorkspaceId}:$signature"
        }

        Write-Host "Streaming findings to $Destination endpoint..." -ForegroundColor Cyan
        Write-Host " Endpoint: $Uri" -ForegroundColor Yellow
        Write-Host " Format: $Format | Batch size: $BatchSize | Max retries: $MaxRetries" -ForegroundColor Yellow
        if (-not [string]::IsNullOrWhiteSpace($Token)) {
            Write-Host " Token: $(ConvertTo-MaskedToken -Secret $Token)" -ForegroundColor Yellow
        }

        try {
            [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
        }
        catch {
            Write-Verbose "Could not enforce TLS 1.2: $_"
        }

        $validatedUri = $null
        if (-not [System.Uri]::TryCreate($Uri, [System.UriKind]::Absolute, [ref]$validatedUri) -or ($validatedUri.Scheme -ne 'https' -and $validatedUri.Scheme -ne 'http')) {
            Write-Error "Uri '$Uri' is not a valid absolute HTTP(S) endpoint."
            throw "Invalid SIEM endpoint Uri."
        }

        if ($validatedUri.Scheme -eq 'http') {
            if (-not [string]::IsNullOrWhiteSpace($Token)) {
                Write-Error "Refusing to send -Token credentials to the cleartext HTTP endpoint '$Uri'. Use HTTPS."
                throw "Cleartext HTTP endpoint rejected."
            }
            Write-Warning "Endpoint '$Uri' uses cleartext HTTP; event data and any custom headers (including API keys) will be sent unencrypted."
        }

        if ($Destination -eq 'SplunkHEC' -and [string]::IsNullOrWhiteSpace($Token)) {
            Write-Error "SplunkHEC requires -Token (HEC token) for the 'Splunk <token>' Authorization header."
            throw "Missing Splunk HEC token."
        }

        if ($Destination -eq 'Sentinel') {
            if ([string]::IsNullOrWhiteSpace($Token)) {
                Write-Error "Sentinel requires -Token containing the Log Analytics shared key."
                throw "Missing Sentinel 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)) {
                Write-Error "Sentinel requires -WorkspaceId or a -Uri of the form https://<workspaceId>.ods.opinsights.azure.com/api/logs."
                throw "Missing Sentinel workspace ID."
            }
        }

        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 -eq 'SplunkHEC' -or $Destination -eq 'Sentinel') {
            $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
            }
        }

        $totalBatches = [int][math]::Ceiling($totalEvents / $BatchSize)
        Write-Host "Sending $totalEvents event(s) in $totalBatches batch(es)..." -ForegroundColor Yellow

        for ($batchIndex = 0; $batchIndex -lt $totalBatches; $batchIndex++) {
            $batchNumber = $batchIndex + 1
            $rangeStart = $batchIndex * $BatchSize
            $rangeEnd = [math]::Min($rangeStart + $BatchSize, $totalEvents) - 1
            $batchLines = @($eventLines[$rangeStart..$rangeEnd])
            $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) {
                                $timestampProp = $eventObject.PSObject.Properties['timestamp']
                                if ($null -ne $timestampProp -and $null -ne $timestampProp.Value) {
                                    $eventTime = $timestampProp.Value
                                }
                                $hostProp = $eventObject.PSObject.Properties['host']
                                if ($null -ne $hostProp -and -not [string]::IsNullOrWhiteSpace([string]$hostProp.Value)) {
                                    $eventHost = [string]$hostProp.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 = [System.Text.Encoding]::UTF8.GetBytes($body)
                        $rfc1123Date = [DateTime]::UtcNow.ToString('r', [System.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
                    }
                    'GenericWebhook' {
                        $body = ($batchLines -join "`n")
                    }
                }
            }
            catch {
                Write-Error "Failed to build $Destination request payload: $_"
                throw
            }

            $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')
            }

            $attempt = 0
            $delivered = $false
            $lastErrorMessage = ''
            while (-not $delivered -and $attempt -le $MaxRetries) {
                $attempt++
                try {
                    $null = Invoke-RestMethod @requestParams
                    $delivered = $true
                }
                catch {
                    $lastErrorMessage = $_.Exception.Message
                    $statusCode = Get-HttpStatusCodeFromException -Exception $_.Exception
                    $isTransient = ($null -ne $statusCode) -and (($statusCode -eq 429) -or ($statusCode -ge 500 -and $statusCode -le 599))
                    if ($isTransient -and $attempt -le $MaxRetries) {
                        $backoffMs = $ThrottleMs * [int][math]::Pow(2, $attempt)
                        Write-Warning "Batch $batchNumber transient failure (HTTP $statusCode); retry $attempt of $MaxRetries in $backoffMs ms."
                        if ($backoffMs -gt 0) {
                            Start-Sleep -Milliseconds $backoffMs
                        }
                    }
                    else {
                        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 $attempt attempt(s) and was skipped: $lastErrorMessage"
            }

            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
        }
    }
}