272 lines
10 KiB
Go
272 lines
10 KiB
Go
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)
|
|
}
|
|
}
|