feat(audit): JSONL-Logger mit Größenrotation, gzip und SIGHUP-Reopen
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
This commit is contained in:
parent
b47ee11ba4
commit
2eb69bdcfe
5 changed files with 526 additions and 0 deletions
124
internal/audit/logger_test.go
Normal file
124
internal/audit/logger_test.go
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
package audit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestLogger(t *testing.T, opts Options) (*Logger, string, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "audit.log")
|
||||
var stdout bytes.Buffer
|
||||
opts.Stdout = &stdout
|
||||
if opts.MaxSizeMB == 0 {
|
||||
opts.MaxSizeMB = 50
|
||||
}
|
||||
lg, err := New(path, opts)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { lg.Close() })
|
||||
return lg, path, &stdout
|
||||
}
|
||||
|
||||
func readLines(t *testing.T, path string) []map[string]any {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out []map[string]any
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &m); err != nil {
|
||||
t.Fatalf("Zeile ist kein gültiges JSON: %q: %v", line, err)
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestLogWritesJSONLWithTimezone(t *testing.T) {
|
||||
fixed := time.Date(2026, 8, 14, 9, 32, 11, 0, time.FixedZone("CEST", 2*3600))
|
||||
lg, path, stdout := newTestLogger(t, Options{Clock: func() time.Time { return fixed }})
|
||||
|
||||
lg.Log(Event{Event: EventLoginSuccess, User: "mmueller", SrcIP: "10.1.20.34", Session: "a3f9"})
|
||||
if err := lg.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
lines := readLines(t, path)
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("got %d Zeilen, want 1", len(lines))
|
||||
}
|
||||
got := lines[0]
|
||||
if got["ts"] != "2026-08-14T09:32:11+02:00" {
|
||||
t.Errorf("ts = %v, want RFC3339 mit Zeitzone", got["ts"])
|
||||
}
|
||||
if got["event"] != EventLoginSuccess || got["user"] != "mmueller" {
|
||||
t.Errorf("Feldwerte falsch: %v", got)
|
||||
}
|
||||
if _, present := got["reason"]; present {
|
||||
t.Errorf("leere Felder dürfen nicht serialisiert werden: %v", got)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), EventLoginSuccess) {
|
||||
t.Error("Event muss zusätzlich nach stdout gehen")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogIsConcurrencySafe(t *testing.T) {
|
||||
lg, path, _ := newTestLogger(t, Options{})
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
lg.Log(Event{Event: EventLoginFailed, User: UnknownUser, SrcIP: "10.0.0.1"})
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if err := lg.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := len(readLines(t, path)); n != 50 {
|
||||
t.Fatalf("got %d Zeilen, want 50 (kein Interleaving erlaubt)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCreatesFileWithTightPermissions(t *testing.T) {
|
||||
_, path, _ := newTestLogger(t, Options{})
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm&0o077 != 0 {
|
||||
t.Errorf("Audit-Log hat Rechte %#o — Gruppe/Welt dürfen nicht zugreifen", perm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortSessionNeverLeaksToken(t *testing.T) {
|
||||
token := "0123456789abcdef0123456789abcdef"
|
||||
short := ShortSession(token)
|
||||
if len(short) != 4 {
|
||||
t.Errorf("ShortSession = %q, want 4 Zeichen", short)
|
||||
}
|
||||
if strings.Contains(token, short) {
|
||||
t.Errorf("gekürzte ID %q ist ein Präfix des Tokens — muss gehasht sein", short)
|
||||
}
|
||||
if ShortSession(token) != short {
|
||||
t.Error("ShortSession muss deterministisch sein")
|
||||
}
|
||||
if ShortSession("") != "" {
|
||||
t.Error("leerer Token muss leere ID liefern")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue