first commit
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
// Package pipeline implements the inbound security pipeline: SPF, DKIM
|
||||
// verification, DMARC, header/URL heuristics — each stage contributes a
|
||||
// score, and the orchestrator maps the total score to a verdict (clean,
|
||||
// flagged, quarantine, blocked) per the configured thresholds.
|
||||
//
|
||||
// Every stage is blocking and runs before the SMTP DATA response — no
|
||||
// third-party spam-filtering library, entirely stdlib DNS/crypto/net/mail.
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gomail/internal/config"
|
||||
"gomail/internal/db"
|
||||
)
|
||||
|
||||
// MailContext carries everything a stage needs and accumulates results.
|
||||
type MailContext struct {
|
||||
SenderIP net.IP
|
||||
SenderHost string
|
||||
MailFrom string
|
||||
RcptTo string
|
||||
RawMessage []byte
|
||||
|
||||
Checks []StageResult
|
||||
TotalScore float64
|
||||
Verdict db.MessageVerdict
|
||||
|
||||
parsedMessage *mail.Message
|
||||
parseErr error
|
||||
parseAttempted bool
|
||||
}
|
||||
|
||||
// ParsedMessage lazily parses RawMessage via net/mail — stages call this
|
||||
// instead of parsing independently, so the (relatively expensive) header
|
||||
// parse happens at most once per message regardless of how many stages need it.
|
||||
func (mc *MailContext) ParsedMessage() (*mail.Message, error) {
|
||||
if !mc.parseAttempted {
|
||||
mc.parsedMessage, mc.parseErr = mail.ReadMessage(strings.NewReader(string(mc.RawMessage)))
|
||||
mc.parseAttempted = true
|
||||
}
|
||||
return mc.parsedMessage, mc.parseErr
|
||||
}
|
||||
|
||||
// RcptDomain returns the domain portion of RcptTo.
|
||||
func (mc *MailContext) RcptDomain() string {
|
||||
return domainOf(mc.RcptTo)
|
||||
}
|
||||
|
||||
// MailFromDomain returns the domain portion of the envelope sender.
|
||||
func (mc *MailContext) MailFromDomain() string {
|
||||
return domainOf(mc.MailFrom)
|
||||
}
|
||||
|
||||
func domainOf(addr string) string {
|
||||
parts := strings.SplitN(strings.ToLower(addr), "@", 2)
|
||||
if len(parts) == 2 {
|
||||
return parts[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// StageResult is one check's outcome.
|
||||
type StageResult struct {
|
||||
Stage string
|
||||
Result db.CheckResult
|
||||
Score float64
|
||||
Detail string
|
||||
DurationMs int64
|
||||
}
|
||||
|
||||
// Stage is one pipeline check. Run must not block indefinitely — pass a
|
||||
// context with a deadline and respect it for any network I/O (DNS lookups).
|
||||
type Stage interface {
|
||||
Name() string
|
||||
Run(ctx context.Context, mc *MailContext) *StageResult
|
||||
}
|
||||
|
||||
// Orchestrator runs the configured stages in order and computes the verdict.
|
||||
type Orchestrator struct {
|
||||
stages []Stage
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewOrchestrator(cfg *config.Config, stages []Stage) *Orchestrator {
|
||||
return &Orchestrator{stages: stages, cfg: cfg}
|
||||
}
|
||||
|
||||
// DefaultStages returns the deterministic, always-on stage set (SPF, DKIM,
|
||||
// DMARC, header anomaly, URL heuristics) — no external service
|
||||
// dependencies, always safe to run regardless of what's configured.
|
||||
func DefaultStages() []Stage {
|
||||
return []Stage{
|
||||
&SPFStage{},
|
||||
&DKIMStage{},
|
||||
&DMARCStage{},
|
||||
&HeaderStage{},
|
||||
&URLStage{},
|
||||
}
|
||||
}
|
||||
|
||||
// StagesFromConfig returns DefaultStages() plus any of the optional
|
||||
// external-service stages (ClamAV, Rspamd, LLM) that config has an address
|
||||
// configured for — each is entirely absent from the pipeline, not merely
|
||||
// disabled, when its config field is empty, so an unreachable/misconfigured
|
||||
// service that was never intended to be used can't accidentally affect
|
||||
// delivery.
|
||||
func StagesFromConfig(cfg *config.Config) []Stage {
|
||||
stages := DefaultStages()
|
||||
|
||||
if cfg.Pipeline.ClamAVSocket != "" {
|
||||
stages = append(stages, &ClamAVStage{Addr: cfg.Pipeline.ClamAVSocket, Timeout: 30 * time.Second})
|
||||
}
|
||||
if cfg.Pipeline.RspamdURL != "" {
|
||||
stages = append(stages, &RspamdStage{BaseURL: cfg.Pipeline.RspamdURL, Timeout: 15 * time.Second})
|
||||
}
|
||||
if cfg.Pipeline.LLMURL != "" {
|
||||
timeout := time.Duration(cfg.Pipeline.LLMTimeoutSecs) * time.Second
|
||||
if timeout <= 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
stages = append(stages, &LLMStage{BaseURL: cfg.Pipeline.LLMURL, Model: cfg.Pipeline.LLMModel, Timeout: timeout})
|
||||
}
|
||||
|
||||
return stages
|
||||
}
|
||||
|
||||
// Run executes every stage in order, accumulating score, and computes the
|
||||
// final verdict against the configured thresholds. Individual stage panics
|
||||
// are not recovered here deliberately — a panicking stage is a bug that
|
||||
// should surface loudly in testing, not be silently swallowed in production
|
||||
// and misclassify mail.
|
||||
func (o *Orchestrator) Run(ctx context.Context, mc *MailContext) {
|
||||
for _, stage := range o.stages {
|
||||
start := time.Now()
|
||||
result := stage.Run(ctx, mc)
|
||||
if result == nil {
|
||||
continue
|
||||
}
|
||||
result.DurationMs = time.Since(start).Milliseconds()
|
||||
mc.Checks = append(mc.Checks, *result)
|
||||
mc.TotalScore += result.Score
|
||||
}
|
||||
|
||||
mc.Verdict = verdictFor(mc.TotalScore, o.cfg.Pipeline)
|
||||
}
|
||||
|
||||
func verdictFor(score float64, p config.PipelineConfig) db.MessageVerdict {
|
||||
switch {
|
||||
case score >= p.ScoreBlock:
|
||||
return db.VerdictBlocked
|
||||
case score >= p.ScoreQuarantine:
|
||||
return db.VerdictQuarantine
|
||||
case score >= p.ScoreFlag:
|
||||
return db.VerdictFlagged
|
||||
default:
|
||||
return db.VerdictClean
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gomail/internal/db"
|
||||
)
|
||||
|
||||
// ClamAVStage scans the raw message via clamd's INSTREAM protocol — a
|
||||
// small, well-documented binary protocol (no third-party clamd client
|
||||
// library): send "zINSTREAM\0", then the message in 4-byte-big-endian-
|
||||
// length-prefixed chunks terminated by a zero-length chunk, then read the
|
||||
// single-line response ("stream: OK", "stream: <name> FOUND", or
|
||||
// "stream: <error>"). Off by default — only active when
|
||||
// config.Pipeline.ClamAVSocket is set.
|
||||
type ClamAVStage struct {
|
||||
Addr string // "unix:/var/run/clamav/clamd.ctl" or "tcp:127.0.0.1:3310"
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
func (s *ClamAVStage) Name() string { return "clamav" }
|
||||
|
||||
func (s *ClamAVStage) Run(ctx context.Context, mc *MailContext) *StageResult {
|
||||
start := time.Now()
|
||||
result := &StageResult{Stage: s.Name()}
|
||||
|
||||
verdict, detail, err := s.scan(ctx, mc.RawMessage)
|
||||
result.DurationMs = time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
result.Result = db.CheckError
|
||||
result.Detail = "clamd scan failed: " + err.Error()
|
||||
return result
|
||||
}
|
||||
|
||||
switch verdict {
|
||||
case "FOUND":
|
||||
result.Result = db.CheckFail
|
||||
result.Score = 100 // malware is always a hard block, not a scored contribution
|
||||
result.Detail = "malware detected: " + detail
|
||||
case "OK":
|
||||
result.Result = db.CheckPass
|
||||
result.Detail = "clean"
|
||||
default:
|
||||
result.Result = db.CheckError
|
||||
result.Detail = "unexpected clamd response: " + detail
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *ClamAVStage) scan(ctx context.Context, raw []byte) (verdict, detail string, err error) {
|
||||
network, address, err := parseClamAddr(s.Addr)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
dialer := net.Dialer{Timeout: s.Timeout}
|
||||
conn, err := dialer.DialContext(ctx, network, address)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("connecting to clamd: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
conn.SetDeadline(deadline)
|
||||
} else if s.Timeout > 0 {
|
||||
conn.SetDeadline(time.Now().Add(s.Timeout))
|
||||
}
|
||||
|
||||
if _, err := conn.Write([]byte("zINSTREAM\x00")); err != nil {
|
||||
return "", "", fmt.Errorf("sending INSTREAM command: %w", err)
|
||||
}
|
||||
|
||||
const chunkSize = 8192
|
||||
for i := 0; i < len(raw); i += chunkSize {
|
||||
end := i + chunkSize
|
||||
if end > len(raw) {
|
||||
end = len(raw)
|
||||
}
|
||||
chunk := raw[i:end]
|
||||
|
||||
lenBuf := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(lenBuf, uint32(len(chunk)))
|
||||
if _, err := conn.Write(lenBuf); err != nil {
|
||||
return "", "", fmt.Errorf("writing chunk length: %w", err)
|
||||
}
|
||||
if _, err := conn.Write(chunk); err != nil {
|
||||
return "", "", fmt.Errorf("writing chunk data: %w", err)
|
||||
}
|
||||
}
|
||||
if _, err := conn.Write([]byte{0, 0, 0, 0}); err != nil {
|
||||
return "", "", fmt.Errorf("writing terminator: %w", err)
|
||||
}
|
||||
|
||||
buf := make([]byte, 4096)
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("reading clamd response: %w", err)
|
||||
}
|
||||
response := strings.TrimRight(string(buf[:n]), "\x00\r\n")
|
||||
|
||||
switch {
|
||||
case strings.HasSuffix(response, "OK"):
|
||||
return "OK", response, nil
|
||||
case strings.Contains(response, "FOUND"):
|
||||
return "FOUND", response, nil
|
||||
default:
|
||||
return "ERROR", response, nil
|
||||
}
|
||||
}
|
||||
|
||||
// parseClamAddr accepts "unix:/path/to/socket" or "tcp:host:port" —
|
||||
// explicit scheme prefix rather than sniffing, so a misconfigured address
|
||||
// fails loudly at startup instead of guessing wrong.
|
||||
func parseClamAddr(addr string) (network, address string, err error) {
|
||||
switch {
|
||||
case strings.HasPrefix(addr, "unix:"):
|
||||
return "unix", strings.TrimPrefix(addr, "unix:"), nil
|
||||
case strings.HasPrefix(addr, "tcp:"):
|
||||
return "tcp", strings.TrimPrefix(addr, "tcp:"), nil
|
||||
default:
|
||||
return "", "", fmt.Errorf("clamav_socket must start with 'unix:' or 'tcp:', got %q", addr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"gomail/internal/db"
|
||||
"gomail/internal/dkim"
|
||||
)
|
||||
|
||||
type DKIMStage struct{}
|
||||
|
||||
func (s *DKIMStage) Name() string { return "dkim" }
|
||||
|
||||
func (s *DKIMStage) Run(ctx context.Context, mc *MailContext) *StageResult {
|
||||
domain, selector, found := dkim.ExtractSignatureInfo(mc.RawMessage)
|
||||
if !found {
|
||||
return &StageResult{Stage: s.Name(), Result: db.CheckWarn, Score: 5, Detail: "no DKIM-Signature header present"}
|
||||
}
|
||||
|
||||
dnsHost := selector + "._domainkey." + domain
|
||||
resolver := net.DefaultResolver
|
||||
txts, err := resolver.LookupTXT(ctx, dnsHost)
|
||||
if err != nil {
|
||||
return &StageResult{Stage: s.Name(), Result: db.CheckWarn, Score: 8,
|
||||
Detail: fmt.Sprintf("DKIM public key DNS lookup failed for %s: %v", dnsHost, err)}
|
||||
}
|
||||
|
||||
var record string
|
||||
for _, txt := range txts {
|
||||
if strings.Contains(txt, "p=") {
|
||||
record = txt
|
||||
break
|
||||
}
|
||||
}
|
||||
if record == "" {
|
||||
return &StageResult{Stage: s.Name(), Result: db.CheckWarn, Score: 8,
|
||||
Detail: fmt.Sprintf("no DKIM key record found at %s", dnsHost)}
|
||||
}
|
||||
|
||||
pubDER, err := dkim.ParseDNSPublicKey(record)
|
||||
if err != nil {
|
||||
return &StageResult{Stage: s.Name(), Result: db.CheckError, Score: 5,
|
||||
Detail: fmt.Sprintf("malformed DKIM public key at %s: %v", dnsHost, err)}
|
||||
}
|
||||
|
||||
if err := dkim.Verify(pubDER, mc.RawMessage); err != nil {
|
||||
return &StageResult{Stage: s.Name(), Result: db.CheckFail, Score: 20,
|
||||
Detail: fmt.Sprintf("DKIM signature verification failed (d=%s s=%s): %v", domain, selector, err)}
|
||||
}
|
||||
|
||||
return &StageResult{Stage: s.Name(), Result: db.CheckPass, Score: 0,
|
||||
Detail: fmt.Sprintf("DKIM signature valid (d=%s s=%s)", domain, selector)}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"gomail/internal/db"
|
||||
)
|
||||
|
||||
type DMARCStage struct{}
|
||||
|
||||
func (s *DMARCStage) Name() string { return "dmarc" }
|
||||
|
||||
func (s *DMARCStage) Run(ctx context.Context, mc *MailContext) *StageResult {
|
||||
msg, err := mc.ParsedMessage()
|
||||
if err != nil {
|
||||
return &StageResult{Stage: s.Name(), Result: db.CheckError, Score: 3,
|
||||
Detail: fmt.Sprintf("could not parse message headers: %v", err)}
|
||||
}
|
||||
|
||||
fromHeader := msg.Header.Get("From")
|
||||
fromDomain := extractDomainFromHeader(fromHeader)
|
||||
if fromDomain == "" {
|
||||
return &StageResult{Stage: s.Name(), Result: db.CheckWarn, Score: 5, Detail: "could not parse From header domain"}
|
||||
}
|
||||
|
||||
// Alignment: does the RFC 5322 From domain match (or share an
|
||||
// organisational domain with) the envelope MAIL FROM domain that SPF
|
||||
// already checked? Misalignment is exactly what DMARC exists to catch —
|
||||
// SPF/DKIM passing for a *different* domain than what the user sees in
|
||||
// their inbox is a classic spoofing pattern.
|
||||
envelopeDomain := mc.MailFromDomain()
|
||||
aligned := envelopeDomain != "" && (fromDomain == envelopeDomain || orgDomain(fromDomain) == orgDomain(envelopeDomain))
|
||||
|
||||
resolver := net.DefaultResolver
|
||||
txts, err := resolver.LookupTXT(ctx, "_dmarc."+fromDomain)
|
||||
if err != nil || len(txts) == 0 {
|
||||
// Fall back to organisational domain per RFC 7489 §6.6.3. A failure
|
||||
// here just leaves txts empty, handled by the "no record found"
|
||||
// check below — no separate error path needed.
|
||||
org := orgDomain(fromDomain)
|
||||
if org != fromDomain {
|
||||
txts, _ = resolver.LookupTXT(ctx, "_dmarc."+org)
|
||||
}
|
||||
}
|
||||
|
||||
var record string
|
||||
for _, txt := range txts {
|
||||
if strings.HasPrefix(strings.ToLower(txt), "v=dmarc1") {
|
||||
record = txt
|
||||
break
|
||||
}
|
||||
}
|
||||
if record == "" {
|
||||
return &StageResult{Stage: s.Name(), Result: db.CheckWarn, Score: 5,
|
||||
Detail: fmt.Sprintf("no DMARC record published for %s", fromDomain)}
|
||||
}
|
||||
|
||||
policy := dmarcTag(record, "p")
|
||||
detail := fmt.Sprintf("DMARC policy=%s for %s, envelope/header alignment=%v", policy, fromDomain, aligned)
|
||||
|
||||
if aligned {
|
||||
return &StageResult{Stage: s.Name(), Result: db.CheckPass, Score: 0, Detail: detail}
|
||||
}
|
||||
|
||||
switch policy {
|
||||
case "reject":
|
||||
return &StageResult{Stage: s.Name(), Result: db.CheckFail, Score: 25, Detail: detail}
|
||||
case "quarantine":
|
||||
return &StageResult{Stage: s.Name(), Result: db.CheckFail, Score: 15, Detail: detail}
|
||||
default: // "none" or unrecognised
|
||||
return &StageResult{Stage: s.Name(), Result: db.CheckWarn, Score: 5, Detail: detail}
|
||||
}
|
||||
}
|
||||
|
||||
func extractDomainFromHeader(headerValue string) string {
|
||||
// RFC 5322 From can be "Name <addr@domain>" or bare "addr@domain".
|
||||
addr := headerValue
|
||||
if i := strings.Index(headerValue, "<"); i >= 0 {
|
||||
if j := strings.Index(headerValue[i:], ">"); j >= 0 {
|
||||
addr = headerValue[i+1 : i+j]
|
||||
}
|
||||
}
|
||||
parts := strings.SplitN(strings.ToLower(strings.TrimSpace(addr)), "@", 2)
|
||||
if len(parts) == 2 {
|
||||
return parts[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// orgDomain approximates the "organisational domain" (RFC 7489 §3.2) by
|
||||
// taking the last two labels — good enough for common TLDs (.com, .net,
|
||||
// .org). It does not consult the Public Suffix List, so it will
|
||||
// mis-identify the org domain for domains under multi-label public suffixes
|
||||
// like .co.uk; that refinement can be added later without changing the
|
||||
// stage's shape (it would only affect the fallback DNS lookup and the
|
||||
// alignment comparison, both isolated to this one helper).
|
||||
func orgDomain(domain string) string {
|
||||
labels := strings.Split(domain, ".")
|
||||
if len(labels) <= 2 {
|
||||
return domain
|
||||
}
|
||||
return strings.Join(labels[len(labels)-2:], ".")
|
||||
}
|
||||
|
||||
func dmarcTag(record, tag string) string {
|
||||
for _, part := range strings.Split(record, ";") {
|
||||
part = strings.TrimSpace(part)
|
||||
name, value, found := strings.Cut(part, "=")
|
||||
if found && strings.TrimSpace(name) == tag {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"gomail/internal/db"
|
||||
)
|
||||
|
||||
type HeaderStage struct{}
|
||||
|
||||
func (s *HeaderStage) Name() string { return "headers" }
|
||||
|
||||
func (s *HeaderStage) Run(_ context.Context, mc *MailContext) *StageResult {
|
||||
msg, err := mc.ParsedMessage()
|
||||
if err != nil {
|
||||
return &StageResult{Stage: s.Name(), Result: db.CheckError, Score: 5,
|
||||
Detail: fmt.Sprintf("could not parse headers: %v", err)}
|
||||
}
|
||||
|
||||
var issues []string
|
||||
score := 0.0
|
||||
|
||||
if msg.Header.Get("From") == "" {
|
||||
issues = append(issues, "missing From header")
|
||||
score += 15
|
||||
}
|
||||
if msg.Header.Get("Subject") == "" {
|
||||
issues = append(issues, "missing Subject header")
|
||||
score += 3
|
||||
}
|
||||
if msg.Header.Get("Date") == "" {
|
||||
issues = append(issues, "missing Date header")
|
||||
score += 5
|
||||
}
|
||||
|
||||
fromDomain := extractDomainFromHeader(msg.Header.Get("From"))
|
||||
envDomain := mc.MailFromDomain()
|
||||
if fromDomain != "" && envDomain != "" && fromDomain != envDomain {
|
||||
issues = append(issues, fmt.Sprintf("From header domain (%s) differs from envelope sender (%s)", fromDomain, envDomain))
|
||||
score += 10
|
||||
}
|
||||
|
||||
if replyTo := msg.Header.Get("Reply-To"); replyTo != "" {
|
||||
replyDomain := extractDomainFromHeader(replyTo)
|
||||
if replyDomain != "" && fromDomain != "" && replyDomain != fromDomain {
|
||||
issues = append(issues, "Reply-To domain differs from From domain")
|
||||
score += 8
|
||||
}
|
||||
}
|
||||
|
||||
subject := strings.ToLower(msg.Header.Get("Subject"))
|
||||
urgencyPhrases := []string{
|
||||
"urgent", "verify your account", "confirm your", "suspended",
|
||||
"unusual activity", "act now", "immediately", "security alert",
|
||||
}
|
||||
for _, phrase := range urgencyPhrases {
|
||||
if strings.Contains(subject, phrase) {
|
||||
issues = append(issues, fmt.Sprintf("urgency language in subject: %q", phrase))
|
||||
score += 4
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
result := db.CheckPass
|
||||
if score > 0 {
|
||||
result = db.CheckWarn
|
||||
}
|
||||
if score >= 20 {
|
||||
result = db.CheckFail
|
||||
}
|
||||
|
||||
detail := "no header issues found"
|
||||
if len(issues) > 0 {
|
||||
detail = strings.Join(issues, "; ")
|
||||
}
|
||||
|
||||
return &StageResult{Stage: s.Name(), Result: result, Score: score, Detail: detail}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gomail/internal/db"
|
||||
)
|
||||
|
||||
// LLMStage asks a local LLM server for a spam/phishing judgment via the
|
||||
// OpenAI-compatible /v1/chat/completions endpoint — what llama.cpp's
|
||||
// server exposes (also what most other local-inference servers converged
|
||||
// on), so no custom llama.cpp-specific protocol is needed. The model is
|
||||
// instructed to answer with a single 0-100 integer, parsed directly with
|
||||
// no JSON-mode/function-calling dependency, since not every local server
|
||||
// build supports those reliably.
|
||||
//
|
||||
// This is deliberately a coarse signal, not a primary verdict: LLM output
|
||||
// is non-deterministic and shouldn't singlehandedly quarantine mail, so
|
||||
// its score contribution is capped lower than the deterministic stages
|
||||
// (SPF/DKIM/DMARC) — see the capping in Run.
|
||||
type LLMStage struct {
|
||||
BaseURL string
|
||||
Model string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
func (s *LLMStage) Name() string { return "llm" }
|
||||
|
||||
const llmSystemPrompt = `You are a spam and phishing classifier. You will be given the headers and ` +
|
||||
`body of an email. Respond with ONLY a single integer from 0 to 100 representing how likely ` +
|
||||
`this email is to be spam, phishing, or malicious — 0 means definitely legitimate, 100 means ` +
|
||||
`definitely malicious. Do not include any other text, explanation, or punctuation in your response.`
|
||||
|
||||
type chatCompletionRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []chatMessage `json:"messages"`
|
||||
}
|
||||
|
||||
type chatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type chatCompletionResponse struct {
|
||||
Choices []struct {
|
||||
Message chatMessage `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
// maxScoreContribution caps how much the LLM stage alone can push the
|
||||
// total score, regardless of what the model returns — see the doc comment
|
||||
// above for why.
|
||||
const maxScoreContribution = 30.0
|
||||
|
||||
func (s *LLMStage) Run(ctx context.Context, mc *MailContext) *StageResult {
|
||||
start := time.Now()
|
||||
result := &StageResult{Stage: s.Name()}
|
||||
|
||||
content := mc.RawMessage
|
||||
const maxContentBytes = 8192
|
||||
if len(content) > maxContentBytes {
|
||||
content = content[:maxContentBytes]
|
||||
}
|
||||
|
||||
score, err := s.classify(ctx, string(content))
|
||||
result.DurationMs = time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
result.Result = db.CheckError
|
||||
result.Detail = "LLM classification failed: " + err.Error()
|
||||
return result
|
||||
}
|
||||
|
||||
scaledScore := (score / 100.0) * maxScoreContribution
|
||||
result.Score = scaledScore
|
||||
result.Detail = fmt.Sprintf("LLM raw score=%.0f/100, capped contribution=%.1f", score, scaledScore)
|
||||
if score >= 70 {
|
||||
result.Result = db.CheckFail
|
||||
} else if score >= 40 {
|
||||
result.Result = db.CheckWarn
|
||||
} else {
|
||||
result.Result = db.CheckPass
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *LLMStage) classify(ctx context.Context, content string) (float64, error) {
|
||||
reqBody := chatCompletionRequest{
|
||||
Model: s.Model,
|
||||
Messages: []chatMessage{
|
||||
{Role: "system", Content: llmSystemPrompt},
|
||||
{Role: "user", Content: content},
|
||||
},
|
||||
}
|
||||
bodyJSON, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.BaseURL+"/v1/chat/completions", bytes.NewReader(bodyJSON))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("building request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: s.Timeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("request to LLM server: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 0, fmt.Errorf("LLM server returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result chatCompletionResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return 0, fmt.Errorf("parsing LLM response: %w", err)
|
||||
}
|
||||
if len(result.Choices) == 0 {
|
||||
return 0, fmt.Errorf("LLM response had no choices")
|
||||
}
|
||||
|
||||
raw := strings.TrimSpace(result.Choices[0].Message.Content)
|
||||
digits := extractLeadingDigits(raw)
|
||||
if digits == "" {
|
||||
return 0, fmt.Errorf("LLM response did not contain a parseable score: %q", raw)
|
||||
}
|
||||
score, err := strconv.ParseFloat(digits, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parsing score %q: %w", digits, err)
|
||||
}
|
||||
if score < 0 {
|
||||
score = 0
|
||||
}
|
||||
if score > 100 {
|
||||
score = 100
|
||||
}
|
||||
return score, nil
|
||||
}
|
||||
|
||||
func extractLeadingDigits(s string) string {
|
||||
var sb strings.Builder
|
||||
for _, r := range s {
|
||||
if r >= '0' && r <= '9' {
|
||||
sb.WriteRune(r)
|
||||
} else if sb.Len() > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gomail/internal/db"
|
||||
)
|
||||
|
||||
// RspamdStage submits the raw message to rspamd's documented /checkv2 HTTP
|
||||
// API and maps its score/action into this pipeline's scoring model. No
|
||||
// third-party rspamd client — a plain POST with the raw RFC 5322 message
|
||||
// as the body is rspamd's actual documented interface. Off by default —
|
||||
// only active when config.Pipeline.RspamdURL is set.
|
||||
type RspamdStage struct {
|
||||
BaseURL string // e.g. "http://127.0.0.1:11333"
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
func (s *RspamdStage) Name() string { return "rspamd" }
|
||||
|
||||
type rspamdResponse struct {
|
||||
Score float64 `json:"score"`
|
||||
RequiredScore float64 `json:"required_score"`
|
||||
Action string `json:"action"`
|
||||
Symbols map[string]rspamdSymbol `json:"symbols"`
|
||||
}
|
||||
|
||||
type rspamdSymbol struct {
|
||||
Score float64 `json:"score"`
|
||||
Name string `json:"name"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
func (s *RspamdStage) Run(ctx context.Context, mc *MailContext) *StageResult {
|
||||
start := time.Now()
|
||||
result := &StageResult{Stage: s.Name()}
|
||||
|
||||
resp, err := s.check(ctx, mc.RawMessage)
|
||||
result.DurationMs = time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
result.Result = db.CheckError
|
||||
result.Detail = "rspamd check failed: " + err.Error()
|
||||
return result
|
||||
}
|
||||
|
||||
// Translate rspamd's own score onto this pipeline's scale by using its
|
||||
// score directly — rspamd's score is already meant to be compared
|
||||
// against thresholds the same way this pipeline's is, so no unit
|
||||
// conversion trickery, just pass it through.
|
||||
result.Score = resp.Score
|
||||
switch resp.Action {
|
||||
case "reject":
|
||||
result.Result = db.CheckFail
|
||||
case "add header", "rewrite subject", "greylist":
|
||||
result.Result = db.CheckWarn
|
||||
default:
|
||||
result.Result = db.CheckPass
|
||||
}
|
||||
result.Detail = fmt.Sprintf("rspamd score=%.2f required=%.2f action=%s symbols=%d",
|
||||
resp.Score, resp.RequiredScore, resp.Action, len(resp.Symbols))
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *RspamdStage) check(ctx context.Context, raw []byte) (*rspamdResponse, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.BaseURL+"/checkv2", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("building request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "message/rfc822")
|
||||
|
||||
client := &http.Client{Timeout: s.Timeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request to rspamd: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("rspamd returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result rspamdResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("parsing rspamd response: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"gomail/internal/db"
|
||||
)
|
||||
|
||||
type SPFStage struct{}
|
||||
|
||||
func (s *SPFStage) Name() string { return "spf" }
|
||||
|
||||
func (s *SPFStage) Run(ctx context.Context, mc *MailContext) *StageResult {
|
||||
if mc.SenderIP == nil {
|
||||
return &StageResult{Stage: s.Name(), Result: db.CheckError, Detail: "no sender IP available"}
|
||||
}
|
||||
|
||||
domain := mc.MailFromDomain()
|
||||
if domain == "" {
|
||||
// Null sender (bounces, MAIL FROM:<>) — SPF simply doesn't apply.
|
||||
return &StageResult{Stage: s.Name(), Result: db.CheckSkipped, Detail: "null sender, SPF not applicable"}
|
||||
}
|
||||
|
||||
result, detail := checkSPF(ctx, mc.SenderIP, domain)
|
||||
return &StageResult{Stage: s.Name(), Result: result.check, Score: result.score, Detail: detail}
|
||||
}
|
||||
|
||||
type spfOutcome struct {
|
||||
check db.CheckResult
|
||||
score float64
|
||||
}
|
||||
|
||||
func checkSPF(ctx context.Context, senderIP net.IP, domain string) (spfOutcome, string) {
|
||||
resolver := net.DefaultResolver
|
||||
txts, err := resolver.LookupTXT(ctx, domain)
|
||||
if err != nil {
|
||||
return spfOutcome{db.CheckWarn, 5}, fmt.Sprintf("SPF DNS lookup error for %s: %v", domain, err)
|
||||
}
|
||||
|
||||
var spfRecord string
|
||||
for _, txt := range txts {
|
||||
if strings.HasPrefix(strings.ToLower(txt), "v=spf1") {
|
||||
spfRecord = txt
|
||||
break
|
||||
}
|
||||
}
|
||||
if spfRecord == "" {
|
||||
return spfOutcome{db.CheckWarn, 8}, fmt.Sprintf("no SPF record published for %s", domain)
|
||||
}
|
||||
|
||||
pass, reason := evaluateSPF(ctx, senderIP, domain, spfRecord, 0)
|
||||
if pass {
|
||||
return spfOutcome{db.CheckPass, 0}, fmt.Sprintf("SPF pass for %s (%s)", domain, reason)
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(spfRecord, "-all"):
|
||||
return spfOutcome{db.CheckFail, 25}, fmt.Sprintf("SPF hard fail for %s: %s", domain, reason)
|
||||
case strings.Contains(spfRecord, "~all"):
|
||||
return spfOutcome{db.CheckWarn, 10}, fmt.Sprintf("SPF softfail for %s: %s", domain, reason)
|
||||
default:
|
||||
return spfOutcome{db.CheckWarn, 5}, fmt.Sprintf("SPF neutral/no-match for %s: %s", domain, reason)
|
||||
}
|
||||
}
|
||||
|
||||
// evaluateSPF is a pragmatic RFC 7208 evaluator: ip4/ip6/a/mx/include/redirect
|
||||
// mechanisms, up to 10 levels of recursion (the spec's own limit). It does not
|
||||
// implement every rarely-used mechanism (ptr, exists) — those are uncommon in
|
||||
// modern SPF records and can be added later without changing the stage's shape.
|
||||
func evaluateSPF(ctx context.Context, ip net.IP, domain, record string, depth int) (bool, string) {
|
||||
if depth > 10 {
|
||||
return false, "too many SPF redirects/includes"
|
||||
}
|
||||
resolver := net.DefaultResolver
|
||||
|
||||
for _, tok := range strings.Fields(record)[1:] { // skip "v=spf1"
|
||||
lower := strings.ToLower(tok)
|
||||
switch {
|
||||
case lower == "+all" || lower == "all":
|
||||
return true, "all"
|
||||
case lower == "-all" || lower == "~all" || lower == "?all":
|
||||
return false, "all (no earlier match)"
|
||||
|
||||
case strings.HasPrefix(lower, "ip4:"), strings.HasPrefix(lower, "ip6:"):
|
||||
cidr := tok[strings.Index(tok, ":")+1:]
|
||||
if matchCIDR(ip, cidr) {
|
||||
return true, "matched " + tok
|
||||
}
|
||||
|
||||
case strings.HasPrefix(lower, "include:"):
|
||||
incDomain := tok[len("include:"):]
|
||||
txts, err := resolver.LookupTXT(ctx, incDomain)
|
||||
if err == nil {
|
||||
for _, txt := range txts {
|
||||
if strings.HasPrefix(strings.ToLower(txt), "v=spf1") {
|
||||
if ok, r := evaluateSPF(ctx, ip, incDomain, txt, depth+1); ok {
|
||||
return true, "include:" + incDomain + " -> " + r
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case lower == "a" || strings.HasPrefix(lower, "a:") || strings.HasPrefix(lower, "a/"):
|
||||
checkDomain := domain
|
||||
if strings.HasPrefix(lower, "a:") {
|
||||
checkDomain = tok[len("a:"):]
|
||||
}
|
||||
addrs, err := resolver.LookupHost(ctx, checkDomain)
|
||||
if err == nil {
|
||||
for _, a := range addrs {
|
||||
if net.ParseIP(a).Equal(ip) {
|
||||
return true, "matched a:" + checkDomain
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case lower == "mx" || strings.HasPrefix(lower, "mx:"):
|
||||
checkDomain := domain
|
||||
if strings.HasPrefix(lower, "mx:") {
|
||||
checkDomain = tok[len("mx:"):]
|
||||
}
|
||||
mxs, err := resolver.LookupMX(ctx, checkDomain)
|
||||
if err == nil {
|
||||
for _, mx := range mxs {
|
||||
addrs, _ := resolver.LookupHost(ctx, mx.Host)
|
||||
for _, a := range addrs {
|
||||
if net.ParseIP(a).Equal(ip) {
|
||||
return true, "matched mx:" + checkDomain
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case strings.HasPrefix(lower, "redirect="):
|
||||
redir := tok[len("redirect="):]
|
||||
txts, err := resolver.LookupTXT(ctx, redir)
|
||||
if err == nil {
|
||||
for _, txt := range txts {
|
||||
if strings.HasPrefix(strings.ToLower(txt), "v=spf1") {
|
||||
return evaluateSPF(ctx, ip, redir, txt, depth+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, "no mechanism matched"
|
||||
}
|
||||
|
||||
func matchCIDR(ip net.IP, cidr string) bool {
|
||||
if !strings.Contains(cidr, "/") {
|
||||
return net.ParseIP(cidr).Equal(ip)
|
||||
}
|
||||
_, network, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return network.Contains(ip)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gomail/internal/db"
|
||||
)
|
||||
|
||||
type URLStage struct{}
|
||||
|
||||
func (s *URLStage) Name() string { return "urls" }
|
||||
|
||||
var urlRE = regexp.MustCompile(`https?://[^\s<>"']+`)
|
||||
|
||||
var shortenerDomains = []string{
|
||||
"bit.ly", "tinyurl.com", "t.co", "goo.gl", "ow.ly", "is.gd", "buff.ly", "short.link",
|
||||
}
|
||||
|
||||
var suspiciousTLDs = []string{
|
||||
".xyz", ".top", ".click", ".work", ".loan", ".gq", ".tk", ".ml",
|
||||
}
|
||||
|
||||
func (s *URLStage) Run(_ context.Context, mc *MailContext) *StageResult {
|
||||
text := string(mc.RawMessage)
|
||||
urls := urlRE.FindAllString(text, 50)
|
||||
if len(urls) == 0 {
|
||||
return &StageResult{Stage: s.Name(), Result: db.CheckPass, Detail: "no URLs found"}
|
||||
}
|
||||
|
||||
var issues []string
|
||||
score := 0.0
|
||||
seen := map[string]bool{}
|
||||
|
||||
for _, u := range urls {
|
||||
u = strings.TrimRight(u, ".,;:!?)'\"")
|
||||
if seen[u] {
|
||||
continue
|
||||
}
|
||||
seen[u] = true
|
||||
|
||||
lower := strings.ToLower(u)
|
||||
for _, shortener := range shortenerDomains {
|
||||
if strings.Contains(lower, shortener) {
|
||||
issues = append(issues, fmt.Sprintf("URL shortener: %s", shortener))
|
||||
score += 6
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, tld := range suspiciousTLDs {
|
||||
if strings.Contains(lower, tld) {
|
||||
issues = append(issues, fmt.Sprintf("suspicious TLD in URL: %s", u))
|
||||
score += 4
|
||||
break
|
||||
}
|
||||
}
|
||||
// IP-address-literal URLs (http://1.2.3.4/...) are a strong phishing
|
||||
// signal — legitimate mail almost never links directly to a bare IP.
|
||||
if ipLiteralRE.MatchString(u) {
|
||||
issues = append(issues, fmt.Sprintf("IP-literal URL: %s", u))
|
||||
score += 8
|
||||
}
|
||||
}
|
||||
|
||||
if score > 30 {
|
||||
score = 30 // cap — URL heuristics alone shouldn't dominate the verdict
|
||||
}
|
||||
|
||||
result := db.CheckPass
|
||||
if score > 0 {
|
||||
result = db.CheckWarn
|
||||
}
|
||||
if score >= 15 {
|
||||
result = db.CheckFail
|
||||
}
|
||||
|
||||
detail := fmt.Sprintf("%d unique URL(s) found", len(seen))
|
||||
if len(issues) > 0 {
|
||||
detail += ": " + strings.Join(dedupe(issues), "; ")
|
||||
}
|
||||
|
||||
return &StageResult{Stage: s.Name(), Result: result, Score: score, Detail: detail}
|
||||
}
|
||||
|
||||
var ipLiteralRE = regexp.MustCompile(`https?://\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}`)
|
||||
|
||||
func dedupe(items []string) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, i := range items {
|
||||
if !seen[i] {
|
||||
seen[i] = true
|
||||
out = append(out, i)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user