**Related issue:** Resolves #25574 # Checklist for submitter - [x] Changes file added for user-visible changes in `changes/` - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. - [x] Timeouts are implemented and retries are limited to avoid infinite loops ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually --- ## Summary - Adds a new `splunk` log plugin that sends osquery logs directly to Splunk's HTTP Event Collector (HEC) endpoint - Eliminates the need for middleware like AWS Firehose when using Splunk as a log destination - Follows the same pattern as existing log destinations (Firehose, Kafka REST, NATS, etc.) - Includes `insecure_skip_verify` option for environments with self-signed TLS certs ## UI changes Follows the same pattern as the NATS log destination PR (#36527) -- adding "Splunk" to the display name, tooltip, and TypeScript type union. No new components, pages, or styles. ### Manage automations modal -- "Log destination: Splunk" <img width="822" height="527" alt="image" src="https://github.com/user-attachments/assets/2533207f-fa95-4364-8ee0-3c39cd3e8e4d" /> ### Query details page -- "Log destination: Splunk" <img width="1905" height="662" alt="image" src="https://github.com/user-attachments/assets/069a5005-f95c-4562-a819-fd8bdcc349f7" /> ### Tooltip on hover <img width="639" height="348" alt="image" src="https://github.com/user-attachments/assets/809a47a6-b82a-4f45-b731-77b2d2c87947" /> ### Edit query form -- "sent to your log destination: Splunk" <img width="451" height="814" alt="image" src="https://github.com/user-attachments/assets/b78b9a57-1f0c-4413-8b7c-654de1fd40a2" /> ### Save new query modal -- "sent to your log destination: Splunk" <img width="536" height="698" alt="image" src="https://github.com/user-attachments/assets/d0a0ab01-66fe-4d63-9190-9c5e840e456d" /> --- ### How it works The Splunk writer (`server/logging/splunk.go`) implements the `fleet.JSONLogger` interface. On startup it performs a health check against the HEC `/services/collector/health` endpoint. On each `Write()` call, it wraps each log entry in Splunk's HEC event format (adding `time`, `index`, `source`, `sourcetype`), batches them up to 1 MB, and POSTs to `/services/collector/event` with the `Authorization: Splunk <token>` header. If a batch exceeds 1 MB it flushes and starts a new one. Events over 1 MB are dropped with a log warning. Transient errors (HTTP 503) are retried with exponential backoff (up to 8 retries). ### Configuration ```yaml osquery: status_log_plugin: splunk result_log_plugin: splunk splunk: url: https://splunk.example.com:8088 token: <HEC token> index: main source: fleet source_type: fleet:json insecure_skip_verify: false # set true for self-signed certs ``` Or via environment variables: ``` FLEET_OSQUERY_STATUS_LOG_PLUGIN=splunk FLEET_OSQUERY_RESULT_LOG_PLUGIN=splunk FLEET_SPLUNK_URL=https://splunk.example.com:8088 FLEET_SPLUNK_TOKEN=<HEC token> FLEET_SPLUNK_INDEX=main FLEET_SPLUNK_SOURCE=fleet FLEET_SPLUNK_SOURCE_TYPE=fleet:json ``` ### Files changed - `server/logging/splunk.go` -- Splunk HEC log writer with batching, retry, and health check - `server/logging/splunk_test.go` -- 9 unit tests - `server/logging/splunk_integration_test.go` -- 3 integration tests against real Splunk (gated by env var) - `server/logging/logging.go` -- Added `SplunkConfig` and `case "splunk"` to factory - `server/config/config.go` -- Added `SplunkConfig` struct and config flags - `cmd/fleet/logging.go` -- Wired Splunk config into logging builder - `server/fleet/app.go` -- Added `SplunkConfig` type for API responses (excludes token) - `server/service/service_appconfig.go` -- Added `case "splunk"` to logging plugin validation - `frontend/interfaces/config.ts` -- Added `"splunk"` to LogDestination type - `frontend/components/LogDestinationIndicator/LogDestinationIndicator.tsx` -- Added Splunk display name and tooltip - `docs/Configuration/fleet-server-configuration.md` -- Splunk config documentation - `docs/Get started/FAQ.md` -- Updated plugin list - `articles/log-destinations.md` -- Updated Splunk section with native HEC docs - `changes/25574-splunk-log-destination` -- Change file ## Test plan ### Unit tests (9 tests) - [x] `TestSplunkWrite` -- sends 3 events, verifies HEC format, auth header, index/source/sourcetype - [x] `TestSplunkWriteEmpty` -- empty logs don't trigger HTTP request - [x] `TestSplunkServerError` -- HEC 403 propagates as error - [x] `TestSplunkHealthCheckFailure` -- constructor fails on bad health - [x] `TestSplunkRecordTooBig` -- oversized events (>1MB) are dropped, normal events still sent - [x] `TestSplunkSplitBatchBySize` -- logs exceeding 1MB batch limit are split into multiple requests - [x] `TestSplunkRetryOnServiceUnavailable` -- 503 retried with backoff, succeeds on 3rd attempt - [x] `TestSplunkRetryExhausted` -- after 9 attempts (1 + 8 retries) returns error - [x] `TestSplunkMissingConfig` -- empty URL/token returns descriptive error ### Integration tests (3 tests, gated by `SPLUNK_INTEGRATION_TEST=1`) - [x] `TestSplunkIntegration` -- 3 events sent via writer, queried back from Splunk REST API - [x] `TestSplunkIntegrationBatch` -- 100 events in one Write(), all confirmed indexed - [x] `TestSplunkIntegrationBadToken` -- bad token Write() returns 403 ### End-to-end test (macOS ARM64, real osquery agent) 1. Started Splunk Enterprise, MySQL, Redis via Docker 2. Built Fleet server from this branch with `--osquery_status_log_plugin=splunk` 3. Set up Fleet, enrolled a real osquery 5.23.0 agent on this MacBook 4. **83 real osquery status log events indexed in Splunk** with correct source/sourcetype/index 5. Each event contained full osquery data (`hostIdentifier`, `host_uuid`, `calendarTime`, `severity`, `message`, `decorations`) ### Splunk showing real osquery events from Fleet <img width="1910" height="861" alt="image" src="https://github.com/user-attachments/assets/192490bf-d594-4424-a3e3-a18306892873" /> Generated with [Claude Code](https://claude.ai/code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added native Splunk HEC logging destination for status, result, and audit logs. * Updated the log destination UI to display **Splunk** with a dedicated tooltip. * Added Splunk HEC configuration (URL/token/index/source/source type) including TLS verification control. * **Bug Fixes** * Improved log delivery with batching, retries for temporary HTTP failures, and safeguards for oversized events. * **Tests** * Added unit tests and optional integration tests covering routing, batching, retries, and error scenarios. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
316 lines
9.3 KiB
Go
316 lines
9.3 KiB
Go
package logging
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestSplunkWrite(t *testing.T) {
|
|
ctx := t.Context()
|
|
|
|
var receivedBody []byte
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == splunkHealthPath {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
assert.Equal(t, splunkHECPath, r.URL.Path)
|
|
assert.Equal(t, "Splunk test-token", r.Header.Get("Authorization"))
|
|
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
|
|
|
var err error
|
|
receivedBody, err = io.ReadAll(r.Body)
|
|
assert.NoError(t, err)
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer server.Close()
|
|
|
|
writer, err := NewSplunkLogWriter(server.URL, "test-token", "main", "fleet", "fleet:json", false, slog.Default())
|
|
require.NoError(t, err)
|
|
|
|
err = writer.Write(ctx, logs)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, receivedBody)
|
|
|
|
// The body should be concatenated JSON objects (one per log entry).
|
|
decoder := json.NewDecoder(bytes.NewReader(receivedBody))
|
|
var events []splunkEvent
|
|
for decoder.More() {
|
|
var evt splunkEvent
|
|
err := decoder.Decode(&evt)
|
|
require.NoError(t, err)
|
|
events = append(events, evt)
|
|
}
|
|
|
|
require.Len(t, events, 3)
|
|
for i, evt := range events {
|
|
assert.JSONEq(t, string(logs[i]), string(evt.Event))
|
|
assert.Equal(t, "main", evt.Index)
|
|
assert.Equal(t, "fleet", evt.Source)
|
|
assert.Equal(t, "fleet:json", evt.SourceType)
|
|
assert.NotZero(t, evt.Time)
|
|
}
|
|
}
|
|
|
|
func TestSplunkWriteEmpty(t *testing.T) {
|
|
ctx := t.Context()
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == splunkHealthPath {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
t.Fatal("should not send request for empty logs")
|
|
}))
|
|
defer server.Close()
|
|
|
|
writer, err := NewSplunkLogWriter(server.URL, "test-token", "", "", "", false, slog.Default())
|
|
require.NoError(t, err)
|
|
|
|
err = writer.Write(ctx, []json.RawMessage{})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestSplunkServerError(t *testing.T) {
|
|
ctx := t.Context()
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == splunkHealthPath {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
http.Error(w, `{"text":"Invalid token","code":4}`, http.StatusForbidden)
|
|
}))
|
|
defer server.Close()
|
|
|
|
writer, err := NewSplunkLogWriter(server.URL, "test-token", "", "", "", false, slog.Default())
|
|
require.NoError(t, err)
|
|
|
|
err = writer.Write(ctx, logs)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "403")
|
|
}
|
|
|
|
func TestSplunkHealthCheckFailure(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
|
|
}))
|
|
defer server.Close()
|
|
|
|
_, err := NewSplunkLogWriter(server.URL, "test-token", "", "", "", false, slog.Default())
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "health check")
|
|
}
|
|
|
|
func TestSplunkRecordTooBig(t *testing.T) {
|
|
ctx := t.Context()
|
|
|
|
var receivedBody []byte
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == splunkHealthPath {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
var err error
|
|
receivedBody, err = io.ReadAll(r.Body)
|
|
assert.NoError(t, err)
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer server.Close()
|
|
|
|
writer, err := NewSplunkLogWriter(server.URL, "test-token", "", "", "", false, slog.Default())
|
|
require.NoError(t, err)
|
|
|
|
// Create one normal log and one oversized log (>1MB)
|
|
normalLog := json.RawMessage(`{"normal":"event"}`)
|
|
bigPayload := make([]byte, splunkMaxSizeOfRecord+1)
|
|
for i := range bigPayload {
|
|
bigPayload[i] = 'x'
|
|
}
|
|
oversizedLog := json.RawMessage(`{"big":"` + string(bigPayload) + `"}`)
|
|
|
|
err = writer.Write(ctx, []json.RawMessage{normalLog, oversizedLog})
|
|
require.NoError(t, err)
|
|
|
|
// Only the normal event should have been sent; the oversized one should be dropped
|
|
decoder := json.NewDecoder(bytes.NewReader(receivedBody))
|
|
var count int
|
|
for decoder.More() {
|
|
var evt splunkEvent
|
|
err := decoder.Decode(&evt)
|
|
require.NoError(t, err)
|
|
count++
|
|
}
|
|
assert.Equal(t, 1, count, "only the normal-sized event should be sent")
|
|
}
|
|
|
|
func TestSplunkSplitBatchBySize(t *testing.T) {
|
|
ctx := t.Context()
|
|
|
|
var batchCount int
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == splunkHealthPath {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
batchCount++
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer server.Close()
|
|
|
|
writer, err := NewSplunkLogWriter(server.URL, "test-token", "", "", "", false, slog.Default())
|
|
require.NoError(t, err)
|
|
|
|
// Create logs that together exceed splunkMaxBatchSize (1MB).
|
|
// Each log wraps to ~10KB after HEC envelope, so ~120 logs should exceed 1MB.
|
|
var largeLogs []json.RawMessage
|
|
payload := make([]byte, 10000)
|
|
for i := range payload {
|
|
payload[i] = 'a'
|
|
}
|
|
for range 120 {
|
|
largeLogs = append(largeLogs, json.RawMessage(`{"data":"`+string(payload)+`"}`))
|
|
}
|
|
|
|
err = writer.Write(ctx, largeLogs)
|
|
require.NoError(t, err)
|
|
assert.Greater(t, batchCount, 1, "should split into multiple batches")
|
|
}
|
|
|
|
func TestSplunkRetryOnServiceUnavailable(t *testing.T) {
|
|
ctx := t.Context()
|
|
origDelay := splunkRetryDelay
|
|
splunkRetryDelay = func(_ int) time.Duration { return time.Millisecond }
|
|
t.Cleanup(func() { splunkRetryDelay = origDelay })
|
|
|
|
callCount := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == splunkHealthPath {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
callCount++
|
|
if callCount <= 2 {
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer server.Close()
|
|
|
|
writer, err := NewSplunkLogWriter(server.URL, "test-token", "", "", "", false, slog.Default())
|
|
require.NoError(t, err)
|
|
|
|
err = writer.Write(ctx, logs)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 3, callCount, "should retry twice then succeed on third attempt")
|
|
}
|
|
|
|
func TestSplunkRetryExhausted(t *testing.T) {
|
|
ctx := t.Context()
|
|
origDelay := splunkRetryDelay
|
|
splunkRetryDelay = func(_ int) time.Duration { return time.Millisecond }
|
|
t.Cleanup(func() { splunkRetryDelay = origDelay })
|
|
|
|
callCount := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == splunkHealthPath {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
callCount++
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
}))
|
|
defer server.Close()
|
|
|
|
writer, err := NewSplunkLogWriter(server.URL, "test-token", "", "", "", false, slog.Default())
|
|
require.NoError(t, err)
|
|
|
|
err = writer.Write(ctx, logs)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "503")
|
|
// 1 initial attempt + 8 retries = 9 total
|
|
assert.Equal(t, splunkMaxRetries+1, callCount, "should exhaust all retries")
|
|
}
|
|
|
|
func TestSplunkRetryBodyIntegrity(t *testing.T) {
|
|
ctx := t.Context()
|
|
origDelay := splunkRetryDelay
|
|
splunkRetryDelay = func(_ int) time.Duration { return time.Millisecond }
|
|
t.Cleanup(func() { splunkRetryDelay = origDelay })
|
|
|
|
var bodies [][]byte
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == splunkHealthPath {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
b, _ := io.ReadAll(r.Body)
|
|
bodies = append(bodies, b)
|
|
if len(bodies) <= 2 {
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer server.Close()
|
|
|
|
writer, err := NewSplunkLogWriter(server.URL, "test-token", "", "", "", false, slog.Default())
|
|
require.NoError(t, err)
|
|
|
|
err = writer.Write(ctx, logs)
|
|
require.NoError(t, err)
|
|
require.Len(t, bodies, 3)
|
|
// Every retry must send the exact same payload
|
|
assert.Equal(t, bodies[0], bodies[1], "retry 1 body must match original")
|
|
assert.Equal(t, bodies[0], bodies[2], "retry 2 body must match original")
|
|
assert.NotEmpty(t, bodies[0], "body must not be empty")
|
|
}
|
|
|
|
func TestSplunkRetryNoNestedRetries(t *testing.T) {
|
|
ctx := t.Context()
|
|
origDelay := splunkRetryDelay
|
|
splunkRetryDelay = func(_ int) time.Duration { return time.Millisecond }
|
|
t.Cleanup(func() { splunkRetryDelay = origDelay })
|
|
|
|
callCount := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == splunkHealthPath {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
callCount++
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
}))
|
|
defer server.Close()
|
|
|
|
writer, err := NewSplunkLogWriter(server.URL, "test-token", "", "", "", false, slog.Default())
|
|
require.NoError(t, err)
|
|
|
|
_ = writer.Write(ctx, logs)
|
|
// Must be exactly splunkMaxRetries+1, not exponentially more.
|
|
// Nested retries would produce 2^9 = 512 calls.
|
|
assert.Equal(t, splunkMaxRetries+1, callCount, "retries must be linear, not nested")
|
|
}
|
|
|
|
func TestSplunkMissingConfig(t *testing.T) {
|
|
ctx := t.Context()
|
|
// Validation now happens in the factory (logging.go), not in NewSplunkLogWriter.
|
|
_, err := NewJSONLogger(ctx, "status", Config{Plugin: "splunk", Splunk: SplunkConfig{Token: "t"}}, slog.Default())
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "URL")
|
|
|
|
_, err = NewJSONLogger(ctx, "status", Config{Plugin: "splunk", Splunk: SplunkConfig{URL: "http://localhost"}}, slog.Default())
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "token")
|
|
}
|