Merge pull request #180 from christianlouis/codex/m11-compat-fixtures-docs
[codex] Add DMARC compatibility fixture pack
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<feedback>
|
||||
<report_metadata>
|
||||
<org_name>google.com</org_name>
|
||||
<email>noreply-dmarc-support@google.com</email>
|
||||
<report_id>123456789</report_id>
|
||||
<date_range>
|
||||
<begin>1597449600</begin>
|
||||
<end>1597535999</end>
|
||||
</date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>example.com</domain>
|
||||
<adkim>r</adkim>
|
||||
<aspf>r</aspf>
|
||||
<p>none</p>
|
||||
<sp>none</sp>
|
||||
<pct>100</pct>
|
||||
</policy_published>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>203.0.113.1</source_ip>
|
||||
<count>2</count>
|
||||
<policy_evaluated>
|
||||
<disposition>none</disposition>
|
||||
<dkim>pass</dkim>
|
||||
<spf>fail</spf>
|
||||
</policy_evaluated>
|
||||
</row>
|
||||
<identifiers>
|
||||
<header_from>example.com</header_from>
|
||||
</identifiers>
|
||||
<auth_results>
|
||||
<dkim>
|
||||
<domain>example.com</domain>
|
||||
<result>pass</result>
|
||||
<selector>default</selector>
|
||||
</dkim>
|
||||
<spf>
|
||||
<domain>example.com</domain>
|
||||
<result>fail</result>
|
||||
</spf>
|
||||
</auth_results>
|
||||
</record>
|
||||
</feedback>
|
||||
@@ -0,0 +1,100 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<feedback xmlns="urn:ietf:params:xml:ns:dmarc-2.0" xmlns:vendor="https://reports.example.test/dmarc">
|
||||
<version>1.0</version>
|
||||
<report_metadata>
|
||||
<org_name>Mailbox Provider</org_name>
|
||||
<email>dmarc-reports@mailbox.example</email>
|
||||
<extra_contact_info>mailto:dmarc-help@mailbox.example</extra_contact_info>
|
||||
<report_id>fixture-rfc9990-multi-auth</report_id>
|
||||
<date_range>
|
||||
<begin>1779580800</begin>
|
||||
<end>1779667199</end>
|
||||
</date_range>
|
||||
<generator>MailboxRUA 2026.05</generator>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>example.net</domain>
|
||||
<discovery_method>psd</discovery_method>
|
||||
<p>reject</p>
|
||||
<sp>quarantine</sp>
|
||||
<np>reject</np>
|
||||
<fo>1:d:s</fo>
|
||||
<adkim>r</adkim>
|
||||
<aspf>s</aspf>
|
||||
<pct>75</pct>
|
||||
<testing>n</testing>
|
||||
</policy_published>
|
||||
<extension>
|
||||
<vendor:provider>
|
||||
<vendor:name>Mailbox Provider</vendor:name>
|
||||
<vendor:region>us-east</vendor:region>
|
||||
</vendor:provider>
|
||||
</extension>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>192.0.2.44</source_ip>
|
||||
<count>10</count>
|
||||
<policy_evaluated>
|
||||
<disposition>reject</disposition>
|
||||
<dkim>fail</dkim>
|
||||
<spf>fail</spf>
|
||||
<reason>
|
||||
<type>forwarded</type>
|
||||
<comment>forwarder modified body</comment>
|
||||
</reason>
|
||||
</policy_evaluated>
|
||||
</row>
|
||||
<identifiers>
|
||||
<header_from>billing.example.net</header_from>
|
||||
<envelope_from>bounce.example.net</envelope_from>
|
||||
<envelope_to>customer.example.org</envelope_to>
|
||||
</identifiers>
|
||||
<auth_results>
|
||||
<dkim>
|
||||
<domain>example.net</domain>
|
||||
<selector>mail</selector>
|
||||
<result>fail</result>
|
||||
<human_result>signature expired</human_result>
|
||||
</dkim>
|
||||
<dkim>
|
||||
<domain>vendor.example</domain>
|
||||
<selector>relay</selector>
|
||||
<result>pass</result>
|
||||
</dkim>
|
||||
<spf>
|
||||
<domain>bounce.example.net</domain>
|
||||
<scope>mfrom</scope>
|
||||
<result>fail</result>
|
||||
<human_result>ip not authorized</human_result>
|
||||
</spf>
|
||||
</auth_results>
|
||||
<vendor:category>transactional</vendor:category>
|
||||
</record>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>198.51.100.77</source_ip>
|
||||
<count>5</count>
|
||||
<policy_evaluated>
|
||||
<disposition>none</disposition>
|
||||
<dkim>pass</dkim>
|
||||
<spf>fail</spf>
|
||||
</policy_evaluated>
|
||||
</row>
|
||||
<identifiers>
|
||||
<header_from>news.example.net</header_from>
|
||||
<envelope_from>mailer.example.net</envelope_from>
|
||||
</identifiers>
|
||||
<auth_results>
|
||||
<dkim>
|
||||
<domain>example.net</domain>
|
||||
<selector>news</selector>
|
||||
<result>pass</result>
|
||||
</dkim>
|
||||
<spf>
|
||||
<domain>mailer.example.net</domain>
|
||||
<scope>mfrom</scope>
|
||||
<result>fail</result>
|
||||
</spf>
|
||||
</auth_results>
|
||||
</record>
|
||||
</feedback>
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<feedback xmlns="urn:ietf:params:xml:ns:dmarc-2.0">
|
||||
<report_metadata>
|
||||
<org_name>web.de</org_name>
|
||||
<email>dmarc@web.de</email>
|
||||
<report_id>987654321</report_id>
|
||||
<date_range>
|
||||
<begin>1597449600</begin>
|
||||
<end>1597535999</end>
|
||||
</date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>example.com</domain>
|
||||
<adkim>r</adkim>
|
||||
<aspf>r</aspf>
|
||||
<p>reject</p>
|
||||
<sp>reject</sp>
|
||||
<pct>100</pct>
|
||||
</policy_published>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>198.51.100.5</source_ip>
|
||||
<count>3</count>
|
||||
<policy_evaluated>
|
||||
<disposition>reject</disposition>
|
||||
<dkim>pass</dkim>
|
||||
<spf>pass</spf>
|
||||
</policy_evaluated>
|
||||
</row>
|
||||
<identifiers>
|
||||
<header_from>example.com</header_from>
|
||||
</identifiers>
|
||||
<auth_results>
|
||||
<dkim>
|
||||
<domain>example.com</domain>
|
||||
<result>pass</result>
|
||||
<selector>s1</selector>
|
||||
</dkim>
|
||||
<spf>
|
||||
<domain>example.com</domain>
|
||||
<result>pass</result>
|
||||
</spf>
|
||||
</auth_results>
|
||||
</record>
|
||||
</feedback>
|
||||
@@ -0,0 +1,65 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<feedback xmlns="urn:ietf:params:xml:ns:dmarc-2.0" xmlns:vendor="https://reports.example.test/dmarc">
|
||||
<version>1.0</version>
|
||||
<report_metadata>
|
||||
<org_name>Example Receiver</org_name>
|
||||
<email>dmarc@example.test</email>
|
||||
<extra_contact_info>https://example.test/dmarc</extra_contact_info>
|
||||
<report_id>fixture-rfc9990-treewalk</report_id>
|
||||
<date_range>
|
||||
<begin>1779494400</begin>
|
||||
<end>1779580799</end>
|
||||
</date_range>
|
||||
<error>Multiple DMARC records were ignored before treewalk.</error>
|
||||
<generator>ExampleRUA 2.0</generator>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>example.org</domain>
|
||||
<discovery_method>treewalk</discovery_method>
|
||||
<p>quarantine</p>
|
||||
<sp>reject</sp>
|
||||
<np>none</np>
|
||||
<fo>1</fo>
|
||||
<adkim>s</adkim>
|
||||
<aspf>r</aspf>
|
||||
<testing>y</testing>
|
||||
</policy_published>
|
||||
<extension>
|
||||
<vendor:receiver>mx1.example.test</vendor:receiver>
|
||||
</extension>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>2001:db8::1</source_ip>
|
||||
<count>5</count>
|
||||
<policy_evaluated>
|
||||
<disposition>quarantine</disposition>
|
||||
<dkim>fail</dkim>
|
||||
<spf>pass</spf>
|
||||
<reason>
|
||||
<type>local_policy</type>
|
||||
<comment>trusted relay</comment>
|
||||
</reason>
|
||||
</policy_evaluated>
|
||||
</row>
|
||||
<identifiers>
|
||||
<header_from>news.example.org</header_from>
|
||||
<envelope_from>bounce.example.org</envelope_from>
|
||||
<envelope_to>customer.example.net</envelope_to>
|
||||
</identifiers>
|
||||
<auth_results>
|
||||
<dkim>
|
||||
<domain>example.net</domain>
|
||||
<selector>selector1</selector>
|
||||
<result>fail</result>
|
||||
<human_result>body hash did not verify</human_result>
|
||||
</dkim>
|
||||
<spf>
|
||||
<domain>bounce.example.org</domain>
|
||||
<scope>mfrom</scope>
|
||||
<result>pass</result>
|
||||
<human_result>sender authorized</human_result>
|
||||
</spf>
|
||||
</auth_results>
|
||||
<vendor:source>mail-platform</vendor:source>
|
||||
</record>
|
||||
</feedback>
|
||||
@@ -1,97 +1,69 @@
|
||||
"""Shared test data for DMARC report tests."""
|
||||
|
||||
SAMPLE_XML = """\
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<feedback>
|
||||
<report_metadata>
|
||||
<org_name>google.com</org_name>
|
||||
<email>noreply-dmarc-support@google.com</email>
|
||||
<report_id>123456789</report_id>
|
||||
<date_range>
|
||||
<begin>1597449600</begin>
|
||||
<end>1597535999</end>
|
||||
</date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>example.com</domain>
|
||||
<adkim>r</adkim>
|
||||
<aspf>r</aspf>
|
||||
<p>none</p>
|
||||
<sp>none</sp>
|
||||
<pct>100</pct>
|
||||
</policy_published>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>203.0.113.1</source_ip>
|
||||
<count>2</count>
|
||||
<policy_evaluated>
|
||||
<disposition>none</disposition>
|
||||
<dkim>pass</dkim>
|
||||
<spf>fail</spf>
|
||||
</policy_evaluated>
|
||||
</row>
|
||||
<identifiers>
|
||||
<header_from>example.com</header_from>
|
||||
</identifiers>
|
||||
<auth_results>
|
||||
<dkim>
|
||||
<domain>example.com</domain>
|
||||
<result>pass</result>
|
||||
<selector>default</selector>
|
||||
</dkim>
|
||||
<spf>
|
||||
<domain>example.com</domain>
|
||||
<result>fail</result>
|
||||
</spf>
|
||||
</auth_results>
|
||||
</record>
|
||||
</feedback>
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
SAMPLE_XML_WITH_NAMESPACE = """\
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<feedback xmlns="urn:ietf:params:xml:ns:dmarc-2.0">
|
||||
<report_metadata>
|
||||
<org_name>web.de</org_name>
|
||||
<email>dmarc@web.de</email>
|
||||
<report_id>987654321</report_id>
|
||||
<date_range>
|
||||
<begin>1597449600</begin>
|
||||
<end>1597535999</end>
|
||||
</date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>example.com</domain>
|
||||
<adkim>r</adkim>
|
||||
<aspf>r</aspf>
|
||||
<p>reject</p>
|
||||
<sp>reject</sp>
|
||||
<pct>100</pct>
|
||||
</policy_published>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>198.51.100.5</source_ip>
|
||||
<count>3</count>
|
||||
<policy_evaluated>
|
||||
<disposition>reject</disposition>
|
||||
<dkim>pass</dkim>
|
||||
<spf>pass</spf>
|
||||
</policy_evaluated>
|
||||
</row>
|
||||
<identifiers>
|
||||
<header_from>example.com</header_from>
|
||||
</identifiers>
|
||||
<auth_results>
|
||||
<dkim>
|
||||
<domain>example.com</domain>
|
||||
<result>pass</result>
|
||||
<selector>s1</selector>
|
||||
</dkim>
|
||||
<spf>
|
||||
<domain>example.com</domain>
|
||||
<result>pass</result>
|
||||
</spf>
|
||||
</auth_results>
|
||||
</record>
|
||||
</feedback>
|
||||
"""
|
||||
|
||||
DMARC_FIXTURE_DIR = Path(__file__).with_name("fixtures") / "dmarc_aggregate"
|
||||
|
||||
|
||||
def load_dmarc_fixture(filename: str) -> str:
|
||||
"""Load a curated aggregate-report fixture as text."""
|
||||
return (DMARC_FIXTURE_DIR / filename).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
DMARC_COMPATIBILITY_FIXTURES = [
|
||||
{
|
||||
"id": "rfc7489-google",
|
||||
"filename": "rfc7489-google.xml",
|
||||
"domain": "example.com",
|
||||
"report_id": "123456789",
|
||||
"variant": "rfc7489-compatible",
|
||||
"total_count": 2,
|
||||
},
|
||||
{
|
||||
"id": "rfc9990-namespaced-legacy-fields",
|
||||
"filename": "rfc9990-namespaced-legacy-fields.xml",
|
||||
"domain": "example.com",
|
||||
"report_id": "987654321",
|
||||
"variant": "rfc9990",
|
||||
"total_count": 3,
|
||||
},
|
||||
{
|
||||
"id": "rfc9990-treewalk-extension",
|
||||
"filename": "rfc9990-treewalk-extension.xml",
|
||||
"domain": "example.org",
|
||||
"report_id": "fixture-rfc9990-treewalk",
|
||||
"variant": "rfc9990",
|
||||
"schema_version": "1.0",
|
||||
"total_count": 5,
|
||||
"policy": {
|
||||
"p": "quarantine",
|
||||
"sp": "reject",
|
||||
"np": "none",
|
||||
"fo": "1",
|
||||
"testing": "y",
|
||||
"discovery_method": "treewalk",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "rfc9990-multi-auth-overrides",
|
||||
"filename": "rfc9990-multi-auth-overrides.xml",
|
||||
"domain": "example.net",
|
||||
"report_id": "fixture-rfc9990-multi-auth",
|
||||
"variant": "rfc9990",
|
||||
"schema_version": "1.0",
|
||||
"total_count": 15,
|
||||
"policy": {
|
||||
"p": "reject",
|
||||
"sp": "quarantine",
|
||||
"np": "reject",
|
||||
"fo": "1:d:s",
|
||||
"testing": "n",
|
||||
"discovery_method": "psd",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
SAMPLE_XML = load_dmarc_fixture("rfc7489-google.xml")
|
||||
|
||||
SAMPLE_XML_WITH_NAMESPACE = load_dmarc_fixture("rfc9990-namespaced-legacy-fields.xml")
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import base64
|
||||
import email
|
||||
import gzip
|
||||
import io
|
||||
import zipfile
|
||||
from email import encoders as email_encoders
|
||||
from email.mime.application import MIMEApplication
|
||||
from email.mime.base import MIMEBase
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.models.report import DMARCReport
|
||||
from app.services.dmarc_parser import DMARCParser
|
||||
from app.services.gmail_client import GmailClient
|
||||
from app.services.imap_client import IMAPClient
|
||||
from app.tests.test_data import DMARC_COMPATIBILITY_FIXTURES, load_dmarc_fixture
|
||||
|
||||
|
||||
def _fixture_id(fixture: dict) -> str:
|
||||
return fixture["id"]
|
||||
|
||||
|
||||
def _fixture_bytes(fixture: dict) -> bytes:
|
||||
return load_dmarc_fixture(fixture["filename"]).encode("utf-8")
|
||||
|
||||
|
||||
def _zip_xml(xml_content: bytes, filename: str = "report.xml") -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as archive:
|
||||
archive.writestr(filename, xml_content)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _make_mime_attachment(filename: str, content: bytes, content_type: str) -> bytes:
|
||||
msg = MIMEMultipart()
|
||||
msg["Subject"] = "DMARC aggregate report"
|
||||
msg["From"] = "reports@example.test"
|
||||
msg["To"] = "dmarc@example.test"
|
||||
msg.attach(MIMEText("DMARC report attached.", "plain"))
|
||||
|
||||
part = MIMEApplication(content, Name=filename)
|
||||
part["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||
part.set_type(content_type)
|
||||
msg.attach(part)
|
||||
return msg.as_bytes()
|
||||
|
||||
|
||||
def _make_gmail_raw_attachment(filename: str, content: bytes) -> bytes:
|
||||
msg = MIMEMultipart()
|
||||
msg["Subject"] = "DMARC aggregate report"
|
||||
msg["From"] = "reports@example.test"
|
||||
msg["To"] = "dmarc@example.test"
|
||||
msg.attach(MIMEText("DMARC report attached.", "plain"))
|
||||
|
||||
part = MIMEBase("application", "zip")
|
||||
part.set_payload(content)
|
||||
email_encoders.encode_base64(part)
|
||||
part.add_header("Content-Disposition", "attachment", filename=filename)
|
||||
msg.attach(part)
|
||||
return msg.as_bytes()
|
||||
|
||||
|
||||
def _make_imap_client(db_session) -> IMAPClient:
|
||||
with patch("app.services.imap_client.get_settings") as mock_settings:
|
||||
mock_settings.return_value = MagicMock(
|
||||
IMAP_SERVER="imap.example.test",
|
||||
IMAP_PORT=993,
|
||||
IMAP_USERNAME="dmarc@example.test",
|
||||
IMAP_PASSWORD="password",
|
||||
)
|
||||
return IMAPClient(db=db_session)
|
||||
|
||||
|
||||
def _make_gmail_client(db_session) -> GmailClient:
|
||||
with patch("app.services.gmail_client.Credentials") as mock_credentials_class:
|
||||
credentials = MagicMock()
|
||||
credentials.token = "access-token"
|
||||
credentials.refresh_token = "refresh-token"
|
||||
credentials.expired = False
|
||||
mock_credentials_class.return_value = credentials
|
||||
return GmailClient(
|
||||
client_id="client-id",
|
||||
client_secret="client-secret",
|
||||
access_token="access-token",
|
||||
refresh_token="refresh-token",
|
||||
db=db_session,
|
||||
)
|
||||
|
||||
|
||||
def _assert_expected_report(report: dict, fixture: dict) -> None:
|
||||
assert report["domain"] == fixture["domain"]
|
||||
assert report["report_id"] == fixture["report_id"]
|
||||
assert report["variant"] == fixture["variant"]
|
||||
assert report["summary"]["total_count"] == fixture["total_count"]
|
||||
|
||||
for key, value in fixture.get("policy", {}).items():
|
||||
assert report["policy"][key] == value
|
||||
|
||||
|
||||
def _assert_persisted_report(db_session, fixture: dict) -> DMARCReport:
|
||||
db_session.flush()
|
||||
report = db_session.query(DMARCReport).filter_by(report_id=fixture["report_id"]).one()
|
||||
assert report.domain.name == fixture["domain"]
|
||||
assert report.report_variant == fixture["variant"]
|
||||
assert sum(record.count for record in report.records) == fixture["total_count"]
|
||||
return report
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fixture", DMARC_COMPATIBILITY_FIXTURES, ids=_fixture_id)
|
||||
def test_aggregate_compatibility_fixtures_parse_xml_zip_and_gzip(fixture):
|
||||
xml_bytes = _fixture_bytes(fixture)
|
||||
|
||||
xml_report = DMARCParser.parse_file(xml_bytes, fixture["filename"])
|
||||
zip_report = DMARCParser.parse_file(_zip_xml(xml_bytes), fixture["filename"] + ".zip")
|
||||
gzip_report = DMARCParser.parse_file(gzip.compress(xml_bytes), fixture["filename"] + ".gz")
|
||||
|
||||
for report in (xml_report, zip_report, gzip_report):
|
||||
_assert_expected_report(report, fixture)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fixture", DMARC_COMPATIBILITY_FIXTURES, ids=_fixture_id)
|
||||
def test_aggregate_compatibility_fixtures_import_via_upload(
|
||||
client: TestClient, db_session, fixture
|
||||
):
|
||||
zip_bytes = _zip_xml(_fixture_bytes(fixture), fixture["filename"])
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/reports/upload",
|
||||
files={"file": (fixture["filename"] + ".zip", zip_bytes, "application/zip")},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
_assert_persisted_report(db_session, fixture)
|
||||
|
||||
reports = client.get(f"/api/v1/domains/{fixture['domain']}/reports")
|
||||
assert reports.status_code == 200
|
||||
assert reports.json()["reports"][0]["id"] == fixture["report_id"]
|
||||
|
||||
export = client.get(f"/api/v1/domains/{fixture['domain']}/reports/export")
|
||||
assert export.status_code == 200
|
||||
assert fixture["report_id"] in export.text
|
||||
assert "report_variant" in export.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fixture", DMARC_COMPATIBILITY_FIXTURES, ids=_fixture_id)
|
||||
def test_aggregate_compatibility_fixtures_import_via_imap(db_session, fixture):
|
||||
client = _make_imap_client(db_session)
|
||||
zip_bytes = _zip_xml(_fixture_bytes(fixture), fixture["filename"])
|
||||
raw_message = _make_mime_attachment(fixture["filename"] + ".zip", zip_bytes, "application/zip")
|
||||
msg = email.message_from_bytes(raw_message)
|
||||
stats = {"processed": 0, "reports_found": 0, "errors": []}
|
||||
|
||||
count = client._process_attachments(msg, stats, message_id="imap-" + fixture["id"])
|
||||
|
||||
assert count == 1
|
||||
assert stats["details"][0]["status"] == "imported"
|
||||
assert stats["details"][0]["report_id"] == fixture["report_id"]
|
||||
_assert_persisted_report(db_session, fixture)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fixture", DMARC_COMPATIBILITY_FIXTURES, ids=_fixture_id)
|
||||
def test_aggregate_compatibility_fixtures_import_via_gmail(db_session, fixture):
|
||||
client = _make_gmail_client(db_session)
|
||||
zip_bytes = _zip_xml(_fixture_bytes(fixture), fixture["filename"])
|
||||
raw_message = _make_gmail_raw_attachment(fixture["filename"] + ".zip", zip_bytes)
|
||||
msg = email.message_from_bytes(raw_message)
|
||||
stats = {"reports_found": 0, "errors": []}
|
||||
|
||||
count = client._process_attachments(msg, stats, message_id="gmail-" + fixture["id"])
|
||||
|
||||
assert count == 1
|
||||
assert stats["details"][0]["status"] == "imported"
|
||||
assert stats["details"][0]["report_id"] == fixture["report_id"]
|
||||
_assert_persisted_report(db_session, fixture)
|
||||
|
||||
|
||||
def test_gmail_fixture_email_shape_matches_api_raw_encoding():
|
||||
"""The fixture MIME shape can be decoded from Gmail's raw message payload form."""
|
||||
fixture = DMARC_COMPATIBILITY_FIXTURES[2]
|
||||
zip_bytes = _zip_xml(_fixture_bytes(fixture), fixture["filename"])
|
||||
encoded = base64.urlsafe_b64encode(
|
||||
_make_gmail_raw_attachment(fixture["filename"] + ".zip", zip_bytes)
|
||||
)
|
||||
|
||||
decoded = base64.urlsafe_b64decode(encoded)
|
||||
msg = email.message_from_bytes(decoded)
|
||||
|
||||
assert msg["Subject"] == "DMARC aggregate report"
|
||||
assert any(part.get_filename() == fixture["filename"] + ".zip" for part in msg.walk())
|
||||
@@ -4,7 +4,7 @@ import zipfile
|
||||
import pytest
|
||||
|
||||
from app.services.dmarc_parser import DMARCParser
|
||||
from app.tests.test_data import SAMPLE_XML, SAMPLE_XML_WITH_NAMESPACE
|
||||
from app.tests.test_data import SAMPLE_XML, SAMPLE_XML_WITH_NAMESPACE, load_dmarc_fixture
|
||||
|
||||
|
||||
class TestDMARCParser:
|
||||
@@ -132,80 +132,14 @@ class TestDMARCParser:
|
||||
|
||||
def test_parse_rfc9990_style_report_variant(self):
|
||||
"""RFC 9990-era namespaces and optional fields should parse without breaking legacy shape."""
|
||||
xml = b"""
|
||||
<feedback xmlns="urn:ietf:params:xml:ns:dmarc-2.0"
|
||||
xmlns:vendor="https://reports.example.test/dmarc">
|
||||
<version>1.0</version>
|
||||
<report_metadata>
|
||||
<org_name>Example Receiver</org_name>
|
||||
<email>dmarc@example.test</email>
|
||||
<extra_contact_info>https://example.test/dmarc</extra_contact_info>
|
||||
<report_id>2026-05-23-example.org</report_id>
|
||||
<date_range>
|
||||
<begin>1779494400</begin>
|
||||
<end>1779580799</end>
|
||||
</date_range>
|
||||
<error>Multiple DMARC records were ignored before treewalk.</error>
|
||||
<generator>ExampleRUA 2.0</generator>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>example.org</domain>
|
||||
<discovery_method>treewalk</discovery_method>
|
||||
<p>quarantine</p>
|
||||
<sp>reject</sp>
|
||||
<np>none</np>
|
||||
<fo>1</fo>
|
||||
<adkim>s</adkim>
|
||||
<aspf>r</aspf>
|
||||
<testing>y</testing>
|
||||
</policy_published>
|
||||
<extension>
|
||||
<vendor:receiver>mx1.example.test</vendor:receiver>
|
||||
</extension>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>2001:db8::1</source_ip>
|
||||
<count>5</count>
|
||||
<policy_evaluated>
|
||||
<disposition>quarantine</disposition>
|
||||
<dkim>fail</dkim>
|
||||
<spf>pass</spf>
|
||||
<reason>
|
||||
<type>local_policy</type>
|
||||
<comment>trusted relay</comment>
|
||||
</reason>
|
||||
</policy_evaluated>
|
||||
</row>
|
||||
<identifiers>
|
||||
<header_from>news.example.org</header_from>
|
||||
<envelope_from>bounce.example.org</envelope_from>
|
||||
<envelope_to>customer.example.net</envelope_to>
|
||||
</identifiers>
|
||||
<auth_results>
|
||||
<dkim>
|
||||
<domain>example.net</domain>
|
||||
<selector>selector1</selector>
|
||||
<result>fail</result>
|
||||
<human_result>body hash did not verify</human_result>
|
||||
</dkim>
|
||||
<spf>
|
||||
<domain>bounce.example.org</domain>
|
||||
<scope>mfrom</scope>
|
||||
<result>pass</result>
|
||||
<human_result>sender authorized</human_result>
|
||||
</spf>
|
||||
</auth_results>
|
||||
<vendor:source>mail-platform</vendor:source>
|
||||
</record>
|
||||
</feedback>
|
||||
"""
|
||||
xml = load_dmarc_fixture("rfc9990-treewalk-extension.xml").encode("utf-8")
|
||||
|
||||
result = DMARCParser.parse_file(xml, "report.xml")
|
||||
|
||||
assert result["variant"] == "rfc9990"
|
||||
assert result["schema_version"] == "1.0"
|
||||
assert result["xml_namespace"] == "urn:ietf:params:xml:ns:dmarc-2.0"
|
||||
assert result["report_id"] == "2026-05-23-example.org"
|
||||
assert result["report_id"] == "fixture-rfc9990-treewalk"
|
||||
assert result["generator"] == "ExampleRUA 2.0"
|
||||
assert result["errors"] == ["Multiple DMARC records were ignored before treewalk."]
|
||||
assert result["extensions"] == {"receiver": "mx1.example.test"}
|
||||
|
||||
@@ -7,75 +7,10 @@ from app.models.domain import Domain
|
||||
from app.models.report import DMARCReport, ReportRecord
|
||||
from app.services.report_persistence import persisted_report_to_dict
|
||||
from app.services.report_store import ReportStore
|
||||
from app.tests.test_data import SAMPLE_XML
|
||||
from app.tests.test_data import SAMPLE_XML, load_dmarc_fixture
|
||||
|
||||
|
||||
SAMPLE_RFC9990_XML = """
|
||||
<feedback xmlns="urn:ietf:params:xml:ns:dmarc-2.0" xmlns:vendor="https://reports.example.test/dmarc">
|
||||
<version>1.0</version>
|
||||
<report_metadata>
|
||||
<org_name>Example Receiver</org_name>
|
||||
<email>dmarc@example.test</email>
|
||||
<extra_contact_info>https://example.test/dmarc</extra_contact_info>
|
||||
<report_id>2026-05-23-example.org</report_id>
|
||||
<date_range>
|
||||
<begin>1779494400</begin>
|
||||
<end>1779580799</end>
|
||||
</date_range>
|
||||
<error>Multiple records ignored.</error>
|
||||
<generator>ExampleRUA 2.0</generator>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>example.org</domain>
|
||||
<discovery_method>treewalk</discovery_method>
|
||||
<p>quarantine</p>
|
||||
<sp>reject</sp>
|
||||
<np>none</np>
|
||||
<fo>1</fo>
|
||||
<adkim>s</adkim>
|
||||
<aspf>r</aspf>
|
||||
<testing>y</testing>
|
||||
</policy_published>
|
||||
<extension>
|
||||
<vendor:receiver>mx1.example.test</vendor:receiver>
|
||||
</extension>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>2001:db8::1</source_ip>
|
||||
<count>5</count>
|
||||
<policy_evaluated>
|
||||
<disposition>quarantine</disposition>
|
||||
<dkim>fail</dkim>
|
||||
<spf>pass</spf>
|
||||
<reason>
|
||||
<type>local_policy</type>
|
||||
<comment>trusted relay</comment>
|
||||
</reason>
|
||||
</policy_evaluated>
|
||||
</row>
|
||||
<identifiers>
|
||||
<header_from>news.example.org</header_from>
|
||||
<envelope_from>bounce.example.org</envelope_from>
|
||||
<envelope_to>customer.example.net</envelope_to>
|
||||
</identifiers>
|
||||
<auth_results>
|
||||
<dkim>
|
||||
<domain>example.net</domain>
|
||||
<selector>selector1</selector>
|
||||
<result>fail</result>
|
||||
<human_result>body hash did not verify</human_result>
|
||||
</dkim>
|
||||
<spf>
|
||||
<domain>bounce.example.org</domain>
|
||||
<scope>mfrom</scope>
|
||||
<result>pass</result>
|
||||
<human_result>sender authorized</human_result>
|
||||
</spf>
|
||||
</auth_results>
|
||||
<vendor:source>mail-platform</vendor:source>
|
||||
</record>
|
||||
</feedback>
|
||||
"""
|
||||
SAMPLE_RFC9990_XML = load_dmarc_fixture("rfc9990-treewalk-extension.xml")
|
||||
|
||||
|
||||
def _make_zip(xml_content: str) -> bytes:
|
||||
@@ -123,7 +58,7 @@ def test_upload_persists_rfc9990_optional_fields(client: TestClient, db_session)
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
report = db_session.query(DMARCReport).filter_by(report_id="2026-05-23-example.org").one()
|
||||
report = db_session.query(DMARCReport).filter_by(report_id="fixture-rfc9990-treewalk").one()
|
||||
assert report.domain.name == "example.org"
|
||||
assert report.extra_contact_info == "https://example.test/dmarc"
|
||||
assert report.generator == "ExampleRUA 2.0"
|
||||
@@ -134,7 +69,7 @@ def test_upload_persists_rfc9990_optional_fields(client: TestClient, db_session)
|
||||
assert report.failure_options == "1"
|
||||
assert report.testing == "y"
|
||||
assert report.discovery_method == "treewalk"
|
||||
assert "Multiple records ignored." in report.report_errors
|
||||
assert "Multiple DMARC records were ignored before treewalk." in report.report_errors
|
||||
assert "mx1.example.test" in report.report_extensions
|
||||
|
||||
record = db_session.query(ReportRecord).filter_by(report_id=report.id).one()
|
||||
|
||||
@@ -22,3 +22,5 @@ DMARQ is a full-stack DMARC monitoring platform designed to help organizations t
|
||||
To get started with DMARQ, please see the [Getting Started](user_guide/getting_started.md) guide.
|
||||
|
||||
For installation instructions, check the [Docker Setup](deployment/docker.md) or [Manual Installation](deployment/manual.md) guides. Operators should use the [Operator Runbook](deployment/operations.md) for deployment modes, verification, upgrades, and rollback, and the [Troubleshooting Playbooks](deployment/troubleshooting.md) for ingestion, authentication, DNS, database, and notification failures. For production secrets, use [Secret Handling with 1Password](deployment/secrets.md). For database operations, use [Database Backup and Restore](deployment/backups.md). For upgrades, use the [Release Checklist](deployment/release-checklist.md).
|
||||
|
||||
For aggregate-report parser support, known edge cases, and fixture guidance, see [DMARC Aggregate Format Compatibility](reference/dmarc-compatibility.md).
|
||||
|
||||
+4
-7
@@ -163,7 +163,7 @@ Exit criteria:
|
||||
|
||||
## Milestone 10: Forensic Report Support
|
||||
|
||||
Status: In Progress
|
||||
Status: Complete
|
||||
|
||||
Goal: support DMARC RUF/forensic reports for individual failure investigation.
|
||||
|
||||
@@ -182,7 +182,7 @@ Exit criteria:
|
||||
|
||||
## Milestone 11: DMARC Format Compatibility (DMARCbis) and Standards Alignment
|
||||
|
||||
Status: In Progress
|
||||
Status: Complete
|
||||
|
||||
Goal: keep DMARQ compatible with evolving DMARC report formats and nomenclature without breaking existing imports.
|
||||
|
||||
@@ -190,11 +190,8 @@ Delivered:
|
||||
- Add parser compatibility for RFC 9990-style aggregate report namespaces, version detection, policy metadata, identifiers, override reasons, auth-result details, and namespaced extensions.
|
||||
- Keep legacy RFC 7489-style reports backward compatible through fixture coverage.
|
||||
- Persist and CSV-export newly introduced aggregate metadata with nullable, backward-safe database fields.
|
||||
|
||||
Planned:
|
||||
- Update domain/source reporting to include new metadata where it improves operator actionability.
|
||||
- Add fixture-driven tests for representative real-world DMARCbis-style reports.
|
||||
- Update documentation to clarify supported formats and terminology.
|
||||
- Add a fixture-driven compatibility pack covering parser, upload, IMAP, and Gmail import paths with supported-format documentation.
|
||||
- Verify domain report views and CSV exports render fixture-backed DMARCbis-style imports correctly.
|
||||
|
||||
Exit criteria:
|
||||
- A DMARCbis-style aggregate report can be imported via upload/IMAP/Gmail and renders correctly in dashboards and exports.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# DMARC Aggregate Format Compatibility
|
||||
|
||||
DMARQ imports DMARC aggregate reports from direct uploads, IMAP attachments, Gmail API attachments, and the Cloudflare Email Worker webhook. Compatibility is locked by the fixture pack in `backend/app/tests/fixtures/dmarc_aggregate`.
|
||||
|
||||
## Supported Aggregate Inputs
|
||||
|
||||
- Plain XML files with a `.xml` extension.
|
||||
- ZIP archives containing an XML report.
|
||||
- GZIP archives with `.gz` or `.gzip` extensions.
|
||||
- RFC 7489-compatible aggregate reports without XML namespaces.
|
||||
- Namespaced aggregate reports using `urn:ietf:params:xml:ns:dmarc-2.0`.
|
||||
- RFC 9990-style reports with optional metadata such as `version`, `generator`, `extra_contact_info`, repeated `error` values, `np`, `fo`, `testing`, `discovery_method`, `envelope_to`, policy override reasons, `human_result`, SPF `scope`, and namespaced extension elements.
|
||||
|
||||
## Preserved Metadata
|
||||
|
||||
Newer optional fields are parsed without changing the legacy response shape that existing screens use. When a database is configured, DMARQ also persists the optional report metadata, policy metadata, record identifiers, policy override reasons, and extension payloads so exports and future views can use them.
|
||||
|
||||
CSV exports include the most useful aggregate metadata for operators:
|
||||
|
||||
- `subdomain_policy`
|
||||
- `non_subdomain_policy`
|
||||
- `adkim`
|
||||
- `aspf`
|
||||
- `failure_options`
|
||||
- `testing`
|
||||
- `discovery_method`
|
||||
- `schema_version`
|
||||
- `report_variant`
|
||||
- `generator`
|
||||
|
||||
## Known Edge Cases
|
||||
|
||||
- Unknown namespaced extension fields are preserved as best-effort key/value data after namespace prefixes are stripped by the XML parser.
|
||||
- Malformed optional timestamps and counts use safe defaults so one bad optional value does not reject the whole report.
|
||||
- Reports with no `<record>` elements import with a zero-count summary.
|
||||
- Unsupported attachments are skipped by IMAP and Gmail import paths and recorded in import details when stats are available.
|
||||
- For duplicate detection, DMARQ uses the domain and `report_id` pair. Fixture report IDs must remain unique within a single test import run.
|
||||
|
||||
## Fixture Pack
|
||||
|
||||
The current fixture pack covers:
|
||||
|
||||
- `rfc7489-google.xml`: legacy no-namespace aggregate report.
|
||||
- `rfc9990-namespaced-legacy-fields.xml`: namespaced aggregate report that keeps legacy fields working.
|
||||
- `rfc9990-treewalk-extension.xml`: RFC 9990-style policy metadata, treewalk discovery, `envelope_to`, override reasons, auth-result details, and report/record extensions.
|
||||
- `rfc9990-multi-auth-overrides.xml`: multiple records, multiple DKIM auth results, policy override reasons, PSD-style discovery, and nested vendor extensions.
|
||||
|
||||
The compatibility tests exercise every fixture through parser extraction, upload import, IMAP attachment import, and Gmail attachment import.
|
||||
|
||||
## Adding Fixtures
|
||||
|
||||
1. Add the XML file under `backend/app/tests/fixtures/dmarc_aggregate`.
|
||||
2. Add its expected metadata to `DMARC_COMPATIBILITY_FIXTURES` in `backend/app/tests/test_data.py`.
|
||||
3. Include a unique `report_id`, stable domain, expected variant, expected total count, and any policy fields that should be asserted.
|
||||
4. Run `pytest backend/app/tests/test_dmarc_compatibility_fixtures.py backend/app/tests/test_dmarc_parser.py backend/app/tests/test_reports_api.py`.
|
||||
Reference in New Issue
Block a user