Skip to content

Commit 9045a91

Browse files
committed
Merge branch 'k8s-audit-log-import'
# Conflicts: # cmd/spectre/commands/server.go # docs/docs/configuration/storage-settings.md # internal/api/import_handler.go # internal/importexport/json_import.go # internal/importexport/json_import_test.go
2 parents bfa30be + 335c190 commit 9045a91

13 files changed

Lines changed: 1301 additions & 39 deletions

File tree

cmd/spectre/commands/server_import_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,50 @@ func TestRunStartupImportLogsWarningWhenBothIngestorsAreSet(t *testing.T) {
368368
}
369369
}
370370

371+
func TestRunStartupImportLogsAuditWarnings(t *testing.T) {
372+
os.Setenv("LOG_TIMESTAMP", "2024-01-01T12:00:00Z")
373+
defer os.Unsetenv("LOG_TIMESTAMP")
374+
375+
tmpDir := t.TempDir()
376+
auditPath := filepath.Join(tmpDir, "audit.log")
377+
payload := `{
378+
"kind":"Event",
379+
"apiVersion":"audit.k8s.io/v1",
380+
"auditID":"audit-warning",
381+
"stage":"ResponseComplete",
382+
"stageTimestamp":"2024-01-02T03:04:05Z",
383+
"verb":"update",
384+
"objectRef":{
385+
"resource":"deployments",
386+
"namespace":"default",
387+
"name":"missing-payload",
388+
"apiGroup":"apps",
389+
"apiVersion":"v1"
390+
}
391+
}`
392+
if err := os.WriteFile(auditPath, []byte(payload), 0o644); err != nil {
393+
t.Fatalf("failed to create audit fixture: %v", err)
394+
}
395+
396+
output := captureStartupImportOutput(t, func() {
397+
err := runStartupImport(context.Background(), startupImportOptions{
398+
Path: auditPath,
399+
Logger: logging.GetLogger("test_startup_import_audit_warning"),
400+
BatchIngestor: &fakeStartupImportBatchIngestor{},
401+
})
402+
if err != nil {
403+
t.Fatalf("runStartupImport returned error: %v", err)
404+
}
405+
})
406+
407+
if !strings.Contains(output, "Import warning") {
408+
t.Fatalf("expected startup import logs to contain %q, got %q", "Import warning", output)
409+
}
410+
if !strings.Contains(output, "missing responseObject/requestObject payload") {
411+
t.Fatalf("expected startup import logs to mention missing audit payload, got %q", output)
412+
}
413+
}
414+
371415
func TestServerCommandDefinesStartupImportDisableCausalityFlag(t *testing.T) {
372416
t.Parallel()
373417

docs/docs/configuration/storage-settings.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,32 @@ spectre server --data-dir=./data --scrub-sensitive-data=true
3535
- Applies to startup imports before imported events are processed by the embedded backend
3636
- Does not rewrite historical data already stored on disk
3737
- Leaves references such as `valueFrom` unchanged because they do not contain literal secret values
38+
39+
## Startup Import
40+
41+
**Flag:** `--import-path`
42+
**Type:** String (file or directory path)
43+
**Default:** `""` (disabled)
44+
45+
**Purpose:** Import historical events at startup before the embedded backend begins serving requests.
46+
47+
Spectre accepts these startup import formats:
48+
49+
- Native Spectre JSON: `{"events":[...]}`
50+
- Kubernetes audit `Event`
51+
- Kubernetes audit `EventList`
52+
- Line-delimited Kubernetes audit JSON in `.jsonl` or `.log` files
53+
54+
When importing official Kubernetes audit logs, Spectre:
55+
56+
- keeps mutating requests only: `create`, `update`, `patch`, `apply`, `delete`, `deletecollection`
57+
- skips read-only requests such as `get`, `list`, `watch`, `proxy`, and `connect`
58+
- uses `responseObject` first, then `requestObject`, as the best-effort resource snapshot
59+
- warns and skips mutating audit entries that do not contain enough object payload or identity data to build a Spectre event
60+
61+
**Examples:**
62+
63+
```bash
64+
spectre server --import-path=/backups/events-2025-12-11.json
65+
spectre server --import-path=/backups/kube-apiserver-audit/
66+
```

internal/api/handlers/import_handler.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,10 @@ func (h *ImportHandler) handleJSONEventImport(w http.ResponseWriter, r *http.Req
114114
return
115115
}
116116

117-
// Parse JSON request using new Import API
117+
// Parse JSON request using shared import parser so audit import warnings can
118+
// be surfaced to the caller without changing ingest semantics.
118119
h.logger.Debug("Starting to parse JSON events from request body")
119-
eventValues, err := importexport.Import(importexport.FromReader(decompressedBody), importexport.WithLogger(h.logger))
120+
eventValues, warnings, err := importexport.ParseImportPayload(decompressedBody)
120121
if err != nil {
121122
h.logger.Error("Failed to parse JSON: %v", err)
122123
api.WriteError(w, http.StatusBadRequest, "INVALID_JSON", err.Error())
@@ -126,6 +127,7 @@ func (h *ImportHandler) handleJSONEventImport(w http.ResponseWriter, r *http.Req
126127
parseDuration := time.Since(startTime)
127128
h.logger.InfoWithFields("Parsed JSON import request",
128129
logging.Field("event_count", len(eventValues)),
130+
logging.Field("warning_count", len(warnings)),
129131
logging.Field("parse_duration", parseDuration))
130132

131133
// Process events through the ingest backend.
@@ -157,6 +159,12 @@ func (h *ImportHandler) handleJSONEventImport(w http.ResponseWriter, r *http.Req
157159
h.logger.InfoWithFields("JSON event batch import completed",
158160
logging.Field("total_events", len(eventValues)),
159161
logging.Field("duration", duration))
162+
if len(warnings) > 0 {
163+
h.logger.Warn("JSON import completed with %d warnings", len(warnings))
164+
for _, warning := range warnings {
165+
h.logger.Warn("Import warning: %s", warning)
166+
}
167+
}
160168

161169
// Calculate approximate "files created" based on unique hours
162170
// This is for compatibility with existing tests that expect this field
@@ -179,6 +187,7 @@ func (h *ImportHandler) handleJSONEventImport(w http.ResponseWriter, r *http.Req
179187
"imported_files": 0, // Not applicable in ingest mode
180188
"duration": duration.String(),
181189
"errors": []string{}, // No errors in success path
190+
"warnings": warnings,
182191
}
183192

184193
h.logger.Debug("Writing import response to client")
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package handlers
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"strings"
9+
"testing"
10+
11+
"github.com/moolen/spectre/internal/logging"
12+
"github.com/moolen/spectre/internal/models"
13+
)
14+
15+
type fakeBatchIngestor struct {
16+
batches [][]models.Event
17+
}
18+
19+
func (f *fakeBatchIngestor) ProcessBatch(_ context.Context, events []models.Event) error {
20+
chunk := make([]models.Event, len(events))
21+
copy(chunk, events)
22+
f.batches = append(f.batches, chunk)
23+
return nil
24+
}
25+
26+
func TestImportHandler_JSONImportIncludesWarnings(t *testing.T) {
27+
ingestor := &fakeBatchIngestor{}
28+
handler := NewImportHandler(ingestor, logging.GetLogger("import-handler-test"))
29+
30+
body := strings.NewReader(`{
31+
"kind":"Event",
32+
"apiVersion":"audit.k8s.io/v1",
33+
"auditID":"audit-warning",
34+
"stage":"ResponseComplete",
35+
"stageTimestamp":"2024-01-02T03:04:05Z",
36+
"verb":"update",
37+
"objectRef":{
38+
"resource":"deployments",
39+
"namespace":"default",
40+
"name":"missing-payload",
41+
"apiGroup":"apps",
42+
"apiVersion":"v1"
43+
}
44+
}`)
45+
46+
req := httptest.NewRequest(http.MethodPost, "/v1/storage/import", body)
47+
req.Header.Set("Content-Type", "application/json")
48+
rr := httptest.NewRecorder()
49+
50+
handler.Handle(rr, req)
51+
52+
if rr.Code != http.StatusOK {
53+
t.Fatalf("Handle() status = %d, want %d, body=%s", rr.Code, http.StatusOK, rr.Body.String())
54+
}
55+
56+
var response struct {
57+
Status string `json:"status"`
58+
Warnings []string `json:"warnings"`
59+
}
60+
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
61+
t.Fatalf("failed to decode response: %v", err)
62+
}
63+
64+
if response.Status != "success" {
65+
t.Fatalf("response status = %q, want %q", response.Status, "success")
66+
}
67+
if len(ingestor.batches) != 1 {
68+
t.Fatalf("expected one processed batch, got %d", len(ingestor.batches))
69+
}
70+
if len(ingestor.batches[0]) != 0 {
71+
t.Fatalf("expected warning-only import to process an empty batch, got %d events", len(ingestor.batches[0]))
72+
}
73+
if len(response.Warnings) == 0 {
74+
t.Fatalf("response warnings = %v, want at least one warning", response.Warnings)
75+
}
76+
if !strings.Contains(response.Warnings[0], "missing-payload") {
77+
t.Fatalf("response warnings = %v, want warning mentioning %q", response.Warnings, "missing-payload")
78+
}
79+
}

0 commit comments

Comments
 (0)