DMARC集計レポート(RUA)を自分で集計する|policy_evaluatedとauth_resultsを取り違えない

Security

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ポリシー(padkimaspfpct など)
  • record(複数) … 送信元IPごとの集計行
  •     ├ rowsource_ipcountpolicy_evaluated
  •     ├ identifiersheader_from など
  •     └ auth_resultsdkim(複数可)、spf(複数可)

record は「1通ごと」ではなく「同じ結果になったメッセージのまとまり」です。通数は row/count に入っています。行数を数えても意味がありません。count を足す必要があります。

最重要:policy_evaluated と auth_results は別物

ここを取り違えると、原因を100%誤診します。どちらにも dkimspf という同じ名前の要素があるためです。

RFC 7489 Appendix C の型定義を見ると、両者が別物であることが機械的に確認できます。

  • policy_evaluateddkim/spfDMARCResultType。取り得る値は passfail2つだけ
  • auth_resultsdkimDKIMResultTypenone / pass / fail / policy / neutral / temperror / permerror7つ
  • auth_resultsspfSPFResultTypenone / neutral / pass / fail / softfail / temperror / permerror7つ

値の種類が違うのは、意味が違うからです。

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.exampleheader_fromexample.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/typesampled_out が入ります。

これらはDMARCとしては失敗だが、実際には隔離も拒否もされていないメッセージです。「失敗136通」の中に60通の sampled_out が混ざっていると、影響範囲を過大に見積もります。p=reject に上げたときに実際に落ちるのは何通か、を知りたいなら分けて数える必要があります。

なお reason/type には forwardedtrusted_forwardermailing_listlocal_policyother も入ります。転送やメーリングリスト経由の失敗は自分では直せないため、これらも切り分けておくと精神衛生上よいです。

実務ではどこから手をつけるか

集計結果が出たら、次の順で見ています。

  • 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にも投稿しました。あわせてどうぞ。

関連記事

タイトルとURLをコピーしました