#12: implement Antispam & Antivirus scanning pipeline (Rspamd & ClamAV)
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
@@ -28,15 +28,22 @@ func (r *InboundMessageRepository) Persist(ctx context.Context, input port.Persi
|
||||
}
|
||||
defer tx.Rollback(ctx) // no-op if Commit succeeds
|
||||
|
||||
targetFolder := input.TargetFolderName
|
||||
if targetFolder == "" {
|
||||
targetFolder = "INBOX"
|
||||
}
|
||||
|
||||
var folderID string
|
||||
var uid int64
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT id, uid_next FROM folders
|
||||
WHERE mailbox_id = $1 AND special_use = 'inbox'
|
||||
WHERE mailbox_id = $1 AND (special_use = LOWER($2) OR LOWER(name) = LOWER($2))
|
||||
ORDER BY special_use IS NOT NULL DESC
|
||||
LIMIT 1
|
||||
FOR UPDATE
|
||||
`, input.MailboxID).Scan(&folderID, &uid)
|
||||
`, input.MailboxID, targetFolder).Scan(&folderID, &uid)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("find inbox folder for mailbox %s: %w", input.MailboxID, err)
|
||||
return 0, fmt.Errorf("find folder %q for mailbox %s: %w", targetFolder, input.MailboxID, err)
|
||||
}
|
||||
|
||||
var modseq int64
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package rspamd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
"lambdamail/protocols/internal/domain/valueobject"
|
||||
)
|
||||
|
||||
type RspamdResponse struct {
|
||||
Action string `json:"action"`
|
||||
Score float64 `json:"score"`
|
||||
RequiredScore float64 `json:"required_score"`
|
||||
Symbols map[string]map[string]interface{} `json:"symbols"`
|
||||
}
|
||||
|
||||
type RspamdAdapter struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewRspamdAdapter(baseURL string) *RspamdAdapter {
|
||||
if baseURL == "" {
|
||||
baseURL = "http://localhost:11333"
|
||||
}
|
||||
return &RspamdAdapter{
|
||||
baseURL: strings.TrimSuffix(baseURL, "/"),
|
||||
httpClient: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *RspamdAdapter) Scan(ctx context.Context, input port.ScanInput) (*valueobject.ScanResult, error) {
|
||||
url := fmt.Sprintf("%s/checkv2", a.baseURL)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(input.Payload))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create rspamd request: %w", err)
|
||||
}
|
||||
|
||||
if input.ClientIP != "" {
|
||||
req.Header.Set("IP", input.ClientIP)
|
||||
}
|
||||
if input.HeloDomain != "" {
|
||||
req.Header.Set("Helo", input.HeloDomain)
|
||||
}
|
||||
if input.Sender != "" {
|
||||
req.Header.Set("From", input.Sender)
|
||||
}
|
||||
if input.Recipient != "" {
|
||||
req.Header.Set("Deliver-To", input.Recipient)
|
||||
}
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rspamd http post: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("rspamd returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var parsed RspamdResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
|
||||
return nil, fmt.Errorf("decode rspamd json: %w", err)
|
||||
}
|
||||
|
||||
res := &valueobject.ScanResult{
|
||||
Score: parsed.Score,
|
||||
RequiredScore: parsed.RequiredScore,
|
||||
Action: parsed.Action,
|
||||
Symbols: make(map[string]float64),
|
||||
HeadersToAdd: make(map[string]string),
|
||||
}
|
||||
|
||||
for name, obj := range parsed.Symbols {
|
||||
if scoreVal, ok := obj["score"].(float64); ok {
|
||||
res.Symbols[name] = scoreVal
|
||||
}
|
||||
}
|
||||
|
||||
switch parsed.Action {
|
||||
case "reject":
|
||||
res.Verdict = valueobject.ScanVerdictSpamReject
|
||||
case "greylist":
|
||||
res.Verdict = valueobject.ScanVerdictGreylist
|
||||
case "add header", "rewrite subject":
|
||||
res.Verdict = valueobject.ScanVerdictSpamJunk
|
||||
res.HeadersToAdd["X-Spam-Flag"] = "YES"
|
||||
res.HeadersToAdd["X-Spam-Score"] = fmt.Sprintf("%.2f", parsed.Score)
|
||||
default:
|
||||
res.Verdict = valueobject.ScanVerdictClean
|
||||
res.HeadersToAdd["X-Spam-Flag"] = "NO"
|
||||
res.HeadersToAdd["X-Spam-Score"] = fmt.Sprintf("%.2f", parsed.Score)
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package rspamd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
"lambdamail/protocols/internal/domain/valueobject"
|
||||
)
|
||||
|
||||
func TestRspamdAdapter_Scan_MapsActions(t *testing.T) {
|
||||
tests := []struct {
|
||||
rspamdAction string
|
||||
wantVerdict valueobject.ScanVerdict
|
||||
}{
|
||||
{"no action", valueobject.ScanVerdictClean},
|
||||
{"add header", valueobject.ScanVerdictSpamJunk},
|
||||
{"greylist", valueobject.ScanVerdictGreylist},
|
||||
{"reject", valueobject.ScanVerdictSpamReject},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.rspamdAction, func(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/checkv2" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
resp := RspamdResponse{
|
||||
Action: tt.rspamdAction,
|
||||
Score: 5.0,
|
||||
RequiredScore: 15.0,
|
||||
Symbols: map[string]map[string]interface{}{
|
||||
"SPF_PASS": {"score": -1.0},
|
||||
},
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
adapter := NewRspamdAdapter(ts.URL)
|
||||
res, err := adapter.Scan(context.Background(), port.ScanInput{Payload: []byte("test email")})
|
||||
if err != nil {
|
||||
t.Fatalf("Scan: %v", err)
|
||||
}
|
||||
|
||||
if res.Verdict != tt.wantVerdict {
|
||||
t.Errorf("verdict = %s, want %s", res.Verdict, tt.wantVerdict)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user