From 8eb5dc0fbe3d003d4a23f4ed41bceceadb5bff08 Mon Sep 17 00:00:00 2001 From: Juliano Martinez Date: Tue, 17 Feb 2026 06:23:22 +0100 Subject: [PATCH] feat(auditserver): expose MatchFrame library API --- pkg/auditserver/bench_test.go | 25 ++++++ pkg/auditserver/doc.go | 20 +++++ pkg/auditserver/server.go | 138 +++++++++++++++++++++++---------- pkg/auditserver/server_test.go | 112 ++++++++++++++++++++++++++ 4 files changed, 253 insertions(+), 42 deletions(-) create mode 100644 pkg/auditserver/doc.go diff --git a/pkg/auditserver/bench_test.go b/pkg/auditserver/bench_test.go index c14084a..b89ecef 100644 --- a/pkg/auditserver/bench_test.go +++ b/pkg/auditserver/bench_test.go @@ -31,6 +31,31 @@ func BenchmarkReact(b *testing.B) { } } +func BenchmarkMatchFrame(b *testing.B) { + logger := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelInfo})) + viper.Reset() + viper.Set("rule_groups", []map[string]interface{}{ + { + "name": "rg", + "rules": []string{"Auth.PolicyResults.Allowed == true"}, + "log_file": map[string]interface{}{ + "file_path": "/tmp/test.log", + "max_size": 1, + }, + }, + }) + server, _ := New(logger) + frame := []byte(`{"type":"request","time":"2000-01-01T00:00:00Z","auth":{"policy_results":{"allowed":true}},"request":{},"response":{}}`) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := server.MatchFrame(frame) + if err != nil { + b.Fatal(err) + } + } +} + func BenchmarkShouldLog(b *testing.B) { p, _ := expr.Compile("true", expr.Env(&AuditLog{})) rg := &RuleGroup{CompiledRules: []CompiledRule{{Program: p}}} diff --git a/pkg/auditserver/doc.go b/pkg/auditserver/doc.go new file mode 100644 index 0000000..580cb6c --- /dev/null +++ b/pkg/auditserver/doc.go @@ -0,0 +1,20 @@ +// Package auditserver provides Vault audit log filtering and side-effect processing. +// +// In library mode, callers can construct a server with `New`, then call `MatchFrame` +// to evaluate a raw audit log frame without running a gnet event loop. +// +// Example: +// +// server, err := New(nil) +// if err != nil { +// // handle init error +// } +// result, err := server.MatchFrame([]byte("{\"type\":\"request\",\"time\":\"2024-01-01T00:00:00Z\",\"request\":{\"operation\":\"update\",\"path\":\"secret/data/config\"},\"auth\":{\"policy_results\":{\"allowed\":true}}}")) +// if err != nil { +// // handle parse error +// } +// if result.Matched { +// // result.Log holds the parsed AuditLog +// // result.MatchedGroups lists matched rule group names +// } +package auditserver diff --git a/pkg/auditserver/server.go b/pkg/auditserver/server.go index c738c49..d73eda3 100644 --- a/pkg/auditserver/server.go +++ b/pkg/auditserver/server.go @@ -150,70 +150,124 @@ type AuditServer struct { sideTaskSeq atomic.Uint64 } -func (as *AuditServer) handleFrame(frame []byte) gnet.Action { - // Parse the audit log for rule evaluation +// MatchResult describes the outcome of matching a single audit frame. +// It mirrors the rule-group matching behavior used by the runtime event loop, +// returning the decoded log and any rule groups that matched. +type MatchResult struct { + Matched bool + Log AuditLog + MatchedGroups []string +} + +type matchResult struct { + Matched bool + Log AuditLog + matchedGroupIndexes []int +} + +// MatchFrame evaluates a raw audit log frame against configured rule groups. +// It returns whether any group matched, the decoded audit log, and the names of +// matching rule groups in configured order. +func (as *AuditServer) MatchFrame(frame []byte) (MatchResult, error) { + result, err := as.matchFrame(frame) + if err != nil { + return MatchResult{}, err + } + + matchedGroups := make([]string, 0, len(result.matchedGroupIndexes)) + for _, idx := range result.matchedGroupIndexes { + matchedGroups = append(matchedGroups, as.ruleGroups[idx].Name) + } + + return MatchResult{ + Matched: result.Matched, + Log: result.Log, + MatchedGroups: matchedGroups, + }, nil +} + +func (as *AuditServer) matchFrame(frame []byte) (matchResult, error) { auditLog := auditLogPool.Get().(*AuditLog) - *auditLog = AuditLog{} // reset pooled object + *auditLog = AuditLog{} err := json.Unmarshal(frame, auditLog) if err != nil { - as.logger.Error("Error parsing audit log", "error", err) auditLogPool.Put(auditLog) - return gnet.Close + return matchResult{}, err + } + + var matchedIndexes []int + for idx := range as.ruleGroups { + if as.ruleGroups[idx].shouldLog(auditLog) { + matchedIndexes = append(matchedIndexes, idx) + } + } + result := matchResult{ + Matched: len(matchedIndexes) > 0, + Log: *auditLog, + matchedGroupIndexes: matchedIndexes, } - matched := false + auditLogPool.Put(auditLog) + return result, nil +} + +func (as *AuditServer) handleFrameWithResult(frame []byte, result matchResult) { + var payload []byte var payloadStr string payloadReady := false payloadStrReady := false - // Check each rule group - for _, rg := range as.ruleGroups { - if rg.shouldLog(auditLog) { - matched = true - as.logger.Debug("Matched rule group", "group", rg.Name) + for _, rgIdx := range result.matchedGroupIndexes { + rg := as.ruleGroups[rgIdx] - if rg.Messenger != nil || rg.Forwarder != nil { - if !payloadReady { - payload = append([]byte(nil), frame...) - payloadReady = true - } - if rg.Messenger != nil && !payloadStrReady { - payloadStr = string(payload) - payloadStrReady = true - } - _ = as.enqueueSide(sideTask{ - groupName: rg.Name, - payload: payload, - payloadStr: payloadStr, - messenger: rg.Messenger, - forwarder: rg.Forwarder, - }) + as.logger.Debug("Matched rule group", "group", rg.Name) + + if rg.Messenger != nil || rg.Forwarder != nil { + if !payloadReady { + payload = append([]byte(nil), frame...) + payloadReady = true + } + if rg.Messenger != nil && !payloadStrReady { + payloadStr = string(payload) + payloadStrReady = true } + _ = as.enqueueSide(sideTask{ + groupName: rg.Name, + payload: payload, + payloadStr: payloadStr, + messenger: rg.Messenger, + forwarder: rg.Forwarder, + }) + } - // zero‑copy write to log when possible - if rg.Writer != nil { - if _, err := rg.Writer.Write(frame); err != nil { - as.logger.Error("Failed to write audit log", "group", rg.Name, "error", err) - } + if rg.Writer != nil { + if _, err := rg.Writer.Write(frame); err != nil { + as.logger.Error("Failed to write audit log", "group", rg.Name, "error", err) + } + } else { + if payloadStrReady { + rg.Logger.Print(payloadStr) } else { - if payloadStrReady { - rg.Logger.Print(payloadStr) - } else { - rg.Logger.Print(string(frame)) - } + rg.Logger.Print(string(frame)) } - // TODO(JM):Add a flag to prevent logging to multiple groups - // break } } +} - auditLogPool.Put(auditLog) - - if !matched { +func (as *AuditServer) handleFrame(frame []byte) gnet.Action { + result, err := as.matchFrame(frame) + if err != nil { + as.logger.Error("Error parsing audit log", "error", err) + return gnet.Close + } + if !result.Matched { return gnet.Close } + + as.handleFrameWithResult(frame, result) + return gnet.None } diff --git a/pkg/auditserver/server_test.go b/pkg/auditserver/server_test.go index 8aee81d..3797162 100644 --- a/pkg/auditserver/server_test.go +++ b/pkg/auditserver/server_test.go @@ -269,6 +269,118 @@ func TestAuditServer_React(t *testing.T) { } } +func TestMatchFrame(t *testing.T) { + viper.Reset() + viper.Set("rule_groups", []RuleGroupConfig{ + { + Name: "updates", + Rules: []string{ + `Request.Operation in ["update", "create"] && Request.Path == "secret/data/config" && Auth.PolicyResults.Allowed == true`, + }, + }, + }) + + as, err := New(nil) + require.NoError(t, err) + + t.Run("matches and returns parsed log", func(t *testing.T) { + log := []byte(`{"type":"request","time":"2024-01-01T00:00:00Z","request":{"operation":"update","path":"secret/data/config"},"auth":{"policy_results":{"allowed":true}}}`) + result, err := as.MatchFrame(log) + require.NoError(t, err) + assert.True(t, result.Matched) + assert.Equal(t, "update", result.Log.Request.Operation) + assert.Equal(t, "secret/data/config", result.Log.Request.Path) + assert.Equal(t, []string{"updates"}, result.MatchedGroups) + }) + + t.Run("non-matching returns false", func(t *testing.T) { + log := []byte(`{"type":"request","time":"2024-01-01T00:00:00Z","request":{"operation":"read","path":"secret/data/config"},"auth":{"policy_results":{"allowed":true}}}`) + result, err := as.MatchFrame(log) + require.NoError(t, err) + assert.False(t, result.Matched) + assert.Empty(t, result.MatchedGroups) + }) + + t.Run("invalid json returns error", func(t *testing.T) { + _, err := as.MatchFrame([]byte(`{"invalid":`)) + require.Error(t, err) + }) +} + +func TestMatchFrame_UsesMatcherRulesForMatchGroups(t *testing.T) { + viper.Reset() + viper.Set("rule_groups", []RuleGroupConfig{ + { + Name: "only_updates", + Rules: []string{`Request.Operation == "update" && Auth.PolicyResults.Allowed == true`}, + }, + { + Name: "all_reads", + Rules: []string{`Request.Operation == "read" && Auth.PolicyResults.Allowed == true`}, + }, + }) + + as, err := New(nil) + require.NoError(t, err) + + result, err := as.MatchFrame([]byte(`{"type":"request","time":"2024-01-01T00:00:00Z","request":{"operation":"read","path":"secret/data/config"},"auth":{"policy_results":{"allowed":true}}}`)) + require.NoError(t, err) + assert.True(t, result.Matched) + assert.Equal(t, []string{"all_reads"}, result.MatchedGroups) +} + +func TestMatchFrame_ReportsAllMatchingGroupsInOrder(t *testing.T) { + viper.Reset() + viper.Set("rule_groups", []RuleGroupConfig{ + { + Name: "first", + Rules: []string{`Request.Operation == "read" && Auth.PolicyResults.Allowed == true`}, + }, + { + Name: "second", + Rules: []string{`Request.Path == "secret/data/config" && Auth.PolicyResults.Allowed == true`}, + }, + { + Name: "third", + Rules: []string{`Request.Operation == "read" && Request.Path == "secret/data/config" && Auth.PolicyResults.Allowed == true`}, + }, + }) + + as, err := New(nil) + require.NoError(t, err) + + result, err := as.MatchFrame([]byte(`{"type":"request","time":"2024-01-01T00:00:00Z","request":{"operation":"read","path":"secret/data/config"},"auth":{"policy_results":{"allowed":true}}}`)) + require.NoError(t, err) + assert.True(t, result.Matched) + assert.Equal(t, []string{"first", "second", "third"}, result.MatchedGroups) +} + +func ExampleAuditServer_MatchFrame() { + viper.Reset() + defer viper.Reset() + + viper.Set("rule_groups", []RuleGroupConfig{ + { + Name: "writes", + Rules: []string{`Request.Operation == "update" && Auth.PolicyResults.Allowed == true`}, + }, + }) + + as, err := New(nil) + if err != nil { + panic(err) + } + + result, err := as.MatchFrame([]byte(`{"type":"request","time":"2024-01-01T00:00:00Z","request":{"operation":"update","path":"secret/data/config"},"auth":{"policy_results":{"allowed":true}}}`)) + if err != nil { + panic(err) + } + + fmt.Printf("matched=%v groups=%v\n", result.Matched, result.MatchedGroups) + // Output: + // matched=true groups=[writes] +} + func TestNew(t *testing.T) { // Define rule group configurations with an invalid rule and messenger type ruleGroupConfigs := []RuleGroupConfig{