feat: add forensic report parsing storage

This commit is contained in:
Christian Krakau-Louis
2026-05-23 12:52:54 +02:00
parent ca9e0bfd4a
commit 1f954c9e08
18 changed files with 1005 additions and 17 deletions
+77
View File
@@ -0,0 +1,77 @@
import email
from app.services.forensic_parser import ForensicParser
SAMPLE_FORENSIC_EMAIL = b"""\
From: DMARC Reporter <dmarc-reports@example.net>
To: postmaster@example.com
Subject: DMARC Failure Report for example.com
Message-ID: <report-1@example.net>
MIME-Version: 1.0
Content-Type: multipart/report; report-type=feedback-report; boundary="ruf-boundary"
--ruf-boundary
Content-Type: text/plain; charset=utf-8
This is a DMARC failure report.
--ruf-boundary
Content-Type: message/feedback-report
Feedback-Type: auth-failure
User-Agent: Example Reporter
Version: 1
Original-Mail-From: alice@example.com
Arrival-Date: Fri, 22 May 2026 10:15:00 +0000
Source-IP: 203.0.113.8
Reported-Domain: example.com
Authentication-Results: mx.example.net; dkim=fail header.d=example.com; spf=pass
Auth-Failure: dkim
Delivery-Result: reject
--ruf-boundary
Content-Type: text/rfc822-headers
From: Alice Sender <alice@example.com>
To: Bob Receiver <bob@example.net>
Subject: Customer renewal token abcdefghijklmnopqrstuvwxyz123456
Message-ID: <original-message@example.com>
Date: Fri, 22 May 2026 10:14:55 +0000
--ruf-boundary--
"""
def test_detects_forensic_report_email():
msg = email.message_from_bytes(SAMPLE_FORENSIC_EMAIL)
assert ForensicParser.is_forensic_report(msg) is True
def test_parse_forensic_email_redacts_and_extracts_failure_fields():
parsed = ForensicParser.parse_bytes(SAMPLE_FORENSIC_EMAIL)
assert parsed["report_id"].startswith("ruf-")
assert parsed["reported_domain"] == "example.com"
assert parsed["source_ip"] == "203.0.113.8"
assert parsed["auth_failure"] == "dkim"
assert parsed["delivery_result"] == "reject"
assert parsed["arrival_date"].year == 2026
assert parsed["original_mail_from"] == "al***@example.com"
assert "al***@example.com" in parsed["original_from"]
assert "bo***@example.net" in parsed["original_to"]
assert "[redacted-token]" in parsed["original_subject"]
assert parsed["original_message_id"]
assert "original-message@example.com" not in parsed["original_message_id"]
def test_non_forensic_email_is_rejected():
content = b"From: sender@example.com\r\nSubject: hello\r\n\r\nplain email"
try:
ForensicParser.parse_bytes(content)
except ValueError as exc:
assert "forensic" in str(exc)
else:
raise AssertionError("Expected parser to reject non-forensic email")
+56
View File
@@ -0,0 +1,56 @@
from app.models.report import ForensicReport
from app.tests.test_forensic_parser import SAMPLE_FORENSIC_EMAIL
def test_upload_forensic_report_persists_redacted_metadata(authed_client, db_session):
response = authed_client.post(
"/api/v1/forensics/upload",
files={"file": ("report.eml", SAMPLE_FORENSIC_EMAIL, "message/rfc822")},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["domain"] == "example.com"
assert db_session.query(ForensicReport).count() == 1
def test_upload_forensic_report_rejects_duplicates(authed_client):
files = {"file": ("report.eml", SAMPLE_FORENSIC_EMAIL, "message/rfc822")}
assert authed_client.post("/api/v1/forensics/upload", files=files).status_code == 200
response = authed_client.post(
"/api/v1/forensics/upload",
files={"file": ("report.eml", SAMPLE_FORENSIC_EMAIL, "message/rfc822")},
)
assert response.status_code == 409
def test_list_and_detail_forensic_reports(authed_client):
authed_client.post(
"/api/v1/forensics/upload",
files={"file": ("report.eml", SAMPLE_FORENSIC_EMAIL, "message/rfc822")},
)
list_response = authed_client.get("/api/v1/forensics?domain=example.com")
assert list_response.status_code == 200
list_data = list_response.json()
assert list_data["total"] == 1
item = list_data["reports"][0]
assert item["source_ip"] == "203.0.113.8"
assert item["auth_failure"] == "dkim"
assert item["original_message_id"] != "<original-message@example.com>"
detail_response = authed_client.get(f"/api/v1/forensics/{item['id']}")
assert detail_response.status_code == 200
assert detail_response.json()["reported_domain"] == "example.com"
def test_upload_forensic_report_rejects_aggregate_xml(authed_client):
response = authed_client.post(
"/api/v1/forensics/upload",
files={"file": ("report.xml", b"<feedback />", "application/xml")},
)
assert response.status_code == 400
+24 -1
View File
@@ -19,10 +19,11 @@ from unittest.mock import MagicMock, patch
import pytest
from app.models.report import DMARCReport
from app.models.report import DMARCReport, ForensicReport
from app.services.gmail_client import GmailClient
from app.services.report_store import ReportStore
from app.tests.test_data import SAMPLE_XML
from app.tests.test_forensic_parser import SAMPLE_FORENSIC_EMAIL
# ---------------------------------------------------------------------------
# Helpers
@@ -442,6 +443,28 @@ class TestProcessMessage:
assert stats["details"][0]["status"] == "error"
assert stats["details"][0]["message_id"] == "bad-id"
def test_forensic_report_is_processed_separately(self, db_session):
client = _make_client(db=db_session)
service = MagicMock()
service.users.return_value.messages.return_value.get.return_value.execute.return_value = {
"raw": _b64_raw(SAMPLE_FORENSIC_EMAIL)
}
stats = {
"reports_found": 0,
"forensic_reports_found": 0,
"duplicate_forensic_reports": 0,
"errors": [],
}
count = client._process_message(service, "msg-forensic", stats)
assert count == 1
assert stats["reports_found"] == 0
assert stats["forensic_reports_found"] == 1
assert stats["details"][0]["reason"] == "forensic_report"
assert db_session.query(DMARCReport).count() == 0
assert db_session.query(ForensicReport).count() == 1
# ===========================================================================
# _process_attachments
+27 -3
View File
@@ -17,9 +17,10 @@ from zipfile import ZipFile
import pytest
from app.models.report import DMARCReport
from app.models.report import DMARCReport, ForensicReport
from app.services.imap_client import IMAPClient
from app.services.report_store import ReportStore
from app.tests.test_forensic_parser import SAMPLE_FORENSIC_EMAIL
# ---------------------------------------------------------------------------
# Helpers
@@ -587,7 +588,7 @@ class TestProcessAttachments:
class TestProcessSingleEmail:
def _make_client(self):
def _make_client(self, db=None):
with patch("app.services.imap_client.get_settings") as mock_settings:
mock_settings.return_value = MagicMock(
IMAP_SERVER="imap.example.com",
@@ -595,7 +596,7 @@ class TestProcessSingleEmail:
IMAP_USERNAME="u",
IMAP_PASSWORD="p",
)
return IMAPClient()
return IMAPClient(db=db)
def test_processes_valid_dmarc_email(self):
client = self._make_client()
@@ -616,6 +617,29 @@ class TestProcessSingleEmail:
assert stats["reports_found"] == 1
assert stats["details"][0]["status"] == "imported"
def test_processes_forensic_report_without_aggregate_count(self, db_session):
client = self._make_client(db=db_session)
mock_mail = MagicMock()
mock_mail.fetch.return_value = ("OK", [(b"1", SAMPLE_FORENSIC_EMAIL)])
mock_mail.store.return_value = ("OK", None)
stats = {
"processed": 0,
"reports_found": 0,
"forensic_reports_found": 0,
"deleted": 0,
"errors": [],
}
client._process_single_email(mock_mail, b"1", stats)
assert stats["processed"] == 1
assert stats["reports_found"] == 0
assert stats["forensic_reports_found"] == 1
assert stats["details"][0]["reason"] == "forensic_report"
assert db_session.query(DMARCReport).count() == 0
assert db_session.query(ForensicReport).count() == 1
assert ReportStore.get_instance().get_domains() == []
def test_fetch_error_skips_email(self):
client = self._make_client()
mock_mail = MagicMock()