This commit is contained in:
2026-05-24 17:15:48 +00:00
parent 329d5c665a
commit 063b3b643f
22 changed files with 1348 additions and 92 deletions
+231
View File
@@ -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)
}