Skip to content

Commit a04f296

Browse files
authored
RFC 9991 failure reporting: Identity-Alignment, DKIM-Canonicalized-Header/-Body, ruf= verification/rate-limiting (#439)
* feat: always log StaleMARC/suppressions-DB report skips Previously every skip reason (StaleMARC, the suppressions DB, and NoReportsList) was only printed under --verbose, so a report silently skipped via StaleMARC or the suppressions table left no trace in a normal cron run. NoReportsList stays --verbose-only since it's operator-curated and the operator already knows what's in it; the other two are dynamic/external and worth surfacing by default. * feat: send loud_skip() report skips to syslog (facility mail) Checking the mail log is the natural first move when a report didn't go out, but a StaleMARC/suppressions-DB skip never reaches the local MTA, so it never had a footprint there. Open syslog at startup with facility mail and have log_skip() (replacing the inline print conditionals) notice() those skips there too, so they land in the conventional mail log (e.g. /var/log/maillog) instead of only being visible via stderr/cron-mail. NoReportsList/--skipdomains skips stay off syslog entirely, consistent with their existing --verbose-only stderr treatment. * feat: wire RFC 9990 np/testing/discovery_method through aggregate reporting Adds the three RFC 9990 policy_published fields OpenDMARC never emitted: np (non-existent-subdomain policy), testing (the RFC 9989 t= tag), and discovery_method (psl|treewalk, tracking whether a record was found via PSL-style org-domain lookup or the RFC 9989 DNS tree walk). Bumps the report namespace to urn:ietf:params:xml:ns:dmarc-2.0 and drops <pct>, which RFC 9990 removes from the schema entirely -- pct= is still honored for enforcement, unchanged, it's just no longer reported to recipients. Threaded through the full pipeline: new libopendmarc accessors (opendmarc_policy_fetch_np/_discovery_method) and discovery_method tracking in the walk-mode query functions, historyfile lines in the milter, opendmarc-import parsing into new requests.npolicy/testing/ discovery_method columns (db/update-db-schema.mysql + schema.mysql), and conditional XML output in opendmarc-reports matching the schema's minOccurs="0" on all three. Verified: full build + 12/12 libopendmarc test suite (including live-DNS discovery_method assertions against gushi.org) on quark against real libspf2, since libspf2 isn't available in the local dev sandbox. Both the full-fields and all-omitted report shapes validate against RFC 9990's own Appendix A XSD via xmllint. * feat: RFC 9991 failure reporting -- Identity-Alignment, ruf verification/rate-limiting, psd=y Closes the feasible-now portion of the RFC 9991 gap: Identity-Alignment and DKIM-Domain/-Identity/-Selector ARF header fields (RFC 9991 S4), external destination verification and rate-limiting for ruf= in opendmarc-reports --forensic (S2/S5/S8.1), and a psd=y exclusion for ruf= in the milter (S2). SPF-DNS and DKIM-Canonicalized-Header/-Body remain out of scope -- both need real library-level work (libspf2 doesn't expose the DNS record chain it consults; no DKIM verification library is linked anywhere in this codebase), not just wiring, and are left as follow-ups. Also fixes a regression found while working on this: PR #392's rua= external-destination verification (_report._dmarc DNS lookup) was silently wiped by PR #394's commit ~5 hours later (built from a pre-#392 checkout, squash-committed over it) despite CHANGES still documenting it as shipped. Restored, adapted to the six commits' worth of unrelated changes that have landed in that code since, and generalized to cover ruf= per RFC 9991 S5 (which reuses RFC 9990 S4's procedure verbatim). Also fixes a real bug surfaced while live-testing the restored verification against real DNS on quark: Net::DNS's query() returns undef for both NXDOMAIN and NOERROR/NODATA (a valid domain with no matching record), but the code only recognized the errorstring "NXDOMAIN" before trying the wildcard fallback -- anything else, including the literal string "NOERROR", fell into the fail-open branch meant for genuinely transient conditions (SERVFAIL, timeout). Confirmed live: a nonexistent record under isc.org (NXDOMAIN) was correctly rejected, but the same nonexistent record under Cloudflare-hosted example.net (NOERROR/NODATA) was incorrectly authorized. NOERROR now gets the same wildcard-fallback-then-reject treatment as NXDOMAIN. Verified: full build + 12/12 libopendmarc test suite on quark (real libspf2), including new opendmarc_policy_fetch_psd() coverage. Perl syntax clean on the generated opendmarc-reports. verify_external_destination() exercised directly against live DNS for same-org, NXDOMAIN, and NODATA cases. Rate-limiting and the full send path are not tested against a live DB, per the same restriction as the RFC 9990 work -- quark's only opendmarc database is live production. * docs: add DMARCbis remaining-work tracker Consolidates what's left across RFC 9989/9990/9991 into one file, since issue #371's gap analysis predates most of the work done on this branch and its sibling (feat/rfc9990-aggregate-reporting) and doesn't get rewritten as items close. Covers items issue #371 already flagged that we haven't touched (pass disposition value, policy_test_mode reason, generator/error elements, DKIM signature priority/cap, rf=/ri= cleanup), plus SPF-DNS and DKIM-Canonicalized-Header/-Body, which are finer-grained than that issue's analysis and were found while scoping the RFC 9991 work. Also records the cross-project (OpenDKIM + OpenDMARC) design worked out for DKIM-Canonicalized-Header/-Body: OpenDKIM emits the canonicalized data it already computes as extra headers, opt-in via an OpenDKIM-side config toggle so it costs nothing for deployments that don't send failure reports, and OpenDMARC strips those headers at eom before final delivery so nothing leaks to recipients. Avoids building a redundant local DKIM verifier into OpenDMARC just for this one field. * feat: RFC 9991 DKIM-Canonicalized-Header/-Body via OpenDKIM's AddCanonicalizedData Closes the last item in the RFC 9991 gap: the DKIM-Canonicalized-Header/ -Body ARF fields required on ruf= forensic reports. This codebase never verifies DKIM locally, so it has no canonicalized bytes of its own -- instead, a new opt-in ReadCanonicalizedData option reads X-DKIM-Canonicalized-Header/-Body staging headers added by an upstream OpenDKIM's new AddCanonicalizedData directive (trusteddomainproject/ OpenDKIM PR #423), correlates them to whichever DKIM signature is blamed for a DMARC failure via d=/s=, and copies the base64 payload into the real RFC 6591 fields, re-folded to this codebase's own ARF-body convention rather than passed through verbatim. The staging headers are always stripped before final delivery, whether or not ReadCanonicalizedData is set, so a signing filter with the upstream directive enabled can never leak them to a recipient. Getting this right took two passes: the first version only stripped on the single normal-completion path at the end of mlfi_eom, missing five early "ret = SMFIS_ACCEPT; goto done;" shortcuts elsewhere in the function (malformed/missing From with lenient config, ignored domains, DMARC permerror) that also deliver the message -- moved the logic into a shared dmarcf_strip_canon_headers() helper and call it from all six delivery paths. Also caught and fixed a real bug before it shipped: looping on smfi_chgheader()'s return value to know when to stop deleting repeated header instances would have been an infinite loop in production, since that call is fire-and-forget over the milter socket and its return value only reflects whether the write succeeded, not whether a header existed at that index. Fixed by counting real instances via dmarcf_findheader() first, then issuing exactly that many deletes. Also fixes an unrelated pre-existing bug found via a syntax-only compile check while working on this: opendmarc_policy_fetch_np/_psd/ _discovery_method (from the RFC 9990 work) were defined in opendmarc_policy.c but never prototyped in dmarc.h.in, silently relying on implicit-declaration behavior. New test t-verify-canondata covers the stripping behavior (deliberately without ReadCanonicalizedData set, since stripping must not depend on it). Built and ran the full miltertest suite against real libmilter/ libspf2 on quark: 9/9 pass, including all pre-existing tests (no regressions). * feat: RFC 9991 SPF-DNS ARF field via a custom libspf2 DNS layer Closes the last item in the RFC 9991 gap: the SPF-DNS ARF field (RFC 6591), required on ruf= forensic reports whenever OpenDMARC's own SPF self-validation contributed to a DMARC failure. libspf2's public API doesn't expose the DNS records it consults during evaluation -- only a human-readable summary -- so unlike the DKIM-Canonicalized-Header/-Body field, this one needed real new code rather than wiring. New opt-in LogSPFDNS option (only meaningful alongside SPFSelfValidate, off by default so it costs nothing when not wanted). When enabled, opendmarc_spf2_alloc_ctx() builds a custom SPF_dns_server_t "DNS layer" (libopendmarc/opendmarc_spf_dns_log.c) and splices it into libspf2's own pluggable resolver chain, above the caching layer so every SPF-record lookup a self-validated message makes is seen regardless of cache hits, including domains reached via include:/redirect=. The layer passes every query through to the real resolver unchanged -- evaluation behavior is never altered -- and for TXT/SPF-type queries whose content is itself an SPF policy record (starts "v=spf1", or any content for the historic RR type 99), appends an "SPF-DNS: txt|spf : domain : \"content\"" line, with '"'/'\' backslash-escaped per RFC 5322 quoted-string rules and embedded CR/LF dropped outright (this text is later written raw into an outgoing message piped to sendmail, so a malicious DNS record must not be able to inject header lines into the report). opendmarc_spf2_test()/opendmarc_spf_test() both gain a want_dns_log parameter and an optional out-param for the accumulated lines, threaded through a real architectural wrinkle: opendmarc_spf2_test() frees its whole SPF_CTX_T (including the DNS layer chain) before returning to its caller, so the accumulator has to be extracted before that free, not read back afterward. The fallback (non-libspf2) evaluator gets the same signature for call-site compatibility but doesn't populate the lines -- it isn't the path linked in production, so real wiring there (it already tracks queried domains/records internally) is left as a follow-up rather than done speculatively. Also includes a real, independent correctness fix found while testing this: mlfi_eom()'s job-ID lookup carried a comment describing itself as a retry "in case it came down later than expected (e.g. postfix)", but no earlier attempt actually existed anywhere in the code for it to be retrying. Added one in mlfi_envfrom(), matching the documented intent. New t-verify-self-spf-report test exercises this against isc.org's real DNS -- deliberately chosen over the existing t-verify-self-spf test's trusteddomain.org because isc.org's SPF record has several include: mechanisms, so the test covers real chain-following, not just a single-record lookup. Verified live on quark against real libmilter and libspf2: 11/11 tests pass, and the captured report's SPF-DNS lines were manually checked against isc.org's actual published SPF record. * fix: config directive allowlist gaps, test macro-ordering bug, configure.ac footgun Three unrelated bugs found and fixed while building/testing the SPF-DNS work, all discovered because it was the first time this cycle a new directive/build config actually got exercised for real rather than just compiled: opendmarc-config.h's dmarcf_config[] table -- a separate, hand-maintained allowlist config_check() validates every directive name against before any config_get() call ever runs -- was missing LogSPFDNS (this cycle), ReadCanonicalizedData (previous commit's DKIM-Canonicalized-Header/-Body work), and DMARCbisIgnorePct/DMARCbisWalkModeFallback (the earlier RFC 9989 t=/pct= work). Any of the four in a config file produced "configuration error: unrecognized parameter" and an immediate startup failure despite being fully wired up and documented. All four added. t-verify-authservid-jobid was failing whenever OpenDMARC actually linked a real SPF library instead of the built-in fallback evaluator. Root cause, confirmed with ktrace (byte-perfect macro delivery on the wire) and lldb (breakpoints in both OpenDMARC and, via its exported-but- internal symbols, real libmilter itself): the test sends its "i" (job ID) macro scoped to the MAIL stage, then calls mt.mailfrom() without ever calling mt.helo() first, so miltertest auto-inserts a filler HELO command at that point per its documented "fill in skipped steps" behavior -- after the macro was already sent. Real libmilter's st_helo() handler unconditionally clears any macros already stored for later protocol stages (mi_clr_macros(ctx, CI_HELO+1), a correct, by-design safeguard against stale macros surviving from a prior transaction on the same connection), which wipes the just-stored MAIL-stage macro before it's ever read back. Not a bug in miltertest, real libmilter, or OpenDMARC's C code -- purely a test-script ordering issue, and it was present identically in all eleven t-verify-*.lua tests that send the "i" macro this way; only this one noticed, since it's the only one that asserts on the delivered value rather than just the pass/fail outcome. Fixed by sequencing mt.helo() before the macro send everywhere. configure.ac: --with-spf2 (a very plausible but nonexistent flag -- SPF support is --with-spf, with --with-spf2-include/--with-spf2-lib separately selecting libspf2 over the fallback evaluator) is what actually caused the above to go unnoticed for as long as it did: it's silently ignored by autoconf's default unrecognized-option handling (a warning, not an error), so a build using it quietly falls back to the built-in evaluator instead of libspf2 with no indication anything's wrong. Added an explicit AC_ARG_WITH([spf2], ...) that fails configure immediately with a message pointing at the correct flags. Hit a real autoconf/m4 gotcha getting this right: an unquoted comma inside the AC_MSG_ERROR text was parsed by m4 as an argument separator to the enclosing AC_ARG_WITH, corrupting the generated shell code (a stray "else case e in" block and a syntax error deep in ./configure). Fixed with proper [[...]] quoting; verified by reading the generated configure script directly, not just re-running it. Verified together with the SPF-DNS commit: 11/11 tests pass on quark against real libmilter/libspf2, and both --with-spf2 (now errors) and the legitimate --with-spf --with-spf2-include=... --with-spf2-lib=... combination (still configures HAVE_SPF2_H/WITH_SPF correctly) were exercised directly.
1 parent 3968b72 commit a04f296

38 files changed

Lines changed: 1967 additions & 98 deletions

CHANGES-202605.md

Lines changed: 31 additions & 1 deletion
Large diffs are not rendered by default.

DMARCBIS-REMAINING-WORK.md

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
# DMARCbis remaining work
2+
3+
Tracks what's left across RFC 9989 (DMARC core), RFC 9990 (aggregate
4+
reporting), and RFC 9991 (failure reporting) — the documents that obsolete
5+
RFC 7489. Source of truth for the overall gap analysis is GitHub issue
6+
[#371](https://github.com/trusteddomainproject/OpenDMARC/issues/371)
7+
(`trusteddomainproject/OpenDMARC`); this file exists because that issue
8+
predates most of the work below and doesn't get rewritten as items close.
9+
Copies of the RFCs themselves are in the repo root (`rfc7489.txt`,
10+
`rfc9989.txt`, `rfc9990.txt`, `rfc9991.txt`). `DMARCBIS-WALK-NOTES.txt` and
11+
`DMARCBIS-EDITOR-EMAIL.txt` cover the DNS Tree Walk design questions
12+
specifically (all resolved) and are not duplicated here.
13+
14+
## Done
15+
16+
- **RFC 9989 DNS Tree Walk**: walk-mode selection (PSL/RFC7489/RFC9989/AUTO),
17+
configurable fallback, secondary alignment walk, `opendmarc-check`
18+
comparison tooling. All open questions in `DMARCBIS-WALK-NOTES.txt`
19+
resolved. (#430, #431, #433)
20+
- **RFC 9989 `t=`/`pct=`**: `t=` parsing, fetch accessor, and enforcement
21+
step-down (reject->quarantine->none). `pct=` deliberately kept for POLA,
22+
with `DMARCbisIgnorePct` for operators who want strict compliance. (#434)
23+
- **RFC 9990 aggregate reporting**: `np`/`testing`/`discovery_method` in
24+
`policy_published`, namespace bumped to `dmarc-2.0`, `<pct>` removed
25+
(branch `feat/rfc9990-aggregate-reporting`).
26+
- **RFC 9991 failure reporting**: `Identity-Alignment` and
27+
`DKIM-Domain`/`-Identity`/`-Selector` ARF headers, `ruf=` external
28+
destination verification + rate-limiting in `opendmarc-reports
29+
--forensic`, `psd=y` excludes `ruf=` (branch
30+
`feat/rfc9991-forensic-reporting`). Also restored PR #392's `rua=`
31+
destination verification, which had been silently lost, and fixed a
32+
NOERROR-vs-NXDOMAIN bug in it found via live testing.
33+
- **RFC 9991 `DKIM-Canonicalized-Header`/`-Body` ARF fields**: new
34+
`ReadCanonicalizedData` option reads `X-DKIM-Canonicalized-Header`/`-Body`
35+
staging headers from an upstream OpenDKIM running the new
36+
`AddCanonicalizedData` directive (`trusteddomainproject/OpenDKIM` PR
37+
#423), correlates them to whichever DKIM signature is blamed for a DMARC
38+
failure, and copies the base64 payload into the failure report,
39+
re-folded to this codebase's own ARF-body convention. Staging headers
40+
are always stripped before final delivery regardless of the setting.
41+
See CHANGES-202605.md for the full writeup; cross-project design
42+
history preserved below.
43+
- **RFC 9991 `SPF-DNS` ARF field**: new opt-in `LogSPFDNS` option (only
44+
meaningful with `SPFSelfValidate`). Real new code, as anticipated below
45+
-- libspf2 has a pluggable "DNS layer" system built exactly for this
46+
kind of interception (`SPF_dns_server_t`, chainable via `layer_below`);
47+
a new logging layer (`libopendmarc/opendmarc_spf_dns_log.c`) is spliced
48+
in above libspf2's own caching layer so it sees every SPF-record lookup
49+
regardless of cache hits, including domains reached via
50+
`include:`/`redirect=`, without altering evaluation behavior. This
51+
closes RFC 9991's SPF-DNS gap entirely; both fields originally listed
52+
as blocked in this section are now done. See CHANGES-202605.md for the
53+
full writeup.
54+
55+
## Remaining
56+
57+
### RFC 9990 aggregate reporting
58+
59+
- **`pass` disposition value** (S3.1.1.9): `ActionDispositionType` now
60+
includes `pass` (message passed DMARC under an *enforcing* policy)
61+
alongside `none`/`quarantine`/`reject`. Current code only ever emits the
62+
original three. Not touched by the `np`/`testing`/`discovery_method` work.
63+
- **`policy_test_mode` reason type** (S3.1.6): a `<reason><type>` value a
64+
report record should carry when `t=y` caused a policy step-down. Directly
65+
adjacent to work already done — `t=` enforcement and `<testing>` in
66+
`policy_published` both shipped, but this per-record annotation didn't.
67+
- **`generator` element** (S3.1.1.3): identifies the report-generating
68+
software. Not implemented in `opendmarc-reports`. (Unrelated: the
69+
separate `contrib/dmarc-report-totext.pl` *consumer* tool already parses
70+
this field from other senders' reports — that's reading, not writing.)
71+
- **`error` element** (S3.1.1.3/S3.1.5): describes processing errors
72+
encountered while evaluating the DMARC Policy Record. Not implemented.
73+
- **DKIM signature priority + 100-signature cap** (S3.1.3): defines which
74+
signatures to include when a message has several (strict-aligned pass
75+
first, then relaxed, then others) and caps the list. Not implemented as
76+
specified.
77+
- **Extension mechanism** (S3.2, S5): `<extension>` at file level,
78+
namespaced elements at record level. Low priority — only matters if
79+
extensions are actually adopted by report consumers.
80+
- **`rf=`/`ri=` cleanup**: both tags were removed from the DMARC record
81+
format by RFC 9989. OpenDMARC still parses them into unused
82+
`DMARC_POLICY_T` fields (`rf`, `ri`). Minor; safe to remove.
83+
84+
### RFC 9991 failure reporting
85+
86+
Both RFC 6591 fields originally tracked here (`DKIM-Canonicalized-Header`/
87+
`-Body` and `SPF-DNS`) have shipped -- see "Done" above. Nothing remaining
88+
in this section.
89+
90+
### Bugs found along the way
91+
92+
- **`t-verify-authservid-jobid` test-ordering bug, fixed**: found while
93+
live-testing the `LogSPFDNS` work (that's what prompted actually
94+
getting a real-libspf2 build going, via `--with-spf
95+
--with-spf2-include=... --with-spf2-lib=...` -- the environment used
96+
for most of this DMARCbis work had been silently building against the
97+
built-in fallback SPF evaluator instead, an easy mistake since
98+
`--with-spf2` is a *different*, unrecognized flag that `configure`
99+
silently ignores rather than erroring on). Root-caused with `ktrace`
100+
(confirmed byte-perfect wire delivery of the macro) and `lldb`
101+
(breakpoints in both OpenDMARC and, via its exported-but-internal
102+
symbols, real libmilter itself): the test sends the `i` (job ID) macro
103+
scoped to the MAIL stage, then calls `mt.mailfrom()` without ever
104+
having called `mt.helo()` -- so miltertest auto-inserts a filler HELO
105+
command at that point, per its documented "fill in skipped steps"
106+
behavior. Real libmilter's `st_helo()` handler unconditionally clears
107+
any macros already stored for *later* protocol stages
108+
(`mi_clr_macros(ctx, CI_HELO+1)`, a correct, by-design safeguard
109+
against stale macros surviving from a prior transaction on the same
110+
connection) -- which wipes the just-stored MAIL-stage macro before it's
111+
ever read back, since it arrived earlier than the (delayed) HELO it's
112+
nominally supposed to follow. Not a bug in miltertest, real libmilter,
113+
or OpenDMARC's C code -- purely a test-script ordering issue, present
114+
identically in every test in this suite that sends the `i` macro this
115+
way (all of them), just never noticed elsewhere since no other test
116+
asserts on the delivered value. Fixed by adding an explicit
117+
`mt.helo()` call before the macro send in every affected test file
118+
(all eleven `t-verify-*.lua` files that send the `i` macro), not just
119+
the one that was actually failing.
120+
- **`--with-spf2` now a hard configure error**: the flag that caused the
121+
above (silently building against the built-in fallback SPF evaluator
122+
instead of real libspf2, since `--with-spf` is the real flag and
123+
`--with-spf2` doesn't exist) now fails `configure` immediately with a
124+
message pointing at the correct flags, instead of the default
125+
autoconf behavior of a warning + silent continue. Verified both that
126+
`--with-spf2` now errors and that the legitimate
127+
`--with-spf --with-spf2-include=... --with-spf2-lib=...` combination
128+
still configures `HAVE_SPF2_H`/`WITH_SPF` correctly.
129+
- **Four config directives silently rejected at startup**: fixed in the
130+
same session (`opendmarc-config.h`'s `dmarcf_config[]` validation table
131+
was missing `DMARCbisIgnorePct`, `DMARCbisWalkModeFallback`,
132+
`ReadCanonicalizedData`, and `LogSPFDNS`) -- see CHANGES-202605.md. Worth
133+
a standing reminder: this table is hand-maintained and separate from
134+
every `config_get()` call site, so a new directive silently fails at
135+
startup instead of a compile error until someone actually parses a
136+
config file containing it, which apparently hadn't happened for three
137+
of these four since they were introduced.
138+
139+
### Open decision, not just missing code
140+
141+
- **`!NNNk` RUA size-suffix syntax**: RFC 9990 Appendix C says this syntax
142+
is obsolete and receivers (i.e. OpenDMARC, acting as report generator)
143+
MUST ignore it. The restored PR #392 code (`check_size_restriction`)
144+
actively parses and enforces it, faithfully reproducing pre-regression
145+
behavior rather than the RFC 9990 text. Same shape of question as the
146+
`pct=` POLA decision: keep honoring it for senders who rely on it, or
147+
drop it now that it's back in front of you. Needs an explicit call, not
148+
a default.

configure.ac

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,19 @@ AC_ARG_WITH([spf],
360360
AC_DEFINE(WITH_SPF, 1, [Define to 1 if you want SPF support.]),
361361
[])
362362

363+
dnl There is no "--with-spf2" flag -- SPF support is enabled with
364+
dnl --with-spf, and libspf2 (as opposed to the built-in fallback
365+
dnl evaluator) is selected by additionally pointing at it via
366+
dnl --with-spf2-include/--with-spf2-lib below. Autoconf silently ignores
367+
dnl unrecognized --with-* options by default (a warning, not an error),
368+
dnl so this exact, easy typo has previously caused builds to silently
369+
dnl fall back to the built-in evaluator instead of libspf2. Fail loudly
370+
dnl instead.
371+
AC_ARG_WITH([spf2],
372+
AS_HELP_STRING([--with-spf2], [(invalid; see --with-spf, --with-spf2-include, --with-spf2-lib)]),
373+
[AC_MSG_ERROR([[--with-spf2 is not a valid option. Use --with-spf to enable SPF checking; use --with-spf2-include=path and --with-spf2-lib=path to select libspf2 over the built-in fallback evaluator.]])],
374+
[])
375+
363376
AC_ARG_WITH([spf2-include],
364377
AS_HELP_STRING([--with-spf2-include], [path to libspf2 includes]),
365378
SPF2_INCLUDE="$withval",

contrib/opendmarc-reports.conf.sample

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,17 @@
6060
# attachment) for later review, e.g. RFC-compliance checking.
6161
#archive-dir = /var/spool/opendmarc/sent
6262

63+
# --- External destination verification (RFC 9990 S4 / RFC 9991 S5) ---
64+
# Same file used by the milter's PublicSuffixList; used to recognize
65+
# same-organization RUA/RUF destinations without a DNS query.
66+
#public-suffix-list = /etc/opendmarc/public_suffix_list.dat
67+
68+
# --- Forensic report rate limiting (RFC 9991 S2/S8.1) ---
69+
# Uncomment to cap failure reports per destination address. Unset (no
70+
# limit) by default.
71+
#forensic-rate-limit = 10
72+
#forensic-rate-window = 3600
73+
6374
# --- Miscellaneous ---
6475
#interval = 86400
6576
#utc = 1

db/schema.mysql

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,11 @@ CREATE TABLE IF NOT EXISTS requests (
6464
aspf TINYINT NOT NULL DEFAULT '0',
6565
policy TINYINT NOT NULL DEFAULT '0',
6666
spolicy TINYINT NOT NULL DEFAULT '0',
67+
npolicy TINYINT NOT NULL DEFAULT '0',
6768
pct TINYINT NOT NULL DEFAULT '0',
6869
fo TINYINT NOT NULL DEFAULT '0',
70+
testing TINYINT NOT NULL DEFAULT '0',
71+
discovery_method TINYINT NOT NULL DEFAULT '0',
6972
locked TINYINT NOT NULL DEFAULT '0',
7073
firstseen TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
7174
lastsent TIMESTAMP NULL DEFAULT NULL,
@@ -148,5 +151,16 @@ CREATE TABLE IF NOT EXISTS suppressions (
148151
UNIQUE KEY(address)
149152
);
150153

154+
-- RFC 9991 S2/S8.1: tracks outgoing failure (ruf=) report sends per
155+
-- recipient address, so opendmarc-reports --forensic can rate-limit them.
156+
CREATE TABLE IF NOT EXISTS forensic_sent (
157+
id INT NOT NULL AUTO_INCREMENT,
158+
address VARCHAR(255) NOT NULL,
159+
sent_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
160+
161+
PRIMARY KEY(id),
162+
KEY(address, sent_at)
163+
);
164+
151165
-- CREATE USER 'opendmarc'@'localhost' IDENTIFIED BY 'changeme';
152166
-- GRANT ALL ON opendmarc.* to 'opendmarc'@'localhost';

db/update-db-schema.mysql

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ ALTER TABLE messages MODIFY COLUMN arc_policy TINYINT UNSIGNED NOT NULL DEFAULT
3232
-- RFC 7489 §8.2: spf_scope distinguishes mfrom vs. helo SPF evaluation.
3333
ALTER TABLE messages ADD COLUMN spf_scope TINYINT NOT NULL DEFAULT '-1' AFTER spf;
3434

35+
-- RFC 9990 policy_published extensions: npolicy (non-existent-subdomain
36+
-- policy, the np= tag), testing (t=, RFC 9989 Appendix A.6), and
37+
-- discovery_method (psl|treewalk, how the record was found).
38+
ALTER TABLE requests ADD COLUMN npolicy TINYINT NOT NULL DEFAULT '0' AFTER spolicy;
39+
ALTER TABLE requests ADD COLUMN testing TINYINT NOT NULL DEFAULT '0' AFTER fo;
40+
ALTER TABLE requests ADD COLUMN discovery_method TINYINT NOT NULL DEFAULT '0' AFTER testing;
41+
3542
-- VERP bounce tracking: suppressions table for persistent report address suppression.
3643
CREATE TABLE IF NOT EXISTS suppressions (
3744
id INT NOT NULL AUTO_INCREMENT,
@@ -42,3 +49,14 @@ CREATE TABLE IF NOT EXISTS suppressions (
4249
PRIMARY KEY(id),
4350
UNIQUE KEY(address)
4451
);
52+
53+
-- RFC 9991 S2/S8.1: tracks outgoing failure (ruf=) report sends per
54+
-- recipient address, so opendmarc-reports --forensic can rate-limit them.
55+
CREATE TABLE IF NOT EXISTS forensic_sent (
56+
id INT NOT NULL AUTO_INCREMENT,
57+
address VARCHAR(255) NOT NULL,
58+
sent_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
59+
60+
PRIMARY KEY(id),
61+
KEY(address, sent_at)
62+
);

libopendmarc/Makefile.am

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ libopendmarc_la_SOURCES = dmarc.h \
1313
opendmarc_strl.h \
1414
opendmarc_spf.c \
1515
opendmarc_spf_dns.c \
16+
opendmarc_spf_dns_log.c \
1617
opendmarc_internal.h
1718
libopendmarc_la_LDFLAGS = -version-info $(LIBOPENDMARC_VERSION_INFO)
1819
libopendmarc_la_LIBADD = $(LIBRESOLV)

libopendmarc/dmarc.h.in

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,16 @@ typedef struct {
125125
#define DMARC_RECORD_PSD_UNSPECIFIED (0) /* psd= absent */
126126
#define DMARC_RECORD_PSD_N (1) /* psd=n: organizational domain */
127127
#define DMARC_RECORD_PSD_Y (2) /* psd=y: public suffix domain */
128-
128+
129+
/* RFC 9990 policy_published/discovery_method: how the record was found.
130+
* UNSPECIFIED covers a direct match at _dmarc.<domain>, where no PSL
131+
* lookup or tree walk was needed at all; the RFC only defines "psl" and
132+
* "treewalk", so that case is left unreported (discovery_method is
133+
* OPTIONAL) rather than forced into one of the two. */
134+
#define OPENDMARC_DISCOVERY_UNSPECIFIED (0)
135+
#define OPENDMARC_DISCOVERY_PSL (1) /* RFC 7489 org-domain method */
136+
#define OPENDMARC_DISCOVERY_TREEWALK (2) /* RFC 9989 S 4.10 DNS Tree Walk */
137+
129138
/*
130139
* Library one time initialization.
131140
*/
@@ -164,8 +173,14 @@ OPENDMARC_STATUS_T opendmarc_policy_fetch_adkim(DMARC_POLICY_T *pctx, int *adkim
164173
OPENDMARC_STATUS_T opendmarc_policy_fetch_aspf(DMARC_POLICY_T *pctx, int *aspf);
165174
OPENDMARC_STATUS_T opendmarc_policy_fetch_p(DMARC_POLICY_T *pctx, int *p);
166175
OPENDMARC_STATUS_T opendmarc_policy_fetch_sp(DMARC_POLICY_T *pctx, int *sp);
176+
OPENDMARC_STATUS_T opendmarc_policy_fetch_np(DMARC_POLICY_T *pctx, int *np);
177+
OPENDMARC_STATUS_T opendmarc_policy_fetch_psd(DMARC_POLICY_T *pctx, int *psd);
167178
OPENDMARC_STATUS_T opendmarc_policy_fetch_t(DMARC_POLICY_T *pctx, int *t);
179+
OPENDMARC_STATUS_T opendmarc_policy_fetch_discovery_method(DMARC_POLICY_T *pctx, int *discovery_method);
168180
OPENDMARC_STATUS_T opendmarc_policy_fetch_fo(DMARC_POLICY_T *pctx, int *fo);
181+
OPENDMARC_STATUS_T opendmarc_policy_fetch_np(DMARC_POLICY_T *pctx, int *np);
182+
OPENDMARC_STATUS_T opendmarc_policy_fetch_psd(DMARC_POLICY_T *pctx, int *psd);
183+
OPENDMARC_STATUS_T opendmarc_policy_fetch_discovery_method(DMARC_POLICY_T *pctx, int *discovery_method);
169184
u_char ** opendmarc_policy_fetch_rua(DMARC_POLICY_T *pctx, u_char *list_buf, size_t size_of_buf, int constant);
170185
u_char ** opendmarc_policy_fetch_ruf(DMARC_POLICY_T *pctx, u_char *list_buf, size_t size_of_buf, int constant);
171186
OPENDMARC_STATUS_T opendmarc_policy_fetch_utilized_domain(DMARC_POLICY_T *pctx, u_char *buf, size_t buflen);
@@ -198,8 +213,8 @@ int opendmarc_policy_to_buf(DMARC_POLICY_T *pctx, char *buf, size_t buflen)
198213
/*
199214
* SPF Processing
200215
*/
201-
int opendmarc_spf_test(char *ip_address, char *mail_from_domain, char *helo_domain, char *spf_record, int soft_fail_as_pass, char *human_readable, size_t human_readable_len, int *use_mailfrom);
202-
int opendmarc_spf2_test(char *ip_address, char *mail_from_domain, char *helo_domain, char *spf_record, int softfail_okay_flag, char *human_readable, size_t human_readable_len, int *used_mfrom);
216+
int opendmarc_spf_test(char *ip_address, char *mail_from_domain, char *helo_domain, char *spf_record, int soft_fail_as_pass, char *human_readable, size_t human_readable_len, int *use_mailfrom, int want_dns_log, char **spf_dns_lines);
217+
int opendmarc_spf2_test(char *ip_address, char *mail_from_domain, char *helo_domain, char *spf_record, int softfail_okay_flag, char *human_readable, size_t human_readable_len, int *used_mfrom, int want_dns_log, char **spf_dns_lines);
203218

204219
#ifdef __cplusplus
205220
}

libopendmarc/opendmarc_internal.h

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ typedef struct dmarc_policy_t {
160160
u_char * from_domain; /* Input: From: header domain */
161161
u_char * organizational_domain;
162162
int org_domain_from_fallback; /* Non-zero if PSL was absent and label-walk was used */
163+
int discovery_method; /* RFC 9990: OPENDMARC_DISCOVERY_*, how the record was found */
163164

164165
/*
165166
* Found in the _dmarc record or supplied to us.
@@ -258,16 +259,20 @@ void opendmarc_policy_library_dns_hook(int *nscountp, struct sockaddr_in *nsaddr
258259

259260
#if HAVE_SPF2_H
260261
#include "spf.h"
261-
typedef struct spf_context_struct {
262+
#include "spf_dns_resolv.h"
263+
#include "spf_dns_cache.h"
264+
typedef struct spf_context_struct {
262265
SPF_server_t * spf_server;
263266
SPF_request_t * spf_request;
264267
SPF_response_t * spf_response;
265268
SPF_result_t spf_result;
266269
char mailfrom_addr[512];
267270
char mailfrom_domain[256];
268271
char helo_domain[256];
272+
char * spf_dns_log; /* RFC 9991 SPF-DNS lines, owned by caller once handed over */
269273
} SPF_CTX_T;
270-
int opendmarc_spf2_test(char *ip_address, char *mail_from_domain, char *helo_domain, char *spf_record, int softfail_okay_flag, char *human_readable, size_t human_readable_len, int *used_mfrom);
274+
int opendmarc_spf2_test(char *ip_address, char *mail_from_domain, char *helo_domain, char *spf_record, int softfail_okay_flag, char *human_readable, size_t human_readable_len, int *used_mfrom, int want_dns_log, char **spf_dns_lines);
275+
SPF_dns_server_t *opendmarc_spf_dns_log_new(SPF_dns_server_t *layer_below, char **logbuf, int debug);
271276

272277
#else /* not HAVE_SPF2_H */
273278

@@ -289,7 +294,7 @@ typedef struct spf_context_struct {
289294
char exp_buf[512];
290295
int did_get_exp;
291296
} SPF_CTX_T;
292-
int opendmarc_spf_test(char *ip_address, char *mail_from_domain, char *helo_domain, char *spf_record, int softfail_okay_flag, char *human_readable, size_t human_readable_len, int *used_mfrom);
297+
int opendmarc_spf_test(char *ip_address, char *mail_from_domain, char *helo_domain, char *spf_record, int softfail_okay_flag, char *human_readable, size_t human_readable_len, int *used_mfrom, int want_dns_log, char **spf_dns_lines);
293298
char ** opendmarc_spf_dns_lookup_a(char *domain, char **ary, int *cnt);
294299
char ** opendmarc_spf_dns_lookup_mx(char *domain, char **ary, int *cnt);
295300
char ** opendmarc_spf_dns_lookup_mx_domain(char *domain, char **ary, int *cnt);

0 commit comments

Comments
 (0)