feat(audit): forward audit entries to stdout, syslog and webhook sinks
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.
This commit is contained in:
parent
ae908be716
commit
ab038ec6c4
|
|
@ -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```"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@
|
|||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
// 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
|
||||
}
|
||||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
Loading…
Reference in New Issue