From 08f525c3ce32292f95d550b53241a716b6d1afab Mon Sep 17 00:00:00 2001 From: rabbitstack Date: Sun, 7 Jun 2026 19:29:20 +0200 Subject: [PATCH 01/15] feat(eventsource): Smart event filtering with approvers CreateFile and RegOpenKey are amongst most voluminous events in the system. We pay for the allocation cost because the raw event record is transformed into typed Event structure. Additionally, there is also a processing penalty at the rule evaluation time. With approvers we can accept or reject events at the early stage of event processing, reducing both the pressure on the allocator and the rule engine as well. --- internal/etw/approvers/approver.go | 161 ++++++++++ internal/etw/approvers/approver_test.go | 388 ++++++++++++++++++++++++ internal/etw/approvers/fs.go | 158 ++++++++++ internal/etw/approvers/fs_test.go | 328 ++++++++++++++++++++ internal/etw/approvers/process.go | 85 ++++++ internal/etw/approvers/process_test.go | 248 +++++++++++++++ internal/etw/approvers/registry.go | 102 +++++++ internal/etw/approvers/registry_test.go | 288 ++++++++++++++++++ 8 files changed, 1758 insertions(+) create mode 100644 internal/etw/approvers/approver.go create mode 100644 internal/etw/approvers/approver_test.go create mode 100644 internal/etw/approvers/fs.go create mode 100644 internal/etw/approvers/fs_test.go create mode 100644 internal/etw/approvers/process.go create mode 100644 internal/etw/approvers/process_test.go create mode 100644 internal/etw/approvers/registry.go create mode 100644 internal/etw/approvers/registry_test.go diff --git a/internal/etw/approvers/approver.go b/internal/etw/approvers/approver.go new file mode 100644 index 000000000..1e7dc63ab --- /dev/null +++ b/internal/etw/approvers/approver.go @@ -0,0 +1,161 @@ +/* + * Copyright 2020-present by Nedim Sabic Sabic + * https://www.fibratus.io + * All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package approvers + +import ( + "path/filepath" + "strings" + + "github.com/rabbitstack/fibratus/internal/etw/processors" + "github.com/rabbitstack/fibratus/pkg/config" + "github.com/rabbitstack/fibratus/pkg/filter/ql" + "github.com/rabbitstack/fibratus/pkg/ps" + "github.com/rabbitstack/fibratus/pkg/sys/etw" + "github.com/rabbitstack/fibratus/pkg/util/wildcard" +) + +// Approver represents the contract that every approver must satisfy. +type Approver interface { + // Approve receives a raw event recored. It may return + // a new event record, and always returns a flag to + // indicate if the event is approved or rejected. + Approve(r *etw.EventRecord) (*etw.EventRecord, bool) +} + +// Approvers is the registry for all known approvers. +type Approvers struct { + approvers []Approver + + fs *fs +} + +// New creates a new approvers set. +func New(psnap ps.Snapshotter, r *config.RulesCompileResult, processors *processors.Chain) Approvers { + p := Approvers{ + approvers: make([]Approver, 0), + } + + p.fs = newFSApprover(r, processors).(*fs) + + p.approvers = append(p.approvers, p.fs) + if r != nil { + p.approvers = append(p.approvers, newRegistryApprover(r)) + } + if r != nil { + p.approvers = append(p.approvers, newProcApprover(psnap, r)) + } + + return p +} + +// Approve renders a verdict about the event record. +// It evalutes available approvers against the event +// and if it satisifes the main condition for rule +// assertion, this method returns true. Otherwise, it +// returns false and instructs the consumer to drop the +// event record. +func (p *Approvers) Approve(r *etw.EventRecord) (*etw.EventRecord, bool) { + if len(p.approvers) == 0 { + return r, true + } + + rec := r + for _, approver := range p.approvers { + e, ok := approver.Approve(rec) + if !ok { + return rec, false + } + rec = e + } + + return rec, true +} + +// Cleanup housekeeps approvers state. +func (p *Approvers) Cleanup(r *etw.EventRecord) { + p.fs.cleanup(r) +} + +// approver contains the base logic any approver can consume. +type approver struct { + r *config.RulesCompileResult +} + +func (p *approver) approvePath(path string) bool { + return p.matchPredicate(p.r.Approvers.Paths, path) +} + +func (p *approver) approveBasename(path string) bool { + return p.matchPredicate(p.r.Approvers.Bases, filepath.Base(path)) +} + +func (p *approver) approveExtension(path string) bool { + return p.matchPredicate(p.r.Approvers.Extensions, filepath.Ext(path)) +} + +func (p *approver) approveKey(key string) bool { + return p.matchPredicate(p.r.Approvers.Keys, key) +} + +func (p *approver) approveExecutable(exe string) bool { + return p.matchPredicate(p.r.Approvers.Executables, exe) +} + +func (*approver) matchPredicate(m map[string][]string, v string) bool { + s := strings.ToLower(v) + for op, patterns := range m { + for _, pattern := range patterns { + switch op { + case ql.IMatches.String(): + if wildcard.Match(strings.ToLower(pattern), s) { + return true + } + case ql.Matches.String(): + if wildcard.Match(pattern, v) { + return true + } + case ql.IContains.String(): + if strings.Contains(s, strings.ToLower(pattern)) { + return true + } + case ql.Contains.String(): + if strings.Contains(v, pattern) { + return true + } + case ql.IEq.String(), ql.IIn.String(): + if s == strings.ToLower(pattern) { + return true + } + case ql.Eq.String(), ql.In.String(): + if v == pattern { + return true + } + case ql.IStartswith.String(): + return strings.HasPrefix(s, pattern) + case ql.Startswith.String(): + return strings.HasPrefix(v, pattern) + case ql.IEndswith.String(): + return strings.HasSuffix(s, pattern) + case ql.Endswith.String(): + return strings.HasSuffix(v, pattern) + } + } + } + return false +} diff --git a/internal/etw/approvers/approver_test.go b/internal/etw/approvers/approver_test.go new file mode 100644 index 000000000..8a61dbc47 --- /dev/null +++ b/internal/etw/approvers/approver_test.go @@ -0,0 +1,388 @@ +/* + * Copyright 2020-present by Nedim Sabic Sabic + * https://www.fibratus.io + * All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package approvers + +import ( + "testing" + "unsafe" + + "github.com/rabbitstack/fibratus/pkg/config" + "github.com/rabbitstack/fibratus/pkg/event" + "github.com/rabbitstack/fibratus/pkg/ps" + pstypes "github.com/rabbitstack/fibratus/pkg/ps/types" + "github.com/rabbitstack/fibratus/pkg/sys/etw" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "golang.org/x/sys/windows" +) + +func makeRecord(providerID windows.GUID, opcode uint8, eventID uint16, buf []byte) *etw.EventRecord { + b := make([]byte, len(buf)) + copy(b, buf) + r := &etw.EventRecord{} + r.Header.ProviderID = providerID + r.Header.EventDescriptor.Opcode = opcode + r.Header.EventDescriptor.ID = eventID + r.BufferLen = uint16(len(b)) + if len(b) > 0 { + r.Buffer = uintptr(unsafe.Pointer(&b[0])) + } + return r +} + +func TestApproversNoApprovers(t *testing.T) { + p := Approvers{approvers: make([]Approver, 0)} + r := &etw.EventRecord{} + rec, approved := p.Approve(r) + assert.True(t, approved) + assert.Equal(t, r, rec) +} + +func TestApproversChainPassesEnrichedRecord(t *testing.T) { + psnap := &ps.SnapshotterMock{} + rules := &config.RulesCompileResult{ + Approvers: config.Approvers{ + Paths: map[string][]string{ + "IMATCHES": {`C:\Windows\*`}, + }, + }, + } + + p := New(psnap, rules, nil) + + r := &etw.EventRecord{} + r.Header.ProviderID = etw.WindowsKernelProcessGUID + rec, approved := p.Approve(r) + assert.True(t, approved) + assert.Equal(t, r, rec) +} + +func TestApproversFirstApproverRejectsShortCircuits(t *testing.T) { + callCount := 0 + first := &mockApprover{fn: func(r *etw.EventRecord) (*etw.EventRecord, bool) { + callCount++ + return r, false + }} + second := &mockApprover{fn: func(r *etw.EventRecord) (*etw.EventRecord, bool) { + callCount++ + return r, true + }} + + p := Approvers{approvers: []Approver{first, second}} + r := &etw.EventRecord{} + + _, approved := p.Approve(r) + assert.False(t, approved) + assert.Equal(t, 1, callCount, "second approver should not be called") +} + +func TestApproversCleanupDelegatesToFS(t *testing.T) { + psnap := &ps.SnapshotterMock{} + p := New(psnap, nil, nil) + + // enqueue a CreateFile + cr := createFileRecord(t, createBuf) + p.Approve(cr) + assert.Equal(t, 1, len(p.fs.irps)) + + // simulate consumer calling cleanup with the returned CreateFile record + irp := p.fs.irps[*(*uint64)(unsafe.Pointer(&createBuf[0]))] + p.Cleanup(irp.rec) + assert.Equal(t, 0, len(p.fs.irps)) +} + +func TestApproversFileEventFullFlow(t *testing.T) { + psnap := &ps.SnapshotterMock{} + p := New(psnap, nil, nil) + + // CreateFile is suppressed and stored + cr := createFileRecord(t, createBuf) + _, approved := p.Approve(cr) + assert.False(t, approved, "CreateFile should be put in queue") + assert.Equal(t, 1, len(p.fs.irps)) + + // FileOpEnd with matching IRP releases stored CreateFile + foe := buildMatchingFileOpEnd(t, createBuf, uint64(windows.FILE_CREATE)) + rec, approved := p.Approve(foe) + assert.True(t, approved, "Pending CreateFile should be approved") + assert.Equal(t, event.CreateFileID, rec.Header.EventDescriptor.Opcode, + "should return stored CreateFile record") + assert.NotNil(t, rec.ExtendedData, "extended data should be attached") +} + +func TestApproversFileEventWithRulesApproved(t *testing.T) { + rules := &config.RulesCompileResult{ + Approvers: config.Approvers{ + Paths: map[string][]string{ + "ICONTAINS": {`Windows\AppCompat`}, + }, + }, + } + + psnap := &ps.SnapshotterMock{} + p := New(psnap, rules, nil) + + cr := createFileRecord(t, createBuf) + p.Approve(cr) + assert.Equal(t, 1, len(p.fs.irps)) + + foe := buildMatchingFileOpEnd(t, createBuf, uint64(windows.FILE_OPEN)) + rec, approved := p.Approve(foe) + assert.True(t, approved, "Pending CreateFile should be approved") + assert.Equal(t, event.CreateFileID, rec.Header.EventDescriptor.Opcode, + "should return stored CreateFile record") + assert.NotNil(t, rec.ExtendedData, "extended data should be attached") +} + +func TestApproversFileEventWithRulesRejected(t *testing.T) { + rules := &config.RulesCompileResult{ + Approvers: config.Approvers{ + Extensions: map[string][]string{ + "IN": {".exe", ".cpl"}, + }, + }, + } + + psnap := &ps.SnapshotterMock{} + p := New(psnap, rules, nil) + + cr := createFileRecord(t, createBuf) + p.Approve(cr) + assert.Equal(t, 1, len(p.fs.irps)) + + foe := buildMatchingFileOpEnd(t, createBuf, uint64(windows.FILE_OPEN)) + _, approved := p.Approve(foe) + assert.False(t, approved, "Pending CreateFile shouldn't be approved") +} + +func TestApproversRegistryEventNoRulesApprovesAll(t *testing.T) { + psnap := &ps.SnapshotterMock{} + + p := New(psnap, nil, nil) + r := makeRecord(event.RegistryEventGUID, event.RegOpenKeyID, 0, regOpenKeyBuf) + + _, approved := p.Approve(r) + assert.True(t, approved) +} + +func TestApproversRegistryEventWithRulesApproved(t *testing.T) { + psnap := &ps.SnapshotterMock{} + rules := &config.RulesCompileResult{ + Approvers: config.Approvers{ + Keys: map[string][]string{ + "IMATCHES": {`HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\*`}, + }, + }, + } + + p := New(psnap, rules, nil) + + k := makeRecord(event.RegistryEventGUID, event.RegCreateKCBID, 0, regCreateKCBBuf) + r := makeRecord(event.RegistryEventGUID, event.RegOpenKeyID, 0, regOpenKeyBuf) + + // first send the RegCreateKCB event to store the KCB mapping + _, approved := p.Approve(k) + assert.True(t, approved) + + _, approved = p.Approve(r) + assert.True(t, approved) +} + +func TestApproversRegistryEventWithRulesRejected(t *testing.T) { + psnap := &ps.SnapshotterMock{} + rules := &config.RulesCompileResult{ + Approvers: config.Approvers{ + Keys: map[string][]string{ + "IMATCHES": {`HKEY_LOCAL_MACHINE\SYSTEM\*`}, + }, + }, + } + + p := New(psnap, rules, nil) + + k := makeRecord(event.RegistryEventGUID, event.RegCreateKCBID, 0, regCreateKCBBuf) + r := makeRecord(event.RegistryEventGUID, event.RegOpenKeyID, 0, regOpenKeyBuf) + + p.Approve(k) + + _, approved := p.Approve(r) + assert.False(t, approved) +} + +func TestApproversProcEventNoRulesApprovesAll(t *testing.T) { + psnap := &ps.SnapshotterMock{} + p := New(psnap, nil, nil) + + buf := make([]byte, 4) + *(*uint32)(unsafe.Pointer(&buf[0])) = uint32(1234) + r := makeRecord(event.AuditAPIEventGUID, 0, event.OpenProcessID, buf) + r.Header.ProcessID = 1234 + + _, approved := p.Approve(r) + assert.True(t, approved) + psnap.AssertNotCalled(t, "Find", mock.Anything) +} + +func TestApproversProcEventWithRulesApproved(t *testing.T) { + psnap := &ps.SnapshotterMock{} + rules := &config.RulesCompileResult{ + Approvers: config.Approvers{ + Executables: map[string][]string{ + "IMATCHES": {`?:\Windows\System32\*`}, + }, + }, + } + + p := New(psnap, rules, nil) + + const pid = uint32(1234) + psnap.On("Find", pid).Return(true, &pstypes.PS{ + Exe: `C:\Windows\System32\svchost.exe`, + }) + + buf := make([]byte, 4) + *(*uint32)(unsafe.Pointer(&buf[0])) = pid + r := makeRecord(event.AuditAPIEventGUID, 0, event.OpenProcessID, buf) + r.Header.ProcessID = pid + + _, approved := p.Approve(r) + assert.True(t, approved) +} + +func TestApproversProcEventWithRulesRejected(t *testing.T) { + psnap := &ps.SnapshotterMock{} + rules := &config.RulesCompileResult{ + Approvers: config.Approvers{ + Executables: map[string][]string{ + "IMATCHES": {`C:\Windows\System32\*`}, + }, + }, + } + p := New(psnap, rules, nil) + + const pid = uint32(5678) + psnap.On("Find", pid).Return(true, &pstypes.PS{ + Exe: `C:\Users\Administrator\cmd.exe`, + }) + + buf := make([]byte, 4) + *(*uint32)(unsafe.Pointer(&buf[0])) = pid + r := makeRecord(event.AuditAPIEventGUID, 0, event.OpenProcessID, buf) + r.Header.ProcessID = pid + + _, approved := p.Approve(r) + assert.False(t, approved) +} + +func TestApproversMatchPredicate(t *testing.T) { + a := &approver{} + tests := []struct { + name string + m map[string][]string + val string + want bool + }{ + { + name: "imatches wildcard hit", + m: map[string][]string{"IMATCHES": {`C:\Windows\*`}}, + val: `C:\Windows\System32\ntdll.dll`, + want: true, + }, + { + name: "imatches wildcard miss", + m: map[string][]string{"IMATCHES": {`C:\Windows\*`}}, + val: `C:\Users\Administrator\cmd.exe`, + want: false, + }, + { + name: "imatches case insensitive", + m: map[string][]string{"IMATCHES": {`c:\windows\*`}}, + val: `C:\WINDOWS\System32\ntdll.dll`, + want: true, + }, + { + name: "icontains hit", + m: map[string][]string{"ICONTAINS": {"svchost"}}, + val: `C:\Windows\System32\svchost.exe`, + want: true, + }, + { + name: "icontains case insensitive", + m: map[string][]string{"ICONTAINS": {"SVCHOST"}}, + val: `C:\Windows\System32\svchost.exe`, + want: true, + }, + { + name: "icontains miss", + m: map[string][]string{"ICONTAINS": {"evil"}}, + val: `C:\Windows\System32\svchost.exe`, + want: false, + }, + { + name: "eq hit", + m: map[string][]string{"=": {`C:\Windows\System32\svchost.exe`}}, + val: `C:\Windows\System32\svchost.exe`, + want: true, + }, + { + name: "eq miss", + m: map[string][]string{"=": {`C:\Windows\System32\svchost.exe`}}, + val: `C:\Windows\System32\lsass.exe`, + want: false, + }, + { + name: "ieq hit", + m: map[string][]string{"~=": {`C:\Windows\System32\svchost.exe`}}, + val: `C:\Windows\System32\svchost.exe`, + want: true, + }, + { + name: "multiple patterns first hits", + m: map[string][]string{ + "IMATCHES": {`C:\Windows\*`, `C:\System32\*`}, + }, + val: `C:\Windows\notepad.exe`, + want: true, + }, + { + name: "multiple operators one hits", + m: map[string][]string{ + "IMATCHES": {`C:\System32\*`}, + "ICONTAINS": {"notepad"}, + }, + val: `C:\Windows\notepad.exe`, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := a.matchPredicate(tt.m, tt.val) + assert.Equal(t, tt.want, got) + }) + } +} + +// mockApprover is a simple test double for the Approver interface +type mockApprover struct { + fn func(r *etw.EventRecord) (*etw.EventRecord, bool) +} + +func (m *mockApprover) Approve(r *etw.EventRecord) (*etw.EventRecord, bool) { + return m.fn(r) +} diff --git a/internal/etw/approvers/fs.go b/internal/etw/approvers/fs.go new file mode 100644 index 000000000..34f3681c5 --- /dev/null +++ b/internal/etw/approvers/fs.go @@ -0,0 +1,158 @@ +/* + * Copyright 2020-present by Nedim Sabic Sabic + * https://www.fibratus.io + * All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package approvers + +import ( + "expvar" + + "github.com/rabbitstack/fibratus/internal/etw/processors" + "github.com/rabbitstack/fibratus/pkg/config" + "github.com/rabbitstack/fibratus/pkg/event" + devmapper "github.com/rabbitstack/fibratus/pkg/fs" + "github.com/rabbitstack/fibratus/pkg/sys/etw" + "golang.org/x/sys/windows" +) + +var ( + fsApproverApprovals = expvar.NewInt("approver.fs.approvals") + fsApproverRejections = expvar.NewInt("approver.fs.rejections") +) + +// irp acts as a scratch area for the pending IRP request +// that is used as a signal to promote the file operation. +// The memory buffer backing the event record must outlive +// event processors scope. +type irp struct { + rec *etw.EventRecord + buf []byte // keeps the data buffer alive + items *etw.FileExtendedDataItems // keeps the extended data items alive +} + +// fs is the file system approver that accepts or discards +// file events as soon as they are pulled from the session +// buffers. +type fs struct { + approver + + irps map[uint64]irp + + processors *processors.Chain + approvers []func(string) bool +} + +func newFSApprover(r *config.RulesCompileResult, processors *processors.Chain) Approver { + fs := &fs{ + approver: approver{ + r: r, + }, + approvers: make([]func(string) bool, 0), + processors: processors, + irps: make(map[uint64]irp), + } + + if r != nil && len(r.Approvers.Paths) > 0 { + fs.approvers = append(fs.approvers, fs.approvePath) + } + if r != nil && len(r.Approvers.Extensions) > 0 { + fs.approvers = append(fs.approvers, fs.approveExtension) + } + if r != nil && len(r.Approvers.Bases) > 0 { + fs.approvers = append(fs.approvers, fs.approveBasename) + } + + return fs +} + +func (f *fs) Approve(r *etw.EventRecord) (*etw.EventRecord, bool) { + if r.Header.ProviderID != event.FileEventGUID { + return r, true + } + + // enqueue in flight CreateFile event + if r.Header.EventDescriptor.Opcode == event.CreateFileID { + rec, buf := r.Copy() + f.irps[r.ReadUint64(0)] = irp{rec: rec, buf: buf} + return r, false + } + + if r.Header.EventDescriptor.Opcode == event.FileOpEndID { + disposition := r.ReadUint64(8) + + // the file operation finalization event arrived but not + // for our previously queued CreateFile event as the extra + // file information doesn't match any of the known file + // dispositions flags. We can safely drop the event + if disposition > windows.FILE_MAXIMUM_DISPOSITION { + return r, false + } + + i := r.ReadUint64(0) + irp, ok := f.irps[i] + if !ok { + return r, false + } + + rec := irp.rec + status := r.ReadUint32(16) + // if the I/O status is different than file open + // or the rules compilation result is not present + // we'll allow events flow downstream processors + if f.r == nil || disposition != windows.FILE_OPEN { + irp.items = etw.AppendEventHeaderFileExtendedDataItems(rec, disposition, status) + f.irps[i] = irp + return rec, true + } + + // evaluate against available approvers + var approved bool + path := devmapper.GetDevMapper().Convert(rec.ConsumeUTF16String(32)) + for _, approver := range f.approvers { + if approver(path) { + approved = true + break + } + } + if !approved { + // the event is rejected by approvers. Make sure to + // evict the enqueded StackWalk event produced by the + // CreateFile operation + delete(f.irps, i) + fsApproverRejections.Add(1) + stackID := uint64(rec.Header.ProcessID + rec.Header.ThreadID) + if f.processors != nil { + f.processors.DequeueStackwalk(stackID) + } + return rec, false + } + + fsApproverApprovals.Add(1) + irp.items = etw.AppendEventHeaderFileExtendedDataItems(rec, disposition, status) + f.irps[i] = irp + return rec, true + } + + return r, true +} + +func (f *fs) cleanup(r *etw.EventRecord) { + if r.Header.ProviderID == event.FileEventGUID && r.Header.EventDescriptor.Opcode == event.CreateFileID { + i := r.ReadUint64(0) + delete(f.irps, i) + } +} diff --git a/internal/etw/approvers/fs_test.go b/internal/etw/approvers/fs_test.go new file mode 100644 index 000000000..78b471266 --- /dev/null +++ b/internal/etw/approvers/fs_test.go @@ -0,0 +1,328 @@ +/* + * Copyright 2020-present by Nedim Sabic Sabic + * https://www.fibratus.io + * All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package approvers + +import ( + "testing" + "unsafe" + + "github.com/rabbitstack/fibratus/pkg/config" + "github.com/rabbitstack/fibratus/pkg/event" + "github.com/rabbitstack/fibratus/pkg/sys/etw" + "golang.org/x/sys/windows" +) + +// createFileRecord builds an EventRecord for a CreateFile event +// using the provided raw buffer. +func createFileRecord(t *testing.T, buf []byte) *etw.EventRecord { + t.Helper() + b := make([]byte, len(buf)) + copy(b, buf) + r := &etw.EventRecord{} + r.Header.ProviderID = event.FileEventGUID + r.Header.EventDescriptor.Opcode = event.CreateFileID + r.BufferLen = uint16(len(b)) + r.Buffer = uintptr(unsafe.Pointer(&b[0])) + // store b to prevent GC + t.Cleanup(func() { _ = b }) + return r +} + +// fileOpEndRecord builds an EventRecord for a FileOpEnd event. +func fileOpEndRecord(t *testing.T, buf []byte) *etw.EventRecord { + t.Helper() + b := make([]byte, len(buf)) + copy(b, buf) + r := &etw.EventRecord{} + r.Header.ProviderID = event.FileEventGUID + r.Header.EventDescriptor.Opcode = event.FileOpEndID + r.BufferLen = uint16(len(b)) + r.Buffer = uintptr(unsafe.Pointer(&b[0])) + t.Cleanup(func() { _ = b }) + return r +} + +var ( + createBuf = []byte{ + 200, 7, 94, 150, 141, 215, 255, 255, // Irp + 80, 102, 11, 146, 141, 215, 255, 255, // FileObject + 136, 25, 0, 0, // ThreadId + 36, 128, 0, 3, // Options + 128, 0, 0, 0, // Attributes + 0, 0, 0, 0, // ShareAccess + // \Device\HarddiskVolume3\WINDOWS\AppCompat\Programs\Amcache.hve + 92, 0, + 68, 0, 101, 0, 118, 0, 105, 0, 99, 0, 101, 0, + 92, 0, + 72, 0, 97, 0, 114, 0, 100, 0, 100, 0, 105, 0, + 115, 0, 107, 0, + 86, 0, 111, 0, 108, 0, 117, 0, 109, 0, 101, 0, + 51, 0, + 92, 0, + 87, 0, 73, 0, 78, 0, 68, 0, 79, 0, 87, 0, + 83, 0, + 92, 0, + 65, 0, 112, 0, 112, 0, + 67, 0, 111, 0, 109, 0, 112, 0, 97, 0, 116, 0, + 92, 0, + 80, 0, 114, 0, 111, 0, 103, 0, 114, 0, 97, 0, + 109, 0, 115, 0, + 92, 0, + 65, 0, 109, 0, 99, 0, 97, 0, 99, 0, 104, 0, + 101, 0, 46, 0, 104, 0, 118, 0, 101, 0, + 0, 0, + } + opendBuf = []byte{ + 248, 240, 61, 151, 141, 215, 255, 255, // Irp + 0, 0, 0, 0, 40, 0, 0, 0, // ExtraInformation + 0, 0, 0, 0, // Status + } +) + +// buildMatchingFileOpEnd builds a FileOpEnd whose IRP matches the given CreateFile buffer. +func buildMatchingFileOpEnd(t *testing.T, createBuf []byte, disposition uint64) *etw.EventRecord { + t.Helper() + // read IRP from createFile buffer (first 8 bytes) + irp := *(*uint64)(unsafe.Pointer(&createBuf[0])) + + buf := make([]byte, 20) + *(*uint64)(unsafe.Pointer(&buf[0])) = irp + *(*uint64)(unsafe.Pointer(&buf[8])) = disposition + *(*uint32)(unsafe.Pointer(&buf[16])) = 0 + + r := &etw.EventRecord{} + r.Header.ProviderID = event.FileEventGUID + r.Header.EventDescriptor.Opcode = event.FileOpEndID + r.BufferLen = uint16(len(buf)) + r.Buffer = uintptr(unsafe.Pointer(&buf[0])) + t.Cleanup(func() { _ = buf }) + return r +} + +func newTestFSApprover(r *config.RulesCompileResult) *fs { + return newFSApprover(r, nil).(*fs) +} + +func TestFSApproverNonFileEvent(t *testing.T) { + f := newTestFSApprover(nil) + r := &etw.EventRecord{} + r.Header.ProviderID = event.ProcessEventGUID + rec, approved := f.Approve(r) + if !approved { + t.Error("non-file event should be approved") + } + if rec != r { + t.Error("non-file event should return original record") + } +} + +func TestFSApproverCreateFileIsEnqueuedAndSuppressed(t *testing.T) { + f := newTestFSApprover(nil) + r := createFileRecord(t, createBuf) + + rec, approved := f.Approve(r) + if approved { + t.Error("CreateFile should be suppressed") + } + if rec != r { + t.Error("CreateFile should return original record unchanged") + } + if len(f.irps) != 1 { + t.Errorf("expected 1 IRP in map, got %d", len(f.irps)) + } +} + +func TestFSApproverFileOpEndNoMatchingIRP(t *testing.T) { + f := newTestFSApprover(nil) + // FileOpEnd arrives with no prior CreateFile + r := fileOpEndRecord(t, opendBuf) + rec, approved := f.Approve(r) + if approved { + t.Error("FileOpEnd with no matching IRP should be suppressed") + } + if rec != r { + t.Error("should return original FileOpEnd record") + } +} + +func TestFSApproverFileOpEndDispositionOutOfRange(t *testing.T) { + f := newTestFSApprover(nil) + // first enqueue CreateFile + f.Approve(createFileRecord(t, createBuf)) + + // FileOpEnd with disposition > FILE_MAXIMUM_DISPOSITION + r := buildMatchingFileOpEnd(t, createBuf, windows.FILE_MAXIMUM_DISPOSITION+1) + _, approved := f.Approve(r) + if approved { + t.Error("FileOpEnd with out-of-range disposition should be suppressed") + } +} + +func TestFSApproverFileOpEndNoRules(t *testing.T) { + // r == nil means no rules compiled, all events flow through + f := newTestFSApprover(nil) + f.Approve(createFileRecord(t, createBuf)) + + r := buildMatchingFileOpEnd(t, createBuf, windows.FILE_OPEN) + rec, approved := f.Approve(r) + if !approved { + t.Error("with no rules, all file events should be approved") + } + if rec.Header.EventDescriptor.Opcode != event.CreateFileID { + t.Error("should return stored CreateFile record, not FileOpEnd") + } + if rec.ExtendedData == nil { + t.Error("extended data should be attached to the returned record") + } +} + +func TestFSApproverFileOpEndNonOpenDispositionApprovesWithoutPathCheck(t *testing.T) { + rules := &config.RulesCompileResult{ + Approvers: config.Approvers{ + Paths: map[string][]string{ + "IMATCHES": {`C:\Windows\*`}, + }, + }, + } + f := newTestFSApprover(rules) + f.Approve(createFileRecord(t, createBuf)) + + // FILE_CREATE disposition should bypass path approvers + r := buildMatchingFileOpEnd(t, createBuf, windows.FILE_CREATE) + rec, approved := f.Approve(r) + if !approved { + t.Error("non-OPEN disposition should be approved without path check") + } + if rec.ExtendedData == nil { + t.Error("extended data should be attached") + } +} + +func TestFSApproverFileOpEndPathApproverApproved(t *testing.T) { + rules := &config.RulesCompileResult{ + Approvers: config.Approvers{ + Paths: map[string][]string{ + "ICONTAINS": {`Windows\AppCompat`}, + }, + }, + } + f := newTestFSApprover(rules) + f.Approve(createFileRecord(t, createBuf)) + + r := buildMatchingFileOpEnd(t, createBuf, windows.FILE_OPEN) + rec, approved := f.Approve(r) + if !approved { + t.Error("path matching approver should approve the event") + } + if rec.ExtendedData == nil { + t.Error("extended data should be attached") + } + if len(f.irps) != 1 { + t.Error("IRP should still be in map until cleanup is called") + } +} + +func TestFSApproverFileOpEndPathApproverRejected(t *testing.T) { + rules := &config.RulesCompileResult{ + Approvers: config.Approvers{ + Paths: map[string][]string{ + "ICONTAINS": {`Windows\System32`}, + }, + }, + } + f := newTestFSApprover(rules) + + f.Approve(createFileRecord(t, createBuf)) + + r := buildMatchingFileOpEnd(t, createBuf, windows.FILE_OPEN) + _, approved := f.Approve(r) + if approved { + t.Error("non-matching path should reject the event") + } + if len(f.irps) != 0 { + t.Error("rejected IRP should be deleted from map immediately") + } +} + +func TestFSApproverCleanup(t *testing.T) { + f := newTestFSApprover(nil) + cr := createFileRecord(t, createBuf) + f.Approve(cr) + + if len(f.irps) != 1 { + t.Fatalf("expected 1 IRP before cleanup") + } + + // simulate what consumer does: cleanup with the stored CreateFile record + irp := f.irps[*(*uint64)(unsafe.Pointer(&createBuf[0]))] + f.cleanup(irp.rec) + + if len(f.irps) != 0 { + t.Error("IRP should be removed after cleanup") + } +} + +func TestFSApproverCleanupNonFileEventIsNoop(t *testing.T) { + f := newTestFSApprover(nil) + f.Approve(createFileRecord(t, createBuf)) + + r := &etw.EventRecord{} + r.Header.ProviderID = event.ProcessEventGUID + f.cleanup(r) // should not panic or delete anything + + if len(f.irps) != 1 { + t.Error("cleanup of non-file event should be a no-op") + } +} + +func TestFSApproverMultipleIRPs(t *testing.T) { + f := newTestFSApprover(nil) + + // build two different CreateFile buffers with different IRPs + buf1 := make([]byte, len(createBuf)) + copy(buf1, createBuf) + buf2 := make([]byte, len(createBuf)) + copy(buf2, createBuf) + // modify IRP in buf2 + *(*uint64)(unsafe.Pointer(&buf2[0])) = 0xDEADBEEF + + f.Approve(createFileRecord(t, buf1)) + f.Approve(createFileRecord(t, buf2)) + + if len(f.irps) != 2 { + t.Fatalf("expected 2 IRPs in map, got %d", len(f.irps)) + } + + // complete first IRP + r1 := buildMatchingFileOpEnd(t, buf1, windows.FILE_OPEN) + _, approved := f.Approve(r1) + if !approved { + t.Error("first FileOpEnd should be approved") + } + if len(f.irps) != 2 { + t.Error("second IRP should still be in map") + } + + // complete second IRP + r2 := buildMatchingFileOpEnd(t, buf2, windows.FILE_OPEN) + _, approved = f.Approve(r2) + if !approved { + t.Error("second FileOpEnd should be approved") + } +} diff --git a/internal/etw/approvers/process.go b/internal/etw/approvers/process.go new file mode 100644 index 000000000..7154645d1 --- /dev/null +++ b/internal/etw/approvers/process.go @@ -0,0 +1,85 @@ +/* + * Copyright 2020-present by Nedim Sabic Sabic + * https://www.fibratus.io + * All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package approvers + +import ( + "expvar" + + "github.com/rabbitstack/fibratus/pkg/config" + "github.com/rabbitstack/fibratus/pkg/event" + "github.com/rabbitstack/fibratus/pkg/ps" + "github.com/rabbitstack/fibratus/pkg/sys/etw" +) + +var ( + procApproverApprovals = expvar.NewInt("approver.proc.approvals") + procApproverRejections = expvar.NewInt("approver.proc.rejections") +) + +// proc approver is responsible for filtering out process +// and thread access events. +type proc struct { + approver + psnap ps.Snapshotter +} + +func newProcApprover(psnap ps.Snapshotter, r *config.RulesCompileResult) Approver { + return &proc{ + approver: approver{ + r: r, + }, + psnap: psnap, + } +} + +func (p *proc) Approve(r *etw.EventRecord) (*etw.EventRecord, bool) { + if r.Header.ProviderID != event.AuditAPIEventGUID { + return r, true + } + + id := r.Header.EventDescriptor.ID + if id != event.OpenProcessID && id != event.OpenThreadID { + return r, true + } + + // allow remote thread opens + pid := r.ReadUint32(0) + if id == event.OpenThreadID && r.Header.ProcessID != pid { + return r, true + } + + // attempt to find the target process in + // the snapshotter state and passs the + // executable path to the approver. If + // we can find the process in snapshot + // or the executable path is not resolve, + // the event is approved + ok, ps := p.psnap.Find(pid) + if !ok || ps == nil || ps.Exe == "" { + return r, true + } + if p.approveExecutable(ps.Exe) { + procApproverApprovals.Add(1) + return r, true + } + + procApproverRejections.Add(1) + + return r, false +} diff --git a/internal/etw/approvers/process_test.go b/internal/etw/approvers/process_test.go new file mode 100644 index 000000000..fc33155ee --- /dev/null +++ b/internal/etw/approvers/process_test.go @@ -0,0 +1,248 @@ +/* + * Copyright 2020-present by Nedim Sabic Sabic + * https://www.fibratus.io + * All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package approvers + +import ( + "testing" + "unsafe" + + "github.com/rabbitstack/fibratus/pkg/config" + "github.com/rabbitstack/fibratus/pkg/event" + "github.com/rabbitstack/fibratus/pkg/ps" + pstypes "github.com/rabbitstack/fibratus/pkg/ps/types" + "github.com/rabbitstack/fibratus/pkg/sys/etw" + "github.com/stretchr/testify/mock" + "golang.org/x/sys/windows" +) + +func procRecord(opcode uint8, eventID uint16, pid uint32, providerID windows.GUID) *etw.EventRecord { + buf := make([]byte, 4) + *(*uint32)(unsafe.Pointer(&buf[0])) = pid + r := &etw.EventRecord{} + r.Header.ProviderID = providerID + r.Header.EventDescriptor.Opcode = opcode + r.Header.EventDescriptor.ID = eventID + r.Header.ProcessID = pid + r.BufferLen = uint16(len(buf)) + r.Buffer = uintptr(unsafe.Pointer(&buf[0])) + return r +} + +func openProcRecord(pid uint32) *etw.EventRecord { + r := procRecord(0, event.OpenProcessID, pid, event.AuditAPIEventGUID) + return r +} + +func openThreadRecord(callerPID, targetPID uint32) *etw.EventRecord { + buf := make([]byte, 4) + *(*uint32)(unsafe.Pointer(&buf[0])) = targetPID + r := &etw.EventRecord{} + r.Header.ProviderID = event.AuditAPIEventGUID + r.Header.EventDescriptor.ID = event.OpenThreadID + r.Header.ProcessID = callerPID + r.BufferLen = uint16(len(buf)) + r.Buffer = uintptr(unsafe.Pointer(&buf[0])) + return r +} + +func newTestProcApprover(psnap *ps.SnapshotterMock, r *config.RulesCompileResult) *proc { + return newProcApprover(psnap, r).(*proc) +} + +func TestProcApproverNonAuditAPIEvent(t *testing.T) { + psnap := &ps.SnapshotterMock{} + a := newTestProcApprover(psnap, nil) + + r := &etw.EventRecord{} + r.Header.ProviderID = event.FileEventGUID + rec, approved := a.Approve(r) + if !approved { + t.Error("non-AuditAPI event should be approved") + } + if rec != r { + t.Error("should return original record") + } + psnap.AssertNotCalled(t, "Find", mock.Anything) +} + +func TestProcApproverNonOpenProcessOrThreadID(t *testing.T) { + psnap := &ps.SnapshotterMock{} + a := newTestProcApprover(psnap, nil) + + r := &etw.EventRecord{} + r.Header.ProviderID = event.AuditAPIEventGUID + r.Header.EventDescriptor.ID = 9999 // some other event ID + rec, approved := a.Approve(r) + if !approved { + t.Error("non-OpenProcess/Thread event should be approved") + } + if rec != r { + t.Error("should return original record") + } + psnap.AssertNotCalled(t, "Find", mock.Anything) +} + +func TestProcApproverOpenRemoteThreadAlwaysApproved(t *testing.T) { + psnap := &ps.SnapshotterMock{} + a := newTestProcApprover(psnap, nil) + + // caller PID != target PID = remote thread open, always allow + r := openThreadRecord(1234, 5678) + _, approved := a.Approve(r) + if !approved { + t.Error("remote thread open should always be approved") + } + psnap.AssertNotCalled(t, "Find", mock.Anything) +} + +func TestProcApproverOpenProcessProcessNotInSnapshot(t *testing.T) { + psnap := &ps.SnapshotterMock{} + a := newTestProcApprover(psnap, nil) + + const pid = uint32(1234) + psnap.On("Find", pid).Return(false, (*pstypes.PS)(nil)) + + r := openProcRecord(pid) + _, approved := a.Approve(r) + if !approved { + t.Error("process not in snapshot should be approved") + } + psnap.AssertCalled(t, "Find", pid) +} + +func TestProcApproverOpenProcessNilPS(t *testing.T) { + psnap := &ps.SnapshotterMock{} + a := newTestProcApprover(psnap, nil) + + const pid = uint32(1234) + psnap.On("Find", pid).Return(true, (*pstypes.PS)(nil)) + + r := openProcRecord(pid) + _, approved := a.Approve(r) + if !approved { + t.Error("nil PS entry should be approved") + } +} + +func TestProcApproverOpenProcessExeApproved(t *testing.T) { + psnap := &ps.SnapshotterMock{} + rules := &config.RulesCompileResult{ + Approvers: config.Approvers{ + Executables: map[string][]string{ + "IMATCHES": {`C:\Windows\System32\*`}, + }, + }, + } + a := newTestProcApprover(psnap, rules) + + const pid = uint32(1234) + psnap.On("Find", pid).Return(true, &pstypes.PS{Exe: `C:\Windows\System32\svchost.exe`}) + + r := openProcRecord(pid) + _, approved := a.Approve(r) + if !approved { + t.Error("matching executable should be approved") + } +} + +func TestProcApproverOpenProcessExeRejected(t *testing.T) { + psnap := &ps.SnapshotterMock{} + rules := &config.RulesCompileResult{ + Approvers: config.Approvers{ + Executables: map[string][]string{ + "IMATCHES": {`C:\Windows\System32\*`}, + }, + }, + } + a := newTestProcApprover(psnap, rules) + + const pid = uint32(5678) + psnap.On("Find", pid).Return(true, &pstypes.PS{Exe: `C:\Users\Administrator\cmd.exe`}) + + r := openProcRecord(pid) + _, approved := a.Approve(r) + if approved { + t.Error("non-matching executable should be rejected") + } +} + +func TestProcApproverOpenProcessEmptyExeApproved(t *testing.T) { + psnap := &ps.SnapshotterMock{} + rules := &config.RulesCompileResult{ + Approvers: config.Approvers{ + Executables: map[string][]string{ + "IMATCHES": {`C:\Windows\*`}, + }, + }, + } + a := newTestProcApprover(psnap, rules) + + const pid = uint32(9999) + psnap.On("Find", pid).Return(true, &pstypes.PS{Exe: ""}) + + r := openProcRecord(pid) + _, approved := a.Approve(r) + if !approved { + t.Error("empty exe should be automatically approved") + } +} + +func TestProcApproverOpenThreadSameProcessExeApproved(t *testing.T) { + psnap := &ps.SnapshotterMock{} + rules := &config.RulesCompileResult{ + Approvers: config.Approvers{ + Executables: map[string][]string{ + "IMATCHES": {`C:\Windows\*`}, + }, + }, + } + + const pid = uint32(888) + a := newTestProcApprover(psnap, rules) + r := openThreadRecord(pid, pid) + + psnap.On("Find", pid).Return(true, &pstypes.PS{Exe: `C:\Windows\System32\lsass.exe`}) + + _, approved := a.Approve(r) + if !approved { + t.Error("self thread open with matching exe should be approved") + } +} + +func TestProcApproverOpenThreadSameProcessExeRejected(t *testing.T) { + psnap := &ps.SnapshotterMock{} + rules := &config.RulesCompileResult{ + Approvers: config.Approvers{ + Executables: map[string][]string{ + "IMATCHES": {`C:\Windows\*`}, + }, + }, + } + const pid = uint32(777) + + a := newTestProcApprover(psnap, rules) + r := openThreadRecord(pid, pid) + + psnap.On("Find", pid).Return(true, &pstypes.PS{Exe: `C:\suspicious\inject.exe`}) + + _, approved := a.Approve(r) + if approved { + t.Error("self thread open with non-matching exe should be rejected") + } +} diff --git a/internal/etw/approvers/registry.go b/internal/etw/approvers/registry.go new file mode 100644 index 000000000..ade474d52 --- /dev/null +++ b/internal/etw/approvers/registry.go @@ -0,0 +1,102 @@ +/* + * Copyright 2020-present by Nedim Sabic Sabic + * https://www.fibratus.io + * All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package approvers + +import ( + "expvar" + + "github.com/rabbitstack/fibratus/pkg/config" + "github.com/rabbitstack/fibratus/pkg/event" + "github.com/rabbitstack/fibratus/pkg/sys/etw" + "github.com/rabbitstack/fibratus/pkg/util/key" +) + +var ( + registryApproverApprovals = expvar.NewInt("approver.registry.approvals") + registryApproverRejections = expvar.NewInt("approver.registry.rejections") +) + +// registry approver accepts or discards key access events +// as soon as they are offloaded from the session buffer. +type registry struct { + approver + kcbs map[uint64]string +} + +func newRegistryApprover(r *config.RulesCompileResult) Approver { + return ®istry{ + approver: approver{ + r: r, + }, + kcbs: make(map[uint64]string), + } +} + +func (r *registry) Approve(rec *etw.EventRecord) (*etw.EventRecord, bool) { + if rec.Header.ProviderID != event.RegistryEventGUID { + return rec, true + } + + id := rec.Header.EventDescriptor.Opcode + + // keep the state of allocated key control blocks + // to be able to derive the full registry path + if id == event.RegKCBRundownID || id == event.RegCreateKCBID { + r.kcbs[rec.ReadUint64(16)] = rec.ConsumeUTF16String(24) + return rec, true + } + if id == event.RegDeleteKCBID { + delete(r.kcbs, rec.ReadUint64(16)) + return rec, true + } + + // accept all but key access events + if id != event.RegOpenKeyID { + return rec, true + } + + // lookup KCB map to check if the event + // KCB object address references a key + // we can use to reconstruct the full + // registry path + kcb := rec.ReadUint64(16) + path := rec.ConsumeUTF16String(24) + if kcb != 0 { + path = key.ConcatPaths(r.kcbs[kcb], path) + } + + rootkey, subkey := key.Format(path) + if rootkey != key.Invalid { + root := rootkey.String() + if subkey != "" { + path = key.ConcatPaths(root, subkey) + } else { + path = root + } + } + + if r.approveKey(path) { + registryApproverApprovals.Add(1) + return rec, true + } + + registryApproverRejections.Add(1) + + return rec, false +} diff --git a/internal/etw/approvers/registry_test.go b/internal/etw/approvers/registry_test.go new file mode 100644 index 000000000..3be9c3501 --- /dev/null +++ b/internal/etw/approvers/registry_test.go @@ -0,0 +1,288 @@ +/* + * Copyright 2020-present by Nedim Sabic Sabic + * https://www.fibratus.io + * All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package approvers + +import ( + "testing" + "unsafe" + + "github.com/rabbitstack/fibratus/pkg/config" + "github.com/rabbitstack/fibratus/pkg/event" + "github.com/rabbitstack/fibratus/pkg/sys/etw" +) + +var ( + regOpenKeyBuf = []byte{ + 248, 104, 16, 11, 5, 0, 0, 0, // Status + 0, 0, 0, 0, 0, 0, 0, 0, // Index + 144, 249, 47, 116, 139, 181, 255, 255, // KCB (offset 16) + 83, 0, 111, 0, 102, 0, 116, 0, // S o f t + 119, 0, 97, 0, 114, 0, 101, 0, // w a r e + 92, 0, + 77, 0, 105, 0, 99, 0, 114, 0, 111, 0, 115, 0, + 111, 0, 102, 0, 116, 0, + 92, 0, + 87, 0, 105, 0, 110, 0, 100, 0, 111, 0, 119, 0, + 115, 0, + 92, 0, + 67, 0, 117, 0, 114, 0, 114, 0, 101, 0, 110, 0, + 116, 0, 86, 0, 101, 0, 114, 0, 115, 0, 105, 0, + 111, 0, 110, 0, + 92, 0, + 67, 0, 97, 0, 112, 0, 97, 0, 98, 0, 105, 0, + 108, 0, 105, 0, 116, 0, 121, 0, + 65, 0, 99, 0, 99, 0, 101, 0, 115, 0, 115, 0, + 77, 0, 97, 0, 110, 0, 97, 0, 103, 0, 101, 0, + 114, 0, + 92, 0, + 67, 0, 97, 0, 112, 0, 97, 0, 98, 0, 105, 0, + 108, 0, 105, 0, 116, 0, 105, 0, 101, 0, 115, 0, + 0, 0, + } + regCreateKCBBuf = []byte{ + 248, 104, 16, 11, 5, 0, 0, 0, // Status + 0, 0, 0, 0, 0, 0, 0, 0, // Index + 144, 249, 47, 116, 139, 181, 255, 255, // KCB (offset 16) + // \REGISTRY\MACHINE (UTF-16LE) + 92, 0, // '\' + 82, 0, 69, 0, 71, 0, 73, 0, // R E G I + 83, 0, 84, 0, 82, 0, 89, 0, // S T R Y + 92, 0, // '\' + 77, 0, 65, 0, 67, 0, 72, 0, // M A C H + 73, 0, 78, 0, 69, 0, // I N E + 0, 0, // null terminator + } +) + +func registryRecord(t *testing.T, opcode uint8, buf []byte) *etw.EventRecord { + t.Helper() + b := make([]byte, len(buf)) + copy(b, buf) + r := &etw.EventRecord{} + r.Header.ProviderID = event.RegistryEventGUID + r.Header.EventDescriptor.Opcode = opcode + r.BufferLen = uint16(len(b)) + r.Buffer = uintptr(unsafe.Pointer(&b[0])) + t.Cleanup(func() { _ = b }) + return r +} + +// buildKCBRecord builds a RegCreateKCB/RegKCBRundown record with the given KCB key and path. +func buildKCBRecord(t *testing.T, opcode uint8, kcb uint64, path string) *etw.EventRecord { + t.Helper() + // layout: 8 bytes status + 8 bytes index + 8 bytes KCB + UTF-16 path + utf16Path := utf16Encode(path) + bufLen := 24 + len(utf16Path) + buf := make([]byte, bufLen) + *(*uint64)(unsafe.Pointer(&buf[16])) = kcb + copy(buf[24:], utf16Path) + r := &etw.EventRecord{} + r.Header.ProviderID = event.RegistryEventGUID + r.Header.EventDescriptor.Opcode = opcode + r.BufferLen = uint16(bufLen) + r.Buffer = uintptr(unsafe.Pointer(&buf[0])) + t.Cleanup(func() { _ = buf }) + return r +} + +// buildRegOpenKeyRecord builds a RegOpenKey record with given KCB and path. +func buildRegOpenKeyRecord(t *testing.T, kcb uint64, path string) *etw.EventRecord { + t.Helper() + utf16Path := utf16Encode(path) + bufLen := 24 + len(utf16Path) + buf := make([]byte, bufLen) + *(*uint64)(unsafe.Pointer(&buf[16])) = kcb + copy(buf[24:], utf16Path) + r := &etw.EventRecord{} + r.Header.ProviderID = event.RegistryEventGUID + r.Header.EventDescriptor.Opcode = event.RegOpenKeyID + r.BufferLen = uint16(bufLen) + r.Buffer = uintptr(unsafe.Pointer(&buf[0])) + t.Cleanup(func() { _ = buf }) + return r +} + +// utf16Encode encodes a string as UTF-16LE with null terminator +func utf16Encode(s string) []byte { + buf := make([]byte, (len(s)+1)*2) + for i, c := range s { + buf[i*2] = byte(c) + buf[i*2+1] = byte(c >> 8) + } + return buf +} + +func newTestRegistryApprover(r *config.RulesCompileResult) *registry { + return newRegistryApprover(r).(*registry) +} + +func TestRegistryApproverNonRegistryEvent(t *testing.T) { + a := newTestRegistryApprover(nil) + r := &etw.EventRecord{} + r.Header.ProviderID = event.FileEventGUID + rec, approved := a.Approve(r) + if !approved { + t.Error("non-registry event should be approved") + } + if rec != r { + t.Error("non-registry event should return original record") + } +} + +func TestRegistryApproverRegCreateKCBStoresPath(t *testing.T) { + a := newTestRegistryApprover(nil) + const kcb = uint64(0xffb58174f990) + r := buildKCBRecord(t, event.RegCreateKCBID, kcb, `HKEY_LOCAL_MACHINE\SYSTEM`) + rec, approved := a.Approve(r) + if !approved { + t.Error("RegCreateKCB should be approved") + } + if rec != r { + t.Error("should return original record") + } + if got := a.kcbs[kcb]; got != `HKEY_LOCAL_MACHINE\SYSTEM` { + t.Errorf("KCB path not stored correctly, got %q", got) + } +} + +func TestRegistryApproverRegKCBRundownStoresPath(t *testing.T) { + a := newTestRegistryApprover(nil) + const kcb = uint64(0xffb58174f990) + r := buildKCBRecord(t, event.RegKCBRundownID, kcb, `HKEY_CURRENT_USER\Software`) + rec, approved := a.Approve(r) + if !approved { + t.Error("RegKCBRundown should be approved") + } + if rec != r { + t.Error("should return original record") + } + if got := a.kcbs[kcb]; got != `HKEY_CURRENT_USER\Software` { + t.Errorf("KCB path not stored correctly, got %q", got) + } +} + +func TestRegistryApproverRegDeleteKCBRemovesEntry(t *testing.T) { + a := newTestRegistryApprover(nil) + const kcb = uint64(0xffb58174f990) + // first store it + a.kcbs[kcb] = `HKEY_LOCAL_MACHINE\SYSTEM` + r := buildKCBRecord(t, event.RegDeleteKCBID, kcb, "") + rec, approved := a.Approve(r) + if !approved { + t.Error("RegDeleteKCB should be approved") + } + if rec != r { + t.Error("should return original record") + } + if _, ok := a.kcbs[kcb]; ok { + t.Error("KCB entry should be removed after RegDeleteKCB") + } +} + +func TestRegistryApproverNonOpenKeyOpcodeApproved(t *testing.T) { + a := newTestRegistryApprover(nil) + r := registryRecord(t, event.RegSetValueID, regOpenKeyBuf) + _, approved := a.Approve(r) + if !approved { + t.Error("non-RegOpenKey registry event should be approved unconditionally") + } +} + +func TestRegistryApproverRegOpenKeyApproved(t *testing.T) { + rules := &config.RulesCompileResult{ + Approvers: config.Approvers{ + Keys: map[string][]string{ + "IMATCHES": {`HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\*`}, + }, + }, + } + + a := newTestRegistryApprover(rules) + r := registryRecord(t, event.RegOpenKeyID, regOpenKeyBuf) + + const kcb = uint64(18446662209287223696) + a.kcbs[kcb] = `HKEY_LOCAL_MACHINE` + + _, approved := a.Approve(r) + if !approved { + t.Error("key matching approver should approve the event") + } +} + +func TestRegistryApproverRegOpenKeyRejected(t *testing.T) { + rules := &config.RulesCompileResult{ + Approvers: config.Approvers{ + Keys: map[string][]string{ + "IMATCHES": {`HKEY_LOCAL_MACHINE\SYSTEM\*`}, + }, + }, + } + + a := newTestRegistryApprover(rules) + r := registryRecord(t, event.RegOpenKeyID, regOpenKeyBuf) + + const kcb = uint64(18446662209287223696) + a.kcbs[kcb] = `HKEY_LOCAL_MACHINE` + + _, approved := a.Approve(r) + if approved { + t.Error("non-matching key should reject the event") + } +} + +func TestRegistryApproverRegOpenKeyKCBPathPrepended(t *testing.T) { + rules := &config.RulesCompileResult{ + Approvers: config.Approvers{ + Keys: map[string][]string{ + "IMATCHES": {`HKEY_LOCAL_MACHINE\SYSTEM\*`}, + }, + }, + } + + a := newTestRegistryApprover(rules) + const kcb = uint64(0xffb58174f990) + // store KCB with a root path + a.kcbs[kcb] = `\REGISTRY\MACHINE` + + // build RegOpenKey with the same KCB and a relative subkey + r := buildRegOpenKeyRecord(t, kcb, `SYSTEM\CurrentControlSet`) + + _, approved := a.Approve(r) + if !approved { + t.Error("prepended KCB path should be approved") + } +} + +func TestRegistryApproverRegOpenKeyZeroKCBUsesPathDirectly(t *testing.T) { + rules := &config.RulesCompileResult{ + Approvers: config.Approvers{ + Keys: map[string][]string{ + "IMATCHES": {`HKEY_LOCAL_MACHINE\SOFTWARE\*`}, + }, + }, + } + + a := newTestRegistryApprover(rules) + r := buildRegOpenKeyRecord(t, 0, `HKEY_LOCAL_MACHINE\SOFTWARE\Classes\.AAC`) + + _, approved := a.Approve(r) + if !approved { + t.Error("direct registry path from event should be directly passed to approver") + } +} From e947516b18d9e786d00cd78ca5c48a3c73732751 Mon Sep 17 00:00:00 2001 From: rabbitstack Date: Sun, 7 Jun 2026 19:31:34 +0200 Subject: [PATCH 02/15] refactor(processors): Remove IRP correlation logic from fs processor As the approvers are now responsible for this task, we can offload IRP correlation via FileOpEnd events and get the CreateFile event with the create disposition. --- internal/etw/processors/fs_windows.go | 80 ++++++++-------------- internal/etw/processors/fs_windows_test.go | 75 +------------------- 2 files changed, 28 insertions(+), 127 deletions(-) diff --git a/internal/etw/processors/fs_windows.go b/internal/etw/processors/fs_windows.go index 5ad2f16db..20dbebbb2 100644 --- a/internal/etw/processors/fs_windows.go +++ b/internal/etw/processors/fs_windows.go @@ -52,11 +52,7 @@ type fsProcessor struct { hsnap handle.Snapshotter psnap ps.Snapshotter - // irps contains a mapping between the IRP (I/O request packet) and CreateFile events - irps map[uint64]*event.Event - - devMapper fs.DevMapper - config *config.Config + config *config.Config // buckets stores stack walk events per stack id buckets map[uint64][]*event.Event @@ -75,19 +71,16 @@ type FileInfo struct { func newFsProcessor( hsnap handle.Snapshotter, psnap ps.Snapshotter, - devMapper fs.DevMapper, config *config.Config, ) Processor { f := &fsProcessor{ - files: make(map[uint64]*FileInfo), - irps: make(map[uint64]*event.Event), - hsnap: hsnap, - psnap: psnap, - devMapper: devMapper, - config: config, - buckets: make(map[uint64][]*event.Event), - purger: time.NewTicker(time.Second * 5), - quit: make(chan struct{}, 1), + files: make(map[uint64]*FileInfo), + hsnap: hsnap, + psnap: psnap, + config: config, + buckets: make(map[uint64][]*event.Event), + purger: time.NewTicker(time.Second * 5), + quit: make(chan struct{}, 1), } go f.purge() @@ -145,13 +138,6 @@ func (f *fsProcessor) processEvent(e *event.Event) (*event.Event, error) { } return e, f.psnap.AddMmap(e) - case event.CreateFile: - // we defer the processing of the CreateFile event until we get - // the matching FileOpEnd event. This event contains the operation - // that was done on behalf of the file, e.g. create or open. - irp := e.Params.MustGetUint64(params.FileIrpPtr) - e.WaitEnqueue = true - f.irps[irp] = e case event.StackWalk: if !event.IsCurrentProcDropped(e.PID) { f.mu.Lock() @@ -166,45 +152,23 @@ func (f *fsProcessor) processEvent(e *event.Event) (*event.Event, error) { f.buckets[id] = append(q, e) } } - case event.FileOpEnd: - // get the CreateFile pending event by IRP identifier - // and fetch the file create disposition value - var ( - irp = e.Params.MustGetUint64(params.FileIrpPtr) - dispo = e.Params.MustGetUint64(params.FileExtraInfo) - status = e.Params.MustGetUint32(params.NTStatus) - ) - - if dispo > windows.FILE_MAXIMUM_DISPOSITION { - return e, nil - } - ev, ok := f.irps[irp] - if !ok { - return e, nil - } - delete(f.irps, irp) - - // reset the wait status to allow passage of this event to - // the aggregator queue. Additionally, append params to it - ev.WaitEnqueue = false - fileObject := ev.Params.MustGetUint64(params.FileObject) + case event.CreateFile: + fileObject := e.Params.MustGetUint64(params.FileObject) // try to get extended file info. If the file object is already // present in the map, we'll reuse the existing file information fileinfo, ok := f.files[fileObject] if !ok { - opts := ev.Params.MustGetUint32(params.FileCreateOptions) + opts := e.Params.MustGetUint32(params.FileCreateOptions) opts &= 0xFFFFFF - filepath := ev.GetParamAsString(params.FilePath) + filepath := e.GetParamAsString(params.FilePath) fileinfo = f.getFileInfo(filepath, opts) f.files[fileObject] = fileinfo } - ev.AppendParam(params.NTStatus, params.Status, status) if fileinfo.Type != fs.Unknown { - ev.AppendEnum(params.FileType, uint32(fileinfo.Type), fs.FileTypes) + e.AppendEnum(params.FileType, uint32(fileinfo.Type), fs.FileTypes) } - ev.AppendEnum(params.FileOperation, uint32(dispo), fs.FileCreateDispositions) // attach stack walk return addresses. CreateFile events // represent an edge case in callstack enrichment. Since @@ -220,17 +184,17 @@ func (f *fsProcessor) processEvent(e *event.Event) (*event.Event, error) { f.mu.Lock() defer f.mu.Unlock() - id := ev.StackID() + id := e.StackID() q, ok := f.buckets[id] if ok && len(q) > 0 { var s *event.Event s, f.buckets[id] = q[len(q)-1], q[:len(q)-1] callstack := s.Params.MustGetSlice(params.Callstack) - ev.AppendParam(params.Callstack, params.Slice, callstack) + e.AppendParam(params.Callstack, params.Slice, callstack) } } - return ev, nil + return e, nil case event.ReleaseFile: fileReleaseCount.Add(1) // delete file metadata by file object address @@ -303,6 +267,16 @@ func (f *fsProcessor) processEvent(e *event.Event) (*event.Event, error) { return e, nil } +func (f *fsProcessor) dequeueStackwalk(stackID uint64) { + f.mu.Lock() + defer f.mu.Unlock() + + q, ok := f.buckets[stackID] + if ok && len(q) > 0 { + f.buckets[stackID] = q[:len(q)-1] + } +} + func (f *fsProcessor) findFile(fileKey, fileObject uint64) *FileInfo { fileinfo, ok := f.files[fileKey] if ok { @@ -331,7 +305,7 @@ func (f *fsProcessor) getMappedFile(pid uint32, addr uint64) string { return "" } defer windows.Close(process) - return f.devMapper.Convert(sys.GetMappedFile(process, uintptr(addr))) + return fs.GetDevMapper().Convert(sys.GetMappedFile(process, uintptr(addr))) } func (f *fsProcessor) purge() { diff --git a/internal/etw/processors/fs_windows_test.go b/internal/etw/processors/fs_windows_test.go index 7efe7e548..70b841ccd 100644 --- a/internal/etw/processors/fs_windows_test.go +++ b/internal/etw/processors/fs_windows_test.go @@ -19,8 +19,6 @@ package processors import ( - "os" - "reflect" "testing" "github.com/rabbitstack/fibratus/pkg/config" @@ -38,9 +36,6 @@ import ( ) func TestFsProcessor(t *testing.T) { - exe, err := os.Executable() - require.NoError(t, err) - var tests = []struct { name string e *event.Event @@ -101,74 +96,6 @@ func TestFsProcessor(t *testing.T) { psnap.AssertNumberOfCalls(t, "AddMmap", 1) }, }, - { - "wait enqueue for create file events", - &event.Event{ - Type: event.CreateFile, - Category: event.File, - Params: event.Params{ - params.FileObject: {Name: params.FileObject, Type: params.Uint64, Value: uint64(18446738026482168384)}, - params.ThreadID: {Name: params.ThreadID, Type: params.Uint32, Value: uint32(1484)}, - params.FileCreateOptions: {Name: params.FileCreateOptions, Type: params.Uint32, Value: uint32(1223456)}, - params.FilePath: {Name: params.FilePath, Type: params.UnicodeString, Value: "C:\\Windows\\system32\\kernel32.dll"}, - params.FileShareMask: {Name: params.FileShareMask, Type: params.Uint32, Value: uint32(5)}, - params.FileIrpPtr: {Name: params.FileIrpPtr, Type: params.Uint64, Value: uint64(1234543123112321)}, - }, - }, - nil, - func() *handle.SnapshotterMock { - hsnap := new(handle.SnapshotterMock) - return hsnap - }, - func(e *event.Event, t *testing.T, hsnap *handle.SnapshotterMock, p Processor) { - fsProcessor := p.(*fsProcessor) - assert.True(t, e.WaitEnqueue) - assert.Contains(t, fsProcessor.irps, uint64(1234543123112321)) - assert.True(t, reflect.DeepEqual(e, fsProcessor.irps[1234543123112321])) - }, - }, - { - "get IRP completion for create file event", - &event.Event{ - Type: event.FileOpEnd, - Category: event.File, - Params: event.Params{ - params.FileObject: {Name: params.FileObject, Type: params.Uint64, Value: uint64(18446738026482168384)}, - params.FileExtraInfo: {Name: params.FileExtraInfo, Type: params.Uint64, Value: uint64(2)}, - params.FileIrpPtr: {Name: params.FileIrpPtr, Type: params.Uint64, Value: uint64(1334543123112321)}, - params.NTStatus: {Name: params.NTStatus, Type: params.Status, Value: uint32(0)}, - }, - }, - func(p Processor) { - fsProcessor := p.(*fsProcessor) - fsProcessor.irps[1334543123112321] = &event.Event{ - Type: event.CreateFile, - Category: event.File, - Params: event.Params{ - params.FileObject: {Name: params.FileObject, Type: params.Uint64, Value: uint64(12446738026482168384)}, - params.FileCreateOptions: {Name: params.FileCreateOptions, Type: params.Uint32, Value: uint32(18874368)}, - params.FilePath: {Name: params.FilePath, Type: params.UnicodeString, Value: exe}, - params.FileShareMask: {Name: params.FileShareMask, Type: params.Uint32, Value: uint32(5)}, - params.FileIrpPtr: {Name: params.FileIrpPtr, Type: params.Uint64, Value: uint64(1334543123112321)}, - }, - } - }, - func() *handle.SnapshotterMock { - hsnap := new(handle.SnapshotterMock) - return hsnap - }, - func(e *event.Event, t *testing.T, hsnap *handle.SnapshotterMock, p Processor) { - fsProcessor := p.(*fsProcessor) - assert.Equal(t, event.CreateFile, e.Type) - assert.NotContains(t, fsProcessor.irps, uint64(1334543123112321)) - assert.False(t, e.WaitEnqueue) - assert.Contains(t, fsProcessor.files, uint64(12446738026482168384)) - assert.Equal(t, exe, fsProcessor.files[12446738026482168384].Name) - assert.Equal(t, "Success", e.GetParamAsString(params.NTStatus)) - assert.Equal(t, "File", e.GetParamAsString(params.FileType)) - assert.Equal(t, "CREATE", e.GetParamAsString(params.FileOperation)) - }, - }, { "release file and remove file info", &event.Event{ @@ -306,7 +233,7 @@ func TestFsProcessor(t *testing.T) { {File: "C:\\Windows\\System32\\kernel32.dll", BaseAddress: va.Address(0xffff23433), Size: 3098}, }, }) - p := newFsProcessor(hsnap, psnap, fs.NewDevMapper(), &config.Config{}) + p := newFsProcessor(hsnap, psnap, &config.Config{}) if tt.setupProcessor != nil { tt.setupProcessor(p) } From 7cf8303a16697d37cbc6565f1fd1a37291bf546d Mon Sep 17 00:00:00 2001 From: rabbitstack Date: Sun, 7 Jun 2026 19:32:43 +0200 Subject: [PATCH 03/15] refactor(processors): Convert processor chain to structure --- internal/etw/processors/chain.go | 23 ++++++++++------------- internal/etw/processors/chain_windows.go | 16 ++++++++-------- 2 files changed, 18 insertions(+), 21 deletions(-) diff --git a/internal/etw/processors/chain.go b/internal/etw/processors/chain.go index 81012a919..04c872e78 100644 --- a/internal/etw/processors/chain.go +++ b/internal/etw/processors/chain.go @@ -21,6 +21,7 @@ package processors import ( "expvar" "fmt" + "github.com/rabbitstack/fibratus/pkg/event" "github.com/rabbitstack/fibratus/pkg/util/multierror" ) @@ -28,24 +29,14 @@ import ( // processorFailures counts the number of failures caused by event processors var processorFailures = expvar.NewInt("event.processor.failures") -// Chain defines the event process chain has to satisfy. -type Chain interface { - // ProcessEvent pushes the event into processor chain. Processors are applied sequentially, so we have to make - // sure that any processor providing additional context to the next processor is defined first in the chain. If - // one processor fails, the next processor in chain is invoked. - ProcessEvent(evt *event.Event) (*event.Event, error) - // Close closes the processor chain and frees all allocated resources. - Close() error -} - -func (c *chain) addProcessor(processor Processor) { +func (c *Chain) addProcessor(processor Processor) { if processor == nil { return } c.processors = append(c.processors, processor) } -func (c chain) ProcessEvent(e *event.Event) (*event.Event, error) { +func (c *Chain) ProcessEvent(e *event.Event) (*event.Event, error) { var errs = make([]error, 0) var evt *event.Event @@ -69,8 +60,14 @@ func (c chain) ProcessEvent(e *event.Event) (*event.Event, error) { return evt, nil } +func (c *Chain) DequeueStackwalk(stackID uint64) { + if c.fsProcessor != nil { + c.fsProcessor.(*fsProcessor).dequeueStackwalk(stackID) + } +} + // Close closes the processor chain and frees all allocated resources. -func (c chain) Close() error { +func (c *Chain) Close() error { for _, processor := range c.processors { processor.Close() } diff --git a/internal/etw/processors/chain_windows.go b/internal/etw/processors/chain_windows.go index f7db20e34..ea6514f95 100644 --- a/internal/etw/processors/chain_windows.go +++ b/internal/etw/processors/chain_windows.go @@ -20,37 +20,37 @@ package processors import ( "github.com/rabbitstack/fibratus/pkg/config" - "github.com/rabbitstack/fibratus/pkg/fs" "github.com/rabbitstack/fibratus/pkg/handle" "github.com/rabbitstack/fibratus/pkg/ps" "github.com/rabbitstack/fibratus/pkg/util/va" ) -type chain struct { +type Chain struct { processors []Processor psnapshotter ps.Snapshotter + fsProcessor Processor } // NewChain constructs the processor chain. It arranges all the processors -// according to enabled kernel event categories. +// according to enabled event categories. func NewChain( psnap ps.Snapshotter, hsnap handle.Snapshotter, config *config.Config, -) Chain { +) *Chain { var ( - chain = &chain{ + chain = &Chain{ psnapshotter: psnap, processors: make([]Processor, 0), } - devMapper = fs.NewDevMapper() vaRegionProber = va.NewRegionProber() ) chain.addProcessor(newPsProcessor(psnap, vaRegionProber)) if config.EventSource.EnableFileIOEvents { - chain.addProcessor(newFsProcessor(hsnap, psnap, devMapper, config)) + chain.fsProcessor = newFsProcessor(hsnap, psnap, config) + chain.addProcessor(chain.fsProcessor) } if config.EventSource.EnableRegistryEvents { chain.addProcessor(newRegistryProcessor(hsnap)) @@ -62,7 +62,7 @@ func NewChain( chain.addProcessor(newNetProcessor()) } if config.EventSource.EnableHandleEvents { - chain.addProcessor(newHandleProcessor(hsnap, psnap, devMapper)) + chain.addProcessor(newHandleProcessor(hsnap, psnap)) } if config.EventSource.EnableMemEvents { chain.addProcessor(newMemProcessor(psnap, vaRegionProber)) From 2c865c9bfe4d4039e4e2b87c7069d8a797501cdd Mon Sep 17 00:00:00 2001 From: rabbitstack Date: Sun, 7 Jun 2026 19:33:40 +0200 Subject: [PATCH 04/15] perf(processors): More efficient registry path concatenation --- internal/etw/processors/registry_windows.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/etw/processors/registry_windows.go b/internal/etw/processors/registry_windows.go index 80567675b..d3979493f 100644 --- a/internal/etw/processors/registry_windows.go +++ b/internal/etw/processors/registry_windows.go @@ -139,7 +139,7 @@ func (r *registryProcessor) processEvent(e *event.Event) (*event.Event, error) { path := e.Params.MustGetString(params.RegPath) if kcb != 0 { if baseKey, ok := r.keys[kcb]; ok { - path = baseKey + "\\" + path + path = key.ConcatPaths(baseKey, path) } else { kcbMissCount.Add(1) path = r.findMatchingKey(e.PID, path) From 35197a70c3ed0ef550bcba64e80432d338473034 Mon Sep 17 00:00:00 2001 From: rabbitstack Date: Sun, 7 Jun 2026 19:34:15 +0200 Subject: [PATCH 05/15] refactor(processors): Use fs dev mapper singleton --- internal/etw/processors/handle_windows.go | 13 +++++-------- internal/etw/processors/handle_windows_test.go | 3 +-- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/internal/etw/processors/handle_windows.go b/internal/etw/processors/handle_windows.go index ea01de038..0ce844897 100644 --- a/internal/etw/processors/handle_windows.go +++ b/internal/etw/processors/handle_windows.go @@ -28,20 +28,17 @@ import ( ) type handleProcessor struct { - hsnap handle.Snapshotter - psnap ps.Snapshotter - devMapper fs.DevMapper + hsnap handle.Snapshotter + psnap ps.Snapshotter } func newHandleProcessor( hsnap handle.Snapshotter, psnap ps.Snapshotter, - devMapper fs.DevMapper, ) Processor { return &handleProcessor{ - hsnap: hsnap, - psnap: psnap, - devMapper: devMapper, + hsnap: hsnap, + psnap: psnap, } } @@ -80,7 +77,7 @@ func (h *handleProcessor) processEvent(e *event.Event) (*event.Event, error) { name += "\\" + keyName } case handle.File: - name = h.devMapper.Convert(name) + name = fs.GetDevMapper().Convert(name) } // assign the formatted handle name if err := e.Params.SetValue(params.HandleObjectName, name); err != nil { diff --git a/internal/etw/processors/handle_windows_test.go b/internal/etw/processors/handle_windows_test.go index d7f09138c..62fa5e50a 100644 --- a/internal/etw/processors/handle_windows_test.go +++ b/internal/etw/processors/handle_windows_test.go @@ -23,7 +23,6 @@ import ( "github.com/rabbitstack/fibratus/pkg/event" "github.com/rabbitstack/fibratus/pkg/event/params" - "github.com/rabbitstack/fibratus/pkg/fs" "github.com/rabbitstack/fibratus/pkg/handle" "github.com/rabbitstack/fibratus/pkg/ps" "github.com/stretchr/testify/assert" @@ -93,7 +92,7 @@ func TestHandleProcessor(t *testing.T) { t.Run(tt.name, func(t *testing.T) { hsnap := tt.hsnap() psnap := new(ps.SnapshotterMock) - p := newHandleProcessor(hsnap, psnap, fs.NewDevMapper()) + p := newHandleProcessor(hsnap, psnap) var err error tt.e, _, err = p.ProcessEvent(tt.e) require.NoError(t, err) From 98b24101af5041dd0dfeb00f31617af18312ebf5 Mon Sep 17 00:00:00 2001 From: rabbitstack Date: Sun, 7 Jun 2026 19:35:37 +0200 Subject: [PATCH 06/15] new(eventsource): Dispatch events to approvers for final verdict --- internal/etw/consumer.go | 28 ++++++++++++++++++---------- internal/etw/source.go | 3 ++- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/internal/etw/consumer.go b/internal/etw/consumer.go index a54c5269d..f2f599d54 100644 --- a/internal/etw/consumer.go +++ b/internal/etw/consumer.go @@ -19,6 +19,7 @@ package etw import ( + "github.com/rabbitstack/fibratus/internal/etw/approvers" "github.com/rabbitstack/fibratus/internal/etw/processors" "github.com/rabbitstack/fibratus/pkg/config" "github.com/rabbitstack/fibratus/pkg/event" @@ -36,7 +37,8 @@ import ( type Consumer struct { q *event.Queue sequencer *event.Sequencer - processors processors.Chain + processors *processors.Chain + approvers approvers.Approvers psnap ps.Snapshotter config *config.Config filter filter.Filter @@ -49,7 +51,8 @@ func NewConsumer( config *config.Config, sequencer *event.Sequencer, evts chan *event.Event, - processors processors.Chain, + processors *processors.Chain, + r *config.RulesCompileResult, ) *Consumer { return &Consumer{ q: event.NewQueueWithChannel(evts, config.EventSource.StackEnrichment, config.ForwardMode || config.IsCaptureSet()), @@ -57,6 +60,7 @@ func NewConsumer( processors: processors, psnap: psnap, config: config, + approvers: approvers.New(psnap, r, processors), } } @@ -69,25 +73,32 @@ func (c *Consumer) Close() error { return c.processors.Close() } -func (c *Consumer) ProcessEvent(ev *etw.EventRecord) error { +func (c *Consumer) ProcessEvent(r *etw.EventRecord) error { if c.isClosing { return nil } - if !c.config.EventSource.EventExists(ev.ID()) { + if !c.config.EventSource.EventExists(r.ID()) { eventsUnknown.Add(1) return nil } - if event.IsCurrentProcDropped(ev.Header.ProcessID) && ev.Header.ProviderID != etw.WindowsKernelProcessGUID { + if event.IsCurrentProcDropped(r.Header.ProcessID) && r.Header.ProviderID != etw.WindowsKernelProcessGUID { return nil } - if c.config.EventSource.ExcludeEvent(ev.ID()) { + + rec, approved := c.approvers.Approve(r) + if !approved { + return nil + } + defer c.approvers.Cleanup(rec) + + if c.config.EventSource.ExcludeEvent(rec.ID()) { eventsExcluded.Add(1) return nil } eventsProcessed.Add(1) - evt := event.New(c.sequencer.Get(), ev) + evt := event.New(c.sequencer.Get(), rec) // Dispatch each event to the processor chain. // Processors may further augment the event with @@ -100,9 +111,6 @@ func (c *Consumer) ProcessEvent(ev *etw.EventRecord) error { if err != nil { return err } - if evt.WaitEnqueue { - return nil - } ok, proc := c.psnap.Find(evt.PID) if !ok { c.psnap.Put(proc) diff --git a/internal/etw/source.go b/internal/etw/source.go index 8512899bb..bdb050290 100644 --- a/internal/etw/source.go +++ b/internal/etw/source.go @@ -75,7 +75,7 @@ type EventSource struct { r *config.RulesCompileResult traces []*Trace consumers []*Consumer - processors processors.Chain + processors *processors.Chain errs chan error evts chan *event.Event @@ -253,6 +253,7 @@ func (e *EventSource) Open(config *config.Config) error { e.sequencer, e.evts, e.processors, + e.r, ) consumer.SetFilter(e.filter) From f79d5d4b3180d138112b45ad4eaf4ee38dd2e31a Mon Sep 17 00:00:00 2001 From: rabbitstack Date: Sun, 7 Jun 2026 19:36:30 +0200 Subject: [PATCH 07/15] refactor(event): Transform WaitEnqueue flag to padding --- pkg/event/event.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/pkg/event/event.go b/pkg/event/event.go index 65f21f719..bd1fc85d5 100644 --- a/pkg/event/event.go +++ b/pkg/event/event.go @@ -84,11 +84,7 @@ type Event struct { Type Type `json:"-"` // CPU designates the processor logical core where the event was originated. CPU uint8 `json:"cpu"` - // WaitEnqueue indicates if this event should temporarily defer pushing to - // the consumer output queue. This is usually required in event processors - // to propagate certain events stored in processor's state when the related - // event arrives. - WaitEnqueue bool `json:"waitenqueue"` + _ uint8 // padding // Name is the human friendly name of the event. Name string `json:"name"` From 9da39ceb09846ac0ec23fba00581101579d6c3fd Mon Sep 17 00:00:00 2001 From: rabbitstack Date: Sun, 7 Jun 2026 19:37:30 +0200 Subject: [PATCH 08/15] refactor(event): Use fs dev mapper singleton in param resolution --- pkg/event/param_windows.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pkg/event/param_windows.go b/pkg/event/param_windows.go index b5f437f1e..2025461e0 100644 --- a/pkg/event/param_windows.go +++ b/pkg/event/param_windows.go @@ -63,8 +63,6 @@ func NewParam(name string, typ params.Type, value params.Value, options ...Param return &Param{Name: name, Type: typ, Value: v, Flags: opts.flags, Enum: opts.enum} } -var devMapper = fs.NewDevMapper() - // String returns the string representation of the parameter value. func (p Param) String() string { if p.Value == nil { @@ -83,7 +81,7 @@ func (p Param) String() string { } return sid.String() case params.DOSPath: - return devMapper.Convert(p.Value.(string)) + return fs.GetDevMapper().Convert(p.Value.(string)) case params.Key: rootKey, keyName := key.Format(p.Value.(string)) if keyName != "" && rootKey != key.Invalid { From 28611a89782dee4d6f955ccee981229fd66b93af Mon Sep 17 00:00:00 2001 From: rabbitstack Date: Sun, 7 Jun 2026 19:38:48 +0200 Subject: [PATCH 09/15] refactor(event): Read disposition/status params directly from extended data items --- pkg/event/param_decoder_windows.go | 5 +++++ pkg/event/param_decoder_windows_test.go | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/pkg/event/param_decoder_windows.go b/pkg/event/param_decoder_windows.go index 271325088..3e8ad801c 100644 --- a/pkg/event/param_decoder_windows.go +++ b/pkg/event/param_decoder_windows.go @@ -168,6 +168,11 @@ func (d *ParamDecoder) DecodeFile(r *etw.EventRecord, e *Event) { e.AppendParam(params.FileAttributes, params.Flags, r.ReadUint32(24), WithFlags(FileAttributeFlags)) e.AppendParam(params.FileShareMask, params.Flags, r.ReadUint32(28), WithFlags(FileShareModeFlags)) e.AppendParam(params.FilePath, params.DOSPath, r.ConsumeUTF16String(32)) + + // read create disposition/status from extended data items + disposition, status := r.ReadEventHeaderFileExtendedDataItems() + e.AppendParam(params.NTStatus, params.Status, status) + e.AppendEnum(params.FileOperation, disposition, fs.FileCreateDispositions) case FileOpEndID: // typedef struct _PERFINFO_FILE_OPERATION_END { // ULONG_PTR Irp; diff --git a/pkg/event/param_decoder_windows_test.go b/pkg/event/param_decoder_windows_test.go index 85b6f493d..09363a3cf 100644 --- a/pkg/event/param_decoder_windows_test.go +++ b/pkg/event/param_decoder_windows_test.go @@ -147,13 +147,15 @@ func TestDecodeFile(t *testing.T) { { name: "CreateFile", opcode: CreateFileID, assertions: func(t *testing.T, e *Event) { - assert.Len(t, e.Params, 7) + assert.Len(t, e.Params, 9) assert.Equal(t, uint64(0xffffd78d965e07c8), e.Params.MustGetUint64(params.FileIrpPtr)) assert.Equal(t, uint64(0xffffd78d920b6650), e.Params.MustGetUint64(params.FileObject)) assert.Equal(t, `\Device\HarddiskVolume3\WINDOWS\AppCompat\Programs\Amcache.hve`, e.Params.MustGetString(params.FilePath)) assert.Equal(t, "NORMAL", e.GetParamAsString(params.FileAttributes)) assert.Equal(t, "SEQUENTIAL_ONLY|SYNCHRONOUS_IO_NONALERT|NO_COMPRESSION", e.GetParamAsString(params.FileCreateOptions)) assert.Equal(t, uint32(6536), e.Params.MustGetTid()) + assert.Equal(t, "SUPERSEDE", e.GetParamAsString(params.FileOperation)) + assert.Equal(t, "Success", e.GetParamAsString(params.NTStatus)) }, buf: []byte{ 200, 7, 94, 150, 141, 215, 255, 255, From 086d45fbd70008163878d3dcb6407c5c6458c3ba Mon Sep 17 00:00:00 2001 From: rabbitstack Date: Sun, 7 Jun 2026 19:41:12 +0200 Subject: [PATCH 10/15] refactor(evasion): Stop gating CreateFile with open disposition With approvers many CreateFile events with OPEN disposition mask are rejected early. This allows us to dispatch all CreateFile events to the evasion scanners regardless of the file operation. --- internal/evasion/scanner.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/internal/evasion/scanner.go b/internal/evasion/scanner.go index e1eb08ad6..26951f0fd 100644 --- a/internal/evasion/scanner.go +++ b/internal/evasion/scanner.go @@ -56,13 +56,6 @@ func NewScanner(config Config) *Scanner { } func (s *Scanner) ProcessEvent(e *event.Event) (bool, error) { - // filter out CreateFile events with the open disposition - // as they tend to be noisy and could impact performance - // when hitting evasion detectors - if e.IsOpenDisposition() { - return true, nil - } - var enq bool // run registered evasion detectors From c9e499f6dcae20b74c6d73b637953f60ccd6c4a2 Mon Sep 17 00:00:00 2001 From: rabbitstack Date: Sun, 7 Jun 2026 19:44:07 +0200 Subject: [PATCH 11/15] feat(rules_engine): Populate approver predicates At rule compilation time, the compiler traverses the AST tree and attempts to find event types for which approver predicates can be derived. Most notably, file and registry paths, file extensions, file base names and process executable paths are all candidates for approver predicates. --- pkg/config/filters.go | 58 +++++++++- pkg/filter/filter.go | 6 + pkg/filter/ql/expr.go | 2 +- pkg/filter/ql/lexer.go | 18 +-- pkg/filter/ql/lexer_test.go | 2 +- pkg/filter/ql/parser.go | 4 +- pkg/filter/ql/token.go | 22 ++-- pkg/rules/compiler.go | 211 +++++++++++++++++++++++++++++++++- pkg/rules/compiler_test.go | 221 ++++++++++++++++++++++++++++++++++++ 9 files changed, 511 insertions(+), 33 deletions(-) diff --git a/pkg/config/filters.go b/pkg/config/filters.go index 87ab6bb96..c704f319d 100644 --- a/pkg/config/filters.go +++ b/pkg/config/filters.go @@ -202,11 +202,59 @@ type RulesCompileResult struct { HasThreadpoolEvents bool UsedEvents []event.Type NumberRules int + Approvers Approvers } -func (r RulesCompileResult) ContainsEvent(Type event.Type) bool { - for _, ktyp := range r.UsedEvents { - if ktyp == Type { +type Approvers struct { + Keys map[string][]string + Paths map[string][]string + Extensions map[string][]string + Bases map[string][]string + Executables map[string][]string +} + +func (p *Approvers) AppendKey(op, path string) { + if slices.Contains(p.Keys[op], path) { + return + } + p.Keys[op] = append(p.Keys[op], path) +} + +func (p *Approvers) AppendPath(op, path string) { + if slices.Contains(p.Paths[op], path) { + return + } + p.Paths[op] = append(p.Paths[op], path) +} + +func (p *Approvers) AppendExtension(op, ext string) { + if slices.Contains(p.Extensions[op], ext) { + return + } + p.Extensions[op] = append(p.Extensions[op], ext) +} + +func (p *Approvers) AppendBase(op, base string) { + if slices.Contains(p.Bases[op], base) { + return + } + p.Bases[op] = append(p.Bases[op], base) +} + +func (p *Approvers) AppendExecutable(op, exe string) { + if slices.Contains(p.Executables[op], exe) { + return + } + p.Executables[op] = append(p.Executables[op], exe) +} + +func (p Approvers) String() string { + return fmt.Sprintf("Keys: %v, Paths: %v, Extensions: %v, Bases: %v, Executables: %v", p.Keys, p.Paths, p.Extensions, p.Bases, p.Executables) +} + +func (r RulesCompileResult) ContainsEvent(e event.Type) bool { + for _, typ := range r.UsedEvents { + if typ == e { return true } } @@ -237,7 +285,8 @@ func (r RulesCompileResult) String() string { HasAuditAPIEvents: %t HasDNSEvents: %t HasThreadpoolEvents: %t - Events: %s`, + Events: %s + Approvers: %s`, r.HasProcEvents, r.HasThreadEvents, r.HasModuleEvents, @@ -251,6 +300,7 @@ func (r RulesCompileResult) String() string { r.HasDNSEvents, r.HasThreadpoolEvents, strings.Join(events, ", "), + r.Approvers, ) } diff --git a/pkg/filter/filter.go b/pkg/filter/filter.go index 3a05dcec5..5ff528b7c 100644 --- a/pkg/filter/filter.go +++ b/pkg/filter/filter.go @@ -69,6 +69,8 @@ type Filter interface { GetSequence() *ql.Sequence // IsSequence determines if this filter is a sequence. IsSequence() bool + // Expr returns the raw AST expression. + Expr() ql.Expr } // Field contains field meta attributes all accessors need to extract the value. @@ -263,6 +265,10 @@ func (f *filter) EvalWithValuer(e *event.Event, cache *ValuerCache) bool { return ql.Eval(f.expr, f.mapValuer(e, cache), f.hasFunctions) } +func (f *filter) Expr() ql.Expr { + return f.expr +} + // evalBoundSequence evaluates the sequence with bound fields // and returns true if the sequence expression matches or false // otherwise. diff --git a/pkg/filter/ql/expr.go b/pkg/filter/ql/expr.go index cbf39499e..94f565ddb 100644 --- a/pkg/filter/ql/expr.go +++ b/pkg/filter/ql/expr.go @@ -49,7 +49,7 @@ func (e *ParenExpr) String() string { // BinaryExpr represents an operation between two expressions. type BinaryExpr struct { - Op token + Op Token LHS Expr RHS Expr } diff --git a/pkg/filter/ql/lexer.go b/pkg/filter/ql/lexer.go index 4733285e4..d9b5cc0d5 100644 --- a/pkg/filter/ql/lexer.go +++ b/pkg/filter/ql/lexer.go @@ -43,7 +43,7 @@ func newScanner(r io.Reader) *scanner { // scan returns the next token and position from the underlying reader. // Also returns the literal text read for strings, numbers, and duration tokens // since these token types can have different literal representations. -func (s *scanner) scan() (tok token, pos int, lit string) { +func (s *scanner) scan() (tok Token, pos int, lit string) { // Read next code point. ch0, pos := s.r.read() @@ -124,7 +124,7 @@ func (s *scanner) scan() (tok token, pos int, lit string) { } // scanWhitespace consumes the current rune and all contiguous whitespace. -func (s *scanner) scanWhitespace() (tok token, pos int, lit string) { +func (s *scanner) scanWhitespace() (tok Token, pos int, lit string) { // Create a buffer and read the current character into it. var buf bytes.Buffer ch, pos := s.r.curr() @@ -147,7 +147,7 @@ func (s *scanner) scanWhitespace() (tok token, pos int, lit string) { return WS, pos, buf.String() } -func (s *scanner) scanIdent() (tok token, pos int, lit string) { +func (s *scanner) scanIdent() (tok Token, pos int, lit string) { // Save the starting position of the identifier. _, pos = s.r.read() s.r.unread() @@ -180,7 +180,7 @@ func (s *scanner) scanIdent() (tok token, pos int, lit string) { } // scanNumber consumes anything that looks like the start of a number. -func (s *scanner) scanNumber() (tok token, pos int, lit string) { +func (s *scanner) scanNumber() (tok Token, pos int, lit string) { var buf bytes.Buffer // Check if the initial rune is a ".". @@ -322,7 +322,7 @@ func scanBareIdent(r io.RuneScanner) string { // scanString consumes a contiguous string of non-quote characters. // Quote characters can be consumed if they're first escaped with a backslash. -func (s *scanner) scanString() (tok token, pos int, lit string) { +func (s *scanner) scanString() (tok Token, pos int, lit string) { s.r.unread() _, pos = s.r.curr() @@ -384,7 +384,7 @@ type bufScanner struct { i int // buffer index n int // buffer size buf [3]struct { - tok token + tok Token pos int lit string } @@ -396,12 +396,12 @@ func newBufScanner(r io.Reader) *bufScanner { } // scan reads the next token from the scanner. -func (s *bufScanner) scan() (tok token, pos int, lit string) { +func (s *bufScanner) scan() (tok Token, pos int, lit string) { return s.scanFunc(s.s.scan) } // scanFunc uses the provided function to scan the next token. -func (s *bufScanner) scanFunc(scan func() (token, int, string)) (tok token, pos int, lit string) { +func (s *bufScanner) scanFunc(scan func() (Token, int, string)) (tok Token, pos int, lit string) { // If we have unread tokens then read them off the buffer first. if s.n > 0 { s.n-- @@ -420,7 +420,7 @@ func (s *bufScanner) scanFunc(scan func() (token, int, string)) (tok token, pos func (s *bufScanner) unscan() { s.n++ } // curr returns the last read token. -func (s *bufScanner) curr() (tok token, pos int, lit string) { +func (s *bufScanner) curr() (tok Token, pos int, lit string) { buf := &s.buf[(s.i-s.n+len(s.buf))%len(s.buf)] return buf.tok, buf.pos, buf.lit } diff --git a/pkg/filter/ql/lexer_test.go b/pkg/filter/ql/lexer_test.go index ee229eb21..b9f2929de 100644 --- a/pkg/filter/ql/lexer_test.go +++ b/pkg/filter/ql/lexer_test.go @@ -26,7 +26,7 @@ import ( func TestScanner(t *testing.T) { var tests = []struct { s string - tok token + tok Token lit string pos int }{ diff --git a/pkg/filter/ql/parser.go b/pkg/filter/ql/parser.go index d8edda461..a86ff98c4 100644 --- a/pkg/filter/ql/parser.go +++ b/pkg/filter/ql/parser.go @@ -650,10 +650,10 @@ func parseDuration(s string) (time.Duration, error) { } // scan returns the next token from the underlying scanner. -func (p *Parser) scan() (tok token, pos int, lit string) { return p.s.scan() } +func (p *Parser) scan() (tok Token, pos int, lit string) { return p.s.scan() } // scanIgnoreWhitespace scans the next non-whitespace. -func (p *Parser) scanIgnoreWhitespace() (tok token, pos int, lit string) { +func (p *Parser) scanIgnoreWhitespace() (tok Token, pos int, lit string) { for { tok, pos, lit = p.scan() if tok == WS { diff --git a/pkg/filter/ql/token.go b/pkg/filter/ql/token.go index 5cddc60b1..cad27783a 100644 --- a/pkg/filter/ql/token.go +++ b/pkg/filter/ql/token.go @@ -23,10 +23,10 @@ import ( ) // token represents the lexical token of the filter expression -type token int +type Token int const ( - Illegal token = iota + Illegal Token = iota WS EOF @@ -86,11 +86,11 @@ const ( As // AS ) -var keywords map[string]token +var keywords map[string]Token func init() { - keywords = make(map[string]token) - for _, tok := range []token{And, Or, Contains, IContains, In, + keywords = make(map[string]Token) + for _, tok := range []Token{And, Or, Contains, IContains, In, IIn, Not, Startswith, IStartswith, Endswith, IEndswith, Matches, IMatches, Fuzzy, IFuzzy, Fuzzynorm, IFuzzynorm, Intersects, IIntersects, Seq, MaxSpan, By, As} { @@ -161,18 +161,18 @@ var tokens = [...]string{ } // isOperator determines whether the current token is an operator. -func (tok token) isOperator() bool { return tok > opBeg && tok < opEnd } +func (tok Token) isOperator() bool { return tok > opBeg && tok < opEnd } // String returns the string representation of the token. -func (tok token) String() string { - if tok >= 0 && tok < token(len(tokens)) { +func (tok Token) String() string { + if tok >= 0 && tok < Token(len(tokens)) { return tokens[tok] } return "" } // precedence returns the operator precedence of the binary operator token. -func (tok token) precedence() int { +func (tok Token) precedence() int { switch tok { case Or: return 1 @@ -189,7 +189,7 @@ func (tok token) precedence() int { return 0 } -func tokstr(tok token, lit string) string { +func tokstr(tok Token, lit string) string { if lit != "" { return lit } @@ -197,7 +197,7 @@ func tokstr(tok token, lit string) string { } // lookup returns the token associated with a given string. -func lookup(id string) (token, string) { +func lookup(id string) (Token, string) { if tok, ok := keywords[strings.ToLower(id)]; ok { return tok, "" } diff --git a/pkg/rules/compiler.go b/pkg/rules/compiler.go index 516e3668e..d0c107df3 100644 --- a/pkg/rules/compiler.go +++ b/pkg/rules/compiler.go @@ -21,12 +21,16 @@ package rules import ( "expvar" "fmt" + "slices" + "strings" semver "github.com/hashicorp/go-version" "github.com/rabbitstack/fibratus/pkg/config" "github.com/rabbitstack/fibratus/pkg/event" + "github.com/rabbitstack/fibratus/pkg/event/params" "github.com/rabbitstack/fibratus/pkg/filter" "github.com/rabbitstack/fibratus/pkg/filter/fields" + "github.com/rabbitstack/fibratus/pkg/filter/ql" "github.com/rabbitstack/fibratus/pkg/ps" "github.com/rabbitstack/fibratus/pkg/util/version" log "github.com/sirupsen/logrus" @@ -54,12 +58,19 @@ var ( ) type compiler struct { - psnap ps.Snapshotter - config *config.Config + psnap ps.Snapshotter + config *config.Config + approvers config.Approvers } -func newCompiler(psnap ps.Snapshotter, config *config.Config) *compiler { - return &compiler{psnap: psnap, config: config} +func newCompiler(psnap ps.Snapshotter, cfg *config.Config) *compiler { + return &compiler{psnap: psnap, config: cfg, approvers: config.Approvers{ + Keys: make(map[string][]string), + Paths: make(map[string][]string), + Extensions: make(map[string][]string), + Bases: make(map[string][]string), + Executables: make(map[string][]string), + }} } func (c *compiler) compile() (map[*config.FilterConfig]filter.Filter, *config.RulesCompileResult, error) { @@ -125,6 +136,17 @@ func (c *compiler) compile() (map[*config.FilterConfig]filter.Filter, *config.Ru } } + // visit filter or sequence expressions + // to extract approver predicates + expr := fltr.Expr() + if expr != nil { + c.visitApproverPredicates(expr) + } else { + for _, expr := range fltr.GetSequence().Expressions { + c.visitApproverPredicates(expr.Expr) + } + } + filters[f] = fltr } @@ -132,7 +154,176 @@ func (c *compiler) compile() (map[*config.FilterConfig]filter.Filter, *config.Ru return filters, nil, nil } - return filters, c.buildCompileResult(filters), nil + r := c.buildCompileResult(filters) + if r != nil { + r.Approvers = c.approvers + } + + return filters, r, nil +} + +func (c *compiler) visitApproverPredicates(node ql.Node) { + walk := func(n ql.Node) { + expr, ok := n.(*ql.BinaryExpr) + if !ok { + return + } + + // skip expressions wrapped in NOT + if c.isNegated(node, n) { + return + } + + lhs, ok := expr.LHS.(*ql.FieldLiteral) + if !ok { + return + } + + // only extract if the rule targets interested event types + if !c.referencesApproverEvents(node) { + return + } + + // extract the string value(s) from RHS + values, ok := rhsToStrings(expr.RHS) + if !ok { + return + } + + op := expr.Op.String() + + switch lhs.Field { + case fields.RegistryPath: + for _, v := range values { + c.approvers.AppendKey(op, v) + } + case fields.FilePath: + for _, v := range values { + c.approvers.AppendPath(op, v) + } + case fields.FileExtension: + for _, v := range values { + c.approvers.AppendExtension(op, v) + } + case fields.FileName: + for _, v := range values { + c.approvers.AppendBase(op, v) + } + case fields.EvtArg: + if lhs.Arg == params.Exe { + for _, v := range values { + c.approvers.AppendExecutable(op, v) + } + } + } + } + ql.WalkFunc(node, walk) +} + +// referencesTargetEvents checks whether the rule AST contains +// an event type filter for high-volume events we want to approve. +func (c *compiler) referencesApproverEvents(root ql.Node) bool { + var found bool + ql.WalkFunc(root, func(n ql.Node) { + expr, ok := n.(*ql.BinaryExpr) + if !ok { + return + } + + // direct event match. We also include SetFileInformation + // to approve any paths referenced in the condition + if c.containsEventTypes(expr, event.RegOpenKey, event.OpenThread, event.OpenProcess, event.SetFileInformation) { + found = true + return + } + + // for file events require open file operation + if expr.Op == ql.And { + if c.containsEventTypes(expr, event.CreateFile) && c.containsFieldMatch(expr, fields.FileOperation, ql.Eq, "OPEN") { + found = true + } + } + }) + return found +} + +func (c *compiler) containsEventTypes(root ql.Node, types ...event.Type) bool { + var contains bool + ql.WalkFunc(root, func(n ql.Node) { + expr, ok := n.(*ql.BinaryExpr) + if !ok { + return + } + lhs, ok := expr.LHS.(*ql.FieldLiteral) + if !ok || lhs.Field != fields.EvtName { + return + } + + vals, ok := rhsToStrings(expr.RHS) + if !ok { + return + } + + evts := make([]event.Type, 0, len(vals)) + for _, v := range vals { + evts = append(evts, event.NameToType(v)) + } + + for _, typ := range types { + if slices.Contains(evts, typ) { + contains = true + return + } + } + }) + return contains +} + +func (c *compiler) containsFieldMatch(root ql.Node, field fields.Field, op ql.Token, val string) bool { + var contains bool + ql.WalkFunc(root, func(n ql.Node) { + expr, ok := n.(*ql.BinaryExpr) + if !ok { + return + } + + lhs, ok := expr.LHS.(*ql.FieldLiteral) + if !ok || lhs.Field != field { + return + } + + if expr.Op != op { + return + } + + values, ok := rhsToStrings(expr.RHS) + if !ok { + return + } + for _, v := range values { + if strings.EqualFold(v, val) { + contains = true + return + } + } + }) + return contains +} + +// isNegated walks up the AST to check if the given node +// is a direct child of a NOT unary expression. +func (c *compiler) isNegated(root ql.Node, node ql.Node) bool { + negated := false + ql.WalkFunc(root, func(n ql.Node) { + unary, ok := n.(*ql.NotExpr) + if !ok { + return + } + if unary.Expr == node { + negated = true + } + }) + return negated } func (c *compiler) buildCompileResult(filters map[*config.FilterConfig]filter.Filter) *config.RulesCompileResult { @@ -195,3 +386,13 @@ func (c *compiler) buildCompileResult(filters map[*config.FilterConfig]filter.Fi return rs } + +func rhsToStrings(n ql.Node) ([]string, bool) { + switch v := n.(type) { + case *ql.StringLiteral: + return []string{v.Value}, true + case *ql.ListLiteral: + return v.Values, true + } + return []string{}, false +} diff --git a/pkg/rules/compiler_test.go b/pkg/rules/compiler_test.go index 664ca01cd..46e54ac78 100644 --- a/pkg/rules/compiler_test.go +++ b/pkg/rules/compiler_test.go @@ -22,6 +22,7 @@ import ( "testing" "github.com/rabbitstack/fibratus/pkg/event" + "github.com/rabbitstack/fibratus/pkg/filter/ql" "github.com/rabbitstack/fibratus/pkg/ps" "github.com/rabbitstack/fibratus/pkg/util/version" "github.com/stretchr/testify/assert" @@ -97,3 +98,223 @@ func TestCompileEventCategoryFieldNames(t *testing.T) { }) } } + +func TestVisitApproverPredicatesRegistryEvents(t *testing.T) { + tests := []struct { + name string + expr string + want bool + }{ + { + name: "RegSetValue does not match", + expr: "evt.name = 'RegSetValue'", + want: false, + }, + { + name: "RegOpenKey matches", + expr: "evt.name = 'RegOpenKey'", + want: true, + }, + { + name: "CreateProcess does not match", + expr: "evt.name = 'CreateProcess'", + want: false, + }, + { + name: "RegOpenKey event nested in AND matches", + expr: `evt.name = 'RegOpenKey' and registry.path imatches ('HKEY_LOCAL_MACHINE\\SYSTEM\\*')`, + want: true, + }, + { + name: "Registry event nested in paren matches", + expr: "(evt.name = 'RegOpenKey')", + want: true, + }, + } + + c := newCompiler(new(ps.SnapshotterMock), newConfig("_fixtures/default/*.yml")) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := ql.NewParser(tt.expr) + n, err := p.ParseExpr() + require.NoError(t, err) + got := c.referencesApproverEvents(n) + if got != tt.want { + t.Errorf("registry approver predicates: %v, want %v", got, tt.want) + } + }) + } +} + +func TestVisitApproverPredicatesCreateFile(t *testing.T) { + tests := []struct { + name string + expr string + want bool + }{ + { + name: "CreateFile with OPEN operation matches", + expr: "evt.name = 'CreateFile' and file.operation = 'OPEN'", + want: true, + }, + { + name: "CreateFile without file.operation does not match", + expr: "evt.name = 'CreateFile'", + want: false, + }, + { + name: "CreateFile with non-OPEN operation does not match", + expr: "evt.name = 'CreateFile' and file.operation = 'CREATE'", + want: false, + }, + { + name: "file.operation OPEN without CreateFile does not match", + expr: "file.operation = 'OPEN'", + want: false, + }, + { + name: "CreateFile and OPEN in separate OR branches does not match", + expr: "evt.name = 'CreateFile' or file.operation = 'OPEN'", + want: false, + }, + { + name: "CreateFile with OPEN nested in paren matches", + expr: "evt.name = 'CreateFile' and (file.operation = 'OPEN')", + want: true, + }, + { + name: "CreateFile with OPEN and extra conditions matches", + expr: "evt.name = 'CreateFile' and file.operation = 'OPEN' and file.path imatches '?:\\\\Windows\\\\*'", + want: true, + }, + } + + c := newCompiler(new(ps.SnapshotterMock), newConfig("_fixtures/default/*.yml")) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := ql.NewParser(tt.expr) + n, err := p.ParseExpr() + require.NoError(t, err) + got := c.referencesApproverEvents(n) + if got != tt.want { + t.Errorf("file approver predicates: %v, want %v", got, tt.want) + } + }) + } +} + +func TestAccumulatedApproverPredicates(t *testing.T) { + tests := []struct { + name string + expr string + wantKeys map[string][]string + wantPaths map[string][]string + wantExtensions map[string][]string + wantExecutables map[string][]string + wantBases map[string][]string + }{ + { + name: "extracts registry key path with imatches", + expr: "evt.name = 'RegOpenKey' and registry.path imatches 'HKEY_LOCAL_MACHINE\\\\SYSTEM\\\\*'", + wantKeys: map[string][]string{ + "IMATCHES": {`HKEY_LOCAL_MACHINE\SYSTEM\*`}, + }, + }, + { + name: "extracts file path with imatches", + expr: "evt.name = 'CreateFile' and file.operation = 'OPEN' and file.path imatches 'C:\\\\Windows\\\\*'", + wantPaths: map[string][]string{ + "IMATCHES": {`C:\Windows\*`}, + }, + }, + { + name: "negated registry path is not extracted", + expr: "evt.name = 'RegSetValue' and registry.path not imatches 'HKEY_LOCAL_MACHINE\\\\SOFTWARE\\\\*'", + }, + { + name: "extracts file extension", + expr: "evt.name = 'CreateFile' and file.operation = 'OPEN' and file.extension = '.exe'", + wantExtensions: map[string][]string{ + "=": {".exe"}, + }, + }, + { + name: "extracts file base name", + expr: "evt.name = 'CreateFile' and file.operation = 'OPEN' and file.name icontains 'svchost'", + wantBases: map[string][]string{ + "ICONTAINS": {"svchost"}, + }, + }, + { + name: "extracts process executable", + expr: "evt.name = 'OpenProcess' and evt.arg[exe] icontains 'lsass'", + wantExecutables: map[string][]string{ + "ICONTAINS": {"lsass"}, + }, + }, + } + + c := newCompiler(new(ps.SnapshotterMock), newConfig("")) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := ql.NewParser(tt.expr) + n, err := p.ParseExpr() + require.NoError(t, err) + + c.visitApproverPredicates(n) + + if tt.wantKeys != nil { + assertMapEqual(t, "Keys", c.approvers.Keys, tt.wantKeys) + } + + if tt.wantPaths != nil { + assertMapEqual(t, "Paths", c.approvers.Paths, tt.wantPaths) + } + + if tt.wantExtensions != nil { + assertMapEqual(t, "Extensions", c.approvers.Extensions, tt.wantExtensions) + } + + if tt.wantBases != nil { + assertMapEqual(t, "Bases", c.approvers.Bases, tt.wantBases) + } + + if tt.wantExecutables != nil { + assertMapEqual(t, "Executables", c.approvers.Executables, tt.wantExecutables) + } + }) + } + + assert.Len(t, c.approvers.Paths, 1) + assert.Len(t, c.approvers.Keys, 1) + assert.Len(t, c.approvers.Extensions, 1) + assert.Len(t, c.approvers.Bases, 1) + assert.Len(t, c.approvers.Executables, 1) +} + +func assertMapEqual(t *testing.T, name string, got, want map[string][]string) { + t.Helper() + if len(got) != len(want) { + t.Errorf("%s: got %d keys, want %d keys. got=%v want=%v", name, len(got), len(want), got, want) + return + } + for k, wantVals := range want { + gotVals, ok := got[k] + if !ok { + t.Errorf("%s: missing key %q", name, k) + continue + } + if len(gotVals) != len(wantVals) { + t.Errorf("%s[%q]: got %v, want %v", name, k, gotVals, wantVals) + continue + } + for i, v := range wantVals { + if gotVals[i] != v { + t.Errorf("%s[%q][%d]: got %q, want %q", name, k, i, gotVals[i], v) + } + } + } +} From 5f35b492321ddff23918180a463206f601d784eb Mon Sep 17 00:00:00 2001 From: rabbitstack Date: Sun, 7 Jun 2026 19:44:32 +0200 Subject: [PATCH 12/15] refactor(fs): Convert fs dev mapper to singleton --- pkg/fs/dev.go | 59 +++++++++++++++++++++++++++------------------- pkg/fs/dev_test.go | 8 +++---- 2 files changed, 39 insertions(+), 28 deletions(-) diff --git a/pkg/fs/dev.go b/pkg/fs/dev.go index c55719cbf..68321fef4 100644 --- a/pkg/fs/dev.go +++ b/pkg/fs/dev.go @@ -22,28 +22,39 @@ package fs import ( - "github.com/rabbitstack/fibratus/pkg/sys" "os" "strings" + "sync" + + "github.com/rabbitstack/fibratus/pkg/sys" ) const deviceOffset = 8 const vmsmbDevice = `\Device\vmsmb` -// DevMapper is the minimal interface for the device converters. -type DevMapper interface { - // Convert receives the fully qualified file path and replaces the DOS device name with a drive letter. - Convert(filename string) string +var ( + devMapper *DevMapper + onceDevMapper sync.Once +) + +// GetDevMapper builds and returns the singleton dev mapper instance. +func GetDevMapper() *DevMapper { + onceDevMapper.Do(func() { + devMapper = newDevMapper() + }) + return devMapper } -type mapper struct { +// DevMapper converts the fully qualified file path and +// replaces the DOS device name with a drive letter. +type DevMapper struct { cache map[string]string sysroot string } -// NewDevMapper creates a new instance of the DOS device replacer. -func NewDevMapper() DevMapper { - m := &mapper{ +// newDevMapper creates a new instance of the DOS device replacer. +func newDevMapper() *DevMapper { + m := &DevMapper{ cache: make(map[string]string), } @@ -65,39 +76,39 @@ func NewDevMapper() DevMapper { return m } -func (m *mapper) Convert(filename string) string { - if filename == "" || len(filename) < deviceOffset { - return filename +func (m *DevMapper) Convert(path string) string { + if path == "" || len(path) < deviceOffset { + return path } // find the backslash index - n := strings.Index(filename[deviceOffset:], "\\") + n := strings.Index(path[deviceOffset:], "\\") if n < 0 { - if f, ok := m.cache[filename]; ok { + if f, ok := m.cache[path]; ok { return f } - return filename + return path } - dev := filename[:n+deviceOffset] + dev := path[:n+deviceOffset] if drive, ok := m.cache[dev]; ok { // the mapping for the DOS device exists - return strings.Replace(filename, dev, drive, 1) + return strings.Replace(path, dev, drive, 1) } switch { case dev == vmsmbDevice: // convert Windows Sandbox path to native path - if n := strings.Index(filename, "os"); n > 0 { - return "C:" + filename[n+2:] + if n := strings.Index(path, "os"); n > 0 { + return "C:" + path[n+2:] } - case strings.HasPrefix(filename, "\\SystemRoot"): + case strings.HasPrefix(path, "\\SystemRoot"): // normalize paths starting with SystemRoot - return strings.Replace(filename, "\\SystemRoot", m.sysroot, 1) - case strings.HasPrefix(filename, "\\SYSTEMROOT"): + return strings.Replace(path, "\\SystemRoot", m.sysroot, 1) + case strings.HasPrefix(path, "\\SYSTEMROOT"): // normalize paths starting with SYSTEMROOT - return strings.Replace(filename, "\\SYSTEMROOT", m.sysroot, 1) + return strings.Replace(path, "\\SYSTEMROOT", m.sysroot, 1) } - return filename + return path } diff --git a/pkg/fs/dev_test.go b/pkg/fs/dev_test.go index 972bdba1e..0624326ac 100644 --- a/pkg/fs/dev_test.go +++ b/pkg/fs/dev_test.go @@ -58,7 +58,7 @@ var drives = []string{ "Z"} func TestConvertDosDevice(t *testing.T) { - m := NewDevMapper() + m := GetDevMapper() files := make([]string, 0, len(drives)) for _, drive := range drives { @@ -74,9 +74,9 @@ func TestConvertDosDevice(t *testing.T) { } assert.Contains(t, files, filename) - m.(*mapper).cache["\\Device\\HarddiskVolume1"] = "C:" - m.(*mapper).cache["\\Device\\HarddiskVolume5"] = "\\Device\\HarddiskVolume5" - m.(*mapper).sysroot = "C:\\Windows" + m.cache["\\Device\\HarddiskVolume1"] = "C:" + m.cache["\\Device\\HarddiskVolume5"] = "\\Device\\HarddiskVolume5" + m.sysroot = "C:\\Windows" var tests = []struct { inputFilename string From fcc99522d6b8b19955fe12b63d93cb73fdac3d05 Mon Sep 17 00:00:00 2001 From: rabbitstack Date: Sun, 7 Jun 2026 19:45:04 +0200 Subject: [PATCH 13/15] refactor(handle): Use fs dev mapper singleton in file path resolution --- pkg/handle/object.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pkg/handle/object.go b/pkg/handle/object.go index 530fc1670..6a3584678 100644 --- a/pkg/handle/object.go +++ b/pkg/handle/object.go @@ -24,6 +24,7 @@ package handle import ( "errors" "fmt" + "github.com/rabbitstack/fibratus/pkg/fs" htypes "github.com/rabbitstack/fibratus/pkg/handle/types" "github.com/rabbitstack/fibratus/pkg/sys" @@ -31,8 +32,6 @@ import ( "golang.org/x/sys/windows" ) -var devMapper = fs.NewDevMapper() - // Duplicate duplicates the handle in the caller process's address space. func Duplicate(handle windows.Handle, pid uint32, access uint32) (windows.Handle, error) { // handle to the process with the handle to be duplicated. @@ -91,7 +90,7 @@ func QueryName(handle windows.Handle, typ string, withTimeout bool) (string, hty if err != nil { return "", nil, err } - name = devMapper.Convert(name) + name = fs.GetDevMapper().Convert(name) fileInfo := &htypes.FileInfo{IsDirectory: sys.PathIsDirectory(name)} return name, fileInfo, nil case ALPCPort: From 388a0378e696cde18f0e0b900d803fb85a677a7a Mon Sep 17 00:00:00 2001 From: rabbitstack Date: Sun, 7 Jun 2026 19:50:30 +0200 Subject: [PATCH 14/15] feat(sys): Introduce synthethic extended item types The new fs approver keeps in-flight CreateFile event records and attributes them to FileOpEnd events via IRP link. When the respective FileOpEnd event record arrives, we extract the create disposition and the system status attributes and attach them to the CreateFile event record by pushing synthethic extended items to the event record. --- pkg/sys/etw/types.go | 77 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/pkg/sys/etw/types.go b/pkg/sys/etw/types.go index e540d1521..738b1ba5b 100644 --- a/pkg/sys/etw/types.go +++ b/pkg/sys/etw/types.go @@ -570,6 +570,72 @@ type EventFilterDescriptor struct { Type uint32 } +const ( + // ExtTypeDisposition represents a custom extended item type for the file disposition. + ExtTypeDisposition = 0x8000 + // ExtTypeStatus represents a custom extended item type for the file system status. + ExtTypeStatus = 0x8001 +) + +// FileExtendedDataItems stores file extended data items. +type FileExtendedDataItems struct { + status uint32 + disposition uint32 + items []EventHeaderExtendedDataItem +} + +// AppendEventHeaderFileExtendedDataItems appends custom file extendeed data items to the event record. +func AppendEventHeaderFileExtendedDataItems(r *EventRecord, disposition uint64, status uint32) *FileExtendedDataItems { + f := &FileExtendedDataItems{ + disposition: uint32(disposition), + status: status, + items: make([]EventHeaderExtendedDataItem, 2), + } + + f.items[0] = EventHeaderExtendedDataItem{ + ExtType: ExtTypeDisposition, + DataSize: 4, + DataPtr: uint64(uintptr(unsafe.Pointer(&f.disposition))), + } + f.items[1] = EventHeaderExtendedDataItem{ + ExtType: ExtTypeStatus, + DataSize: 4, + DataPtr: uint64(uintptr(unsafe.Pointer(&f.status))), + } + + r.ExtendedDataCount = uint16(len(f.items)) + r.ExtendedData = &f.items[0] + + return f +} + +// ReadEventHeaderFileExtendedDataItems reads the custom file extended data items from the event record. +func (r *EventRecord) ReadEventHeaderFileExtendedDataItems() (uint32, uint32) { + if r.ExtendedData == nil { + return 0, 0 + } + + items := unsafe.Slice(r.ExtendedData, r.ExtendedDataCount) + + var disposition uint32 + var status uint32 + + for _, item := range items { + switch item.ExtType { + case ExtTypeDisposition: + if item.DataSize == 4 && item.DataPtr != 0 { + disposition = *(*uint32)(unsafe.Pointer(uintptr(item.DataPtr))) + } + case ExtTypeStatus: + if item.DataSize == 4 && item.DataPtr != 0 { + status = *(*uint32)(unsafe.Pointer(uintptr(item.DataPtr))) + } + } + } + + return disposition, status +} + // NewClassicEventID creates a new instance of classic event identifier. func NewClassicEventID(guid windows.GUID, typ uint16) ClassicEventID { return ClassicEventID{GUID: guid, Type: uint8(typ)} @@ -606,6 +672,17 @@ func (e *EventRecord) ID() uint { return id } +// Copy makes a copy of this event record and returns the +// copy itself and the event buffer. The buffer must outlive +// the event record instance. +func (r *EventRecord) Copy() (*EventRecord, []byte) { + c := *r + buf := make([]byte, r.BufferLen) + copy(buf, unsafe.Slice((*byte)(unsafe.Pointer(r.Buffer)), r.BufferLen)) + c.Buffer = uintptr(unsafe.Pointer(&buf[0])) + return &c, buf +} + // ReadByte reads the byte from the buffer at the specified offset. func (e *EventRecord) ReadByte(offset uint16) byte { if offset > e.BufferLen { From 1a057ad4e0af70b8c4945b5407c01fbc63b3437a Mon Sep 17 00:00:00 2001 From: rabbitstack Date: Mon, 8 Jun 2026 18:32:17 +0200 Subject: [PATCH 15/15] perf(key): Use string builder for path concatenation By leveraging the string builder and establishing the capacity of the underlying slice, we can reduce a few unnecessary allocations per event. --- pkg/util/key/key.go | 17 +++++++++++++++-- pkg/util/key/key_test.go | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/pkg/util/key/key.go b/pkg/util/key/key.go index cf59affc1..eed0cf71e 100644 --- a/pkg/util/key/key.go +++ b/pkg/util/key/key.go @@ -22,11 +22,12 @@ package key import ( - "github.com/rabbitstack/fibratus/pkg/sys" - "golang.org/x/sys/windows/registry" "path/filepath" "regexp" "strings" + + "github.com/rabbitstack/fibratus/pkg/sys" + "golang.org/x/sys/windows/registry" ) var ( @@ -202,6 +203,18 @@ func Format(key string) (Key, string) { return Invalid, key } +// ConcatPaths concatenates root and subkey registry paths. +func ConcatPaths(root, path string) string { + var b strings.Builder + b.Grow(len(root) + len(path) + 1) + if root != "" { + b.WriteString(root) + b.WriteByte('\\') + } + b.WriteString(path) + return b.String() +} + func subkey(key string, prefix string) string { if len(key) > len(prefix) { return key[len(prefix)+1:] diff --git a/pkg/util/key/key_test.go b/pkg/util/key/key_test.go index aed798cda..cb9f683b8 100644 --- a/pkg/util/key/key_test.go +++ b/pkg/util/key/key_test.go @@ -22,11 +22,12 @@ package key import ( + "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/sys/windows" "golang.org/x/sys/windows/registry" - "testing" ) func init() { @@ -182,3 +183,33 @@ func TestReadValue(t *testing.T) { }) } } + +func TestConcatPaths(t *testing.T) { + var tests = []struct { + root string + subkey string + expected string + }{ + { + root: "HKEY_LOCAL_MACHINE", + subkey: `Software\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\Capabilities`, + expected: `HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\Capabilities`, + }, + { + root: "", + subkey: `Software\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\Capabilities`, + expected: `Software\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\Capabilities`, + }, + { + root: "HKEY_LOCAL_MACHINE", + subkey: "", + expected: "HKEY_LOCAL_MACHINE\\", + }, + } + + for _, tt := range tests { + t.Run(tt.root+tt.subkey, func(t *testing.T) { + assert.Equal(t, tt.expected, ConcatPaths(tt.root, tt.subkey)) + }) + } +}