From 803dfd90275687cb180b7251f69097497b7f08a1 Mon Sep 17 00:00:00 2001 From: rabbitstack Date: Sat, 17 Jan 2026 17:15:25 +0100 Subject: [PATCH 1/3] feat(alertsender,eventlog): Allow events in JSON format This change allows to configure the eventlog alertsender to emit the alerts in JSON format. --- cmd/systray/main_windows.go | 12 ++++++----- configs/fibratus.yml | 3 +++ pkg/alertsender/alert.go | 25 ++++++++++++++++++++++- pkg/alertsender/eventlog/config.go | 8 ++++++++ pkg/alertsender/eventlog/eventlog.go | 20 +++++++++++++++--- pkg/alertsender/eventlog/eventlog_test.go | 7 ++++--- pkg/alertsender/systray/systray_test.go | 10 +++++---- pkg/config/config.schema.json | 4 ++++ 8 files changed, 73 insertions(+), 16 deletions(-) diff --git a/cmd/systray/main_windows.go b/cmd/systray/main_windows.go index c3e479b26..78774fb78 100644 --- a/cmd/systray/main_windows.go +++ b/cmd/systray/main_windows.go @@ -22,6 +22,12 @@ import ( "encoding/json" "errors" "fmt" + "io" + "net" + "os" + "path/filepath" + "unsafe" + "github.com/Microsoft/go-winio" "github.com/mitchellh/mapstructure" "github.com/rabbitstack/fibratus/pkg/alertsender" @@ -31,11 +37,6 @@ import ( "github.com/rabbitstack/fibratus/pkg/util/signals" "github.com/sirupsen/logrus" "golang.org/x/sys/windows" - "io" - "net" - "os" - "path/filepath" - "unsafe" ) const systrayPipe = `\\.\pipe\fibratus-systray` @@ -65,6 +66,7 @@ func (m Msg) decode(output any) error { DecodeHook: mapstructure.ComposeDecodeHookFunc( mapstructure.StringToTimeDurationHookFunc(), mapstructure.StringToSliceHookFunc(","), + alertsender.StringToSeverityDecodeHook(), ), } decoder, err := mapstructure.NewDecoder(decoderConfig) diff --git a/configs/fibratus.yml b/configs/fibratus.yml index 01a21b405..d86d78952 100644 --- a/configs/fibratus.yml +++ b/configs/fibratus.yml @@ -86,6 +86,9 @@ alertsenders: # in the log message. verbose: true + # Specifies the eventlog record format for the alert. Can be pretty|json + format: pretty + # =============================== API ================================================== # Settings that influence the behaviour of the HTTP server that exposes a number of endpoints such as diff --git a/pkg/alertsender/alert.go b/pkg/alertsender/alert.go index 9c3243717..cf68adf4a 100644 --- a/pkg/alertsender/alert.go +++ b/pkg/alertsender/alert.go @@ -21,11 +21,14 @@ package alertsender import ( "bytes" "fmt" + "reflect" + "strings" + + "github.com/mitchellh/mapstructure" "github.com/rabbitstack/fibratus/pkg/event" "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/renderer/html" - "strings" ) // Severity is the type alias for alert's severity level. @@ -58,6 +61,26 @@ func (s Severity) String() string { } } +// StringToSeverityDecodeHook converts severity string to integer. +func StringToSeverityDecodeHook() mapstructure.DecodeHookFuncType { + return func( + from reflect.Type, + to reflect.Type, + data any, + ) (any, error) { + + if from.Kind() != reflect.String { + return data, nil + } + + if to != reflect.TypeOf(Severity(0)) { + return data, nil + } + + return ParseSeverityFromString(data.(string)), nil + } +} + // ParseSeverityFromString parses the severity from the string representation. func ParseSeverityFromString(sever string) Severity { switch sever { diff --git a/pkg/alertsender/eventlog/config.go b/pkg/alertsender/eventlog/config.go index 4a2b669bb..d93d398d9 100644 --- a/pkg/alertsender/eventlog/config.go +++ b/pkg/alertsender/eventlog/config.go @@ -25,6 +25,10 @@ import ( const ( enabled = "alertsenders.eventlog.enabled" verbose = "alertsenders.eventlog.verbose" + format = "alertsenders.eventlog.format" + + prettyFormat = "pretty" + jsonFormat = "json" ) // Config defines the configuration for the eventlog sender. @@ -35,10 +39,14 @@ type Config struct { // event context, including parameters and the process // state are included in the log message. Verbose bool `mapstructure:"verbose"` + // Format specifies the eventlog record format for the alert. + // Can be pretty or json. + Format string `mapstructure:"format"` } // AddFlags registers persistent flags. func AddFlags(flags *pflag.FlagSet) { flags.Bool(enabled, true, "Indicates if eventlog alert sender is enabled") flags.Bool(verbose, true, "Enables/disables the verbose mode. In verbose mode, the full event context, including all parameters and the process information are included in the log message") + flags.String(format, "pretty", "Specifies the eventlog record format for the alert. Can be pretty|json") } diff --git a/pkg/alertsender/eventlog/eventlog.go b/pkg/alertsender/eventlog/eventlog.go index 9608d86ba..c6e111d45 100644 --- a/pkg/alertsender/eventlog/eventlog.go +++ b/pkg/alertsender/eventlog/eventlog.go @@ -19,13 +19,15 @@ package eventlog import ( + "encoding/json" "errors" "fmt" + "hash/crc32" + "strings" + "github.com/rabbitstack/fibratus/pkg/alertsender" evlog "github.com/rabbitstack/fibratus/pkg/util/eventlog" "golang.org/x/sys/windows" - "hash/crc32" - "strings" ) const minIDChars = 12 @@ -83,7 +85,19 @@ func (s *eventlog) Send(alert alertsender.Alert) error { code = uint16(h & 0xFFFF) } - msg := alert.String(s.config.Verbose) + // build the eventlog event + var msg string + switch s.config.Format { + case prettyFormat: + msg = alert.String(s.config.Verbose) + case jsonFormat: + b, err := json.MarshalIndent(alert, "", " ") + if err != nil { + return err + } + msg = string(b) + } + // trim null characters to avoid // UTF16PtrFromString complaints msg = strings.ReplaceAll(msg, "\x00", "") diff --git a/pkg/alertsender/eventlog/eventlog_test.go b/pkg/alertsender/eventlog/eventlog_test.go index 3567dd8f6..0d9ae3891 100644 --- a/pkg/alertsender/eventlog/eventlog_test.go +++ b/pkg/alertsender/eventlog/eventlog_test.go @@ -19,6 +19,9 @@ package eventlog import ( + "testing" + "time" + "github.com/rabbitstack/fibratus/pkg/alertsender" "github.com/rabbitstack/fibratus/pkg/event" "github.com/rabbitstack/fibratus/pkg/event/params" @@ -28,12 +31,10 @@ import ( "github.com/rabbitstack/fibratus/pkg/util/va" "github.com/stretchr/testify/require" "golang.org/x/sys/windows" - "testing" - "time" ) func TestEventlogSender(t *testing.T) { - s, err := alertsender.Load(alertsender.Config{Type: alertsender.Eventlog, Sender: Config{Verbose: true, Enabled: true}}) + s, err := alertsender.Load(alertsender.Config{Type: alertsender.Eventlog, Sender: Config{Verbose: true, Enabled: true, Format: prettyFormat}}) require.NoError(t, err) require.NotNil(t, s) diff --git a/pkg/alertsender/systray/systray_test.go b/pkg/alertsender/systray/systray_test.go index 163d6f651..2d49821a4 100644 --- a/pkg/alertsender/systray/systray_test.go +++ b/pkg/alertsender/systray/systray_test.go @@ -20,15 +20,16 @@ package systray import ( "encoding/json" + "io" + "net" + "sync" + "testing" + "github.com/Microsoft/go-winio" "github.com/mitchellh/mapstructure" "github.com/rabbitstack/fibratus/pkg/alertsender" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "io" - "net" - "sync" - "testing" ) func handleMessage(t *testing.T, conn net.Conn, wg *sync.WaitGroup, msgs chan Msg) { @@ -107,6 +108,7 @@ func decodeMsg(output any, data any) error { DecodeHook: mapstructure.ComposeDecodeHookFunc( mapstructure.StringToTimeDurationHookFunc(), mapstructure.StringToSliceHookFunc(","), + alertsender.StringToSeverityDecodeHook(), ), } decoder, err := mapstructure.NewDecoder(decoderConfig) diff --git a/pkg/config/config.schema.json b/pkg/config/config.schema.json index 1cdf2868e..0fccfc8d0 100644 --- a/pkg/config/config.schema.json +++ b/pkg/config/config.schema.json @@ -155,6 +155,10 @@ }, "verbose": { "type": "boolean" + }, + "format": { + "type": "string", + "enum": ["pretty", "json"] } }, "additionalProperties": false From 4ac9b75ece68dccf395658f9cab0880f48a92191 Mon Sep 17 00:00:00 2001 From: rabbitstack Date: Sat, 17 Jan 2026 17:15:54 +0100 Subject: [PATCH 2/3] new(alert): Marshal alert to JSON --- pkg/alertsender/alert.go | 181 +++++++++++++++++++++++++++++++++- pkg/alertsender/alert_test.go | 34 ++++++- 2 files changed, 213 insertions(+), 2 deletions(-) diff --git a/pkg/alertsender/alert.go b/pkg/alertsender/alert.go index cf68adf4a..eebb3a43b 100644 --- a/pkg/alertsender/alert.go +++ b/pkg/alertsender/alert.go @@ -20,12 +20,16 @@ package alertsender import ( "bytes" + "encoding/json" "fmt" "reflect" "strings" + "time" "github.com/mitchellh/mapstructure" + "github.com/rabbitstack/fibratus/pkg/event" + "github.com/rabbitstack/fibratus/pkg/event/params" "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/renderer/html" @@ -68,7 +72,6 @@ func StringToSeverityDecodeHook() mapstructure.DecodeHookFuncType { to reflect.Type, data any, ) (any, error) { - if from.Kind() != reflect.String { return data, nil } @@ -163,6 +166,182 @@ func (a *Alert) MDToHTML() error { return nil } +// MarshalJSON encodes the alert to JSON format. +func (a Alert) MarshalJSON() ([]byte, error) { + var msg = &struct { + ID string `json:"id"` + Title string `json:"title"` + Severity string `json:"severity"` + Text string `json:"text,omitempty"` + Description string `json:"description"` + Labels map[string]string `json:"labels,omitempty"` + Events []struct { + Name string `json:"name"` + Category string `json:"category"` + Timestamp time.Time `json:"timestamp"` + Params map[string]any `json:"params"` + Callstack []string `json:"callstack,omitempty"` + Proc *struct { + PID uint32 `json:"pid"` + TID uint32 `json:"tid"` + PPID uint32 `json:"ppid"` + Name string `json:"name"` + Exe string `json:"exe"` + Cmdline string `json:"cmdline,omitempty"` + Pname string `json:"parent_name,omitempty"` + Pcmdline string `json:"parent_cmdline,omitempty"` + Cwd string `json:"cwd,omitempty"` + SID string `json:"sid"` + Username string `json:"username"` + Domain string `json:"domain"` + SessionID uint32 `json:"session_id"` + IntegrityLevel string `json:"integrity_level"` + IsWOW64 bool `json:"is_wow64"` + IsPackaged bool `json:"is_packaged"` + IsProtected bool `json:"is_protected"` + Ancestors []string `json:"ancestors"` + } `json:"proc,omitempty"` + } `json:"events"` + }{ + ID: a.ID, + Title: a.Title, + Severity: a.Severity.String(), + Text: a.Text, + Description: a.Description, + Labels: a.Labels, + } + + events := make([]struct { + Name string `json:"name"` + Category string `json:"category"` + Timestamp time.Time `json:"timestamp"` + Params map[string]any `json:"params"` + Callstack []string `json:"callstack,omitempty"` + Proc *struct { + PID uint32 `json:"pid"` + TID uint32 `json:"tid"` + PPID uint32 `json:"ppid"` + Name string `json:"name"` + Exe string `json:"exe"` + Cmdline string `json:"cmdline,omitempty"` + Pname string `json:"parent_name,omitempty"` + Pcmdline string `json:"parent_cmdline,omitempty"` + Cwd string `json:"cwd,omitempty"` + SID string `json:"sid"` + Username string `json:"username"` + Domain string `json:"domain"` + SessionID uint32 `json:"session_id"` + IntegrityLevel string `json:"integrity_level"` + IsWOW64 bool `json:"is_wow64"` + IsPackaged bool `json:"is_packaged"` + IsProtected bool `json:"is_protected"` + Ancestors []string `json:"ancestors"` + } `json:"proc,omitempty"` + }, 0, len(a.Events)) + + for _, e := range a.Events { + var evt = struct { + Name string `json:"name"` + Category string `json:"category"` + Timestamp time.Time `json:"timestamp"` + Params map[string]any `json:"params"` + Callstack []string `json:"callstack,omitempty"` + Proc *struct { + PID uint32 `json:"pid"` + TID uint32 `json:"tid"` + PPID uint32 `json:"ppid"` + Name string `json:"name"` + Exe string `json:"exe"` + Cmdline string `json:"cmdline,omitempty"` + Pname string `json:"parent_name,omitempty"` + Pcmdline string `json:"parent_cmdline,omitempty"` + Cwd string `json:"cwd,omitempty"` + SID string `json:"sid"` + Username string `json:"username"` + Domain string `json:"domain"` + SessionID uint32 `json:"session_id"` + IntegrityLevel string `json:"integrity_level"` + IsWOW64 bool `json:"is_wow64"` + IsPackaged bool `json:"is_packaged"` + IsProtected bool `json:"is_protected"` + Ancestors []string `json:"ancestors"` + } `json:"proc,omitempty"` + }{ + Name: e.Name, + Category: string(e.Category), + Timestamp: e.Timestamp, + Params: make(map[string]any), + Callstack: make([]string, 0, len(e.Callstack)), + } + + // populate event parameters + for _, param := range e.Params { + if param.Type == params.Bool || param.Type == params.PID || + param.Type == params.TID || param.Type == params.Port || param.IsNumber() { + evt.Params[param.Name] = param.Value + } else { + evt.Params[param.Name] = param.String() + } + } + + // populate callstack + for i := range e.Callstack { + frame := e.Callstack[len(e.Callstack)-i-1] + evt.Callstack = append(evt.Callstack, fmt.Sprintf("%s %s!%s", frame.Addr, frame.Module, frame.Symbol)) + } + + ps := e.PS + if ps != nil { + evt.Proc = &struct { + PID uint32 `json:"pid"` + TID uint32 `json:"tid"` + PPID uint32 `json:"ppid"` + Name string `json:"name"` + Exe string `json:"exe"` + Cmdline string `json:"cmdline,omitempty"` + Pname string `json:"parent_name,omitempty"` + Pcmdline string `json:"parent_cmdline,omitempty"` + Cwd string `json:"cwd,omitempty"` + SID string `json:"sid"` + Username string `json:"username"` + Domain string `json:"domain"` + SessionID uint32 `json:"session_id"` + IntegrityLevel string `json:"integrity_level"` + IsWOW64 bool `json:"is_wow64"` + IsPackaged bool `json:"is_packaged"` + IsProtected bool `json:"is_protected"` + Ancestors []string `json:"ancestors"` + }{ + PID: ps.PID, + TID: e.Tid, + PPID: ps.Ppid, + Name: ps.Name, + Exe: ps.Exe, + Cmdline: ps.Cmdline, + Cwd: ps.Cwd, + SID: ps.SID, + Username: ps.Username, + Domain: ps.Domain, + SessionID: ps.SessionID, + IntegrityLevel: ps.TokenIntegrityLevel, + IsWOW64: ps.IsWOW64, + IsPackaged: ps.IsPackaged, + IsProtected: ps.IsProtected, + Ancestors: ps.Ancestors(), + } + if ps.Parent != nil { + evt.Proc.Pname = ps.Parent.Name + evt.Proc.Pcmdline = ps.Parent.Cmdline + } + } + + events = append(events, evt) + } + msg.Events = events + + return json.Marshal(msg) +} + // NewAlert builds a new alert. func NewAlert(title, text string, tags []string, severity Severity) Alert { return Alert{Title: title, Text: text, Tags: tags, Severity: severity} diff --git a/pkg/alertsender/alert_test.go b/pkg/alertsender/alert_test.go index e429d1f3e..2e7b5b08d 100644 --- a/pkg/alertsender/alert_test.go +++ b/pkg/alertsender/alert_test.go @@ -19,11 +19,13 @@ package alertsender import ( + "encoding/json" + "testing" + "github.com/rabbitstack/fibratus/pkg/event" "github.com/rabbitstack/fibratus/pkg/event/params" pstypes "github.com/rabbitstack/fibratus/pkg/ps/types" "github.com/stretchr/testify/require" - "testing" ) func TestAlertString(t *testing.T) { @@ -94,3 +96,33 @@ func TestAlertString(t *testing.T) { }) } } + +func TestAlertJSON(t *testing.T) { + alert := NewAlertWithEvents("Credential discovery via VaultCmd.exe", "Suspicious vault enumeration via VaultCmd tool", nil, Normal, []*event.Event{{ + Type: event.CreateProcess, + Category: event.Process, + Params: event.Params{ + params.Cmdline: {Name: params.Cmdline, Type: params.UnicodeString, Value: "C:\\Windows\\system32\\svchost-fake.exe -k RPCSS"}, + params.ProcessName: {Name: params.ProcessName, Type: params.AnsiString, Value: "svchost-fake.exe"}}, + Name: "CreateProcess", + PID: 1023, + PS: &pstypes.PS{ + Name: "svchost.exe", + Cmdline: "C:\\Windows\\System32\\svchost.exe", + Ppid: 345, + Username: "SYSTEM", + Domain: "NT AUTHORITY", + SID: "S-1-5-18", + TokenIntegrityLevel: "HIGH", + }, + }}) + + alert.ID = "64af2e2e-2309-4079-9c0f-985f1dd930f5" + + b, err := json.MarshalIndent(alert, "", " ") + require.NoError(t, err) + + expectedJSON := "{\n \"id\": \"64af2e2e-2309-4079-9c0f-985f1dd930f5\",\n \"title\": \"Credential discovery via VaultCmd.exe\",\n \"severity\": \"low\",\n \"text\": \"Suspicious vault enumeration via VaultCmd tool\",\n \"description\": \"\",\n \"events\": [\n {\n \"name\": \"CreateProcess\",\n \"category\": \"process\",\n \"timestamp\": \"0001-01-01T00:00:00Z\",\n \"params\": {\n \"cmdline\": \"C:\\\\Windows\\\\system32\\\\svchost-fake.exe -k RPCSS\",\n \"name\": \"svchost-fake.exe\"\n },\n \"proc\": {\n \"pid\": 0,\n \"tid\": 0,\n \"ppid\": 345,\n \"name\": \"svchost.exe\",\n \"exe\": \"\",\n \"cmdline\": \"C:\\\\Windows\\\\System32\\\\svchost.exe\",\n \"sid\": \"S-1-5-18\",\n \"username\": \"SYSTEM\",\n \"domain\": \"NT AUTHORITY\",\n \"session_id\": 0,\n \"integrity_level\": \"HIGH\",\n \"is_wow64\": false,\n \"is_packaged\": false,\n \"is_protected\": false,\n \"ancestors\": []\n }\n }\n ]\n}" + + require.Equal(t, expectedJSON, string(b)) +} From 6b37dc39cad436ed7c304cd01a0f60486017ef40 Mon Sep 17 00:00:00 2001 From: rabbitstack Date: Sat, 17 Jan 2026 17:16:39 +0100 Subject: [PATCH 3/3] new(event): Introduce IsNumber method for param The method checks if the event param stores the integer (unsigned/signed) value type. --- pkg/event/param.go | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/pkg/event/param.go b/pkg/event/param.go index 894bf27c8..82e88104f 100644 --- a/pkg/event/param.go +++ b/pkg/event/param.go @@ -20,17 +20,18 @@ package event import ( "fmt" + "net" + "reflect" + "sort" + "strings" + "time" + "github.com/rabbitstack/fibratus/pkg/fs" "github.com/rabbitstack/fibratus/pkg/network" "github.com/rabbitstack/fibratus/pkg/util/key" "github.com/rabbitstack/fibratus/pkg/util/va" "golang.org/x/text/cases" "golang.org/x/text/language" - "net" - "reflect" - "sort" - "strings" - "time" "github.com/rabbitstack/fibratus/pkg/errors" "github.com/rabbitstack/fibratus/pkg/event/params" @@ -99,6 +100,12 @@ type Param struct { Enum ParamEnum `json:"enum"` } +// IsNumber determines if the parameter stores the integer value type. +func (p Param) IsNumber() bool { + return p.Type == params.Int8 || p.Type == params.Int16 || p.Type == params.Int32 || p.Type == params.Int64 || + p.Type == params.Uint8 || p.Type == params.Uint16 || p.Type == params.Uint32 || p.Type == params.Uint64 +} + // CaptureType returns the event type saved inside the capture file. // Captures usually override the type of the parameter to provide // consistent replay experience. For example, the file path param