// 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: 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) } }