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:
sysops
2026-09-01 09:07:35 +02:00
parent b22ab67bb2
commit 7c892ed10a
14 changed files with 930 additions and 6 deletions
+68
View File
@@ -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
}
+63
View File
@@ -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...)
}
+91
View File
@@ -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)
}
}
}
+35
View File
@@ -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, " ")
}