DMARCはRFC 9989になった|pct廃止・Tree Walk導入の要点と、主要30ドメインを新仕様で読み直したら認可の無いレポート宛先が1件あった

Security

DMARCの仕様が変わりました。2015年から参照され続けてきたRFC 7489は、2026年5月にRFC 9989(本体)・RFC 9990(集計レポート)・RFC 9991(失敗レポート)の3本に置き換えられ、廃止されています。RFC 7489は独立投稿のInformationalでしたが、新しい3本はIETFの標準化過程(Proposed Standard)です。

何が変わったのかを原文で確認したうえで、CAA・DNSSECの記事と同じ主要30ドメイン(参照6件を加えて36件)のDMARCレコードを新仕様で読み直しました。結果は、p=rejectが17件、廃止されたpctタグを置いたままのレコードが12件、そして外部のレポート宛先に認可レコードが無い(受信側に無視される)ものが1件でした。仕様の確認、実測、スクリプト、落とし穴の順にまとめます。

RFC 9989 で変わったこと(Appendix C と本文で確認)

RFC 9989のAppendix C「Changes from RFC 7489」に差分がまとまっています。運用者に関係する変更を本文の該当節と合わせて確認しました。

タグの追加と削除(C.5)

追加されたタグ(C.5.1)
  np   存在しないサブドメイン向けのポリシー(RFC 9091 から取り込み)
  psd  そのドメインが PSD(Public Suffix Domain)かどうか。y / n / u(既定)
  t    テストモード。y なら受信側は 1 段階緩いポリシーを適用(pct=0 の代替)

削除されたタグ(C.5.2)
  pct  ポリシーを適用するメッセージの割合(Appendix A.6 参照)
  rf   失敗レポートの形式
  ri   集計レポートの間隔

残ったタグ: v(必須・先頭), p, sp, adkim, aspf, fo, rua, ruf
URI の末尾でレポートサイズを指定する "!10m" の書き方も廃止(4.8 obs-dmarc-uri)

pctの廃止理由はAppendix A.6に詳しく書かれています。運用上、0と100以外の値は実装ごとに扱いがばらばらで正確に適用されておらず、一方でpct=0は「ポリシーを適用せずFromを書き換える」合図として中継側で独自の意味を持ってしまっていた。そこで0と100に相当する2値だけをt=y/t=nとして残した、という経緯です。t=yのときは、受信側は指定より1段階緩いポリシー(rejectならquarantine、quarantineならnone)を適用します(4.7)。

削除されたタグを置いたままにしても、4.8の「未知のタグは無視しなければならない(MUST)」が効くのでレコードが無効になることはありません。ただしpct=50のような段階適用を期待していた場合、新仕様の受信側はそれを読みません。

組織ドメインの決め方:Public Suffix List から DNS Tree Walk へ(4.10、C.3)

RFC 7489は「組織ドメイン」をPublic Suffix List(PSL)で決めていましたが、どのPSLをいつ取得するかの規定が無く、受信側ごとに答えが変わり得ました。RFC 9989はこれをDNSを実際に登って_dmarcレコードを探す「DNS Tree Walk」に置き換えました。

  • 起点から親へ1ラベルずつ_dmarc.<名前>のTXTを引く。ラベル数が8以上なら7ラベルに切り詰めてから登り、問い合わせは最大8回(DoS対策。4.10)
  • 同じ名前にDMARCレコードが2本以上あれば全部捨てる。psd=nかpsd=yを含むレコードが見つかったらそこで止まる
  • 組織ドメインの選び方(4.10.2):psd=nのレコードがあればその名前。起点以外でpsd=yがあればその1ラベル下。どちらも無ければ、見つかったレコードのうちラベル数が最も少ない名前

RFCの例をそのまま書くと、a.mail.example.comから始めて_dmarc.mail.example.comと_dmarc.example.comにレコードがあれば組織ドメインはexample.com。_dmarc.mail.example.comにpsd=nがあればmail.example.com。_dmarc.comにpsd=yしか無ければexample.comです。

C.3は、この変更で旧実装(PSL)と新実装(Tree Walk)で答えが食い違う可能性を明記し、回避策として「すべての送信ドメインに明示的にレコードを置き、strict alignmentを使う」ことを挙げています。

適用ポリシーの決め方(4.10.1)

  • Author Domain自身に有効なレコードがあればそれを使い、pを適用
  • 無ければTree Walkで組織ドメイン/PSDのレコードを使う。サブドメインには存在するサブドメインならsp、存在しないサブドメインならnp、どちらも無ければp
  • pが無い・無効でも、ruaに有効なURIが1つでもあればp=noneとして処理を続ける。ruaも無ければDMARC処理をしない
  • v=DMARC1が先頭に無い、値が違う(大文字小文字を区別)ならレコード全体を無視(4.7)

p=reject への警告(7.4、C.6)

意外だったのがここです。C.6は「一般ユーザーが日常的にメールを送るドメインは、p=rejectを公開すべきではない(SHOULD NOT)」と明文化したと述べています。7.4の理由はメーリングリストなどの間接配送で、p=rejectのドメインの利用者が投稿すると、リスト経由の配信が拒否されて購読が自動解除されるといった実害が出るためです。同時に受信側にも「p=rejectだけを根拠に拒否してはならない(MUST NOT)。他の判断材料が無ければquarantineとして扱う(MUST)」と求めています。送信側・受信側の両方に、rejectを文字どおり適用するなと言っているわけです。

外部レポート宛先の認可(RFC 9990 4、RFC 9991 5)

rua/rufの宛先が自分の組織ドメインの外にある場合、受信側は<ポリシーを取得したドメイン>._report._dmarc.<宛先ホスト>のTXTを引き、v=DMARC1で始まるレコードが1つでもあれば認可済みと判断します。無ければそのURIは無視しなければならない(MUST)。悪意ある第三者が他人のアドレスをレポート宛先に書いてレポートを浴びせる攻撃を防ぐためです。認可レコード側でruaを書けば宛先を上書きできますが、上書き先が別ホストなら元も上書き先も送信禁止です。rufについてはRFC 9991の5節が「ruaをrufに読み替えて同じ手順を使う」と定めています。

ここで「組織ドメインの外かどうか」の判定にもTree Walkが使われます。gmail.comのruaはgoogle.com宛てですが、_dmarc.gmail.com自体にレコードがあるのでgmail.comの組織ドメインはgmail.com、宛先google.comとは別組織。実際にgmail.com._report._dmarc.google.comにv=DMARC1が置かれています。

36ドメインの実測(2026年10月12日)

主要30ドメイン(海外10、日本20)に、参照としてgmail.com、yahoo.com、outlook.com、icloud.com、ietf.org、ac-5.netの6件を加えました。DoH(dns.google)で_dmarcのTXTを取り、Tree Walkと認可確認で必要になった名前も含めて113件の応答を記録しています。

主要30ドメインの p(2026-10-12、_dmarc.<ドメイン> の TXT)
  reject     17  google.com, microsoft.com, cloudflare.com, salesforce.com, zoom.us, slack.com,
                 okta.com, cybozu.com, mufg.jp, smbc.co.jp, jal.co.jp, rakuten.co.jp,
                 sakura.ad.jp, so-net.ne.jp, japanpost.jp, mercari.com, moneyforward.com
  quarantine  8  apple.com, amazon.com, github.com, yahoo.co.jp, freee.co.jp,
                 biglobe.ne.jp, kddi.com, ntt.com
  none        3  ana.co.jp, line.me, nifty.com
  レコード無し 2  softbank.jp, ocn.ne.jp(_dmarc が NXDOMAIN。Tree Walk で jp まで登っても無し)

参照6ドメイン
  gmail.com    p=none; sp=quarantine      outlook.com  p=none; sp=quarantine
  icloud.com   p=quarantine; sp=quarantine  yahoo.com  p=reject
  ietf.org     p=none                      ac-5.net     p=none

RFC 9989 で削除されたタグが残っているレコード(主要30)
  pct  12 件(すべて pct=100)  ri  4 件(zoom.us, smbc.co.jp, so-net.ne.jp, japanpost.jp)

sp を明示しているもの(主要30)
  sp=reject      apple.com, cloudflare.com, github.com, sakura.ad.jp
  sp=quarantine  mufg.jp          sp=none  ntt.com

外部レポート宛先(主要30、ドメインごとの宛先ホスト単位)
  28 件中 認可レコードあり 27 件、無し 1 件
  無し: kddi.com → ezweb.ne.jp(kddi.com._report._dmarc.ezweb.ne.jp が NXDOMAIN)
  宛先として多かったサービス: powerdmarc.com 4、dmarc25.jp 4、vali.email 3、proofpoint.com 2

新設タグの実例
  _dmarc.securemx.jp  v=DMARC1; p=reject; np=reject; adkim=s; aspf=s; psd=n; rua=...

読み取れることをいくつか。

  • 主要30ドメインのpはreject 17、quarantine 8、none 3、レコード無し 2(softbank.jp、ocn.ne.jp)。海外10件は10件ともreject/quarantine、日本20件はreject 10、quarantine 5、none 3、無し 2
  • 消費者向けメールサービスの参照4件はgmail.comとoutlook.comがp=none(sp=quarantine)、icloud.comがquarantine、yahoo.comがreject。7.4の「一般ユーザーのドメインはp=rejectをSHOULD NOT」と整合する運用が多い
  • 廃止されたpctを12件、riを4件が置いたまま。すべてpct=100(既定値と同じ)なので実害はないが、新仕様の受信側は読まない
  • 新設タグを既に使っている実例があった。smbc.co.jpのレポート宛先であるsecuremx.jp(IIJ)のレコードはnp=reject; psd=nを含む
  • 外部レポート宛先は28件。27件は認可レコードがあり、1件(kddi.comのrua2つ目、ezweb.ne.jp宛て)はkddi.com._report._dmarc.ezweb.ne.jpがNXDOMAINだった。受信側はこのURIを無視するので、ezweb.ne.jp側には集計レポートが届かない。同じ会社のドメイン同士でも、Tree Walkで組織ドメインが別なら認可レコードが要る

Tree Walk が効く例:rx.rakuten.co.jp と gp.biglobe.co.jp

rakuten.co.jpのruaはrx.rakuten.co.jp宛てです。_dmarc.rx.rakuten.co.jpにレコードは無く、1つ登った_dmarc.rakuten.co.jpにあるので、宛先の組織ドメインはrakuten.co.jp。レコードを公開したドメインと同じ組織なので認可レコードは不要です。

一方、biglobe.ne.jpのruaはgp.biglobe.co.jp宛て。_dmarc.gp.biglobe.co.jpと_dmarc.biglobe.co.jpの両方にレコードがあり、ラベル数が少ないbiglobe.co.jpが組織ドメインになります。biglobe.ne.jpとは別組織なので認可レコードが必要で、実際にbiglobe.ne.jp._report._dmarc.gp.biglobe.co.jpが置かれていました。会社としては同じでも、DNS上は別組織として扱われる、という例です。

rx.rakuten.co.jp の Tree Walk
  _dmarc.rx.rakuten.co.jp   NOERROR / TXT 無し
  _dmarc.rakuten.co.jp      v=DMARC1; p=reject; rua=mailto:[email protected]   ← 組織ドメイン
  (co.jp, jp は引くまでもなく確定。引けば両方 NXDOMAIN)

gp.biglobe.co.jp の Tree Walk
  _dmarc.gp.biglobe.co.jp   v=DMARC1; p=quarantine; rua=...
  _dmarc.biglobe.co.jp      v=DMARC1; p=quarantine; sp=reject; rua=...   ← ラベル数が最少 → 組織ドメイン
  _dmarc.co.jp              NXDOMAIN
  _dmarc.jp                 NXDOMAIN

認可の確認
  biglobe.ne.jp._report._dmarc.gp.biglobe.co.jp   v=DMARC1;   → 認可あり
  kddi.com._report._dmarc.ezweb.ne.jp             NXDOMAIN    → 認可なし。受信側はこの URI を無視

検証スクリプト

Python 3の標準ライブラリだけで動きます。レコードを4.7/4.8で解析し、Tree Walkで組織ドメインを求め、外部宛先の認可を確認します。--fixturesでテキストの応答を渡せばネットワークに出ません。

#!/usr/bin/env python3
"""
dmarcrec.py - DMARC ポリシーレコードを RFC 9989 / RFC 9990 どおりに読む

使い方:
    ./dmarcrec.py <ドメイン> [--fixtures <txt>] [--json]
    例: ./dmarcrec.py kddi.com

やること:
  1. _dmarc.<ドメイン> の TXT を取る。無ければ DNS Tree Walk(RFC 9989 4.10)で
     組織ドメイン/PSD のレコードを探し、sp / np / p の優先順で適用ポリシーを決める
  2. レコードを 4.7 / 4.8 の定義で解析する。v=DMARC1 が先頭でなければ無効、
     未知タグは無視、RFC 7489 にあって RFC 9989 で削除された pct / ri / rf は「無視される」と指摘
  3. rua / ruf の宛先ホストごとに Tree Walk で組織ドメインを求め、レコードの組織ドメインと
     違えば <ドメイン>._report._dmarc.<宛先ホスト> の TXT を引いて外部宛先の認可を確認する
     (RFC 9990 4 節)。認可が無い URI は受信側に無視される

Python 標準ライブラリだけで動きます。--fixtures を渡すとネットワークに出ません。
"""
import argparse
import json
import re
import sys
import urllib.parse
import urllib.request

DOH = "https://dns.google/resolve"
TYPE_TXT = 16
MAX_LABELS = 8                      # RFC 9989 4.10: Tree Walk の問い合わせ上限
POLICIES = ("none", "quarantine", "reject")
REMOVED_TAGS = {"pct": "A.6(t タグに置換)", "ri": "C.5.2", "rf": "C.5.2"}
KNOWN_TAGS = {"v", "p", "sp", "np", "adkim", "aspf", "fo", "psd", "rua", "ruf", "t"}
_FO = re.compile(r"^(?:[01](?::[ds])*|[ds](?::[01])?(?::[ds])?|(?:[ds]:)*[01])$")


# ---------------------------------------------------------------- DNS 取得
def doh_txt(name):
    """dns.google に TXT を問い合わせ、(status, ad, [txt]) を返す。TXT の引用符は外す"""
    url = DOH + "?" + urllib.parse.urlencode({"name": name, "type": TYPE_TXT})
    req = urllib.request.Request(url, headers={"Accept": "application/dns-json"})
    with urllib.request.urlopen(req, timeout=10) as r:
        j = json.load(r)
    txts = []
    for a in j.get("Answer", []):
        if a.get("type") == TYPE_TXT:
            d = a["data"]
            # dns.google は "..." "..." の形で返すことがある(255 オクテット分割)
            parts = re.findall(r'"((?:[^"\\]|\\.)*)"', d)
            txts.append("".join(parts) if parts else d)
    return j.get("Status", -1), bool(j.get("AD")), txts


class FixtureLookup:
    """'名前 | Status | AD | TXT1 || TXT2' 形式のテキストで応答を再生する"""

    def __init__(self, path):
        self.data = {}
        self.missing = []
        with open(path, encoding="utf-8") as f:
            for line in f:
                line = line.rstrip("\n")
                if not line or line.startswith("#"):
                    continue
                name, st, ad, txt = [x.strip() for x in line.split("|", 3)]
                txts = [t.strip() for t in txt.split("||") if t.strip()] if txt else []
                self.data[name.lower()] = (int(st), ad == "1", txts)

    def __call__(self, name):
        key = name.lower().rstrip(".")
        if key not in self.data:
            self.missing.append(key)
            return 3, False, []
        return self.data[key]


# ---------------------------------------------------------------- レコード解析
def is_dmarc(txt):
    """4.7 "v": 先頭のタグが v=DMARC1(大文字小文字を区別)でなければレコードではない"""
    m = re.match(r"^\s*v\s*=\s*DMARC1\s*(;|$)", txt)
    return m is not None


def parse_record(txt):
    """4.7 / 4.8 に沿って tag=value を解析。戻り値: {'tags':{}, 'findings':[(level,msg)], 'valid':bool}"""
    tags, findings = {}, []
    if not is_dmarc(txt):
        return {"tags": {}, "findings": [("ERROR", "v=DMARC1 が先頭に無い。レコード全体を無視(4.7)")], "valid": False}
    seen = set()
    for raw in txt.split(";"):
        if not raw.strip():
            continue
        if "=" not in raw:
            findings.append(("WARN", "'=' の無い要素 %r は無視" % raw.strip()))
            continue
        k, v = raw.split("=", 1)
        k, v = k.strip(), v.strip()          # タグ名は大文字小文字を区別する(RFC 6376 3.2)
        if k in seen:
            findings.append(("WARN", "タグ %s が重複。RFC 6376 3.2 ではレコード全体が無効、RFC 9989 4.8 では構文エラーは既定値で読む。ここでは最初の値を採用するが、実装で結果が分かれる" % k))
            continue
        seen.add(k)
        if k in REMOVED_TAGS:
            findings.append(("INFO", "タグ %s=%s は RFC 9989 で削除済み(%s)。受信側には未知タグとして無視される" % (k, v, REMOVED_TAGS[k])))
            tags[k] = v
            continue
        if k not in KNOWN_TAGS:
            if k.lower() in KNOWN_TAGS | set(REMOVED_TAGS):
                findings.append(("WARN", "タグ名 %s は大文字小文字を区別するので %s とは別物。未知タグとして無視される(RFC 6376 3.2)" % (k, k.lower())))
            else:
                findings.append(("INFO", "未知タグ %s は無視(4.8)" % k))
            continue
        tags[k] = v

    # 値の検証(4.8 Table 2)
    # 値も大文字小文字を区別する(RFC 6376 3.2。RFC 9989 4.7 の値はすべて小文字)
    for k in ("p", "sp", "np"):
        if k in tags and tags[k] not in POLICIES:
            hint = "(大文字小文字が違う。値は区別される)" if tags[k].lower() in POLICIES else ""
            findings.append(("ERROR", "%s=%s は none/quarantine/reject のいずれでもない%s" % (k, tags[k], hint)))
            tags[k + "_invalid"] = tags.pop(k)
    for k in ("adkim", "aspf"):
        if k in tags and tags[k] not in ("r", "s"):
            findings.append(("WARN", "%s=%s は r/s 以外。既定 r で読む" % (k, tags[k])))
            tags.pop(k)
    if "t" in tags and tags["t"] not in ("y", "n"):
        findings.append(("WARN", "t=%s は y/n 以外。既定 n で読む" % tags["t"]))
        tags.pop("t")
    if "psd" in tags and tags["psd"] not in ("y", "n", "u"):
        findings.append(("WARN", "psd=%s は y/n/u 以外。既定 u で読む" % tags["psd"]))
        tags.pop("psd")
    if "fo" in tags:
        if not _FO.match(tags["fo"]):
            findings.append(("WARN", "fo=%s は 4.8 の dmarc-fo 構文に合わない" % tags["fo"]))
        if "ruf" not in tags:
            findings.append(("INFO", "ruf が無いので fo は無視される(4.7)"))
    for k in ("rua", "ruf"):
        if k in tags:
            uris = []
            for u in tags[k].split(","):
                u = u.strip()
                if not u:
                    continue
                if "!" in u:
                    findings.append(("INFO", "%s の %s にレポートサイズ指定(obs-dmarc-uri)。RFC 9989 で廃止、無視される" % (k, u)))
                    u = u.split("!", 1)[0]
                if ":" not in u:
                    findings.append(("WARN", "%s の %r は URI ではない" % (k, u)))
                    continue
                uris.append(u)
            tags[k + "_uris"] = uris
    if "p" not in tags:
        if tags.get("rua_uris"):
            findings.append(("WARN", "p タグが無い(または無効)。rua があるので p=none として扱う(4.10.1)"))
            tags["p"] = "none"
            tags["p_assumed"] = True
        else:
            findings.append(("ERROR", "p タグが無く rua も無い。受信側は DMARC 処理をしない(4.10.1)"))
            return {"tags": tags, "findings": findings, "valid": False}
    return {"tags": tags, "findings": findings, "valid": True}


def valid_records(txts):
    """DMARC レコードとして有効な TXT だけ返す。複数あれば(4.10 手順2)全部捨てる"""
    recs = [t for t in txts if is_dmarc(t)]
    return recs if len(recs) == 1 else []


# ---------------------------------------------------------------- Tree Walk
def walk_targets(domain):
    """4.10 手順3〜8。domain 自身は含めず、次の問い合わせ先の列を返す(最大 7 個)"""
    labels = domain.lower().rstrip(".").split(".")
    x = len(labels)
    cur = labels[1:] if x < MAX_LABELS else labels[x - 7:]
    out = []
    while cur:
        out.append(".".join(cur))
        cur = cur[1:]
    return out


def tree_walk(domain, lookup):
    """domain 自身から始めて、有効なレコードが見つかった名前と psd 値を順に返す。
    psd=n / psd=y を含むレコードで停止(4.10 手順2・6)"""
    found, queries = [], []
    for t in [domain.lower().rstrip(".")] + walk_targets(domain):
        st, ad, txts = lookup("_dmarc." + t)
        queries.append("_dmarc." + t)
        recs = valid_records(txts)
        if recs:
            tags = parse_record(recs[0])["tags"]
            psd = tags.get("psd", "u")
            found.append((t, psd, recs[0], ad))
            if psd in ("n", "y"):
                break
    return found, queries


def org_domain(domain, lookup):
    """4.10.2: 組織ドメインの決定"""
    domain = domain.lower().rstrip(".")
    found, _ = tree_walk(domain, lookup)
    for t, psd, _, _ in found:                       # 長い方から
        if psd == "n":
            return t
    for t, psd, _, _ in found:
        if psd == "y" and t != domain:
            # psd=y の 1 ラベル下
            return domain[: domain.rfind("." + t)].split(".")[-1] + "." + t
    if found:
        return found[-1][0]                           # 最もラベル数が少ないもの
    return domain


# ---------------------------------------------------------------- 外部宛先の認可(RFC 9990 4)
def verify_external(domain, host, lookup):
    name = "%s._report._dmarc.%s" % (domain, host)
    if len(name) > 253:
        return name, False, "名前が DNS の上限を超える(手順4)"
    st, ad, txts = lookup(name)
    if st not in (0, 3):
        return name, False, "DNS エラー status=%d" % st
    recs = [t for t in txts if is_dmarc(t)]
    if not recs:
        return name, False, "v=DMARC1 のレコードが無い(status=%d)" % st
    # 上書き rua があれば同じホストでなければならない
    for r in recs:
        tags = parse_record(r)["tags"]
        for u in tags.get("rua_uris", []):
            m = re.match(r"^mailto:[^@]+@([^\s]+)$", u, re.I)
            if m and m.group(1).lower() != host.lower():
                return name, False, "認可レコードの rua が別ホスト %s を指す。両方とも送信禁止" % m.group(1)
    return name, True, "認可あり"


# ---------------------------------------------------------------- 判定
def check(domain, lookup=doh_txt):
    domain = domain.lower().rstrip(".")
    out = {"domain": domain, "findings": [], "record_at": None, "record": None,
           "policy": None, "effective": None, "external": []}
    st, ad, txts = lookup("_dmarc." + domain)
    if st not in (0, 3):
        raise RuntimeError("DNS 応答が異常: status=%d" % st)
    recs = [t for t in txts if is_dmarc(t)]
    if len(recs) > 1:
        out["findings"].append(("ERROR", "_dmarc.%s に DMARC レコードが %d 本。全部無視される(4.10 手順2)" % (domain, len(recs))))
    if len(recs) == 1:
        out["record_at"] = domain
        out["record"] = recs[0]
        out["dnssec_ad"] = ad
        subdomain = False
    else:
        # Tree Walk(起点は親)
        found, queries = tree_walk(domain, lookup)
        found = [f for f in found if f[0] != domain]
        out["walk"] = queries
        if not found:
            out["findings"].append(("INFO", "DMARC レコード無し。Tree Walk(%d 問い合わせ)でも見つからない。DMARC は適用されない" % len(queries)))
            out["policy"] = "none-published"
            return out
        t, psd, rec, ad = found[0]
        out["record_at"] = t
        out["record"] = rec
        out["dnssec_ad"] = ad
        subdomain = True
        out["findings"].append(("INFO", "%s 自身にレコードは無く、%s のレコードを適用(4.10.1)" % (domain, t)))

    parsed = parse_record(out["record"])
    out["findings"] += parsed["findings"]
    tags = parsed["tags"]
    out["tags"] = {k: v for k, v in tags.items() if not k.endswith("_uris")}
    if not parsed["valid"]:
        out["policy"] = "invalid"
        return out

    p = tags["p"]
    if subdomain:
        # 存在するサブドメインとして sp → p。np は非存在ドメイン用なので適用しない
        eff = tags.get("sp", p)
        out["effective"] = eff
        out["findings"].append(("INFO", "サブドメインなので適用ポリシーは sp=%s(無ければ p)" % eff))
    else:
        out["effective"] = p
    out["policy"] = p
    if tags.get("t", "n") == "y" and p != "none":
        out["findings"].append(("INFO", "t=y: テストモード。受信側は 1 段階緩いポリシーを適用する(4.7)"))
    if "np" in tags:
        out["findings"].append(("INFO", "np=%s: 存在しないサブドメイン向けポリシー(RFC 9989 新設)" % tags["np"]))
    if tags.get("psd", "u") != "u":
        out["findings"].append(("INFO", "psd=%s(RFC 9989 新設)" % tags["psd"]))
    if not tags.get("rua_uris"):
        out["findings"].append(("INFO", "rua 無し。集計レポートは生成されない(4.7)"))

    # 外部宛先の認可
    own_org = org_domain(out["record_at"], lookup)
    out["org_domain"] = own_org
    checked = set()
    for k in ("rua", "ruf"):
        for u in tags.get(k + "_uris", []):
            m = re.match(r"^mailto:[^@]+@([^\s]+)$", u, re.I)
            if not m:
                out["findings"].append(("INFO", "%s の %s は mailto 以外。受信側が対応しなければ無視" % (k, u)))
                continue
            host = m.group(1).lower()
            if host in checked:
                continue
            checked.add(host)
            host_org = org_domain(host, lookup)
            if host_org == own_org:
                out["external"].append({"tag": k, "host": host, "external": False, "host_org": host_org})
                continue
            name, ok, why = verify_external(out["record_at"], host, lookup)
            out["external"].append({"tag": k, "host": host, "external": True, "host_org": host_org,
                                    "query": name, "authorized": ok, "why": why})
            if not ok:
                out["findings"].append(("ERROR", "%s の宛先 %s は外部(組織ドメイン %s ≠ %s)だが認可レコードが無い。受信側はこの URI を無視する(RFC 9990 4): %s"
                                        % (k, host, host_org, own_org, why)))
    return out


def fmt(r):
    L = ["対象        : %s" % r["domain"]]
    if r["record"] is None:
        L.append("レコード    : なし(Tree Walk %d 回)" % len(r.get("walk", [])))
    else:
        L.append("レコード    : _dmarc.%s%s" % (r["record_at"], "(DNSSEC AD)" if r.get("dnssec_ad") else ""))
        L.append("  " + r["record"])
        L.append("ポリシー    : p=%s → 適用 %s" % (r["policy"], r["effective"]))
        if r.get("org_domain"):
            L.append("組織ドメイン: %s" % r["org_domain"])
    for e in r["external"]:
        if e["external"]:
            L.append("  %s → %s  外部(%s): %s" % (e["tag"], e["host"], e["host_org"], "認可あり" if e["authorized"] else "認可なし → 無視される"))
        else:
            L.append("  %s → %s  同一組織" % (e["tag"], e["host"]))
    for lv, msg in r["findings"]:
        L.append("  [%s] %s" % (lv, msg))
    return "\n".join(L)


def main(argv=None):
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("domain")
    ap.add_argument("--fixtures", help="テキスト形式の DNS 応答で動く(ネットワークに出ない)")
    ap.add_argument("--json", action="store_true")
    a = ap.parse_args(argv)
    lookup = FixtureLookup(a.fixtures) if a.fixtures else doh_txt
    r = check(a.domain, lookup)
    print(json.dumps(r, ensure_ascii=False, indent=2) if a.json else fmt(r))
    return 1 if any(lv == "ERROR" for lv, _ in r["findings"]) else 0


if __name__ == "__main__":
    sys.exit(main())

実行例

kddi.com。2つ目のrua宛先に認可レコードが無く、終了コードが1になります。

対象        : kddi.com
レコード    : _dmarc.kddi.com
  v=DMARC1; p=quarantine; rua=mailto:[email protected],mailto:[email protected]
ポリシー    : p=quarantine → 適用 quarantine
組織ドメイン: kddi.com
  rua → dmarc25.jp  外部(dmarc25.jp): 認可あり
  rua → ezweb.ne.jp  外部(ezweb.ne.jp): 認可なし → 無視される
  [ERROR] rua の宛先 ezweb.ne.jp は外部(組織ドメイン ezweb.ne.jp ≠ kddi.com)だが認可レコードが無い。受信側はこの URI を無視する(RFC 9990 4): v=DMARC1 のレコードが無い(status=3)
(終了コード 1)

gmail.com。組織ドメインはgmail.com、宛先google.comは外部で、認可レコードあり。

対象        : gmail.com
レコード    : _dmarc.gmail.com
  v=DMARC1; p=none; sp=quarantine; rua=mailto:[email protected]
ポリシー    : p=none → 適用 none
組織ドメイン: gmail.com
  rua → google.com  外部(google.com): 認可あり
(終了コード 0)

rx.rakuten.co.jp。自身にレコードは無く、Tree Walkでrakuten.co.jpのレコードを適用。サブドメインなのでsp(無ければp)が適用ポリシーです。

対象        : rx.rakuten.co.jp
レコード    : _dmarc.rakuten.co.jp
  v=DMARC1; p=reject; rua=mailto:[email protected]
ポリシー    : p=reject → 適用 reject
組織ドメイン: rakuten.co.jp
  rua → rx.rakuten.co.jp  同一組織
  [INFO] rx.rakuten.co.jp 自身にレコードは無く、rakuten.co.jp のレコードを適用(4.10.1)
  [INFO] サブドメインなので適用ポリシーは sp=reject(無ければ p)
(終了コード 0)

smbc.co.jp。廃止されたpctとri、rufが無いので無視されるfoを指摘しています。

対象        : smbc.co.jp
レコード    : _dmarc.smbc.co.jp(DNSSEC AD)
  v=DMARC1;p=reject;pct=100;fo=1;ri=86400;rua=mailto:[email protected]
ポリシー    : p=reject → 適用 reject
組織ドメイン: smbc.co.jp
  rua → report.securemx.jp  外部(securemx.jp): 認可あり
  [INFO] タグ pct=100 は RFC 9989 で削除済み(A.6(t タグに置換))。受信側には未知タグとして無視される
  [INFO] タグ ri=86400 は RFC 9989 で削除済み(C.5.2)。受信側には未知タグとして無視される
  [INFO] ruf が無いので fo は無視される(4.7)
(終了コード 0)

実データで踏んだ落とし穴

最初の実装で実データ36件はひととおり読めました。テストで境界を突いて直した点と、仕様を読んで気づいた注意点です。

1. タグ名は大文字小文字を区別する

最初の実装はタグ名を小文字に正規化していました。しかしRFC 9989の4.7が参照するDKIM(RFC 6376)の3.2は「タグは大文字小文字を区別して解釈しなければならない(MUST)。値も、タグの定義で明示されない限り区別する」です。つまりP=rejectは未知タグとして無視され、pが無いレコードになります。ruaがあればp=none扱い、無ければDMARC処理なし。4.7でvの値だけ「case sensitive」と書かれているのは、他が区別しないという意味ではありませんでした。正規化をやめ、大文字小文字の違いを警告するようにしました。

2. タグの重複は仕様が2つある

RFC 6376の3.2は「重複するタグ名があればタグリスト全体が無効」ですが、RFC 9989の4.8は「残りの構文エラーは既定値で読むか無視する」です。どちらが適用されるかは明文化されておらず、実装で結果が分かれ得ます。スクリプトは最初の値を採用したうえで、この曖昧さを警告に書くようにしました。重複を書かない、が唯一の安全策です。

3. 認可レコードは「存在」だけでは足りない

RFC 9990の4節では、認可レコード側のruaで宛先を上書きできます。ただし上書き先が元の宛先ホストと違えば、元のURIにも上書き先にも送ってはならない(MUST NOT)。認可レコードの存在確認だけで済ませると、この「両方とも送信禁止」のケースを認可ありと誤読します。上書き先のホストを比較する判定を入れています。実データでは上書きを使っている認可レコードはありませんでした(見つかった認可レコード35件はすべてv=DMARC1のみで、末尾の;の有無が違うだけ)。

4. 同じ会社でも DNS 上は別組織

これはバグではなく読み違いの話です。kddi.comとezweb.ne.jpは同じ会社のドメインですが、Tree Walkは会社を知りません。_dmarc.ezweb.ne.jpに独立したレコードがあるので組織ドメインはezweb.ne.jp、kddi.comとは別組織、よって認可レコードが要る。「自社のドメインだから外部ではない」という直感は、仕様上は通りません。

自分のドメインでは

ac-5.netはp=noneで、ruaはCloudflareの集計サービス(dmarc-reports.cloudflare.net)宛てです。外部宛先なので認可レコードが必要ですが、ac-5.net._report._dmarc.dmarc-reports.cloudflare.netにv=DMARC1が置かれており、認可済みでした。試しに存在しないドメイン名を頭に付けて同じ名前を引いてもv=DMARC1;が返ったので、Cloudflare側はRFC 9990の4節にある*._report._dmarc.<ホスト>のワイルドカードで「どのドメインのレポートでも受ける」と宣言しています。pctやriは使っていないので、新仕様で読んでも差分はありません。

まとめ

  • DMARCは2026年5月にRFC 9989/9990/9991へ。RFC 7489は廃止。Informationalから標準化過程に格上げ
  • タグはnp/psd/tが追加、pct/ri/rfが削除。残っていても無視されるだけだが、pct=50のような段階適用は効かない
  • 組織ドメインはPSLではなくDNS Tree Walkで決める。問い合わせは最大8回。旧実装と答えが食い違う可能性がある
  • 一般ユーザーのドメインはp=rejectをSHOULD NOT(7.4)。受信側もp=rejectだけで拒否してはならない
  • 外部レポート宛先には<ドメイン>._report._dmarc.<宛先>の認可レコードが要る。無ければ受信側に無視される
  • 主要30ドメインでreject 17/quarantine 8/none 3/無し 2。pct残存12件。認可の無い外部宛先1件
  • タグ名と値は大文字小文字を区別する(RFC 6376 3.2)

参考:RFC 9989(Domain-based Message Authentication, Reporting, and Conformance (DMARC))4.6〜4.10節・7.4節・Appendix A.6・Appendix C/RFC 9990(DMARC Aggregate Reporting)4節/RFC 9991(DMARC Failure Reporting)5節/RFC 6376(DKIM)3.2節(2026年10月12日閲覧)

コードとテスト

検証スクリプト、113件のDNS応答を収めたフィクスチャ、RFC 9989のTree Walkの例とRFC 9990の認可の例をそのまま突き合わせたテストはGitHubに置いています。python3 test_dmarcrec.py で70件のテストがオフラインで通ります。

github.com/ac5net/dmarcrec

関連記事

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