DMARCレコードに rua= を書くと、翌日から集計レポートが届き始めます。gzipで圧縮されたXMLが毎日、受信側のプロバイダごとに送られてきます。開いてみたものの、どこを見れば何が分かるのか判断できずに放置している——という状態は珍しくありません。
専用のSaaSに投げれば可視化はしてくれますが、自分で一度集計してみると「レポートのどの値が何を意味するか」が腹に落ちます。そして実際にやってみると、多くの解説記事が触れていない落とし穴が2つあることに気づきます。この記事では、RFC 7489の型定義を確認したうえで、集計スクリプトを書き、実際に動かして踏んだバグまで含めて書きます。
集計レポート(RUA)の正体
仕様は RFC 7489 の Section 7.2 と Appendix C にあります。Appendix C にXMLスキーマ(XSD)が定義されており、これを読むのが最短です。
ファイル名は 受信者!ポリシードメイン!開始タイムスタンプ!終了タイムスタンプ という形式で、末尾に一意なIDが付くことがあります。gzipで圧縮されて届くのが一般的ですが、ZIPで送ってくる送信元もあります。スクリプトを書くなら両方に対応しておく必要があります。
XMLの骨格はこうなっています。
feedback(ルート)- └
report_metadata… 誰がいつの分を送ってきたか - └
policy_published… その時点で公開されていたDMARCポリシー(p、adkim、aspf、pctなど) - └
record(複数) … 送信元IPごとの集計行 - ├
row…source_ip、count、policy_evaluated - ├
identifiers…header_fromなど - └
auth_results…dkim(複数可)、spf(複数可)
record は「1通ごと」ではなく「同じ結果になったメッセージのまとまり」です。通数は row/count に入っています。行数を数えても意味がありません。count を足す必要があります。
最重要:policy_evaluated と auth_results は別物
ここを取り違えると、原因を100%誤診します。どちらにも dkim と spf という同じ名前の要素があるためです。
RFC 7489 Appendix C の型定義を見ると、両者が別物であることが機械的に確認できます。
policy_evaluatedのdkim/spfは DMARCResultType。取り得る値はpassとfailの2つだけauth_resultsのdkimは DKIMResultType。none/pass/fail/policy/neutral/temperror/permerrorの7つauth_resultsのspfは SPFResultType。none/neutral/pass/fail/softfail/temperror/permerrorの7つ
値の種類が違うのは、意味が違うからです。
auth_results は生の認証結果です。SPFやDKIMの検証そのものが通ったかどうか。policy_evaluated はアライメント(ドメインの一致)まで含めたDMARCとしての評価結果です。DMARCは「認証が通ったこと」ではなく「認証が通ったドメインが header_from と揃っていること」を要求します。
実際にこうなる
後述のスクリプトの出力から、失敗行を1つ抜き出します。
198.51.100.25 / 47 通 / disposition=none
header_from=example.jp policy_domain=example.jp pct=100
SPF domain=bounce.vendor-a.example scope=mfrom -> pass
SPF認証は pass しているのに、DMARCとしてはSPFが失敗しています。 理由は bounce.vendor-a.example が header_from の example.jp と揃っていないからです。SaaSからの送信でエンベロープFromがベンダー側ドメインになっている、典型的なパターンです。
auth_results だけを見て「SPFはpassだから問題ない」と判断すると、この47通の原因には永遠にたどり着けません。対処はSPFレコードではなく、そのSaaSでDKIM署名を自ドメインで行う(あるいはカスタムのエンベロープドメインを設定する)ことです。
集計スクリプト
Python標準ライブラリだけで動きます。gzipとZIPの両方、複数の dkim 要素、要素の欠落に対応しています。
#!/usr/bin/env python3
# dmarcsum.py - DMARC集計レポート(RUA)を集計する
# 使い方: ./dmarcsum.py <レポートを置いたディレクトリ>
import sys, os, glob, gzip, zipfile
import xml.etree.ElementTree as ET
from collections import defaultdict
def load_bytes(path):
""".gz / .zip / 素の .xml を透過的に読む。zipは中の全xmlを返す"""
if path.endswith('.gz'):
with gzip.open(path, 'rb') as f:
return [f.read()]
if path.endswith('.zip'):
out = []
with zipfile.ZipFile(path) as z:
for n in z.namelist():
if n.lower().endswith('.xml'):
out.append(z.read(n))
return out
if path.lower().endswith('.xml'):
with open(path, 'rb') as f:
return [f.read()]
return []
def strip_ns(elem):
"""名前空間を再帰的に剥がす。これをやらないとレポートを無言で取りこぼす"""
for e in elem.iter():
if isinstance(e.tag, str) and e.tag.startswith('{'):
e.tag = e.tag.split('}', 1)[1]
return elem
def text(node, path, default=''):
"""要素が無い場合に落ちないget。policy_evaluated/dkim は省略されることがある"""
f = node.find(path)
return (f.text or '').strip() if f is not None and f.text else default
total = 0
aligned_pass = 0
sampled_out = 0
by_source = defaultdict(lambda: {'n': 0, 'pass': 0})
fail_rows = []
raw_results = defaultdict(int)
parse_errors = []
files = 0
target = sys.argv[1] if len(sys.argv) > 1 else '.'
for path in sorted(glob.glob(os.path.join(target, '*'))):
for blob in load_bytes(path):
files += 1
try:
root = strip_ns(ET.fromstring(blob))
except ET.ParseError as e:
parse_errors.append((os.path.basename(path), str(e)))
continue
pol_domain = text(root, 'policy_published/domain', '(不明)')
pct = text(root, 'policy_published/pct', '100')
for rec in root.findall('record'):
try:
cnt = int(text(rec, 'row/count', '0'))
except ValueError:
cnt = 0
total += cnt
ip = text(rec, 'row/source_ip', '(不明)')
disp = text(rec, 'row/policy_evaluated/disposition', 'none')
# policy_evaluated は「アライメント込みの評価結果」。pass/fail の2値しかない
a_dkim = text(rec, 'row/policy_evaluated/dkim', 'fail')
a_spf = text(rec, 'row/policy_evaluated/spf', 'fail')
reason = text(rec, 'row/policy_evaluated/reason/type', '')
hfrom = text(rec, 'identifiers/header_from', '')
by_source[ip]['n'] += cnt
if a_dkim == 'pass' or a_spf == 'pass':
aligned_pass += cnt
by_source[ip]['pass'] += cnt
else:
if reason == 'sampled_out':
sampled_out += cnt
# auth_results は「生の認証結果」。ここを取り違えると原因を誤診する
rd = [(text(d, 'domain'), text(d, 'selector'), text(d, 'result'))
for d in rec.findall('auth_results/dkim')]
rs = [(text(s, 'domain'), text(s, 'scope'), text(s, 'result'))
for s in rec.findall('auth_results/spf')]
for _, _, r in rd:
raw_results['dkim:' + (r or 'なし')] += cnt
for _, _, r in rs:
raw_results['spf:' + (r or 'なし')] += cnt
if not rd:
raw_results['dkim:署名なし'] += cnt
fail_rows.append({'ip': ip, 'cnt': cnt, 'disp': disp, 'reason': reason,
'hfrom': hfrom, 'dkim': rd, 'spf': rs,
'domain': pol_domain, 'pct': pct})
print('読み込んだレポート : {} 件'.format(files))
print('総メッセージ数 : {}'.format(total))
if total:
print('DMARC pass : {} ({:.1f}%)'.format(aligned_pass, 100*aligned_pass/total))
print('DMARC fail : {} ({:.1f}%)'.format(total-aligned_pass, 100*(total-aligned_pass)/total))
if sampled_out:
print('うち sampled_out : {} (pct未満で対象外。実際には隔離/拒否されていない)'.format(sampled_out))
print('\n=== 送信元IP別 ===')
for ip, v in sorted(by_source.items(), key=lambda x: -x[1]['n']):
rate = 100*v['pass']/v['n'] if v['n'] else 0
print(' {:<24} {:>6} 通 pass {:>5.1f}%'.format(ip, v['n'], rate))
print('\n=== 失敗の生の認証結果(auth_results) ===')
for k, c in sorted(raw_results.items(), key=lambda x: -x[1]):
print(' {:<20} {}'.format(k, c))
print('\n=== 失敗行の詳細(上位10件) ===')
for r in sorted(fail_rows, key=lambda x: -x['cnt'])[:10]:
print(' {} / {} 通 / disposition={} {}'.format(r['ip'], r['cnt'], r['disp'],
'/ reason=' + r['reason'] if r['reason'] else ''))
print(' header_from={} policy_domain={} pct={}'.format(r['hfrom'], r['domain'], r['pct']))
for d, s, res in r['dkim']:
print(' DKIM d={} s={} -> {}'.format(d, s, res))
for d, sc, res in r['spf']:
print(' SPF domain={} scope={} -> {}'.format(d, sc, res))
if parse_errors:
print('\n=== 解析できなかったファイル ===')
for n, e in parse_errors:
print(' {}: {}'.format(n, e))
sys.exit(0)
実行結果
複数の送信元を模したレポート4件を置いて実行した結果です(値はすべてダミーです)。
読み込んだレポート : 4 件
総メッセージ数 : 570
DMARC pass : 434 (76.1%)
DMARC fail : 136 (23.9%)
うち sampled_out : 60 (pct未満で対象外。実際には隔離/拒否されていない)
=== 送信元IP別 ===
203.0.113.77 301 通 pass 100.0%
203.0.113.10 128 通 pass 100.0%
2001:db8::1 60 通 pass 0.0%
198.51.100.25 47 通 pass 0.0%
2001:db8::2 20 通 pass 0.0%
192.0.2.200 9 通 pass 0.0%
203.0.113.99 5 通 pass 100.0%
=== 失敗の生の認証結果(auth_results) ===
dkim:署名なし 127
spf:none 60
spf:pass 47
spf:permerror 20
dkim:temperror 9
spf:softfail 9
spf:pass が失敗の内訳に47通ある——これが前述のアライメント不一致です。生の結果だけ見ていると絶対に説明がつかない数字が、ここに出てきます。
このスクリプトと検証に使った4パターンのテストデータは、GitHubに置いてあります。ac5net/dmarcsum(MIT)です。python3 dmarcsum.py testdata で本記事と同じ出力が再現できます。名前空間バグを含んだ第1版との差を確かめたい場合もこちらをご覧ください。
書いてから気づいた実装上の注意
ここが本題です。第1版を実際に動かして、3つ直しました。同じものを書く人向けに残します。
1. 名前空間を剥がさないと、レポートを無言で取りこぼす
これが最も危険でした。第1版は root.findall('record') と書いていました。ところが送信元によって、ルート要素に既定の名前空間が付いています。
<!-- 名前空間なし(多数派) -->
<feedback>
<!-- 名前空間あり -->
<feedback xmlns="http://dmarc.org/dmarc-xml/0.1">
ElementTreeは名前空間付きの要素を {名前空間}record というタグ名として扱うため、findall('record') は0件を返します。例外は出ません。終了コードも0です。そのファイルの集計結果だけが静かに消えます。
実際に比較した数字がこれです。
名前空間なし2件+名前空間あり1件を集計
第1版: 総メッセージ数 264
修正版: 総メッセージ数 565
301通、全体の53%が欠落していました。しかもエラーは一切出ません。「DMARC passが48%しかない」という誤った結論で対策を始めるところでした。strip_ns() で全要素のタグから名前空間を剥がしてから処理する必要があります。
2. policy_evaluated の dkim / spf は省略されることがある
第1版は rec.find('row/policy_evaluated/dkim').text と書いていました。DKIM署名が無いメッセージのレポートで、この要素自体が存在しないケースに当たって落ちました。
AttributeError: 'NoneType' object has no attribute 'text'
スキーマ上も必須とは限らないため、要素の有無を確認するヘルパー(上の text())を通すのが安全です。欠落時のデフォルトは fail にしておくと、passを過大に見積もる事故を防げます。
3. sampled_out を失敗と混ぜてはいけない
pct=25 のようにポリシーを段階適用している場合、対象外になったメッセージには policy_evaluated/reason/type に sampled_out が入ります。
これらはDMARCとしては失敗だが、実際には隔離も拒否もされていないメッセージです。「失敗136通」の中に60通の sampled_out が混ざっていると、影響範囲を過大に見積もります。p=reject に上げたときに実際に落ちるのは何通か、を知りたいなら分けて数える必要があります。
なお reason/type には forwarded、trusted_forwarder、mailing_list、local_policy、other も入ります。転送やメーリングリスト経由の失敗は自分では直せないため、これらも切り分けておくと精神衛生上よいです。
実務ではどこから手をつけるか
集計結果が出たら、次の順で見ています。
- pass率の低い送信元IPを通数の多い順に見る。上位3つを潰せば大半が解決します。全部を追いかける必要はありません
- その送信元の
auth_resultsを見て、認証自体が失敗しているのか、アライメントで落ちているのかを切り分ける。前者はSPF/DKIMの設定ミス、後者はSaaS側の設定です。対処がまったく違います dkim:署名なしが多い送信元は、DKIM署名を追加できないか検討する。SPFはアライメントの制約が厳しく転送で壊れますが、DKIMは転送に強いため、両方通しておくと安定します- 身元不明のIPからの送信が多い場合は、なりすましか、把握できていない社内システムかを確認する。後者のほうが多いのが実情です
ポリシーを none から上げる判断は、この集計で「自分の正規の送信がすべてpassしている」と確認できてからです。順序を逆にすると自社のメールが届かなくなります。
まとめ
recordは集計行。通数はrow/countを足す。行数を数えないpolicy_evaluatedはアライメント込みのDMARC評価(pass/failの2値)。auth_resultsは生の認証結果(7値)。取り違えると原因を誤診する- 名前空間を剥がさないと、エラーも出さずにレポートが丸ごと欠落する(検証では53%が消えた)
policy_evaluated/dkimは省略されることがある。要素の有無を確認するsampled_outは失敗と分けて数える
集計そのものより、「認証は通っているのにDMARCが落ちる」という状態が存在することを理解しているかどうかが実務での分かれ目でした。同じところで詰まった人の時間が少しでも減れば幸いです。
参考:RFC 7489 Section 7.2(Aggregate Reports)および Appendix C(XMLスキーマ定義)
なお、この記事を書くきっかけになった「名前空間でレポートが53%消えていた」件だけを技術者向けに切り出したものをZennにも投稿しました。あわせてどうぞ。
関連記事
- SPFのDNSルックアップ10回制限とPermErrorの原因・対処法 … 集計レポートで
spf:permerrorが出ていたらこちら - BIMIの設定手順 … DMARCを
quarantine以上に上げた後の次の一手
