feat: add forensic redaction controls

This commit is contained in:
Christian Krakau-Louis
2026-05-23 13:21:03 +02:00
parent c2057acc71
commit 8bce8a8595
16 changed files with 399 additions and 38 deletions
+23
View File
@@ -12,6 +12,7 @@ from app.services.forensic_parser import (
_message_part_payload,
_payload_text,
)
from app.services.forensic_redaction import ForensicRedactionPolicy
SAMPLE_FORENSIC_EMAIL = b"""\
@@ -77,6 +78,28 @@ def test_parse_forensic_email_redacts_and_extracts_failure_fields():
assert "original-message@example.com" not in parsed["original_message_id"]
def test_parse_forensic_email_supports_stricter_redaction_policies():
domain_only = ForensicParser.parse_bytes(
SAMPLE_FORENSIC_EMAIL,
redaction_policy=ForensicRedactionPolicy(mode="domain_only"),
)
strict = ForensicParser.parse_bytes(
SAMPLE_FORENSIC_EMAIL,
redaction_policy=ForensicRedactionPolicy(mode="strict"),
)
token_visible = ForensicParser.parse_bytes(
SAMPLE_FORENSIC_EMAIL,
redaction_policy=ForensicRedactionPolicy(redact_long_tokens=False),
)
assert domain_only["original_mail_from"] == "***@example.com"
assert "***@example.com" in domain_only["original_from"]
assert domain_only["source_email"] == "DMARC Reporter <***@example.net>"
assert strict["original_mail_from"] == "[redacted-email]"
assert strict["source_email"] == "DMARC Reporter <[redacted-email]>"
assert "abcdefghijklmnopqrstuvwxyz123456" in token_visible["original_subject"]
def test_non_forensic_email_is_rejected():
content = b"From: sender@example.com\r\nSubject: hello\r\n\r\nplain email"
+36
View File
@@ -10,6 +10,7 @@ from app.services.forensic_persistence import (
forensic_report_to_dict,
save_forensic_report,
)
from app.services.forensic_redaction import ForensicRedactionPolicy
from app.tests.test_forensic_parser import SAMPLE_FORENSIC_EMAIL
@@ -62,6 +63,36 @@ def test_list_and_detail_forensic_reports(authed_client):
assert detail_response.json()["reported_domain"] == "example.com"
def test_forensic_api_applies_configured_redaction_policy(authed_client, db_session):
authed_client.post(
"/api/v1/forensics/upload",
files={"file": ("report.eml", SAMPLE_FORENSIC_EMAIL, "message/rfc822")},
)
authed_client.put("/api/v1/settings/forensics.redaction_mode", json={"value": "strict"})
list_response = authed_client.get("/api/v1/forensics?domain=example.com")
assert list_response.status_code == 200
item = list_response.json()["reports"][0]
assert item["original_mail_from"] == "[redacted-email]"
assert item["source_email"] == "DMARC Reporter <[redacted-email]>"
stored = db_session.query(ForensicReport).one()
assert stored.original_mail_from == "al***@example.com"
def test_upload_forensic_report_uses_configured_redaction_policy(authed_client, db_session):
authed_client.put("/api/v1/settings/forensics.redaction_mode", json={"value": "domain_only"})
response = authed_client.post(
"/api/v1/forensics/upload",
files={"file": ("report.eml", SAMPLE_FORENSIC_EMAIL, "message/rfc822")},
)
assert response.status_code == 200
report = db_session.query(ForensicReport).one()
assert report.original_mail_from == "***@example.com"
def test_upload_forensic_report_rejects_aggregate_xml(authed_client):
response = authed_client.post(
"/api/v1/forensics/upload",
@@ -156,6 +187,11 @@ def test_save_forensic_report_duplicate_and_invalid_domain_paths(db_session):
assert forensic_report_exists(db_session, parsed["report_id"]) is True
assert forensic_report_exists(db_session, "") is False
assert forensic_report_to_dict(first)["feedback_headers"] == {"identity_alignment": "dkim"}
strict = forensic_report_to_dict(
first,
redaction_policy=ForensicRedactionPolicy(mode="strict"),
)
assert strict["original_mail_from"] == "[redacted-email]"
first.feedback_headers = "{not-json"
assert forensic_report_to_dict(first)["feedback_headers"] == {}
+27
View File
@@ -20,6 +20,7 @@ from unittest.mock import MagicMock, patch
import pytest
from app.models.report import DMARCReport, ForensicReport
from app.models.setting import Setting
from app.services.gmail_client import GmailClient, RETRYABLE_MESSAGE_FAILURE
from app.services.report_store import ReportStore
from app.tests.test_data import SAMPLE_XML
@@ -465,6 +466,32 @@ class TestProcessMessage:
assert db_session.query(DMARCReport).count() == 0
assert db_session.query(ForensicReport).count() == 1
def test_forensic_report_uses_configured_redaction_policy(self, db_session):
setting = (
db_session.query(Setting).filter(Setting.key == "forensics.redaction_mode").first()
)
if setting is None:
setting = Setting(
key="forensics.redaction_mode",
value_type="string",
category="forensics",
)
db_session.add(setting)
setting.value = "strict"
db_session.commit()
client = _make_client(db=db_session)
stats = {"forensic_reports_found": 0, "duplicate_forensic_reports": 0, "errors": []}
count = client._process_forensic_message(
SAMPLE_FORENSIC_EMAIL,
stats,
message_id="msg-forensic",
)
assert count == 1
report = db_session.query(ForensicReport).one()
assert report.original_mail_from == "[redacted-email]"
def test_forensic_report_without_database_is_skipped(self):
client = _make_client()
stats = {"forensic_reports_found": 0, "duplicate_forensic_reports": 0, "errors": []}
+22
View File
@@ -18,6 +18,7 @@ from zipfile import ZipFile
import pytest
from app.models.report import DMARCReport, ForensicReport
from app.models.setting import Setting
from app.services.imap_client import IMAPClient
from app.services.report_store import ReportStore
from app.tests.test_forensic_parser import SAMPLE_FORENSIC_EMAIL
@@ -641,6 +642,27 @@ class TestProcessSingleEmail:
assert db_session.query(ForensicReport).count() == 1
assert ReportStore.get_instance().get_domains() == []
def test_forensic_report_uses_configured_redaction_policy(self, db_session):
setting = (
db_session.query(Setting).filter(Setting.key == "forensics.redaction_mode").first()
)
if setting is None:
setting = Setting(
key="forensics.redaction_mode",
value_type="string",
category="forensics",
)
db_session.add(setting)
setting.value = "domain_only"
db_session.commit()
client = self._make_client(db=db_session)
stats = {"forensic_reports_found": 0, "duplicate_forensic_reports": 0, "errors": []}
assert client._process_forensic_email(SAMPLE_FORENSIC_EMAIL, stats=stats, message_id="1")
report = db_session.query(ForensicReport).one()
assert report.original_mail_from == "***@example.com"
def test_forensic_report_without_database_is_skipped(self):
client = self._make_client()
+2
View File
@@ -123,6 +123,8 @@ class TestSettingsAPI:
assert "notifications.summary_weekday_utc" in keys
assert "notifications.min_send_interval_minutes" in keys
assert "notifications.redact_pii_enabled" in keys
assert "forensics.redaction_mode" in keys
assert "forensics.redact_long_tokens_enabled" in keys
def test_list_settings_filter_by_category(self, authed_client: TestClient):
"""GET /api/v1/settings?category=dmarc returns only dmarc settings."""