diff --git a/CHANGELOG.md b/CHANGELOG.md index 307bf14..9cf18ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ +2.1.0 +----- +- Add `get_report_zip()` and `email_results()` +- Add support for sending report emails via the command line + 2.0.1 ----- - - Fix documentation - Remove Python 2 code diff --git a/README.rst b/README.rst index b63092c..fb1f986 100644 --- a/README.rst +++ b/README.rst @@ -15,6 +15,7 @@ Features * Transparently handles gzip or zip compressed reports * Consistent data structures * Simple JSON and/or CSV output +* Optionally email the results CLI help ======== @@ -22,8 +23,12 @@ CLI help :: usage: parsedmarc [-h] [-o OUTPUT] [-n NAMESERVERS [NAMESERVERS ...]] - [-t TIMEOUT] [-H HOST] [-U USERNAME] [-p PASSWORD] - [-a ARCHIVE_FOLDER] [-d] [-i] [-T] [-v] + [-t TIMEOUT] [-H HOST] [-u USER] [-p PASSWORD] + [-a ARCHIVE_FOLDER] [-d] [-O OUTGOING_HOST] + [-U OUTGOING_USER] [-P OUTGOING_PASSWORD] + [-F OUTGOING_FROM] [-T OUTGOING_TO [OUTGOING_TO ...]] + [-S OUTGOING_SUBJECT] [-A OUTGOING_ATTACHMENT] + [-M OUTGOING_MESSAGE] [-i] [--test] [-v] [file_path [file_path ...]] Parses DMARC reports @@ -42,17 +47,32 @@ CLI help number of seconds to wait for an answer from DNS (default 6.0) -H HOST, --host HOST IMAP hostname or IP address - -U USERNAME, --username USERNAME - IMAP username + -u USER, --user USER IMAP user -p PASSWORD, --password PASSWORD IMAP password -a ARCHIVE_FOLDER, --archive-folder ARCHIVE_FOLDER Specifies the IMAP folder to move messages to after processing them (default: Archive) -d, --delete Delete the reports after processing them + -O OUTGOING_HOST, --outgoing-host OUTGOING_HOST + Email the results using this host + -U OUTGOING_USER, --outgoing-user OUTGOING_USER + Email the results using this user + -P OUTGOING_PASSWORD, --outgoing-password OUTGOING_PASSWORD + Email the results using this password + -F OUTGOING_FROM, --outgoing-from OUTGOING_FROM + Email the results using this from address + -T OUTGOING_TO [OUTGOING_TO ...], --outgoing-to OUTGOING_TO [OUTGOING_TO ...] + Email the results to these addresses + -S OUTGOING_SUBJECT, --outgoing-subject OUTGOING_SUBJECT + Email the results using this subject + -A OUTGOING_ATTACHMENT, --outgoing-attachment OUTGOING_ATTACHMENT + Email the results using this filename + -M OUTGOING_MESSAGE, --outgoing-message OUTGOING_MESSAGE + Email the results using this message -i, --idle Use an IMAP IDLE connection to process reports as they arrive in the inbox - -T, --test Do not move or delete IMAP messages + --test Do not move or delete IMAP messages -v, --version show program's version number and exit Sample aggregate report output diff --git a/docs/index.rst b/docs/index.rst index cdefa28..adf59e4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -20,6 +20,7 @@ Features * Transparently handles gzip or zip compressed reports * Consistent data structures * Simple JSON and/or CSV output +* Optionally email the results CLI help ======== @@ -27,8 +28,12 @@ CLI help :: usage: parsedmarc [-h] [-o OUTPUT] [-n NAMESERVERS [NAMESERVERS ...]] - [-t TIMEOUT] [-H HOST] [-U USERNAME] [-p PASSWORD] - [-a ARCHIVE_FOLDER] [-d] [-i] [-T] [-v] + [-t TIMEOUT] [-H HOST] [-u USER] [-p PASSWORD] + [-a ARCHIVE_FOLDER] [-d] [-O OUTGOING_HOST] + [-U OUTGOING_USER] [-P OUTGOING_PASSWORD] + [-F OUTGOING_FROM] [-T OUTGOING_TO [OUTGOING_TO ...]] + [-S OUTGOING_SUBJECT] [-A OUTGOING_ATTACHMENT] + [-M OUTGOING_MESSAGE] [-i] [--test] [-v] [file_path [file_path ...]] Parses DMARC reports @@ -47,17 +52,32 @@ CLI help number of seconds to wait for an answer from DNS (default 6.0) -H HOST, --host HOST IMAP hostname or IP address - -U USERNAME, --username USERNAME - IMAP username + -u USER, --user USER IMAP user -p PASSWORD, --password PASSWORD IMAP password -a ARCHIVE_FOLDER, --archive-folder ARCHIVE_FOLDER Specifies the IMAP folder to move messages to after processing them (default: Archive) -d, --delete Delete the reports after processing them + -O OUTGOING_HOST, --outgoing-host OUTGOING_HOST + Email the results using this host + -U OUTGOING_USER, --outgoing-user OUTGOING_USER + Email the results using this user + -P OUTGOING_PASSWORD, --outgoing-password OUTGOING_PASSWORD + Email the results using this password + -F OUTGOING_FROM, --outgoing-from OUTGOING_FROM + Email the results using this from address + -T OUTGOING_TO [OUTGOING_TO ...], --outgoing-to OUTGOING_TO [OUTGOING_TO ...] + Email the results to these addresses + -S OUTGOING_SUBJECT, --outgoing-subject OUTGOING_SUBJECT + Email the results using this subject + -A OUTGOING_ATTACHMENT, --outgoing-attachment OUTGOING_ATTACHMENT + Email the results using this filename + -M OUTGOING_MESSAGE, --outgoing-message OUTGOING_MESSAGE + Email the results using this message -i, --idle Use an IMAP IDLE connection to process reports as they arrive in the inbox - -T, --test Do not move or delete IMAP messages + --test Do not move or delete IMAP messages -v, --version show program's version number and exit Sample aggregate report output diff --git a/parsedmarc.py b/parsedmarc.py index af464cc..f5dcd72 100644 --- a/parsedmarc.py +++ b/parsedmarc.py @@ -12,7 +12,7 @@ from datetime import timedelta from io import BytesIO, StringIO from gzip import GzipFile import tarfile -from zipfile import ZipFile +import zipfile from csv import DictWriter import re from base64 import b64decode @@ -24,6 +24,12 @@ import tempfile import subprocess import socket from time import sleep +from email.mime.application import MIMEApplication +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.utils import COMMASPACE, formatdate +import smtplib +import ssl import publicsuffix import xmltodict @@ -38,7 +44,7 @@ import imapclient.exceptions import dateparser import mailparser -__version__ = "2.0.1" +__version__ = "2.1.0" logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) @@ -55,6 +61,10 @@ class IMAPError(RuntimeError): """Raised when an IMAP error occurs""" +class SMTPError(RuntimeError): + """Raised when a SMTP error occurs""" + + class InvalidDMARCReport(ParserError): """Raised when an invalid DMARC report is encountered""" @@ -89,7 +99,7 @@ def _get_base_domain(domain): def download_psl(): fresh_psl = publicsuffix.fetch() - with open(psl_path, "w") as fresh_psl_file: + with open(psl_path, "w", encoding="utf-8") as fresh_psl_file: fresh_psl_file.write(fresh_psl.read()) return publicsuffix.PublicSuffixList(fresh_psl) @@ -493,7 +503,7 @@ def extract_xml(input_): header = file_object.read(6) file_object.seek(0) if header.startswith(b"\x50\x4B\x03\x04"): - _zip = ZipFile(file_object) + _zip = zipfile.ZipFile(file_object) xml = _zip.open(_zip.namelist()[0]).read().decode() elif header.startswith(b"\x1F\x8B"): xml = GzipFile(fileobj=file_object).read().decode() @@ -982,7 +992,7 @@ def parse_report_file(input_, nameservers=None, timeout=6.0): return results -def get_dmarc_reports_from_inbox(host, username, password, +def get_dmarc_reports_from_inbox(host, user, password, archive_folder="Archive", delete=False, test=False, nameservers=None, @@ -992,7 +1002,7 @@ def get_dmarc_reports_from_inbox(host, username, password, Args: host: The mail server hostname or IP address - username: The mail server username + user: The mail server user password: The mail server password archive_folder: The folder to move processed mail to delete (bool): Delete messages after processing them @@ -1018,7 +1028,7 @@ def get_dmarc_reports_from_inbox(host, username, password, try: server = imapclient.IMAPClient(host, use_uid=True) - server.login(username, password) + server.login(user, password) server.select_folder(b'INBOX') if not server.folder_exists(archive_folder): server.create_folder(archive_folder) @@ -1134,6 +1144,111 @@ def save_output(results, output_directory="output"): sample_file.write(sample) +def get_report_zip(results): + """ + Creates a zip file of parsed report output + + Args: + results (OrderedDict): The parsed results + + Returns: + bytes: zip file bytes + """ + def add_subdir(root_path, subdir): + subdir_path = os.path.join(root_path, subdir) + for subdir_root, subdir_dirs, subdir_files in os.walk(subdir_path): + for subdir_file in subdir_files: + subdir_file_path = os.path.join(root_path, subdir, subdir_file) + if os.path.isfile(subdir_file_path): + relpath = os.path.relpath(subdir_root, subdir_file_path) + subdir_arcname = os.path.join(relpath, subdir_file) + zip_file.write(subdir_file_path, subdir_arcname) + for subdir in subdir_dirs: + add_subdir(subdir_path, subdir) + + storage = BytesIO() + tmp_dir = tempfile.mkdtemp() + try: + save_output(results, tmp_dir) + with zipfile.ZipFile(storage, 'w', zipfile.ZIP_DEFLATED) as zip_file: + for root, dirs, files in os.walk(tmp_dir): + for file in files: + file_path = os.path.join(root, file) + if os.path.isfile(file_path): + arcname = os.path.join(os.path.relpath(root, tmp_dir), + file) + zip_file.write(file_path, arcname) + for directory in dirs: + zip_file.write(directory) + add_subdir(root, directory) + finally: + shutil.rmtree(tmp_dir) + + return storage.getvalue() + + +def email_results(results, host, mail_from, mail_to, user=None, + password=None, subject=None, attachment_filename=None, + message=None, ssl_context=None): + """ + Emails parsing results as a zip file + + Args: + results (OrderedDict): Parsing results + host: Mail server hostname or IP address + mail_from: The value of the message from header + mail_to : A list of addresses to mail to + user: An optional username + password: An optional password + subject: Overrides the default message subject + attachment_filename: Override the default attachment filename + message: Override the default plain text body + ssl_context: SSL context options + + Notes: + The server is required to support TLS for privacy reasons + """ + date_string = datetime.utcnow().strftime("%Y-%m-%d") + if attachment_filename: + if not attachment_filename.lower().endswith(".zip"): + attachment_filename += ".zip" + filename = attachment_filename + else: + filename = "{0}".format(date_string) + + assert isinstance(mail_to, list) + + msg = MIMEMultipart() + msg['From'] = mail_from + msg['To'] = COMMASPACE.join(mail_to) + msg['Date'] = formatdate(localtime=True) + msg['Subject'] = subject or "DMARC results for {0}".format(date_string) + text = message or "Please see the attached zip file" + + msg.attach(MIMEText(text)) + + zip_bytes = get_report_zip(results) + part = MIMEApplication(zip_bytes, Name=filename) + + part['Content-Disposition'] = 'attachment; filename="{0}"'.format(filename) + msg.attach(part) + + try: + if ssl_context is None: + ssl_context = ssl.create_default_context() + server = smtplib.SMTP_SSL(host, context=ssl_context) + if user and password: + server.login(user, password) + server.sendmail(mail_from, mail_to, msg.as_string()) + except smtplib.SMTPException as error: + error = error.__str__().lstrip("b'").rstrip("'").rstrip(".") + raise SMTPError(error) + except socket.gaierror: + raise SMTPError("DNS resolution failed") + except TimeoutError: + raise SMTPError("The connection timed out") + + def watch_inbox(host, username, password, callback, archive_folder="Archive", delete=False, test=False, wait=30, nameservers=None, dns_timeout=6.0): @@ -1220,7 +1335,7 @@ def _main(): type=float, default=6.0) arg_parser.add_argument("-H", "--host", help="IMAP hostname or IP address") - arg_parser.add_argument("-U", "--username", help="IMAP username") + arg_parser.add_argument("-u", "--user", help="IMAP user") arg_parser.add_argument("-p", "--password", help="IMAP password") arg_parser.add_argument("-a", "--archive-folder", help="Specifies the IMAP folder to move " @@ -1230,10 +1345,27 @@ def _main(): arg_parser.add_argument("-d", "--delete", help="Delete the reports after processing them", action="store_true", default=False) + arg_parser.add_argument("-O", "--outgoing-host", + help="Email the results using this host") + arg_parser.add_argument("-U", "--outgoing-user", + help="Email the results using this user") + arg_parser.add_argument("-P", "--outgoing-password", + help="Email the results using this password") + arg_parser.add_argument("-F", "--outgoing-from", + help="Email the results using this from address") + arg_parser.add_argument("-T", "--outgoing-to", nargs="+", + help="Email the results to these addresses") + arg_parser.add_argument("-S", "--outgoing-subject", + help="Email the results using this subject") + arg_parser.add_argument("-A", "--outgoing-attachment", + help="Email the results using this filename") + arg_parser.add_argument("-M", "--outgoing-message", + help="Email the results using this message") + arg_parser.add_argument("-i", "--idle", action="store_true", help="Use an IMAP IDLE connection to process " "reports as they arrive in the inbox") - arg_parser.add_argument("-T", "--test", + arg_parser.add_argument("--test", help="Do not move or delete IMAP messages", action="store_true", default=False) arg_parser.add_argument("-v", "--version", action="version", @@ -1270,7 +1402,7 @@ def _main(): af = args.archive_folder reports = get_dmarc_reports_from_inbox(args.host, - args.username, + args.user, args.password, archive_folder=af, delete=args.delete, @@ -1291,6 +1423,21 @@ def _main(): print_results(results) + if args.outgoing_host: + if args.outgoing_from is None or args.outgoing_to is None: + logger.error("--outgoing-from and --outgoing-to must " + "be provided if --outgoing-host is used") + exit(1) + + try: + email_results(results, args.outgoing_host, args.outgoing_from, + args.outgoing_to, user=args.outgoing_user, + password=args.outgoing_password, + subject=args.outgoing_subject) + except SMTPError as error: + logger.error("SMTP Error: {0}".format(error.__str__())) + exit(1) + if args.host and args.idle: sleep(2) logger.warning("The IMAP Connection is now in IDLE mode. "