Files
parsedmarc/tests/test_postgres.py
T
02fcfd6e82 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>
2026-11-10 18:44:59 -05:00

2462 lines
107 KiB
Python

"""Tests for parsedmarc.postgres — the PostgreSQL output backend.
The pure timestamp/contact-info helpers are tested directly. The
``PostgreSQLClient`` save methods are tested with psycopg mocked at the SDK
boundary (``parsedmarc.postgres.psycopg``); the assertions check the SQL and
the bound parameters that a real PostgreSQL server would receive, plus the
real-sample round trip, so the tests fail if the dict-key mapping regresses.
"""
import contextlib
import importlib.util
import os
import shlex
import socket
import struct
import sys
import tempfile
import threading
import traceback
import unittest
import urllib.parse
from glob import glob
from types import SimpleNamespace
from typing import cast
from unittest.mock import MagicMock, patch
import parsedmarc
from parsedmarc.postgres import (
AlreadySaved,
PostgreSQLClient,
PostgreSQLError,
PostgreSQLRejectedReport,
_contact_info_to_text,
_ensure_utc_suffix,
_has_unencoded_at,
_naive_local_to_timestamptz,
_normalize_arrival_date,
_nul_safe_dumper_classes,
_register_nul_safe_dumpers,
_strip_nul,
)
OFFLINE_MODE = os.environ.get("GITHUB_ACTIONS", "false").lower() == "true"
PSYCOPG_INSTALLED = importlib.util.find_spec("psycopg") is not None
class _FakeStrDumper:
"""Stands in for a psycopg ``str`` dumper: encodes with no NUL check."""
def __init__(self, cls, context=None):
pass
def dump(self, obj):
return obj.encode("utf-8")
class _FakeStrBinaryDumper(_FakeStrDumper):
pass
class _FakeConninfoError(Exception):
"""Stands in for the ProgrammingError of a malformed connection string."""
def _fake_conninfo_to_dict(conninfo: str = "") -> dict:
"""Stands in for ``psycopg.conninfo.conninfo_to_dict`` when psycopg is
absent: parses ``key=value`` pairs (a value may be single-quoted, as
in libpq) and the host and query of a URI,
and, like libpq, raises on a word without ``=`` (quoting with that
word in the message). Like libpq, a URI's userinfo ends at the
*first* ``@``, and the authority at the first ``/`` (so
``postgresql://u:x/y@db/d`` has host ``u``, port ``x`` and dbname
``y@db/d``), so the rest of a password containing ``@`` is part of
the host, the port or the database name, as with
``psycopg.conninfo.conninfo_to_dict`` 3.3 on libpq:
``postgresql://u:s3c@pw@db.example/d`` has host ``pw@db.example``,
``postgresql://u:s3c@pw:x@db.example/d`` host ``pw`` and port
``x@db.example``, and ``postgresql://u:s3c@pw/x@db.example/d`` host
``pw`` and dbname ``x@db.example/d``. Components are percent-decoded
(``u:s3c%40pw@db.example`` has password ``s3c@pw``)."""
unquote = urllib.parse.unquote
if conninfo.startswith(("postgresql://", "postgres://")):
try:
parts = urllib.parse.urlsplit(conninfo)
except ValueError:
raise _FakeConninfoError(f'invalid URI: "{conninfo}"') from None
params = {}
authority = parts.netloc
if "@" in authority:
userinfo, _, authority = authority.partition("@")
user, sep, password = userinfo.partition(":")
if user:
params["user"] = unquote(user)
if sep:
params["password"] = unquote(password)
if parts.path[1:]:
params["dbname"] = unquote(parts.path[1:])
if authority.startswith("["):
host, _, rest = authority[1:].partition("]")
port = rest[1:] if rest.startswith(":") else ""
else:
host, _, port = authority.partition(":")
if host:
params["host"] = unquote(host)
if port:
params["port"] = unquote(port)
params.update(urllib.parse.parse_qsl(parts.query))
return params
params = {}
for word in shlex.split(conninfo):
key, sep, value = word.partition("=")
if not sep:
raise _FakeConninfoError(f'missing "=" after "{word}"')
params[key] = value
return params
# psycopg is an optional dependency and is not installed in CI (whose
# unit-test job installs the [build,all] extras, deliberately without
# postgresql). The save methods mock the connection, but the
# failure path also references ``psycopg_json.Jsonb`` at module scope, and
# connecting subclasses the dumpers of ``psycopg_string`` and parses a
# connection string with ``psycopg_conninfo``, so mock those SDK boundaries
# for the whole module when psycopg is absent.
_types_patchers = []
def setUpModule():
import parsedmarc.postgres as pg
if pg.psycopg_json is None:
_types_patchers.append(patch("parsedmarc.postgres.psycopg_json", MagicMock()))
if pg.psycopg_conninfo is None:
_types_patchers.append(
patch(
"parsedmarc.postgres.psycopg_conninfo",
SimpleNamespace(conninfo_to_dict=_fake_conninfo_to_dict),
)
)
if pg.psycopg_string is None:
_types_patchers.append(
patch(
"parsedmarc.postgres.psycopg_string",
SimpleNamespace(
StrDumperUnknown=_FakeStrDumper,
StrBinaryDumper=_FakeStrBinaryDumper,
),
)
)
for patcher in _types_patchers:
patcher.start()
def tearDownModule():
for patcher in _types_patchers:
patcher.stop()
class TestPsycopgImportFallback(unittest.TestCase):
"""The module-level psycopg import has two arms — psycopg installed
and psycopg missing — and a normal test run only ever executes the
arm that matches the environment. Re-execute the module's source
with sys.modules manipulated to force each arm, so both are
exercised (and covered) whether or not psycopg is installed."""
def _exec_fresh_module_with(self, modules):
"""Execute parsedmarc.postgres's source into a fresh, throwaway
module object under a patched sys.modules, and return the
(psycopg, psycopg_json, psycopg_string, psycopg_conninfo) it bound.
The canonical module in sys.modules is untouched, so the identity of its classes
(PostgreSQLError, AlreadySaved, ...) that other tests hold
references to is preserved."""
spec = importlib.util.find_spec("parsedmarc.postgres")
assert spec is not None and spec.loader is not None
mod = importlib.util.module_from_spec(spec)
with patch.dict(sys.modules, modules):
spec.loader.exec_module(mod)
return mod.psycopg, mod.psycopg_json, mod.psycopg_string, mod.psycopg_conninfo
def test_missing_psycopg_falls_back_to_none(self):
"""A None entry in sys.modules makes ``import psycopg`` raise
ImportError, exercising the fallback arm."""
psycopg_mod, json_mod, string_mod, conninfo_mod = self._exec_fresh_module_with(
{
"psycopg": None,
"psycopg.conninfo": None,
"psycopg.types": None,
"psycopg.types.json": None,
"psycopg.types.string": None,
}
)
self.assertIsNone(psycopg_mod)
self.assertIsNone(json_mod)
self.assertIsNone(string_mod)
self.assertIsNone(conninfo_mod)
def test_installed_psycopg_binds_module_and_type_submodules(self):
fake_json = MagicMock()
fake_string = MagicMock()
fake_types = MagicMock(json=fake_json, string=fake_string)
fake_conninfo = MagicMock()
fake_psycopg = MagicMock(types=fake_types, conninfo=fake_conninfo)
psycopg_mod, json_mod, string_mod, conninfo_mod = self._exec_fresh_module_with(
{
"psycopg": fake_psycopg,
"psycopg.conninfo": fake_conninfo,
"psycopg.types": fake_types,
"psycopg.types.json": fake_json,
"psycopg.types.string": fake_string,
}
)
self.assertIs(psycopg_mod, fake_psycopg)
self.assertIs(json_mod, fake_json)
self.assertIs(string_mod, fake_string)
self.assertIs(conninfo_mod, fake_conninfo)
class TestPostgreSQLHelpers(unittest.TestCase):
"""Unit tests for the pure helper functions in parsedmarc.postgres."""
# -- _ensure_utc_suffix --------------------------------------------------
def test_ensure_utc_suffix_none(self):
"""None passes through unchanged."""
self.assertIsNone(_ensure_utc_suffix(None))
def test_ensure_utc_suffix_empty_string(self):
"""Empty string passes through unchanged (falsy)."""
self.assertEqual(_ensure_utc_suffix(""), "")
def test_ensure_utc_suffix_naive_utc(self):
"""A naive UTC timestamp gets '+00' appended."""
self.assertEqual(
_ensure_utc_suffix("2024-01-15 10:30:00"),
"2024-01-15 10:30:00+00",
)
def test_ensure_utc_suffix_already_has_plus(self):
"""A timestamp already containing '+' is left unchanged."""
val = "2024-01-15 10:30:00+05:30"
self.assertEqual(_ensure_utc_suffix(val), val)
def test_ensure_utc_suffix_already_has_z(self):
"""A timestamp ending with 'Z' is left unchanged."""
val = "2024-01-15T10:30:00Z"
self.assertEqual(_ensure_utc_suffix(val), val)
def test_ensure_utc_suffix_negative_offset(self):
"""A timestamp with a negative offset after position 10 is unchanged."""
val = "2024-01-15 10:30:00-05:00"
self.assertEqual(_ensure_utc_suffix(val), val)
def test_ensure_utc_suffix_iso_t_naive(self):
"""Naive ISO 8601 with T separator gets '+00'."""
self.assertEqual(
_ensure_utc_suffix("2024-01-15T10:30:00"),
"2024-01-15T10:30:00+00",
)
# -- _naive_local_to_timestamptz -----------------------------------------
def test_naive_local_to_timestamptz_none(self):
self.assertIsNone(_naive_local_to_timestamptz(None))
def test_naive_local_to_timestamptz_empty(self):
self.assertEqual(_naive_local_to_timestamptz(""), "")
def test_naive_local_to_timestamptz_valid(self):
"""A valid naive string is returned with a timezone offset."""
result = _naive_local_to_timestamptz("2024-01-15 10:30:00")
assert result is not None
self.assertIsInstance(result, str)
self.assertTrue(
"+" in result or "-" in result[10:],
f"Expected timezone offset in result: {result}",
)
from datetime import datetime as _dt
parsed = _dt.fromisoformat(result)
self.assertIsNotNone(parsed.tzinfo)
def test_naive_local_to_timestamptz_bad_format_raises(self):
"""An unparseable string raises ValueError (from strptime)."""
with self.assertRaises(ValueError):
_naive_local_to_timestamptz("not-a-date")
# -- _normalize_arrival_date ---------------------------------------------
def test_normalize_arrival_date_none(self):
self.assertIsNone(_normalize_arrival_date(None))
def test_normalize_arrival_date_empty(self):
self.assertEqual(_normalize_arrival_date(""), "")
def test_normalize_arrival_date_iso_naive_utc(self):
"""A naive ISO string (known UTC) is returned with +00 suffix."""
result = _normalize_arrival_date("2024-01-15 10:30:00")
assert result is not None
self.assertTrue(result.endswith("+00"), f"Expected +00 suffix: {result}")
def test_normalize_arrival_date_rfc2822(self):
"""An RFC 2822 date is converted to UTC with +00 suffix."""
result = _normalize_arrival_date("Fri, 28 Oct 2022 00:34:24 +0800")
assert result is not None
self.assertTrue(result.endswith("+00"), f"Expected +00 suffix: {result}")
# 00:34:24 +0800 is 16:34:24 UTC on 27 Oct 2022.
self.assertIn("2022-10-27", result)
self.assertIn("16:34:24", result)
def test_normalize_arrival_date_already_utc(self):
"""A string already ending with +00 still works."""
result = _normalize_arrival_date("2024-01-15 10:30:00+00")
assert result is not None
self.assertTrue(result.endswith("+00"), f"Expected +00 suffix: {result}")
def test_normalize_arrival_date_unparseable(self):
"""An unparseable string is returned as-is (fallback)."""
garbage = "not a date at all"
self.assertEqual(_normalize_arrival_date(garbage), garbage)
# -- _contact_info_to_text -----------------------------------------------
def test_contact_info_to_text_none(self):
self.assertIsNone(_contact_info_to_text(None))
def test_contact_info_to_text_string(self):
self.assertEqual(
_contact_info_to_text("admin@example.com"),
"admin@example.com",
)
def test_contact_info_to_text_list(self):
self.assertEqual(
_contact_info_to_text(["admin@example.com", "abuse@example.com"]),
"admin@example.com, abuse@example.com",
)
def test_contact_info_to_text_empty_list(self):
self.assertEqual(_contact_info_to_text([]), "")
def test_contact_info_to_text_numeric(self):
"""Non-string scalars are converted via str()."""
# Deliberately outside the annotated parameter types
self.assertEqual(_contact_info_to_text(123), "123") # pyright: ignore[reportArgumentType]
def _without_libpq_env():
"""Patch os.environ for the duration of a ``with`` block so that none of
libpq's ``PG*`` variables (PGSSLMODE, PGHOST, ...) from the developer's
shell reaches the code under test."""
return patch.dict(
os.environ,
{k: v for k, v in os.environ.items() if not k.startswith("PG")},
clear=True,
)
def _without_early_refusal():
"""Patch out the shape check that refuses a URI connection string
before anything is logged (``_has_unencoded_at``), so a test exercises
what keeps the password out of the logs whatever shape gets past it:
withholding every value parsed from the string."""
return patch("parsedmarc.postgres._has_unencoded_at", MagicMock(return_value=False))
@contextlib.contextmanager
def _captured_log_text():
"""Capture every parsedmarc log record, DEBUG and up, into a list of
formatted strings (unlike assertLogs, an empty capture is allowed)."""
import logging
from parsedmarc.log import logger
records: list[str] = []
class _ListHandler(logging.Handler):
def emit(self, record):
records.append(self.format(record))
handler = _ListHandler(level=logging.DEBUG)
old_level = logger.level
logger.addHandler(handler)
logger.setLevel(logging.DEBUG)
try:
yield records
finally:
logger.removeHandler(handler)
logger.setLevel(old_level)
def _make_client():
"""Create a PostgreSQLClient with a fully-mocked psycopg connection."""
with patch("parsedmarc.postgres.psycopg") as mock_psycopg:
mock_conn = MagicMock()
mock_psycopg.connect.return_value = mock_conn
mock_psycopg.Error = Exception
client = PostgreSQLClient(
host="localhost", database="test", user="test", password="test"
)
mock_conn.closed = False
client._conn = mock_conn
return client, mock_conn
def _mock_cursor(mock_conn, fetchone_results):
"""Wire up a mock cursor whose fetchone() yields *fetchone_results*."""
mock_cursor = MagicMock()
mock_cursor.fetchone.side_effect = list(fetchone_results)
mock_cursor.__enter__ = MagicMock(return_value=mock_cursor)
mock_cursor.__exit__ = MagicMock(return_value=False)
mock_conn.cursor.return_value = mock_cursor
mock_conn.transaction.return_value.__enter__ = MagicMock()
mock_conn.transaction.return_value.__exit__ = MagicMock(return_value=False)
return mock_cursor
def _executed_sql(mock_cursor):
"""Return the list of SQL strings passed to cursor.execute()."""
return [c.args[0] for c in mock_cursor.execute.call_args_list]
def _named_params(call):
"""Map an INSERT's column names to the bound parameter values.
Lets tests assert by column name instead of fragile positional indices.
"""
import re
sql = call.args[0]
m = re.search(r"\(([^)]*?)\)\s*VALUES", sql, re.S)
assert m is not None
cols = [c.strip() for c in m.group(1).split(",") if c.strip()]
return dict(zip(cols, call.args[1]))
class TestPostgreSQLConstruction(unittest.TestCase):
"""Construction-time behaviour, including the optional-dependency guard."""
def test_missing_psycopg_raises_install_hint(self):
"""Without psycopg installed, construction fails with an install hint."""
with patch("parsedmarc.postgres.psycopg", None):
with self.assertRaises(PostgreSQLError) as ctx:
PostgreSQLClient(host="localhost")
self.assertIn("pip install parsedmarc[postgresql]", str(ctx.exception))
def test_close_closes_open_connection(self):
"""close() closes a live connection and is a no-op once closed."""
client, mock_conn = _make_client()
mock_conn.closed = False
client.close()
mock_conn.close.assert_called_once()
mock_conn.close.reset_mock()
mock_conn.closed = True
client.close()
mock_conn.close.assert_not_called()
def test_ensure_connected_reconnects_on_closed(self):
"""_ensure_connected reconnects when the connection is closed."""
client, mock_conn = _make_client()
mock_conn.closed = True
with patch.object(client, "_connect") as mock_reconnect:
client._ensure_connected()
mock_reconnect.assert_called_once()
def test_connect_uses_connection_string_when_provided(self):
"""A DSN/URI is passed straight to psycopg.connect, with the default
sslmode as a keyword argument because the string sets none."""
with (
patch("parsedmarc.postgres.psycopg") as mock_psycopg,
_without_libpq_env(),
):
mock_psycopg.Error = Exception
PostgreSQLClient(connection_string="postgresql://u:p@h/db")
mock_psycopg.connect.assert_called_once_with(
"postgresql://u:p@h/db", sslmode="require"
)
def test_connect_failure_raises_postgresql_error(self):
"""A driver-level connection error is wrapped in PostgreSQLError."""
with patch("parsedmarc.postgres.psycopg") as mock_psycopg:
mock_psycopg.Error = Exception
mock_psycopg.ProgrammingError = type("ProgrammingError", (Exception,), {})
mock_psycopg.connect.side_effect = mock_psycopg.Error("refused")
with self.assertRaises(PostgreSQLError) as ctx:
PostgreSQLClient(host="localhost")
self.assertIn("refused", str(ctx.exception))
def test_connect_failure_detail_is_url_redacted(self):
"""With the separate arguments, a driver error keeps its detail,
with URL credentials masked."""
programming_error = type("ProgrammingError", (Exception,), {})
with patch("parsedmarc.postgres.psycopg") as mock_psycopg:
mock_psycopg.Error = Exception
mock_psycopg.ProgrammingError = programming_error
mock_psycopg.connect.side_effect = mock_psycopg.Error(
'connection to "postgresql://u:PGSECRET@db.example/x" failed'
)
with self.assertRaises(PostgreSQLError) as ctx:
PostgreSQLClient(host="h", user="u", password="PGSECRET")
message = str(ctx.exception)
self.assertNotIn("PGSECRET", message)
self.assertIn('connection to "postgresql://u:***@db.example/x" failed', message)
def test_connect_failure_is_not_chained_to_the_driver_error(self):
"""The driver names the host when it cannot connect, and a host
with an "@" can hold part of a password (a URI connection string
with one is refused before connecting; the *host* argument is
passed on). The replacement message is credential-masked, and it is
raised from None: chaining printed the driver's own text, unmasked,
wherever the error was logged with its traceback (the SIGHUP reload
path's logger.exception)."""
operational_error = type("OperationalError", (Exception,), {})
with (
patch("parsedmarc.postgres.psycopg") as mock_psycopg,
_without_libpq_env(),
):
mock_psycopg.Error = Exception
mock_psycopg.ProgrammingError = type("ProgrammingError", (Exception,), {})
mock_psycopg.connect.side_effect = operational_error(
"failed to resolve host 'pw@db.example': [Errno -2] Name or "
"service not known"
)
with self.assertRaises(PostgreSQLError) as ctx:
PostgreSQLClient(
host="pw@db.example", password="s3c", sslmode="verify-full"
)
exc = ctx.exception
rendered = "".join(traceback.format_exception(exc))
for secret in ("s3c", "pw@"):
self.assertNotIn(secret, str(exc))
self.assertNotIn(secret, rendered)
self.assertIn("***@db.example", str(exc))
self.assertIsNone(exc.__cause__)
self.assertTrue(exc.__suppress_context__)
# The real host and password still reach the driver.
self.assertEqual(mock_psycopg.connect.call_args.kwargs["host"], "pw@db.example")
self.assertEqual(mock_psycopg.connect.call_args.kwargs["password"], "s3c")
def test_malformed_connection_string_detail_withheld(self):
"""A ProgrammingError from parsing the connection string (which libpq
words with the whole string or a password fragment) is replaced with
a fixed message that still identifies the problem. The string is
parsed before connecting too; that parse is stubbed to succeed here
so the error surfaces from psycopg.connect, the guard under test
(the pre-parse is tested in TestPostgreSQLTLS)."""
programming_error = type("ProgrammingError", (Exception,), {})
for dsn, driver_message in (
(
"postgresql://u:PGSECRET@[::1/db",
'end of string reached when looking for matching "]" in IPv6 '
'host address in URI: "postgresql://u:PGSECRET@[::1/db"',
),
(
"postgresql://u:PG%ZZSECRET@h/db",
'invalid percent-encoded token: "PG%ZZSECRET"',
),
):
with self.subTest(dsn=dsn):
with (
patch("parsedmarc.postgres.psycopg") as mock_psycopg,
patch(
"parsedmarc.postgres.psycopg_conninfo",
SimpleNamespace(conninfo_to_dict=lambda conninfo: {}),
),
):
mock_psycopg.Error = Exception
mock_psycopg.ProgrammingError = programming_error
mock_psycopg.connect.side_effect = programming_error(driver_message)
with self.assertRaises(PostgreSQLError) as ctx:
PostgreSQLClient(connection_string=dsn)
message = str(ctx.exception)
self.assertNotIn("PGSECRET", message)
self.assertNotIn("SECRET", message)
self.assertIn("Invalid PostgreSQL connection_string", message)
@unittest.skipIf(
importlib.util.find_spec("psycopg") is None, "psycopg is not installed"
)
def test_real_psycopg_malformed_connection_string_withheld(self):
"""With the real driver, both malformed strings fail in parsing (no
network I/O) and the raised message carries no part of the password."""
for dsn in (
"postgresql://u:PGSECRET@[::1/db",
"postgresql://u:PG%ZZSECRET@h/db",
):
with self.subTest(dsn=dsn):
with self.assertRaises(PostgreSQLError) as ctx:
PostgreSQLClient(connection_string=dsn)
self.assertNotIn("SECRET", str(ctx.exception))
self.assertIn(
"Invalid PostgreSQL connection_string", str(ctx.exception)
)
def test_create_tables_executes_all_ddl(self):
"""create_tables issues CREATE TABLE for every table and the indexes."""
client, mock_conn = _make_client()
cur = _mock_cursor(mock_conn, [])
client.create_tables()
executed = " ".join(_executed_sql(cur))
for table in (
"dmarc_aggregate_report",
"dmarc_aggregate_record",
"dmarc_aggregate_record_dkim",
"dmarc_aggregate_record_spf",
"dmarc_aggregate_record_policy_override",
"dmarc_failure_report",
"dmarc_failure_sample_address",
"smtp_tls_report",
"smtp_tls_policy",
"smtp_tls_failure_detail",
):
self.assertIn(f"CREATE TABLE IF NOT EXISTS {table}", executed)
self.assertIn("CREATE INDEX IF NOT EXISTS", executed)
def test_create_tables_indexes_every_foreign_key(self):
"""Every REFERENCES column gets an index.
PostgreSQL indexes the referenced primary key but not the referencing
column ("the declaration of a foreign key constraint does not
automatically create an index on the referencing columns", PostgreSQL
docs, "Foreign Keys"), so without one a per-record lookup such as the
PostgreSQL Grafana dashboard's DKIM/SPF result subqueries scans the
whole child table.
"""
import re
client, mock_conn = _make_client()
cur = _mock_cursor(mock_conn, [])
client.create_tables()
statements = _executed_sql(cur)
foreign_keys = set()
for stmt in statements:
table = re.search(r"CREATE TABLE IF NOT EXISTS (\w+)", stmt)
if table:
for column in re.findall(
r"^\s*(\w+)[^\n]*\n?\s*REFERENCES", stmt, re.M | re.I
):
foreign_keys.add((table.group(1), column))
indexed = {
(m.group(1), m.group(2))
for stmt in statements
if (m := re.search(r"ON (\w+) \((\w+)\)", stmt))
}
# Every REFERENCES in the DDL must have been attributed to a column,
# so a layout the column regex misses fails here instead of passing.
references = sum(
len(re.findall(r"\bREFERENCES\b", s, re.I)) for s in statements
)
self.assertEqual(len(foreign_keys), references)
self.assertEqual(foreign_keys - indexed, set())
def test_create_tables_wraps_db_error(self):
"""A driver error during DDL is wrapped in PostgreSQLError."""
class FakeDriverError(Exception):
pass
client, mock_conn = _make_client()
cur = _mock_cursor(mock_conn, [])
cur.execute.side_effect = FakeDriverError("ddl boom")
with patch("parsedmarc.postgres.psycopg") as mp:
mp.Error = FakeDriverError
with self.assertRaises(PostgreSQLError) as ctx:
client.create_tables()
self.assertIn("ddl boom", str(ctx.exception))
class TestPostgreSQLClientSave(unittest.TestCase):
"""Save methods with a mocked DB: assert on SQL and bound parameters."""
# -- aggregate -----------------------------------------------------------
def test_save_aggregate_report_calls_insert(self):
"""Aggregate save executes INSERTs for report, record, dkim and spf."""
client, mock_conn = _make_client()
cur = _mock_cursor(mock_conn, [(1,), (10,)])
report = {
"xml_schema": "1.0",
"xml_namespace": "urn:ietf:params:xml:ns:dmarc-2.0",
"report_metadata": {
"org_name": "Example Inc.",
"org_email": "dmarc@example.com",
"org_extra_contact_info": None,
"report_id": "rpt-123",
"begin_date": "2024-01-15 00:00:00",
"end_date": "2024-01-15 23:59:59",
"errors": [],
"generator": "ExampleReporter/2.0",
},
"policy_published": {
"domain": "example.com",
"adkim": "r",
"aspf": "r",
"p": "none",
"sp": "none",
"pct": "100",
"fo": "0",
"np": "reject",
"testing": "y",
"discovery_method": "treewalk",
},
"records": [
{
"source": {
"ip_address": "203.0.113.1",
"country": "US",
"reverse_dns": "mail.example.com",
"base_domain": "example.com",
"name": None,
"type": None,
},
"count": 5,
"alignment": {"spf": True, "dkim": True, "dmarc": True},
"policy_evaluated": {
"disposition": "none",
"dkim": "pass",
"spf": "pass",
"policy_override_reasons": [],
},
"identifiers": {
"header_from": "example.com",
"envelope_from": "example.com",
"envelope_to": None,
},
"interval_begin": "2024-01-15 00:00:00",
"interval_end": "2024-01-15 23:59:59",
"auth_results": {
"dkim": [
{
"domain": "example.com",
"selector": "s1",
"result": "pass",
"human_result": "valid signature",
}
],
"spf": [
{
"domain": "example.com",
"scope": "mfrom",
"result": "pass",
"human_result": None,
}
],
},
}
],
}
client.save_aggregate_report_to_postgresql(report)
sqls = _executed_sql(cur)
self.assertIn("dmarc_aggregate_report", sqls[0])
self.assertIn("dmarc_aggregate_record", sqls[1])
self.assertTrue(any("dmarc_aggregate_record_dkim" in s for s in sqls))
self.assertTrue(any("dmarc_aggregate_record_spf" in s for s in sqls))
# The RFC 9990 / DMARCbis fields must reach the report INSERT.
report_params = _named_params(cur.execute.call_args_list[0])
self.assertEqual(
report_params["xml_namespace"], "urn:ietf:params:xml:ns:dmarc-2.0"
)
self.assertEqual(report_params["generator"], "ExampleReporter/2.0")
self.assertEqual(report_params["np"], "reject")
self.assertEqual(report_params["testing"], "y")
self.assertEqual(report_params["discovery_method"], "treewalk")
# DKIM auth-result values, including human_result, reach the INSERT.
dkim_sql_idx = next(
i for i, s in enumerate(sqls) if "dmarc_aggregate_record_dkim" in s
)
dkim_params = _named_params(cur.execute.call_args_list[dkim_sql_idx])
self.assertEqual(dkim_params["domain"], "example.com")
self.assertEqual(dkim_params["selector"], "s1")
self.assertEqual(dkim_params["result"], "pass")
self.assertEqual(dkim_params["human_result"], "valid signature")
def test_save_aggregate_report_already_saved(self):
"""AlreadySaved is raised when ON CONFLICT returns no row."""
client, mock_conn = _make_client()
_mock_cursor(mock_conn, [None])
report = {
"report_metadata": {
"org_name": "Dup Inc.",
"report_id": "dup-001",
"begin_date": "2024-01-01 00:00:00",
"end_date": "2024-01-01 23:59:59",
},
"policy_published": {"domain": "example.com"},
"records": [],
}
with self.assertRaises(AlreadySaved):
client.save_aggregate_report_to_postgresql(report)
def test_aggregate_report_normalizes_timestamps(self):
"""Report dates get a tz offset; record intervals get a +00 suffix."""
client, mock_conn = _make_client()
cur = _mock_cursor(mock_conn, [(1,), (10,)])
report = {
"report_metadata": {
"org_name": "TZ Test",
"report_id": "tz-001",
"begin_date": "2024-01-15 00:00:00",
"end_date": "2024-01-15 23:59:59",
},
"policy_published": {"domain": "example.com"},
"records": [
{
"source": {},
"count": 1,
"alignment": {},
"policy_evaluated": {},
"identifiers": {"header_from": "example.com"},
"interval_begin": "2024-01-15 00:00:00",
"interval_end": "2024-01-15 23:59:59",
"auth_results": {"dkim": [], "spf": []},
}
],
}
client.save_aggregate_report_to_postgresql(report)
report_params = _named_params(cur.execute.call_args_list[0])
for label in ("begin_date", "end_date"):
val = report_params[label]
self.assertIsNotNone(val, f"{label} should not be None")
self.assertTrue(
"+" in val or "-" in val[10:],
f"Report {label} should carry a tz offset: {val}",
)
record_params = _named_params(cur.execute.call_args_list[1])
for label in ("interval_begin", "interval_end"):
val = record_params[label]
self.assertIsNotNone(val, f"{label} should not be None")
self.assertTrue(
val.endswith("+00"),
f"Record {label} should end with +00: {val}",
)
# -- failure -------------------------------------------------------------
def test_save_failure_report_calls_insert(self):
"""Failure save dedups, then INSERTs the report and sample addresses."""
client, mock_conn = _make_client()
# 1st fetchone = dedup SELECT (None → not a duplicate); 2nd = INSERT id.
cur = _mock_cursor(mock_conn, [None, (1,)])
report = {
"feedback_type": "auth-failure",
"user_agent": "test/1.0",
"version": "1",
"original_envelope_id": None,
"original_mail_from": "sender@example.com",
"original_rcpt_to": "receiver@example.com",
"arrival_date": "Mon, 15 Jan 2024 10:30:00 +0000",
"arrival_date_utc": "2024-01-15 10:30:00",
"authentication_results": "spf=pass",
"delivery_result": None,
"auth_failure": ["dkim"],
"authentication_mechanisms": [],
"dkim_domain": "example.com",
"reported_domain": "example.com",
"sample_headers_only": False,
"source": {
"ip_address": "203.0.113.1",
"country": "US",
"reverse_dns": "mail.example.com",
"base_domain": "example.com",
"name": None,
"type": None,
},
"sample": "raw email content",
"parsed_sample": {
"date": "2024-01-15",
"subject": "Test",
"body": "Hello",
"has_defects": False,
"headers": {"From": "sender@example.com"},
"from": {"display_name": "Sender", "address": "sender@example.com"},
"to": [{"display_name": "Receiver", "address": "receiver@example.com"}],
"cc": [],
"bcc": [],
"reply_to": [],
},
}
client.save_failure_report_to_postgresql(report)
sqls = _executed_sql(cur)
# First statement is the dedup SELECT, then the report INSERT.
self.assertIn("SELECT", sqls[0])
self.assertIn("dmarc_failure_report", sqls[0])
self.assertTrue(
any("INSERT INTO dmarc_failure_report" in s for s in sqls),
"expected a failure-report INSERT",
)
self.assertTrue(
any("dmarc_failure_sample_address" in s for s in sqls),
"expected a sample-address INSERT for the 'to' recipient",
)
def test_save_failure_report_already_saved(self):
"""A matching existing failure report raises AlreadySaved."""
client, mock_conn = _make_client()
# Dedup SELECT returns a row → duplicate.
_mock_cursor(mock_conn, [(1,)])
report = {
"arrival_date_utc": "2024-01-15 10:30:00",
"reported_domain": "example.com",
"source": {"ip_address": "203.0.113.1"},
"parsed_sample": {"subject": "Test"},
}
with self.assertRaises(AlreadySaved):
client.save_failure_report_to_postgresql(report)
# -- SMTP TLS ------------------------------------------------------------
def test_save_smtp_tls_report_calls_insert(self):
"""SMTP TLS save INSERTs report, policy, and failure detail rows."""
client, mock_conn = _make_client()
cur = _mock_cursor(mock_conn, [(1,), (10,)])
report = {
"organization_name": "Example Inc.",
"begin_date": "2024-01-15T00:00:00Z",
"end_date": "2024-01-16T00:00:00Z",
"contact_info": "admin@example.com",
"report_id": "tls-001",
"policies": [
{
"policy_domain": "example.com",
"policy_type": "sts",
"policy_strings": ["version: STSv1"],
"mx_host_patterns": ["*.example.com"],
"successful_session_count": 100,
"failed_session_count": 2,
"failure_details": [
{
"result_type": "certificate-expired",
"failed_session_count": 2,
"sending_mta_ip": "203.0.113.1",
"receiving_ip": "198.51.100.1",
"receiving_mx_hostname": "mx.example.com",
"receiving_mx_helo": "mx.example.com",
"additional_info_uri": None,
"failure_reason_code": None,
}
],
}
],
}
client.save_smtp_tls_report_to_postgresql(report)
sqls = _executed_sql(cur)
self.assertIn("smtp_tls_report", sqls[0])
self.assertIn("smtp_tls_policy", sqls[1])
self.assertIn("smtp_tls_failure_detail", sqls[2])
# Policy field mapping must reach the INSERT (regression guard).
policy_params = cur.execute.call_args_list[1].args[1]
self.assertIn("example.com", policy_params)
self.assertIn("sts", policy_params)
self.assertIn(100, policy_params)
self.assertIn(2, policy_params)
def test_save_smtp_tls_report_blank_failure_detail_fields_become_null(self):
"""Blank optional failure-detail fields reach PostgreSQL as None,
not "" (issue #915).
samples/smtp_tls/empty_failure_detail_fields.json reproduces a
real reporter's shape: sending-mta-ip/receiving-ip/
receiving-mx-hostname/additional-information sent as "" on an
sts-policy-fetch-error. "" is not a valid PostgreSQL INET value, so
before the parser fix in parsedmarc/__init__.py this reached
cur.execute() as an empty string and crashed the save with
"invalid input syntax for type inet: \"\"" against a real server.
This test goes through the real parser (parsedmarc.parse_report_file)
rather than a hand-built dict, so it fails on unfixed
parsedmarc/__init__.py even though this test file only mocks
psycopg.
"""
client, mock_conn = _make_client()
cur = _mock_cursor(mock_conn, [(1,), (10,)])
result = parsedmarc.parse_report_file(
"samples/smtp_tls/empty_failure_detail_fields.json", offline=True
)
report = result["report"]
client.save_smtp_tls_report_to_postgresql(cast(dict, report))
sqls = _executed_sql(cur)
self.assertIn("smtp_tls_failure_detail", sqls[2])
detail_params = _named_params(cur.execute.call_args_list[2])
self.assertIsNone(detail_params["sending_mta_ip"])
self.assertIsNone(detail_params["receiving_ip"])
self.assertIsNone(detail_params["receiving_mx_hostname"])
self.assertIsNone(detail_params["additional_info_uri"])
# A non-blank optional field is preserved.
self.assertEqual(detail_params["failure_reason_code"], "no-policy-served")
# Required fields are preserved.
self.assertEqual(detail_params["result_type"], "sts-policy-fetch-error")
self.assertEqual(detail_params["failed_session_count"], 1)
def test_save_smtp_tls_report_already_saved(self):
"""AlreadySaved is raised when ON CONFLICT returns no row."""
client, mock_conn = _make_client()
_mock_cursor(mock_conn, [None])
report = {
"organization_name": "Dup Inc.",
"begin_date": "2024-01-01T00:00:00Z",
"end_date": "2024-01-02T00:00:00Z",
"contact_info": "admin@dup.com",
"report_id": "dup-tls-001",
"policies": [],
}
with self.assertRaises(AlreadySaved):
client.save_smtp_tls_report_to_postgresql(report)
def test_save_smtp_tls_report_contact_info_list(self):
"""A contact_info list is joined to a string before insert."""
client, mock_conn = _make_client()
cur = _mock_cursor(mock_conn, [(1,)])
report = {
"organization_name": "Multi Inc.",
"begin_date": "2024-01-15T00:00:00Z",
"end_date": "2024-01-16T00:00:00Z",
"contact_info": ["admin@multi.com", "abuse@multi.com"],
"report_id": "multi-001",
"policies": [],
}
client.save_smtp_tls_report_to_postgresql(report)
insert_params = cur.execute.call_args_list[0].args[1]
self.assertEqual(insert_params[3], "admin@multi.com, abuse@multi.com")
def test_save_failure_report_single_address_dict(self):
"""A recipient header parsed as a single dict (not a list) is wrapped."""
client, mock_conn = _make_client()
cur = _mock_cursor(mock_conn, [None, (1,)])
report = {
"arrival_date_utc": "2024-01-15 10:30:00",
"reported_domain": "example.com",
"source": {"ip_address": "203.0.113.1"},
"parsed_sample": {
"subject": "Single",
# 'to' as a lone dict rather than a list of dicts.
"to": {"display_name": "Solo", "address": "solo@example.com"},
},
}
client.save_failure_report_to_postgresql(report)
addr_sqls = [
(c.args[0], c.args[1])
for c in cur.execute.call_args_list
if "dmarc_failure_sample_address" in c.args[0]
]
self.assertEqual(len(addr_sqls), 1)
self.assertIn("solo@example.com", addr_sqls[0][1])
def test_save_failure_report_indexes_reply_to_address(self):
"""A parsed Reply-To address is written to
dmarc_failure_sample_address with address_type 'reply_to' — the
rows the Grafana PostgreSQL failure panel aggregates for its
'Reply To' column. Guards the path that parse_email now
populates (reply_to was always [] before the hyphen-key fix)."""
client, mock_conn = _make_client()
cur = _mock_cursor(mock_conn, [None, (1,)])
report = {
"arrival_date_utc": "2024-01-15 10:30:00",
"reported_domain": "example.com",
"source": {"ip_address": "203.0.113.1"},
"parsed_sample": {
"subject": "Test",
"reply_to": [
{"display_name": "Real One", "address": "real@phish.example"}
],
},
}
client.save_failure_report_to_postgresql(report)
reply_to_inserts = [
_named_params(c)
for c in cur.execute.call_args_list
if "dmarc_failure_sample_address" in c.args[0]
and c.args[1][1] == "reply_to"
]
self.assertEqual(len(reply_to_inserts), 1)
self.assertEqual(reply_to_inserts[0]["address"], "real@phish.example")
self.assertEqual(reply_to_inserts[0]["display_name"], "Real One")
class TestPostgreSQLSaveErrors(unittest.TestCase):
"""Driver errors raised mid-save are wrapped in PostgreSQLError."""
class _FakeDriverError(Exception):
pass
def _run(self, method, report):
client, mock_conn = _make_client()
cur = _mock_cursor(mock_conn, [])
cur.execute.side_effect = self._FakeDriverError("db boom")
with patch("parsedmarc.postgres.psycopg") as mp:
mp.Error = self._FakeDriverError
mp.DataError = type("DataError", (self._FakeDriverError,), {})
with self.assertRaises(PostgreSQLError) as ctx:
getattr(client, method)(report)
self.assertIn("db boom", str(ctx.exception))
def test_save_aggregate_wraps_db_error(self):
self._run(
"save_aggregate_report_to_postgresql",
{"report_metadata": {}, "policy_published": {}, "records": []},
)
def test_save_failure_wraps_db_error(self):
self._run(
"save_failure_report_to_postgresql",
{"parsed_sample": {}, "source": {}},
)
def test_save_smtp_tls_wraps_db_error(self):
self._run(
"save_smtp_tls_report_to_postgresql",
{"policies": []},
)
class TestPostgreSQLWithSamples(unittest.TestCase):
"""Feed real parsed sample reports through the save methods (DB mocked)."""
def test_aggregate_samples(self):
client, mock_conn = _make_client()
saved = 0
for sample_path in glob("samples/aggregate/*"):
if os.path.isdir(sample_path):
continue
try:
parsed = parsedmarc.parse_report_file(
sample_path,
always_use_local_files=True,
offline=OFFLINE_MODE,
)
except parsedmarc.ParserError:
continue
if parsed.get("report_type") != "aggregate":
continue
report = parsed["report"]
num_records = len(report.get("records", []))
_mock_cursor(mock_conn, [(rid,) for rid in range(1, 2 + num_records)])
try:
client.save_aggregate_report_to_postgresql(cast(dict, report))
saved += 1
except Exception as exc:
self.fail(f"aggregate save failed for {sample_path}: {exc}")
self.assertGreater(saved, 0, "Expected at least one aggregate sample")
def test_failure_samples(self):
client, mock_conn = _make_client()
saved = 0
for sample_path in glob("samples/failure/*.eml"):
try:
parsed = parsedmarc.parse_report_file(sample_path, offline=OFFLINE_MODE)
except parsedmarc.ParserError:
continue
if parsed.get("report_type") != "failure":
continue
reports = parsed["report"]
if not isinstance(reports, list):
reports = [reports]
for report in reports:
# Dedup SELECT returns None (not a dup), then the INSERT id.
_mock_cursor(mock_conn, [None, (1,)])
try:
client.save_failure_report_to_postgresql(cast(dict, report))
saved += 1
except Exception as exc:
self.fail(f"failure save failed for {sample_path}: {exc}")
self.assertGreater(saved, 0, "Expected at least one failure sample")
def test_smtp_tls_samples(self):
client, mock_conn = _make_client()
saved = 0
for sample_path in glob("samples/smtp_tls/*"):
if os.path.isdir(sample_path):
continue
try:
parsed = parsedmarc.parse_report_file(sample_path, offline=OFFLINE_MODE)
except parsedmarc.ParserError:
continue
if parsed.get("report_type") != "smtp_tls":
continue
report = parsed["report"]
num_policies = len(report.get("policies", []))
_mock_cursor(mock_conn, [(rid,) for rid in range(1, 2 + num_policies)])
try:
client.save_smtp_tls_report_to_postgresql(cast(dict, report))
saved += 1
except Exception as exc:
self.fail(f"smtp_tls save failed for {sample_path}: {exc}")
self.assertGreater(saved, 0, "Expected at least one SMTP TLS sample")
class TestEnsureUtcSuffixNonString(unittest.TestCase):
"""GHSA-4272-4r8g-6c5h: ``_ensure_utc_suffix(5)`` raised ``TypeError``
(``"+" in 5``) outside the save's ``psycopg.Error`` handler, so an SMTP
TLS report with ``"start-datetime": 5`` escaped the CLI. A non-string
is now returned unchanged, for PostgreSQL to accept or reject."""
def test_non_string_is_returned_unchanged(self):
for value in (5, 1.5, ["2024-01-01"]):
with self.subTest(value=value):
self.assertEqual(
_ensure_utc_suffix(value), # pyright: ignore[reportArgumentType]
value,
)
class TestNulReplacement(unittest.TestCase):
"""GHSA-fxr7-7h9q-v949: PostgreSQL cannot store NUL in ``text`` or
``jsonb``. psycopg's text-format ``str`` dumper raises ``DataError``
("PostgreSQL text fields cannot contain NUL (0x00) bytes") and the server
rejects ``\\u0000`` in ``jsonb``, so a failure report whose sample
Subject was ``=?utf-8?q?a=00b?=`` failed the save and held the batch.
NUL is now replaced with U+FFFD."""
def test_strip_nul_replaces_nested_strings_and_keys(self):
value = {
"Sub\x00ject": ["a\x00b", {"x": "\x00\x00"}],
"count": 3,
"none": None,
"flag": True,
}
self.assertEqual(
_strip_nul(value),
{
"Sub\ufffdject": ["a\ufffdb", {"x": "\ufffd\ufffd"}],
"count": 3,
"none": None,
"flag": True,
},
)
def test_connect_registers_binary_then_text_nul_safe_dumper(self):
"""psycopg uses the dumper registered last for a type as its
default, so the text dumper must be registered after the binary one.
Each registered class must replace NUL before its parent dumps."""
_make_client_conn = MagicMock()
fake_string = SimpleNamespace(
StrDumperUnknown=_FakeStrDumper, StrBinaryDumper=_FakeStrBinaryDumper
)
with (
patch("parsedmarc.postgres.psycopg") as mock_psycopg,
patch("parsedmarc.postgres.psycopg_string", fake_string),
):
mock_psycopg.Error = Exception
mock_psycopg.connect.return_value = _make_client_conn
client = PostgreSQLClient(host="localhost")
self.assertIs(client._conn, _make_client_conn)
calls = _make_client_conn.adapters.register_dumper.call_args_list
self.assertEqual([c.args[0] for c in calls], [str, str])
binary_cls, text_cls = (c.args[1] for c in calls)
self.assertTrue(issubclass(binary_cls, _FakeStrBinaryDumper))
self.assertTrue(issubclass(text_cls, _FakeStrDumper))
self.assertFalse(issubclass(text_cls, _FakeStrBinaryDumper))
for cls in (binary_cls, text_cls):
self.assertEqual(cls(str, None).dump("a\x00b"), "a\ufffdb".encode())
@unittest.skipUnless(PSYCOPG_INSTALLED, "psycopg is not installed")
def test_real_psycopg_parent_dumpers_reject_or_keep_nul(self):
"""The behavior being worked around: the text dumper raises
DataError and the binary one passes the NUL on to the server."""
import psycopg
from psycopg.types.string import StrBinaryDumper, StrDumperUnknown
with self.assertRaises(psycopg.DataError):
StrDumperUnknown(str, None).dump("a\x00b")
self.assertEqual(StrBinaryDumper(str, None).dump("a\x00b"), b"a\x00b")
text_cls, binary_cls = _nul_safe_dumper_classes()
self.assertEqual(text_cls(str, None).dump("a\x00b"), "a\ufffdb".encode())
self.assertEqual(binary_cls(str, None).dump("a\x00b"), "a\ufffdb".encode())
@unittest.skipUnless(PSYCOPG_INSTALLED, "psycopg is not installed")
def test_real_psycopg_dumps_str_and_str_list_without_nul(self):
"""With the real adapters map, every format psycopg may pick for a
``str`` parameter, and for a ``list[str]`` (``TEXT[]`` columns),
resolves to a NUL-safe dumper."""
import psycopg
from psycopg.abc import AdaptContext
from psycopg.adapt import AdaptersMap, PyFormat, Transformer
context = SimpleNamespace(
adapters=AdaptersMap(psycopg.adapters), connection=None
)
_register_nul_safe_dumpers(cast(psycopg.Connection, context))
transformer = Transformer(cast(AdaptContext, context))
for fmt in (PyFormat.AUTO, PyFormat.TEXT, PyFormat.BINARY):
with self.subTest(fmt=fmt):
dumped = transformer.get_dumper("a\x00b", fmt).dump("a\x00b")
self.assertEqual(bytes(cast(bytes, dumped)), "a\ufffdb".encode())
list_dumped = bytes(
cast(
bytes,
transformer.get_dumper(["a\x00b"], fmt).dump(["a\x00b"]),
)
)
self.assertNotIn(b"\x00b", list_dumped)
self.assertIn("a\ufffdb".encode(), list_dumped)
def test_failure_report_jsonb_values_have_nul_replaced(self):
"""The sample headers, From and To reach Jsonb with NUL replaced."""
class FakeJsonb:
def __init__(self, obj):
self.obj = obj
client, mock_conn = _make_client()
cur = _mock_cursor(mock_conn, [None, (1,)])
report = {
"arrival_date_utc": "2024-01-15 10:30:00",
"reported_domain": "example.com",
"source": {"ip_address": "203.0.113.1"},
"parsed_sample": {
"subject": "a\x00b",
"headers": {"Subject": "a\x00b"},
"from": {"display_name": "N\x00", "address": "x@example.com"},
"to": [{"display_name": None, "address": "y\x00@example.com"}],
},
}
with patch(
"parsedmarc.postgres.psycopg_json", SimpleNamespace(Jsonb=FakeJsonb)
):
client.save_failure_report_to_postgresql(report)
insert = next(
c
for c in cur.execute.call_args_list
if "INSERT INTO dmarc_failure_report" in c.args[0]
)
params = _named_params(insert)
self.assertEqual(params["sample_headers"].obj, {"Subject": "a\ufffdb"})
self.assertEqual(
params["sample_from"].obj,
{"display_name": "N\ufffd", "address": "x@example.com"},
)
self.assertEqual(
params["sample_to"].obj,
[{"display_name": None, "address": "y\ufffd@example.com"}],
)
# The input report is not modified.
self.assertEqual(report["parsed_sample"]["headers"], {"Subject": "a\x00b"})
class TestPostgreSQLRejectedReport(unittest.TestCase):
"""GHSA-fxr7-7h9q-v949: a value PostgreSQL rejects (``psycopg.DataError``)
raises PostgreSQLRejectedReport naming the report, so the CLI can skip
that one report instead of holding the batch; any other driver error
stays a plain PostgreSQLError."""
class _FakeError(Exception):
pass
class _FakeDataError(_FakeError):
pass
class _FakeOperationalError(_FakeError):
pass
REPORTS = {
"save_aggregate_report_to_postgresql": (
{
"report_metadata": {"org_name": "Example Inc.", "report_id": "agg-1"},
"policy_published": {},
"records": [],
},
("aggregate report from Example Inc.", "agg-1"),
),
"save_failure_report_to_postgresql": (
{
"reported_domain": "example.com",
"arrival_date_utc": "2024-01-15 10:30:00",
"parsed_sample": {},
"source": {},
},
("failure report for example.com", "2024-01-15 10:30:00+00"),
),
"save_smtp_tls_report_to_postgresql": (
{"organization_name": "TLS Org", "report_id": "tls-1", "policies": []},
("SMTP TLS report from TLS Org", "tls-1"),
),
}
def _save(self, method, report, error):
client, mock_conn = _make_client()
cur = _mock_cursor(mock_conn, [])
cur.execute.side_effect = error
with patch("parsedmarc.postgres.psycopg") as mp:
mp.Error = self._FakeError
mp.DataError = self._FakeDataError
with self.assertRaises(PostgreSQLError) as ctx:
getattr(client, method)(report)
return ctx.exception
def test_data_error_raises_rejected_report_naming_the_report(self):
for method, (report, expected) in self.REPORTS.items():
with self.subTest(method=method):
exc = self._save(
method,
report,
self._FakeDataError('invalid input syntax for type inet: "x"'),
)
self.assertIsInstance(exc, PostgreSQLRejectedReport)
message = str(exc)
self.assertTrue(message.startswith("PostgreSQL rejected a value"))
for part in expected:
self.assertIn(part, message)
self.assertIn("invalid input syntax for type inet", message)
def test_rejected_report_message_escapes_line_breaks(self):
"""The org name and the driver text come from the sender, so a
CR/LF in them cannot forge a log line."""
exc = self._save(
"save_aggregate_report_to_postgresql",
{
"report_metadata": {"org_name": "Evil\r\nFAKE", "report_id": "1"},
"policy_published": {},
"records": [],
},
self._FakeDataError("bad value\nFAKE"),
)
self.assertNotIn("\n", str(exc))
self.assertNotIn("\r", str(exc))
self.assertIn("Evil\\r\\nFAKE", str(exc))
def test_rejected_report_message_truncates_long_driver_text(self):
"""A DataError can quote the rejected value, which the sender chose
and can make any length; the driver text in the message is cut to
200 characters with an ellipsis, for every report type."""
driver_text = 'invalid input syntax for type inet: "' + "A" * 5000 + '"'
for method, (report, expected) in self.REPORTS.items():
with self.subTest(method=method):
exc = self._save(method, report, self._FakeDataError(driver_text))
self.assertIsInstance(exc, PostgreSQLRejectedReport)
message = str(exc)
for part in expected:
self.assertIn(part, message)
self.assertTrue(message.endswith(": " + driver_text[:200] + "..."))
self.assertNotIn("A" * 201, message)
def test_rejected_report_message_truncates_before_escaping(self):
"""The driver text is cut before it is escaped, so the cut cannot
split an escape sequence: a line break at character 200 is kept
whole as ``\\n``, and one after it is cut off."""
exc = self._save(
"save_aggregate_report_to_postgresql",
self.REPORTS["save_aggregate_report_to_postgresql"][0],
self._FakeDataError("x" * 199 + "\n" + "\r" * 50),
)
self.assertTrue(str(exc).endswith(": " + "x" * 199 + "\\n..."))
self.assertNotIn("\\r", str(exc))
def test_rejected_report_message_keeps_short_driver_text_whole(self):
exc = self._save(
"save_aggregate_report_to_postgresql",
self.REPORTS["save_aggregate_report_to_postgresql"][0],
self._FakeDataError("x" * 200),
)
self.assertTrue(str(exc).endswith(": " + "x" * 200))
def test_other_driver_error_stays_plain_postgresql_error(self):
for method, (report, _expected) in self.REPORTS.items():
with self.subTest(method=method):
exc = self._save(
method, report, self._FakeOperationalError("server closed")
)
self.assertNotIsInstance(exc, PostgreSQLRejectedReport)
self.assertEqual(str(exc), "server closed")
if __name__ == "__main__":
unittest.main()
# A password with the characters that end a URL's authority early, so a
# leak through a URL-shaped value would not be masked by accident.
_TLS_TEST_PASSWORD = "s3c/r?t#pw"
# (text after "postgresql://", refused by _has_unencoded_at(), the part of
# the password libpq parses into another component, or None for a string
# libpq parses as written).
_AT_RULE_CASES = (
("u:SECRETX/x@db.invalid/d", True, "SECRETX"),
("127.0.0.1:SECRETX/x@db.invalid/d", True, "SECRETX"),
("u:pa?sslmode=SECRETX/x@db.invalid/d", True, "SECRETX"),
("u:SECRETX/x?sslrootcert=a@b", True, "SECRETX"),
("u:pa@ss-SECRETX,@db.invalid/d", True, "SECRETX"),
("u:s3c@pw@db.example/d", True, "pw@"),
("u:s3c@pw:x@db.example/d", True, "x@db"),
("u:s3c@pw/x@db.example/d", True, "x@db"),
("u:s3c@p,w@db.example/d", True, "p,w"),
("u:a@b?sslmode=require@db/d", True, "require@"),
("u:a@b?sslrootcert=SECRET@db", True, "SECRET"),
("u/x:SECRETX@db.invalid/d", True, "SECRETX"),
("u:/SECRETX@db.invalid/d", True, "SECRETX"),
("u:12345/x@db.invalid/d", True, "12345"),
# Not recognized, because each reads like a legitimate string: an
# all-digit password before a "/", with the "@" only in a query value
# (host "u", port 12345), and an "@", "?" and "/" in a password (host
# "pw" and an sslmode after a "?", like a certificate path after one).
("u:12345/x?sslrootcert=a@b", False, "12345"),
("u:s3c@pw?sslmode=SECRET/x@db", False, "SECRET"),
("u:p%40w@db/d", False, None),
("u@db/d", False, None),
("db.invalid/d?sslrootcert=/home/a@b/ca.pem", False, None),
("db.invalid:5432/d?sslrootcert=/home/a@b/ca.pem", False, None),
("u:p@db.invalid/d?sslrootcert=/home/a@b/ca.pem", False, None),
("u:p@db.invalid?sslrootcert=/home/a@b/ca.pem", False, None),
("/d?host=/tmp", False, None),
("?host=/tmp&sslrootcert=/a@b/c", False, None),
("u:pw@%40sock/d", False, None),
("u:p@db.invalid/d%40x", False, None),
("[::1]:5432/d", False, None),
("[::1]:5432/d?sslrootcert=/a@b/c", False, None),
("u:p@[::1]:5432/d", False, None),
("u:pa?x@db/d", False, None),
("u:p@h1:5432,h2:5433/d", False, None),
("db.invalid", False, None),
)
# Connection strings whose password a shape check once let through, or
# that the early refusal catches, each with the secret (and fragments of it)
# that must never be logged or raised. The driver error each test raises
# quotes the first one.
_SECRET_CONNECTION_STRINGS = (
("postgresql://u:SECRETX/x@db.invalid/d", ("SECRETX",)),
("postgresql://127.0.0.1:SECRETX/x@db.invalid/d", ("SECRETX",)),
("postgresql://u:pa?sslmode=SECRETX/x@db.invalid/d", ("SECRETX",)),
("postgresql://u:pa@ss-SECRETX,@db.invalid/d", ("SECRETX", "ss-")),
("postgresql://u:s3c@pw@db.example/d", ("s3c", "pw@")),
(
"host=db.invalid password='SEC RETX' port=notanumber",
("SEC RETX", "SEC", "RETX"),
),
)
class TestConnectionStringDetailsWithheld(unittest.TestCase):
"""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 (real psycopg 3.3 ``conninfo_to_dict``) to host ``u``, port
``SECRETX`` and dbname ``x@db.invalid/d``, and connecting raised
``connection is bad: invalid integer value "SECRETX" for connection
option "port"``, which the client passed on. Three rounds of shape
checks each missed a shape, so with a connection string nothing
derived from it is logged or raised any more: a connection failure
carries a fixed message (plus a well-formed SQLSTATE) and the startup
warning names no host or value from the string."""
def _construct(self, dsn, driver_error, refuse_early=True):
"""Construct a client from *dsn* with psycopg.connect raising
*driver_error*; return (exception, log lines, connect mock)."""
operational_error = type(driver_error)
with contextlib.ExitStack() as stack:
mock_psycopg = stack.enter_context(patch("parsedmarc.postgres.psycopg"))
stack.enter_context(_without_libpq_env())
if not refuse_early:
stack.enter_context(_without_early_refusal())
logs = stack.enter_context(_captured_log_text())
mock_psycopg.Error = operational_error.__mro__[1]
mock_psycopg.ProgrammingError = type(
"ProgrammingError", (mock_psycopg.Error,), {}
)
mock_psycopg.connect.side_effect = driver_error
with self.assertRaises(PostgreSQLError) as ctx:
PostgreSQLClient(connection_string=dsn)
return ctx.exception, logs, mock_psycopg.connect
@staticmethod
def _driver_error(text, sqlstate=None):
"""Return a stand-in psycopg OperationalError (a subclass of a
fresh Error class) with *text* and a ``sqlstate`` attribute."""
error = type("Error", (Exception,), {})
operational_error = type("OperationalError", (error,), {"sqlstate": sqlstate})
return operational_error(text)
def _assert_nothing_leaks(self, dsn, secrets, exc, logs):
rendered = "".join(traceback.format_exception(exc))
log_text = "\n".join(logs)
for secret in (dsn, *secrets):
self.assertNotIn(secret, str(exc))
self.assertNotIn(secret, rendered)
self.assertNotIn(secret, log_text)
self.assertIsNone(exc.__cause__)
self.assertTrue(exc.__suppress_context__)
def test_secret_never_logged_or_raised_with_the_early_refusal(self):
"""Each string is refused before anything is logged, or (the
keyword/value one, whose password is its own keyword) connects
and fails with the driver's text withheld."""
for dsn, secrets in _SECRET_CONNECTION_STRINGS:
with self.subTest(dsn=dsn):
driver_error = self._driver_error(
f'invalid integer value "{secrets[0]}" for connection option "port"'
)
exc, logs, _ = self._construct(dsn, driver_error)
self._assert_nothing_leaks(dsn, secrets, exc, logs)
def test_secret_never_logged_or_raised_without_the_early_refusal(self):
"""With the shape checks patched out, every string reaches the
driver, whose error quotes the secret, and still nothing derived
from the string is logged or raised: the message is the fixed
withheld one, with no chain. This is what holds whatever shape
gets past the early refusal."""
for dsn, secrets in _SECRET_CONNECTION_STRINGS:
with self.subTest(dsn=dsn):
driver_error = self._driver_error(
f'connection is bad: invalid integer value "{secrets[0]}" '
'for connection option "port"'
)
exc, logs, connect = self._construct(
dsn, driver_error, refuse_early=False
)
connect.assert_called_once()
self.assertEqual(connect.call_args.args, (dsn,))
self.assertEqual(
str(exc),
"PostgreSQL connection failed (details withheld because "
"the connection string may include the password; use the "
"separate host, port, user, password and database "
"settings to see the driver's message)",
)
self._assert_nothing_leaks(dsn, secrets, exc, logs)
def test_sqlstate_is_appended_only_when_well_formed(self):
"""A server's SQLSTATE (five digits or upper-case letters) is a
fixed code, not text from the string, so it is kept; a missing,
empty or malformed one is not shown."""
dsn = "host=db.example password=SECRETX"
for sqlstate, suffix in (
("28P01", " [SQLSTATE 28P01]"),
(None, ""),
("", ""),
("28P01; DROP", ""),
("28p01", ""),
):
with self.subTest(sqlstate=sqlstate):
driver_error = self._driver_error(
'password authentication failed for user "SECRETX"', sqlstate
)
exc, _, _ = self._construct(dsn, driver_error)
self.assertTrue(str(exc).endswith("driver's message)" + suffix))
self.assertNotIn("SECRETX", str(exc))
self.assertNotIn("DROP", str(exc))
def test_separate_arguments_keep_the_driver_message(self):
"""Without a connection string the driver's message is passed on
(with URL credentials masked), since no value in it is derived
from a string a password fragment could land in."""
driver_error = self._driver_error(
'connection to "postgresql://u:PGSECRET@db.example/x" failed: '
"Connection refused",
"08001",
)
with (
patch("parsedmarc.postgres.psycopg") as mock_psycopg,
_without_libpq_env(),
):
mock_psycopg.Error = type(driver_error).__mro__[1]
mock_psycopg.ProgrammingError = type("ProgrammingError", (Exception,), {})
mock_psycopg.connect.side_effect = driver_error
with self.assertRaises(PostgreSQLError) as ctx:
PostgreSQLClient(host="db.example", sslmode="verify-full")
exc = ctx.exception
self.assertEqual(
str(exc),
'connection to "postgresql://u:***@db.example/x" failed: '
"Connection refused",
)
self.assertIsNone(exc.__cause__)
self.assertTrue(exc.__suppress_context__)
def _assert_no_secret_anywhere(self, exc, logs, secret="SECRETX"):
"""Assert *secret* is in no rendering of *exc* (str, repr, args,
formatted traceback) nor in the captured log, and that nothing is
chained."""
rendered = "".join(traceback.format_exception(exc))
for text in (str(exc), repr(exc), repr(exc.args), rendered, *logs):
self.assertNotIn(secret, text)
self.assertIsNone(exc.__cause__)
self.assertTrue(exc.__suppress_context__)
def _non_driver_failure(self, failure, client_kwargs, stage="connect"):
"""Construct a client whose *stage* (``connect``, ``autocommit`` or
``dumpers``) raises *failure*, an error that is not the driver's
own (psycopg.Error is a fresh class it does not derive from);
return (exception, log lines)."""
with contextlib.ExitStack() as stack:
mock_psycopg = stack.enter_context(patch("parsedmarc.postgres.psycopg"))
stack.enter_context(_without_libpq_env())
logs = stack.enter_context(_captured_log_text())
mock_psycopg.Error = type("Error", (Exception,), {})
mock_psycopg.ProgrammingError = type(
"ProgrammingError", (mock_psycopg.Error,), {}
)
conn = MagicMock()
if stage == "connect":
mock_psycopg.connect.side_effect = failure
elif stage == "autocommit":
type(conn).autocommit = property(
lambda self: False,
lambda self, value: (_ for _ in ()).throw(failure),
)
mock_psycopg.connect.return_value = conn
else:
conn.adapters.register_dumper.side_effect = failure
mock_psycopg.connect.return_value = conn
with self.assertRaises(PostgreSQLError) as ctx:
PostgreSQLClient(**client_kwargs)
return ctx.exception, logs
def test_non_driver_connect_errors_are_withheld(self):
"""Fifth review of GHSA-xvp7-7762-gx9g: _connect caught only
psycopg.Error, so any other exception escaped unwrapped. psycopg
encodes the connection string it builds from the separate
arguments, so a password holding a lone surrogate (an environment
variable with invalid UTF-8) raised UnicodeEncodeError with the
whole string, password included, in ``.object``, ``.args`` and
``repr()``. Any such error is now a PostgreSQLError with a fixed
message and no chain, from the connect call, setting autocommit,
or registering the dumpers."""
failures = (
lambda: UnicodeEncodeError("utf-8", "password=SECRETX", 0, 1, "x"),
lambda: ValueError("SECRETX"),
)
cases = (
(
{"connection_string": "host=db.invalid password=SECRETX"},
"PostgreSQL connection failed (details withheld because the "
"connection string may include the password; use the "
"separate host, port, user, password and database settings "
"to see the driver's message)",
),
(
{"host": "db.invalid", "user": "u", "password": "SECRETX"},
"PostgreSQL connection failed: a connection setting could not "
"be passed to the driver (details withheld because they may "
"include the password)",
),
)
for make_failure in failures:
for client_kwargs, message in cases:
for stage in ("connect", "autocommit", "dumpers"):
failure = make_failure()
with self.subTest(
failure=type(failure).__name__,
kwargs=sorted(client_kwargs),
stage=stage,
):
exc, logs = self._non_driver_failure(
failure, client_kwargs | {"sslmode": "verify-full"}, stage
)
self.assertEqual(str(exc), message)
self._assert_no_secret_anywhere(exc, logs)
def test_keyboard_interrupt_is_not_swallowed(self):
"""The catch-all is ``except Exception``: a Ctrl-C during the
connect attempt still propagates as KeyboardInterrupt."""
with (
patch("parsedmarc.postgres.psycopg") as mock_psycopg,
_without_libpq_env(),
):
mock_psycopg.Error = type("Error", (Exception,), {})
mock_psycopg.connect.side_effect = KeyboardInterrupt
with self.assertRaises(KeyboardInterrupt):
PostgreSQLClient(host="db.invalid", sslmode="verify-full")
@unittest.skipUnless(PSYCOPG_INSTALLED, "psycopg is not installed")
def test_real_psycopg_surrogate_password_is_withheld(self):
"""With the real driver, a password holding a lone surrogate makes
psycopg raise UnicodeEncodeError('utf-8', 'host=127.0.0.1 port=1
user=u password=\\udcffSECRETX ...') while it encodes the
connection string, before any network I/O (Python 3.12 and 3.15).
The client raises a PostgreSQLError with none of it."""
with _without_libpq_env(), _captured_log_text() as logs:
with self.assertRaises(PostgreSQLError) as ctx:
PostgreSQLClient(
host="127.0.0.1",
port=1,
user="u",
password="\udcffSECRETX",
database="d",
sslmode="disable",
)
exc = ctx.exception
self.assertIsInstance(exc, PostgreSQLError)
self.assertTrue(str(exc).startswith("PostgreSQL connection failed: a "))
self._assert_no_secret_anywhere(exc, logs)
@unittest.skipUnless(PSYCOPG_INSTALLED, "psycopg is not installed")
def test_real_psycopg_overlong_host_label_is_withheld(self):
"""A host label over 63 characters fails in psycopg's name
resolution before any network I/O: on Python 3.15 with
UnicodeEncodeError('idna', '<the host>', ...), which psycopg's
getaddrinfo wrapper (it catches only OSError) does not wrap, and
on 3.12 with a bare UnicodeError. Here the host holds the rest of
a password (``password=se host=a`` + ``SECRETX`` * 10 +
``.invalid``); the client raises the withheld message instead."""
dsn = "password=se host=a" + "SECRETX" * 10 + ".invalid"
with _without_libpq_env(), _captured_log_text() as logs:
with self.assertRaises(PostgreSQLError) as ctx:
PostgreSQLClient(connection_string=dsn, sslmode="disable")
exc = ctx.exception
self.assertIsInstance(exc, PostgreSQLError)
self.assertTrue(str(exc).startswith("PostgreSQL connection failed ("))
self._assert_no_secret_anywhere(exc, logs)
@unittest.skipUnless(PSYCOPG_INSTALLED, "psycopg is not installed")
def test_real_psycopg_withholds_a_password_parsed_as_the_port(self):
"""With the real driver and the early refusal patched out,
``postgresql://127.0.0.1:SECRETX/x@db.invalid/d`` reaches libpq,
which fails locally on the port ``SECRETX`` (no server is
contacted) with ``invalid integer value "SECRETX" for connection
option "port"``; the raised error, its traceback and the log carry
no part of it."""
dsn = "postgresql://127.0.0.1:SECRETX/x@db.invalid/d"
with (
_without_libpq_env(),
_without_early_refusal(),
_captured_log_text() as logs,
):
with self.assertRaises(PostgreSQLError) as ctx:
PostgreSQLClient(connection_string=dsn)
exc = ctx.exception
self.assertTrue(str(exc).startswith("PostgreSQL connection failed"))
self._assert_nothing_leaks(dsn, ("SECRETX",), exc, logs)
class TestPostgreSQLTLS(unittest.TestCase):
"""GHSA-xvp7-7762-gx9g: the client connects with ``sslmode=require``
unless the caller, the connection string, or ``PGSSLMODE`` chooses a
mode, and warns at startup when a remote server's certificate is not
verified. Before the fix no sslmode was passed, so libpq used
``prefer`` and continued over plain TCP when an on-path attacker
declined TLS, then answered its clear-text password request.
psycopg is mocked at ``parsedmarc.postgres.psycopg``; the libpq
environment is cleared so the developer's shell does not leak in."""
def _connect_call(self, env=None, **client_kwargs):
"""Construct a client with psycopg.connect mocked and return the
call psycopg.connect received."""
with (
patch("parsedmarc.postgres.psycopg") as mock_psycopg,
_without_libpq_env(),
patch.dict(os.environ, env or {}),
):
mock_psycopg.Error = Exception
PostgreSQLClient(**client_kwargs)
mock_psycopg.connect.assert_called_once()
return mock_psycopg.connect.call_args
def _warnings(self, env=None, **client_kwargs) -> list[str]:
"""Construct a client and return the warnings it logged."""
with self.assertLogs("parsedmarc.log", level="WARNING") as cm:
self._connect_call(env=env, **client_kwargs)
return cm.output
def _assert_no_warning(self, env=None, **client_kwargs):
with self.assertNoLogs("parsedmarc.log", level="WARNING"):
self._connect_call(env=env, **client_kwargs)
# -- the sslmode passed to psycopg.connect -------------------------------
def test_default_sslmode_is_require_with_host(self):
call = self._connect_call(
host="db.example", user="u", password="pw", database="d"
)
self.assertEqual(call.args, ())
self.assertEqual(call.kwargs["sslmode"], "require")
self.assertNotIn("sslrootcert", call.kwargs)
self.assertEqual(call.kwargs["host"], "db.example")
self.assertEqual(call.kwargs["password"], "pw")
def test_default_sslmode_is_require_with_connection_string(self):
call = self._connect_call(connection_string="host=db.example user=u")
self.assertEqual(call.args, ("host=db.example user=u",))
self.assertEqual(call.kwargs, {"sslmode": "require"})
def test_explicit_sslmode_and_sslrootcert_pass_through(self):
call = self._connect_call(
host="db.example", sslmode="verify-full", sslrootcert="/etc/ca.pem"
)
self.assertEqual(call.kwargs["sslmode"], "verify-full")
self.assertEqual(call.kwargs["sslrootcert"], "/etc/ca.pem")
def test_explicit_sslmode_overrides_connection_string_and_environment(self):
call = self._connect_call(
env={"PGSSLMODE": "disable"},
connection_string="host=db.example sslmode=prefer",
sslmode="verify-ca",
)
self.assertEqual(call.kwargs, {"sslmode": "verify-ca"})
def test_connection_string_sslmode_is_left_to_libpq(self):
"""A string that sets sslmode gets no sslmode keyword, which psycopg
would merge over the string's own value."""
call = self._connect_call(
connection_string="host=db.example sslmode=verify-full user=u",
sslrootcert="/etc/ca.pem",
)
self.assertEqual(call.args, ("host=db.example sslmode=verify-full user=u",))
self.assertEqual(call.kwargs, {"sslrootcert": "/etc/ca.pem"})
def test_pgsslmode_environment_is_left_to_libpq(self):
call = self._connect_call(env={"PGSSLMODE": "verify-ca"}, host="db.example")
self.assertNotIn("sslmode", call.kwargs)
def test_malformed_connection_string_withheld_before_connecting(self):
"""The string is parsed before connecting; a parse failure, whose
libpq message quotes the offending word, raises the withheld-details
error from None, so neither the message nor the traceback carries
the string or the password, and no connection is attempted."""
dsn = f"host=db.example {_TLS_TEST_PASSWORD}"
with (
patch("parsedmarc.postgres.psycopg") as mock_psycopg,
_without_libpq_env(),
):
mock_psycopg.Error = Exception
with self.assertRaises(PostgreSQLError) as ctx:
PostgreSQLClient(connection_string=dsn)
mock_psycopg.connect.assert_not_called()
exc = ctx.exception
self.assertIn("Invalid PostgreSQL connection_string", str(exc))
rendered = "".join(traceback.format_exception(exc))
for secret in (dsn, _TLS_TEST_PASSWORD, "s3c"):
self.assertNotIn(secret, str(exc))
self.assertNotIn(secret, rendered)
self.assertIsNone(exc.__cause__)
self.assertTrue(exc.__suppress_context__)
# -- the startup warning -------------------------------------------------
def test_warning_for_remote_host_without_verification(self):
cases = (
({"host": "db.example"}, "require"),
({"host": "db.example", "sslmode": "require"}, "require"),
({"host": "db.example", "sslmode": "prefer"}, "prefer"),
({"connection_string": "host=db.example"}, "require"),
({"connection_string": "hostaddr=192.0.2.10"}, "require"),
({"host": "localhost,db.example"}, "require"),
({"host": "192.0.2.10"}, "require"),
# libpq connects to hostaddr, not host, when both are set.
({"connection_string": "host=localhost hostaddr=203.0.113.5"}, "require"),
)
for kwargs, mode in cases:
with self.subTest(kwargs=kwargs):
output = self._warnings(**kwargs)
self.assertEqual(len(output), 1)
if "connection_string" in kwargs:
self.assertIn(
"The PostgreSQL connection_string does not verify the "
f"server certificate (sslmode is {mode});",
output[0],
)
else:
self.assertIn(
f"uses sslmode={mode}, which does not verify", output[0]
)
self.assertIn("set [postgresql] sslmode = verify-full", output[0])
def test_warning_names_the_host_but_no_credential(self):
"""With the separate arguments the warning names the host only:
not the password (one with "/", "?" and "#") or the user."""
output = "\n".join(
self._warnings(
host="db.example", user="dmarcuser", password=_TLS_TEST_PASSWORD
)
)
self.assertIn("PostgreSQL connection to db.example uses", output)
for secret in (_TLS_TEST_PASSWORD, "s3c", "dmarcuser"):
self.assertNotIn(secret, output)
def test_connection_string_warning_names_no_value_from_the_string(self):
"""With a connection string the warning names no host, user,
password or other value from the string: when the user name or
password holds an unencoded "@" or "/", libpq parses part of the
password as the host or a query parameter. PGHOST from the
environment is not named either."""
dsn = f"host=db.example user=dmarcuser password={_TLS_TEST_PASSWORD}"
for env in ({}, {"PGHOST": "pghost.example"}):
with self.subTest(env=env):
output = "\n".join(self._warnings(env=env, connection_string=dsn))
self.assertIn(
"The PostgreSQL connection_string does not verify the server "
"certificate (sslmode is require)",
output,
)
for value in (
"db.example",
"pghost.example",
_TLS_TEST_PASSWORD,
"s3c",
"dmarcuser",
dsn,
):
self.assertNotIn(value, output)
def test_connection_string_warning_prints_only_a_recognized_mode(self):
"""The mode is printed only when it is one of SSL_MODES: a typo
(``verfy-full``) and an sslmode that holds a password fragment
(``postgresql://u:pa?sslmode=SECRETX/x@db.invalid/d`` parses with
host ``u``, port ``pa`` and sslmode ``SECRETX/x@db.invalid/d``) are
both shown as "not a recognized value". The early refusal is
patched out so the warning itself is what is tested."""
for dsn, secrets in (
("host=db.example sslmode=verfy-full", ("db.example", "verfy")),
(
"postgresql://u:pa?sslmode=SECRETX/x@db.invalid/d",
("SECRETX", "db.invalid", "x@"),
),
):
with self.subTest(dsn=dsn), _without_early_refusal():
output = "\n".join(self._warnings(connection_string=dsn))
self.assertIn("(sslmode is not a recognized value)", output)
for secret in secrets:
self.assertNotIn(secret, output)
def test_warning_masks_a_host_with_an_at_sign(self):
"""A URI connection string whose host holds an "@" is refused (see
test_unencoded_at_in_connection_string_is_refused), but the *host*
argument, ``PGHOST`` and ``PGHOSTADDR`` are passed on, so the
warning still credential-masks the host it names, in a host list
too."""
cases = (
({"host": "pw@db.example"}, {}),
({"host": "pw@db.example,h2.example"}, {}),
({}, {"PGHOST": "pw@db.example"}),
({}, {"PGHOSTADDR": "pw@db.example"}),
)
for kwargs, env in cases:
with self.subTest(kwargs=kwargs, env=env):
output = "\n".join(self._warnings(env=env, **kwargs))
self.assertIn("PostgreSQL connection to ***@db.example", output)
self.assertNotIn("pw@", output)
def test_unencoded_at_in_connection_string_is_refused(self):
"""libpq ends a URI's userinfo at the first "@", so the rest of a
password containing an unencoded "@" lands in the host, the port or
the database name (with psycopg 3.3's conninfo_to_dict:
``u:s3c@pw:x@db.example`` gives host ``pw`` and port
``x@db.example``; ``u:s3c@pw/x@db.example/d`` host ``pw`` and
dbname ``x@db.example/d``; ``u:s3c@p,w@db.example`` host
``p,w@db.example``; ``u:s3c@pw@db.example`` host
``pw@db.example``). The startup warning then named host ``pw`` and
the driver failed to resolve it, a password fragment in the log
both times. Such a string is now refused before anything is logged
or any connection is attempted, with every sslmode, and neither
the message nor the traceback holds the password or a fragment."""
cases = (
("postgresql://u:s3c@pw:x@db.example/d", ("s3c", "pw", "x@db")),
("postgresql://u:s3c@pw/x@db.example/d", ("s3c", "pw", "x@db")),
("postgresql://u:s3c@p,w@db.example/d", ("s3c", "p,w")),
("postgresql://u:s3c@pw@db.example/d", ("s3c", "pw")),
# An "@" at the start of a host-list entry names an abstract
# socket, which a check on the parsed host exempted.
("postgresql://u:pa@ss-SECRETX,@db.invalid/d", ("SECRETX", "ss-", "pa@ss")),
# The fragment lands in a query parameter: sslmode (which the
# warning names) or sslrootcert (which libpq's errors quote).
("postgresql://u:a@b?sslmode=require@db/d", ("a@b", "sslmode=require")),
("postgresql://u:a@b?sslrootcert=SECRET@db", ("SECRET", "a@b")),
# libpq's scan for the userinfo stops at the first "/" as well as
# the first "@", so an unencoded "/" in the password ends the
# authority: host "u", port "SECRETX", dbname "x@db.invalid/d".
("postgresql://u:SECRETX/x@db.invalid/d", ("SECRETX",)),
("postgresql://127.0.0.1:SECRETX/x@db.invalid/d", ("SECRETX",)),
("postgresql://u:pa?sslmode=SECRETX/x@db.invalid/d", ("SECRETX",)),
# Port "SECRETX", and the "@" only in a query value.
("postgresql://u:SECRETX/x?sslrootcert=a@b", ("SECRETX",)),
# A "/" in the user name, or a password that starts with "/" or
# is all digits: the "@" lands in the database name.
("postgresql://u/x:SECRETX@db.invalid/d", ("SECRETX",)),
("postgresql://u:/SECRETX@db.invalid/d", ("SECRETX",)),
("postgresql://u:12345/x@db.invalid/d", ("12345",)),
# Case and leading whitespace do not hide the URI form.
(" PostgreSQL://u:s3c@pw@db.example/d", ("s3c", "pw")),
)
for dsn, secrets in cases:
for sslmode in (None, "verify-full"):
with self.subTest(dsn=dsn, sslmode=sslmode):
with (
patch("parsedmarc.postgres.psycopg") as mock_psycopg,
_without_libpq_env(),
self.assertNoLogs("parsedmarc.log", level="DEBUG"),
):
mock_psycopg.Error = Exception
with self.assertRaises(PostgreSQLError) as ctx:
PostgreSQLClient(connection_string=dsn, sslmode=sslmode)
mock_psycopg.connect.assert_not_called()
exc = ctx.exception
self.assertIn(
'appears to have an unencoded "@" or "/" in its user '
"name, password or database name",
str(exc),
)
self.assertIn("percent-encode", str(exc))
rendered = "".join(traceback.format_exception(exc))
for secret in (dsn, *secrets):
self.assertNotIn(secret, str(exc))
self.assertNotIn(secret, rendered)
self.assertIsNone(exc.__cause__)
def test_unencoded_at_rule_table(self):
"""_has_unencoded_at() mirrors libpq's URI scan (userinfo up to the
first "@" or "/") and refuses each string in which a part of the
password lands in another component, except two shapes it does not
recognize because each reads like a legitimate string (see the
table), which the withheld messages keep out of the logs
regardless. Strings libpq parses as written are accepted."""
for rest, refused, fragment in _AT_RULE_CASES:
with self.subTest(rest=rest):
self.assertIs(_has_unencoded_at("postgresql://" + rest), refused)
if refused:
self.assertIsNotNone(fragment)
@unittest.skipUnless(PSYCOPG_INSTALLED, "psycopg is not installed")
def test_real_parser_agrees_with_the_rule_table(self):
"""With the real conninfo_to_dict, the password fragment of each
misparsed string in the table is in a component other than the
user name and password, and each string listed as parsed as
written has its "@" only where it was written: in the userinfo
separator, a %40, or a query value."""
from psycopg.conninfo import conninfo_to_dict
for rest, _refused, fragment in _AT_RULE_CASES:
with self.subTest(rest=rest):
params = conninfo_to_dict("postgresql://" + rest)
others = [
str(value)
for key, value in params.items()
if key not in ("user", "password")
]
if fragment is not None:
self.assertTrue(any(fragment in v for v in others), params)
continue
for key in ("host", "port", "dbname"):
value = str(params.get(key) or "")
self.assertTrue("@" not in value or "%40" in rest, (key, params))
port = str(params.get("port") or "")
self.assertTrue(all(p.isdigit() for p in port.split(",") if p))
def test_unencoded_at_is_refused_on_reconnect(self):
"""_connect parses the string itself as well, so a reconnect (whose
sslmode lookup skips the string when sslmode is set) never hands a
refused string to the driver either."""
with (
patch("parsedmarc.postgres.psycopg") as mock_psycopg,
_without_libpq_env(),
):
mock_psycopg.Error = Exception
client = PostgreSQLClient(
connection_string="postgresql://u:p@db.example/d",
sslmode="verify-full",
)
client._connection_string = "postgresql://u:s3c@pw@db.example/d"
with self.assertRaises(PostgreSQLError) as ctx:
client._connect()
self.assertEqual(mock_psycopg.connect.call_count, 1)
self.assertIn('unencoded "@"', str(ctx.exception))
def test_percent_encoded_at_in_password_connects(self):
"""A password whose "@" is percent-encoded parses with the right
host, and the string reaches the driver unchanged."""
dsn = "postgresql://u:s3c%40pw@db.example/d"
call = self._connect_call(connection_string=dsn, sslmode="verify-full")
self.assertEqual(call.args, (dsn,))
def test_percent_encoded_at_elsewhere_in_uri_connects(self):
"""A URI never needs a second literal "@": one in an abstract
socket host or a query value is written %40, which libpq decodes
(psycopg 3.3: ``postgresql://u@%40sock/d`` has host ``@sock``,
``?sslrootcert=a%40b`` sslrootcert ``a@b``), so such a URI is
accepted and passed on unchanged."""
for dsn in (
"postgresql://u@%40pgsock/d",
"postgresql://u:pw@/d?host=%40pgsock",
"postgresql://u:pw@db.example/d?sslrootcert=a%40b",
"postgresql://u:p%2Fw@db.example/d",
):
with self.subTest(dsn=dsn):
call = self._connect_call(connection_string=dsn, sslmode="verify-full")
self.assertEqual(call.args, (dsn,))
def test_legitimate_connection_strings_are_not_refused(self):
"""Strings libpq parses as written are passed on unchanged: an "@"
that is percent-encoded (a database name ``d%40x`` cannot be fixed
by encoding, so refusing it would leave no way to name it), one in
a query value after a "/" (a certificate path), IPv6 hosts with a
port, host lists, a "?" in a password, and keyword/value strings,
whose password is a keyword of its own: an "@" in their host,
hostaddr or dbname is not a password fragment, and libpq does not
percent-decode them, so encoding advice would be wrong."""
for dsn in (
"postgresql://u:p%40w@db.invalid/d",
"postgresql://u@db.invalid/d",
"postgresql://db.invalid/d?sslrootcert=/home/a@b/ca.pem",
"postgresql://db.invalid:5432/d?sslrootcert=/home/a@b/ca.pem",
"postgresql://u:p@db.invalid/d?sslrootcert=/home/a@b/ca.pem",
"postgresql://u:p@db.invalid?sslrootcert=/home/a@b/ca.pem",
"postgresql:///d?host=/tmp",
"postgresql://u:pw@%40sock/d",
"postgresql://u:p@db.invalid/d%40x",
"postgresql://[::1]:5432/d",
"postgresql://u:p@[::1]:5432/d",
"postgresql://u:pa?x@db.invalid/d",
"postgresql://u:p@h1.invalid:5432,h2.invalid:5433/d",
"dbname=a@b host=db.invalid",
"host=pw@db.invalid,h2.invalid",
"hostaddr=pw@db.invalid",
):
with self.subTest(dsn=dsn):
call = self._connect_call(connection_string=dsn, sslmode="verify-full")
self.assertEqual(call.args, (dsn,))
def test_abstract_socket_host_in_connection_string_is_allowed(self):
"""An "@" at the start of a host entry names a Linux abstract
socket, not a password fragment, in a keyword/value string (whose
password is its own keyword, so no fragment of it can land in the
host)."""
for dsn in ("host=@pgsock dbname=d", "host=db.example,@pgsock dbname=d"):
with self.subTest(dsn=dsn):
call = self._connect_call(connection_string=dsn)
self.assertEqual(call.args, (dsn,))
def test_warning_host_and_mode_come_from_the_environment(self):
"""With no host argument libpq connects to PGHOST (or PGHOSTADDR),
and with no sslmode set elsewhere it uses PGSSLMODE."""
output = self._warnings(env={"PGHOST": "db.example", "PGSSLMODE": "allow"})
self.assertIn("connection to db.example uses sslmode=allow", output[0])
output = self._warnings(env={"PGHOSTADDR": "192.0.2.10"})
self.assertIn("connection to 192.0.2.10 uses sslmode=require", output[0])
output = self._warnings(
env={"PGSSLMODE": "prefer"},
connection_string="host=db.example sslmode=disable",
)
self.assertIn("(sslmode is disable)", output[0])
# PGHOST decides that a connection string without a host is remote,
# but is not named.
output = self._warnings(
env={"PGHOST": "pghost.example"}, connection_string="dbname=d"
)
self.assertIn("(sslmode is require)", output[0])
self.assertNotIn("pghost.example", output[0])
def test_warning_host_is_escaped(self):
"""A line break in the host is escaped, so it cannot forge a log
line."""
output = self._warnings(host="db.example\nforged")
self.assertIn("db.example\\nforged", output[0])
self.assertNotIn("\nforged", output[0])
def test_no_warning_for_local_or_verified_connections(self):
cases = (
{"host": "127.0.0.1"},
{"host": "127.0.0.53"},
{"host": "::1"},
{"host": "localhost"},
{"host": "LocalHost"},
{"host": "/var/run/postgresql"},
{"host": "@abstract"},
{"host": "localhost,::1,/tmp"},
{"host": None},
{"connection_string": "dbname=dmarc"},
{"connection_string": "postgresql:///dmarc"},
{"host": "db.example", "sslmode": "verify-full"},
{"host": "db.example", "sslmode": "verify-ca"},
{"connection_string": "host=db.example sslmode=verify-full"},
)
for kwargs in cases:
with self.subTest(kwargs=kwargs):
self._assert_no_warning(**kwargs)
with self.subTest(env="PGSSLMODE=verify-full"):
self._assert_no_warning(env={"PGSSLMODE": "verify-full"}, host="db.example")
def test_no_warning_for_require_with_an_existing_root_certificate(self):
"""libpq: "if a root CA file exists, the behavior of sslmode=require
will be the same as that of verify-ca" (PostgreSQL docs, libpq SSL
support), so the default mode with an sslrootcert that exists is
not warned about, from the argument, the string or PGSSLROOTCERT.
A root certificate that does not exist leaves require unverified."""
with tempfile.TemporaryDirectory() as tmp:
ca = os.path.join(tmp, "ca.pem")
with open(ca, "w") as f:
f.write("not parsed")
for env, kwargs in (
({}, {"host": "db.example", "sslrootcert": ca}),
({}, {"host": "db.example", "sslmode": "require", "sslrootcert": ca}),
({}, {"connection_string": f"host=db.example sslrootcert={ca}"}),
({"PGSSLROOTCERT": ca}, {"host": "db.example"}),
):
with self.subTest(env=env, kwargs=kwargs):
self._assert_no_warning(env=env, **kwargs)
missing = os.path.join(tmp, "missing.pem")
output = self._warnings(host="db.example", sslrootcert=missing)
self.assertIn("uses sslmode=require", output[0])
output = self._warnings(host="db.example", sslmode="prefer", sslrootcert=ca)
self.assertIn("uses sslmode=prefer", output[0])
def test_warning_is_logged_once_not_on_reconnect(self):
with (
patch("parsedmarc.postgres.psycopg") as mock_psycopg,
_without_libpq_env(),
):
mock_psycopg.Error = Exception
with self.assertLogs("parsedmarc.log", level="WARNING") as cm:
client = PostgreSQLClient(host="db.example")
mock_psycopg.connect.return_value.closed = True
client._ensure_connected()
self.assertEqual(mock_psycopg.connect.call_count, 2)
self.assertEqual(
mock_psycopg.connect.call_args_list[1].kwargs["sslmode"], "require"
)
tls = [m for m in cm.output if "does not verify the server" in m]
self.assertEqual(len(tls), 1)
# -- the real driver against a server that declines TLS ------------------
@unittest.skipUnless(PSYCOPG_INSTALLED, "psycopg is not installed")
def test_real_psycopg_sends_nothing_after_tls_is_declined(self):
"""With the default sslmode, libpq gives up when the server answers
the SSLRequest with "N": no startup packet (which names the user),
let alone a password, follows. The same server with
``sslmode="disable"`` receives the startup packet, so the server
does observe what it claims to. Before the fix, libpq's ``prefer``
sent the startup packet over plain TCP after the "N" and then
answered a clear-text password request."""
with _without_libpq_env():
server = _TLSDecliningServer()
with self.assertRaises(PostgreSQLError) as ctx:
PostgreSQLClient(
host="127.0.0.1",
port=server.port,
user="u",
password=_TLS_TEST_PASSWORD,
database="d",
)
server.join()
message = str(ctx.exception)
self.assertIn("SSL", message)
self.assertNotIn(_TLS_TEST_PASSWORD, message)
self.assertNotIn("s3c", message)
self.assertTrue(server.ssl_requested)
self.assertEqual(server.after_refusal, b"")
with _without_libpq_env():
server = _TLSDecliningServer()
with self.assertRaises(PostgreSQLError):
PostgreSQLClient(
host="127.0.0.1",
port=server.port,
user="u",
password=_TLS_TEST_PASSWORD,
database="d",
sslmode="disable",
)
server.join()
self.assertFalse(server.ssl_requested)
self.assertIn(b"user\x00u\x00", server.startup)
# PostgreSQL protocol request codes (protocol docs, "Message Formats"):
# SSLRequest and GSSENCRequest are an Int32 length of 8 and one of these.
_SSL_REQUEST_CODE = 80877103
_GSSENC_REQUEST_CODE = 80877104
class _TLSDecliningServer:
"""A one-connection loopback server that declines encryption.
It answers an SSLRequest (and a GSSENCRequest, which libpq built with
GSSAPI may send first) with "N", then records every byte the client
sends afterwards until the client closes or 1 s passes. A first
message that is not such a request is a startup packet, recorded the
same way. No authentication request is ever sent, so the client fails.
"""
def __init__(self):
self._sock = socket.create_server(("127.0.0.1", 0))
self.port = self._sock.getsockname()[1]
self.ssl_requested = False
self.after_refusal = b""
self.startup = b""
self._thread = threading.Thread(target=self._serve, daemon=True)
self._thread.start()
def join(self):
self._thread.join(timeout=10)
self._sock.close()
@staticmethod
def _read_until_idle(conn) -> bytes:
data = b""
conn.settimeout(1.0)
try:
while chunk := conn.recv(4096):
data += chunk
except (TimeoutError, OSError):
pass
return data
def _serve(self):
self._sock.settimeout(10)
conn, _ = self._sock.accept()
with conn:
conn.settimeout(5)
while True:
first = b""
while len(first) < 8:
chunk = conn.recv(8 - len(first))
if not chunk:
return
first += chunk
length, code = struct.unpack("!ii", first)
if length == 8 and code == _GSSENC_REQUEST_CODE:
conn.sendall(b"N")
continue
if length == 8 and code == _SSL_REQUEST_CODE:
self.ssl_requested = True
conn.sendall(b"N")
self.after_refusal = self._read_until_idle(conn)
else:
self.startup = first + self._read_until_idle(conn)
return