Add TLS reports capabilities

This commit is contained in:
2026-06-23 11:49:58 -03:00
parent 5c1dc95c36
commit f4ae9f8532
20 changed files with 1158 additions and 75 deletions
+30
View File
@@ -0,0 +1,30 @@
{
"organization-name": "Google Inc.",
"date-range": {
"start-datetime": "2026-06-17T00:00:00Z",
"end-datetime": "2026-06-17T23:59:59Z"
},
"contact-info": "smtp-tls-reporting@google.com",
"report-id": "2026-06-17T00:00:00Z_anamaka.net",
"policies": [
{
"policy": {
"policy-type": "sts",
"policy-string": [
"version: STSv1",
"mode: enforce",
"mx: mail.dynamicpress.org",
"max_age: 604800"
],
"policy-domain": "anamaka.net",
"mx-host": [
"mail.dynamicpress.org"
]
},
"summary": {
"total-successful-session-count": 5,
"total-failure-session-count": 0
}
}
]
}
+46 -1
View File
@@ -7,7 +7,8 @@ from sqlalchemy.orm import Session
from app.analyzer import analyze_report
from app.config import Settings
from app.db import Base
from app.models import Record, Report
from app.models import Record, Report, TLSPolicy, TLSFailure, TLSReport
from app.tlsrpt_analyzer import analyze_tls_report
def _session():
@@ -209,3 +210,47 @@ def test_alert_details_include_published_policy_and_receiver_action():
assert details["published_policy"]["effective_source"] == "p"
assert details["receiver_action"]["disposition"] == "reject"
assert "Published DMARC policy was p=reject; pct=100" in alert.summary
def test_tls_failures_create_alert_with_tls_details():
session = _session()
report = TLSReport(
inbox_id="anamaka",
raw_json_sha256="tls-sha",
report_id="tls-report",
org_name="Google Inc.",
domain="anamaka.net",
date_begin=datetime.now(timezone.utc) - timedelta(hours=1),
date_end=datetime.now(timezone.utc),
)
session.add(report)
session.flush()
policy = TLSPolicy(
tls_report=report,
policy_type="sts",
policy_domain="anamaka.net",
mx_hosts_json='["mail.dynamicpress.org"]',
policy_strings_json='["version: STSv1", "mode: enforce"]',
total_successful_session_count=80,
total_failure_session_count=20,
)
session.add(policy)
session.flush()
session.add(
TLSFailure(
policy=policy,
result_type="certificate-host-mismatch",
receiving_mx_hostname="mail.dynamicpress.org",
failed_session_count=20,
)
)
session.commit()
alerts = analyze_tls_report(session, _settings(), report)
assert any(alert.type == "tls_delivery_failures" for alert, _, _ in alerts)
detail = next(alert for alert, _, _ in alerts if alert.type == "tls_failure_detail")
details = json.loads(detail.details_json)
assert details["report_type"] == "tlsrpt"
assert details["policy_domain"] == "anamaka.net"
assert details["result_type"] == "certificate-host-mismatch"
+38 -1
View File
@@ -1,4 +1,7 @@
from app.main import app
import json
from app.main import _alert_view, app
from app.models import Alert
def test_generated_api_documentation_is_disabled():
@@ -7,3 +10,37 @@ def test_generated_api_documentation_is_disabled():
assert "/docs" not in paths
assert "/redoc" not in paths
assert "/openapi.json" not in paths
def test_tls_report_detail_route_exists():
paths = {route.path for route in app.routes}
assert "/tls-reports/{report_id}" in paths
def test_tls_alert_links_to_tls_report_detail():
alert = Alert(
fingerprint="anamaka.net:tls_delivery_failures:anamaka.net:sts",
inbox_id="anamaka",
domain="anamaka.net",
severity="warning",
type="tls_delivery_failures",
title="TLS delivery failures reported for anamaka.net",
summary="summary",
details_json=json.dumps(
{
"report_type": "tlsrpt",
"report_db_id": 42,
"date_range": {
"begin": "2026-06-17T00:00:00+00:00",
"end": "2026-06-17T23:59:59+00:00",
},
}
),
)
view = _alert_view(alert)
assert view.report_db_id is None
assert view.tls_report_db_id == 42
assert view.source_report_url == "/tls-reports/42"
+25 -1
View File
@@ -13,6 +13,10 @@ def _xml() -> bytes:
return Path("tests/fixtures/sample_dmarc.xml").read_bytes()
def _json() -> bytes:
return Path("tests/fixtures/sample_tlsrpt.json").read_bytes()
def test_gzip_attachment_extraction():
gz = gzip.compress(_xml())
reports = extract_payload("report.xml.gz", "application/octet-stream", gz, 20)
@@ -22,6 +26,16 @@ def test_gzip_attachment_extraction():
assert len(reports[0].sha256) == 64
def test_json_gzip_attachment_extraction():
gz = gzip.compress(_json())
reports = extract_payload("report.json.gz", "application/octet-stream", gz, 20)
assert len(reports) == 1
assert reports[0].filename == "report.json"
assert reports[0].payload.startswith(b"{")
assert len(reports[0].sha256) == 64
def test_zip_attachment_extraction_rejects_traversal():
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as archive:
@@ -47,7 +61,17 @@ def test_zip_attachment_extraction_caps_reports_per_archive():
archive.writestr("one.xml", _xml())
archive.writestr("two.xml", _xml())
with pytest.raises(AttachmentExtractionError, match="archive XML report limit"):
with pytest.raises(AttachmentExtractionError, match="archive report limit"):
extract_payload("reports.zip", "application/zip", buf.getvalue(), 20, max_reports_per_archive=1)
def test_zip_attachment_extraction_caps_json_reports_per_archive():
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as archive:
archive.writestr("one.json", _json())
archive.writestr("two.json", _json())
with pytest.raises(AttachmentExtractionError, match="archive report limit"):
extract_payload("reports.zip", "application/zip", buf.getvalue(), 20, max_reports_per_archive=1)
+29 -1
View File
@@ -7,7 +7,7 @@ from sqlalchemy.orm import Session
from app.config import Settings
from app.db import Base
from app.attachment_extractor import ExtractedReport
from app.message_processor import _store_report
from app.message_processor import _store_report, _store_tls_report
from app.models import MailMessage
@@ -32,6 +32,34 @@ def test_duplicate_sha_detection():
assert second_duplicate == report
def test_tls_report_storage_and_duplicate_sha_detection():
engine = create_engine("sqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
session = Session(engine)
mail = MailMessage(inbox_id="anamaka", imap_uid="1", folder="DMARC", status="skipped")
session.add(mail)
session.commit()
payload = Path("tests/fixtures/sample_tlsrpt.json").read_bytes()
extracted = ExtractedReport("report.json", payload, "1" * 64)
settings = Settings.model_validate({"alerts": {"email": {"enabled": False}}})
report, duplicate = _store_tls_report(session, settings, _TLSInbox(), mail, extracted)
session.commit()
second, second_duplicate = _store_tls_report(session, settings, _TLSInbox(), mail, extracted)
assert report is not None
assert duplicate is None
assert report.domain == "anamaka.net"
assert report.policies[0].total_successful_session_count == 5
assert second is None
assert second_duplicate == report
class _Inbox:
id = "tukutoi"
domain = "tukutoi.com"
class _TLSInbox:
id = "anamaka"
domain = "anamaka.net"
+36
View File
@@ -0,0 +1,36 @@
from pathlib import Path
import pytest
from app.tlsrpt_parser import TLSRPTParseError, parse_tlsrpt_json
def test_parser_valid_tlsrpt_report():
payload = Path("tests/fixtures/sample_tlsrpt.json").read_bytes()
report = parse_tlsrpt_json(payload)
assert report.org_name == "Google Inc."
assert report.domain == "anamaka.net"
assert report.report_id == "2026-06-17T00:00:00Z_anamaka.net"
assert report.date_begin is not None
assert len(report.policies) == 1
policy = report.policies[0]
assert policy.policy_type == "sts"
assert policy.policy_domain == "anamaka.net"
assert policy.mx_hosts == ["mail.dynamicpress.org"]
assert policy.total_successful_session_count == 5
assert policy.total_failure_session_count == 0
def test_parser_rejects_missing_policy_domain():
payload = Path("tests/fixtures/sample_tlsrpt.json").read_text().replace('"policy-domain": "anamaka.net"', '"policy-domain": ""').encode()
with pytest.raises(TLSRPTParseError, match="Missing TLS policy domain"):
parse_tlsrpt_json(payload)
def test_parser_rejects_negative_session_count():
payload = Path("tests/fixtures/sample_tlsrpt.json").read_text().replace('"total-failure-session-count": 0', '"total-failure-session-count": -1').encode()
with pytest.raises(TLSRPTParseError, match="must not be negative"):
parse_tlsrpt_json(payload)