@@ -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")
|
||||
@@ -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))
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user