feat(mail): ING-08 strukturiertes Protokoll-Logging & Diagnose für IMAP/POP3/SMTP
Neues Paket mail/internal/protolog (log/slog): SessionLogger loggt strukturierte Ereignisse einer Verbindung mit fester correlation_id und protocol über die gesamte Verbindungsdauer (Akzeptanzkriterium 1) — ein Logger mit logger==nil ist sicher benutzbar und loggt nichts (Rückwärtskompatibilität zu ING-01..ING-07, Logging ist opt-in wie TLS und Guard-Konfiguration). RedactCommandLine ersetzt bei sensiblen Kommandos (PASS, LOGIN, AUTH) alle Argumente vollständig durch [REDACTED] statt einzeln zu parsen (Akzeptanzkriterium 2). Reconstruct liest zeilenweise JSON-Logs und liefert ausschließlich die Einträge einer Korrelations-ID in Reihenfolge — das geforderte Diagnosewerkzeug (Akzeptanzkriterium 3). Alle drei Sessions loggen jetzt session_start/command (je empfangener Zeile, redigiert)/session_end. Nachrichteninhalte werden strukturell nie geloggt: SMTP-DATA-Body-Zeilen laufen durch eine eigene Leseschleife, die nicht durch den Kommando-Logpfad der Hauptschleife kommt: nur das Kommando DATA selbst erscheint im Log. Alle drei Pflichtprüfungen mit echten Nachweisen durchgeführt, jeweils in IMAP, POP3 und SMTP einzeln: Redaktion gegen den echten laufenden Server bestätigt (Klartextpasswort bzw. absichtlich eingebettetes Geheimnis im SMTP-Body erscheint nie im Log), zwei gemischte reale Sessions über dieselbe Korrelations-ID lückenlos rekonstruiert, Lasttest mit 100 Sessions mit/ohne Logging ohne relevante Durchsatzeinbuße. go build/go vet/golangci-lint clean, gesamtes Mail-Modul (~29 Pakete) regressionsfrei getestet.
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
package protolog
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Entry ist ein einzelner strukturierter Logeintrag, wie ihn
|
||||
// slog.NewJSONHandler schreibt.
|
||||
type Entry struct {
|
||||
Time string
|
||||
Level string
|
||||
Msg string
|
||||
CorrelationID string
|
||||
Protocol string
|
||||
// Fields enthält alle weiteren Felder des Eintrags (auch time/
|
||||
// level/msg/correlation_id/protocol nochmals, der Einfachheit
|
||||
// halber), für Diagnosewerkzeuge, die zusätzliche Attribute
|
||||
// auswerten wollen.
|
||||
Fields map[string]any
|
||||
}
|
||||
|
||||
// Reconstruct liest zeilenweise JSON-Logs aus r und liefert,
|
||||
// in Log-Reihenfolge, ausschließlich die Einträge mit passender
|
||||
// correlation_id — das geforderte Diagnosewerkzeug
|
||||
// (Akzeptanzkriterium 3): eine einzelne Session vollständig anhand
|
||||
// ihrer Korrelations-ID nachvollziehbar.
|
||||
func Reconstruct(r io.Reader, correlationID string) ([]Entry, error) {
|
||||
var result []Entry
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||
lineNo := 0
|
||||
for scanner.Scan() {
|
||||
lineNo++
|
||||
line := scanner.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(line, &raw); err != nil {
|
||||
return nil, fmt.Errorf("protolog: log-zeile %d parsen: %w", lineNo, err)
|
||||
}
|
||||
cid, _ := raw["correlation_id"].(string)
|
||||
if cid != correlationID {
|
||||
continue
|
||||
}
|
||||
entry := Entry{CorrelationID: cid, Fields: raw}
|
||||
if v, ok := raw["time"].(string); ok {
|
||||
entry.Time = v
|
||||
}
|
||||
if v, ok := raw["level"].(string); ok {
|
||||
entry.Level = v
|
||||
}
|
||||
if v, ok := raw["msg"].(string); ok {
|
||||
entry.Msg = v
|
||||
}
|
||||
if v, ok := raw["protocol"].(string); ok {
|
||||
entry.Protocol = v
|
||||
}
|
||||
result = append(result, entry)
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("protolog: log lesen: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Package protolog implementiert ING-08: strukturiertes Logging für
|
||||
// IMAP-/POP3-/SMTP-Sessions mit Korrelations-ID (Akzeptanzkriterium 1),
|
||||
// Redaktion sensibler Felder (Akzeptanzkriterium 2) und ein
|
||||
// Diagnosewerkzeug, das eine einzelne Session anhand ihrer
|
||||
// Korrelations-ID aus den Logs rekonstruiert (Akzeptanzkriterium 3,
|
||||
// diagnose.go).
|
||||
package protolog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// NewCorrelationID erzeugt eine zufällige, session-eindeutige
|
||||
// Korrelations-ID.
|
||||
func NewCorrelationID() string {
|
||||
buf := make([]byte, 8)
|
||||
// crypto/rand.Read schlägt praktisch nie fehl; ein Nullwert würde
|
||||
// höchstens zu einer unwahrscheinlichen ID-Kollision führen, kein
|
||||
// Sicherheitsproblem für ein reines Diagnosemerkmal.
|
||||
_, _ = rand.Read(buf)
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
// SessionLogger loggt strukturierte Ereignisse EINER Verbindung mit
|
||||
// fester Korrelations-ID über deren gesamte Dauer (Akzeptanzkriterium
|
||||
// 1). Ein SessionLogger mit logger == nil ist sicher benutzbar und
|
||||
// loggt nichts (Standard für Server ohne konfigurierten Logger).
|
||||
type SessionLogger struct {
|
||||
logger *slog.Logger
|
||||
correlationID string
|
||||
protocol string
|
||||
}
|
||||
|
||||
// NewSessionLogger erstellt einen SessionLogger mit frischer
|
||||
// Korrelations-ID. logger darf nil sein (Logging dann deaktiviert).
|
||||
func NewSessionLogger(logger *slog.Logger, protocol string) *SessionLogger {
|
||||
return &SessionLogger{logger: logger, correlationID: NewCorrelationID(), protocol: protocol}
|
||||
}
|
||||
|
||||
// CorrelationID liefert die Korrelations-ID dieser Session.
|
||||
func (l *SessionLogger) CorrelationID() string {
|
||||
if l == nil {
|
||||
return ""
|
||||
}
|
||||
return l.correlationID
|
||||
}
|
||||
|
||||
// Event loggt EIN strukturiertes Ereignis mit correlation_id und
|
||||
// protocol als festen Feldern. attrs dürfen NIE Zugangsdaten oder
|
||||
// Nachrichteninhalte enthalten — siehe RedactCommandLine für
|
||||
// Kommandozeilen (Akzeptanzkriterium 2).
|
||||
func (l *SessionLogger) Event(ctx context.Context, event string, attrs ...slog.Attr) {
|
||||
if l == nil || l.logger == nil {
|
||||
return
|
||||
}
|
||||
all := make([]slog.Attr, 0, len(attrs)+2)
|
||||
all = append(all, slog.String("correlation_id", l.correlationID), slog.String("protocol", l.protocol))
|
||||
all = append(all, attrs...)
|
||||
l.logger.LogAttrs(ctx, slog.LevelInfo, event, all...)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package protolog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRedactCommandLine_HidesCredentials ist Teil der geforderten
|
||||
// Pflichtprüfung 1 (ING-08): Redaktion sensibler Felder.
|
||||
func TestRedactCommandLine_HidesCredentials(t *testing.T) {
|
||||
cases := []struct {
|
||||
verb string
|
||||
args []string
|
||||
wantSafe bool // true: darf das geheimnis NICHT enthalten
|
||||
secret string
|
||||
}{
|
||||
{"PASS", []string{"geheim123"}, true, "geheim123"},
|
||||
{"LOGIN", []string{"alice", "geheim123"}, true, "geheim123"},
|
||||
{"AUTH", []string{"PLAIN", "AGFsaWNlAGdlaGVpbTEyMw=="}, true, "AGFsaWNlAGdlaGVpbTEyMw=="},
|
||||
{"STAT", nil, false, ""},
|
||||
{"USER", []string{"alice"}, false, "alice"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
out := RedactCommandLine(tc.verb, tc.args)
|
||||
if tc.wantSafe && strings.Contains(out, tc.secret) {
|
||||
t.Fatalf("%s: geheimnis im klartext gefunden: %q", tc.verb, out)
|
||||
}
|
||||
if !strings.HasPrefix(out, strings.ToUpper(tc.verb)) {
|
||||
t.Fatalf("%s: kommandoname fehlt in redigierter zeile: %q", tc.verb, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionLogger_EventNeverContainsRawMessage stellt sicher, dass
|
||||
// über die reguläre Event-API keine Nachrichteninhalte geloggt werden
|
||||
// können, ohne dass der Aufrufer sie explizit (und damit sichtbar im
|
||||
// Code) als Attribut übergibt — Event selbst fügt nie Rohinhalte hinzu.
|
||||
func TestSessionLogger_EventNeverContainsRawMessage(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(slog.NewJSONHandler(&buf, nil))
|
||||
sl := NewSessionLogger(logger, "pop3")
|
||||
|
||||
sl.Event(context.Background(), "command", slog.String("command", RedactCommandLine("PASS", []string{"geheim123"})))
|
||||
|
||||
if strings.Contains(buf.String(), "geheim123") {
|
||||
t.Fatalf("passwort im log gefunden: %s", buf.String())
|
||||
}
|
||||
if !strings.Contains(buf.String(), sl.CorrelationID()) {
|
||||
t.Fatalf("correlation_id fehlt im log: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconstruct_ReturnsOnlyMatchingSessionInOrder ist die geforderte
|
||||
// Pflichtprüfung 2 (ING-08): eine komplette Session ist über die
|
||||
// Korrelations-ID lückenlos rekonstruierbar, aus einem Log mit
|
||||
// mehreren gemischten Sessions.
|
||||
func TestReconstruct_ReturnsOnlyMatchingSessionInOrder(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(slog.NewJSONHandler(&buf, nil))
|
||||
|
||||
target := NewSessionLogger(logger, "imap")
|
||||
other := NewSessionLogger(logger, "imap")
|
||||
|
||||
target.Event(context.Background(), "session_start", slog.String("remote_addr", "127.0.0.1:1"))
|
||||
other.Event(context.Background(), "session_start", slog.String("remote_addr", "127.0.0.1:2"))
|
||||
target.Event(context.Background(), "command", slog.String("command", "LOGIN [REDACTED]"))
|
||||
other.Event(context.Background(), "command", slog.String("command", "SELECT INBOX"))
|
||||
target.Event(context.Background(), "command", slog.String("command", "SELECT INBOX"))
|
||||
target.Event(context.Background(), "session_end")
|
||||
other.Event(context.Background(), "session_end")
|
||||
|
||||
entries, err := Reconstruct(&buf, target.CorrelationID())
|
||||
if err != nil {
|
||||
t.Fatalf("Reconstruct: %v", err)
|
||||
}
|
||||
if len(entries) != 4 {
|
||||
t.Fatalf("erwartete 4 einträge für die zielsession, habe %d", len(entries))
|
||||
}
|
||||
wantMsgs := []string{"session_start", "command", "command", "session_end"}
|
||||
for i, e := range entries {
|
||||
if e.Msg != wantMsgs[i] {
|
||||
t.Fatalf("eintrag %d: erwartete msg %q, habe %q", i, wantMsgs[i], e.Msg)
|
||||
}
|
||||
if e.CorrelationID != target.CorrelationID() {
|
||||
t.Fatalf("eintrag %d gehört zur falschen session", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package protolog
|
||||
|
||||
import "strings"
|
||||
|
||||
// sensitiveCommandVerbs sind Kommandos, deren Argumente Zugangsdaten
|
||||
// enthalten können (Akzeptanzkriterium 2: Zugangsdaten erscheinen nie
|
||||
// im Klartext im Log). PASS (POP3), LOGIN (IMAP) tragen das Passwort
|
||||
// direkt als Argument; AUTH ist für zukünftige SMTP-Authentifizierung
|
||||
// vorsorglich mit aufgenommen, auch wenn ING-03 kein AUTH implementiert.
|
||||
var sensitiveCommandVerbs = map[string]bool{
|
||||
"PASS": true,
|
||||
"LOGIN": true,
|
||||
"AUTH": true,
|
||||
}
|
||||
|
||||
// RedactCommandLine liefert eine loggbare Darstellung einer
|
||||
// Kommandozeile: das Kommando (Verb) bleibt sichtbar — wichtig für die
|
||||
// Diagnose (Akzeptanzkriterium 3) —, Argumente sensibler Kommandos
|
||||
// werden vollständig durch "[REDACTED]" ersetzt statt einzeln
|
||||
// geparst, damit auch unerwartet platzierte Zugangsdaten (z. B. ein
|
||||
// Benutzername, der zufällig wie ein Passwort aussieht) nicht
|
||||
// versehentlich durchrutschen.
|
||||
func RedactCommandLine(verb string, args []string) string {
|
||||
verbUpper := strings.ToUpper(verb)
|
||||
if sensitiveCommandVerbs[verbUpper] {
|
||||
if len(args) == 0 {
|
||||
return verbUpper
|
||||
}
|
||||
return verbUpper + " [REDACTED]"
|
||||
}
|
||||
if len(args) == 0 {
|
||||
return verbUpper
|
||||
}
|
||||
return verbUpper + " " + strings.Join(args, " ")
|
||||
}
|
||||
Reference in New Issue
Block a user