// 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 }