mirror of
https://github.com/domainaware/parsedmarc.git
synced 2026-09-05 13:38:00 +00:00
* Make the output and mailbox integrations optional extras (#883) Breaking change for the next major release: pip install parsedmarc now installs the parsing core plus a working core CLI (file, IMAP, Maildir, and mbox input; CSV/JSON, Splunk HEC, webhook, and syslog output). Everything else moves behind an extra: elastic, opensearch, kafka, s3, gelf, loganalytics, msgraph, and gmail, joining the existing postgresql extra, with an umbrella [all] that deliberately excludes postgresql (psycopg's binary wheels do not exist on every platform, so parsedmarc[all] must never fail to install there). cli.py imports the six SDK-dependent output modules behind the #884 TYPE_CHECKING/try-except guard; a configured section whose extra is missing fails fast with a ConfigurationError naming the section and the exact pip install command — including the msgraph and gmail_api mailbox sections (detected via parsedmarc.mail's placeholder classes) and postgresql (checked before the constructor so the startup retry loop does not retry a missing dependency for a minute). The Azure/kiota Graph error types fall back to never-raised sentinel classes. The Docker image installs [all,postgresql], so container users see no change. CI lint installs [build,all,postgresql]; the unit-test job installs [build,all], deliberately without postgresql so test_postgres.py's absent-psycopg arm stays exercised. The never-imported dateparser dependency is dropped in favor of declaring python-dateutil, which utils.py actually imports; pytz moves to the build extra for the one test that uses it. Verified live: a no-extras wheel install imports, parses samples, and reports the install hint for each gated section; a [all] install restores every integration; the Docker image builds with every SDK importable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Patch psycopg presence in the PostgreSQL CLI wiring tests CI's unit-test job deliberately installs [build,all] without the postgresql extra, so parsedmarc.cli.postgres.psycopg is None there and the new missing-extra presence check correctly made _main exit 1 before the wiring under test ran. The tests simulate the SDK being available (PostgreSQLClient is mocked at the SDK boundary), so the module-level psycopg handle is now patched present in setUp. Verified against a simulated psycopg-absent environment as well as the local full install. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address Copilot review: narrow guards to ModuleNotFoundError, fix docs - The optional-integration and Graph error-type import guards now catch ModuleNotFoundError instead of ImportError, so only a genuinely absent package reads as a missing extra; a broken-but-present SDK fails loudly with its real error instead of masquerading as one. The test blocker raises ModuleNotFoundError accordingly — the exact exception a missing package produces. - _missing_extra_hint docstring no longer calls every gated integration an output module (it also serves the msgraph/gmail_api mailbox sections). - Fix the pre-existing passsword typo in usage.md's kafka section; the INI key the code reads is password (cli.py _parse_config). Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Quote extras specs in copy-paste install commands From Copilot's second review round: zsh treats an unquoted .[build,all] as a glob and fails with 'no matches found', so the commands shown in AGENTS.md, CONTRIBUTING.md, dashboards/README.md, and the bootstrap script's comment are now quoted. The CI workflows keep the unquoted form: they run under bash, which passes unmatched globs through literally. The suggestion to change the 'Choosing what to install' heading level was rejected — it is a subsection of 'Installing parsedmarc', matching the file's existing hierarchy. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix upgrade command in the changelog * Documentation review: accuracy, spelling, grammar, and clarity pass A full prose review of docs/source, README, CONTRIBUTING, and the dashboards README, with every accuracy claim verified against the code before changing it. Highlights: - usage.md: documented six missing [general] options (the CSV/JSON filename options, prettify_json, normalize_timespan_threshold_hours), the required kafka smtp_tls_topic, [imap] timeout/max_retries, and the postgresql env-var prefix; corrected the maildir_path default (None, not INBOX — cli.py Namespace defaults), the mailbox check_timeout option name, the systemd restart interval (RestartSec is 5m), and merged the duplicate silent entry; quoted every copy-paste extras spec for zsh safety. - elasticsearch.md: fixed an invalid openssl command (rsa:4096 -nodes), the dashboards filename (opensearch_dashboards.ndjson, matching the file the link serves), and assorted grammar. - davmail.md: the service-enable command now enables davmail.service (was parsedmarc.service — a copy-paste error that left DavMail unenabled), plus a view typo and DavMail capitalization. - output.md: the example schema reference is RFC 7489 Appendix C (7480 is RDAP). kibana.md: SPF relies on the SMTP envelope, not session headers (RFC 7208). dmarc.md: DKM -> DKIM. - README: the intro now also names the OpenSearch/Grafana stack, matching the feature list. CONTRIBUTING: pre-PR checks now include ruff format --check and pyright, matching CI's lint job. - dashboards/README: the service table and seed description now include the PostgreSQL backend the compose stack runs. Sample data blocks, the CLI-help mirror block, and released CHANGELOG entries were deliberately left untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Docstring review: accuracy, spelling, grammar, and clarity pass Every docstring in parsedmarc/, parsedmarc/mail/, the maps maintainer scripts, and the test suite reviewed with each claim verified against the code it documents. Text-only — no behavior changes. Highlights: - Copy-paste errors corrected: parsed_smtp_tls_reports_to_csv and splunk/loganalytics save functions described aggregate or failure reports they do not handle; LogAnalyticsException claimed to be an Elasticsearch error. - Docstring/behavior mismatches: parse_report_email's report_type enumeration omitted smtp_tls; parse_failure_report typed msg_date as str (it is datetime); strip_attachment_payloads claimed payloads are replaced with None (the key is deleted); kafkaclient's failure and SMTP TLS savers claimed per-record slicing while sending the whole list in one message (docstrings now describe reality — whether slicing was intended is flagged for follow-up); the postgres savers claimed to take parse_report_file's return value but receive the inner report dict; elastic/opensearch save functions' Raises listed only AlreadySaved. - None-as-semantic-state documented where missing (get_base_domain, get_ip_address_country), enumeration completeness fixed (get_ip_address_info's 9 result keys, maps script outputs, TSV columns), and the stale 44-industry-types count corrected to the 46 the authoritative README list defines. - Test docstrings aligned with what the tests actually assert, including two that overstated coverage of the elastic/opensearch address-list tests. - Two argparse help strings fixed: file_path now names SMTP TLS report files alongside aggregate and failure, mirrored into usage.md's CLI-help block; --offline's doubled spaces removed (rendered help unchanged). - elasticsearch.md's security claim corrected against Elastic's docs: security is enabled and auto-configured on first startup since 8.0 (not "8.7 secure mode"), so the settings are verified, not hand-written. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
340 lines
12 KiB
Python
340 lines
12 KiB
Python
"""Tests for parsedmarc.gelf"""
|
|
|
|
import logging
|
|
import unittest
|
|
from typing import Any, cast
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from parsedmarc.gelf import ContextFilter, GelfClient, log_context_data
|
|
from parsedmarc.types import AggregateReport, FailureReport, SMTPTLSReport
|
|
|
|
|
|
def _sample_aggregate_report() -> AggregateReport:
|
|
"""Minimal aggregate report shape acceptable to
|
|
parsed_aggregate_reports_to_csv_rows."""
|
|
report = {
|
|
"xml_schema": "draft",
|
|
"xml_namespace": None,
|
|
"report_metadata": {
|
|
"org_name": "example.com",
|
|
"org_email": "dmarc@example.com",
|
|
"org_extra_contact_info": None,
|
|
"report_id": "agg-1",
|
|
"begin_date": "2024-01-01 00:00:00",
|
|
"end_date": "2024-01-02 00:00:00",
|
|
"timespan_requires_normalization": False,
|
|
"original_timespan_seconds": 86400,
|
|
"errors": [],
|
|
"generator": None,
|
|
},
|
|
"policy_published": {
|
|
"domain": "example.com",
|
|
"adkim": "r",
|
|
"aspf": "r",
|
|
"p": "none",
|
|
"sp": "none",
|
|
"pct": None,
|
|
"fo": None,
|
|
"np": None,
|
|
"testing": None,
|
|
"discovery_method": None,
|
|
},
|
|
"records": [
|
|
{
|
|
"interval_begin": "2024-01-01 00:00:00",
|
|
"interval_end": "2024-01-02 00:00:00",
|
|
"normalized_timespan": False,
|
|
"source": {
|
|
"ip_address": "192.0.2.1",
|
|
"country": "US",
|
|
"reverse_dns": None,
|
|
"base_domain": None,
|
|
"name": None,
|
|
"type": None,
|
|
"asn": 64496,
|
|
"as_name": "Example AS",
|
|
"as_domain": "example.net",
|
|
},
|
|
"count": 7,
|
|
"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,
|
|
},
|
|
"auth_results": {
|
|
"dkim": [
|
|
{
|
|
"domain": "example.com",
|
|
"selector": "s1",
|
|
"result": "pass",
|
|
"human_result": None,
|
|
}
|
|
],
|
|
"spf": [
|
|
{
|
|
"domain": "example.com",
|
|
"scope": "mfrom",
|
|
"result": "pass",
|
|
"human_result": None,
|
|
}
|
|
],
|
|
},
|
|
}
|
|
],
|
|
}
|
|
return cast(AggregateReport, report)
|
|
|
|
|
|
class _Handler(logging.Handler):
|
|
"""Capture the (message, parsedmarc payload) of every log emit, so
|
|
tests can assert on what GelfClient actually pushed."""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.records: list[tuple[str, Any]] = []
|
|
|
|
def emit(self, record):
|
|
# ContextFilter has run by this point so `record.parsedmarc` is
|
|
# whatever payload GelfClient set via log_context_data.
|
|
self.records.append((record.getMessage(), getattr(record, "parsedmarc", None)))
|
|
|
|
|
|
class TestGelfClientInit(unittest.TestCase):
|
|
"""GelfClient.__init__ wires a pygelf handler for the requested
|
|
transport. The mode lookup is a real failure surface: a typo in the
|
|
config (`udb` instead of `udp`) should KeyError loudly, not silently
|
|
pick the wrong transport."""
|
|
|
|
def test_init_udp_picks_udp_handler(self):
|
|
with (
|
|
patch("parsedmarc.gelf.GelfUdpHandler") as mock_udp,
|
|
patch("parsedmarc.gelf.GelfTcpHandler"),
|
|
patch("parsedmarc.gelf.GelfTlsHandler"),
|
|
):
|
|
GelfClient(host="graylog.example.com", port=12201, mode="udp")
|
|
mock_udp.assert_called_once_with(
|
|
host="graylog.example.com", port=12201, include_extra_fields=True
|
|
)
|
|
|
|
def test_init_tcp_picks_tcp_handler(self):
|
|
with (
|
|
patch("parsedmarc.gelf.GelfTcpHandler") as mock_tcp,
|
|
patch("parsedmarc.gelf.GelfUdpHandler"),
|
|
patch("parsedmarc.gelf.GelfTlsHandler"),
|
|
):
|
|
GelfClient(host="g", port=12201, mode="tcp")
|
|
mock_tcp.assert_called_once_with(
|
|
host="g", port=12201, include_extra_fields=True
|
|
)
|
|
|
|
def test_init_tls_picks_tls_handler(self):
|
|
with (
|
|
patch("parsedmarc.gelf.GelfTlsHandler") as mock_tls,
|
|
patch("parsedmarc.gelf.GelfUdpHandler"),
|
|
patch("parsedmarc.gelf.GelfTcpHandler"),
|
|
):
|
|
GelfClient(host="g", port=12201, mode="tls")
|
|
mock_tls.assert_called_once_with(
|
|
host="g", port=12201, include_extra_fields=True
|
|
)
|
|
|
|
def test_init_unknown_mode_raises_keyerror(self):
|
|
"""An unknown mode in config should be a loud failure, not silent."""
|
|
with (
|
|
patch("parsedmarc.gelf.GelfUdpHandler"),
|
|
patch("parsedmarc.gelf.GelfTcpHandler"),
|
|
patch("parsedmarc.gelf.GelfTlsHandler"),
|
|
):
|
|
with self.assertRaises(KeyError):
|
|
GelfClient(host="g", port=12201, mode="udb")
|
|
|
|
|
|
def _install_capturing_handler(client):
|
|
"""Replace the real pygelf handler with one that records emitted
|
|
log records and their `parsedmarc` payload. Returns the handler
|
|
so the test can inspect captured records."""
|
|
client.logger.removeHandler(client.handler)
|
|
h = _Handler()
|
|
client.logger.addHandler(h)
|
|
client.handler = h
|
|
return h
|
|
|
|
|
|
def _gelf_client():
|
|
# The parsedmarc_gelf logger is module-level — each new client adds
|
|
# another handler. Clear stale handlers from prior tests so the
|
|
# logger only carries this client's handler.
|
|
logging.getLogger("parsedmarc_gelf").handlers.clear()
|
|
with (
|
|
patch("parsedmarc.gelf.GelfUdpHandler"),
|
|
patch("parsedmarc.gelf.GelfTcpHandler"),
|
|
patch("parsedmarc.gelf.GelfTlsHandler"),
|
|
):
|
|
return GelfClient(host="g", port=12201, mode="udp")
|
|
|
|
|
|
class TestGelfClientSaveAggregate(unittest.TestCase):
|
|
"""save_aggregate_report_to_gelf emits one log record per
|
|
aggregate CSV row, with the row payload on `record.parsedmarc`.
|
|
Verifying the payload — not just "log was called" — catches future
|
|
regressions in the row-builder or filter wiring."""
|
|
|
|
def test_emits_one_record_per_csv_row_with_payload(self):
|
|
client = _gelf_client()
|
|
handler = _install_capturing_handler(client)
|
|
client.save_aggregate_report_to_gelf([_sample_aggregate_report()])
|
|
# One row in the sample report → one log record.
|
|
self.assertEqual(len(handler.records), 1)
|
|
message, payload = handler.records[0]
|
|
self.assertEqual(message, "parsedmarc aggregate report")
|
|
# The payload is the flattened CSV row; verify the key fields a
|
|
# Graylog dashboard would actually filter on.
|
|
self.assertEqual(payload["source_ip_address"], "192.0.2.1")
|
|
self.assertEqual(payload["header_from"], "example.com")
|
|
self.assertEqual(payload["count"], 7)
|
|
|
|
def test_clears_context_after_emit(self):
|
|
"""The thread-local payload is reset to None after the loop so
|
|
a later unrelated log call on the same thread doesn't carry
|
|
stale DMARC data."""
|
|
client = _gelf_client()
|
|
_install_capturing_handler(client)
|
|
client.save_aggregate_report_to_gelf([_sample_aggregate_report()])
|
|
self.assertIsNone(log_context_data.parsedmarc)
|
|
|
|
|
|
class TestGelfClientSaveFailure(unittest.TestCase):
|
|
"""save_failure_report_to_gelf operates on already-parsed failure
|
|
reports, flattening each through the CSV-row helper; verify
|
|
GelfClient surfaces the right fields."""
|
|
|
|
def _sample_failure_report(self) -> FailureReport:
|
|
report = {
|
|
"feedback_type": "auth-failure",
|
|
"user_agent": "test/1.0",
|
|
"version": "1",
|
|
"original_envelope_id": None,
|
|
"original_mail_from": "x@example.com",
|
|
"original_rcpt_to": None,
|
|
"arrival_date": "Thu, 1 Jan 2024 00:00:00 +0000",
|
|
"arrival_date_utc": "2024-01-01 00:00:00",
|
|
"authentication_results": None,
|
|
"delivery_result": "other",
|
|
"auth_failure": ["dmarc"],
|
|
"authentication_mechanisms": [],
|
|
"dkim_domain": None,
|
|
"reported_domain": "example.com",
|
|
"sample_headers_only": True,
|
|
"source": {
|
|
"ip_address": "192.0.2.5",
|
|
"country": "US",
|
|
"reverse_dns": None,
|
|
"base_domain": None,
|
|
"name": None,
|
|
"type": None,
|
|
"asn": 64496,
|
|
"as_name": "Example AS",
|
|
"as_domain": "example.net",
|
|
},
|
|
"sample": "...",
|
|
"parsed_sample": {"subject": "Test"},
|
|
}
|
|
return cast(FailureReport, report)
|
|
|
|
def test_emits_one_record_per_failure_report(self):
|
|
client = _gelf_client()
|
|
handler = _install_capturing_handler(client)
|
|
client.save_failure_report_to_gelf([self._sample_failure_report()])
|
|
self.assertEqual(len(handler.records), 1)
|
|
message, payload = handler.records[0]
|
|
self.assertEqual(message, "parsedmarc failure report")
|
|
self.assertEqual(payload["source_ip_address"], "192.0.2.5")
|
|
self.assertEqual(payload["reported_domain"], "example.com")
|
|
|
|
|
|
class TestGelfClientSaveSmtpTls(unittest.TestCase):
|
|
def _sample_smtp_tls(self) -> SMTPTLSReport:
|
|
report = {
|
|
"organization_name": "example.com",
|
|
"begin_date": "2024-02-03T00:00:00Z",
|
|
"end_date": "2024-02-04T00:00:00Z",
|
|
"contact_info": "tls@example.com",
|
|
"report_id": "tls-1",
|
|
"policies": [
|
|
{
|
|
"policy_domain": "example.com",
|
|
"policy_type": "sts",
|
|
"successful_session_count": 100,
|
|
"failed_session_count": 0,
|
|
}
|
|
],
|
|
}
|
|
return cast(SMTPTLSReport, report)
|
|
|
|
def test_emits_one_record_per_policy(self):
|
|
client = _gelf_client()
|
|
handler = _install_capturing_handler(client)
|
|
client.save_smtp_tls_report_to_gelf([self._sample_smtp_tls()])
|
|
self.assertEqual(len(handler.records), 1)
|
|
message, payload = handler.records[0]
|
|
self.assertEqual(message, "parsedmarc smtptls report")
|
|
self.assertEqual(payload["policy_domain"], "example.com")
|
|
self.assertEqual(payload["successful_session_count"], 100)
|
|
|
|
|
|
class TestContextFilter(unittest.TestCase):
|
|
"""ContextFilter copies log_context_data.parsedmarc onto the log
|
|
record so pygelf can include it as an extra field. Failure mode:
|
|
if the filter raises (or removes itself), GELF output goes dark."""
|
|
|
|
def test_filter_copies_thread_local_onto_record(self):
|
|
log_context_data.parsedmarc = {"hello": "world"}
|
|
try:
|
|
f = ContextFilter()
|
|
record = logging.LogRecord(
|
|
name="x",
|
|
level=logging.INFO,
|
|
pathname=__file__,
|
|
lineno=1,
|
|
msg="msg",
|
|
args=(),
|
|
exc_info=None,
|
|
)
|
|
result = f.filter(record)
|
|
self.assertTrue(result)
|
|
self.assertEqual(record.parsedmarc, {"hello": "world"}) # type: ignore[attr-defined]
|
|
finally:
|
|
log_context_data.parsedmarc = None
|
|
|
|
|
|
class TestGelfClientClose(unittest.TestCase):
|
|
def test_close_removes_and_closes_handler(self):
|
|
client = _gelf_client()
|
|
handler = MagicMock()
|
|
client.logger.removeHandler(client.handler)
|
|
client.logger.addHandler(handler)
|
|
client.handler = handler
|
|
client.close()
|
|
handler.close.assert_called_once()
|
|
# Handler should no longer be attached after close().
|
|
self.assertNotIn(handler, client.logger.handlers)
|
|
|
|
|
|
class TestGelfClientBackwardCompatAlias(unittest.TestCase):
|
|
def test_forensic_alias_points_to_failure_method(self):
|
|
self.assertIs(
|
|
GelfClient.save_forensic_report_to_gelf,
|
|
GelfClient.save_failure_report_to_gelf,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|