import gzip import io import logging import zipfile from datetime import datetime from typing import Any, Dict, Optional import defusedxml.ElementTree as ET # Set up logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) # Security constants for file upload protection MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB MAX_UNCOMPRESSED_SIZE = 100 * 1024 * 1024 # 100 MB for zip bomb protection MAX_FILES_IN_ARCHIVE = 10 # Maximum number of files in a zip archive class DMARCParser: """ Parser for DMARC Aggregate Reports (XML format) """ @staticmethod def parse_file(file_content: bytes, filename: str) -> Dict[str, Any]: """ Parse a DMARC report file (XML, zip, or gzip) into a dictionary Args: file_content: The binary content of the file filename: The name of the file (used to determine type) Returns: Dict containing the parsed report data Raises: ValueError: If file is invalid, too large, or potentially malicious """ # Security: Check file size if len(file_content) > MAX_FILE_SIZE: raise ValueError( f"File too large. Maximum size is {MAX_FILE_SIZE / (1024*1024):.1f} MB" ) # Determine file type and extract XML content xml_content = DMARCParser._extract_xml_content(file_content, filename) if not xml_content: raise ValueError("Could not extract XML content from file") # Security: Check uncompressed XML size if len(xml_content) > MAX_UNCOMPRESSED_SIZE: raise ValueError( f"Uncompressed content too large ({len(xml_content) / (1024*1024):.1f} MB). " f"Maximum is {MAX_UNCOMPRESSED_SIZE / (1024*1024):.1f} MB. " "Possible zip bomb attack detected." ) # Parse the XML content return DMARCParser._parse_xml(xml_content) @staticmethod def _extract_xml_content(file_content: bytes, filename: str) -> Optional[bytes]: """ Extract XML content from various file formats (ZIP, GZIP, or plain XML) Raises: ValueError: If archive contains too many files or is potentially malicious """ # Try to handle as ZIP file if filename.lower().endswith(".zip"): try: with zipfile.ZipFile(io.BytesIO(file_content)) as z: # Security: Check number of files in archive file_list = z.infolist() if len(file_list) > MAX_FILES_IN_ARCHIVE: raise ValueError( f"ZIP archive contains too many files ({len(file_list)}). " f"Maximum is {MAX_FILES_IN_ARCHIVE}." ) # Security: Check for zip bomb by examining compression ratios total_uncompressed = sum(f.file_size for f in file_list) if total_uncompressed > MAX_UNCOMPRESSED_SIZE: raise ValueError( f"ZIP archive uncompressed size too large ({total_uncompressed / (1024*1024):.1f} MB). " f"Maximum is {MAX_UNCOMPRESSED_SIZE / (1024*1024):.1f} MB. " "Possible zip bomb attack detected." ) # Find the first XML file in the archive for file_info in file_list: if file_info.filename.lower().endswith(".xml"): # Security: Double-check individual file size if file_info.file_size > MAX_UNCOMPRESSED_SIZE: raise ValueError( f"XML file in archive too large ({file_info.file_size / (1024*1024):.1f} MB)" ) return z.read(file_info.filename) except zipfile.BadZipFile: pass # Try to handle as GZIP file if filename.lower().endswith(".gz") or filename.lower().endswith(".gzip"): try: return gzip.decompress(file_content) except gzip.BadGzipFile: pass # Assume it's plain XML if filename.lower().endswith(".xml"): return file_content return None @staticmethod def _parse_xml(xml_content: bytes) -> Dict[str, Any]: """ Parse DMARC XML content according to RFC 7489 """ try: root = ET.fromstring(xml_content) report = {} # Parse report metadata metadata = root.find("report_metadata") if metadata is not None: report["report_id"] = metadata.findtext("report_id", "") report["org_name"] = metadata.findtext("org_name", "") report["email"] = metadata.findtext("email", "") # Parse date range date_range = metadata.find("date_range") if date_range is not None: begin_ts = int(date_range.findtext("begin", 0)) end_ts = int(date_range.findtext("end", 0)) report["begin_date"] = datetime.fromtimestamp(begin_ts).isoformat() report["end_date"] = datetime.fromtimestamp(end_ts).isoformat() report["begin_timestamp"] = begin_ts report["end_timestamp"] = end_ts # Parse policy published policy = root.find("policy_published") if policy is not None: report["domain"] = policy.findtext("domain", "") report["policy"] = { "p": policy.findtext("p", "none"), "sp": policy.findtext("sp", ""), "pct": policy.findtext("pct", "100"), } # Parse records records = [] for record_elem in root.findall("record"): record = {} # Parse row row = record_elem.find("row") if row is not None: record["source_ip"] = row.findtext("source_ip", "") record["count"] = int(row.findtext("count", 0)) policy_evaluated = row.find("policy_evaluated") if policy_evaluated is not None: record["disposition"] = policy_evaluated.findtext("disposition", "none") record["dkim_result"] = policy_evaluated.findtext("dkim", "").lower() record["spf_result"] = policy_evaluated.findtext("spf", "").lower() # Parse identifiers identifiers = record_elem.find("identifiers") if identifiers is not None: record["header_from"] = identifiers.findtext("header_from", "") # Parse auth results auth_results = record_elem.find("auth_results") if auth_results is not None: # SPF results spf_entries = [] for spf in auth_results.findall("spf"): spf_entries.append( { "domain": spf.findtext("domain", ""), "result": spf.findtext("result", "").lower(), } ) if spf_entries: record["spf"] = spf_entries # DKIM results dkim_entries = [] for dkim in auth_results.findall("dkim"): dkim_entries.append( { "domain": dkim.findtext("domain", ""), "result": dkim.findtext("result", "").lower(), "selector": dkim.findtext("selector", ""), } ) if dkim_entries: record["dkim"] = dkim_entries records.append(record) report["records"] = records # Calculate summary stats total_count = sum(r["count"] for r in records) # Count records that pass either SPF or DKIM (or both) passed_count = sum( r["count"] for r in records if r.get("spf_result") == "pass" or r.get("dkim_result") == "pass" ) failed_count = total_count - passed_count # Log parse results for debugging logger.info(f"Parsed DMARC report for domain: {report.get('domain')}") logger.info(f"Found {len(records)} record entries with {total_count} total messages") logger.info(f"Messages passed: {passed_count}, failed: {failed_count}") if len(records) > 0: # Log the first record for debugging logger.info( f"Sample record - SPF: {records[0].get('spf_result')}, DKIM: {records[0].get('dkim_result')}" ) report["summary"] = { "total_count": total_count, "passed_count": passed_count, "failed_count": failed_count, "pass_rate": (passed_count / total_count * 100) if total_count > 0 else 0, } return report except Exception as e: logger.error(f"Error parsing DMARC XML: {str(e)}") raise ValueError(f"Error parsing DMARC XML: {str(e)}")