Merge commit from fork

* Evict expired dedup-cache entries on insert and hash the key (GHSA-9fw6-mwqr-p43m)

The seen-aggregate-report cache is an expiringdict.ExpiringDict, which
removes an expired entry only when that same key is read again, or drops
its oldest entry once it holds max_len (100 million) entries. A report
that is never received again is never read again, so in watch mode every
distinct aggregate report stayed in memory for the life of the process.
The key was the JSON of the sender-controlled reporter, Report-ID and
policy domain, so ten 5.6 KB attachments with a 5 MiB report_id held
64 MiB indefinitely.

SeenReportCache (parsedmarc/config.py) subclasses ExpiringDict and removes
expired entries from the head of its insertion order on every insert;
the cache's max_len is now 100,000. _aggregate_report_dedup_key() now
returns the SHA-256 hex digest of that JSON, so a key is 64 characters
whatever the report contains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep a re-added dedup-cache key at the tail so the expiry sweep is not blocked

OrderedDict refreshes a re-set key's time in place, so a key added again while still stored kept its old position at the head and stopped the insert-time sweep before the expired entries behind it. SeenReportCache now moves such a key to the tail before storing it, keeping insertion order equal to age order.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Bound failed reverse-DNS lookups to 120 seconds per aggregate report (GHSA-rwfh-ww9c-jm9m)

Every record of an aggregate report made one blocking PTR lookup of a sender-chosen source IP, with no bound on count or time; an unanswering reverse zone costs the full 8 s DNS lifetime with the default nameservers, and a weak-fallback result is never cached, so one IP repeated in every record was looked up every time. A 3,500-record report could block processing for hours.

Lookups that return no PTR now share a 120-second budget per report; once it is spent, the remaining records are enriched from the local IP database only, their results are not cached, and one warning is logged. Each distinct source IP is looked up at most once per report, and lookups that find a PTR are not charged, so large legitimate reports keep their enrichment.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Keep values PostgreSQL rejects out of its output, and let one rejected report not hold the batch (GHSA-fxr7-7h9q-v949)

An aggregate count above 2**31 - 1, an SMTP TLS sending-mta-ip or
receiving-ip that is not an IP address, and a NUL in a failure report's
sample each made the PostgreSQL save raise PostgreSQLError, which marked
the whole mailbox batch unsaved, so legitimate reports from other senders
were never archived. The parser now skips a record whose count is outside
0..MAX_MESSAGE_COUNT and rejects such an SMTP TLS session count (closing
the same rejection in Elasticsearch/OpenSearch), drops an invalid TLS IP
with a warning, and the PostgreSQL output replaces NUL with U+FFFD in
text, text array and JSONB values. Any other psycopg.DataError raises the
new PostgreSQLRejectedReport, which the CLI logs and skips for that one
report unless fail_on_output_error is set.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Check an aggregate record's count before its reverse-DNS lookup

A record whose count is outside 0..MAX_MESSAGE_COUNT is dropped, so its
source IP is no longer looked up first, which could cost up to 8 seconds
per record. A regression test mocks dnspython and asserts that the dropped
record's address is never queried.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Require TLS for the PostgreSQL output by default and add [postgresql] sslmode and sslrootcert (GHSA-xvp7-7762-gx9g)

PostgreSQLClient connected without an sslmode, so libpq used its default,
prefer: when the server, or an on-path attacker, declined TLS, it continued
over plain TCP and answered a clear-text password request. The client now
passes sslmode=require unless the new sslmode argument, an sslmode in the
connection string, or PGSSLMODE chooses a mode, and sslrootcert names the
CA certificate for verify-ca and verify-full. A warning is logged at
startup when a TCP host other than localhost or a loopback address is used
with a mode that does not verify the server certificate. A malformed
connection string is now rejected by a parse before connecting, with the
same withheld-details error. A server without TLS fails with libpq's
"server does not support SSL, but SSL was required" until sslmode is set
to disable or prefer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Withhold the hosts URL from Elasticsearch and OpenSearch client-build errors (GHSA-74xv-rmpx-2pjm)

A password with an unencoded "/", "?" or "#" in a [elasticsearch] or
[opensearch] hosts URL makes elastic_transport raise "Could not parse URL
'<url>'" and opensearch-py (via urllib.parse) raise "Port could not be cast
to integer value as '<password prefix>'". The startup wrapper repeated that
text at ERROR without --debug and chained the original, which the SIGHUP
reload's logger.exception printed again.

set_hosts() now runs through _set_search_hosts(), which replaces any
ValueError with a fixed message raised outside the except block. Other
errors in the block keep their text with URL credentials masked and are
raised from None. This completes the GHSA-hjr3-rx4c-24cv fix.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Cache the downloaded IP database in a private per-user directory and validate it (GHSA-5f56-8ph8-v9q5)

load_ip_db() cached the IPinfo Lite download at the fixed, shared
<tmp>/parsedmarc/ipinfo_lite.mmdb, where another local user could plant
a symlink to have the download overwrite a file the parsedmarc user can
write, or plant a database that was used whenever the download failed.

The cache now lives in $XDG_CACHE_HOME/parsedmarc or ~/.cache/parsedmarc
(%LOCALAPPDATA%\parsedmarc\cache on Windows), created 0700 and used only
if it is a real directory the user owns and can write that is not
group- or world-writable; otherwise a per-run mkdtemp() directory is
used. The download is written with mkstemp(), opened with maxminddb to
validate it, and moved into place with os.replace(). A cached copy is
used only if it is a regular file owned by the user.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Skip reports over the Logs Ingestion API size limit and publish the Log Analytics streams independently (GHSA-rg4h-58fm-8pc9)

The Azure Monitor ingestion SDK packs whole reports into chunks of up to
1 MiB and never splits one, so a report whose JSON is larger is sent as a
call of its own, and the Logs Ingestion API rejects any call over 1 MB
(https://learn.microsoft.com/en-us/azure/azure-monitor/fundamentals/service-limits).
The rejected upload raised before the failure and SMTP TLS streams were
sent, and the CLI held the whole batch for retry and finally Unsaved.

publish_json() now measures each report as the SDK does and leaves out
one over 1,000,000 bytes with a warning naming it, and publish_results()
publishes each stream in its own try and raises one LogAnalyticsException
naming every stream that failed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Address review: redact the PostgreSQL warning host, drop the connect error chain, and fix Log Analytics and docs wording

libpq ends a URI's userinfo at the first "@", so the rest of a password
containing "@" becomes part of the host. The PostgreSQL startup warning
now credential-masks every host-like value it logs (host list entries,
hostaddr, PGHOST, PGHOSTADDR), checks hostaddr before host as libpq
does, and the general connect error is raised from None so the driver's
unredacted text no longer reaches a logged traceback. The CLI's
output-client Raises docs now say which outputs do not chain.

publish_json() returns the number of reports uploaded, and
publish_results() logs "Successfully pushed" only for a stream that
uploaded something.

The [postgresql] URI example uses the Unix socket and explains that TCP
to localhost needs TLS on the server or sslmode = disable, the
[log_analytics] docs note the SDK's 1 MiB call packing, the
reverse-DNS-budget wording says "local IP database and reverse DNS map"
everywhere, and the GHSA-fxr7 entry no longer claims the parser rejects
every unreadable start-datetime.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Require mailsuite 2.3.7

mailsuite 2.3.7 fixes parse_email() keeping a CRLF message's carriage
returns in address header values (seanthegeek/mailsuite#74). The floor
is raised in the base dependency and the msgraph, gmail and all extras,
and the usage docs' stated requirement is updated to match.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Drop Python 3.10 and add Python 3.15 to the supported versions and CI (#953)

Python 3.10 reaches end of life in October 2026. requires-python is now
>=3.11, the unused requires_python key under [build-system] (not read by
hatchling, and its <3.15 bound read as excluding 3.15) is removed, and
Python 3.15 joins the CI matrix, classifiers, and support tables. Docs,
AGENTS.md, and comments that named 3.10 as the floor are updated.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>

* Keep aggregate identifiers and reasons within their schema (#950)

* Keep aggregate identifiers and reasons within their schema

AI assistance: OpenAI Codex.

* Expand the #948 CHANGELOG entry and tighten its tests

RFC 9990 Appendix A and RFC 7489 Appendix C define only header_from,
envelope_from and envelope_to under identifiers, and only type and
comment in a reason. Cite them in the test, check that envelope_from
and envelope_to survive next to a dropped nested unknown element, and
cover an unknown key on the legacy identities path. The CHANGELOG
entry now names the affected outputs and the compatibility change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Sean Whalen <44679+seanthegeek@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Reject reports dated in the future and bound what parse_report_file() reads (#954)

* Reject reports whose index-naming date is more than two days in the future (#947)

The date that names a report's dated Elasticsearch/OpenSearch index (an
aggregate report's begin date, a failure report's Arrival-Date, an SMTP
TLS report's start-datetime) was never compared with the present, so a
report dated 2100 named an index for 2100. Each parser now rejects such a
report with its own Invalid*Report; past dates stay unbounded.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Refuse an input larger than the report size cap before parse_report_file() reads it (#949)

The 32 MiB cap was applied only after the whole input was read. A path is
now checked with os.path.getsize(), bytes-like input with len(), and a
file object is read at most one byte past the cap. The error is the
ParserError with the message the content check already gives. Three
existing tests that fed over-cap content now see the earlier rejection.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Measure a memoryview input by its byte size

len() of a memoryview counts items, which are not bytes for every format,
so the size cap in parse_report_file() compares nbytes instead.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>

* Bound the aggregate end date and read file objects to their end

Address the Copilot review of #954. A normalized report's per-day records
name their indexes from their own interval starts, so the report's end date
is bounded like its begin date. A raw stream may return fewer bytes than
asked for before its end, so parse_report_file() now reads in a loop until
end of file or one byte past the cap.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>

* Accumulate bounded reads in one buffer

Address the second Copilot review of #954. Keeping every chunk in a list
cost one bytes object per read, about 85 bytes per byte for a stream that
returns a byte at a time (22.7 MiB for 256 KiB of input). One bytearray
keeps memory proportional to the bytes read.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>

* Skip a message with an invalid report attachment instead of aborting the mbox (#955)

* Skip a message with an invalid report attachment instead of aborting the mbox

parse_report_email() raised a bare ParserError when a report attachment was
invalid, which get_dmarc_reports_from_mbox() re-raises, so one such message
ended the whole file. Raise InvalidDMARCReport (a ParserError subclass), which
the mbox loop skips with a warning.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Skip an invalid SMTP TLS attachment in an mbox too

Address the Copilot review of #955. An invalid SMTP TLS attachment raised
InvalidSMTPTLSReport, which is not an InvalidDMARCReport, so it still
stopped the mbox. parse_report_email() now names the message's subject in
that error, and both mbox loops skip it with a warning. A message that
cannot be parsed at all still raises a plain ParserError.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>

* Address the second review: refuse an unencoded @ in a PostgreSQL URI, charge slow reverse-DNS lookups, and smaller fixes

- PostgreSQL: a connection string whose parsed host, hostaddr, port or
  dbname holds an "@" (the rest of a URI password with an unencoded "@",
  which libpq splits at the first "@") is refused with a fixed error
  before any warning is logged or any connection is attempted; _connect
  parses the string on every path. A leading "@" in a host entry (Linux
  abstract socket) is still allowed. _log_safe_host stays as defence in
  depth for the host argument and PGHOST/PGHOSTADDR.
- Log Analytics: publish_json catches AzureError, so a ServiceRequestError
  or ServiceResponseError no longer stops the remaining streams.
- MAX_MESSAGE_COUNT comment and CHANGELOG: the bound matches PostgreSQL's
  INTEGER columns and the Elasticsearch/OpenSearch integer mapping of the
  SMTP TLS session counts; the aggregate message_count is dynamically
  mapped as long.
- Reverse DNS budget: a lookup taking at least SLOW_REVERSE_DNS_SECONDS
  (1.0) is charged whether or not it returned a name; the warning now says
  "failed or were slow".
- An IPv6 zone ID in an aggregate record's source IP skips the record
  before any DNS lookup, and in a failure report's Source-IP raises
  InvalidFailureReport; one helper serves these and the SMTP TLS path.
- IP database cache: a relative candidate directory (HOME unset, no
  passwd entry) is rejected; offline / always_use_local_file creates no
  directory and no temporary directory and logs no "downloaded again".
- No startup warning for sslmode=require with an existing sslrootcert
  file, which libpq verifies as verify-ca.
- PostgreSQLRejectedReport truncates the driver text to 200 characters.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>

* Address the third review: refuse a URI with more than one @, bound total reverse-DNS time, strip IPv6 zone IDs

- postgres: a URI connection string (postgresql:// or postgres://) with
  more than one literal "@" is refused on the raw string, before any log
  line or connect, closing the abstract-socket host-list and query
  parameter bypasses of the parsed-value check. libpq decodes %40 in a
  password, host, database name and query value, so a URI never needs a
  second "@". The parsed-value check remains for keyword/value strings.
- Reverse DNS: every online lookup is charged to a new 600 s per-report
  total budget (MAX_REVERSE_DNS_SECONDS_PER_REPORT); failed or slow ones
  still also use the 120 s budget. The warning names the limit reached.
- An IPv6 zone ID in an aggregate source_ip or a failure report's
  Source-IP is now stripped with a warning instead of rejecting the
  record or report; SMTP TLS still drops its optional IP fields.
- _rejection_detail truncates the driver text before escaping it.
- Docs and CHANGELOG entries updated.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>

* Withhold everything derived from a PostgreSQL connection string from logs and errors

Fourth review of GHSA-xvp7-7762-gx9g: libpq ends a URI's userinfo at the
first "@" or "/", so postgresql://u:SECRETX/x@db.invalid/d parses to host
"u", port "SECRETX" and dbname "x@db.invalid/d". It has one "@", so the
raw-count refusal let it through, and connecting raised 'invalid integer
value "SECRETX" for connection option "port"', a password fragment in the
error and its traceback. A variant put the fragment in sslmode, which the
startup warning printed. Three rounds of shape checks each missed a shape.

A. With a connection_string, nothing parsed from it is logged or raised:
   - A connection failure raises a fixed "PostgreSQL connection failed
     (details withheld ...)" from None, with " [SQLSTATE xxxxx]" appended
     only when the driver error carries a well-formed SQLSTATE
     (^[0-9A-Z]{5}$). A string libpq cannot parse keeps its own fixed
     message, and any error from the pre-parse is replaced by it.
   - The startup warning names no host and no value from the string; the
     mode is printed only when it is one of SSL_MODES.
   With the separate host/port/user/password/database arguments, the
   driver's message (credential-masked) and the host in the warning are
   kept.

B. The early refusal stays as a convenience that names the common
   mistake, mirroring libpq's scan: a URI is refused when the text after
   "://" holds more than one "@", or an "@" with a "/" before it, and the
   parsed-value check (_has_misplaced_at) runs on URIs again as well as
   keyword/value strings. Docstrings, usage.md and the CHANGELOG now say
   the refusal is a convenience and A is what keeps the password out.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>

* Wrap every PostgreSQL connect failure and narrow the connection-string refusal

- postgres._connect(): everything from parsing the settings to holding a
  configured connection (pre-parse, ssl kwargs, connect, autocommit,
  dumper registration) is protected. A non-driver error (psycopg's
  UnicodeEncodeError carrying the whole built connection string for a
  password with a lone surrogate; on Python 3.15 the unwrapped
  UnicodeEncodeError('idna', '<host>') for an over-long host label) is
  replaced with a fixed message from None: the withheld connection
  failure message with a connection string, a new fixed "a connection
  setting could not be passed to the driver" message with the separate
  settings. KeyboardInterrupt/SystemExit still propagate.
- cli._init_output_clients(): the PostgreSQL wrapper no longer chains
  (from None), and a non-PostgreSQLError gets a fixed message.
- _has_unencoded_at() now mirrors libpq's URI scan (userinfo up to the
  first "@" or "/") instead of counting "@", so a percent-encoded
  database name (d%40x) and an "@" in a query value after a "/" (a
  certificate path) are accepted, while every password-spill shape in
  the table is still refused except two that read like legitimate
  strings.
- _has_misplaced_at() is removed: a keyword/value password is its own
  keyword, so an "@" in a kv host, hostaddr or dbname is not a password
  fragment, and libpq does not percent-decode kv values, so the encoding
  advice was wrong. The refusal message now names the database name.
- Docstrings, usage.md and the GHSA-xvp7 CHANGELOG entry updated; tests
  for real psycopg (surrogate password, long host label), fake-psycopg
  non-driver errors at connect/autocommit/dumpers for both modes, a
  KeyboardInterrupt check, the rule table (also checked against the real
  parser), legitimate strings that must connect, and the CLI wrapper.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: jwalith4418-Mj <jwalith4418@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-11-10 18:44:59 -05:00
1 parent 88899be94a
commit 02fcfd6e82
16 files changed
+4591 -178

No files matched your search

+100 -9
View File
@@ -187,6 +187,27 @@ The full set of configuration options is:
bundled IPinfo Lite MMDB (env var:
`PARSEDMARC_GENERAL_IPINFO_URL`). The pre-9.10 name `ip_db_url` is
still accepted as a deprecated alias and logs a warning.
:::{note}
The downloaded database is cached as `ipinfo_lite.mmdb` in a per-user
directory: `$XDG_CACHE_HOME/parsedmarc` (when `XDG_CACHE_HOME` is an
absolute path), otherwise `~/.cache/parsedmarc`, or
`%LOCALAPPDATA%\parsedmarc\cache` on Windows. parsedmarc creates the
directory with mode `0700` and uses it only if it is a real directory
(not a symlink) that the user can write to and, on Linux and macOS,
owns and is not writable by its group or by others; an existing
directory's permissions are never changed. Otherwise it logs a warning
and caches the download in a new private temporary directory that is
removed when parsedmarc exits, so the database is downloaded again on
every run. A download is cached only if it opens as a valid MMDB file,
and when the download fails (or `offline` or `always_use_local_files`
is set) the cached copy is used only if it is a regular file (not a
symlink) and, on Linux and macOS, owned by the user; otherwise the
bundled copy is used. Earlier releases cached the download at a shared
path in the system temporary directory
(`/tmp/parsedmarc/ipinfo_lite.mmdb` on Linux), which is no longer read;
its owner can delete it.
:::
- `ipinfo_api_token` - str: Optional [IPinfo Lite REST API] token. When
set, IP lookups hit the API first for the freshest country/ASN data
and fall back to the local MMDB on rate limit, quota exhaustion, or
@@ -210,7 +231,12 @@ The full set of configuration options is:
see [Encrypted DNS](#encrypted-dns)
- `dns_test_address` - str: A dummy address used for the DNS pre-flight
check that runs when `nameservers` is set (Default: `1.1.1.1`)
- `dns_timeout` - float: DNS timeout period in seconds (Default: `2.0`)
- `dns_timeout` - float: DNS timeout period in seconds (Default: `2.0`).
Reverse DNS lookups that fail or are slow (one second or more) are
limited to 120 seconds per aggregate report in total, and all of a
report's reverse DNS lookups, however fast, to 600 seconds; after
either limit, the report's remaining records are enriched from the
local IP database and reverse DNS map only, and a warning is logged.
- `dns_retries` - int: Number of times to retry a DNS query after a
timeout or other transient error (Default: `0`)
- `debug` - bool: Print debugging messages
@@ -221,7 +247,10 @@ The full set of configuration options is:
reports (Default: `False`)
:::{note}
This option only controls the process exit code. Retaining mailbox
This option only controls the process exit code, with one exception:
a report containing a value PostgreSQL rejects is logged and skipped
by the PostgreSQL output unless this option is set, and counts as an
output error when it is (see `postgresql` below). Retaining mailbox
messages whose reports could not be saved is automatic and happens
either way — see
[Mailbox messages are only archived once the reports are saved](#mailbox-messages-are-only-archived-once-the-reports-are-saved).
@@ -615,7 +644,7 @@ The full set of configuration options is:
|---|---|---|
| *"...needs permission to access resources in your organization that only an admin can grant"* / "Admin consent required" | A delegated auth method (`UsernamePassword`, `DeviceCode`) is authenticating with a scope (`Mail.ReadWrite` or `Mail.ReadWrite.Shared`) the tenant admin hasn't consented to yet. | Have an Entra ID admin grant consent: Azure Portal → **Enterprise Applications** → *your app* → **Permissions** → **Grant admin consent**, or `az ad app permission admin-consent --id <CLIENT_ID>`. This is separate from the `New-ApplicationAccessPolicy` step above, which only applies to app-only auth. |
| `ErrorItemNotFound: ... Default folder Root not found` | `mailsuite` can resolve the well-known folders (`Inbox`, `Archive`, `Drafts`, `Sent Items`, `Deleted Items`, `Junk Email`) even when a mailbox's folder hierarchy hasn't fully provisioned, but a **custom, non-well-known** `reports_folder` name still fails to resolve on such a mailbox. | Point `reports_folder` at (or under) one of the six well-known folder names above, or sign into the (shared) mailbox once via Outlook/OWA to force Exchange to provision it, then retry. |
| `RuntimeError: Event loop is closed` | Historical bug, fixed in `mailsuite` 2.0.2. Not reachable with the `mailsuite>=2.2.2` this project requires. | Confirm your installed `mailsuite` version is current (`pip show mailsuite`); upgrade if it's somehow pinned below 2.0.2. |
| `RuntimeError: Event loop is closed` | Historical bug, fixed in `mailsuite` 2.0.2. Not reachable with the `mailsuite>=2.3.7` this project requires. | Confirm your installed `mailsuite` version is current (`pip show mailsuite`); upgrade if it's somehow pinned below 2.0.2. |
| Invalid/rejected timestamp in the `since`/`receivedDateTime` filter | Historical bug (parsedmarc [#706](https://github.com/domainaware/parsedmarc/pull/706)/[#708](https://github.com/domainaware/parsedmarc/pull/708)): older versions appended a spurious `Z` to an already-UTC-offset ISO timestamp. Fixed since parsedmarc 9.5.1/9.5.5. | Upgrade parsedmarc if you're on a version older than 9.5.5. |
:::
- `elasticsearch`
@@ -777,8 +806,50 @@ The full set of configuration options is:
`host`, `port`, `user`, `password`, and `database` settings and do
not put the password in the connection string: a malformed
connection string can make the driver echo it back in an error
message. parsedmarc replaces such errors with a fixed message in
its log, but cannot stop the driver from producing them.
message. With a connection string, parsedmarc withholds the
driver's message for a connection failure (keeping only the
server's SQLSTATE code, when there is one) and does not name the
server in its startup warning, because either could repeat part of
a password; the separate settings give full diagnostics. An `@` or
`/` in a URI's user name or password must be percent-encoded (`@`
as `%40`, `/` as `%2F`): libpq ends the user name and password at
the first `@` or `/`, so the rest of the password would be parsed
as the host, port, database name, or a query parameter. As a
convenience, a URI that libpq would parse that way is refused with a
fixed error before anything is logged: one with an unencoded `@` in
its host list or database name, or in a query value written before
the first `/`, or with an `@` after a host whose port is not a
number. An `@` in such a place is written `%40` (an abstract socket
host as `%40sock`, a database name `d@x` as `d%40x`); an `@` in a
query value after a `/`, such as a certificate path, is accepted as
written. A keyword/value connection string is not checked: its
password is a keyword of its own, and libpq does not percent-decode
it.
- `sslmode` - str: The libpq TLS mode of the connection, one of
`disable`, `allow`, `prefer`, `require`, `verify-ca`, or
`verify-full` (Default: `require`). It applies with `host` and with
`connection_string`.
- `require` encrypts the connection but does not check the server's
identity, so a server that offers no TLS is refused, but anyone
who can impersonate the server still receives the password.
- `verify-ca` also checks that the server's certificate was issued
by the CA in `sslrootcert`, and `verify-full` also checks that it
names the host. Use `verify-full` with `sslrootcert` for any
remote server.
- `disable`, `allow`, and `prefer` can let the connection, and the
password, travel without encryption. Use them only for a server
without TLS on a trusted network. A Unix socket or the loopback
address does not need TLS.
When `sslmode` is not set, an `sslmode=` inside `connection_string`
and the `PGSSLMODE` environment variable are respected, in that
order, before the default. A warning is logged at startup when the
host is a TCP host other than `localhost` or a loopback address and
the mode is below `verify-ca`, except `require` with an existing
`sslrootcert` file, which libpq verifies as `verify-ca` does.
- `sslrootcert` - str: Path to the CA certificate file that
`verify-ca` and `verify-full` check the server's certificate
against (Optional; `~` and environment variables are expanded)
The PostgreSQL backend is an optional extra. Install it with
`pip install "parsedmarc[postgresql]"` (it pulls in `psycopg`); the
@@ -798,11 +869,13 @@ The full set of configuration options is:
```ini
[postgresql]
host = localhost
host = db.example.com
port = 5432
user = parsedmarc
password = secret
database = parsedmarc
sslmode = verify-full
sslrootcert = /etc/ssl/certs/db-ca.pem
```
Or using a DSN/URI, with the password supplied separately (for
@@ -811,9 +884,15 @@ The full set of configuration options is:
```ini
[postgresql]
connection_string = postgresql://parsedmarc@localhost/parsedmarc
connection_string = postgresql:///parsedmarc?host=/var/run/postgresql
```
That example connects over the local Unix socket, which needs no TLS. A
TCP connection to `localhost` (`postgresql://parsedmarc@localhost/parsedmarc`)
is refused under the default `sslmode = require` unless the server has
TLS enabled; for a server without TLS, set `sslmode = disable`, which
is safe only for the loopback address, as described under `sslmode`.
Saving parsed data to PostgreSQL is controlled by the `[general]`
options `save_aggregate`, `save_failure`, and `save_smtp_tls`
(`save_forensic` is still accepted as a deprecated alias for
@@ -822,6 +901,13 @@ The full set of configuration options is:
SMTP TLS reports) or no data will be written to PostgreSQL, even if
this section is configured.
A report with a value PostgreSQL rejects (the server raises a data
error, so the connection and the tables are fine) is logged and
skipped by the PostgreSQL output, and the rest of the batch is saved
and archived as usual; the other outputs still receive that report. Set
`fail_on_output_error` to treat it as an output error that stops the
run instead.
- `s3`
Requires the `s3` extra: `pip install "parsedmarc[s3]"`
@@ -978,6 +1064,9 @@ The full set of configuration options is:
- `dcr_failure_stream` - str: The stream name for the failure reports in the DCR
- `dcr_smtp_tls_stream` - str: The stream name for the SMTP TLS reports in the DCR
A report whose JSON is larger than 1,000,000 bytes, the limit of one Logs Ingestion API call, is not published to Log Analytics, and a warning naming it is logged.
The client library packs several reports into one call of up to 1 MiB (1,048,576 bytes), slightly over the API's 1 MB, so a call of several reports that are each under the limit can still be rejected; that failure is reported as an output error, and the batch is retried.
:::{note}
Information regarding the setup of the Data Collection Rule can be found [in the Azure documentation](https://learn.microsoft.com/en-us/azure/azure-monitor/logs/tutorial-logs-ingestion-portal).
:::
@@ -1176,7 +1265,9 @@ destinations accepted it would still permanently lose the data for the one
that didn't. It also applies regardless of `fail_on_output_error`, which
only controls the process exit code. Messages that could not be parsed at
all carry no report data, so they are filed under `Invalid` (or deleted per
`delete_invalid`) as usual.
`delete_invalid`) as usual. A report whose value PostgreSQL rejects is
not a failure of the destination: the PostgreSQL output logs and skips it
unless `fail_on_output_error` is set (see `postgresql` above).
A destination that is broken rather than briefly unavailable would
otherwise be retried forever, so retries are capped. Once a message's batch
@@ -1713,7 +1804,7 @@ could not be opened when parsedmarc started (a warning at startup) is
retried by the next reload even if the setting is unchanged. (One thing
an aborted reload does not put back: if it got as far as downloading a
new IP database from a changed `ipinfo_url`, that file stays in the
shared cache directory. Which database parsedmarc *uses* is unchanged,
cache directory. Which database parsedmarc *uses* is unchanged,
and the cached file is the same one the next restart would download.)
Elasticsearch and OpenSearch are the exception to "cannot be reached":
their clients connect lazily and the index migration only warns when the