337 lines
12 KiB
Python
337 lines
12 KiB
Python
"""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,
|
|
}
|