a
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
// Package dmarcreport parses DMARC aggregate report emails (RFC 7489).
|
||||
// Handles gzip and zip compressed XML attachments from multipart emails.
|
||||
package dmarcreport
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/base64"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/mail"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Feedback is the top-level DMARC aggregate report (feedback element).
|
||||
type Feedback struct {
|
||||
ReportMetadata ReportMetadata `xml:"report_metadata"`
|
||||
PolicyPublished PolicyPublished `xml:"policy_published"`
|
||||
Records []Record `xml:"record"`
|
||||
}
|
||||
|
||||
// ReportMetadata holds reporter identification and date range.
|
||||
type ReportMetadata struct {
|
||||
OrgName string `xml:"org_name"`
|
||||
Email string `xml:"email"`
|
||||
ReportID string `xml:"report_id"`
|
||||
DateRange DateRange `xml:"date_range"`
|
||||
}
|
||||
|
||||
// DateRange is the Unix timestamp range the report covers.
|
||||
type DateRange struct {
|
||||
Begin int64 `xml:"begin"`
|
||||
End int64 `xml:"end"`
|
||||
}
|
||||
|
||||
// PolicyPublished is the DMARC policy in effect during the report period.
|
||||
type PolicyPublished struct {
|
||||
Domain string `xml:"domain"`
|
||||
ADKIM string `xml:"adkim"`
|
||||
ASPF string `xml:"aspf"`
|
||||
P string `xml:"p"`
|
||||
PCT int `xml:"pct"`
|
||||
}
|
||||
|
||||
// Record is one IP-level row in the report.
|
||||
type Record struct {
|
||||
Row Row `xml:"row"`
|
||||
Identifiers Identifiers `xml:"identifiers"`
|
||||
AuthResults AuthResults `xml:"auth_results"`
|
||||
}
|
||||
|
||||
// Row contains the source IP, message count, and policy evaluation result.
|
||||
type Row struct {
|
||||
SourceIP string `xml:"source_ip"`
|
||||
Count int `xml:"count"`
|
||||
PolicyEvaluated PolicyEvaluated `xml:"policy_evaluated"`
|
||||
}
|
||||
|
||||
// PolicyEvaluated is how DMARC evaluated this IP's messages.
|
||||
type PolicyEvaluated struct {
|
||||
Disposition string `xml:"disposition"`
|
||||
DKIM string `xml:"dkim"`
|
||||
SPF string `xml:"spf"`
|
||||
}
|
||||
|
||||
// Identifiers holds From and envelope domain information.
|
||||
type Identifiers struct {
|
||||
HeaderFrom string `xml:"header_from"`
|
||||
EnvelopeFrom string `xml:"envelope_from"`
|
||||
}
|
||||
|
||||
// AuthResults holds actual DKIM and SPF results.
|
||||
type AuthResults struct {
|
||||
DKIM DKIMAuthResult `xml:"dkim"`
|
||||
SPF SPFAuthResult `xml:"spf"`
|
||||
}
|
||||
|
||||
// DKIMAuthResult is the per-signature DKIM check result.
|
||||
type DKIMAuthResult struct {
|
||||
Domain string `xml:"domain"`
|
||||
Selector string `xml:"selector"`
|
||||
Result string `xml:"result"`
|
||||
}
|
||||
|
||||
// SPFAuthResult is the SPF check result.
|
||||
type SPFAuthResult struct {
|
||||
Domain string `xml:"domain"`
|
||||
Result string `xml:"result"`
|
||||
}
|
||||
|
||||
// ParseReportEmail extracts and parses a DMARC aggregate report from a raw email.
|
||||
// Handles multipart/mixed emails with gzip or zip compressed XML attachments.
|
||||
func ParseReportEmail(rawEmail []byte) (*Feedback, error) {
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(rawEmail))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse email: %w", err)
|
||||
}
|
||||
|
||||
ct := msg.Header.Get("Content-Type")
|
||||
if ct == "" {
|
||||
ct = "text/plain"
|
||||
}
|
||||
mediaType, params, err := mime.ParseMediaType(ct)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse content-type: %w", err)
|
||||
}
|
||||
|
||||
// Multipart email — scan parts for compressed XML attachment.
|
||||
if strings.HasPrefix(mediaType, "multipart/") {
|
||||
mr := multipart.NewReader(msg.Body, params["boundary"])
|
||||
for {
|
||||
part, partErr := mr.NextPart()
|
||||
if partErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if partErr != nil {
|
||||
return nil, fmt.Errorf("read multipart: %w", partErr)
|
||||
}
|
||||
|
||||
partCT := part.Header.Get("Content-Type")
|
||||
partMedia, _, _ := mime.ParseMediaType(partCT)
|
||||
|
||||
partData, readErr := io.ReadAll(io.LimitReader(part, 10<<20))
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("read part: %w", readErr)
|
||||
}
|
||||
|
||||
// Decode base64 Content-Transfer-Encoding if present.
|
||||
if strings.EqualFold(strings.TrimSpace(part.Header.Get("Content-Transfer-Encoding")), "base64") {
|
||||
clean := bytes.Map(func(r rune) rune {
|
||||
if r == '\r' || r == '\n' || r == ' ' || r == '\t' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, partData)
|
||||
decoded := make([]byte, base64.StdEncoding.DecodedLen(len(clean)))
|
||||
n, decErr := base64.StdEncoding.Decode(decoded, clean)
|
||||
if decErr != nil {
|
||||
continue // skip malformed parts
|
||||
}
|
||||
partData = decoded[:n]
|
||||
}
|
||||
|
||||
xmlData, decErr := decompressToXML(partMedia, partData)
|
||||
if decErr == nil && len(xmlData) > 0 {
|
||||
return ParseXML(xmlData)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no DMARC XML attachment found in multipart email")
|
||||
}
|
||||
|
||||
// Single-part — try decompressing the body directly.
|
||||
body, err := io.ReadAll(io.LimitReader(msg.Body, 10<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
xmlData, err := decompressToXML(mediaType, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decompress body: %w", err)
|
||||
}
|
||||
return ParseXML(xmlData)
|
||||
}
|
||||
|
||||
// ParseXML parses a raw DMARC XML aggregate report.
|
||||
func ParseXML(data []byte) (*Feedback, error) {
|
||||
var f Feedback
|
||||
if err := xml.Unmarshal(data, &f); err != nil {
|
||||
return nil, fmt.Errorf("parse dmarc xml: %w", err)
|
||||
}
|
||||
if f.ReportMetadata.ReportID == "" {
|
||||
return nil, fmt.Errorf("missing report_id in DMARC report XML")
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
// decompressToXML attempts to return raw XML from possibly-compressed data.
|
||||
// Detection is by magic bytes, not MIME type (senders are inconsistent).
|
||||
func decompressToXML(mediaType string, data []byte) ([]byte, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, fmt.Errorf("empty data")
|
||||
}
|
||||
|
||||
// gzip magic: 0x1f 0x8b
|
||||
if len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b {
|
||||
r, err := gzip.NewReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gzip open: %w", err)
|
||||
}
|
||||
defer r.Close()
|
||||
out, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gzip read: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// zip magic: PK 0x03 0x04
|
||||
if len(data) >= 4 && data[0] == 'P' && data[1] == 'K' && data[2] == 0x03 && data[3] == 0x04 {
|
||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("zip open: %w", err)
|
||||
}
|
||||
for _, f := range zr.File {
|
||||
if strings.HasSuffix(strings.ToLower(f.Name), ".xml") {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("zip entry open: %w", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
out, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("zip entry read: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no .xml file found in zip")
|
||||
}
|
||||
|
||||
// Try as raw XML.
|
||||
trimmed := bytes.TrimSpace(data)
|
||||
if bytes.HasPrefix(trimmed, []byte("<?xml")) || bytes.HasPrefix(trimmed, []byte("<feedback")) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unrecognised format (media-type: %s)", mediaType)
|
||||
}
|
||||
Reference in New Issue
Block a user