feat: add TLS reporting posture summaries

Closes #140
This commit is contained in:
Christian Krakau-Louis
2026-05-23 16:53:17 +02:00
committed by GitHub
17 changed files with 1518 additions and 3 deletions
@@ -0,0 +1,128 @@
"""add tls reports
Revision ID: f7a8b9c0d1e2
Revises: c4d5e6f7a8b9
Create Date: 2026-05-23 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "f7a8b9c0d1e2"
down_revision: Union[str, Sequence[str], None] = "c4d5e6f7a8b9"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create privacy-conscious SMTP TLS report storage."""
op.create_table(
"tls_reports",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("domain_id", sa.Integer(), nullable=True),
sa.Column("report_id", sa.String(), nullable=False),
sa.Column("org_name", sa.String(), nullable=True),
sa.Column("contact_info", sa.String(), nullable=True),
sa.Column("policy_domain", sa.String(), nullable=False),
sa.Column("policy_type", sa.String(), nullable=True),
sa.Column("begin_date", sa.DateTime(), nullable=True),
sa.Column("end_date", sa.DateTime(), nullable=True),
sa.Column("total_successful_sessions", sa.Integer(), nullable=False),
sa.Column("total_failure_sessions", sa.Integer(), nullable=False),
sa.Column("raw_policy", sa.Text(), nullable=True),
sa.Column("processed_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["domain_id"], ["domains.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("report_id", "policy_domain", name="uq_tls_reports_report_domain"),
)
op.create_index(op.f("ix_tls_reports_id"), "tls_reports", ["id"])
op.create_index(op.f("ix_tls_reports_domain_id"), "tls_reports", ["domain_id"])
op.create_index(op.f("ix_tls_reports_report_id"), "tls_reports", ["report_id"])
op.create_index(op.f("ix_tls_reports_org_name"), "tls_reports", ["org_name"])
op.create_index(op.f("ix_tls_reports_policy_domain"), "tls_reports", ["policy_domain"])
op.create_index(op.f("ix_tls_reports_policy_type"), "tls_reports", ["policy_type"])
op.create_index(op.f("ix_tls_reports_begin_date"), "tls_reports", ["begin_date"])
op.create_index(op.f("ix_tls_reports_end_date"), "tls_reports", ["end_date"])
op.create_index(op.f("ix_tls_reports_processed_at"), "tls_reports", ["processed_at"])
op.create_index(
"ix_tls_reports_domain_dates",
"tls_reports",
["domain_id", "begin_date", "end_date"],
)
op.create_index(
"ix_tls_reports_policy_domain_dates",
"tls_reports",
["policy_domain", "begin_date", "end_date"],
)
op.create_table(
"tls_report_failures",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("report_id", sa.Integer(), nullable=False),
sa.Column("result_type", sa.String(), nullable=False),
sa.Column("failed_session_count", sa.Integer(), nullable=False),
sa.Column("sending_mta_ip", sa.String(), nullable=True),
sa.Column("receiving_mx_hostname", sa.String(), nullable=True),
sa.Column("receiving_mx_helo", sa.String(), nullable=True),
sa.Column("receiving_ip", sa.String(), nullable=True),
sa.Column("failure_reason_code", sa.String(), nullable=True),
sa.Column("additional_information", sa.Text(), nullable=True),
sa.ForeignKeyConstraint(["report_id"], ["tls_reports.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_tls_report_failures_id"), "tls_report_failures", ["id"])
op.create_index(op.f("ix_tls_report_failures_report_id"), "tls_report_failures", ["report_id"])
op.create_index(
op.f("ix_tls_report_failures_result_type"), "tls_report_failures", ["result_type"]
)
op.create_index(
op.f("ix_tls_report_failures_sending_mta_ip"),
"tls_report_failures",
["sending_mta_ip"],
)
op.create_index(
op.f("ix_tls_report_failures_receiving_mx_hostname"),
"tls_report_failures",
["receiving_mx_hostname"],
)
op.create_index(
"ix_tls_report_failures_result_count",
"tls_report_failures",
["result_type", "failed_session_count"],
)
op.create_index(
"ix_tls_report_failures_report_result",
"tls_report_failures",
["report_id", "result_type"],
)
def downgrade() -> None:
"""Drop SMTP TLS report storage."""
op.drop_index("ix_tls_report_failures_report_result", table_name="tls_report_failures")
op.drop_index("ix_tls_report_failures_result_count", table_name="tls_report_failures")
op.drop_index(
op.f("ix_tls_report_failures_receiving_mx_hostname"),
table_name="tls_report_failures",
)
op.drop_index(op.f("ix_tls_report_failures_sending_mta_ip"), table_name="tls_report_failures")
op.drop_index(op.f("ix_tls_report_failures_result_type"), table_name="tls_report_failures")
op.drop_index(op.f("ix_tls_report_failures_report_id"), table_name="tls_report_failures")
op.drop_index(op.f("ix_tls_report_failures_id"), table_name="tls_report_failures")
op.drop_table("tls_report_failures")
op.drop_index("ix_tls_reports_policy_domain_dates", table_name="tls_reports")
op.drop_index("ix_tls_reports_domain_dates", table_name="tls_reports")
op.drop_index(op.f("ix_tls_reports_processed_at"), table_name="tls_reports")
op.drop_index(op.f("ix_tls_reports_end_date"), table_name="tls_reports")
op.drop_index(op.f("ix_tls_reports_begin_date"), table_name="tls_reports")
op.drop_index(op.f("ix_tls_reports_policy_type"), table_name="tls_reports")
op.drop_index(op.f("ix_tls_reports_policy_domain"), table_name="tls_reports")
op.drop_index(op.f("ix_tls_reports_org_name"), table_name="tls_reports")
op.drop_index(op.f("ix_tls_reports_report_id"), table_name="tls_reports")
op.drop_index(op.f("ix_tls_reports_domain_id"), table_name="tls_reports")
op.drop_index(op.f("ix_tls_reports_id"), table_name="tls_reports")
op.drop_table("tls_reports")
+2
View File
@@ -11,6 +11,7 @@ from app.api.api_v1.endpoints import (
settings,
setup,
stats,
tls_reports,
webhook,
)
@@ -27,4 +28,5 @@ api_router.include_router(imap.router, prefix="/imap", tags=["imap"])
api_router.include_router(stats.router, prefix="/stats", tags=["stats"])
api_router.include_router(mail_sources.router, prefix="/mail-sources", tags=["mail-sources"])
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
api_router.include_router(tls_reports.router, prefix="/tls-reports", tags=["tls-reports"])
api_router.include_router(webhook.router, prefix="/webhook", tags=["webhook"])
@@ -0,0 +1,189 @@
import logging
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session, selectinload
from app.core.database import get_db
from app.core.security import require_admin_auth
from app.models.domain import Domain
from app.models.report import TLSReport
from app.services.tls_report_parser import MAX_TLS_REPORT_SIZE, TLSReportParser
from app.services.tls_report_persistence import (
TLS_REPORT_PRIVACY_CONTROLS,
save_tls_report,
summarize_tls_reports,
tls_report_to_dict,
)
logger = logging.getLogger(__name__)
router = APIRouter()
class TLSFailureResponse(BaseModel):
result_type: str
failed_session_count: int
sending_mta_ip: Optional[str] = None
receiving_mx_hostname: Optional[str] = None
receiving_mx_helo: Optional[str] = None
receiving_ip: Optional[str] = None
failure_reason_code: Optional[str] = None
additional_information: Optional[str] = None
class TLSReportResponse(BaseModel):
id: int
report_id: str
domain: Optional[str] = None
org_name: Optional[str] = None
contact_info: Optional[str] = None
policy_domain: str
policy_type: Optional[str] = None
begin_date: Optional[str] = None
end_date: Optional[str] = None
total_successful_sessions: int
total_failure_sessions: int
processed_at: Optional[str] = None
failures: List[TLSFailureResponse] = Field(default_factory=list)
class TLSReportListResponse(BaseModel):
total: int
page: int
page_size: int
total_pages: int
reports: List[TLSReportResponse]
privacy: Dict[str, Any]
class TLSReportUploadResponse(BaseModel):
success: bool
report_id: str
policies_created: int
policies_skipped: int
duplicate: bool = False
message: str
privacy: Dict[str, Any]
class TLSSummaryResponse(BaseModel):
domain: Optional[str] = None
days: int
totals: Dict[str, Any]
trends: List[Dict[str, Any]] = Field(default_factory=list)
top_failures: List[Dict[str, Any]] = Field(default_factory=list)
affected_domains: List[Dict[str, Any]] = Field(default_factory=list)
privacy: Dict[str, Any]
def _validate_upload(file: UploadFile, content: bytes) -> None:
filename = file.filename or ""
if not filename:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Filename is required")
if len(content) == 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="File is empty")
if len(content) > MAX_TLS_REPORT_SIZE:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="File too large",
)
if not filename.lower().endswith((".json", ".json.gz", ".gzip", ".zip")):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid file type. Upload a TLS report as .json, .json.gz, or .zip.",
)
def _filtered_tls_query(db: Session, *, domain: Optional[str] = None):
query = db.query(TLSReport).options(
selectinload(TLSReport.domain), selectinload(TLSReport.failures)
)
if domain:
normalized = domain.lower().strip(".")
query = query.outerjoin(Domain).filter(
(Domain.name == normalized) | (TLSReport.policy_domain == normalized)
)
return query
@router.post("/upload", response_model=TLSReportUploadResponse)
async def upload_tls_report(
file: UploadFile = File(...),
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
):
"""Upload and store an SMTP TLS Reporting aggregate."""
try:
content = await file.read()
_validate_upload(file, content)
parsed = TLSReportParser.parse_file(content, file.filename or "")
result = save_tls_report(db, parsed)
db.commit()
return TLSReportUploadResponse(
success=True,
report_id=parsed["report_id"],
policies_created=result["created"],
policies_skipped=result["skipped"],
duplicate=result["created"] == 0 and result["skipped"] > 0,
message=(
"TLS report imported."
if result["created"]
else "TLS report had already been imported."
),
privacy=TLS_REPORT_PRIVACY_CONTROLS,
)
except HTTPException:
raise
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc) or "Invalid TLS report format.",
) from exc
except Exception as exc: # pylint: disable=broad-exception-caught
logger.error("Unexpected TLS report upload failure for %s: %s", file.filename, exc)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Error processing TLS report.",
) from exc
@router.get("", response_model=TLSReportListResponse)
async def list_tls_reports(
domain: Optional[str] = Query(default=None),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=200),
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
):
"""List stored SMTP TLS reports, newest first."""
query = _filtered_tls_query(db, domain=domain)
total = query.count()
rows = (
query.order_by(TLSReport.begin_date.desc().nullslast(), TLSReport.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
total_pages = (total + page_size - 1) // page_size if total else 0
return TLSReportListResponse(
total=total,
page=page,
page_size=page_size,
total_pages=total_pages,
reports=[TLSReportResponse(**tls_report_to_dict(row)) for row in rows],
privacy=TLS_REPORT_PRIVACY_CONTROLS,
)
@router.get("/summary", response_model=TLSSummaryResponse)
async def tls_report_summary(
domain: Optional[str] = Query(default=None),
days: int = Query(default=30, ge=1, le=365),
limit: int = Query(default=10, ge=1, le=50),
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
):
"""Summarize TLS reports into trends and top failure causes."""
return TLSSummaryResponse(**summarize_tls_reports(db, domain=domain, days=days, limit=limit))
+6
View File
@@ -631,6 +631,12 @@ async def forensic_reports(request: Request):
return templates.TemplateResponse(request, "forensic_reports.html")
@app.get("/tls-reports", response_class=HTMLResponse)
async def tls_reports(request: Request):
"""View SMTP TLS reporting posture summaries."""
return templates.TemplateResponse(request, "tls_reports.html")
@app.get("/forensics/{report_id}", response_class=HTMLResponse)
async def forensic_report_detail(request: Request, report_id: int):
"""View detailed information for a specific forensic report."""
+1
View File
@@ -34,6 +34,7 @@ class Domain(Base):
forensic_reports = relationship(
"ForensicReport", back_populates="domain", cascade="all, delete-orphan"
)
tls_reports = relationship("TLSReport", back_populates="domain", cascade="all, delete-orphan")
user_domains = relationship("UserDomain", back_populates="domain", cascade="all, delete-orphan")
# Indexes for common queries
+64
View File
@@ -151,3 +151,67 @@ class ForensicReport(Base):
def __repr__(self):
return f"<ForensicReport {self.report_id}>"
class TLSReport(Base):
"""SMTP TLS reporting (TLS-RPT) aggregate report."""
__tablename__ = "tls_reports"
id = Column(Integer, primary_key=True, index=True)
domain_id = Column(Integer, ForeignKey("domains.id"), nullable=True, index=True)
report_id = Column(String, nullable=False, index=True)
org_name = Column(String, nullable=True, index=True)
contact_info = Column(String, nullable=True)
policy_domain = Column(String, nullable=False, index=True)
policy_type = Column(String, nullable=True, index=True)
begin_date = Column(DateTime, nullable=True, index=True)
end_date = Column(DateTime, nullable=True, index=True)
total_successful_sessions = Column(Integer, nullable=False, default=0)
total_failure_sessions = Column(Integer, nullable=False, default=0)
raw_policy = Column(Text, nullable=True)
processed_at = Column(DateTime, default=datetime.utcnow, index=True)
domain = relationship("Domain", back_populates="tls_reports")
failures = relationship(
"TLSReportFailure",
back_populates="report",
cascade="all, delete-orphan",
)
__table_args__ = (
UniqueConstraint("report_id", "policy_domain", name="uq_tls_reports_report_domain"),
Index("ix_tls_reports_domain_dates", "domain_id", "begin_date", "end_date"),
Index("ix_tls_reports_policy_domain_dates", "policy_domain", "begin_date", "end_date"),
)
def __repr__(self):
return f"<TLSReport {self.report_id} {self.policy_domain}>"
class TLSReportFailure(Base):
"""Grouped TLS-RPT failure detail without message-level identifiers."""
__tablename__ = "tls_report_failures"
id = Column(Integer, primary_key=True, index=True)
report_id = Column(Integer, ForeignKey("tls_reports.id"), nullable=False, index=True)
result_type = Column(String, nullable=False, index=True)
failed_session_count = Column(Integer, nullable=False, default=0)
sending_mta_ip = Column(String, nullable=True, index=True)
receiving_mx_hostname = Column(String, nullable=True, index=True)
receiving_mx_helo = Column(String, nullable=True)
receiving_ip = Column(String, nullable=True)
failure_reason_code = Column(String, nullable=True)
additional_information = Column(Text, nullable=True)
report = relationship("TLSReport", back_populates="failures")
__table_args__ = (
Index("ix_tls_report_failures_result_count", "result_type", "failed_session_count"),
Index("ix_tls_report_failures_report_result", "report_id", "result_type"),
)
def __repr__(self):
return f"<TLSReportFailure {self.result_type} count={self.failed_session_count}>"
+168
View File
@@ -0,0 +1,168 @@
"""Parser for SMTP TLS Reporting (TLS-RPT) JSON aggregates."""
from __future__ import annotations
import gzip
import hashlib
import io
import json
import zipfile
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
MAX_TLS_REPORT_SIZE = 10 * 1024 * 1024
MAX_TLS_UNCOMPRESSED_SIZE = 100 * 1024 * 1024
MAX_TLS_FILES_IN_ARCHIVE = 10
def _clean(value: Any) -> str:
return " ".join(str(value or "").replace("\r", " ").replace("\n", " ").split())
def _safe_int(value: Any) -> int:
try:
return max(int(value or 0), 0)
except (TypeError, ValueError):
return 0
def _parse_datetime(value: Any) -> Optional[datetime]:
text = _clean(value)
if not text:
return None
try:
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is not None:
parsed = parsed.astimezone(timezone.utc)
return parsed.replace(tzinfo=None)
def _extract_json_from_zip(file_content: bytes) -> Optional[bytes]:
try:
with zipfile.ZipFile(io.BytesIO(file_content)) as archive:
files = archive.infolist()
if len(files) > MAX_TLS_FILES_IN_ARCHIVE:
raise ValueError("TLS report archive contains too many files")
if sum(item.file_size for item in files) > MAX_TLS_UNCOMPRESSED_SIZE:
raise ValueError("TLS report archive is too large after decompression")
for item in files:
if item.filename.lower().endswith(".json"):
if item.file_size > MAX_TLS_UNCOMPRESSED_SIZE:
raise ValueError("TLS report JSON is too large after decompression")
return archive.read(item.filename)
except zipfile.BadZipFile:
return None
return None
def _extract_json_content(file_content: bytes, filename: str) -> bytes:
lower = filename.lower()
if lower.endswith(".zip"):
extracted = _extract_json_from_zip(file_content)
if extracted is not None:
return extracted
if lower.endswith((".gz", ".gzip")):
try:
return gzip.decompress(file_content)
except gzip.BadGzipFile as exc:
raise ValueError("Invalid gzip TLS report") from exc
if lower.endswith(".json"):
return file_content
raise ValueError("Invalid TLS report file type. Upload .json, .json.gz, or .zip.")
def _policy_domain(policy: Dict[str, Any]) -> str:
return _clean(policy.get("policy-domain")).lower().strip(".")
def _normalize_failure(detail: Dict[str, Any]) -> Dict[str, Any]:
return {
"result_type": _clean(detail.get("result-type") or "unknown").lower(),
"failed_session_count": _safe_int(detail.get("failed-session-count")),
"sending_mta_ip": _clean(detail.get("sending-mta-ip")),
"receiving_mx_hostname": _clean(detail.get("receiving-mx-hostname")).lower(),
"receiving_mx_helo": _clean(detail.get("receiving-mx-helo")),
"receiving_ip": _clean(detail.get("receiving-ip")),
"failure_reason_code": _clean(detail.get("failure-reason-code")),
"additional_information": _clean(detail.get("additional-information")),
}
def _load_payload(json_content: bytes) -> Dict[str, Any]:
try:
payload = json.loads(json_content.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ValueError("TLS report is not valid JSON") from exc
if not isinstance(payload, dict):
raise ValueError("TLS report JSON must be an object")
return payload
def _normalize_policy(item: Dict[str, Any]) -> Optional[Dict[str, Any]]:
policy = item.get("policy") or {}
summary = item.get("summary") or {}
if not isinstance(policy, dict) or not isinstance(summary, dict):
return None
domain = _policy_domain(policy)
if not domain:
return None
failures = [
_normalize_failure(detail)
for detail in item.get("failure-details") or []
if isinstance(detail, dict)
]
return {
"policy_domain": domain,
"policy_type": _clean(policy.get("policy-type")).lower(),
"policy": policy,
"total_successful_sessions": _safe_int(summary.get("total-successful-session-count")),
"total_failure_sessions": _safe_int(summary.get("total-failure-session-count")),
"failures": failures,
}
def _normalize_policies(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
policies = []
for item in payload.get("policies") or []:
if not isinstance(item, dict):
continue
policy = _normalize_policy(item)
if policy is not None:
policies.append(policy)
if not policies:
raise ValueError("TLS report does not contain any policy-domain entries")
return policies
class TLSReportParser:
"""Parse TLS-RPT JSON while retaining only aggregate posture data."""
@staticmethod
def parse_file(file_content: bytes, filename: str) -> Dict[str, Any]:
"""Parse a TLS-RPT JSON, gzip, or zip attachment into normalized dictionaries."""
if len(file_content) > MAX_TLS_REPORT_SIZE:
raise ValueError("TLS report is too large")
if not file_content:
raise ValueError("TLS report is empty")
json_content = _extract_json_content(file_content, filename)
if len(json_content) > MAX_TLS_UNCOMPRESSED_SIZE:
raise ValueError("TLS report is too large after decompression")
payload = _load_payload(json_content)
date_range = payload.get("date-range") or {}
policies = _normalize_policies(payload)
report_id = _clean(payload.get("report-id"))
if not report_id:
report_id = "tlsrpt-" + hashlib.sha256(json_content).hexdigest()[:24]
return {
"report_id": report_id,
"org_name": _clean(payload.get("organization-name")),
"contact_info": _clean(payload.get("contact-info")),
"begin_date": _parse_datetime(date_range.get("start-datetime")),
"end_date": _parse_datetime(date_range.get("end-datetime")),
"policies": policies,
}
@@ -0,0 +1,336 @@
"""Persistence and summarization helpers for SMTP TLS reports."""
from __future__ import annotations
import json
from collections import defaultdict
from datetime import datetime, timedelta
from typing import Any, Dict, Iterable, List, Optional
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, selectinload
from app.models.domain import Domain
from app.models.report import TLSReport, TLSReportFailure
from app.utils.domain_validator import DomainValidationError, validate_domain
TLS_REPORT_PRIVACY_CONTROLS = {
"retention": (
"TLS reports store aggregate session counts, reporting organization metadata, "
"policy domains, and grouped TLS failure details."
),
"stored_fields": [
"report id",
"reporting organization",
"contact info",
"policy domain",
"policy type",
"report date range",
"successful and failed session counts",
"grouped result type and failed-session count",
"sending MTA IP when supplied by the reporter",
"receiving MX host/HELO/IP when supplied by the reporter",
"failure reason code and additional grouped diagnostic text",
],
"not_stored": [
"message bodies",
"message subjects",
"sender or recipient addresses",
"recipient local-parts",
"raw uploaded attachments",
"mailbox credentials or source message identifiers",
],
}
def tls_report_exists(db: Session, report_id: str, policy_domain: str) -> bool:
"""Return True when a TLS report policy entry already exists."""
normalized_report_id = str(report_id or "").strip()
normalized_domain = str(policy_domain or "").strip().lower().strip(".")
if not normalized_report_id or not normalized_domain:
return False
return (
db.query(TLSReport.id)
.filter(
TLSReport.report_id == normalized_report_id,
TLSReport.policy_domain == normalized_domain,
)
.first()
is not None
)
def _domain_for_report(db: Session, domain_name: Optional[str]) -> Optional[Domain]:
if not domain_name:
return None
normalized = domain_name.lower().strip(".")
is_valid, _, error_code = validate_domain(normalized, check_dns=False)
if not is_valid and error_code != DomainValidationError.DNS_RESOLUTION_FAILED:
return None
domain = db.query(Domain).filter(Domain.name == normalized).first()
if domain is None:
domain = Domain(name=normalized)
db.add(domain)
db.flush()
return domain
def _save_policy_report(
db: Session,
parsed_report: Dict[str, Any],
policy: Dict[str, Any],
) -> tuple[Optional[TLSReport], bool]:
report_id = str(parsed_report.get("report_id") or "").strip()
policy_domain = str(policy.get("policy_domain") or "").strip().lower().strip(".")
if not report_id or not policy_domain:
return None, False
existing = (
db.query(TLSReport)
.filter(
TLSReport.report_id == report_id,
TLSReport.policy_domain == policy_domain,
)
.first()
)
if existing is not None:
return existing, False
domain = _domain_for_report(db, policy_domain)
row = TLSReport(
domain_id=domain.id if domain else None,
report_id=report_id,
org_name=parsed_report.get("org_name"),
contact_info=parsed_report.get("contact_info"),
policy_domain=policy_domain,
policy_type=policy.get("policy_type"),
begin_date=parsed_report.get("begin_date"),
end_date=parsed_report.get("end_date"),
total_successful_sessions=policy.get("total_successful_sessions") or 0,
total_failure_sessions=policy.get("total_failure_sessions") or 0,
raw_policy=json.dumps(policy.get("policy") or {}, sort_keys=True),
)
for failure in policy.get("failures") or []:
row.failures.append(
TLSReportFailure(
result_type=failure.get("result_type") or "unknown",
failed_session_count=failure.get("failed_session_count") or 0,
sending_mta_ip=failure.get("sending_mta_ip") or None,
receiving_mx_hostname=failure.get("receiving_mx_hostname") or None,
receiving_mx_helo=failure.get("receiving_mx_helo") or None,
receiving_ip=failure.get("receiving_ip") or None,
failure_reason_code=failure.get("failure_reason_code") or None,
additional_information=failure.get("additional_information") or None,
)
)
db.add(row)
try:
db.flush()
except IntegrityError:
db.rollback()
existing = (
db.query(TLSReport)
.filter(
TLSReport.report_id == report_id,
TLSReport.policy_domain == policy_domain,
)
.first()
)
if existing is not None:
return existing, False
raise
return row, True
def save_tls_report(db: Session, parsed_report: Dict[str, Any]) -> Dict[str, Any]:
"""Persist parsed TLS report policy entries.
One TLS-RPT JSON can carry multiple policy domains. Each policy is stored
independently so partial duplicate imports can still add newly seen domains.
The caller owns the transaction.
"""
rows: List[TLSReport] = []
created = 0
skipped = 0
for policy in parsed_report.get("policies") or []:
row, was_created = _save_policy_report(db, parsed_report, policy)
if row is None:
skipped += 1
continue
rows.append(row)
if was_created:
created += 1
else:
skipped += 1
return {"rows": rows, "created": created, "skipped": skipped}
def tls_report_to_dict(row: TLSReport) -> Dict[str, Any]:
"""Convert a TLS report row to an API-safe dictionary."""
return {
"id": row.id,
"report_id": row.report_id,
"domain": row.domain.name if row.domain else row.policy_domain,
"org_name": row.org_name,
"contact_info": row.contact_info,
"policy_domain": row.policy_domain,
"policy_type": row.policy_type,
"begin_date": row.begin_date.isoformat() if row.begin_date else None,
"end_date": row.end_date.isoformat() if row.end_date else None,
"total_successful_sessions": row.total_successful_sessions,
"total_failure_sessions": row.total_failure_sessions,
"processed_at": row.processed_at.isoformat() if row.processed_at else None,
"failures": [
{
"result_type": failure.result_type,
"failed_session_count": failure.failed_session_count,
"sending_mta_ip": failure.sending_mta_ip,
"receiving_mx_hostname": failure.receiving_mx_hostname,
"receiving_mx_helo": failure.receiving_mx_helo,
"receiving_ip": failure.receiving_ip,
"failure_reason_code": failure.failure_reason_code,
"additional_information": failure.additional_information,
}
for failure in row.failures
],
}
def _row_day(row: TLSReport) -> str:
basis = row.begin_date or row.end_date or row.processed_at or datetime.utcnow()
return basis.date().isoformat()
def _report_rows(
db: Session,
*,
domain: Optional[str] = None,
days: int = 30,
) -> Iterable[TLSReport]:
cutoff = datetime.utcnow() - timedelta(days=days)
query = db.query(TLSReport).options(
selectinload(TLSReport.domain), selectinload(TLSReport.failures)
)
query = query.filter(
(TLSReport.begin_date >= cutoff)
| (TLSReport.end_date >= cutoff)
| (TLSReport.processed_at >= cutoff)
)
if domain:
normalized = domain.lower().strip(".")
query = query.outerjoin(Domain).filter(
(Domain.name == normalized) | (TLSReport.policy_domain == normalized)
)
return query.order_by(TLSReport.begin_date.desc().nullslast(), TLSReport.id.desc()).all()
def summarize_tls_reports(
db: Session,
*,
domain: Optional[str] = None,
days: int = 30,
limit: int = 10,
) -> Dict[str, Any]:
"""Summarize TLS reports into trends and actionable failure groupings."""
rows = list(_report_rows(db, domain=domain, days=days))
totals = {
"reports": len(rows),
"successful_sessions": sum(row.total_successful_sessions or 0 for row in rows),
"failed_sessions": sum(row.total_failure_sessions or 0 for row in rows),
}
session_total = totals["successful_sessions"] + totals["failed_sessions"]
totals["failure_rate"] = (totals["failed_sessions"] / session_total) if session_total else 0.0
trend_map: Dict[str, Dict[str, Any]] = defaultdict(
lambda: {"date": "", "reports": 0, "successful_sessions": 0, "failed_sessions": 0}
)
domain_map: Dict[str, Dict[str, Any]] = defaultdict(
lambda: {
"domain": "",
"reports": 0,
"successful_sessions": 0,
"failed_sessions": 0,
"top_failure": None,
}
)
failure_map: Dict[str, Dict[str, Any]] = defaultdict(
lambda: {
"result_type": "",
"failed_sessions": 0,
"reports": set(),
"affected_domains": set(),
"receiving_mx_hostnames": set(),
"reason_codes": set(),
}
)
for row in rows:
day = _row_day(row)
trend = trend_map[day]
trend["date"] = day
trend["reports"] += 1
trend["successful_sessions"] += row.total_successful_sessions or 0
trend["failed_sessions"] += row.total_failure_sessions or 0
domain_summary = domain_map[row.policy_domain]
domain_summary["domain"] = row.policy_domain
domain_summary["reports"] += 1
domain_summary["successful_sessions"] += row.total_successful_sessions or 0
domain_summary["failed_sessions"] += row.total_failure_sessions or 0
top_for_row = None
for failure in row.failures:
result_type = failure.result_type or "unknown"
item = failure_map[result_type]
item["result_type"] = result_type
item["failed_sessions"] += failure.failed_session_count or 0
item["reports"].add(row.report_id)
item["affected_domains"].add(row.policy_domain)
if failure.receiving_mx_hostname:
item["receiving_mx_hostnames"].add(failure.receiving_mx_hostname)
if failure.failure_reason_code:
item["reason_codes"].add(failure.failure_reason_code)
if (
top_for_row is None
or (failure.failed_session_count or 0) > top_for_row.failed_session_count
):
top_for_row = failure
if top_for_row is not None:
domain_summary["top_failure"] = top_for_row.result_type
trends = [trend_map[key] for key in sorted(trend_map)]
affected_domains = []
for item in domain_map.values():
domain_sessions = item["successful_sessions"] + item["failed_sessions"]
item["failure_rate"] = item["failed_sessions"] / domain_sessions if domain_sessions else 0.0
affected_domains.append(item)
top_failures = []
for item in failure_map.values():
top_failures.append(
{
"result_type": item["result_type"],
"failed_sessions": item["failed_sessions"],
"report_count": len(item["reports"]),
"affected_domains": sorted(item["affected_domains"]),
"receiving_mx_hostnames": sorted(item["receiving_mx_hostnames"])[:5],
"reason_codes": sorted(item["reason_codes"])[:5],
}
)
top_failures.sort(key=lambda item: item["failed_sessions"], reverse=True)
affected_domains.sort(key=lambda item: item["failed_sessions"], reverse=True)
return {
"domain": domain,
"days": days,
"totals": totals,
"trends": trends,
"top_failures": top_failures[:limit],
"affected_domains": affected_domains[:limit],
"privacy": TLS_REPORT_PRIVACY_CONTROLS,
}
+1
View File
@@ -39,6 +39,7 @@
<li><a href="/domains">Domains</a></li>
<li><a href="/reports">Reports</a></li>
<li><a href="/forensics">Forensics</a></li>
<li><a href="/tls-reports">TLS Reports</a></li>
<li><a href="/upload">Upload</a></li>
<li><a href="/mail-sources">Mail Sources</a></li>
<li><a href="/operations">Health</a></li>
+281
View File
@@ -0,0 +1,281 @@
{% extends "layouts/base.html" %}
{% from "components/ui/card.html" import card, card_header, card_title, card_description, card_content %}
{% from "components/ui/table.html" import table, thead, tbody, tr, th, td %}
{% block title %}DMARQ - TLS Reports{% endblock %}
{% block content %}
<div class="container mx-auto py-4" x-data="tlsReportsApp()" x-init="init()">
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between mb-6">
<div>
<h1 class="text-2xl font-bold">TLS Reports</h1>
<p class="text-sm text-base-content/70 mt-1">SMTP TLS delivery trends and failure causes</p>
</div>
<div class="flex flex-wrap gap-2">
<select class="select select-bordered select-sm" x-model="filters.days" @change="refresh()">
<option value="7">7 days</option>
<option value="30">30 days</option>
<option value="90">90 days</option>
<option value="365">365 days</option>
</select>
<button class="btn btn-outline btn-sm" @click="refresh()">Refresh</button>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{% call card() %}
{% call card_header() %}{% call card_title() %}Reports{% endcall %}{% endcall %}
{% call card_content() %}
<div class="text-2xl font-semibold" x-text="summary.totals.reports"></div>
{% endcall %}
{% endcall %}
{% call card() %}
{% call card_header() %}{% call card_title() %}Successful Sessions{% endcall %}{% endcall %}
{% call card_content() %}
<div class="text-2xl font-semibold text-success" x-text="formatNumber(summary.totals.successful_sessions)"></div>
{% endcall %}
{% endcall %}
{% call card() %}
{% call card_header() %}{% call card_title() %}Failed Sessions{% endcall %}{% endcall %}
{% call card_content() %}
<div class="text-2xl font-semibold text-error" x-text="formatNumber(summary.totals.failed_sessions)"></div>
{% endcall %}
{% endcall %}
{% call card() %}
{% call card_header() %}{% call card_title() %}Failure Rate{% endcall %}{% endcall %}
{% call card_content() %}
<div class="text-2xl font-semibold" x-text="formatPercent(summary.totals.failure_rate)"></div>
{% endcall %}
{% endcall %}
</div>
<div class="grid grid-cols-1 xl:grid-cols-[1fr_22rem] gap-6 mb-6">
{% call card() %}
{% call card_header() %}
<div class="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div>
{% call card_title() %}Failure Trends{% endcall %}
{% call card_description() %}Daily TLS session results from imported TLS-RPT files{% endcall %}
</div>
<input class="input input-bordered input-sm md:w-64" x-model.debounce.250ms="filters.domain" @input="refresh()" placeholder="Filter by domain">
</div>
{% endcall %}
{% call card_content() %}
<template x-if="loading">
<div class="py-12 text-center"><span class="loading loading-spinner loading-lg"></span></div>
</template>
<template x-if="!loading && error">
<div class="alert alert-error" x-text="error"></div>
</template>
<template x-if="!loading && !error && summary.trends.length === 0">
<p class="text-base-content/60">No TLS report data is available for the current filters.</p>
</template>
<div class="space-y-3" x-show="!loading && !error && summary.trends.length > 0">
<template x-for="day in summary.trends" :key="day.date">
<div class="grid grid-cols-[6.5rem_1fr_7rem] gap-3 items-center">
<span class="text-sm text-base-content/70" x-text="day.date"></span>
<div class="h-3 rounded bg-base-300 overflow-hidden flex">
<div class="bg-success" :style="`width: ${trendSuccessWidth(day)}%`"></div>
<div class="bg-error" :style="`width: ${trendFailureWidth(day)}%`"></div>
</div>
<span class="text-right text-sm font-medium" x-text="formatNumber(day.failed_sessions) + ' failed'"></span>
</div>
</template>
</div>
{% endcall %}
{% endcall %}
{% call card() %}
{% call card_header() %}
{% call card_title() %}Import TLS Report{% endcall %}
{% call card_description() %}.json, .json.gz, or .zip{% endcall %}
{% endcall %}
{% call card_content() %}
<form class="space-y-3" @submit.prevent="uploadReport()">
<input type="file" class="file-input file-input-bordered w-full" accept=".json,.gz,.gzip,.zip,application/json,application/zip" @change="selectedFile = $event.target.files[0] || null">
<button class="btn btn-primary w-full" type="submit" :disabled="uploading || !selectedFile">
<span x-show="!uploading">Upload TLS Report</span>
<span x-show="uploading" class="loading loading-spinner loading-sm"></span>
</button>
<p class="text-sm" :class="uploadError ? 'text-error' : 'text-success'" x-show="uploadMessage" x-text="uploadMessage"></p>
</form>
{% endcall %}
{% endcall %}
</div>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-6">
{% call card() %}
{% call card_header() %}
{% call card_title() %}Top Failure Causes{% endcall %}
{% call card_description() %}Grouped by TLS-RPT result type{% endcall %}
{% endcall %}
{% call card_content() %}
{% call table() %}
{% call thead() %}
{% call tr() %}
{% call th() %}Cause{% endcall %}
{% call th("text-right") %}Failed Sessions{% endcall %}
{% call th() %}Domains{% endcall %}
{% call th() %}MX Hosts{% endcall %}
{% endcall %}
{% endcall %}
{% call tbody() %}
<template x-if="summary.top_failures.length === 0">
<tr><td colspan="4" class="text-center py-8 text-base-content/60">No failures found.</td></tr>
</template>
<template x-for="failure in summary.top_failures" :key="failure.result_type">
{% call tr() %}
{% call td() %}<span class="badge badge-error badge-outline" x-text="failure.result_type"></span>{% endcall %}
{% call td("text-right font-semibold") %}<span x-text="formatNumber(failure.failed_sessions)"></span>{% endcall %}
{% call td() %}<span class="block max-w-56 truncate" x-text="failure.affected_domains.join(', ') || '-'"></span>{% endcall %}
{% call td() %}<span class="block max-w-56 truncate" x-text="failure.receiving_mx_hostnames.join(', ') || '-'"></span>{% endcall %}
{% endcall %}
</template>
{% endcall %}
{% endcall %}
{% endcall %}
{% endcall %}
{% call card() %}
{% call card_header() %}
{% call card_title() %}Affected Domains{% endcall %}
{% call card_description() %}Domains with TLS-RPT activity in the selected window{% endcall %}
{% endcall %}
{% call card_content() %}
{% call table() %}
{% call thead() %}
{% call tr() %}
{% call th() %}Domain{% endcall %}
{% call th("text-right") %}Reports{% endcall %}
{% call th("text-right") %}Failed{% endcall %}
{% call th("text-right") %}Rate{% endcall %}
{% endcall %}
{% endcall %}
{% call tbody() %}
<template x-if="summary.affected_domains.length === 0">
<tr><td colspan="4" class="text-center py-8 text-base-content/60">No affected domains found.</td></tr>
</template>
<template x-for="item in summary.affected_domains" :key="item.domain">
{% call tr() %}
{% call td() %}<a class="link link-hover font-medium" :href="'/domains/' + encodeURIComponent(item.domain)" x-text="item.domain"></a>{% endcall %}
{% call td("text-right") %}<span x-text="item.reports"></span>{% endcall %}
{% call td("text-right font-semibold") %}<span x-text="formatNumber(item.failed_sessions)"></span>{% endcall %}
{% call td("text-right") %}<span x-text="formatPercent(item.failure_rate)"></span>{% endcall %}
{% endcall %}
</template>
{% endcall %}
{% endcall %}
{% endcall %}
{% endcall %}
</div>
{% call card() %}
{% call card_header() %}
{% call card_title() %}Stored Data{% endcall %}
{% call card_description() %}Current TLS reporting retention and privacy controls{% endcall %}
{% endcall %}
{% call card_content() %}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<p class="text-sm text-base-content/70" x-text="summary.privacy.retention"></p>
<div>
<h2 class="font-semibold mb-2 text-sm">Stored</h2>
<ul class="text-sm space-y-1 list-disc pl-4">
<template x-for="field in summary.privacy.stored_fields" :key="field">
<li x-text="field"></li>
</template>
</ul>
</div>
<div>
<h2 class="font-semibold mb-2 text-sm">Not Stored</h2>
<ul class="text-sm space-y-1 list-disc pl-4">
<template x-for="field in summary.privacy.not_stored" :key="field">
<li x-text="field"></li>
</template>
</ul>
</div>
</div>
{% endcall %}
{% endcall %}
</div>
{% endblock %}
{% block scripts %}
<script>
function tlsReportsApp() {
const emptySummary = {
totals: { reports: 0, successful_sessions: 0, failed_sessions: 0, failure_rate: 0 },
trends: [],
top_failures: [],
affected_domains: [],
privacy: { retention: '', stored_fields: [], not_stored: [] },
};
return {
loading: false,
uploading: false,
error: '',
uploadMessage: '',
uploadError: false,
selectedFile: null,
filters: { domain: '', days: '30' },
summary: emptySummary,
init() {
this.refresh();
},
async refresh() {
this.loading = true;
this.error = '';
const params = new URLSearchParams({ days: this.filters.days, limit: '10' });
if (this.filters.domain.trim()) params.set('domain', this.filters.domain.trim());
try {
const response = await fetch(`/api/v1/tls-reports/summary?${params.toString()}`);
if (!response.ok) throw new Error('Unable to load TLS report summary');
this.summary = await response.json();
} catch (err) {
this.error = err.message || 'Unable to load TLS report summary';
} finally {
this.loading = false;
}
},
async uploadReport() {
if (!this.selectedFile) return;
this.uploading = true;
this.uploadMessage = '';
this.uploadError = false;
const payload = new FormData();
payload.append('file', this.selectedFile);
try {
const response = await fetch('/api/v1/tls-reports/upload', {
method: 'POST',
body: payload,
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.detail || 'Upload failed');
this.uploadMessage = data.message || 'TLS report imported.';
this.selectedFile = null;
await this.refresh();
} catch (err) {
this.uploadError = true;
this.uploadMessage = err.message || 'Upload failed';
} finally {
this.uploading = false;
}
},
formatNumber(value) {
return Number(value || 0).toLocaleString();
},
formatPercent(value) {
return `${(Number(value || 0) * 100).toFixed(1)}%`;
},
trendTotal(day) {
return Math.max(Number(day.successful_sessions || 0) + Number(day.failed_sessions || 0), 1);
},
trendSuccessWidth(day) {
return Math.round((Number(day.successful_sessions || 0) / this.trendTotal(day)) * 100);
},
trendFailureWidth(day) {
return Math.round((Number(day.failed_sessions || 0) / this.trendTotal(day)) * 100);
},
};
}
</script>
{% endblock %}
@@ -0,0 +1,99 @@
import gzip
import io
import json
import zipfile
import pytest
from app.services.tls_report_parser import TLSReportParser
SAMPLE_TLS_REPORT = {
"organization-name": "Example Reporter",
"date-range": {
"start-datetime": "2026-05-20T00:00:00Z",
"end-datetime": "2026-05-20T23:59:59Z",
},
"contact-info": "tlsrpt@example-reporter.test",
"report-id": "tls-report-20260520",
"policies": [
{
"policy": {
"policy-type": "sts",
"policy-string": ["version: STSv1", "mode: enforce"],
"policy-domain": "Example.com.",
"mx-host": ["mx.example.com"],
},
"summary": {
"total-successful-session-count": 125,
"total-failure-session-count": 7,
},
"failure-details": [
{
"result-type": "certificate-expired",
"sending-mta-ip": "203.0.113.9",
"receiving-mx-hostname": "MX.EXAMPLE.COM",
"failed-session-count": 7,
"failure-reason-code": "tls",
"additional-information": "certificate expired",
}
],
}
],
}
def sample_tls_report_bytes(report=None):
return json.dumps(report or SAMPLE_TLS_REPORT).encode("utf-8")
def test_parse_tls_report_json_normalizes_policy_and_failures():
parsed = TLSReportParser.parse_file(sample_tls_report_bytes(), "tls-report.json")
assert parsed["report_id"] == "tls-report-20260520"
assert parsed["org_name"] == "Example Reporter"
assert parsed["begin_date"].isoformat() == "2026-05-20T00:00:00"
assert parsed["policies"][0]["policy_domain"] == "example.com"
assert parsed["policies"][0]["policy_type"] == "sts"
assert parsed["policies"][0]["total_failure_sessions"] == 7
assert parsed["policies"][0]["failures"][0]["result_type"] == "certificate-expired"
assert parsed["policies"][0]["failures"][0]["receiving_mx_hostname"] == "mx.example.com"
def test_parse_tls_report_gzip():
compressed = gzip.compress(sample_tls_report_bytes())
parsed = TLSReportParser.parse_file(compressed, "tls-report.json.gz")
assert parsed["report_id"] == "tls-report-20260520"
assert parsed["policies"][0]["total_successful_sessions"] == 125
def test_parse_tls_report_zip():
archive = io.BytesIO()
with zipfile.ZipFile(archive, "w") as zip_file:
zip_file.writestr("nested/tls-report.json", sample_tls_report_bytes())
parsed = TLSReportParser.parse_file(archive.getvalue(), "tls-report.zip")
assert parsed["policies"][0]["policy_domain"] == "example.com"
def test_parse_tls_report_generates_stable_id_when_missing():
report = dict(SAMPLE_TLS_REPORT)
report.pop("report-id")
parsed = TLSReportParser.parse_file(sample_tls_report_bytes(report), "tls-report.json")
assert parsed["report_id"].startswith("tlsrpt-")
assert len(parsed["report_id"]) == 31
def test_parse_tls_report_rejects_missing_policies():
with pytest.raises(ValueError, match="policy-domain"):
TLSReportParser.parse_file(b'{"policies":[]}', "tls-report.json")
def test_parse_tls_report_rejects_invalid_extension():
with pytest.raises(ValueError, match="Invalid TLS report file type"):
TLSReportParser.parse_file(sample_tls_report_bytes(), "tls-report.txt")
+152
View File
@@ -0,0 +1,152 @@
from fastapi import HTTPException
from fastapi.testclient import TestClient
from app.api.api_v1.endpoints import tls_reports as tls_endpoint
from app.models.domain import Domain
from app.models.report import TLSReport, TLSReportFailure
from app.services.tls_report_parser import TLSReportParser
from app.services.tls_report_persistence import (
save_tls_report,
summarize_tls_reports,
tls_report_exists,
)
from app.tests.test_tls_report_parser import sample_tls_report_bytes
def test_upload_tls_report_persists_policy_and_failure_details(authed_client, db_session):
response = authed_client.post(
"/api/v1/tls-reports/upload",
files={"file": ("tls-report.json", sample_tls_report_bytes(), "application/json")},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["policies_created"] == 1
assert data["policies_skipped"] == 0
assert "message bodies" in data["privacy"]["not_stored"]
report = db_session.query(TLSReport).one()
assert report.policy_domain == "example.com"
assert report.total_failure_sessions == 7
assert db_session.query(Domain).filter(Domain.name == "example.com").count() == 1
failure = db_session.query(TLSReportFailure).one()
assert failure.result_type == "certificate-expired"
assert failure.failed_session_count == 7
def test_upload_tls_report_marks_duplicates_without_double_counting(authed_client, db_session):
files = {"file": ("tls-report.json", sample_tls_report_bytes(), "application/json")}
assert authed_client.post("/api/v1/tls-reports/upload", files=files).status_code == 200
response = authed_client.post(
"/api/v1/tls-reports/upload",
files={"file": ("tls-report.json", sample_tls_report_bytes(), "application/json")},
)
assert response.status_code == 200
data = response.json()
assert data["duplicate"] is True
assert data["policies_created"] == 0
assert data["policies_skipped"] == 1
assert db_session.query(TLSReport).count() == 1
def test_tls_report_summary_groups_failures_and_domains(authed_client):
authed_client.post(
"/api/v1/tls-reports/upload",
files={"file": ("tls-report.json", sample_tls_report_bytes(), "application/json")},
)
response = authed_client.get("/api/v1/tls-reports/summary?domain=example.com&days=30")
assert response.status_code == 200
data = response.json()
assert data["totals"]["reports"] == 1
assert data["totals"]["failed_sessions"] == 7
assert data["top_failures"][0]["result_type"] == "certificate-expired"
assert data["top_failures"][0]["affected_domains"] == ["example.com"]
assert data["affected_domains"][0]["failure_rate"] > 0
assert "sender or recipient addresses" in data["privacy"]["not_stored"]
def test_list_tls_reports_filters_by_domain(authed_client, db_session):
parsed = TLSReportParser.parse_file(sample_tls_report_bytes(), "tls-report.json")
second = dict(parsed)
second["report_id"] = "tls-report-second"
second["policies"] = [dict(parsed["policies"][0], policy_domain="example.net")]
save_tls_report(db_session, parsed)
save_tls_report(db_session, second)
db_session.commit()
response = authed_client.get("/api/v1/tls-reports?domain=example.net")
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert data["reports"][0]["policy_domain"] == "example.net"
def test_tls_report_summary_empty_response_includes_privacy_controls(authed_client):
response = authed_client.get("/api/v1/tls-reports/summary")
assert response.status_code == 200
data = response.json()
assert data["totals"]["reports"] == 0
assert data["top_failures"] == []
assert "raw uploaded attachments" in data["privacy"]["not_stored"]
def test_tls_report_persistence_helpers(db_session):
parsed = TLSReportParser.parse_file(sample_tls_report_bytes(), "tls-report.json")
result = save_tls_report(db_session, parsed)
db_session.commit()
assert result["created"] == 1
assert tls_report_exists(db_session, "tls-report-20260520", "example.com")
summary = summarize_tls_reports(db_session, domain="example.com")
assert summary["totals"]["successful_sessions"] == 125
def test_upload_tls_report_rejects_invalid_file_type(authed_client):
response = authed_client.post(
"/api/v1/tls-reports/upload",
files={"file": ("tls-report.txt", sample_tls_report_bytes(), "text/plain")},
)
assert response.status_code == 400
def test_validate_upload_rejects_missing_name_and_large_file():
missing_name = type("Upload", (), {"filename": ""})()
too_large = type("Upload", (), {"filename": "report.json"})()
try:
tls_endpoint._validate_upload(missing_name, b"content")
except HTTPException as exc:
assert exc.status_code == 400
else:
raise AssertionError("Expected missing filename to be rejected")
try:
tls_endpoint._validate_upload(
too_large,
b"x" * (tls_endpoint.MAX_TLS_REPORT_SIZE + 1),
)
except HTTPException as exc:
assert exc.status_code == 413
else:
raise AssertionError("Expected large file to be rejected")
def test_tls_report_html_page_renders():
from app.core.logto import SESSION_COOKIE, create_session_token # noqa: PLC0415
from app.main import app as main_app # noqa: PLC0415
cookies = {SESSION_COOKIE: create_session_token(user_id=1)}
with TestClient(main_app) as client:
response = client.get("/tls-reports", cookies=cookies)
assert response.status_code == 200
assert "TLS Reports" in response.text
+2
View File
@@ -25,4 +25,6 @@ For installation instructions, check the [Docker Setup](deployment/docker.md) or
For Microsoft 365 setup, see [Microsoft 365 Mail Sources](user_guide/microsoft365.md).
For SMTP TLS reporting imports and privacy controls, see [TLS Reports](user_guide/tls_reports.md).
For aggregate-report parser support, known edge cases, and fixture guidance, see [DMARC Aggregate Format Compatibility](reference/dmarc-compatibility.md).
+1 -1
View File
@@ -220,7 +220,7 @@ Goal: turn DMARQ into a broader email authentication posture console (still priv
Planned:
- MTA-STS posture: delivered cached `_mta-sts` TXT checks, HTTPS policy validation, domain-detail evidence, and operator guidance for missing, invalid, or non-enforcing policies. Optional helper tooling remains a future enhancement.
- TLS reporting posture: ingest and summarize TLS report data (where available) with actionable failure grouping.
- TLS reporting posture: delivered authenticated TLS-RPT upload for `.json`, `.json.gz`, and `.zip` attachments; duplicate-safe persistence by report ID and policy domain; daily session trends; top failure-cause grouping; affected-domain summaries; and explicit privacy controls that avoid storing message content or recipient data.
- BIMI posture: record validation + readiness checks + operator guidance.
- Extended DNS checks that support the posture surface (e.g., MX/BIMI; optional DANE/TLSA where relevant).
+33 -1
View File
@@ -259,6 +259,38 @@ Uploads a new DMARC report for processing.
}
```
### TLS Reports
#### Upload TLS Report
```
POST /tls-reports/upload
```
Uploads an SMTP TLS Reporting aggregate attachment. Supported file types are
`.json`, `.json.gz`, and `.zip`.
**Example Response:**
```json
{
"success": true,
"report_id": "tls-report-20260520",
"policies_created": 1,
"policies_skipped": 0,
"duplicate": false,
"message": "TLS report imported."
}
```
#### Summarize TLS Reports
```
GET /tls-reports/summary?domain=example.com&days=30
```
Returns aggregate TLS trends, top failure causes, affected domains, and the
privacy controls for stored TLS-RPT data.
### Statistics
#### Compliance Summary
@@ -413,4 +445,4 @@ Supported events:
- `report.processed` - When a new report is processed
- `compliance.threshold` - When compliance falls below threshold
- `domain.added` - When a domain is added
- `domain.removed` - When a domain is removed
- `domain.removed` - When a domain is removed
+53
View File
@@ -0,0 +1,53 @@
# TLS Reports
DMARQ can import SMTP TLS Reporting (TLS-RPT) JSON aggregates and summarize
delivery security failures alongside the existing DMARC posture views.
## Importing TLS Reports
Open **TLS Reports** and upload a `.json`, `.json.gz`, or `.zip` TLS-RPT
attachment. A single file can contain multiple policy domains; DMARQ stores
each policy-domain entry independently and skips duplicates by `report-id` plus
policy domain.
Imported reports appear in the same page as:
- daily successful and failed TLS session trends
- top TLS failure causes grouped by `result-type`
- affected policy domains with failure rates
- receiving MX hostnames and reason codes when reporters include them
## API
TLS reporting endpoints require the same admin authentication as other
operational endpoints:
- `POST /api/v1/tls-reports/upload` imports one TLS-RPT attachment.
- `GET /api/v1/tls-reports` lists stored TLS report policy entries.
- `GET /api/v1/tls-reports/summary?domain=example.com&days=30` returns trends,
top failure causes, and affected domains.
## Retention and Privacy
DMARQ stores only aggregate TLS reporting posture data:
- report ID
- reporting organization and contact info
- policy domain and policy type
- report date range
- successful and failed session counts
- grouped failure result type and failed-session count
- sending MTA IP, receiving MX hostname, HELO, or IP when supplied by the report
- failure reason code and grouped diagnostic text
DMARQ does not store:
- message bodies
- message subjects
- sender or recipient addresses
- recipient local-parts
- raw uploaded attachments
- mailbox credentials or source message identifiers
Use normal database backup and retention processes to control how long imported
TLS-RPT aggregates remain available.
+2 -1
View File
@@ -31,6 +31,7 @@ nav:
- Dashboard: user_guide/dashboard.md
- Managing Domains: user_guide/domains.md
- DMARC Reports: user_guide/reports.md
- TLS Reports: user_guide/tls_reports.md
- IMAP Integration: user_guide/imap.md
- Settings: user_guide/settings.md
- Installation:
@@ -74,4 +75,4 @@ markdown_extensions:
- attr_list
- md_in_html
- toc:
permalink: true
permalink: true