Add TLS reports capabilities
This commit is contained in:
Vendored
+30
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user