feat: add TLS reporting posture summaries
This commit is contained in:
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user