Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions pkg/auditserver/bench_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}}}
Expand Down
20 changes: 20 additions & 0 deletions pkg/auditserver/doc.go
Original file line number Diff line number Diff line change
@@ -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
138 changes: 96 additions & 42 deletions pkg/auditserver/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
112 changes: 112 additions & 0 deletions pkg/auditserver/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down