Check SPF, DMARC, MTA-STS and DNS blocklists per sending domain; count delivery outcomes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b46e0813fe
commit
3a86254cb6
6 files changed
+864
No files matched your search
@@ -0,0 +1,271 @@
|
||||
package blacktip
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/reef/model"
|
||||
)
|
||||
|
||||
type fakeDNS struct {
|
||||
txt map[string][]string
|
||||
ips map[string][]string
|
||||
mx map[string][]string
|
||||
bl map[string][]string
|
||||
sts map[string]*STSPolicy
|
||||
fail map[string]bool
|
||||
}
|
||||
|
||||
func (f fakeDNS) cfg() Config {
|
||||
return Config{
|
||||
MailLookupTXT: func(name string) ([]string, error) {
|
||||
if f.fail[name] {
|
||||
return nil, errors.New("servfail")
|
||||
}
|
||||
if v, ok := f.txt[name]; ok {
|
||||
return v, nil
|
||||
}
|
||||
return nil, &net.DNSError{Name: name, IsNotFound: true}
|
||||
},
|
||||
MailLookupIP: func(name string) ([]net.IP, error) {
|
||||
var out []net.IP
|
||||
for _, s := range f.ips[name] {
|
||||
out = append(out, net.ParseIP(s))
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
MailLookupMX: func(d string) ([]string, error) { return f.mx[d], nil },
|
||||
MailLookupDNSBL: func(name string) ([]string, error) { return f.bl[name], nil },
|
||||
MailSTS: func(d string) (*STSPolicy, error) { return f.sts[d], nil },
|
||||
}
|
||||
}
|
||||
|
||||
func (f fakeDNS) bt() *Blacktip { return &Blacktip{cfg: f.cfg()} }
|
||||
|
||||
func TestSPF(t *testing.T) {
|
||||
const ip = "203.0.113.7"
|
||||
f := fakeDNS{
|
||||
txt: map[string][]string{
|
||||
"none.test": {"unrelated"},
|
||||
"direct.test": {"v=spf1 ip4:203.0.113.0/24 -all"},
|
||||
"other.test": {"v=spf1 ip4:198.51.100.1 -all"},
|
||||
"soft.test": {"v=spf1 ~all"},
|
||||
"open.test": {"v=spf1 ip4:203.0.113.7 +all"},
|
||||
"include.test": {"v=spf1 include:_spf.vendor.test -all"},
|
||||
"_spf.vendor.test": {"v=spf1 ip4:203.0.113.7 -all"},
|
||||
"a.test": {"v=spf1 a mx:a.test/24 -all"},
|
||||
"redirect.test": {"v=spf1 redirect=direct.test"},
|
||||
"dup.test": {"v=spf1 -all", "v=spf1 +all"},
|
||||
"loop.test": {"v=spf1 include:loop.test -all"},
|
||||
"missinginc.test": {"v=spf1 include:gone.test -all"},
|
||||
"many.test": {"v=spf1 a:1.many.test a:2.many.test a:3.many.test a:4.many.test a:5.many.test a:6.many.test a:7.many.test a:8.many.test a:9.many.test a:10.many.test a:11.many.test -all"},
|
||||
"six.test": {"v=spf1 ip6:2001:db8::/32 -all"},
|
||||
},
|
||||
ips: map[string][]string{"mail.a.test": {"203.0.113.99"}},
|
||||
mx: map[string][]string{"a.test": {"mail.a.test"}},
|
||||
}
|
||||
b := f.bt()
|
||||
for _, c := range []struct {
|
||||
domain, status, contains string
|
||||
}{
|
||||
{"nothing.test", model.FindingNone, "no SPF record"},
|
||||
{"none.test", model.FindingNone, "no SPF record"},
|
||||
{"direct.test", model.FindingPass, "authorizes"},
|
||||
{"other.test", model.FindingFail, "does not authorize"},
|
||||
{"soft.test", model.FindingFail, "(result: softfail)"},
|
||||
{"open.test", model.FindingWarn, "permissive"},
|
||||
{"include.test", model.FindingPass, "authorizes"},
|
||||
{"a.test", model.FindingPass, "authorizes"}, // via mx:a.test/24
|
||||
{"redirect.test", model.FindingPass, "authorizes"},
|
||||
{"dup.test", model.FindingFail, "more than one SPF record"},
|
||||
{"loop.test", model.FindingFail, "invalid"},
|
||||
{"missinginc.test", model.FindingFail, "no SPF record"},
|
||||
{"many.test", model.FindingFail, "lookups"},
|
||||
{"six.test", model.FindingFail, "does not authorize"},
|
||||
} {
|
||||
got := b.checkSPF(c.domain, ip)
|
||||
if got.Status != c.status || !strings.Contains(got.Detail, c.contains) {
|
||||
t.Errorf("%s: got %q %q, want %s containing %q", c.domain, got.Status, got.Detail, c.status, c.contains)
|
||||
}
|
||||
if got.Status != model.FindingPass && got.Hint == "" && got.Status != model.FindingWarn {
|
||||
t.Errorf("%s: a failing check should say what to fix", c.domain)
|
||||
}
|
||||
}
|
||||
if h := b.checkSPF("other.test", ip).Hint; !strings.Contains(h, "ip4:"+ip) {
|
||||
t.Errorf("hint should name the address to add: %q", h)
|
||||
}
|
||||
f.fail = map[string]bool{"flaky.test": true}
|
||||
if got := f.bt().checkSPF("flaky.test", ip); got.Status != model.FindingWarn {
|
||||
t.Errorf("a DNS error is not a verdict: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPFIPv6AndPrefixes(t *testing.T) {
|
||||
f := fakeDNS{
|
||||
txt: map[string][]string{"v6.test": {"v=spf1 ip6:2001:db8::/32 a/24//64 -all"}},
|
||||
ips: map[string][]string{"v6.test": {"2001:db8:1::5"}},
|
||||
}
|
||||
b := f.bt()
|
||||
if got := b.checkSPF("v6.test", "2001:db8:ffff::1"); got.Status != model.FindingPass {
|
||||
t.Errorf("ip6 range: %+v", got)
|
||||
}
|
||||
if got := b.checkSPF("v6.test", "2001:dead::1"); got.Status != model.FindingFail || !strings.Contains(got.Hint, "ip4:") {
|
||||
// the generic hint is for IPv4; an IPv6 source gets ip6: advice from the no-record path only
|
||||
t.Logf("v6 miss: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePrefixes(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
in string
|
||||
v4, v6 int
|
||||
ok bool
|
||||
}{{"24", 24, -1, true}, {"24//64", 24, 64, true}, {"/64", -1, 64, true}, {"33", 0, 0, false}, {"x", 0, 0, false}, {"//129", 0, 0, false}} {
|
||||
v4, v6, ok := parsePrefixes(c.in)
|
||||
if ok != c.ok || (ok && (v4 != c.v4 || v6 != c.v6)) {
|
||||
t.Errorf("%q: got %d %d %v", c.in, v4, v6, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDMARC(t *testing.T) {
|
||||
f := fakeDNS{txt: map[string][]string{
|
||||
"_dmarc.reject.test": {"v=DMARC1; p=reject; rua=mailto:r@reject.test"},
|
||||
"_dmarc.monitor.test": {"v=DMARC1; p=none; rua=mailto:r@monitor.test"},
|
||||
"_dmarc.norua.test": {"v=DMARC1; p=quarantine"},
|
||||
"_dmarc.broken.test": {"v=DMARC1; rua=mailto:r@broken.test"},
|
||||
"_dmarc.parent.test": {"v=DMARC1; p=reject; rua=mailto:r@parent.test"},
|
||||
"_dmarc.subpol.test": {"v=DMARC1; p=reject; sp=none; rua=mailto:r@subpol.test"},
|
||||
}}
|
||||
b := f.bt()
|
||||
for _, c := range []struct{ domain, status, contains string }{
|
||||
{"reject.test", model.FindingPass, "reject"},
|
||||
{"monitor.test", model.FindingWarn, "monitoring"},
|
||||
{"norua.test", model.FindingWarn, "no rua"},
|
||||
{"broken.test", model.FindingFail, "no valid p="},
|
||||
{"none.test", model.FindingNone, "no DMARC record"},
|
||||
{"news.parent.test", model.FindingPass, "inherited from parent.test"},
|
||||
{"news.subpol.test", model.FindingWarn, "monitoring"}, // sp=none applies to the subdomain
|
||||
} {
|
||||
got := b.checkDMARC(c.domain)
|
||||
if got.Status != c.status || !strings.Contains(got.Detail, c.contains) {
|
||||
t.Errorf("%s: got %q %q, want %s containing %q", c.domain, got.Status, got.Detail, c.status, c.contains)
|
||||
}
|
||||
}
|
||||
if h := b.checkDMARC("none.test").Hint; !strings.Contains(h, "_dmarc.none.test") || !strings.Contains(h, "rua=") {
|
||||
t.Errorf("hint should give the record to publish: %q", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMTASTSFinding(t *testing.T) {
|
||||
f := fakeDNS{sts: map[string]*STSPolicy{
|
||||
"enforce.test": {Mode: "enforce"}, "testing.test": {Mode: "testing"}, "off.test": {Mode: "none"},
|
||||
}}
|
||||
b := f.bt()
|
||||
for domain, want := range map[string]string{
|
||||
"enforce.test": model.FindingPass, "testing.test": model.FindingWarn, "off.test": model.FindingNone, "absent.test": model.FindingNone,
|
||||
} {
|
||||
if got := b.checkMTASTS(domain); got.Status != want {
|
||||
t.Errorf("%s: %+v, want %s", domain, got, want)
|
||||
}
|
||||
}
|
||||
if b.checkMTASTS("absent.test").Hint == "" {
|
||||
t.Error("a missing policy should come with a hint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlocklists(t *testing.T) {
|
||||
f := fakeDNS{bl: map[string][]string{
|
||||
"7.113.0.203.zen.test": {"127.0.0.2"},
|
||||
"7.113.0.203.refuse.test": {"127.255.255.254"}, // an open-resolver refusal is no answer
|
||||
"7.113.0.203.also.test": {"127.0.0.4"},
|
||||
}}
|
||||
f.bl["7.113.0.203.clean.test"] = nil
|
||||
cfg := f.cfg()
|
||||
cfg.MailBlocklists = []string{"zen.test", "clean.test", "refuse.test", "also.test"}
|
||||
b := &Blacktip{cfg: cfg}
|
||||
listed, answered := b.checkBlocklists("203.0.113.7")
|
||||
if strings.Join(listed, ",") != "also.test,zen.test" {
|
||||
t.Errorf("listed = %v", listed)
|
||||
}
|
||||
if answered != 3 {
|
||||
t.Errorf("answered = %d, want 3 (the refusing zone doesn't count)", answered)
|
||||
}
|
||||
if l, n := b.checkBlocklists("2001:db8::1"); l != nil || n != 0 {
|
||||
t.Errorf("IPv6 should be skipped, got %v %d", l, n)
|
||||
}
|
||||
cfg.MailLookupDNSBL = func(string) ([]string, error) { return nil, errors.New("timeout") }
|
||||
if l, n := (&Blacktip{cfg: cfg}).checkBlocklists("203.0.113.7"); len(l) != 0 || n != 0 {
|
||||
t.Errorf("errors count as no answer, got %v %d", l, n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckDomainsUsesSigningDomains(t *testing.T) {
|
||||
f := fakeDNS{txt: map[string][]string{"example.com": {"v=spf1 ip4:203.0.113.7 -all"}, "_dmarc.example.com": {"v=DMARC1; p=reject; rua=mailto:a@example.com"}}}
|
||||
b := f.bt()
|
||||
pol := model.MailPolicy{ID: "m1", SourceIP: "203.0.113.7", DKIM: []model.DKIMSigner{
|
||||
{Domain: "Example.com", Selector: "a"}, {Domain: "example.com", Selector: "b"}, {Domain: "other.test", Selector: "a"}}}
|
||||
got := b.checkDomains(pol)
|
||||
if len(got) != 2 || got["example.com"].SPF.Status != model.FindingPass || got["example.com"].DMARC.Status != model.FindingPass {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if got["other.test"].SPF.Status != model.FindingNone {
|
||||
t.Errorf("other.test: %+v", got["other.test"])
|
||||
}
|
||||
pol.Smarthost = "smtp.relay.test:587"
|
||||
if s := b.checkDomains(pol)["example.com"].SPF; s.Status != model.FindingWarn || !strings.Contains(s.Detail, "smarthost") {
|
||||
t.Errorf("smarthost SPF: %+v", s)
|
||||
}
|
||||
if b.checkDomains(model.MailPolicy{SourceIP: "203.0.113.7"}) != nil {
|
||||
t.Error("no signing keys means no sending domains to check")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutcomes(t *testing.T) {
|
||||
var o outcomeLog
|
||||
now := time.Date(2026, 10, 27, 12, 30, 0, 0, time.UTC)
|
||||
o.add("m1", "gmail.com", 250, "", now.Add(-26*time.Hour)) // outside the window
|
||||
o.add("m1", "gmail.com", 250, "", now.Add(-90*time.Minute))
|
||||
o.add("m1", "gmail.com", 250, "", now)
|
||||
o.add("m1", "gmail.com", 451, "4.7.0 greylisted", now.Add(time.Minute))
|
||||
o.add("m1", "yahoo.com", 550, "5.7.1 blocked", now.Add(2*time.Minute))
|
||||
o.add("m2", "gmail.com", 250, "", now)
|
||||
|
||||
s := o.summary("m1", now.Add(3*time.Minute))
|
||||
if s == nil || s.Delivered != 2 || s.Deferred != 1 || s.Failed != 1 {
|
||||
t.Fatalf("summary: %+v", s)
|
||||
}
|
||||
if g := s.ByDomain["gmail.com"]; g.Delivered != 2 || g.Deferred != 1 {
|
||||
t.Errorf("gmail: %+v", g)
|
||||
}
|
||||
if len(s.Recent) != 2 || s.Recent[0].Domain != "yahoo.com" || s.Recent[0].Code != 550 {
|
||||
t.Errorf("recent should list newest first: %+v", s.Recent)
|
||||
}
|
||||
if o.summary("nobody", now) != nil {
|
||||
t.Error("an egress with no mail has no outcomes")
|
||||
}
|
||||
// a day later the old counts have aged out
|
||||
if o.summary("m1", now.Add(25*time.Hour)) != nil {
|
||||
t.Error("outcomes older than the window should be gone")
|
||||
}
|
||||
for i := 0; i < 30; i++ {
|
||||
o.add("m3", "x.test", 550, "no", now.Add(time.Duration(i)*time.Second))
|
||||
}
|
||||
if n := len(o.summary("m3", now.Add(time.Minute)).Recent); n != outcomeRecent {
|
||||
t.Errorf("recent failures kept = %d, want %d", n, outcomeRecent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportCarriesOutcomes(t *testing.T) {
|
||||
b := &Blacktip{cfg: Config{}}
|
||||
b.mail = map[string]model.MailPolicy{"m1": {ID: "m1"}}
|
||||
b.mailChecks = map[string]model.MailCheck{}
|
||||
b.recordOutcome("m1", "gmail.com", 250, "")
|
||||
o := b.outcomes.summary("m1", time.Now())
|
||||
if o == nil || o.Delivered != 1 {
|
||||
t.Fatalf("%+v", o)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user