Skip to content

6.0.0

Latest

Choose a tag to compare

@github-actions github-actions released this 31 Aug 04:50
237b1a7

An RFC conformance audit compared every module line-by-line against its
governing specification (SPF: RFC 7208; DMARC: RFC 9989/9990; TLSRPT:
RFC 8460; MTA-STS: RFC 8461; SMTP/MX: RFC 5321/7505/2181; DNSSEC:
RFC 4033-4035; SOA: RFC 1035/2181; BIMI: draft-brand-indicators-14) and
found 81 discrepancies, most confirmed by executing the old code. This
release fixes all of them. Many fixes change validation verdicts —
records the specs call valid are no longer rejected, and records they
call invalid are no longer accepted — hence the major version.
This release also contains everything staged for 5.18.0, which
was never released.

Breaking changes

  • MX STARTTLS/TLS testing is now opt-in: pass --check-mx-tls on the CLI or check_mx_tls=True to check_domains(), check_mx(), or get_mx_hosts(). The --skip-tls flag and skip_tls parameter are still accepted but do nothing, and passing skip_tls emits a DeprecationWarning. As a result, MX host results no longer carry the tls and starttls keys, and the CSV tls/starttls columns are empty, unless TLS testing is turned on
  • check_dnssec() performs a real chain-of-trust check anchored at the parent zone's DS record instead of verifying a zone's DNSKEY against itself. A zone with no DS at its parent (including "island of security" zones) is insecure per RFC 4033 §4.3 and returns False; a broken zone such as dnssec-failed.org returns False through any resolver, where it previously returned True through non-validating resolvers. Bogus (SERVFAIL with DS present) is now warned about distinctly from unsigned
  • Unknown and extension tags/fields now parse with a warning instead of failing validation, as each spec requires: SPF unknown modifiers (RFC 7208 §6), DMARC unknown tags (RFC 9989 §4.7), TLSRPT extension fields (RFC 8460 §3), MTA-STS extension fields and policy keys (RFC 8461 §3.2), and BIMI unknown tags (draft §4.3). An unknown field is ignored only when it fits the spec's own extension grammar; a malformed extension name or value still fails the record or policy
  • A TXT record unrelated to the record type being queried is now discarded instead of failing validation, per each spec's discard rule: TLSRPT (RFC 8460 §3.1), MTA-STS (RFC 8461 §3.1), BIMI (draft §7.2), and DMARC report authorization records (RFC 9990 §4). Each query also now returns the record carrying the version tag rather than whichever TXT record the resolver listed first. When a real record sits beside the unrelated one, the unrelated record is reported as a warning; when it is the only record present, the check reports that no record exists. DMARC authorization records are discarded silently, and a wildcard authorization record that is unrelated is still an error. Callers catching UnrelatedTXTRecordFoundAtTLSRPT or UnrelatedTXTRecordFoundAtBIMI should note that those queries now raise SMTPTLSReportingRecordNotFound and BIMIRecordNotFound instead
  • Records the old code wrongly accepted are now invalid: SPF records exceeding the 10-DNS-lookup limit through a, ptr, or macro-valued terms (RFC 7208 §4.6.4 — the limit was previously only enforced for some mechanism types), SPF include of a domain with no SPF record (permerror per RFC 7208 §5.2), MTA-STS policies missing a required key (RFC 8461 §3.2 — the check was dead code), BIMI records missing the required l= tag (draft §4.3), lowercase v=dmarc1 (RFC 9989 §4.8), and an SPF qualifier on a modifier or a value on the all mechanism (RFC 7208 §12)
  • MTA-STS and TLSRPT records are now matched case-sensitively, with no whitespace allowed around the version tag's =, because RFC 8461 §3.1 and RFC 8460 §3 spell those literals with the case-sensitive %s notation of RFC 7405. Records such as V=STSv1; id=…, v = STSv1; id=…, v=STSv1; ID=…, and v=TLSRPTv1; RUA=… parsed before and are now syntax errors. DMARC is unaffected: RFC 9989 §4.8 makes its tag names case-insensitive and allows whitespace around =, which this release starts honoring
  • The DMARC tree walk now applies RFC 9989 §4.10.2 Organizational Domain selection (psd=n wins; a psd=y record hands off to the record one label below it, warning when none is published there; otherwise the record with the fewest labels applies) instead of stopping at the closest parent record, which selected the opposite policy in the RFC's own worked example

Added

  • --check-mx-tls CLI flag and check_mx_tls API parameter (see breaking changes)
  • get_mx_record_set() in checkdmarc.utils, returning MX hosts, warnings, null MX status, and the number of MX records in the answer (the new MXRecordSet type), parsed from dnspython rdata instead of text splitting. The record count is what separates "no MX records at all" from "MX records that produced no usable host"
  • Null MX (RFC 7505) handling: a lone 0 . record yields an explicit "does not accept mail" warning distinct from having no MX records (which now notes the RFC 5321 §5.1 implicit MX rule); a null MX coexisting with other MX records is flagged as an RFC 7505 §3 violation instead of producing an empty-hostname host entry; a root (.) target with a non-zero preference, such as 10 ., is warned about as malformed instead of becoming an empty-hostname host entry
  • MX target sanity warnings: IP-address literals (RFC 5321 §5.1), hostnames failing RFC 5321 §2.3.5 label syntax, and targets that are CNAME aliases (RFC 2181 §10.3)
  • DNS over HTTPS (DoH) and DNS over TLS (DoT) support through the existing nameservers option, matching parsedmarc (parsedmarc PR #886). Each entry picks its own transport: an IP address means plain DNS on port 53 exactly as before, an https:// URL means DoH, and tls://ip[:port][#hostname] means DoT, where the port defaults to 853 and the optional #hostname names the server's TLS certificate identity, matching systemd-resolved's syntax. DoH queries go through a shared httpx client that honors the HTTP_PROXY/HTTPS_PROXY/NO_PROXY and SSL_CERT_FILE/SSL_CERT_DIR environment variables, so checks can run on networks that block outbound DNS but provide an HTTP proxy. The DNSSEC, DNSKEY, and TLSA checks use the configured transports too. The dnspython requirement is now dnspython[doh]>=2.7.0, and httpx>=0.26.0 is a new direct dependency for the shared DoH client

Changed

  • Update the GitHub Actions used by the workflows to their latest major versions: checkout v7, setup-python v7, codecov-action v7, upload-artifact v7, download-artifact v8, configure-pages v6, upload-pages-artifact v5, and deploy-pages v5. Most now run on Node.js 24 (upload-pages-artifact and codecov-action are composite actions). The deprecated codecov/test-results-action is replaced by codecov/codecov-action@v7 with report_type: test_results, which is the same upload it performed before; no other workflow behavior changes
  • Renamed identifiers whose names misdescribed what they hold, keeping the old names as deprecated aliases where they were public API:
    • checkdmarc.dnssec.check_dnssec() replaces test_dnssec(), matching every other module's check_*() entry point; test_dnssec() remains as a deprecated alias that warns
    • get_mx_hosts() takes approved_mx_hostnames, matching check_mx() and check_domains(); the old approved_hostnames keyword remains as a deprecated alias that warns
    • The CLI accepts --nameservers as an alias for -n/--nameserver, and --approved-ns/--approved-mx as clearer aliases for --ns/--mx
    • MTASTSQueryResult, MTASTSCheckResult, SMTPTLSReportingQueryResult, and SMTPTLSReportingResult replace their plural forms, matching the singular BIMI result types; the plural names remain as aliases
    • MTA_STS_TAGS and SMTP_TLS_REPORTING_TAGS replace the lowercase mta_sts_tags and smtp_rpt_tags constants, matching BIMI_TAGS; the lowercase names remain as aliases
  • The MXHost type now declares the fields MX host dicts actually carry (addresses, dnssec, tlsa, tls, starttls as optional keys, with hostname and preference required in a new MXRecord base); the declared ip_addresses field never existed in any produced data
  • Removed unused module-level copies of the DMARC grammar internals (checkdmarc.dmarc.version_tag, tag_value, and START), which duplicated the private grammar class and shadow-collided with unrelated locals
  • Many internal variables renamed so a name no longer changes type or meaning mid-function (split results, parse results, pyleri grammar results, joined display strings); no behavior changes

Removed

  • The pyopenssl dependency, as planned in 5.17.5. checkdmarc stopped importing pyOpenSSL in that release; the floor was kept for one release only so upgrades would also move any leftover pyOpenSSL to a version compatible with cryptography 50

Fixed

  • SPF: the 10-lookup and 2-void-lookup limits are enforced after every counted term rather than in some branches only; redirect is ignored when an all mechanism is present (RFC 7208 §6.1); a v=spf10-style sibling TXT record is discarded instead of hiding a valid record (§4.5); multiple all mechanisms are valid with first-match semantics (§4.6.2); terms after all are no longer processed or counted; duplicate includes count their lookups like real evaluation; the term-value charset matches the §12 ABNF (so %{ir=} and ! parse); exp-only macro letters c/r/t are rejected outside exp text (§7.2); the uppercase R transformer is accepted; exp is honored anywhere in the record and its name is matched case-insensitively after all (§4.6.1); mx CIDR suffixes are stripped before the DNS query instead of being sent as part of the name; dual-CIDR lengths and leading-zero CIDRs are validated; empty a:/mx:/ptr: domain-specs are rejected; an MX host with no A/AAAA records is a warning rather than a void DNS lookup, because §4.6.4 counts void lookups per term query, so records that used to fail the 2-void limit for that reason now pass; and a domain-spec that is not a fully qualified domain name (such as exists:localhost) is warned about, since receivers may treat it as a no-match (§4.8)
  • DMARC: the grammar matches the §4.8 ABNF (any-length tag names, full value charset — mandatory percent-encoding like %2C in report URIs now parses, and bare v=DMARC1 is valid); non-mailto report URIs are kept with a warning instead of invalidating the record (§4.7) and are validated against the RFC 3986 URI structure (scheme, authority with a real IP-literal when bracketed, path, query, and at most one fragment); rua/ruf values keep the case they were published with instead of being lowercased along with other tag values, since RFC 3986 makes only the scheme and host case-insensitive; invalid adkim/aspf values fall back to r with a warning; fo=0:1 is reported as invalid (mutually exclusive), not "redundant", and a repeated value such as fo=1:1 is invalid too; both fall back to fo=0 with a warning; the record-detection filter tolerates ABNF-legal whitespace and case around v=; an unrelated TXT record no longer causes a false "authorization record not found"; an NXDOMAIN on the courtesy apex query no longer discards an already-found record; two citations to nonexistent RFC sections corrected
  • BIMI: the organizational-domain fallback keeps the caller's selector instead of reverting to default (draft §7.2 step 6); the SVG-vs-certificate logotype hash check is tag-order-independent and hashes the raw bytes; SVG file size is measured in actual bytes instead of sys.getsizeof of a decoded string; the raw SVG bytes are handed to the XML parser instead of a lossy errors="ignore" decode, so malformed bytes fail validation rather than being silently dropped, and non-UTF-8 encodings declared in the XML declaration parse correctly; the pct warning fires only for p=quarantine per §7.1 step 9; empty lps= parses to an empty list; l=/a= URIs are validated against the bimi-uri ABNF (no raw spaces or unencoded commas, at most one # per RFC 3986); an IP-literal host is rejected in a=, whose prose requires an FQDN, but allowed in l=, which imports the URI grammar with no FQDN rule; unknown tags are ignored only when they fit the DKIM tag-value grammar the draft imports (RFC 6376 §3.2: no . or - in tag names, no control characters in values); the PEM-bytes certificate path reads the leaf certificate
  • MTA-STS: the policy fetch no longer follows redirects and requires HTTP 200 exactly (RFC 8461 §3.3); MX pattern matching is anchored with * matching a single label, so *.example.com no longer matches mail.example.com.evil.com (§4.1); a TXT record without id returns valid: False instead of crashing; duplicate fields are first-wins with a warning (§3.2); mixed LF/CRLF policies parse; mx values are validated against the ["*."] Domain ABNF with each label capped at 63 octets (RFC 1035 §2.3.4); record detection keys on v=STSv1; including the semicolon, while the grammar-legal v=STSv1 ; form is accepted with a warning that senders applying the §3.1 discard rule literally will ignore the record; a policy field value may contain colons, since §3.2 separates the name from the value at the first colon; the query raises SPFRecordFoundWhereMTASTSRecordShouldBe when an SPF record sits at the MTA-STS name; id is capped at 32 alphanumerics; max_age accepts only plain digits; a CR that is not part of a CRLF line ending (such as \r\r\n) is a policy syntax error instead of being silently stripped (§3.2); whitespace before a policy field name or between the name and the colon is a syntax error, since the §3.2 delimiter is ":" *WSP
  • TLSRPT: whitespace around commas between rua URIs is accepted per the §3 ABNF; repeated rua fields merge their destinations, since §3 supports declaring more than one rua (other repeated tags still warn with first-wins); record detection keys on v=TLSRPTv1; per §3.1, while the grammar-legal v=TLSRPTv1 ; form is accepted with a warning that senders applying the discard rule literally will ignore the record; URI validation is anchored so garbage-prefixed schemes are rejected; a bracketed https authority must be a real IPv6 address (RFC 4291) and # may appear only once, as the RFC 3986 fragment delimiter; a mailto local part follows the RFC 5322 dot-atom rules (no leading, trailing, or doubled dots); the parser returns the matching TLSRPT record instead of whichever TXT record sorted first
  • SMTP: negative STARTTLS results are cached like positive ones; the port-465 fallback failure warning names the implicit-TLS probe instead of reading as a port 25 failure
  • SOA: check_soa() walks from the domain up through each ancestor to the base domain and reports the first SOA it finds, so a delegated child zone reports its own SOA and contact rather than the base domain's (RFC 2181 §7); soa_rname_to_email() handles RFC 1035 §5.1 escapes, including an escaped backslash before a real label separator, and quotes a decoded local part that is not a plain dot-atom (RFC 5322 §3.2.4) so the returned address is always syntactically valid; escapes in the domain labels are decoded too, with each label validated on its own before joining so an escaped dot (one DNS label holding a literal dot) is an error rather than silently moving the label boundary to a different mailbox domain
  • DNSSEC: caller-supplied caches are forwarded to get_dnskey(); the dnssec result-key documentation states what the boolean actually means; a nameserver answering REFUSED, FORMERR, or the like is treated as a failed attempt and the next nameserver is tried, instead of the error being mistaken for a clean empty answer that means "no records". This applies to the chain check, get_dnskey(), and get_tlsa_records() alike: previously the first server's error ended the lookup, so a second nameserver holding the answer was never asked, and get_dnskey() cached the failure as though the zone were unsigned. A lookup that could not complete is no longer cached at all. In the chain check a SERVFAIL is still reported as bogus when the parent publishes a DS record, while a SERVFAIL on the DS query itself is reported as a check that could not complete. TLSA records whose signature does not verify are now reported as such rather than being logged as a query error; the record types consulted below a zone apex now include AAAA and TXT, so a name whose only signed record set is one of those is no longer reported as uncovered
  • An SPF a mechanism's CIDR suffix now applies to the returned addresses. A split result was reassigned over the same variable, so the length check inspected the hostname string: a:example.com/24 silently lost its suffix, a two-character hostname had its second character used as one, and a/24 failed to default to the current domain
  • The DNS_CACHE_MAX_AGE_SECONDS environment variable now actually configures the DNS cache, which was wired to the DNSSEC constant, so the documented variable had no effect and DNSSEC_CACHE_MAX_AGE_SECONDS silently controlled both caches
  • The domains field of BIMI certificate metadata now stays a list when the checked domain does not match the certificate; building the error message rebound the list to a joined string. That message now separates the domains with commas rather than periods
  • MTA-STS policy mx values are now validated against the whole value instead of any substring; an unanchored search accepted any value containing a single legal character, so mx: not a hostname! passed as an MX entry, and the "Invalid mx value" error was nearly unreachable
  • parse_mta_sts_record()'s docstring no longer claims tag values carry descriptions; MTA-STS tag values are plain strings and include_tag_descriptions currently adds none
  • An SPF exp modifier with an empty value (exp=) now raises SPFSyntaxError as intended; the check compared the value to the integer 0, which never matches a string, so the empty modifier was silently accepted
  • A BIMI record with an empty l tag no longer warns about DMARC policy requirements that only apply when a logo is published; the check compared the tag's dict to an empty string, which is always unequal. (A record with no l tag at all is now rejected outright — see the breaking changes above — where it previously crashed with a KeyError on this comparison)
  • check_bimi() no longer discards the warnings raised while locating the record — an unrelated TXT record beside it, or a record published at the root of the domain — by overwriting them with the parser's warnings. check_mta_sts() already merged both sets
  • A certificate's wordMark attribute is now labeled wordMark in BIMI certificate metadata instead of its raw dotted OID string; a trailing comma made the label table's key a one-element tuple
  • check_mta_sts() no longer passes its DNS timeout as the HTTP timeout for the policy download; the download uses DEFAULT_HTTP_TIMEOUT, as the BIMI check already did
  • DMARCRecordNotFound now calls its parent constructor, so its message is carried explicitly rather than through a CPython quirk and its data attribute exists like other DMARC errors
  • SOA range and type errors now name the field that failed (retry, expire, or minimum) instead of always naming refresh
  • The BIMI check's DMARC policy warning stated the opposite of the requirement; it now reads "The DMARC policy (p tag) must be set to quarantine or reject"
  • The certificate metadata field for the X.509 subjectAlternativeName extension was misspelled serviceAlternativeName; code reading that key from BIMI certificate results should update
  • The User-Agent header sent on HTTP requests had doubled parentheses around the OS name
  • The example authorization record in the "does not indicate that it accepts DMARC reports" error contained a stray quote and a run of spaces from a misplaced line continuation
  • SPF TXT length warnings now report sizes in bytes, matching what is measured and what RFC 7208 § 3.3 limits, instead of calling them characters
  • Cleaned up user-facing messages across the package: corrected articles ("Found an SPF record…"), IPv4/IPv6 capitalization, double spaces, missing periods, inconsistent RFC citation punctuation, the garbled DMARC fo tag redundancy warning, and the parked-domain MX warning, which now reads "MX records found on a parked domain"
  • Corrected inaccurate docstrings throughout: copy-paste artifacts ("MTA-HTS", "SIS-MTA", "Tne", the wrong TLSRPT record location), missing parameters and return keys, the stale claim that BIMI file content is not analyzed, and wrong exception cross-references
  • Fixed documentation drift: the CLI usage block in the docs was missing --retries and is now generated from the real --help output, the docs index pointed at a nonexistent CI workflow badge, and README/docs typos are corrected