Skip to content

Commit c62f0e7

Browse files
committed
fix: cross-platform syslog — split into platform-specific files for Windows builds
log/syslog doesn't exist on Windows, causing goreleaser to fail. Split syslog.go into: - cef.go: FormatCEF, CEFFileSink, MultiSink (cross-platform) - syslog.go: SyslogSink with log/syslog (linux/macOS only) - syslog_windows.go: stub returning helpful error NewSyslogSink on Windows returns: 'use --cef for file-based SIEM output'
1 parent 15716a5 commit c62f0e7

File tree

5 files changed

+254
-190
lines changed

5 files changed

+254
-190
lines changed

cmd/rampart/cli/serve.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,15 +89,15 @@ func newServeCmd(opts *rootOptions, deps *serveDeps) *cobra.Command {
8989

9090
// Build the final sink, optionally wrapping with syslog/CEF outputs.
9191
var sink audit.AuditSink = jsonlSink
92-
var syslogSinkPtr *audit.SyslogSink
92+
var syslogSender audit.SyslogSender
9393
var cefFilePtr *audit.CEFFileSink
9494

9595
if syslogAddr != "" {
9696
s, sErr := audit.NewSyslogSink(syslogAddr, cef, logger)
9797
if sErr != nil {
9898
logger.Warn("serve: syslog init failed, continuing without syslog", "error", sErr)
9999
} else {
100-
syslogSinkPtr = s
100+
syslogSender = s
101101
logger.Info("serve: syslog output enabled", "addr", syslogAddr, "cef", cef)
102102
}
103103
}
@@ -113,8 +113,8 @@ func newServeCmd(opts *rootOptions, deps *serveDeps) *cobra.Command {
113113
logger.Info("serve: CEF file output enabled", "path", cefPath)
114114
}
115115

116-
if syslogSinkPtr != nil || cefFilePtr != nil {
117-
sink = audit.NewMultiSink(jsonlSink, syslogSinkPtr, cefFilePtr, logger)
116+
if syslogSender != nil || cefFilePtr != nil {
117+
sink = audit.NewMultiSink(jsonlSink, syslogSender, cefFilePtr, logger)
118118
}
119119

120120
defer func() {

internal/audit/cef.go

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
// Copyright 2026 The Rampart Authors
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
package audit
15+
16+
import (
17+
"fmt"
18+
"log/slog"
19+
"os"
20+
"path/filepath"
21+
"strings"
22+
"sync"
23+
24+
"github.com/peg/rampart/internal/build"
25+
)
26+
27+
// CEFSeverity maps decision actions to CEF severity levels.
28+
var CEFSeverity = map[string]int{
29+
"deny": 8,
30+
"ask": 5,
31+
"log": 3,
32+
"allow": 1,
33+
}
34+
35+
// FormatCEF formats an audit event as a CEF string.
36+
func FormatCEF(e Event) string {
37+
sev, ok := CEFSeverity[e.Decision.Action]
38+
if !ok {
39+
sev = 3
40+
}
41+
msg := e.Decision.Message
42+
if msg == "" {
43+
msg = e.Decision.Action + " " + e.Tool
44+
}
45+
46+
// Build extension key-values.
47+
cmd := ""
48+
path := ""
49+
if e.Request != nil {
50+
if v, ok := e.Request["command"]; ok {
51+
cmd = fmt.Sprintf("%v", v)
52+
}
53+
if v, ok := e.Request["path"]; ok {
54+
path = fmt.Sprintf("%v", v)
55+
}
56+
}
57+
policyNames := strings.Join(e.Decision.MatchedPolicies, ",")
58+
59+
// Escape CEF extension values (\ and =).
60+
esc := func(s string) string {
61+
s = strings.ReplaceAll(s, `\`, `\\`)
62+
s = strings.ReplaceAll(s, `=`, `\=`)
63+
return s
64+
}
65+
// Escape CEF header pipes.
66+
escH := func(s string) string {
67+
s = strings.ReplaceAll(s, `\`, `\\`)
68+
s = strings.ReplaceAll(s, `|`, `\|`)
69+
return s
70+
}
71+
72+
return fmt.Sprintf("CEF:0|Rampart|PolicyEngine|%s|%s|%s|%d|src=%s cmd=%s path=%s policy=%s",
73+
escH(build.Version),
74+
escH(e.Decision.Action),
75+
escH(msg),
76+
sev,
77+
esc(e.Agent),
78+
esc(cmd),
79+
esc(path),
80+
esc(policyNames),
81+
)
82+
}
83+
84+
// CEFFileSink writes CEF-formatted events to a file.
85+
type CEFFileSink struct {
86+
mu sync.Mutex
87+
file *os.File
88+
path string
89+
logger *slog.Logger
90+
}
91+
92+
// NewCEFFileSink creates a CEF file sink at the given path.
93+
func NewCEFFileSink(path string, logger *slog.Logger) (*CEFFileSink, error) {
94+
if logger == nil {
95+
logger = slog.Default()
96+
}
97+
dir := filepath.Dir(path)
98+
if err := os.MkdirAll(dir, 0o700); err != nil {
99+
return nil, fmt.Errorf("audit: create cef dir: %w", err)
100+
}
101+
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
102+
if err != nil {
103+
return nil, fmt.Errorf("audit: open cef file: %w", err)
104+
}
105+
return &CEFFileSink{file: f, path: path, logger: logger}, nil
106+
}
107+
108+
// Send writes a CEF-formatted event line to the file.
109+
func (c *CEFFileSink) Send(e Event) {
110+
line := FormatCEF(e) + "\n"
111+
c.mu.Lock()
112+
defer c.mu.Unlock()
113+
if _, err := c.file.WriteString(line); err != nil {
114+
c.logger.Warn("cef: file write failed", "error", err)
115+
}
116+
}
117+
118+
// Close closes the CEF file.
119+
func (c *CEFFileSink) Close() error {
120+
c.mu.Lock()
121+
defer c.mu.Unlock()
122+
return c.file.Close()
123+
}
124+
125+
// MultiSink wraps the primary AuditSink and additional output sinks.
126+
// It implements AuditSink and fans out events to syslog/CEF sinks.
127+
type MultiSink struct {
128+
primary AuditSink
129+
syslogSink SyslogSender
130+
cefFile *CEFFileSink
131+
ch chan Event
132+
done chan struct{}
133+
logger *slog.Logger
134+
}
135+
136+
// SyslogSender is the interface for syslog-like sinks used by MultiSink.
137+
type SyslogSender interface {
138+
Send(e Event)
139+
Close() error
140+
}
141+
142+
// NewMultiSink creates a sink that writes to the primary sink synchronously
143+
// and fans out to syslog/CEF sinks asynchronously via a buffered channel.
144+
func NewMultiSink(primary AuditSink, syslogSink SyslogSender, cefFile *CEFFileSink, logger *slog.Logger) *MultiSink {
145+
if logger == nil {
146+
logger = slog.Default()
147+
}
148+
m := &MultiSink{
149+
primary: primary,
150+
syslogSink: syslogSink,
151+
cefFile: cefFile,
152+
ch: make(chan Event, 1024),
153+
done: make(chan struct{}),
154+
logger: logger,
155+
}
156+
go m.drain()
157+
return m
158+
}
159+
160+
func (m *MultiSink) drain() {
161+
defer close(m.done)
162+
for e := range m.ch {
163+
if m.syslogSink != nil {
164+
m.syslogSink.Send(e)
165+
}
166+
if m.cefFile != nil {
167+
m.cefFile.Send(e)
168+
}
169+
}
170+
}
171+
172+
// Write writes to the primary sink and enqueues to secondary sinks (non-blocking).
173+
func (m *MultiSink) Write(event Event) error {
174+
err := m.primary.Write(event)
175+
// Non-blocking send to secondary sinks.
176+
select {
177+
case m.ch <- event:
178+
default:
179+
m.logger.Warn("audit: secondary sink channel full, dropping event", "id", event.ID)
180+
}
181+
return err
182+
}
183+
184+
// Flush flushes the primary sink.
185+
func (m *MultiSink) Flush() error {
186+
return m.primary.Flush()
187+
}
188+
189+
// Close closes all sinks. Drains the async channel first.
190+
func (m *MultiSink) Close() error {
191+
close(m.ch)
192+
<-m.done // wait for drain
193+
var errs []error
194+
if m.syslogSink != nil {
195+
if err := m.syslogSink.Close(); err != nil {
196+
errs = append(errs, err)
197+
}
198+
}
199+
if m.cefFile != nil {
200+
if err := m.cefFile.Close(); err != nil {
201+
errs = append(errs, err)
202+
}
203+
}
204+
if err := m.primary.Close(); err != nil {
205+
errs = append(errs, err)
206+
}
207+
if len(errs) > 0 {
208+
return fmt.Errorf("audit: close errors: %v", errs)
209+
}
210+
return nil
211+
}

0 commit comments

Comments
 (0)