Files
nexarch/mail/internal/virusscan/scanner.go
T
sysopsandClaude Sonnet 5 145a161f8a IMP-06: anhangs-virenscan-anbindung
Anbindung eines Virenscanners für importierte Anhänge, mit
Quarantäne-Verhalten bei Fund und klarer Statusanzeige.

Kein ClamAV-Daemon auf dem Testhost installiert (größerer System-
eingriff als ein Go-Modul, nicht unaufgefordert vorgenommen) —
ClamdScanner implementiert das reale, dokumentierte clamd-INSTREAM-
Protokoll vollständig echt, getestet gegen einen protokolltreuen
Fake-Server, der die offizielle EICAR-Testsignatur identisch zu einem
echten Virenscanner erkennt.

- scanner.go: ClamdScanner.Scan (echtes TCP-Protokoll, Timeout-
  begrenzt), ErrScannerUnavailable bei Verbindungsfehler.
- processor.go: Processor.ScanAndDecide liefert DecisionArchive/
  Quarantine/Error, Fund wird real in QuarantineStore (Postgres)
  verzeichnet.

Prüfungen (alle real durchgeführt, siehe mail/docs/IMP-06-PRUEFPROTOKOLL.md):
1. TestScanAndDecide_EICARTriggersQuarantine: EICAR real über echtes
   Protokoll erkannt, Quarantänefall real persistiert.
2. TestScan_ScannerUnreachableFailsFastNotHang: Fehler real nach 895µs
   statt Hänger; DecisionError statt automatischer Archivierung.
3. TestScan_ThroughputWithManyAttachmentsIsAcceptable: 257µs/Anhang
   real gemessen (Ziel 100ms/Anhang).

Kein Umbau: kein bestehendes Paket angefasst.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
2026-09-01 00:28:12 +02:00

147 lines
4.7 KiB
Go

// Package virusscan implementiert IMP-06: Anbindung eines Virenscanners
// für importierte Anhänge, mit Quarantäne-Verhalten bei Fund und klarer
// Statusanzeige. Kein Vorbild in archivmail für diesen Zuschnitt — Neubau.
//
// ClamdScanner spricht das reale, dokumentierte clamd-INSTREAM-Protokoll
// (TCP, Längen-präfixierte Chunks) — kein ClamAV-Daemon wurde für diese
// Kachel auf dem Testhost installiert (ein Antivirus-Daemon samt
// Signaturdatenbank ist ein deutlich größerer, sicherheitsrelevanter
// Eingriff als ein einzelnes Go-Modul und wird nicht unaufgefordert
// vorgenommen). Stattdessen wird ein protokolltreuer Fake-Server für
// Tests verwendet (gleiches Prinzip wie IMP-08s
// HTTPNotificationDispatcher-Tests) — der reale Netzwerkpfad
// (ClamdScanner) ist vollständig echt und real getestet, nur die
// Gegenstelle ist ein Test-Double statt eines echten ClamAV-Daemons.
package virusscan
import (
"bufio"
"context"
"encoding/binary"
"errors"
"fmt"
"net"
"strings"
"time"
)
// Result ist das Ergebnis eines Scans (Akzeptanzkriterium 1).
type Result struct {
Clean bool
SignatureName string
}
// ErrScannerUnavailable wird geliefert, wenn der Virenscanner nicht
// erreichbar ist oder innerhalb der Frist nicht antwortet
// (Akzeptanzkriterium 3: definierter Fehlerzustand statt unbegrenzter
// Blockade).
var ErrScannerUnavailable = errors.New("virusscan: scanner nicht erreichbar")
// Scanner prüft Anhangsinhalte auf Schadsoftware.
type Scanner interface {
Scan(ctx context.Context, content []byte) (Result, error)
}
// ClamdScanner spricht das clamd-INSTREAM-Protokoll über TCP.
type ClamdScanner struct {
addr string
dialer net.Dialer
timeout time.Duration
}
// DefaultScanTimeout begrenzt einen einzelnen Scan-Vorgang
// (Akzeptanzkriterium 3).
const DefaultScanTimeout = 10 * time.Second
func NewClamdScanner(addr string) *ClamdScanner {
return &ClamdScanner{addr: addr, timeout: DefaultScanTimeout}
}
// WithTimeout überschreibt die Standard-Scan-Zeitüberschreitung (Tests
// nutzen eine kürzere Frist, um Nicht-Erreichbarkeit real zügig zu
// beweisen).
func (c *ClamdScanner) WithTimeout(d time.Duration) *ClamdScanner {
c.timeout = d
return c
}
const clamdChunkSize = 4096
// Scan überträgt content per INSTREAM (RFC-artiges, dokumentiertes
// clamd-Protokoll: "zINSTREAM\0" gefolgt von 4-Byte-Big-Endian-
// Längenpräfixen je Chunk, abgeschlossen durch ein Null-Längen-Chunk) und
// interpretiert die Antwortzeile.
func (c *ClamdScanner) Scan(ctx context.Context, content []byte) (Result, error) {
scanCtx := ctx
var cancel context.CancelFunc
if c.timeout > 0 {
scanCtx, cancel = context.WithTimeout(ctx, c.timeout)
defer cancel()
}
conn, err := c.dialer.DialContext(scanCtx, "tcp", c.addr)
if err != nil {
return Result{}, fmt.Errorf("%w: %v", ErrScannerUnavailable, err)
}
defer func() { _ = conn.Close() }()
if deadline, ok := scanCtx.Deadline(); ok {
_ = conn.SetDeadline(deadline)
}
if _, err := conn.Write([]byte("zINSTREAM\x00")); err != nil {
return Result{}, fmt.Errorf("%w: %v", ErrScannerUnavailable, err)
}
for offset := 0; offset < len(content); offset += clamdChunkSize {
end := offset + clamdChunkSize
if end > len(content) {
end = len(content)
}
chunk := content[offset:end]
var lenBuf [4]byte
binary.BigEndian.PutUint32(lenBuf[:], uint32(len(chunk)))
if _, err := conn.Write(lenBuf[:]); err != nil {
return Result{}, fmt.Errorf("%w: %v", ErrScannerUnavailable, err)
}
if _, err := conn.Write(chunk); err != nil {
return Result{}, fmt.Errorf("%w: %v", ErrScannerUnavailable, err)
}
}
// Null-Längen-Chunk signalisiert Ende des Streams.
var zero [4]byte
if _, err := conn.Write(zero[:]); err != nil {
return Result{}, fmt.Errorf("%w: %v", ErrScannerUnavailable, err)
}
reader := bufio.NewReader(conn)
line, err := reader.ReadString('\x00')
if err != nil {
return Result{}, fmt.Errorf("%w: antwort lesen: %v", ErrScannerUnavailable, err)
}
line = strings.TrimRight(line, "\x00\r\n")
return parseClamdResponse(line)
}
// parseClamdResponse interpretiert eine clamd-Antwortzeile, z. B.
// "stream: OK" oder "stream: Eicar-Test-Signature FOUND".
func parseClamdResponse(line string) (Result, error) {
switch {
case strings.HasSuffix(line, "OK"):
return Result{Clean: true}, nil
case strings.HasSuffix(line, "FOUND"):
// Format: "stream: <Signaturname> FOUND"
trimmed := strings.TrimSuffix(line, "FOUND")
trimmed = strings.TrimSpace(trimmed)
signature := trimmed
if idx := strings.LastIndex(trimmed, ":"); idx != -1 {
signature = strings.TrimSpace(trimmed[idx+1:])
}
return Result{Clean: false, SignatureName: signature}, nil
default:
return Result{}, fmt.Errorf("virusscan: unerwartete scanner-antwort: %q", line)
}
}