From ab038ec6c4a63283db5f804ce38dafb9d5c94e09 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 22:22:20 +0200 Subject: [PATCH] feat(audit): forward audit entries to stdout, syslog and webhook sinks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the audit.forwarders config list and a Sink interface with three implementations: stdout, RFC 5424 syslog over UDP/TCP (hand-rolled — stdlib log/syslog only emits RFC 3164 and does not build on Windows), and webhook POSTs through the SSRF-safe HTTP client. Forwarders are best-effort fan-out: the local file stays the source of truth and a dead sink is logged instead of poison-queueing every event. --- config-raw.json | 4 ++ pkg/audit/doc.go | 14 +++-- pkg/audit/sinks/sink.go | 24 ++++++++ pkg/audit/sinks/stdout.go | 44 ++++++++++++++ pkg/audit/sinks/syslog.go | 116 +++++++++++++++++++++++++++++++++++++ pkg/audit/sinks/webhook.go | 69 ++++++++++++++++++++++ pkg/audit/writer.go | 88 +++++++++++++++++++++++++++- pkg/config/config.go | 1 + 8 files changed, 351 insertions(+), 9 deletions(-) create mode 100644 pkg/audit/sinks/sink.go create mode 100644 pkg/audit/sinks/stdout.go create mode 100644 pkg/audit/sinks/syslog.go create mode 100644 pkg/audit/sinks/webhook.go diff --git a/config-raw.json b/config-raw.json index 641285994..3fbf5dd5e 100644 --- a/config-raw.json +++ b/config-raw.json @@ -1025,6 +1025,10 @@ "comment": "Delete rotated audit log files older than this many days. This only applies to the local rotated files, it is not a retention policy. Set to 0 to keep rotated files forever." } ] + }, + { + "key": "forwarders", + "comment": "A list of sinks to forward each audit entry to, in addition to the local logfile. Each entry needs a `type` of `stdout`, `syslog` or `webhook`. `syslog` requires `address` (e.g. `udp://logs.example.com:514`) and accepts an optional `facility` (default `local0`). `webhook` requires `url` and accepts an optional `headers` map sent with each request.\nExample:\n\n```yaml\nforwarders:\n- type: stdout\n- type: syslog\n address: udp://logs.example.com:514\n facility: local0\n- type: webhook\n url: https://siem.example.com/ingest\n headers:\n Authorization: Bearer something\n```" } ] }, diff --git a/pkg/audit/doc.go b/pkg/audit/doc.go index f6d04d64d..810b91788 100644 --- a/pkg/audit/doc.go +++ b/pkg/audit/doc.go @@ -15,7 +15,8 @@ // along with this program. If not, see . // Package audit persists an audit trail of authentication, authorization and -// data lifecycle events as JSONL. +// data lifecycle events as JSONL, with optional forwarding to stdout, syslog +// or webhook sinks. // // Events opt in via RegisterEventForAudit, which subscribes one audit // listener per event on the existing watermill bus; the event→Entry mapping @@ -36,9 +37,10 @@ // pkg/events.RequestMeta. Events dispatched outside a request get // source type "system" instead. // -// A failed file write is returned to the router for retry. Tamper evidence -// comes from filesystem permissions (the file is created 0600) plus shipping -// the file to an external system, not from hash chains or signatures. -// Rotation is size-based with age-based cleanup of rotated files; retention -// is the operator's concern. +// The local file is the source of truth: a failed file write is returned to +// the router for retry, while forwarder failures are only logged so a dead +// sink cannot poison-queue every event. Tamper evidence comes from filesystem +// permissions (the file is created 0600) plus shipping entries to an external +// sink, not from hash chains or signatures. Rotation is size-based with +// age-based cleanup of rotated files; retention is the operator's concern. package audit diff --git a/pkg/audit/sinks/sink.go b/pkg/audit/sinks/sink.go new file mode 100644 index 000000000..ff951d452 --- /dev/null +++ b/pkg/audit/sinks/sink.go @@ -0,0 +1,24 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package sinks contains the forwarding targets for audit log entries. +package sinks + +// Sink forwards a single audit entry, passed as its serialized JSON line +// without a trailing newline. Implementations must be safe for concurrent use. +type Sink interface { + Write(line []byte) error +} diff --git a/pkg/audit/sinks/stdout.go b/pkg/audit/sinks/stdout.go new file mode 100644 index 000000000..95d89af03 --- /dev/null +++ b/pkg/audit/sinks/stdout.go @@ -0,0 +1,44 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package sinks + +import ( + "io" + "os" + "sync" +) + +// Stdout writes each entry as one line to standard output. +type Stdout struct { + mu sync.Mutex + // out exists so tests can capture the output. + out io.Writer +} + +func NewStdout() *Stdout { + return &Stdout{out: os.Stdout} +} + +func (s *Stdout) Write(line []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, err := s.out.Write(line); err != nil { + return err + } + _, err := s.out.Write([]byte{'\n'}) + return err +} diff --git a/pkg/audit/sinks/syslog.go b/pkg/audit/sinks/syslog.go new file mode 100644 index 000000000..ba85f17f2 --- /dev/null +++ b/pkg/audit/sinks/syslog.go @@ -0,0 +1,116 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package sinks + +import ( + "context" + "fmt" + "net" + "net/url" + "os" + "strings" + "sync" + "time" +) + +// Hand-rolled RFC 5424 instead of log/syslog: the stdlib package only emits +// the older RFC 3164 format and does not build on Windows. +type Syslog struct { + network string + address string + facility int + hostname string + procid string + + mu sync.Mutex + conn net.Conn +} + +var syslogFacilities = map[string]int{ + "kern": 0, "user": 1, "mail": 2, "daemon": 3, "auth": 4, "syslog": 5, + "lpr": 6, "news": 7, "uucp": 8, "cron": 9, "authpriv": 10, "ftp": 11, + "local0": 16, "local1": 17, "local2": 18, "local3": 19, + "local4": 20, "local5": 21, "local6": 22, "local7": 23, +} + +// NewSyslog creates a syslog sink. The address has the form +// udp://host:port or tcp://host:port; the scheme defaults to udp. +func NewSyslog(address, facility string) (*Syslog, error) { + if address == "" { + return nil, fmt.Errorf("syslog forwarder requires an address") + } + if !strings.Contains(address, "://") { + address = "udp://" + address + } + u, err := url.Parse(address) + if err != nil { + return nil, fmt.Errorf("invalid syslog address %q: %w", address, err) + } + if u.Scheme != "udp" && u.Scheme != "tcp" { + return nil, fmt.Errorf("unsupported syslog scheme %q, must be udp or tcp", u.Scheme) + } + + if facility == "" { + facility = "local0" + } + facilityCode, ok := syslogFacilities[strings.ToLower(facility)] + if !ok { + return nil, fmt.Errorf("unknown syslog facility %q", facility) + } + + hostname, err := os.Hostname() + if err != nil || hostname == "" { + hostname = "-" + } + + return &Syslog{ + network: u.Scheme, + address: u.Host, + facility: facilityCode, + hostname: hostname, + procid: fmt.Sprintf("%d", os.Getpid()), + }, nil +} + +func (s *Syslog) Write(line []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.conn == nil { + dialer := &net.Dialer{Timeout: 10 * time.Second} + conn, err := dialer.DialContext(context.Background(), s.network, s.address) + if err != nil { + return fmt.Errorf("could not connect to syslog at %s://%s: %w", s.network, s.address, err) + } + s.conn = conn + } + + pri := s.facility*8 + 6 // severity: informational + frame := fmt.Sprintf("<%d>1 %s %s vikunja %s audit - %s", + pri, time.Now().UTC().Format(time.RFC3339Nano), s.hostname, s.procid, line) + if s.network == "tcp" { + frame += "\n" // RFC 6587 non-transparent framing + } + + if _, err := s.conn.Write([]byte(frame)); err != nil { + // Drop the connection so the next write redials. + _ = s.conn.Close() + s.conn = nil + return err + } + return nil +} diff --git a/pkg/audit/sinks/webhook.go b/pkg/audit/sinks/webhook.go new file mode 100644 index 000000000..c642d7e43 --- /dev/null +++ b/pkg/audit/sinks/webhook.go @@ -0,0 +1,69 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package sinks + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + + "code.vikunja.io/api/pkg/utils" +) + +// Webhook POSTs each entry as a JSON body to a fixed URL. +type Webhook struct { + url string + headers map[string]string + client *http.Client +} + +func NewWebhook(url string, headers map[string]string) (*Webhook, error) { + if url == "" { + return nil, fmt.Errorf("webhook forwarder requires a url") + } + return &Webhook{ + url: url, + headers: headers, + client: utils.NewSSRFSafeHTTPClient(), + }, nil +} + +func (w *Webhook) Write(line []byte) error { + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, w.url, bytes.NewReader(line)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "Vikunja/audit") + for key, value := range w.headers { + req.Header.Set(key, value) + } + + resp, err := w.client.Do(req) // #nosec G704 -- URL is the operator-configured sink target; the SSRF-safe client enforces IP restrictions + if err != nil { + return err + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + + if resp.StatusCode >= 400 { + return fmt.Errorf("audit webhook %s returned status %d", w.url, resp.StatusCode) + } + return nil +} diff --git a/pkg/audit/writer.go b/pkg/audit/writer.go index 548c380fe..ec7528af0 100644 --- a/pkg/audit/writer.go +++ b/pkg/audit/writer.go @@ -25,6 +25,7 @@ import ( "sync" "time" + "code.vikunja.io/api/pkg/audit/sinks" "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/log" @@ -40,9 +41,10 @@ var ( maxSizeBytes int64 maxAge time.Duration lastSync time.Time + forwarders []sinks.Sink ) -// Init opens the audit log file. +// Init opens the audit log file and sets up the configured forwarders. // Safe to call again to re-read the config (used by tests). func Init() error { mu.Lock() @@ -64,6 +66,13 @@ func Init() error { return err } + var err error + forwarders, err = buildForwarders(config.AuditForwarders.Get()) + if err != nil { + closeLocked() + return err + } + initialized = true return nil } @@ -81,6 +90,7 @@ func closeLocked() { _ = logFile.Close() logFile = nil } + forwarders = nil initialized = false } @@ -99,8 +109,74 @@ func openLogFileLocked() error { return nil } -// WriteAuditEvent writes one entry to the local audit log. A failed write is -// returned so the event router retries it. +func buildForwarders(raw any) (built []sinks.Sink, err error) { + if raw == nil { + return nil, nil + } + rawList, ok := raw.([]any) + if !ok { + return nil, fmt.Errorf("audit.forwarders must be a list, got %T", raw) + } + + for i, rawEntry := range rawList { + entry, ok := toStringMap(rawEntry) + if !ok { + return nil, fmt.Errorf("audit.forwarders[%d] must be a map", i) + } + + var sink sinks.Sink + typ, _ := entry["type"].(string) + switch typ { + case "stdout": + sink = sinks.NewStdout() + case "syslog": + address, _ := entry["address"].(string) + facility, _ := entry["facility"].(string) + sink, err = sinks.NewSyslog(address, facility) + case "webhook": + targetURL, _ := entry["url"].(string) + headers := map[string]string{} + if rawHeaders, ok := toStringMap(entry["headers"]); ok { + for key, value := range rawHeaders { + headers[key], _ = value.(string) + } + } + sink, err = sinks.NewWebhook(targetURL, headers) + default: + return nil, fmt.Errorf("audit.forwarders[%d] has unknown type %q", i, typ) + } + if err != nil { + return nil, fmt.Errorf("audit.forwarders[%d]: %w", i, err) + } + built = append(built, sink) + } + return built, nil +} + +// toStringMap normalizes the two map shapes viper produces depending on the +// config source (file vs. programmatic Set). +func toStringMap(raw any) (map[string]any, bool) { + switch m := raw.(type) { + case map[string]any: + return m, true + case map[any]any: + out := make(map[string]any, len(m)) + for key, value := range m { + keyStr, ok := key.(string) + if !ok { + return nil, false + } + out[keyStr] = value + } + return out, true + } + return nil, false +} + +// WriteAuditEvent writes one entry to the local audit log and forwards it to +// all configured sinks. The local write is the source of truth — its failure +// is returned so the event router retries; forwarder failures are only +// logged, since a dead sink must not poison-queue every event. func WriteAuditEvent(entry *Entry) error { if entry.EventID == "" { id, err := uuid.NewV7() @@ -138,12 +214,18 @@ func WriteAuditEvent(entry *Entry) error { err = logFile.Sync() lastSync = time.Now() } + currentForwarders := forwarders mu.Unlock() if err != nil { return fmt.Errorf("could not write audit entry: %w", err) } + for _, forwarder := range currentForwarders { + if ferr := forwarder.Write(line); ferr != nil { + log.Errorf("Could not forward audit entry %s: %s", entry.EventID, ferr) + } + } return nil } diff --git a/pkg/config/config.go b/pkg/config/config.go index 2443cb627..e7c2a75f0 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -224,6 +224,7 @@ const ( AuditLogfile Key = `audit.logfile` AuditRotationMaxSizeMB Key = `audit.rotation.maxsizemb` AuditRotationMaxAge Key = `audit.rotation.maxage` + AuditForwarders Key = `audit.forwarders` OutgoingRequestsAllowNonRoutableIPs Key = `outgoingrequests.allownonroutableips` OutgoingRequestsProxyURL Key = `outgoingrequests.proxyurl`