From a034a338fd3dd0fa86ce605d8e3da902946ba240 Mon Sep 17 00:00:00 2001 From: rabbitstack Date: Fri, 20 Feb 2026 19:32:50 +0100 Subject: [PATCH] fix(rule_engine): Enforce temporal monotonicity Enforces temporal monotonicity by comparing the timestamp of the last matched partial in the previous slot. --- pkg/rules/engine_test.go | 2 +- pkg/rules/sequence.go | 93 ++++++++++++++++++++++---------------- pkg/rules/sequence_test.go | 88 ++++++++++++++++++++---------------- 3 files changed, 102 insertions(+), 81 deletions(-) diff --git a/pkg/rules/engine_test.go b/pkg/rules/engine_test.go index 9cc32dac1..ca2046977 100644 --- a/pkg/rules/engine_test.go +++ b/pkg/rules/engine_test.go @@ -299,7 +299,7 @@ func TestRunSequenceRuleWithPsUUIDLink(t *testing.T) { e2 := &event.Event{ Seq: 2, Type: event.CreateFile, - Timestamp: time.Now(), + Timestamp: time.Now().Add(time.Second), Name: "CreateFile", Tid: 2484, PID: uint32(os.Getpid()), diff --git a/pkg/rules/sequence.go b/pkg/rules/sequence.go index 6346e6dc8..97887e116 100644 --- a/pkg/rules/sequence.go +++ b/pkg/rules/sequence.go @@ -116,6 +116,10 @@ type sequenceState struct { // smu guards the states map smu sync.RWMutex + // lastMatch is the timestamp of the last matched event. + // The purpose is to enforce temporal monotonicity + lastMatch time.Time + psnap ps.Snapshotter } @@ -370,6 +374,7 @@ func (s *sequenceState) clear() { s.spanDeadlines = make(map[fsm.State]*time.Timer) s.isPartialsBreached.Store(false) partialsPerSequence.Delete(s.name) + s.lastMatch = time.Time{} } func (s *sequenceState) clearLocked() { @@ -391,15 +396,17 @@ func (s *sequenceState) next(seqID int) bool { if seqID == 0 { return true } + var next bool s.smu.RLock() defer s.smu.RUnlock() - for n := 0; n < seqID; n++ { + for n := range seqID { next = s.states[n] if !next { break } } + return next && !s.inDeadline.Load() && !s.inExpired.Load() } @@ -454,53 +461,59 @@ func (s *sequenceState) runSequence(e *event.Event) bool { continue } - // prevent running the filter if the expression - // can't be matched against the current event - if !expr.IsEvaluable(e) { + s.mu.RLock() + matches := expr.IsEvaluable(e) && s.filter.RunSequence(e, i, s.partials, false) + s.mu.RUnlock() + + if !matches { continue } - s.mu.RLock() - matches := s.filter.RunSequence(e, i, s.partials, false) - s.mu.RUnlock() + // enforce temporal monotonicity check for ordered sequences + if !s.seq.IsUnordered && !s.lastMatch.IsZero() && !e.Timestamp.After(s.lastMatch) { + // this event is older than or equal to the previous matched slot + continue + } // append the partial and transition state machine - if matches { - s.addPartial(i, e, false) - err := s.matchTransition(i, e) - if err != nil { - matchTransitionErrors.Add(1) - log.Warnf("match transition failure: %v", err) - } - // now try to match all pending out-of-order - // events from downstream sequence slots if - // the previous match hasn't reached terminal - // state - if s.seq.IsUnordered && s.currentState() != sequenceTerminalState { - s.mu.RLock() - for seqID := range s.partials { - for _, evt := range s.partials[seqID] { - if !evt.ContainsMeta(event.RuleSequenceOOOKey) { - continue - } - // try to initialize process state before evaluating the event - if evt.PS == nil { - _, evt.PS = s.psnap.Find(evt.PID) - } - matches = s.filter.RunSequence(evt, seqID, s.partials, false) - // transition the state machine - if matches { - err := s.matchTransition(seqID, evt) - if err != nil { - matchTransitionErrors.Add(1) - log.Warnf("out of order match transition failure: %v", err) - } - evt.RemoveMeta(event.RuleSequenceOOOKey) - } + s.addPartial(i, e, false) + err := s.matchTransition(i, e) + if err != nil { + matchTransitionErrors.Add(1) + log.Warnf("match transition failure: %v", err) + } + if !s.seq.IsUnordered { + s.lastMatch = e.Timestamp + } + // now try to match all pending out-of-order + // events from downstream sequence slots if + // the previous match hasn't reached terminal + // state + if s.seq.IsUnordered && s.currentState() != sequenceTerminalState { + s.mu.RLock() + for seqID := range s.partials { + for _, evt := range s.partials[seqID] { + if !evt.ContainsMeta(event.RuleSequenceOOOKey) { + continue + } + // try to initialize process state before evaluating the event + if evt.PS == nil { + _, evt.PS = s.psnap.Find(evt.PID) + } + matches = s.filter.RunSequence(evt, seqID, s.partials, false) + if !matches { + continue } + // transition the state machine + err := s.matchTransition(seqID, evt) + if err != nil { + matchTransitionErrors.Add(1) + log.Warnf("out of order match transition failure: %v", err) + } + evt.RemoveMeta(event.RuleSequenceOOOKey) } - s.mu.RUnlock() } + s.mu.RUnlock() } // if both the terminal state is reached and the partials diff --git a/pkg/rules/sequence_test.go b/pkg/rules/sequence_test.go index 4517f953e..bf3966157 100644 --- a/pkg/rules/sequence_test.go +++ b/pkg/rules/sequence_test.go @@ -58,10 +58,11 @@ func TestSequenceState(t *testing.T) { assert.Equal(t, "evt.name = CreateProcess AND ps.name = cmd.exe", ss.expr(ss.initialState)) e1 := &event.Event{ - Type: event.CreateProcess, - Name: "CreateProcess", - Tid: 2484, - PID: 859, + Type: event.CreateProcess, + Name: "CreateProcess", + Tid: 2484, + PID: 859, + Timestamp: time.Now(), PS: &pstypes.PS{ Name: "cmd.exe", Exe: "C:\\Windows\\system32\\svchost.exe", @@ -71,6 +72,22 @@ func TestSequenceState(t *testing.T) { params.ProcessName: {Name: params.ProcessName, Type: params.AnsiString, Value: "powershell.exe"}, }, } + + e2 := &event.Event{ + Type: event.CreateFile, + Name: "CreateFile", + Tid: 2484, + PID: 4143, + Timestamp: time.Now().Add(time.Second * 5), + PS: &pstypes.PS{ + Name: "cmd.exe", + Exe: "C:\\Windows\\system32\\svchost.exe", + }, + Params: event.Params{ + params.FilePath: {Name: params.FilePath, Type: params.UnicodeString, Value: "C:\\Temp\\dropper"}, + }, + } + require.True(t, ss.next(0)) require.False(t, ss.next(1)) require.NoError(t, ss.matchTransition(0, e1)) @@ -82,19 +99,17 @@ func TestSequenceState(t *testing.T) { assert.False(t, ss.isInitialState()) assert.Equal(t, "evt.name = CreateFile AND file.path ICONTAINS temp", ss.expr(ss.currentState())) - e2 := &event.Event{ - Type: event.CreateFile, - Name: "CreateFile", - Tid: 2484, - PID: 4143, - PS: &pstypes.PS{ - Name: "cmd.exe", - Exe: "C:\\Windows\\system32\\svchost.exe", - }, + e3 := &event.Event{ + Type: event.CreateProcess, + Name: "CreateProcess", + Timestamp: time.Now().Add(time.Second * 10), + Tid: 2484, + PID: 4143, Params: event.Params{ - params.FilePath: {Name: params.FilePath, Type: params.UnicodeString, Value: "C:\\Temp\\dropper"}, + params.Exe: {Name: params.Exe, Type: params.UnicodeString, Value: "C:\\Temp\\dropper.exe"}, }, } + // can't go to the next transitions as the expr hasn't matched require.False(t, ss.next(2)) require.NoError(t, ss.matchTransition(1, e2)) @@ -108,15 +123,6 @@ func TestSequenceState(t *testing.T) { assert.Equal(t, 2, ss.currentState()) assert.Equal(t, "evt.name = CreateProcess", ss.expr(ss.currentState())) - e3 := &event.Event{ - Type: event.CreateProcess, - Name: "CreateProcess", - Tid: 2484, - PID: 4143, - Params: event.Params{ - params.Exe: {Name: params.Exe, Type: params.UnicodeString, Value: "C:\\Temp\\dropper.exe"}, - }, - } require.NoError(t, ss.matchTransition(2, e3)) ss.addPartial(2, e3, false) @@ -214,7 +220,7 @@ func TestSimpleSequence(t *testing.T) { }, { Type: event.CreateFile, Name: "CreateFile", - Timestamp: time.Now(), + Timestamp: time.Now().Add(time.Second), Tid: 2484, PID: 859, Category: event.File, @@ -242,7 +248,7 @@ func TestSimpleSequence(t *testing.T) { }, { Type: event.CreateFile, Name: "CreateFile", - Timestamp: time.Now(), + Timestamp: time.Now().Add(time.Second), Tid: 2484, PID: 859, Category: event.File, @@ -410,7 +416,7 @@ func TestUnconstrainedSequenceMatches(t *testing.T) { e2 := &event.Event{ Seq: 21, Type: event.CreateProcess, - Timestamp: time.Now().Add(time.Second), + Timestamp: time.Now().Add(time.Second * 2), Name: "CreateProcess", Tid: 2484, PID: 1859, @@ -430,7 +436,7 @@ func TestUnconstrainedSequenceMatches(t *testing.T) { e3 := &event.Event{ Type: event.CreateFile, Seq: 25, - Timestamp: time.Now().Add(time.Second * time.Duration(2)), + Timestamp: time.Now().Add(time.Second * 3), Name: "CreateFile", Tid: 2484, PID: 3859, @@ -493,7 +499,7 @@ func TestSimpleSequenceDeadline(t *testing.T) { e2 := &event.Event{ Type: event.CreateFile, - Timestamp: time.Now(), + Timestamp: time.Now().Add(time.Millisecond * 200), Name: "CreateFile", Tid: 2484, PID: 859, @@ -563,7 +569,7 @@ func TestSequenceMultiLinks(t *testing.T) { e2 := &event.Event{ Type: event.CreateFile, - Timestamp: time.Now(), + Timestamp: time.Now().Add(time.Second), Name: "CreateFile", Tid: 2484, PID: 859, @@ -856,7 +862,7 @@ func TestSequenceExpire(t *testing.T) { { Seq: 2, Type: event.CreateProcess, - Timestamp: time.Now(), + Timestamp: time.Now().Add(time.Second), Category: event.Process, Name: "CreateProcess", Tid: 2484, @@ -1029,11 +1035,12 @@ func TestSequenceBoundFieldsWithFunctions(t *testing.T) { ss := newSequenceState(f, c, new(ps.SnapshotterMock)) e1 := &event.Event{ - Type: event.CreateFile, - Name: "CreateFile", - Category: event.File, - Tid: 2484, - PID: 859, + Type: event.CreateFile, + Name: "CreateFile", + Category: event.File, + Timestamp: time.Now(), + Tid: 2484, + PID: 859, PS: &pstypes.PS{ Name: "cmd.exe", Exe: "C:\\Windows\\system32\\cmd.exe", @@ -1045,11 +1052,12 @@ func TestSequenceBoundFieldsWithFunctions(t *testing.T) { } e2 := &event.Event{ - Type: event.RegSetValue, - Name: "RegSetValue", - Category: event.Registry, - Tid: 2484, - PID: 859, + Type: event.RegSetValue, + Name: "RegSetValue", + Category: event.Registry, + Timestamp: time.Now().Add(time.Millisecond * 5), + Tid: 2484, + PID: 859, PS: &pstypes.PS{ Name: "cmd.exe", Exe: "C:\\Windows\\system32\\cmd.exe",