Security
- The seen-aggregate-report cache no longer keeps every distinct report in memory for the life of the process (GHSA-9fw6-mwqr-p43m, affects 11.2.2 and earlier). The cache that recognizes a duplicate aggregate report is meant to forget a report after one hour, but the
expiringdictpackage it is built on removes an expired entry only when that same key is looked up again, and otherwise drops its oldest entry only once it holdsmax_lenentries, which was 100 million. A report that is never received again is never looked up again, so in watch mode every distinct aggregate report stayed in memory until the process exited. The cache key was the reporter's name, the Report-ID and the policy domain as JSON, all three chosen by the sender with no length limit below the 32 MiB decompression cap: ten emails each carrying a 5.6 KB gzip attachment with a 5 MiB<report_id>left 64 MiB held by the cache, and none of it was released two hours after the one-hour expiry. Expired entries are now removed each time a report is added to the cache, the cache holds at most 100,000 entries, and the key is a SHA-256 digest of the three fields, so its size no longer depends on the report. Compatibility: the cache is process-local and never saved, so nothing persisted changes; a report is still recognized as a duplicate for one hour. Reported by helixnebulatech. - Reverse DNS lookups are limited to 120 seconds per aggregate report when they fail or are slow, and to 600 seconds in total (GHSA-rwfh-ww9c-jm9m, affects 11.2.2 and earlier). Each
<record>of an aggregate report triggered one blocking reverse DNS (PTR) lookup of its source IP, one record after another, with no bound on the number of lookups or the time they took, and the sender chooses those IPs. An IP whose reverse zone never answers costs the whole DNS lifetime,dns_timeouttimes the number of nameservers: 2 s × 4 default nameservers = 8 s. Repeating one such IP did not help, because a result with no PTR whose AS domain is not in the reverse DNS map is deliberately kept out of the IP address cache, so the same IP was looked up again in every record. Measured against a nameserver that never answers, parsing took 2.2 s per record with one nameserver and 8.2 s per record with four, so a 1 MB report of about 3,500 records held up processing for hours, and no other message was processed meanwhile. Lookups that return no PTR, and lookups that take at least one second whether or not they return a name (a sender who controls the reverse zone could otherwise answer every query with a name just before the timeout), now share a budget of 120 seconds per report. Fast lookups that find a PTR are not counted toward it, so a large legitimate report keeps its enrichment, but every lookup counts toward a second budget of 600 seconds per report for all reverse DNS lookups, since a sender who controls the reverse zone could otherwise answer every query just under one second (5,000 distinct source IPs resolving in 100 ms each use 500 seconds). Once either budget is spent, the report's remaining records are enriched from the local IP database and reverse DNS map only, and one warning is logged: "Reverse DNS lookups for this report failed or were slow for 120 seconds; the remaining records are enriched from the local IP database and reverse DNS map only", or "Reverse DNS lookups for this report took more than 600 seconds; ..." with the same ending. Results produced after the limit are not cached, so those IPs are looked up normally in the next report. Each distinct source IP is also looked up at most once per report. Deployments withoffline = Truemake no DNS queries and were never affected. Reported by helixnebulatech. - A report value that PostgreSQL rejects no longer holds back the whole batch (GHSA-fxr7-7h9q-v949, affects the PostgreSQL output in 11.2.2 and earlier). The PostgreSQL output inserted report values into typed columns without checking them, and three values a sender controls were rejected by the server or the driver: an aggregate
<count>above 2,147,483,647 (integer out of rangein theINTEGERmessage_countcolumn), an SMTP TLSsending-mta-iporreceiving-ipthat is not an IP address (invalid input syntax for type inet), and a NUL character in a failure report's sample, for example aSubject:of=?utf-8?q?a=00b?=(PostgreSQL cannot store NUL intextorjsonb). A fourth trigger, an SMTP TLSstart-datetimePostgreSQL cannot read, has mostly been rejected by the parser since 11.2.2; the remainder (partial dates such as2016, which the parser still accepts and PostgreSQL may reject) now raisePostgreSQLRejectedReportlike any other rejected value and no longer hold back the batch. Each rejection was recorded as an output error, which marks the whole batch as not saved, so none of the batch's messages was archived, including legitimate reports from other senders. In watch mode the batch was retriedmax_unsaved_retriestimes and then moved toArchive/Unsaved; in one-shot mode (cron) the retry counter restarts with every run, so the batch stayed in the inbox and its legitimate reports were sent again to every output that does not deduplicate on every run. An aggregate record whose count is outside 0 to 2,147,483,647 is now skipped with a "Could not parse record" warning (a report with only that one record becomes invalid, as with any other bad record), and an SMTP TLS session count outside that range makes the report invalid; the bound matches PostgreSQL'sINTEGERcount columns (the Elasticsearch and OpenSearch outputs install no mapping for any count, so those clusters map each one dynamically aslong, a range the bound also keeps every count within). An SMTP TLSsending-mta-iporreceiving-ipthat is not an IP address (or carries an IPv6 zone ID) is dropped from the failure details with a warning, which also keeps it out of the Elasticsearch and OpenSearchipfields. An IPv6 zone ID (fe80::1%eth0), which theinettype cannot hold, is removed from an aggregate record'ssource_ipand a failure report'sSource-IP(everything from the first%) with a warning naming the field, and the record or report is kept with the bare address. The PostgreSQL output replaces NUL with U+FFFD, the replacement character, in every text, text array and JSONB value it writes. Any other value PostgreSQL rejects (apsycopg.DataError) now raises the newPostgreSQLRejectedReport, a subclass ofPostgreSQLErrorwhose message names the report; the CLI logs it as an error and skips that report in the PostgreSQL output only, and the batch is saved and archived as usual. Withfail_on_output_errorset it is an output error that stops the run, as before. The in-memorymax_unsaved_retriescounter in one-shot mode is unchanged: the batches it still holds back are the ones an output could not save at all, such as an unreachable server, which the documentation describes as the intended safe direction. Reported by helixnebulatech. - The PostgreSQL output no longer sends the database password in plain text when an attacker declines TLS (GHSA-xvp7-7762-gx9g, affects the PostgreSQL output in 11.2.2 and earlier).
PostgreSQLClientconnected without ansslmode, so libpq used its default,prefer: it asks the server for TLS and, when the answer is no, continues over plain TCP and answers whatever authentication request comes next. That answer arrives before any encryption, so an on-path attacker who declined TLS and then requested a clear-text password received the database password (CWE-319), and with it every stored report. The output now connects withsslmode=requireby default, so a connection without TLS fails before the user name or password is sent. The new[postgresql]sslmodeoption (and thesslmodeargument ofPostgreSQLClient) sets any libpq mode, andsslrootcertnames the CA certificate thatverify-caandverify-fullcheck the server against. Whensslmodeis not set, ansslmodeinconnection_stringor thePGSSLMODEenvironment variable is respected. A warning is logged at startup when a TCP host other thanlocalhostor a loopback address is used with a mode that does not verify the server certificate (requirewith an existingsslrootcertfile is not warned about, because libpq then verifies the certificate asverify-cadoes). With aconnection_string, nothing parsed from the string reaches the log or an error message: a connection failure is reported with a fixed message (plus the server's SQLSTATE code, when it sent one) instead of the driver's, and the startup warning names neither the server nor any other value from the string. libpq ends a URI's user name and password at the first@or/, so the rest of a password containing either becomes the host, port, database name, or a query parameter, which the warning and the driver's errors would otherwise repeat (postgresql://u:SECRET/x@db/dfailed withinvalid integer value "SECRET" for connection option "port"). The separatehost,port,user,password, anddatabasesettings keep the driver's message, with URL credentials masked. As a convenience, a URI that libpq would parse that way (an unencoded@in its host list or database name, or in a query value before the first/, or an@after a host whose port is neither empty nor a number) is refused with a fixed error before anything is logged or a connection is attempted; this recognizes the common mistake but is not what keeps the password out of the logs. Percent-encode@and/in a URI's user name or password (%40,%2F). A keyword/value connection string is not checked, because its password is a keyword of its own. Any other error while connecting, such as theUnicodeEncodeErrorpsycopg raises with the whole connection string it built when the password holds a character it cannot encode, is replaced with a fixed message as well, and the CLI no longer chains the PostgreSQL client's error to theRuntimeErrorit raises. A table creation failure is likewise reported with a fixed message (plus the SQLSTATE) instead of the server's, which can quote a setting from the string'soptions(tablespace "X" does not existfor-c default_tablespace=X), and a connection whose setup or table creation fails is closed instead of left open. Compatibility: a server that offers no TLS at all now fails with libpq's "server does not support SSL, but SSL was required". Set[postgresql] sslmode = disable(orprefer) to allow it again, recommended only for a Unix socket or the loopback address.requirestill accepts any certificate, so a remote server should usesslmode = verify-fullwithsslrootcert. Reported by helixnebulatech. - An Elasticsearch or OpenSearch password containing
/,?or#no longer appears in the startup error (GHSA-74xv-rmpx-2pjm, affects 11.2.2 and earlier; an incomplete fix for GHSA-hjr3-rx4c-24cv). 11.2.0 masked the credentials inhostsURLs logged with--debug, but not in the error raised when the client cannot be built. When the password in ahostsURL contains an unencoded/,?or#, the URL cannot be parsed, and the client library's error quotes the whole URL (Elasticsearch) or the start of the password (OpenSearch). parsedmarc repeated that text at ERROR level, without--debug, on the console, inlog_fileand in any log pipeline:Output client error: Elasticsearch: Could not parse URL 'https://dmarc:<password>@es.example:9200'andOutput client error: OpenSearch: Port could not be cast to integer value as '<start of password>'. The error was also chained to the library's, so a failed SIGHUP reload, which logs the full traceback, printed it a second time. Ahostsvalue that cannot be parsed now fails with a fixed message that names no part of it: "Elasticsearch: could not build a client from the hosts setting (details withheld because they may include credentials; URL-encode special characters in the password, or use the separate user and password settings)", and likewise for OpenSearch. Any other Elasticsearch or OpenSearch startup error keeps its text with URL credentials masked, and neither is chained to the original error any more. Reported by helixnebulatech. - The downloaded IP database is cached in a private per-user directory and checked before it is used (GHSA-5f56-8ph8-v9q5, affects 9.6.0 through 11.2.2).
load_ip_db()cached the IPinfo Lite database it downloads at a fixed path in the shared system temporary directory,/tmp/parsedmarc/ipinfo_lite.mmdbon Linux. Another local user could create/tmp/parsedmarcfirst; parsedmarc accepted the existing directory, wrote the download through a symlink planted atipinfo_lite.mmdb.tmp, and used whatever file was atipinfo_lite.mmdbwhenever the download was skipped or failed, which that user could force, with no check of its owner or content. That let them overwrite any file the parsedmarc user can write (its configuration, a Gmailtoken.json, an output file) with the download, or supply the MMDB file that decides every source IP's country and ASN and thatmaxminddbthen parses. The database is now cached in$XDG_CACHE_HOME/parsedmarcor~/.cache/parsedmarc(%LOCALAPPDATA%\parsedmarc\cacheon Windows), created with mode0700and used only if it is a real directory, not a symlink, that the user can write to and, on POSIX, owns and is not writable by its group or by others. Otherwise parsedmarc logs a warning and uses a new private temporary directory for that run, downloading the database again on every run, and never changes the permissions of a directory it did not create. The download is written to a new file created exclusively withmkstemp(), opened withmaxminddbto check that it is a valid MMDB, and only then moved into place with an atomicos.replace(), which replaces a symlink rather than following it. A cached copy is used only if it is a regular file and, on POSIX, owned by the user; otherwise the bundled copy is used. The old/tmp/parsedmarc/ipinfo_lite.mmdbis no longer read, and can be removed by hand. Only multi-user hosts whose temporary directory is shared were exposed; the Docker image and systemd units withPrivateTmp=were not. Reported by helixnebulatech. - One aggregate report too large for the Logs Ingestion API no longer stops the Log Analytics output from publishing the batch (GHSA-rg4h-58fm-8pc9, affects the Log Analytics output in 11.2.2 and earlier). The Azure Monitor ingestion SDK packs whole reports into upload calls of up to 1 MiB and never splits a report, so a report whose JSON is larger than that is sent as a call of its own, and the Logs Ingestion API rejects any call over 1 MB. An aggregate report of a few thousand records is that large, while its gzipped XML is only a few kilobytes. The rejected upload raised an error before the failure and SMTP TLS streams were sent, and parsedmarc treated it as an output error: the whole batch, legitimate reports from other senders included, was retried and finally moved to the
Unsavedfolder. A report whose JSON is over 1,000,000 bytes is now left out with a warning that names it, and the other reports are uploaded. Each of the three streams is now published even when another fails; a failed upload is still reported as an output error, naming every stream that failed. Reported by helixnebulatech.
Added
[postgresql]sslmodeandsslrootcertoptions (andPostgreSQLClientarguments) to choose the TLS mode of the PostgreSQL connection and the CA certificate that verifies the server.
Changed
- mailsuite 2.3.7 or later is now required. It fixes mailsuite's
parse_email()keeping the carriage returns of a CRLF message's address headers, which left a trailing\rin thefrom,senderanddelivered-tovalues, or added an extra entry toto,cc,reply-toandbccwhen the value ended with a comma; empty values are now treated as absent for both CRLF and LF input (seanthegeek/mailsuite#74). parsedmarc parses report emails with its own code, not mailsuite'sparse_email(). parse_report_file()now refuses an input larger than 32 MiB before reading it into memory (#949). The size cap (MAX_DECOMPRESSED_REPORT_SIZE) was applied only after the whole input had been read, so a path, abytesobject or a file object larger than the cap was held in memory first. The size of a path and the length of abytes,bytearrayormemoryviewinput are now checked up front, and a file object is read at most one byte past the cap (it is left positioned there). The input is rejected withParserErrorand the same message as before (the report is larger than 32 MiB). A valid compressed report is smaller than its decompressed form, so the one cap applies to both.- A report dated more than two days in the future is now rejected (#947). With Elasticsearch or OpenSearch output a report's own date names its dated index (
dmarc_aggregate-YYYY-MM-DD), and nothing compared that date with the present, so a report with<begin>4102444800</begin>created an index for the year 2100. An aggregate report whose begin or end date (a report split per day names an index from each day it covers), an SMTP TLS report whosestart-datetime, or a failure report whoseArrival-Dateis more than 2 days ahead of the current time (MAX_REPORT_FUTURE_DAYS) is now rejected withInvalidAggregateReport,InvalidSMTPTLSReportorInvalidFailureReport, so a mailbox message is moved to theInvalidfolder. The two days absorb a reporter's clock skew and time-zone mistakes; dates in the past are not bounded, so archives of old reports still parse. - Python 3.10 is no longer supported, and Python 3.15 is. Python 3.10 reached end of life in October 2026;
requires-pythonis now>=3.11. Python 3.15 is now tested in CI and listed as supported.
Bug fixes
- One invalid report attachment no longer stops the rest of an mbox file from being parsed.
parse_report_email()raised a bareParserErrorfor a message whose aggregate XML or SMTP TLS JSON attachment was invalid (for example an aggregate report whose date range spans more than 366 days), and the mbox loop only skippedInvalidDMARCReport, so that one message aborted the whole file. It now raisesInvalidDMARCReport, orInvalidSMTPTLSReportfor an SMTP TLS attachment (both stillParserErrorsubclasses), and the mbox loop skips both with a warning and parses the rest. A message that cannot be parsed at all still stops the file with aParserError. Mailbox connections were not affected. Reported by mjatin2007. - Unknown child elements under an aggregate record's
<identifiers>and inside a<reason>are no longer copied into the parsed report (#948)._parse_report_record()copied both mappings whole, so an element that RFC 9990 Appendix A and RFC 7489 Appendix C do not define reached the JSON, Kafka, S3 and webhook outputs with whatever structure the report gave it. Onlyheader_from,envelope_fromandenvelope_to, and a reason'stypeandcomment, are kept. Compatibility: a consumer of those outputs that read such an extra key no longer receives it. - The Elasticsearch and OpenSearch aggregate document classes declare
message_countandoriginal_timespan_secondsaslong. Both were written= Integerwithout parentheses, which assigns the field class itself and declares no field, so reading either attribute from an_AggregateReportDocreturned the class instead of the stored value. They are nowLong(), notInteger(), because that is the type the clusters already hold: parsedmarc does not install the document classes' mappings (the indexes and templates it creates declare only the date fields), so Elasticsearch and OpenSearch have always mapped both fields dynamically aslong. The documents sent are unchanged, and existing and new indexes are unaffected.