Parse DMARC aggregate reports; model deliverability findings and delivery outcomes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6663b268c8
commit
5c1737bad3
3 files changed
+483
No files matched your search
+274
@@ -0,0 +1,274 @@
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user