Files
Sharon KatzandClaude Opus 4.6 b36be84e85 Add native Splunk HEC log destination (#48455)
**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>
2026-07-03 12:14:24 -04:00

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