#12: implement Antispam & Antivirus scanning pipeline (Rspamd & ClamAV)

This commit is contained in:
Lucas Rayan committed 2026-07-25 10:25:52 -03:00
1 parent 4c2e0b9563
commit 54d1ea5810
15 files changed
+825 -5

No files matched your search

@@ -0,0 +1,106 @@
package clamav
import (
"bufio"
"context"
"encoding/binary"
"fmt"
"io"
"net"
"strings"
"time"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/domain/valueobject"
)
type ClamAVAdapter struct {
address string
timeout time.Duration
}
func NewClamAVAdapter(address string) *ClamAVAdapter {
if address == "" {
address = "localhost:3310"
}
return &ClamAVAdapter{
address: address,
timeout: 10 * time.Second,
}
}
func (a *ClamAVAdapter) Scan(ctx context.Context, input port.ScanInput) (*valueobject.ScanResult, error) {
d := net.Dialer{Timeout: a.timeout}
conn, err := d.DialContext(ctx, "tcp", a.address)
if err != nil {
return nil, fmt.Errorf("connect to clamd at %s: %w", a.address, err)
}
defer conn.Close()
if deadline, ok := ctx.Deadline(); ok {
conn.SetDeadline(deadline)
} else {
conn.SetDeadline(time.Now().Add(a.timeout))
}
// Send zINSTREAM command
if _, err := conn.Write([]byte("zINSTREAM\x00")); err != nil {
return nil, fmt.Errorf("write zINSTREAM: %w", err)
}
// Stream payload in chunks of up to 4096 bytes
chunkSize := 4096
data := input.Payload
for i := 0; i < len(data); i += chunkSize {
end := i + chunkSize
if end > len(data) {
end = len(data)
}
chunk := data[i:end]
lenBuf := make([]byte, 4)
binary.BigEndian.PutUint32(lenBuf, uint32(len(chunk)))
if _, err := conn.Write(lenBuf); err != nil {
return nil, fmt.Errorf("write chunk length: %w", err)
}
if _, err := conn.Write(chunk); err != nil {
return nil, fmt.Errorf("write chunk data: %w", err)
}
}
// Write zero-length terminator chunk
if _, err := conn.Write([]byte{0, 0, 0, 0}); err != nil {
return nil, fmt.Errorf("write eof chunk: %w", err)
}
if tcpConn, ok := conn.(*net.TCPConn); ok {
tcpConn.CloseWrite()
}
// Read clamd response
reader := bufio.NewReader(conn)
respStr, err := reader.ReadString('\x00')
if err != nil && err != io.EOF {
return nil, fmt.Errorf("read clamd response: %w", err)
}
respStr = strings.TrimRight(respStr, "\x00\r\n")
if strings.HasSuffix(respStr, "OK") {
res := valueobject.NewCleanScanResult()
res.HeadersToAdd["X-Virus-Scanned"] = "ClamAV clean"
return res, nil
}
if strings.HasSuffix(respStr, "FOUND") {
// Response format: "stream: EICAR-Test-Signature FOUND"
parts := strings.Split(respStr, ":")
virusName := "Unknown"
if len(parts) >= 2 {
virusName = strings.TrimSuffix(strings.TrimSpace(parts[1]), " FOUND")
}
return valueobject.NewVirusScanResult(virusName), nil
}
return nil, fmt.Errorf("unexpected clamd response: %q", respStr)
}
@@ -0,0 +1,70 @@
package clamav
import (
"context"
"net"
"testing"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/domain/valueobject"
)
func TestClamAVAdapter_Scan_CleanAndVirus(t *testing.T) {
// Clean mock server
cleanLn, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen clean server: %v", err)
}
defer cleanLn.Close()
go func() {
conn, err := cleanLn.Accept()
if err != nil {
return
}
defer conn.Close()
buf := make([]byte, 4096)
for {
_, err := conn.Read(buf)
if err != nil {
break
}
}
conn.Write([]byte("stream: OK\x00"))
}()
adapter := NewClamAVAdapter(cleanLn.Addr().String())
res, err := adapter.Scan(context.Background(), port.ScanInput{Payload: []byte("clean content")})
if err != nil || res.Verdict != valueobject.ScanVerdictClean {
t.Fatalf("expected clean scan, got res=%+v, err=%v", res, err)
}
// Virus mock server
virusLn, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen virus server: %v", err)
}
defer virusLn.Close()
go func() {
conn, err := virusLn.Accept()
if err != nil {
return
}
defer conn.Close()
buf := make([]byte, 4096)
for {
_, err := conn.Read(buf)
if err != nil {
break
}
}
conn.Write([]byte("stream: EICAR-Test-Signature FOUND\x00"))
}()
virusAdapter := NewClamAVAdapter(virusLn.Addr().String())
res, err = virusAdapter.Scan(context.Background(), port.ScanInput{Payload: []byte("virus content")})
if err != nil || res.Verdict != valueobject.ScanVerdictVirusReject || res.VirusName != "EICAR-Test-Signature" {
t.Fatalf("expected virus scan, got res=%+v, err=%v", res, err)
}
}