275 lines
8.3 KiB
Go
275 lines
8.3 KiB
Go
// Package dmarc parses DMARC aggregate (rua) reports (RFC 7489 appendix A).
|
|
// Receivers send them as XML, usually gzip or zip compressed, usually as an
|
|
// email attachment; Parse accepts any of those forms.
|
|
package dmarc
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"encoding/base64"
|
|
"encoding/xml"
|
|
"errors"
|
|
"io"
|
|
"mime"
|
|
"mime/multipart"
|
|
"mime/quotedprintable"
|
|
"net/mail"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// maxXML caps a decompressed report; aggregate reports are small, so a larger one is a zip bomb.
|
|
const maxXML = 32 << 20
|
|
|
|
// Report is one receiver's summary of the mail it saw claiming to be from Domain.
|
|
type Report struct {
|
|
Org string `json:"org"` // the receiver that sent the report
|
|
Email string `json:"email"`
|
|
ReportID string `json:"reportId"`
|
|
Begin time.Time `json:"begin"`
|
|
End time.Time `json:"end"`
|
|
Domain string `json:"domain"`
|
|
Policy Policy `json:"policy"`
|
|
Records []Record `json:"records"`
|
|
}
|
|
|
|
// Policy is the DMARC policy the receiver applied.
|
|
type Policy struct {
|
|
P string `json:"p"` // none, quarantine or reject
|
|
SP string `json:"sp"` // for subdomains
|
|
Pct int `json:"pct"` // percent of mail the policy applies to
|
|
ADKIM string `json:"adkim"`
|
|
ASPF string `json:"aspf"`
|
|
}
|
|
|
|
// Record is one row: a sending IP and what happened to its mail.
|
|
type Record struct {
|
|
SourceIP string `json:"sourceIp"`
|
|
Count int `json:"count"`
|
|
Disposition string `json:"disposition"` // what the receiver did: none, quarantine, reject
|
|
DKIM string `json:"dkim"` // aligned DKIM result: pass or fail
|
|
SPF string `json:"spf"` // aligned SPF result: pass or fail
|
|
HeaderFrom string `json:"headerFrom"`
|
|
EnvelopeFrom string `json:"envelopeFrom,omitempty"`
|
|
DKIMAuth []AuthResult `json:"dkimAuth,omitempty"`
|
|
SPFAuth []AuthResult `json:"spfAuth,omitempty"`
|
|
}
|
|
|
|
// AuthResult is a raw DKIM or SPF verdict, before alignment with the From domain.
|
|
type AuthResult struct {
|
|
Domain string `json:"domain"`
|
|
Selector string `json:"selector,omitempty"`
|
|
Result string `json:"result"`
|
|
}
|
|
|
|
// Passed reports whether the row's mail passed DMARC: either aligned check passing is enough.
|
|
func (r Record) Passed() bool { return r.DKIM == "pass" || r.SPF == "pass" }
|
|
|
|
type xmlFeedback struct {
|
|
Meta struct {
|
|
Org string `xml:"org_name"`
|
|
Email string `xml:"email"`
|
|
ReportID string `xml:"report_id"`
|
|
Range struct {
|
|
Begin int64 `xml:"begin"`
|
|
End int64 `xml:"end"`
|
|
} `xml:"date_range"`
|
|
} `xml:"report_metadata"`
|
|
Policy struct {
|
|
Domain string `xml:"domain"`
|
|
ADKIM string `xml:"adkim"`
|
|
ASPF string `xml:"aspf"`
|
|
P string `xml:"p"`
|
|
SP string `xml:"sp"`
|
|
Pct string `xml:"pct"`
|
|
} `xml:"policy_published"`
|
|
Records []struct {
|
|
Row struct {
|
|
SourceIP string `xml:"source_ip"`
|
|
Count int `xml:"count"`
|
|
Eval struct {
|
|
Disposition string `xml:"disposition"`
|
|
DKIM string `xml:"dkim"`
|
|
SPF string `xml:"spf"`
|
|
} `xml:"policy_evaluated"`
|
|
} `xml:"row"`
|
|
Identifiers struct {
|
|
HeaderFrom string `xml:"header_from"`
|
|
EnvelopeFrom string `xml:"envelope_from"`
|
|
} `xml:"identifiers"`
|
|
Auth struct {
|
|
DKIM []struct {
|
|
Domain string `xml:"domain"`
|
|
Selector string `xml:"selector"`
|
|
Result string `xml:"result"`
|
|
} `xml:"dkim"`
|
|
SPF []struct {
|
|
Domain string `xml:"domain"`
|
|
Result string `xml:"result"`
|
|
} `xml:"spf"`
|
|
} `xml:"auth_results"`
|
|
} `xml:"record"`
|
|
}
|
|
|
|
// Parse reads a report from raw XML, gzip, zip, or a whole email carrying one of those.
|
|
func Parse(data []byte) (*Report, error) {
|
|
xmlData, err := unwrap(data, 0)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var f xmlFeedback
|
|
if err := xml.Unmarshal(xmlData, &f); err != nil {
|
|
return nil, errors.New("not a DMARC report: " + err.Error())
|
|
}
|
|
if f.Policy.Domain == "" {
|
|
return nil, errors.New("not a DMARC report: no published policy domain")
|
|
}
|
|
if strings.TrimSpace(f.Meta.ReportID) == "" {
|
|
return nil, errors.New("not a DMARC report: no report_id")
|
|
}
|
|
pct := 100
|
|
if n, err := strconv.Atoi(strings.TrimSpace(f.Policy.Pct)); err == nil && n >= 0 && n <= 100 {
|
|
pct = n
|
|
}
|
|
r := &Report{
|
|
Org: strings.TrimSpace(f.Meta.Org), Email: strings.TrimSpace(f.Meta.Email), ReportID: strings.TrimSpace(f.Meta.ReportID),
|
|
Begin: time.Unix(f.Meta.Range.Begin, 0).UTC(),
|
|
End: time.Unix(f.Meta.Range.End, 0).UTC(),
|
|
Domain: lower(f.Policy.Domain),
|
|
Policy: Policy{P: lower(f.Policy.P), SP: lower(f.Policy.SP), Pct: pct, ADKIM: lower(f.Policy.ADKIM), ASPF: lower(f.Policy.ASPF)},
|
|
}
|
|
for _, x := range f.Records {
|
|
rec := Record{
|
|
SourceIP: strings.TrimSpace(x.Row.SourceIP), Count: x.Row.Count,
|
|
Disposition: lower(x.Row.Eval.Disposition), DKIM: lower(x.Row.Eval.DKIM), SPF: lower(x.Row.Eval.SPF),
|
|
HeaderFrom: lower(x.Identifiers.HeaderFrom), EnvelopeFrom: lower(x.Identifiers.EnvelopeFrom),
|
|
}
|
|
for _, d := range x.Auth.DKIM {
|
|
rec.DKIMAuth = append(rec.DKIMAuth, AuthResult{Domain: lower(d.Domain), Selector: d.Selector, Result: lower(d.Result)})
|
|
}
|
|
for _, s := range x.Auth.SPF {
|
|
rec.SPFAuth = append(rec.SPFAuth, AuthResult{Domain: lower(s.Domain), Result: lower(s.Result)})
|
|
}
|
|
r.Records = append(r.Records, rec)
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
func lower(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
|
|
|
|
// unwrap peels gzip, zip and email layers until XML is left.
|
|
func unwrap(data []byte, depth int) ([]byte, error) {
|
|
if depth > 4 {
|
|
return nil, errors.New("report is wrapped too deeply")
|
|
}
|
|
switch {
|
|
case bytes.HasPrefix(data, []byte{0x1f, 0x8b}):
|
|
zr, err := gzip.NewReader(bytes.NewReader(data))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer zr.Close()
|
|
return readLimited(zr, depth)
|
|
case bytes.HasPrefix(data, []byte("PK\x03\x04")):
|
|
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, f := range zr.File {
|
|
if f.FileInfo().IsDir() {
|
|
continue
|
|
}
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out, err := readLimited(rc, depth)
|
|
rc.Close()
|
|
return out, err
|
|
}
|
|
return nil, errors.New("empty zip")
|
|
}
|
|
trimmed := bytes.TrimSpace(data)
|
|
if bytes.HasPrefix(trimmed, []byte("<")) {
|
|
return trimmed, nil
|
|
}
|
|
return fromEmail(data, depth)
|
|
}
|
|
|
|
func readLimited(r io.Reader, depth int) ([]byte, error) {
|
|
b, err := io.ReadAll(io.LimitReader(r, maxXML+1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(b) > maxXML {
|
|
return nil, errors.New("report is too large")
|
|
}
|
|
return unwrap(b, depth+1)
|
|
}
|
|
|
|
// fromEmail finds the report attachment in a message.
|
|
func fromEmail(data []byte, depth int) ([]byte, error) {
|
|
m, err := mail.ReadMessage(bytes.NewReader(data))
|
|
if err != nil {
|
|
return nil, errors.New("not a DMARC report: unrecognised format")
|
|
}
|
|
b, err := fromPart(m.Header.Get("Content-Type"), m.Header.Get("Content-Transfer-Encoding"), m.Body, depth)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if b == nil {
|
|
return nil, errors.New("no report attached to the email")
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
func fromPart(contentType, encoding string, body io.Reader, depth int) ([]byte, error) {
|
|
mt, params, err := mime.ParseMediaType(contentType)
|
|
if err != nil {
|
|
mt = "text/plain"
|
|
}
|
|
if strings.HasPrefix(mt, "multipart/") {
|
|
mr := multipart.NewReader(body, params["boundary"])
|
|
for {
|
|
p, err := mr.NextPart()
|
|
if err != nil {
|
|
return nil, nil
|
|
}
|
|
b, err := fromPart(p.Header.Get("Content-Type"), p.Header.Get("Content-Transfer-Encoding"), p, depth)
|
|
if err != nil || b != nil {
|
|
return b, err
|
|
}
|
|
}
|
|
}
|
|
switch mt {
|
|
case "application/gzip", "application/x-gzip", "application/zip", "application/x-zip-compressed", "application/xml", "text/xml", "application/octet-stream":
|
|
raw, err := io.ReadAll(io.LimitReader(decodeBody(encoding, body), maxXML+1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return unwrap(raw, depth+1)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func decodeBody(encoding string, r io.Reader) io.Reader {
|
|
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
|
case "base64":
|
|
// mail wraps base64 in lines, which the decoder ignores when it is given the line breaks stripped
|
|
b, _ := io.ReadAll(io.LimitReader(r, maxXML*2))
|
|
clean := strings.Map(func(c rune) rune {
|
|
if c == '\r' || c == '\n' || c == ' ' || c == '\t' {
|
|
return -1
|
|
}
|
|
return c
|
|
}, string(b))
|
|
out, _ := base64.StdEncoding.DecodeString(clean)
|
|
return bytes.NewReader(out)
|
|
case "quoted-printable":
|
|
return quotedprintable.NewReader(r)
|
|
}
|
|
return r
|
|
}
|