DMARC Aggregate Report 人工解析与监控完全指南:rua XML结构、parsedmarc与dmarcian

摘要:DMARC聚合报告(RUA)是了解外部接收方如何评估发件域SPF/DKIM认证状态的核心数据源。RFC 7489 §7明确定义了聚合报告的XML Schema和发送机制,dmarcian、URIports等SaaS服务提供开箱即用的可视化分析,但深入理解报告的结构化字段(policy_evaluated、auth_results、row记录)能够在无外部工具的情况下自行解析异常。本文从原始XML结构入手,覆盖parsedmarc自建监控栈部署,再到常见异常的诊断方法。

1. DMARC聚合报告概览

1.1 数据流路径

DMARC(RFC 7489)定义了两种反馈报告:聚合报告(Aggregate Report, RUA)和法证报告(Forensic Report, RUF/RFC 9991)[1]。聚合报告以XML格式由接收MTA定期(通常每日)汇总发送至DMARC记录中rua标签指定的地址。数据流:发件域配置rua=mailto:dmarc-rua@example.com → 接收MTA收集24小时内所有发件域为example.com的邮件认证结果 → 打包为gzip压缩的XML → 发送至rua邮箱 → 域所有者解析 [1] [2]。

1.2 报告发送约束

接收MTA的聚合报告生成受DMARC策略中的pct字段影响:pct=100表示对100%的邮件生成报告(默认值),pct=50表示仅对50%的邮件抽样生成。域所有者可通过pct分阶段部署策略实现渐进式监控 [1] §6.4。

2. XML Schema 逐字段解析

2.1 根节点结构

<?xml version="1.0" encoding="UTF-8"?>
<feedback>
  <report_metadata>...</report_metadata>
  <policy_published>...</policy_published>
  <record>...</record>  <!-- 每发件源IP一条 -->
</feedback>

2.2 report_metadata — 报告元信息

<report_metadata>
  <org_name>google.com</org_name>  <!-- 报告生成方 -->
  <email>noreply-dmarc-support@google.com</email>
  <extra_contact_info>https://support.google.com/a/answer/2466580</extra_contact_info>
  <report_id>20260724080000.12345@google.com</report_id>
  <date_range>
    <begin>1761264000</begin>  <!-- Unix时间戳,该报告覆盖的起始时间 -->
    <end>1761350399</end>    <!-- 结束时间(24h窗口) -->
  </date_range>
</report_metadata>

org_name指示谁生成的报告——Gmail为google.com,Outlook为outlook.com,腾讯为企业邮箱为exmail.qq.com。关键陷阱:有些接收方使用多个org_name(如谷歌的多个数据中心可能以不同org_name发送),需合并去重 [1]。

2.3 policy_published — 发布的策略

<policy_published>
  <domain>example.com</domain>
  <adkim>r</adkim>  <!-- DKIM对齐模式: r=relaxed, s=strict -->
  <aspf>r</aspf>    <!-- SPF对齐模式: r=relaxed, s=strict -->
  <p>reject</p>    <!-- 父域策略 -->
  <sp>reject</sp>  <!-- 子域策略(可选) -->
  <pct>100</pct>   <!-- 抽样百分比 -->
  <fo>0</fo>       <!-- 法证报告选项 -->
</policy_published>

注意:p字段显示的是发布策略,而非实际执行策略。如果接收方因自身配置原因无法执行reject策略(如客户要求宽松),实际执行策略在policy_evaluated中体现 [1] §7.7。

2.4 record + row — 认证结果核心

<record>
  <row>
    <source_ip>203.0.113.5</source_ip>
    <count>245</count>  <!-- 该IP的邮件数量 -->
    <policy_evaluated>
      <disposition>none</disposition>  <!-- none/quarantine/reject -->
      <dkim>pass</dkim>   <!-- pass/fail -->
      <spf>fail</spf>     <!-- pass/fail -->
    </policy_evaluated>
  </row>
  <identifiers>
    <header_from>example.com</header_from>  <!-- From头域 -->
    <envelope_from>example.com</envelope_from>  <!-- MAIL FROM -->
    <envelope_to>recipient@other.com</envelope_to>
  </identifiers>
  <auth_results>
    <dkim>
      <domain>example.com</domain>
      <result>pass</result>
      <selector>default</selector>
    </dkim>
    <spf>
      <domain>example.com</domain>
      <result>fail</result>  <!-- none/neutral/pass/fail/softfail/temperror/permerror -->
    </spf>
  </auth_results>
</record>

2.4.1 policy_evaluated 解读

disposition是该批邮件的实际执行动作:none(仅监控不操作)、quarantine(标记为垃圾邮件)、reject(拒收)。dkimspf字段表示是否通过了相应认证,二者任意pass即DMARC pass。解析时的关键指标:dkim:fail + spf:fail + disposition:none表示DMARC完全失败的邮件但未被处理,需要立即关注;disposition:reject + spf:fail dkim:pass表示虽然DKIM通过但SPF未对齐且配置为strict对齐,导致DMARC失败。

2.4.2 auth_results vs policy_evaluated 的区别

auth_results报告的是原始SPF和DKIM的验证结果(包含所有被验证的域名),而policy_evaluated仅报告与From域对齐后的结果。例如:SPF验证结果为pass但使用的域为send.thirdparty.com,而From域为example.com,<spf><result>pass</result><domain>send.thirdparty.com</domain></spf>,但policy_evaluated中的spf=pass仅当SPF通过且与From域对齐 [1] §3.1.1。

3. 纯手动解析 XML 报告(无外部工具)

3.1 解压与基础统计

# DMARC报告以gzip压缩的附件形式发送至rua邮箱
# 下载附件后解压
gzip -d google-com!example.com!1761264000!1761350399.xml.gz

# 使用 xmllint 或 Python 快速统计
xmllint --xpath "count(//feedback/record)" report.xml
# 输出:42 条记录

# 统计按策略结果分组
xmllint --xpath "//policy_evaluated/disposition/text()" report.xml \
  | sort | uniq -c
# 39 none  2 quarantine  1 reject

# 统计 pass/fail 比例
xmllint --xpath "//policy_evaluated/dkim/text()" report.xml \
  | sort | uniq -c
# 40 pass  2 fail

3.2 Python 解析脚本基础

#!/usr/bin/env python3
"""快速解析 DMARC 聚合报告"""
import xml.etree.ElementTree as ET

tree = ET.parse('report.xml')
ns = ''  # DMARC XML 无默认命名空间

total_records = 0
total_volume = 0
fail_volume = 0

for record in tree.findall('feedback/record'):
    total_records += 1
    count = int(record.findtext('row/count', '0'))
    total_volume += count
    dkim = record.findtext('row/policy_evaluated/dkim', 'unknown')
    spf = record.findtext('row/policy_evaluated/spf', 'unknown')
    if dkim == 'fail' and spf == 'fail':
        fail_volume += count

print(f"记录数: {total_records}")
print(f"邮件总量: {total_volume}")
print(f"DMARC失败量: {fail_volume} ({fail_volume/total_volume*100:.1f}%)")

# 提取失败源IP
for record in tree.findall('feedback/record'):
    dkim = record.findtext('row/policy_evaluated/dkim', 'unknown')
    spf = record.findtext('row/policy_evaluated/spf', 'unknown')
    if dkim == 'fail' and spf == 'fail':
        ip = record.findtext('row/source_ip', 'unknown')
        count = record.findtext('row/count', '0')
        print(f"  失败源IP: {ip} ({count}封)")

3.3 批量处理多个.gz报告

# 解压所有报告
for f in *.gz; do gzip -d "$f"; done

# 合并为一个CSV用于Excel分析(使用xsltproc或Python)
python3 << 'EOF'
import gzip, io, xml.etree.ElementTree as ET, glob, csv

with open('dmarc_report.csv', 'w', newline='') as f:
    w = csv.writer(f)
    w.writerow(['org','report_id','begin','end','domain','sp_policy','dkim_p','spf_p',
                'source_ip','count','disposition','dkim_auth','spf_auth','header_from'])
    for gz in glob.glob('*.gz'):
        with gzip.open(gz, 'rt', encoding='utf-8') as fh:
            try:
                root = ET.fromstring(fh.read())
            except:
                continue
        meta = root.find('feedback/report_metadata')
        org = meta.findtext('org_name','')
        rid = meta.findtext('report_id','')
        begin = meta.findtext('date_range/begin','')
        end_ = meta.findtext('date_range/end','')
        pp = root.find('feedback/policy_published')
        domain = pp.findtext('domain','')
        sp_pol = pp.findtext('sp','')
        for record in root.findall('feedback/record'):
            ip = record.findtext('row/source_ip','')
            c = record.findtext('row/count','0')
            disp = record.findtext('row/policy_evaluated/disposition','')
            dk = record.findtext('row/policy_evaluated/dkim','')
            sp = record.findtext('row/policy_evaluated/spf','')
            hf = record.findtext('identifiers/header_from','')
            dk_res = record.findtext('auth_results/dkim/result','')
            sp_res = record.findtext('auth_results/spf/result','')
            w.writerow([org,rid,begin,end_,domain,sp_pol,dk,sp,ip,c,disp,dk_res,sp_res,hf])
print("CSV generated: dmarc_report.csv")
EOF

4. 自动化监控栈:parsedmarc + Elasticsearch + Grafana

4.1 parsedmarc 部署

# parsedmarc 是最主流的DMARC报告解析工具
# GitHub: https://github.com/domainaware/parsedmarc

# 安装
pip3 install parsedmarc

# 配置 /etc/parsedmarc.ini
[general]
imap_host = imap.example.com
imap_port = 993
imap_ssl = True
imap_user = dmarc-rua@example.com
imap_password = S3cur3P@ss
save_aggregate = True
save_forensic = True
save_mbox = False
# 存储至Elasticsearch
elasticsearch_host = 127.0.0.1
elasticsearch_port = 9200
elasticsearch_use_ssl = False
# 排除自身报告(避免循环)
filter_domains = []
filter_org_domains = []
# GeoIP 数据库(可选)
geoipdb_path = /usr/share/GeoIP/GeoLite2-City.mmdb

[elasticsearch]
index_prefix = dmarc
# 自动创建索引
create_ilm = True
index_suffix = history

4.2 Elasticsearch + Kibana 可视化

# parsedmarc 运行后自动创建索引
parsedmarc --config /etc/parsedmarc.ini

# 定时执行(crontab)
# 每6小时解析一次
0 */6 * * * /usr/local/bin/parsedmarc --config /etc/parsedmarc.ini

# 查询 Elasticsearch
curl -X GET "localhost:9200/dmarc_*/_search?q=disposition:reject&size=0"

# Kibana 创建仪表盘的关键字段
# - disposition: none / quarantine / reject
# - dkim_result / spf_result 按域聚合
# - source_ip 按地理位置聚合(需GeoIP)
# - header_from 按域分析

4.3 Grafana 监控面板

配置Grafana连接Elasticsearch数据源后,关键面板:① DMARC失败率趋势图(按天统计pass/fail比例);② 按接收方(org_name)分类的认证结果堆叠图;③ 失败源IP Top N表格;④ pct实施度仪表——检查是否有接收方因pct<100导致抽样偏差。

5. 常见异常诊断

5.1 子域未覆盖

症状:聚合报告中大量mail.example.com的认证失败,但example.com的策略正常。疑因DMARC记录的sp字段未设置,默认继承p值。明确设置sp=reject可以消除歧义。聚合报告中的policy_published/sp字段显示实际的子域策略。

5.2 同一域名多个org_name发送报告

Gmail和Google Workspace可能以google.com和googlemail.com两个org_name发送报告。Outlook/Office 365可能使用protection.outlook.com。需要在数据分析时合并同一发送者的报告,否则统计偏差。

5.3 pct偏差导致误判

如果pct=50,报告仅覆盖50%流量。假设今天报告显示100% pass,但可能未覆盖的50%恰好包含所有失败邮件。RFC 7489 §6.4警告这种抽样偏差。正式部署reject策略前,pct应从10→50→100逐步提升,每阶段观察7天以上 [1]。

5.4 报告缺失

并非所有接收MTA都发送聚合报告。常见缺失源:自建Postfix默认不发送DMARC报告(需配置OpenDMARC的AuthservID和ReportInfo);小型ISP的MTA无DMARC实现;部分接收方以报告周期不一致为由延迟发送(长达72小时)。监控周期建议设置为1周而非1天以容忍延迟。

5.5 伪造报告的防护

聚合报告的XML文件理论上可被伪造。建议:① 只接收来自已知org_name域的邮件(如google.com、outlook.com);② DMARC记录中的rua格式支持rua=mailto:report@...!xx:xx的URI编码;③ 通过DKIM验证邮件来源的真实性(报告邮件应由接收方的DKIM签名)。

参考文献

  1. RFC 7489 — Domain-based Message Authentication, Reporting, and Conformance (DMARC). M. Kucherawy, E. Zwicky. March 2015. §7 (Aggregate Reports XML Schema and Transport), §6.4 (Data Sampling with pct), §3.1.1 (Identifier Alignment).
  2. RFC 7960 — Interoperability Issues between DMARC and Indirect Email Flows. M. Kucherawy, E. Zwicky. September 2016. §2 (Mapping DMARC Authentication Results to Aggregated Reports Structure).
  3. RFC 8617 — The Authenticated Received Chain (ARC) Protocol. M. Kucherawy, E. Zwicky, K. Andersen. July 2019. §7 (Interaction with DMARC reporting when forwarding breaks SPF).
  4. RFC 9091 — A P2P Approach to DMARC Reporting. J. Levine. July 2021. §4 (DMARC Reporting Interoperability and the Report URI Encoding).
  5. RFC 5322 — Internet Message Format. P. Resnick. October 2008. §3.6.2 (Originator Fields including From header, the basis for DMARC alignment).