From 0e0bf9708cb83d0987ff328a2b071cbe093d252d Mon Sep 17 00:00:00 2001 From: venkata-stepsecurity Date: Wed, 28 Oct 2026 16:14:47 -0700 Subject: [PATCH 1/2] Skip parsing audit events that cannot be detections An event made only of SYSCALL, PROCTITLE and EOE records has no address, path, working directory or arguments, so isEventReady is always false for it. Check the record types before parsing and return early for these. On a busy runner most events are sends on connected sockets, which have no SOCKADDR record. Parsing them was most of the agent's CPU. --- procmon.go | 19 ++++++++++++ procmon_test.go | 80 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/procmon.go b/procmon.go index 19b840a..a83eb21 100644 --- a/procmon.go +++ b/procmon.go @@ -151,6 +151,9 @@ type auditStream struct { } func (s *auditStream) ReassemblyComplete(msgs []*auparse.AuditMessage) { + if !hasDetectionRecord(msgs) { + return + } event := &Event{} for _, msg := range msgs { eventMap := msg.ToMapStr() @@ -164,6 +167,22 @@ func (s *auditStream) ReassemblyComplete(msgs []*auparse.AuditMessage) { } } +// hasDetectionRecord reports whether an event has a record other than SYSCALL, +// PROCTITLE and EOE. Those three never carry an address, path, working +// directory or arguments, so an event made only of them is never ready and is +// not parsed. Most events on a busy runner are of this kind: a send on a +// connected socket has no SOCKADDR record. +func hasDetectionRecord(msgs []*auparse.AuditMessage) bool { + for _, msg := range msgs { + switch msg.RecordType { + case auparse.AUDIT_SYSCALL, auparse.AUDIT_PROCTITLE, auparse.AUDIT_EOE: + default: + return true + } + } + return false +} + // EventsLost is called for gaps in the audit sequence numbers. A gap is not // always a loss, since record types this agent skips use sequence numbers too. func (s *auditStream) EventsLost(count int) { diff --git a/procmon_test.go b/procmon_test.go index bd390ab..31adae8 100644 --- a/procmon_test.go +++ b/procmon_test.go @@ -383,3 +383,83 @@ func TestNoteReceiveError(t *testing.T) { } } } + +func TestHasDetectionRecord(t *testing.T) { + msg := func(typ auparse.AuditMessageType) *auparse.AuditMessage { + return &auparse.AuditMessage{RecordType: typ} + } + tests := []struct { + name string + msgs []*auparse.AuditMessage + want bool + }{ + {"send without address", []*auparse.AuditMessage{msg(auparse.AUDIT_SYSCALL), msg(auparse.AUDIT_PROCTITLE), msg(auparse.AUDIT_EOE)}, false}, + {"syscall only", []*auparse.AuditMessage{msg(auparse.AUDIT_SYSCALL)}, false}, + {"empty", nil, false}, + {"connect", []*auparse.AuditMessage{msg(auparse.AUDIT_SYSCALL), msg(auparse.AUDIT_SOCKADDR), msg(auparse.AUDIT_PROCTITLE)}, true}, + {"execve", []*auparse.AuditMessage{msg(auparse.AUDIT_SYSCALL), msg(auparse.AUDIT_EXECVE), msg(auparse.AUDIT_CWD)}, true}, + {"file", []*auparse.AuditMessage{msg(auparse.AUDIT_SYSCALL), msg(auparse.AUDIT_CWD), msg(auparse.AUDIT_PATH)}, true}, + {"other record type", []*auparse.AuditMessage{msg(auparse.AUDIT_SYSCALL), msg(auparse.AUDIT_CONFIG_CHANGE)}, true}, + } + for _, tt := range tests { + if got := hasDetectionRecord(tt.msgs); got != tt.want { + t.Errorf("%s: hasDetectionRecord() = %v, want %v", tt.name, got, tt.want) + } + } +} + +// Events that are not parsed must be ones that full parsing would not have +// dispatched either. +func TestEventsWithoutDetectionRecordsAreNeverReady(t *testing.T) { + events := [][]auditRecord{ + {syscallRecord(60, 44, "python3", "/usr/bin/python3.12", "netmon"), proctitleRecord(60), eoeRecord(60)}, + {syscallRecord(61, 46, "node", "/usr/bin/node", "netmon"), proctitleRecord(61)}, + {syscallRecord(62, 42, "curl", "/usr/bin/curl", "netmon")}, + {syscallRecord(63, 59, "bash", "/usr/bin/bash", "procmon"), proctitleRecord(63)}, + {syscallRecord(64, 257, "cp", "/usr/bin/cp", "filemon"), proctitleRecord(64)}, + // values the kernel hex-encodes because they contain spaces + {{auparse.AUDIT_SYSCALL, `audit(1700000000.000:65): arch=c000003e syscall=44 success=yes exit=1 a0=3 a1=7f00 a2=1 a3=0 items=0 ppid=1 pid=165 auid=4294967295 uid=1001 gid=1001 euid=1001 suid=1001 fsuid=1001 egid=1001 sgid=1001 fsgid=1001 tty=(none) ses=4294967295 comm=6164647220616464723D312E322E332E34 exe="/tmp/x" key="netmon"`}, + {auparse.AUDIT_PROCTITLE, `audit(1700000000.000:65): proctitle=6E6320616464723D312E322E332E3420706F72743D343433`}}, + } + for i, records := range events { + var msgs []*auparse.AuditMessage + for _, rec := range records { + msg, err := auparse.Parse(rec.msgType, rec.text) + if err != nil { + t.Fatalf("event %d: parse %v: %v", i, rec.msgType, err) + } + msgs = append(msgs, msg) + } + if hasDetectionRecord(msgs) { + t.Fatalf("event %d: expected no detection record", i) + } + event := &Event{} + for _, msg := range msgs { + event.addRecord(msg.ToMapStr()) + } + if isEventReady(event) { + t.Errorf("event %d: fully parsed event is ready: %+v", i, event) + } + } +} + +func TestReassemblySkipsParsingEventsWithoutDetectionRecords(t *testing.T) { + r, stream, rec, p := newTestReassembler(t, reassemblerMaxInFlight, reassemblerTimeout) + + // an arch that cannot be decoded is a field error, but only if parsed + pushRecords(t, r, []auditRecord{ + {auparse.AUDIT_SYSCALL, `audit(1700000000.000:70): arch=zz syscall=44 success=yes exit=1 a0=3 a1=7f00 a2=1 a3=0 items=0 ppid=1 pid=170 comm="python3" exe="/usr/bin/python3.12" key="netmon"`}, + proctitleRecord(70), + eoeRecord(70), + }) + + if stream.delivered.Load() != 1 { + t.Fatalf("expected the event to be delivered once, got %d", stream.delivered.Load()) + } + if got := p.recordErrors.Load(); got != 0 { + t.Fatalf("expected the event not to be parsed, got %d field errors", got) + } + if got := rec.dispatched(); len(got) != 0 { + t.Fatalf("expected nothing dispatched, got %+v", got) + } +} From 80bbc4d28876dc3d784c8dc1482729a4fc82ea94 Mon Sep 17 00:00:00 2001 From: venkata-stepsecurity Date: Wed, 28 Oct 2026 16:32:37 -0700 Subject: [PATCH 2/2] Clarify which records a detection comes from; fail the equivalence test on unparsed fixtures --- procmon.go | 9 +++++---- procmon_test.go | 6 +++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/procmon.go b/procmon.go index a83eb21..0aaad75 100644 --- a/procmon.go +++ b/procmon.go @@ -168,10 +168,11 @@ func (s *auditStream) ReassemblyComplete(msgs []*auparse.AuditMessage) { } // hasDetectionRecord reports whether an event has a record other than SYSCALL, -// PROCTITLE and EOE. Those three never carry an address, path, working -// directory or arguments, so an event made only of them is never ready and is -// not parsed. Most events on a busy runner are of this kind: a send on a -// connected socket has no SOCKADDR record. +// PROCTITLE and EOE. A detection's address, path, working directory and +// arguments come from the SOCKADDR, PATH, CWD and EXECVE records, so an event +// made only of those three is not parsed. Most events on a busy runner are of +// this kind: a send on a connected socket has no SOCKADDR record. Field errors +// in skipped events are not counted. func hasDetectionRecord(msgs []*auparse.AuditMessage) bool { for _, msg := range msgs { switch msg.RecordType { diff --git a/procmon_test.go b/procmon_test.go index 31adae8..9a840ed 100644 --- a/procmon_test.go +++ b/procmon_test.go @@ -435,7 +435,11 @@ func TestEventsWithoutDetectionRecordsAreNeverReady(t *testing.T) { } event := &Event{} for _, msg := range msgs { - event.addRecord(msg.ToMapStr()) + eventMap := msg.ToMapStr() + if parseErr, found := eventMap["error"]; found { + t.Fatalf("event %d: %v record not parsed: %v", i, msg.RecordType, parseErr) + } + event.addRecord(eventMap) } if isEventReady(event) { t.Errorf("event %d: fully parsed event is ready: %+v", i, event)