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
147 lines
4.7 KiB
Go
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)
|
|
}
|
|
}
|