├── .gitignore ├── LICENSE ├── README.md ├── checks.go ├── dane.go ├── dns └── dnssec.go ├── fcrdns.go ├── go.mod ├── go.sum ├── main.go ├── mtasts.go ├── mtasts ├── download.go ├── mtasts.go └── mtasts_test.go └── spf.go /.gitignore: -------------------------------------------------------------------------------- 1 | mailsec-check 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright © 2019 Max Mazurov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | mailsec-check 2 | =============== 3 | 4 | Another utility to analyze state of deployment of security-related email 5 | protocols. 6 | 7 | Compilation 8 | -------------- 9 | 10 | Needs [Go](https://golang.org) toolchain. 11 | 12 | ``` 13 | go get github.com/foxcpp/mailsec-check 14 | ``` 15 | 16 | Usage 17 | ------- 18 | 19 | ``` 20 | mailsec-check example.org 21 | ``` 22 | 23 | Example 24 | --------- 25 | 26 | ``` 27 | $ mailsec-check protonmail.com 28 | -- Source forgery protection 29 | [+] DKIM: _domainkey subdomain present; DNSSEC-signed; 30 | [+] SPF: present; strict; DNSSEC-signed; 31 | [+] DMARC: present; strict; DNSSEC-signed; 32 | 33 | -- TLS enforcement 34 | [+] MTA-STS: enforced; all MXs match policy; 35 | [+] DANE: present for all MXs; DNSSEC-signed; no validity check done; 36 | 37 | -- DNS consistency 38 | [+] FCrDNS: all MXs have forward-confirmed rDNS 39 | [+] DNSSEC: A/AAAA and MX records are signed; 40 | 41 | $ mailsec-check disroot.org 42 | -- Source forgery protection 43 | [+] DKIM: _domainkey subdomain present; DNSSEC-signed; 44 | [+] SPF: present; strict; DNSSEC-signed; 45 | [ ] DMARC: present; no-op; DNSSEC-signed; 46 | 47 | -- TLS enforcement 48 | [ ] MTA-STS: not enforced; all MXs match policy; 49 | [+] DANE: present for all MXs; DNSSEC-signed; no validity check done; 50 | 51 | -- DNS consistency 52 | [ ] FCrDNS: no MXs with forward-confirmed rDNS 53 | [+] DNSSEC: A/AAAA and MX records are signed; 54 | ``` 55 | -------------------------------------------------------------------------------- /checks.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "strings" 7 | "sync" 8 | 9 | "github.com/emersion/go-msgauth/dmarc" 10 | "github.com/foxcpp/mailsec-check/dns" 11 | ) 12 | 13 | var extR *dns.ExtResolver 14 | 15 | type Level int 16 | 17 | const ( 18 | LevelUnknown Level = iota 19 | LevelInvalid 20 | LevelMissing 21 | LevelInsecure 22 | LevelSecure 23 | ) 24 | 25 | type Results struct { 26 | dkim Level 27 | dkimDesc string 28 | 29 | spf Level 30 | spfDesc string 31 | spfRec string 32 | 33 | dmarc Level 34 | dmarcDesc string 35 | dmarcRec string 36 | 37 | mtasts Level 38 | mtastsDesc string 39 | mtastsRec string 40 | 41 | dane Level 42 | daneDesc string 43 | daneRec string 44 | 45 | dnssecMX Level 46 | dnssecMXDesc string 47 | 48 | fcrdns Level 49 | fcrdnsDesc string 50 | } 51 | 52 | func evaluateAll(domain string) (Results, error) { 53 | res := Results{} 54 | 55 | wg := sync.WaitGroup{} 56 | 57 | wg.Add(7) 58 | go func() { evaluateDKIM(domain, &res); wg.Done() }() 59 | go func() { evaluateSPF(domain, &res); wg.Done() }() 60 | go func() { evaluateDMARC(domain, &res); wg.Done() }() 61 | go func() { evaluateMTASTS(domain, &res); wg.Done() }() 62 | go func() { evaluateDANE(domain, &res); wg.Done() }() 63 | go func() { evaluateDNSSEC(domain, &res); wg.Done() }() 64 | go func() { evaluateFCRDNS(domain, &res); wg.Done() }() 65 | 66 | wg.Wait() 67 | 68 | return res, nil 69 | } 70 | 71 | func evaluateDNSSEC(domain string, res *Results) error { 72 | ad, addrs, err := extR.AuthLookupHost(context.Background(), domain) 73 | if err != nil { 74 | return err 75 | } 76 | if len(addrs) == 0 { 77 | return errors.New("domain does not resolve to an IP addr") 78 | } 79 | if !ad { 80 | res.dnssecMX = LevelInsecure 81 | res.dnssecMXDesc = "A/AAAA records are not signed;" 82 | return nil 83 | } 84 | 85 | ad, mxs, err := extR.AuthLookupMX(context.Background(), domain) 86 | if err != nil { 87 | return err 88 | } 89 | if len(mxs) == 0 { 90 | return errors.New("domain does not have any MX records") 91 | } 92 | if !ad { 93 | res.dnssecMX = LevelInsecure 94 | res.dnssecMXDesc = "MX records are not signed;" 95 | return nil 96 | } 97 | 98 | res.dnssecMX = LevelSecure 99 | res.dnssecMXDesc = "A/AAAA and MX records are signed;" 100 | return nil 101 | } 102 | 103 | func evaluateDKIM(domain string, res *Results) error { 104 | ad, _, err := extR.AuthLookupTXT(context.Background(), "_domainkey."+domain) 105 | if err == dns.ErrNxDomain { 106 | res.dkim = LevelMissing 107 | res.dkimDesc = "no _domainkey subdomain;" 108 | return nil 109 | } else if err != nil { 110 | res.dkim = LevelInvalid 111 | res.dkimDesc = "domain query error: " + err.Error() + ";" 112 | return err 113 | } 114 | 115 | res.dkim = LevelSecure 116 | res.dkimDesc += "_domainkey subdomain present; " 117 | 118 | if !ad { 119 | res.dkim = LevelInsecure 120 | res.dkimDesc += "no DNSSEC; " 121 | } else { 122 | res.dkimDesc += "DNSSEC-signed; " 123 | } 124 | 125 | return nil 126 | } 127 | 128 | func evaluateDMARC(domain string, res *Results) error { 129 | res.dmarc = LevelSecure 130 | 131 | ad, txts, err := extR.AuthLookupTXT(context.Background(), "_dmarc."+domain) 132 | if err == dns.ErrNxDomain { 133 | res.dmarc = LevelMissing 134 | res.dmarcDesc = "no _dmarc subdomain;" 135 | return nil 136 | } else if err != nil { 137 | res.dmarc = LevelInvalid 138 | res.dmarcDesc = "domain query error: " + err.Error() + ";" 139 | return err 140 | } 141 | 142 | txt := strings.Join(txts, "") 143 | res.dmarcRec = txt 144 | rec, err := dmarc.Parse(txt) 145 | if err != nil { 146 | res.dmarc = LevelInvalid 147 | res.dmarcDesc = "policy parse error: " + err.Error() 148 | return nil 149 | } 150 | 151 | res.dmarcDesc += "present; " 152 | 153 | if rec.Policy == dmarc.PolicyNone { 154 | res.dmarc = LevelMissing 155 | res.dmarcDesc += "no-op; " 156 | } else if rec.Percent != nil && *rec.Percent != 100 { 157 | res.dmarc = LevelMissing 158 | res.dmarcDesc += "applied partially; " 159 | } else { 160 | res.dmarcDesc += "strict; " 161 | } 162 | if !ad { 163 | res.dmarc = LevelInsecure 164 | res.dmarcDesc += "no DNSSEC; " 165 | } else { 166 | res.dmarcDesc += "DNSSEC-signed; " 167 | } 168 | 169 | return nil 170 | } 171 | -------------------------------------------------------------------------------- /dane.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/emersion/go-smtp" 9 | "github.com/miekg/dns" 10 | ) 11 | 12 | func evaluateDANE(domain string, res *Results) error { 13 | res.dane = LevelSecure 14 | 15 | _, mxs, err := extR.AuthLookupMX(context.Background(), domain) 16 | if err != nil { 17 | return err 18 | } 19 | if len(mxs) == 0 { 20 | return errors.New("domain does not have any MX records") 21 | } 22 | 23 | levelDown := func(to Level) { 24 | if res.dane > to { 25 | res.dane = to 26 | } 27 | } 28 | 29 | allAD := true 30 | allValid := true 31 | allPresent := true 32 | for _, mx := range mxs { 33 | ad, recs, err := extR.AuthLookupTLSA(context.Background(), "_25._tcp."+mx.Host) 34 | if err != nil { 35 | allPresent = false 36 | levelDown(LevelMissing) 37 | res.daneDesc += fmt.Sprintf("no record for %s; ", mx.Host) 38 | continue 39 | } 40 | if !ad { 41 | allAD = false 42 | } 43 | if len(recs) == 0 { 44 | allPresent = false 45 | levelDown(LevelMissing) 46 | res.daneDesc += fmt.Sprintf("no record for %s; ", mx.Host) 47 | continue 48 | } 49 | for _, rec := range recs { 50 | res.daneRec += rec.String() + "\n" 51 | } 52 | 53 | if !(*active) { 54 | continue 55 | } 56 | 57 | for _, mx := range mxs { 58 | if ok := checkTLSA(mx.Host, recs, res); !ok { 59 | allValid = false 60 | } 61 | } 62 | } 63 | 64 | if allPresent { 65 | res.daneDesc += "present for all MXs; " 66 | } 67 | 68 | if !allAD { 69 | levelDown(LevelInvalid) 70 | res.daneDesc += "no DNSSEC; " 71 | } else { 72 | res.daneDesc += "DNSSEC-signed; " 73 | } 74 | 75 | if !(*active) { 76 | res.daneDesc += "no validity check done; " 77 | return nil 78 | } 79 | 80 | if allValid { 81 | res.daneDesc += "valid for all MXs; " 82 | } 83 | 84 | return nil 85 | } 86 | 87 | func checkTLSA(mx string, recs []dns.TLSA, res *Results) bool { 88 | levelDown := func(to Level) { 89 | if res.dane > to { 90 | res.dane = to 91 | } 92 | } 93 | 94 | cl, err := smtp.Dial(mx + ":25") 95 | if err != nil { 96 | levelDown(LevelUnknown) 97 | res.daneDesc += fmt.Sprintf("can't connect to %s: %v; ", mx, err) 98 | return false 99 | } 100 | defer cl.Close() 101 | 102 | if ok, _ := cl.Extension("STARTTLS"); !ok { 103 | levelDown(LevelInvalid) 104 | res.daneDesc += fmt.Sprintf("%s doesn't support STARTTLS; ", mx) 105 | return false 106 | } 107 | 108 | if err := cl.StartTLS(nil); err != nil { 109 | levelDown(LevelInvalid) 110 | res.daneDesc += err.Error() 111 | return false 112 | } 113 | 114 | state, ok := cl.TLSConnectionState() 115 | if !ok { 116 | panic("No TLS state returned after STARTTLS") 117 | } 118 | 119 | cert := state.PeerCertificates[0] 120 | match := false 121 | for _, rec := range recs { 122 | if rec.Verify(cert) == nil { 123 | match = true 124 | } 125 | } 126 | 127 | if !match { 128 | levelDown(LevelInvalid) 129 | res.daneDesc += fmt.Sprintf("%v uses wrong cert; ", mx) 130 | } 131 | 132 | return true 133 | } 134 | -------------------------------------------------------------------------------- /dns/dnssec.go: -------------------------------------------------------------------------------- 1 | package dns 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net" 7 | "strings" 8 | "time" 9 | 10 | "github.com/miekg/dns" 11 | ) 12 | 13 | var ErrNxDomain = errors.New("NXDOMAIN") 14 | 15 | // ExtResolver is a convenience wrapper for miekg/dns library that provides 16 | // access to certain low-level functionality (notably, AD flag in responses, 17 | // indicating whether DNSSEC verification was performed by the server). 18 | type ExtResolver struct { 19 | cl *dns.Client 20 | cfg *dns.ClientConfig 21 | } 22 | 23 | func (e ExtResolver) exchange(ctx context.Context, msg *dns.Msg) (*dns.Msg, error) { 24 | var resp *dns.Msg 25 | var lastErr error 26 | for _, srv := range e.cfg.Servers { 27 | resp, _, lastErr = e.cl.ExchangeContext(ctx, msg, net.JoinHostPort(srv, e.cfg.Port)) 28 | if lastErr == nil { 29 | break 30 | } 31 | } 32 | return resp, lastErr 33 | } 34 | 35 | func (e ExtResolver) AuthLookupAddr(ctx context.Context, addr string) (ad bool, names []string, err error) { 36 | revAddr, err := dns.ReverseAddr(addr) 37 | if err != nil { 38 | return false, nil, err 39 | } 40 | 41 | msg := new(dns.Msg) 42 | msg.SetQuestion(revAddr, dns.TypePTR) 43 | msg.SetEdns0(4096, false) 44 | msg.AuthenticatedData = true 45 | 46 | resp, err := e.exchange(ctx, msg) 47 | if err != nil { 48 | return false, nil, err 49 | } 50 | 51 | ad = resp.AuthenticatedData 52 | names = make([]string, 0, len(resp.Answer)) 53 | for _, rr := range resp.Answer { 54 | ptrRR, ok := rr.(*dns.PTR) 55 | if !ok { 56 | continue 57 | } 58 | 59 | names = append(names, ptrRR.Ptr) 60 | } 61 | return 62 | } 63 | 64 | func (e ExtResolver) AuthLookupHost(ctx context.Context, host string) (ad bool, addrs []string, err error) { 65 | ad, addrParsed, err := e.AuthLookupIPAddr(ctx, host) 66 | if err != nil { 67 | return false, nil, err 68 | } 69 | 70 | addrs = make([]string, 0, len(addrParsed)) 71 | for _, addr := range addrParsed { 72 | addrs = append(addrs, addr.String()) 73 | } 74 | return ad, addrs, nil 75 | } 76 | 77 | func (e ExtResolver) AuthLookupMX(ctx context.Context, name string) (ad bool, mxs []*net.MX, err error) { 78 | msg := new(dns.Msg) 79 | msg.SetQuestion(dns.Fqdn(name), dns.TypeMX) 80 | msg.SetEdns0(4096, false) 81 | msg.AuthenticatedData = true 82 | 83 | resp, err := e.exchange(ctx, msg) 84 | if err != nil { 85 | return false, nil, err 86 | } 87 | 88 | ad = resp.AuthenticatedData 89 | mxs = make([]*net.MX, 0, len(resp.Answer)) 90 | for _, rr := range resp.Answer { 91 | mxRR, ok := rr.(*dns.MX) 92 | if !ok { 93 | continue 94 | } 95 | 96 | mxs = append(mxs, &net.MX{ 97 | Host: mxRR.Mx, 98 | Pref: mxRR.Preference, 99 | }) 100 | } 101 | return 102 | } 103 | 104 | func (e ExtResolver) AuthLookupTLSA(ctx context.Context, name string) (ad bool, recs []dns.TLSA, err error) { 105 | msg := new(dns.Msg) 106 | msg.SetQuestion(dns.Fqdn(name), dns.TypeTLSA) 107 | msg.SetEdns0(4096, false) 108 | msg.AuthenticatedData = true 109 | 110 | resp, err := e.exchange(ctx, msg) 111 | if err != nil { 112 | return false, nil, err 113 | } 114 | 115 | ad = resp.AuthenticatedData 116 | recs = make([]dns.TLSA, 0, len(resp.Answer)) 117 | for _, rr := range resp.Answer { 118 | rr, ok := rr.(*dns.TLSA) 119 | if !ok { 120 | continue 121 | } 122 | 123 | recs = append(recs, *rr) 124 | } 125 | return 126 | } 127 | 128 | func (e ExtResolver) AuthLookupTXT(ctx context.Context, name string) (ad bool, recs []string, err error) { 129 | msg := new(dns.Msg) 130 | msg.SetQuestion(dns.Fqdn(name), dns.TypeTXT) 131 | msg.SetEdns0(4096, false) 132 | msg.AuthenticatedData = true 133 | 134 | resp, err := e.exchange(ctx, msg) 135 | if err != nil { 136 | return false, nil, err 137 | } 138 | 139 | if resp.MsgHdr.Rcode != dns.RcodeSuccess { 140 | if resp.MsgHdr.Rcode == dns.RcodeNameError { 141 | return false, nil, ErrNxDomain 142 | } else { 143 | return false, nil, errors.New(dns.RcodeToString[resp.MsgHdr.Rcode]) 144 | } 145 | } 146 | 147 | ad = resp.AuthenticatedData 148 | recs = make([]string, 0, len(resp.Answer)) 149 | for _, rr := range resp.Answer { 150 | txtRR, ok := rr.(*dns.TXT) 151 | if !ok { 152 | continue 153 | } 154 | 155 | recs = append(recs, strings.Join(txtRR.Txt, "")) 156 | } 157 | return 158 | } 159 | 160 | func (e ExtResolver) AuthLookupIPAddr(ctx context.Context, host string) (ad bool, addrs []net.IPAddr, err error) { 161 | // First, query IPv6. 162 | msg := new(dns.Msg) 163 | msg.SetQuestion(dns.Fqdn(host), dns.TypeAAAA) 164 | msg.SetEdns0(4096, false) 165 | msg.AuthenticatedData = true 166 | 167 | resp, err := e.exchange(ctx, msg) 168 | if err != nil { 169 | return false, nil, err 170 | } 171 | 172 | ad = resp.AuthenticatedData 173 | addrs = make([]net.IPAddr, 0, len(resp.Answer)) 174 | for _, rr := range resp.Answer { 175 | aaaaRR, ok := rr.(*dns.AAAA) 176 | if !ok { 177 | continue 178 | } 179 | addrs = append(addrs, net.IPAddr{IP: aaaaRR.AAAA}) 180 | } 181 | 182 | // Then repeat query with IPv4. 183 | msg = new(dns.Msg) 184 | msg.SetQuestion(dns.Fqdn(host), dns.TypeA) 185 | msg.SetEdns0(4096, false) 186 | msg.AuthenticatedData = true 187 | 188 | resp, err = e.exchange(ctx, msg) 189 | if err != nil { 190 | return false, nil, err 191 | } 192 | 193 | // Both queries should be authenticated. 194 | ad = ad && resp.AuthenticatedData 195 | 196 | for _, rr := range resp.Answer { 197 | aRR, ok := rr.(*dns.A) 198 | if !ok { 199 | continue 200 | } 201 | addrs = append(addrs, net.IPAddr{IP: aRR.A}) 202 | } 203 | 204 | return ad, addrs, err 205 | } 206 | 207 | func NewExtResolver() (*ExtResolver, error) { 208 | cfg, err := dns.ClientConfigFromFile("/etc/resolv.conf") 209 | if err != nil { 210 | return nil, err 211 | } 212 | cl := new(dns.Client) 213 | // Set the overall DNS timeout(read, write, connect)to 15secs, this is 214 | // high, but we want answers, not speed. 215 | cl.Timeout = 15 * time.Second 216 | return &ExtResolver{ 217 | cl: cl, 218 | cfg: cfg, 219 | }, nil 220 | } 221 | -------------------------------------------------------------------------------- /fcrdns.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | ) 8 | 9 | func evaluateFCRDNS(domain string, res *Results) error { 10 | _, mxs, err := extR.AuthLookupMX(context.Background(), domain) 11 | if err != nil { 12 | res.fcrdns = LevelMissing 13 | res.fcrdnsDesc = fmt.Sprintf("lookup mx %v: %v", domain, err) 14 | } 15 | 16 | allUnmatched := true 17 | allMatched := true 18 | 19 | levelDown := func(to Level) { 20 | if res.fcrdns > to { 21 | res.fcrdns = to 22 | } 23 | } 24 | 25 | res.fcrdns = LevelSecure 26 | 27 | for _, mx := range mxs { 28 | _, addrs, err := extR.AuthLookupHost(context.Background(), mx.Host) 29 | if err != nil { 30 | allMatched = false 31 | levelDown(LevelMissing) 32 | res.fcrdnsDesc += fmt.Sprintf("lookup error %v: %v; ", mx.Host, err) 33 | } 34 | 35 | for _, addr := range addrs { 36 | _, names, err := extR.AuthLookupAddr(context.Background(), addr) 37 | if err != nil { 38 | allMatched = false 39 | levelDown(LevelMissing) 40 | res.fcrdnsDesc += fmt.Sprintf("lookup error %v: %v; ", addr, err) 41 | } 42 | 43 | if len(names) == 0 { 44 | allMatched = false 45 | levelDown(LevelMissing) 46 | res.fcrdnsDesc += fmt.Sprintf("no rDNS for %s; ", addr) 47 | continue 48 | } 49 | 50 | match := false 51 | for _, name := range names { 52 | if strings.EqualFold(strings.TrimSuffix(name, "."), strings.TrimSuffix(mx.Host, ".")) { 53 | match = true 54 | } 55 | } 56 | 57 | if !match { 58 | allMatched = false 59 | levelDown(LevelInsecure) 60 | res.fcrdnsDesc += fmt.Sprintf("%s [%s] != %s; ", names[0], addr, mx.Host) 61 | } else { 62 | allUnmatched = false 63 | } 64 | } 65 | } 66 | 67 | if allUnmatched { 68 | res.fcrdns = LevelMissing 69 | res.fcrdnsDesc = "no MXs with forward-confirmed rDNS" 70 | } else if allMatched { 71 | res.fcrdns = LevelSecure 72 | res.fcrdnsDesc = "all MXs have forward-confirmed rDNS" 73 | } 74 | return nil 75 | } 76 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/foxcpp/mailsec-check 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/emersion/go-msgauth v0.3.2-0.20191028231513-55b75676976c 7 | github.com/emersion/go-smtp v0.11.2 8 | github.com/miekg/dns v1.1.25 9 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db 10 | ) 11 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/emersion/go-milter v0.0.0-20190311184326-c3095a41a6fe/go.mod h1:aEaq7U51ARlk+2UeXTtdrDYeYWAUn/QjEwWzs7lD8OU= 2 | github.com/emersion/go-msgauth v0.3.2-0.20191028231513-55b75676976c h1:0as6ct8PVWrlCofyWPh8o/fNRr6/DPbjkBicSG+3ZQI= 3 | github.com/emersion/go-msgauth v0.3.2-0.20191028231513-55b75676976c/go.mod h1:7r9HUSXL1dq+KK7Xqg0JlyBxNFGf5+JouRvSz4wBZCQ= 4 | github.com/emersion/go-sasl v0.0.0-20190704090222-36b50694675c h1:Spm8jy+jWYG/Dn6ygbq/LBW/6M27kg59GK+FkKjexuw= 5 | github.com/emersion/go-sasl v0.0.0-20190704090222-36b50694675c/go.mod h1:G/dpzLu16WtQpBfQ/z3LYiYJn3ZhKSGWn83fyoyQe/k= 6 | github.com/emersion/go-smtp v0.11.2 h1:5PO2Kwsx+HXuytntCfMvcworC/iq45TPGkwjnaBZFSg= 7 | github.com/emersion/go-smtp v0.11.2/go.mod h1:byi9Y32SuKwjTJt9DO2tTWYjtF3lEh154tE1AcaJQSY= 8 | github.com/miekg/dns v1.1.25 h1:dFwPR6SfLtrSwgDcIq2bcU/gVutB4sNApq2HBdqcakg= 9 | github.com/miekg/dns v1.1.25/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= 10 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= 11 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= 12 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 13 | golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= 14 | golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392 h1:ACG4HJsFiNMf47Y4PeRoebLNy/2lXT9EtprMuTFWt1M= 15 | golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= 16 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 17 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 18 | golang.org/x/net v0.0.0-20190923162816-aa69164e4478 h1:l5EDrHhldLYb3ZRHDUhXF7Om7MvYXnkV9/iQNo1lX6g= 19 | golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 20 | golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= 21 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 22 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 23 | golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 24 | golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 25 | golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe h1:6fAMxZRR6sl1Uq8U61gxU+kPTs2tR8uOySCbBP7BN/M= 26 | golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 27 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 28 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 29 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 30 | golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 31 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 32 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "os" 9 | "strings" 10 | 11 | "github.com/foxcpp/mailsec-check/dns" 12 | "github.com/mitchellh/colorstring" 13 | ) 14 | 15 | var ( 16 | active = flag.Bool("active", false, "Do some tests that require making connections to the SMTP servers") 17 | protocol = flag.Bool("protocol", false, "Display protocol records") 18 | ) 19 | 20 | func printStatus(level Level, name, desc, record string) { 21 | var color, mark string 22 | switch level { 23 | case LevelUnknown: 24 | color = "[dark_gray]" 25 | mark = " " 26 | desc = "not evaluated;" 27 | case LevelSecure: 28 | color = "[green]" 29 | mark = "+" 30 | case LevelInsecure: 31 | color = "[yellow]" 32 | mark = " " 33 | case LevelMissing: 34 | color = "[red]" 35 | mark = " " 36 | case LevelInvalid: 37 | color = "[red]" 38 | mark = "!" 39 | } 40 | 41 | colorstring.Println(fmt.Sprintf("[%s%s[reset]] %s[bold]%s:[reset] \t %s", color, mark, color, name, desc)) 42 | if *protocol && record != "" { 43 | colorstring.Println(fmt.Sprintf(" %s%s[reset]", "[blue]", "Record:")) 44 | scanner := bufio.NewScanner(strings.NewReader(record)) 45 | for scanner.Scan() { 46 | fmt.Printf("\t%s\n", scanner.Text()) 47 | } 48 | if err := scanner.Err(); err != nil { 49 | fmt.Fprintln(os.Stderr, "Error reading record string: ", err) 50 | } 51 | } 52 | } 53 | 54 | func main() { 55 | log.SetFlags(0) 56 | log.SetOutput(os.Stderr) 57 | 58 | flag.Parse() 59 | if len(flag.Args()) != 1 { 60 | log.Println("Usage:", os.Args[0], "") 61 | os.Exit(2) 62 | } 63 | domain := flag.Args()[0] 64 | 65 | var err error 66 | extR, err = dns.NewExtResolver() 67 | if err != nil { 68 | log.Println(err) 69 | os.Exit(1) 70 | } 71 | 72 | res, err := evaluateAll(domain) 73 | if err != nil { 74 | log.Println(err) 75 | os.Exit(1) 76 | } 77 | 78 | colorstring.Println("[bold]-- Source forgery protection[reset]") 79 | printStatus(res.dkim, "DKIM", res.dkimDesc, "") 80 | printStatus(res.spf, "SPF", res.spfDesc, res.spfRec) 81 | printStatus(res.dmarc, "DMARC", res.dmarcDesc, res.dmarcRec) 82 | fmt.Println() 83 | 84 | colorstring.Println("[bold]-- TLS enforcement[reset]") 85 | printStatus(res.mtasts, "MTA-STS", res.mtastsDesc, res.mtastsRec) 86 | printStatus(res.dane, "DANE", res.daneDesc, res.daneRec) 87 | fmt.Println() 88 | 89 | colorstring.Println("[bold]-- DNS consistency[reset]") 90 | printStatus(res.fcrdns, "FCrDNS", res.fcrdnsDesc, "") 91 | printStatus(res.dnssecMX, "DNSSEC", res.dnssecMXDesc, "") 92 | } 93 | -------------------------------------------------------------------------------- /mtasts.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "strings" 7 | 8 | "github.com/foxcpp/mailsec-check/dns" 9 | "github.com/foxcpp/mailsec-check/mtasts" 10 | ) 11 | 12 | func evaluateMTASTS(domain string, res *Results) error { 13 | res.mtasts = LevelSecure 14 | 15 | _, txts, err := extR.AuthLookupTXT(context.Background(), "_mta-sts."+domain) 16 | if err == dns.ErrNxDomain { 17 | res.mtasts = LevelMissing 18 | res.mtastsDesc = "no _mta-sts subdomain;" 19 | return nil 20 | } else if err != nil { 21 | res.mtasts = LevelInvalid 22 | res.mtastsDesc = "domain query error: " + err.Error() + ";" 23 | return err 24 | } 25 | txt := strings.Join(txts, "") 26 | 27 | if strings.TrimSpace(txt) == "" { 28 | res.mtasts = LevelMissing 29 | res.mtastsDesc = "no policy;" 30 | return nil 31 | } 32 | 33 | levelDown := func(to Level) { 34 | if res.mtasts > to { 35 | res.mtasts = to 36 | } 37 | } 38 | 39 | _, err = mtasts.ReadDNSRecord(txt) 40 | if err != nil { 41 | res.mtasts = LevelInvalid 42 | res.mtastsDesc = "malformed record: " + err.Error() + ";" 43 | return nil 44 | } 45 | 46 | policy, rawContents, err := mtasts.DownloadPolicy(domain) 47 | res.mtastsRec = rawContents 48 | if err != nil { 49 | res.mtasts = LevelInvalid 50 | res.mtastsDesc = "policy fetch error: " + err.Error() + ";" 51 | return nil 52 | } 53 | 54 | _, mxs, err := extR.AuthLookupMX(context.Background(), domain) 55 | if err != nil { 56 | return err 57 | } 58 | if len(mxs) == 0 { 59 | return errors.New("domain does not have any MX records") 60 | } 61 | 62 | allMatched := true 63 | allUnmatched := false 64 | 65 | for _, mx := range mxs { 66 | if policy.Match(mx.Host) { 67 | allUnmatched = false 68 | } else { 69 | levelDown(LevelInvalid) 70 | res.mtastsDesc += mx.Host + " does not match the policy" 71 | allMatched = false 72 | } 73 | } 74 | 75 | if policy.Mode != mtasts.ModeEnforce { 76 | levelDown(LevelInsecure) 77 | res.mtastsDesc += "not enforced; " 78 | } else { 79 | levelDown(LevelSecure) 80 | res.mtastsDesc += "enforced; " 81 | } 82 | 83 | if allMatched { 84 | levelDown(LevelSecure) 85 | res.mtastsDesc += "all MXs match policy; " 86 | } else if allUnmatched { 87 | levelDown(LevelInvalid) 88 | res.mtastsDesc += "no MXs match policy; " 89 | } 90 | return nil 91 | } 92 | -------------------------------------------------------------------------------- /mtasts/download.go: -------------------------------------------------------------------------------- 1 | package mtasts 2 | 3 | import ( 4 | "errors" 5 | "mime" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | var httpClient = &http.Client{ 11 | CheckRedirect: func(req *http.Request, via []*http.Request) error { 12 | return errors.New("mtasts: HTTP redirects are forbidden") 13 | }, 14 | Timeout: time.Minute, 15 | } 16 | 17 | func DownloadPolicy(domain string) (*Policy, string, error) { 18 | resp, err := httpClient.Get("https://mta-sts." + domain + "/.well-known/mta-sts.txt") 19 | if err != nil { 20 | return nil, "", err 21 | } 22 | defer resp.Body.Close() 23 | 24 | // Policies fetched via HTTPS are only valid if the HTTP response code is 25 | // 200 (OK). HTTP 3xx redirects MUST NOT be followed. 26 | if resp.StatusCode != 200 { 27 | return nil, "", errors.New("mtasts: HTTP " + resp.Status) 28 | } 29 | 30 | contentType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) 31 | if err != nil { 32 | return nil, "", err 33 | } 34 | 35 | if contentType != "text/plain" { 36 | return nil, "", errors.New("mtasts: unexpected content type") 37 | } 38 | 39 | return readPolicy(resp.Body) 40 | } 41 | -------------------------------------------------------------------------------- /mtasts/mtasts.go: -------------------------------------------------------------------------------- 1 | // The mtasts policy implements parsing, caching and checking of 2 | // MTA-STS (RFC 8461) policies. 3 | package mtasts 4 | 5 | import ( 6 | "bufio" 7 | "bytes" 8 | "fmt" 9 | "io" 10 | "strconv" 11 | "strings" 12 | ) 13 | 14 | type MalformedDNSRecordError struct { 15 | // Additional description of the error. 16 | Desc string 17 | } 18 | 19 | func (e MalformedDNSRecordError) Error() string { 20 | return fmt.Sprintf("mtasts: malformed DNS record: %s", e.Desc) 21 | } 22 | 23 | func ReadDNSRecord(raw string) (id string, err error) { 24 | parts := strings.Split(raw, ";") 25 | versionPresent := false 26 | for _, part := range parts { 27 | part = strings.TrimSpace(part) 28 | // handle k=v;k=v; 29 | // ^ 30 | if part == "" { 31 | continue 32 | } 33 | kv := strings.Split(part, "=") 34 | if len(kv) != 2 { 35 | return "", MalformedDNSRecordError{Desc: "invalid record part: " + part} 36 | } 37 | 38 | if strings.ContainsAny(kv[0], " \t") || strings.ContainsAny(kv[1], " \t") { 39 | return "", MalformedDNSRecordError{Desc: "whitespace is not allowed in name or value"} 40 | } 41 | 42 | switch kv[0] { 43 | case "v": 44 | if kv[1] != "STSv1" { 45 | return "", MalformedDNSRecordError{Desc: "unsupported version: " + kv[1]} 46 | } 47 | versionPresent = true 48 | case "id": 49 | id = kv[1] 50 | } 51 | } 52 | if !versionPresent { 53 | return "", MalformedDNSRecordError{Desc: "missing version value"} 54 | } 55 | if id == "" { 56 | return "", MalformedDNSRecordError{Desc: "missing id value"} 57 | } 58 | return 59 | } 60 | 61 | type MalformedPolicyError struct { 62 | // Additional description of the error. 63 | Desc string 64 | } 65 | 66 | func (e MalformedPolicyError) Error() string { 67 | return fmt.Sprintf("mtasts: malformed policy: %s", e.Desc) 68 | } 69 | 70 | type Mode string 71 | 72 | const ( 73 | ModeEnforce Mode = "enforce" 74 | ModeTesting Mode = "testing" 75 | ModeNone Mode = "none" 76 | ) 77 | 78 | type Policy struct { 79 | Mode Mode 80 | MaxAge int 81 | MX []string 82 | } 83 | 84 | func readPolicy(contents io.Reader) (*Policy, string, error) { 85 | contentsBytes, err := io.ReadAll(contents) 86 | if err != nil { 87 | return nil, "", err 88 | } 89 | rawContents := string(contentsBytes) 90 | contentsReader := bytes.NewReader(contentsBytes) 91 | scnr := bufio.NewScanner(contentsReader) 92 | policy := Policy{} 93 | 94 | present := make(map[string]struct{}) 95 | 96 | for scnr.Scan() { 97 | fieldParts := strings.Split(scnr.Text(), ":") 98 | if len(fieldParts) != 2 { 99 | return nil, rawContents, MalformedPolicyError{Desc: "invalid field: " + scnr.Text()} 100 | } 101 | 102 | // Arbitrary whitespace after colon: 103 | // sts-policy-field-delim = ":" *WSP 104 | fieldName := fieldParts[0] 105 | fieldValue := strings.TrimSpace(fieldParts[1]) 106 | switch fieldName { 107 | case "version": 108 | if fieldValue != "STSv1" { 109 | return nil, rawContents, MalformedPolicyError{Desc: "unsupported policy version: " + fieldValue} 110 | } 111 | case "mode": 112 | switch Mode(fieldValue) { 113 | case ModeEnforce, ModeTesting, ModeNone: 114 | policy.Mode = Mode(fieldValue) 115 | default: 116 | return nil, rawContents, MalformedPolicyError{Desc: "invalid mode value: " + fieldValue} 117 | } 118 | case "max_age": 119 | var err error 120 | policy.MaxAge, err = strconv.Atoi(fieldValue) 121 | if err != nil { 122 | return nil, rawContents, MalformedPolicyError{Desc: "invalid max_age value: " + err.Error()} 123 | } 124 | case "mx": 125 | policy.MX = append(policy.MX, fieldValue) 126 | } 127 | present[fieldName] = struct{}{} 128 | } 129 | if err := scnr.Err(); err != nil { 130 | return nil, rawContents, err 131 | } 132 | 133 | if _, ok := present["version"]; !ok { 134 | return nil, rawContents, MalformedPolicyError{Desc: "version field required"} 135 | } 136 | if _, ok := present["mode"]; !ok { 137 | return nil, rawContents, MalformedPolicyError{Desc: "mode field required"} 138 | } 139 | if _, ok := present["max_age"]; !ok { 140 | return nil, rawContents, MalformedPolicyError{Desc: "max_age field required"} 141 | } 142 | 143 | if policy.Mode != ModeNone && len(policy.MX) == 0 { 144 | return nil, rawContents, MalformedPolicyError{Desc: "at least one mx field required when mode is not none"} 145 | } 146 | 147 | return &policy, rawContents, nil 148 | } 149 | 150 | func (p Policy) Match(mx string) bool { 151 | mx = strings.TrimSuffix(mx, ".") 152 | 153 | for _, mxRecord := range p.MX { 154 | if strings.HasPrefix(mxRecord, "*.") { 155 | if mx[strings.Index(mx, "."):] == mxRecord[1:] { 156 | return true 157 | } 158 | continue 159 | } 160 | 161 | if mxRecord == mx { 162 | return true 163 | } 164 | } 165 | return false 166 | } 167 | -------------------------------------------------------------------------------- /mtasts/mtasts_test.go: -------------------------------------------------------------------------------- 1 | package mtasts 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | func TestReadDNSRecord(t *testing.T) { 11 | cases := []struct { 12 | value string 13 | id string 14 | fail bool 15 | }{ 16 | { 17 | value: "", 18 | fail: true, 19 | }, 20 | { 21 | value: "v=STSv1", 22 | fail: true, 23 | }, 24 | { 25 | value: "id=foo", 26 | fail: true, 27 | }, 28 | { 29 | value: "unrelated=foo", 30 | fail: true, 31 | }, 32 | { 33 | value: "syntax error", 34 | fail: true, 35 | }, 36 | { 37 | value: "v=STSv2;id=foo;include=foo.com", 38 | fail: true, 39 | }, 40 | { 41 | value: "v=STSv1; id=foo include=foo.com", 42 | fail: true, 43 | }, 44 | { 45 | value: "v=STSv1; id=foo include", 46 | fail: true, 47 | }, 48 | { 49 | value: "v=STSv1 ; id=foo", 50 | id: "foo", 51 | }, 52 | { 53 | value: "v=STSv1 ; id=foo; unrelated=1", 54 | id: "foo", 55 | }, 56 | } 57 | 58 | for _, c := range cases { 59 | t.Run(c.value, func(t *testing.T) { 60 | id, err := readDNSRecord(c.value) 61 | if c.fail { 62 | if err == nil { 63 | t.Errorf("expected failure for %v, but got with id=%v", c.value, id) 64 | } 65 | } else { 66 | if err != nil { 67 | t.Errorf("unexpected failure for %v: %v", c.value, err) 68 | return 69 | } 70 | 71 | if id != c.id { 72 | t.Errorf("expected id %v, got %v", c.id, id) 73 | } 74 | } 75 | }) 76 | } 77 | } 78 | 79 | func TestReadPolicy(t *testing.T) { 80 | cases := []struct { 81 | value string 82 | policy *Policy 83 | fail bool 84 | }{ 85 | { 86 | value: `version: STSv2`, 87 | fail: true, 88 | }, 89 | { 90 | value: `version: STSv1`, 91 | fail: true, 92 | }, 93 | { 94 | value: `max_age: 8600`, 95 | fail: true, 96 | }, 97 | { 98 | value: `version: STSv1 99 | max_age: 8600`, 100 | fail: true, 101 | }, 102 | { 103 | value: `version: STSv1 104 | max_age:`, 105 | fail: true, 106 | }, 107 | { 108 | value: `version: STSv1 109 | : 8600`, 110 | fail: true, 111 | }, 112 | { 113 | value: `version: STSv1 114 | mode: invalid_value`, 115 | fail: true, 116 | }, 117 | { 118 | value: `version: STSv1 119 | mode none`, 120 | fail: true, 121 | }, 122 | { 123 | value: `version: STSv1 124 | mode: none`, 125 | fail: true, 126 | }, 127 | { 128 | value: `version: STSv1 129 | max_age: 8600 130 | mode:none`, 131 | policy: &Policy{ 132 | Mode: ModeNone, 133 | MaxAge: 8600, 134 | }, 135 | }, 136 | { 137 | value: `version: STSv1 138 | max_age: 8600 139 | mode: enforce`, 140 | fail: true, 141 | }, 142 | { 143 | value: `version: STSv1 144 | max_age: 8600 145 | mode: enforce 146 | mx: mx0.example.org 147 | mx: *.example.org`, 148 | policy: &Policy{ 149 | Mode: ModeEnforce, 150 | MaxAge: 8600, 151 | MX: []string{"mx0.example.org", "*.example.org"}, 152 | }, 153 | }, 154 | } 155 | 156 | for _, c := range cases { 157 | t.Run(c.value, func(t *testing.T) { 158 | p, err := readPolicy(strings.NewReader(c.value)) 159 | if c.fail { 160 | if err == nil { 161 | t.Errorf("expected failure, but got %+v", p) 162 | } 163 | } else { 164 | if err != nil { 165 | t.Errorf("unexpected failure: %v", err) 166 | return 167 | } 168 | 169 | if !reflect.DeepEqual(c.policy, p) { 170 | t.Log("unexpected read result") 171 | t.Log("policy:") 172 | t.Log(c.value) 173 | t.Logf("expected result: %+v", c.policy) 174 | t.Logf("actual result: %+v", p) 175 | t.Fail() 176 | } 177 | } 178 | }) 179 | } 180 | } 181 | 182 | func TestPolicyMatch(t *testing.T) { 183 | cases := []struct { 184 | mx string 185 | validMxs []string 186 | shouldMatch bool 187 | }{ 188 | { 189 | mx: "example.org.", 190 | validMxs: []string{"example.org"}, 191 | shouldMatch: true, 192 | }, 193 | { 194 | mx: "example.org", 195 | validMxs: []string{"example.org"}, 196 | shouldMatch: true, 197 | }, 198 | { 199 | mx: "mx0.example.org", 200 | validMxs: []string{"special.example.org", "*.example.org"}, 201 | shouldMatch: true, 202 | }, 203 | { 204 | mx: "special.example.org", 205 | validMxs: []string{"special.example.org", "*.example.org"}, 206 | shouldMatch: true, 207 | }, 208 | { 209 | mx: "mx0.special.example.org", 210 | validMxs: []string{"special.example.org", "*.example.org"}, 211 | shouldMatch: false, 212 | }, 213 | { 214 | mx: "mx0.special.example.org", 215 | validMxs: []string{"*.special.example.org", "*.example.org"}, 216 | shouldMatch: true, 217 | }, 218 | { 219 | mx: "unrelated.org", 220 | validMxs: []string{"*.example.org"}, 221 | shouldMatch: false, 222 | }, 223 | } 224 | 225 | for _, c := range cases { 226 | t.Run(fmt.Sprintln(c.mx, c.validMxs), func(t *testing.T) { 227 | p := Policy{MX: c.validMxs} 228 | 229 | matched := p.Match(c.mx) 230 | if c.shouldMatch && !matched { 231 | t.Error(c.mx, "didn't matched", c.validMxs, "but it should") 232 | } 233 | if !c.shouldMatch && matched { 234 | t.Error(c.mx, "matched", c.validMxs, "but it shouldn't") 235 | } 236 | }) 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /spf.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | 7 | "github.com/foxcpp/mailsec-check/dns" 8 | ) 9 | 10 | func evaluateSPF(domain string, res *Results) error { 11 | res.spf = LevelSecure 12 | 13 | ad, txts, err := extR.AuthLookupTXT(context.Background(), domain) 14 | if err == dns.ErrNxDomain { 15 | res.spf = LevelMissing 16 | res.spfDesc = "no domain;" 17 | return nil 18 | } else if err != nil { 19 | res.spf = LevelInvalid 20 | res.spfDesc = "domain query error: " + err.Error() + ";" 21 | return err 22 | } 23 | 24 | spfRecPresent := false 25 | for _, txt := range txts { 26 | if strings.HasPrefix(txt, "v=spf1") { 27 | spfRecPresent = true 28 | res.spfDesc += "present; " 29 | if err := evalSPFRecord(txt, res); err != nil { 30 | return err 31 | } 32 | } 33 | } 34 | 35 | if !spfRecPresent { 36 | res.spf = LevelMissing 37 | res.spfDesc += "no policy;" 38 | return nil 39 | } 40 | 41 | if res.spfDesc == "present; " { 42 | res.spfDesc += "strict; " 43 | } 44 | 45 | if !ad { 46 | res.spf = LevelInsecure 47 | res.spfDesc += "no DNSSEC; " 48 | } else { 49 | res.spf = LevelSecure 50 | res.spfDesc += "DNSSEC-signed; " 51 | } 52 | 53 | return nil 54 | } 55 | 56 | func evalSPFRecord(txt string, res *Results) error { 57 | res.spfRec = txt 58 | parts := strings.Split(txt, " ") 59 | 60 | if len(parts) == 0 { 61 | res.spf = LevelMissing 62 | res.spfDesc += "missing policy;" 63 | } 64 | 65 | for _, part := range parts { 66 | if strings.HasPrefix(part, "redirect=") { 67 | _, txts, err := extR.AuthLookupTXT(context.Background(), strings.TrimPrefix(part, "redirect=")) 68 | if err != nil { 69 | return err 70 | } 71 | newTxt := strings.Join(txts, "") 72 | return evalSPFRecord(newTxt, res) 73 | } 74 | 75 | switch part { 76 | case "+all", "all": 77 | res.spf = LevelInsecure 78 | res.spfDesc += "policy allows any host; " 79 | case "?all": 80 | res.spf = LevelInsecure 81 | res.spfDesc += "policy defines neutral result as default; " 82 | } 83 | } 84 | 85 | return nil 86 | } 87 | --------------------------------------------------------------------------------