Claude Media
DMARC集約レポートの読み方 — XMLをClaudeに渡して送信元を洗う

DMARC集約レポートの読み方 — XMLをClaudeに渡して送信元を洗う

rua宛てに届くDMARC集約レポートのXMLを、CSVに畳んでClaudeに渡し、送信元IPごとのSPF・DKIM判定と未許可の送信元を表にします。p=noneから先へ進む前の確認点も挙げます。

集約レポートで分かることと、Claudeに任せる範囲

DMARC集約レポートは、自分のドメインを名乗ったメールを受け取った側(Mail Receiver)が、認証結果をまとめて送ってくる日次のXMLです。宛先はDNSのDMARCレコードにある rua タグで決まります。rua がなければ、受信側は集約レポートを生成してはいけないとRFC 7489は定めています。

レポートに載るのは、送信元IPごとの通数、SPFとDKIMの結果、DMARCとしての判定、適用された処置(disposition)です。自分が出したつもりのない送信元が混ざっていないかを、ここで洗い出せます。

ただ、生のXMLは人が読むものではありません。1日に何通も届き、1通の中にレコードが何十行も並びます。この記事では、次の分担で読み解きます。

  • 展開・CSV化・合算は、手元のスクリプトで機械的に済ませる
  • 表の読み解き、「許可すべき送信元か」の仮説づくり、移行前の確認リストはClaudeに手伝わせる
  • 送信元IPの持ち主の特定と、許可するかどうかの最終判断は自分で行う

XMLの形 — どこを読めばよいか

届いたレポートのファイル名は、RFC 7489の例では mail.receiver.example!example.com!1013662812!1013749130.gz の形です。受信側のドメイン、ポリシーを持つドメイン、集計期間の開始と終了(UNIX時間の秒)が ! で区切られています。拡張子は、無圧縮なら xml、GZIP圧縮なら xml.gz です。メールの添付として届くので、件名の Report-ID も重複レポートの見分けに使えます。

中身で読むのは、feedback 直下の3つのブロックです。

  • report_metadata — 報告元の組織名、report_id、期間(date_range)
  • policy_published — 受信側が参照した時点のDMARCレコード(p、sp、pct、adkim、aspf)
  • record — 送信元IPごとの結果。ここが本体で、複数並びます

record の中には、読む場所が2つあります。ここを取り違えると、判定を誤ります。

場所中身見方
row/policy_evaluated中身DMARCとしての判定(dkim と spf はpass / fail、disposition、reason)見方アラインメント(From欄のドメインとの一致)まで含んだ結果
auth_results中身SPFとDKIMの生の検証結果(ドメイン、セレクタ、result)見方DMARCの解釈を加える前の結果

RFCのスキーマには、auth_results が「DMARCに関して未解釈の結果」だと注記されています。つまり auth_results の spf が pass でも、policy_evaluated の spf が fail になることがあります。送信元がSPFの検証には通ったものの、検証されたドメインが header_from と一致していない場合です。外部の配信サービスを使っているのにDMARCだけ落ちるときは、まずこの食い違いを疑います。

アラインメントの規則 — passとalignedは別物

DMARCは、SPFまたはDKIMのどちらかが「検証に通り、かつFrom欄のドメインと整合している」ことを求めます。DKIMは署名の d= のドメイン、SPFはエンベロープの MAIL FROM のドメインがFrom欄と比べられます。

整合の判定には、緩い(relaxed)モードと厳格(strict)モードがあり、既定は r です。relaxedでは組織ドメイン(Organizational Domain)が同じなら整合とみなされます。RFCの例では、署名が d=example.com で、Fromが alerts@news.example.com なら、relaxedでは整合、strictでは不整合です。SPFも同様で、MAIL FROM が cbg.bounces.example.com、Fromが payments@example.com ならrelaxedで整合します。

DKIM署名が複数ついたメールは、いずれか1つが整合して検証に通ればDMARCはpassです。逆に言えば、サービス側のドメインで署名・バウンス処理をする配信サービスは、DKIMもSPFも「通るが整合しない」状態になりがちです。

手順1: XMLをCSVに畳む

Claudeのファイルアップロードは、PDF、DOCX、CSV、TXT、HTML、ODT、RTF、EPUB、JSON、XLSXを対応形式として挙げています。XMLは一覧に載っていません。XLSXの扱いにはコード実行とファイル作成の有効化が要ります。そのため、xml.gz は展開してからCSVにして渡す手順にします。CSVにすれば、レコード単位の判定が1行ずつ並び、Claudeが読み違えにくくなります。

次のPythonスクリプトは、フォルダ内の .xml と .xml.gz を読み、1レコード1行のCSVに書き出します。標準ライブラリだけで動きます。

mkdir dmarc && mv ~/Downloads/*.xml.gz dmarc/
python3 dmarc_to_csv.py dmarc out.csv
import csv, glob, gzip, sys
import xml.etree.ElementTree as ET
 
def text(node, path):
    found = node.find(path)
    return (found.text or "").strip() if found is not None else ""
 
def load(path):
    opener = gzip.open if path.endswith(".gz") else open
    with opener(path, "rb") as f:
        return ET.fromstring(f.read())
 
rows = []
for path in sorted(glob.glob(sys.argv[1] + "/*.xml*")):
    root = load(path)
    meta = root.find("report_metadata")
    pub = root.find("policy_published")
    for rec in root.findall("record"):
        ev = rec.find("row/policy_evaluated")
        dkim = rec.find("auth_results/dkim")
        spf = rec.find("auth_results/spf")
        rows.append({
            "reporter": text(meta, "org_name"),
            "report_id": text(meta, "report_id"),
            "begin": text(meta, "date_range/begin"),
            "published_p": text(pub, "p"),
            "source_ip": text(rec, "row/source_ip"),
            "count": int(text(rec, "row/count") or 0),
            "disposition": text(ev, "disposition"),
            "dkim_aligned": text(ev, "dkim"),
            "spf_aligned": text(ev, "spf"),
            "reason": text(ev, "reason/type"),
            "header_from": text(rec, "identifiers/header_from"),
            "dkim_domain": text(dkim, "domain") if dkim is not None else "",
            "dkim_selector": text(dkim, "selector") if dkim is not None else "",
            "dkim_result": text(dkim, "result") if dkim is not None else "",
            "spf_domain": text(spf, "domain") if spf is not None else "",
            "spf_result": text(spf, "result") if spf is not None else "",
        })
 
with open(sys.argv[2], "w", newline="", encoding="utf-8") as f:
    w = csv.DictWriter(f, fieldnames=list(rows[0]))
    w.writeheader()
    w.writerows(rows)
print(f"{len(rows)} records -> {sys.argv[2]}")

このスクリプトは、RFC 7489の付録Cのスキーマに沿った要素名で読みます。次のようなサンプルのレポート(文書用の予約IPアドレスを使った例)で試すと、3行のCSVになります。

source_ip,count,dkim_aligned,spf_aligned,dkim_domain,dkim_result,spf_domain,spf_result
192.0.2.10,120,pass,pass,example.com,pass,example.com,pass
198.51.100.7,35,fail,fail,mailer.example.net,pass,bounce.mailer.example.net,pass
203.0.113.99,4,fail,fail,,,example.com,fail

2行目が先ほどの食い違いの典型です。DKIMもSPFも検証には通っていますが、検証されたドメインが mailer.example.net 系で、From欄の example.com と整合しません。3行目は検証そのものに失敗しています。

報告元によっては要素が欠けたり、DKIM署名が複数並んだりします。上のスクリプトは先頭の署名とSPF結果だけを拾う簡易版なので、署名が複数あるレポートでは行を増やす改造が要ります。

手順2: CSVをClaudeに渡して送信元の表を作らせる

CSVが用意できたら、チャットの「+」ボタンから「Add files or photos」で添付します。1会話に添付できるのは20ファイル、1ファイル500MBまでです。日次レポートを週単位で束ねても、CSVなら収まります。

最初の依頼では、判断基準を先に書いておくと、表の粒度が揃います。XMLタグで依頼の部品を区切る書き方は、ClaudeへのプロンプトをXMLタグで構造化する書き方が参考になります。

<context>
添付は example.com 宛ての DMARC 集約レポートを CSV にしたものです。
現在のポリシーは p=none。許可している送信元は、自社メールサーバー
(192.0.2.10)と、配信サービス A(メルマガ)だけです。
</context>
<task>
source_ip ごとに count を合算し、次の列の表にしてください。
列: source_ip / 合計通数 / dkim_aligned / spf_aligned / dkim_domain /
spf_domain / 許可済みか(上の2つに当てはまるか)/ 備考
</task>
<rules>
- count の合計は、元のCSVの合計と一致させ、最後に両方を書くこと
- IPの持ち主は推測で書かない。分からない場合は「要調査」とする
- dkim_aligned と spf_aligned が両方 fail の行を別表にする
</rules>

3つ目のルールには理由があります。DMARCがfailになるのは「どちらも整合しない」ときだけで、片方がpassなら届きます。両方failの行は、pを強めたときに実際に影響を受ける送信元です。

2つ目のルールも外せません。会話の中で、送信元IPの持ち主を確実に引ける保証はありません。IPが誰のものかは、逆引きDNSやWHOISで自分で確かめます。Claudeには「要調査」と書かせ、その列を埋める作業はこちらで担当します。

検算のさせ方

集計表が出たら、合計通数が元のCSVと合うかを確かめます。行数が多いCSVでは、Claudeの出した合計が元の数字と一致しているかを、自分でも確かめておきます。手元で awk やスクリプトで出した合計との照合が確実です。検算の考え方は、問い合わせCSVから週次レポートを作る手順にある件数の突き合わせと同じです。

手順3: 洗い出した送信元を3つに分ける

表ができたら、各行を次の3つに振り分けます。

分類見分け方次の動き
許可済みで整合している見分け方自社サーバーや契約中の配信サービスで、どちらかがalignedのpass次の動き何もしない
許可済みだが整合していない見分け方持ち主が自分の契約先で、検証には通るがalignedがfail次の動き配信サービス側で独自ドメイン署名を設定する、またはバウンス用ドメインを自ドメインのサブドメインにする
心当たりがない見分け方持ち主が不明、または自分の送信経路に存在しない次の動き調査し、不正ならpを強めて止める対象にする

reason 列にも目を通します。RFCが挙げる policy_evaluated の reason の種類は次の6つです。

  • forwarded — 既知の転送業者を経由した、または転送と推定されたメール
  • mailing_list — メーリングリストを経由したとみなされたメール
  • trusted_forwarder — 認証失敗が、信頼する転送業者の一覧によって予期されていたメール
  • sampled_out — pct の指定で、ポリシーの適用から外れたメール
  • local_policy — 受信側のローカルポリシーで、ポリシーの適用を免除されたメール
  • other — 上のどれにも当たらない例外(comment に詳細)

転送やメーリングリストを経由すると、認証が失敗したり整合が崩れたりします。RFCは、そうした変換が標準に沿っていてもDMARCのpassを妨げることがあると述べています。forwarded や mailing_list の行でfailが出ても、それだけで不正とは限りません。Claudeには、これらの理由が付いた行を別枠で数えさせると、不正の候補が絞れます。

p=noneからquarantineへ進める前の確認

集約レポートの目的は、ポリシーを強める前に「正規の送信元が落ちないか」を見極めることです。RFCは、ドメイン所有者が正規のメールの流れと認証結果、偽造メールの流れを把握したうえでポリシーを決める、という使い方を想定しています。進める前に、次の点を確かめます。

  • 期間が十分か。複数の報告元から、少なくとも月次の送信(請求書、四半期ごとの通知など)が一巡する期間のレポートを集めます。期間の長さそのものはRFCに規定がなく、自分の送信パターンで決めるものです
  • 「許可済みだが整合していない」の行が残っていないか。残ったまま quarantine にすると、その送信元のメールが迷惑メール扱いになりえます
  • サブドメインの扱い。sp を省くと、サブドメインにも p の値が適用されます
  • SPFの記述。DMARCの前にSPFレコードを見直すことをRFCは勧めています。広いネットワーク範囲を許可したレコードは、許可の意味が薄れます。また、-all を使うと、受信側によってはDMARCの処理より前に拒否される場合があります
  • 外部に集約レポートを送る設定。rua の宛先が自ドメインと違う組織ドメインなら、宛先側のDNSに ドメイン名._report._dmarc.宛先ドメイン のTXTレコード(v=DMARC1)が必要です。これがないと、受信側は宛先を無視します

Claudeには、手順2の表を渡したうえで、この確認リストを自分のドメインに当てはめて埋めさせると、抜けを見つけやすくなります。ただし、判断はレポートの数字から出た事実に限ります。「このままquarantineにして大丈夫か」をClaudeに決めさせず、「整合していない許可済み送信元が何行残っているか」を数えさせる使い方が堅実です。

pctで段階的に強める

pct タグは、ポリシーを適用する割合(0〜100、既定は100)を決めます。RFCは、一気に全通へ適用する「all or nothing」を避けて、段階的に強めるための仕組みだと説明しています。

p=reject; pct=20 のように設定すると、対象メールの20%にrejectを適用します。残りの80%はquarantine扱いにするよう、RFCは受信側に求めています。既存のポリシーを緩めずに、より強いポリシーを試せる仕組みです。

pct を使っている間も、レポートには全メールのデータが載ります。ポリシーから外れたメールは reason に sampled_out と付きます。値を変えた直後は、旧ポリシーと新ポリシーの結果が混ざったレポートが届くことがあります。RFCも、受信側がポリシー変更を期間の途中で検知することがあり、混在したレポートを許容するのはレポートを読む側の責任だと述べています。変更直後の数日分は、切り分けて読みます。

レポートをClaudeに渡す前の情報管理

集約レポートに載るのは、送信元IP、header_from、envelope_from、場合によっては宛先ドメイン(envelope_to)などです。RFCの「プライバシーへの配慮」の節は、集約レポートでも送信者・受信者の識別子が見えうること、転送がある場合は元の宛先リストになかったドメインも見えることを述べています。

社内のルールで外部サービスへの送信が制限されている場合は、上のスクリプトに列を落とす処理を足します。envelope_to を出力しない今回のスクリプトは、その点で最初から控えめです。顧客ごとにドメインを分けて運用している場合は、ドメイン名を customer-a のような符号に置き換えてから渡す方法もあります。

なお、ruf で受け取る失敗レポート(failure report)は別物です。RFCは、失敗レポートがメッセージ全体を受信者に見せうると述べています。この記事の手順は集約レポートだけを対象にしています。送信元の確認に失敗レポートを混ぜて貼り付ける運用は避けます。

よくあるつまずき

  • 件数が元の合計と合わない。同じレポートが二度届くことがあります。RFCは、件名の Report-ID が重複レポートを見分けるためのものだと述べています。report_id が重複していないかを、CSVで先に確かめます
  • 自社のIPがfailと出る。自社サーバーが別のドメインでSPFを検証されている、DKIM署名を付けずに送っている、などが原因です。spf_domain と dkim_domain の列で、どのドメインが検証されたかを見ます
  • レポートが届かない。 rua に書いたアドレスの綴り、mailto: の付け忘れ、外部宛先の承認レコードの有無を確かめます。rua の記述を変えた直後は、DNSの反映を待ちます
  • IPの持ち主をClaudeが言い切る。手順2の指示で「推測で書かない」としていても、会話が進むと断定調になることがあります。持ち主の列は、逆引きDNSやWHOISで自分の手で裏取りしてから確定します

まとめ

集約レポートの核心は、auth_results のpassと policy_evaluated のalignedの差にあります。この差が出ている行は、まだ正規の送信元なのにDMARCが落ちている送信元の候補です。ここを潰してから p を強めれば、止まる対象は心当たりのない送信元へ絞られます。

Claudeに任せるのは、CSV化した後の集計表と、行ごとの分類案、確認リストの穴探しです。IPの持ち主の特定、許可の可否、p を強めるタイミングは、事実を握っている自分の仕事として残ります。受け取る側として、公式メールの送信元を見分ける方法は、Anthropicの公式メールの送信元7件と偽メールの見分け方にあります。

この記事を共有:XはてブLinkedIn