commit 6b9e36ed77395cbfc6b623da2108e43ed7e7b5c2 Author: Sean Whalen Date: Mon Feb 5 20:23:07 2018 -0500 First commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..df1a39a --- /dev/null +++ b/.gitignore @@ -0,0 +1,120 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# dotenv +.env + +# virtualenv +.venv +venv/ +ENV/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + +# PyCharm Project settings +.idea/ + +# I/O files + +*.xml +*.zip +*.gz +*.json +*.csv +*.xls* + +# LibreOffice lock files +.~* + +# ignore data files +*.dat +*.mmdb diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..5f0513f --- /dev/null +++ b/.travis.yml @@ -0,0 +1,26 @@ +language: python + +sudo: false + +python: + - '2.7' + - '3.4' + - '3.5' + - '3.6' + +# commands to install dependencies +install: + - "pip install flake8 pytest-cov pytest coveralls" + - "pip install -r requirements.txt" + +# commands to run samples +script: + - "flake8 checkdmarc.py" + - "flake8 samples.py" + - "flake8 samples.py" + - "flake8 setup.py" + - "cd docs" + - "make html" + - "cd .." + - "python samples.py" + - "python setup.py bdist_wheel" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7d8fe4a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +1.0.0 +----- +- Initial release diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..e63106a --- /dev/null +++ b/README.rst @@ -0,0 +1,155 @@ +checkdmarc +========== + +|Build Status| + +``pasedmarc`` is a Python module and CLI utility for parsing aggregate DMARC reports. + +Features +======== + +* Parses draft and 1.0 standard aggregate reports +* Transparently handles gzip or zip compressed reports +* Consistent data structures +* Simple JSON or CSV output +* Python 2 and 3 support + +CLI help +======== + +:: + + usage: parsedmarc.py [-h] [-f FORMAT] [-o OUTPUT] + [-n NAMESERVER [NAMESERVER ...]] [-t TIMEOUT] [-v] + file_path [file_path ...] + + Parses aggregate DMARC reports + + positional arguments: + file_path one or more paths of aggregate report files + (compressed or uncompressed) + + optional arguments: + -h, --help show this help message and exit + -f FORMAT, --format FORMAT + specify JSON or CSV output format + -o OUTPUT, --output OUTPUT + output to a file path rather than printing to the + screen + -n NAMESERVER [NAMESERVER ...], --nameserver NAMESERVER [NAMESERVER ...] + nameservers to query + -t TIMEOUT, --timeout TIMEOUT + number of seconds to wait for an answer from DNS + (default 6.0) + -v, --version show program's version number and exit + + +Sample output +============= + +Here are the results from parsing the `example `_ +report from the dmarc.org wiki. It's actually an older draft of the the 1.0 +report schema standardized in +`RFC 7480 Appendix C `_. +This draft schema is still in wide use. + +``parsedmarc`` produces consistent, normalized output, regardless of the report schema. + +JSON +---- + +.. code-block:: json + + { + "xml_schema": "draft", + "report_metadata": { + "org_name": "acme.com", + "org_email": "noreply-dmarc-support@acme.com", + "org_extra_contact_info": "http://acme.com/dmarc/support", + "report_id": "9391651994964116463", + "begin_date": "2012-04-27 20:00:00", + "end_date": "2012-04-28 19:59:59", + "errors": [] + }, + "policy_published": { + "domain": "example.com", + "adkim": "r", + "aspf": "r", + "p": "none", + "sp": "none", + "pct": "100", + "fo": "0" + }, + "records": [ + { + "source": { + "ip_address": "72.150.241.94", + "country": "US", + "reverse_dns": "adsl-72-150-241-94.shv.bellsouth.net", + "base_domain": "bellsouth.net" + }, + "count": 2, + "policy_evaluated": { + "disposition": "none", + "dkim": "fail", + "spf": "pass", + "policy_override_reasons": [] + }, + "identifiers": { + "header_from": "example.com", + "envelope_from": "example.com", + "envelope_to": null + }, + "auth_results": { + "dkim": [ + { + "domain": "example.com", + "selector": "none", + "result": "fail" + } + ], + "spf": [ + { + "domain": "example.com", + "scope": "mfrom", + "result": "pass" + } + ] + } + } + ] + } + +CSV +--- + +:: + + xml_schema,org_name,org_email,org_extra_contact_info,report_id,begin_date,end_date,errors,domain,adkim,aspf,p,sp,pct,fo,source_ip_address,source_country,source_reverse_dns,source_base_domain,count,disposition,dkim_alignment,spf_alignment,policy_override_reasons,policy_override_comments,envelope_from,header_from,envelope_to,dkim_domains,dkim_selectors,dkim_results,spf_domains,spf_scopes,spf_results + draft,acme.com,noreply-dmarc-support@acme.com,http://acme.com/dmarc/support,9391651994964116463,2012-04-27 20:00:00,2012-04-28 19:59:59,[],example.com,r,r,none,none,100,0,72.150.241.94,US,adsl-72-150-241-94.shv.bellsouth.net,bellsouth.net,2,none,fail,pass,,,example.com,example.com,,example.com,none,fail,example.com,mfrom,pass + +What about forensic DMARC reports? +================================== + +Forensic DMARC reports are emails with an attached email sample that failed a +DMARC check. You can parse them with any email message parser, such as +`mail-parser `_. + +Very few recipients send forensic reports, and even those who do will often +provide only the message headers, and not the message's content, for privacy +reasons. + +Documentation +============= + +https://domainaware.github.io/parsedmarc + +Bug reports +=========== + +Please report bugs on the GitHub issue tracker + +https://github.com/domainaware/parsedmarc/issues + +.. |Build Status| image:: https://travis-ci.org/domainaware/parsedmarc.svg?branch=master + :target: https://travis-ci.org/domainaware/parsedmarc diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..424bf6c --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = python -msphinx +SPHINXPROJ = parsedmarc +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..b76e2b2 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# parsedmarc documentation build configuration file, created by +# sphinx-quickstart on Mon Feb 5 18:25:39 2018. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# +import os +import sys + +sys.path.insert(0, os.path.abspath('..')) + +from parsedmarc import __version__ + + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +# +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = ['sphinx.ext.autodoc', + 'sphinx.ext.doctest', + 'sphinx.ext.todo', + 'sphinx.ext.viewcode', + 'sphinx.ext.githubpages', + 'sphinx.ext.napoleon'] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# +# source_suffix = ['.rst', '.md'] +source_suffix = '.rst' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = 'parsedmarc' +copyright = '2018, Sean Whalen' +author = 'Sean Whalen' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = __version__ +# The full version, including alpha/beta/rc tags. +release = version + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This patterns also effect to html_static_path and html_extra_path +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +todo_include_todos = False + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = 'sphinx_rtd_theme' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# +# html_theme_options = {} + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Custom sidebar templates, must be a dictionary that maps document names +# to template names. +# +# This is required for the alabaster theme +# refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars +html_sidebars = { + '**': [ + 'about.html', + 'navigation.html', + 'relations.html', # needs 'show_related': True theme option to display + 'searchbox.html', + 'donate.html', + ] +} + + +# -- Options for HTMLHelp output ------------------------------------------ + +# Output file base name for HTML help builder. +htmlhelp_basename = 'parsedmarcdoc' + + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # + # 'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + # + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # + # 'preamble': '', + + # Latex figure (float) alignment + # + # 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'parsedmarc.tex', 'parsedmarc Documentation', + 'parsedmarc', 'manual'), +] + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'parsedmarc', 'parsedmarc Documentation', + [author], 1) +] + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'parsedmarc', 'parsedmarc Documentation', + author, 'parsedmarc', 'One line description of project.', + 'Miscellaneous'), +] + + + diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..9ece5b5 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,173 @@ +.. parsedmarc documentation master file, created by + sphinx-quickstart on Mon Feb 5 18:25:39 2018. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to parsedmarc's documentation! +======================================= + +|Build Status| + +``pasedmarc`` is a Python module and CLI utility for parsing aggregate DMARC reports. + +Features +======== + +* Parses draft and 1.0 standard aggregate reports +* Transparently handles gzip or zip compressed reports +* Consistent data structures +* Simple JSON or CSV output +* Python 2 and 3 support + +CLI help +======== + +:: + + usage: parsedmarc.py [-h] [-f FORMAT] [-o OUTPUT] + [-n NAMESERVER [NAMESERVER ...]] [-t TIMEOUT] [-v] + file_path [file_path ...] + + Parses aggregate DMARC reports + + positional arguments: + file_path one or more paths of aggregate report files + (compressed or uncompressed) + + optional arguments: + -h, --help show this help message and exit + -f FORMAT, --format FORMAT + specify JSON or CSV output format + -o OUTPUT, --output OUTPUT + output to a file path rather than printing to the + screen + -n NAMESERVER [NAMESERVER ...], --nameserver NAMESERVER [NAMESERVER ...] + nameservers to query + -t TIMEOUT, --timeout TIMEOUT + number of seconds to wait for an answer from DNS + (default 6.0) + -v, --version show program's version number and exit + + +Sample output +============= + +Here are the results from parsing the `example `_ +report from the dmarc.org wiki. It's actually an older draft of the the 1.0 +report schema standardized in +`RFC 7480 Appendix C `_. +This draft schema is still in wide use. + +``parsedmarc`` produces consistent, normalized output, regardless of the report schema. + +JSON +---- + +.. code-block:: json + + { + "xml_schema": "draft", + "report_metadata": { + "org_name": "acme.com", + "org_email": "noreply-dmarc-support@acme.com", + "org_extra_contact_info": "http://acme.com/dmarc/support", + "report_id": "9391651994964116463", + "begin_date": "2012-04-27 20:00:00", + "end_date": "2012-04-28 19:59:59", + "errors": [] + }, + "policy_published": { + "domain": "example.com", + "adkim": "r", + "aspf": "r", + "p": "none", + "sp": "none", + "pct": "100", + "fo": "0" + }, + "records": [ + { + "source": { + "ip_address": "72.150.241.94", + "country": "US", + "reverse_dns": "adsl-72-150-241-94.shv.bellsouth.net", + "base_domain": "bellsouth.net" + }, + "count": 2, + "policy_evaluated": { + "disposition": "none", + "dkim": "fail", + "spf": "pass", + "policy_override_reasons": [] + }, + "identifiers": { + "header_from": "example.com", + "envelope_from": "example.com", + "envelope_to": null + }, + "auth_results": { + "dkim": [ + { + "domain": "example.com", + "selector": "none", + "result": "fail" + } + ], + "spf": [ + { + "domain": "example.com", + "scope": "mfrom", + "result": "pass" + } + ] + } + } + ] + } + +CSV +--- + +:: + + xml_schema,org_name,org_email,org_extra_contact_info,report_id,begin_date,end_date,errors,domain,adkim,aspf,p,sp,pct,fo,source_ip_address,source_country,source_reverse_dns,source_base_domain,count,disposition,dkim_alignment,spf_alignment,policy_override_reasons,policy_override_comments,envelope_from,header_from,envelope_to,dkim_domains,dkim_selectors,dkim_results,spf_domains,spf_scopes,spf_results + draft,acme.com,noreply-dmarc-support@acme.com,http://acme.com/dmarc/support,9391651994964116463,2012-04-27 20:00:00,2012-04-28 19:59:59,[],example.com,r,r,none,none,100,0,72.150.241.94,US,adsl-72-150-241-94.shv.bellsouth.net,bellsouth.net,2,none,fail,pass,,,example.com,example.com,,example.com,none,fail,example.com,mfrom,pass + +What about forensic DMARC reports? +================================== + +Forensic DMARC reports are emails with an attached email sample that failed a +DMARC check. You can parse them with any email message parser, such as +`mail-parser `_. + +Very few recipients send forensic reports, and even those who do will often +provide only the message headers, and not the message's content, for privacy +reasons. + +Bug reports +=========== + +Please report bugs on the GitHub issue tracker + +https://github.com/domainaware/parsedmarc/issues + +API +=== + +.. automodule:: parsedmarc + :members: + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + + +.. |Build Status| image:: https://travis-ci.org/domainaware/parsedmarc.svg?branch=master + :target: https://travis-ci.org/domainaware/parsedmarc \ No newline at end of file diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..1b4d373 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,36 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=python -msphinx +) +set SOURCEDIR=. +set BUILDDIR=_build +set SPHINXPROJ=parsedmarc + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The Sphinx module was not found. Make sure you have Sphinx installed, + echo.then set the SPHINXBUILD environment variable to point to the full + echo.path of the 'sphinx-build' executable. Alternatively you may add the + echo.Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% + +:end +popd diff --git a/makedocs.sh b/makedocs.sh new file mode 100644 index 0000000..4ad87ec --- /dev/null +++ b/makedocs.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +. ~/venv/domainaware/bin/activate +cd docs && make html && cp -r build/html/* ../../parsedmarc-docs/ diff --git a/parsedmarc.py b/parsedmarc.py new file mode 100644 index 0000000..c2f78b4 --- /dev/null +++ b/parsedmarc.py @@ -0,0 +1,629 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""A Python module and CLI for parsing aggregate DMARC reports""" + +from __future__ import unicode_literals, print_function, absolute_import + +import logging +from sys import version_info +from os import path, stat +import json +from datetime import datetime +from collections import OrderedDict +from datetime import timedelta +from io import BytesIO, StringIO +from gzip import GzipFile +import tarfile +from zipfile import ZipFile +from csv import DictWriter +import shutil +from argparse import ArgumentParser +from glob import glob + +import publicsuffix +import xmltodict +import dns.reversename +import dns.resolver +import dns.exception +from requests import get +import geoip2.database +import geoip2.errors + +__version__ = "1.0.0" + +logger = logging.getLogger(__name__) +logger.setLevel(logging.WARNING) + + +# Python 2 comparability hack +if version_info[0] >= 3: + unicode = str + + +class InvalidAggregateReport(Exception): + """Raised when an invalid DMARC aggregate report is encountered""" + + +def _get_base_domain(domain): + """ + Gets the base domain name for the given domain + + .. note:: + Results are based on a list of public domain suffixes at + https://publicsuffix.org/list/public_suffix_list.dat. + + This file is saved to the current working directory, + where it is used as a cache file for 24 hours. + + Args: + domain (str): A domain or subdomain + + Returns: + str: The base domain of the given domain + + """ + psl_path = "public_suffix_list.dat" + + def download_psl(): + fresh_psl = publicsuffix.fetch() + with open(psl_path, "w", encoding="utf-8") as fresh_psl_file: + fresh_psl_file.write(fresh_psl.read()) + + return publicsuffix.PublicSuffixList(fresh_psl) + + if not path.exists(psl_path): + psl = download_psl() + else: + psl_age = datetime.now() - datetime.fromtimestamp( + stat(psl_path).st_mtime) + if psl_age > timedelta(hours=24): + psl = download_psl() + else: + with open(psl_path, encoding="utf-8") as psl_file: + psl = publicsuffix.PublicSuffixList(psl_file) + + return psl.get_public_suffix(domain) + + +def _query_dns(domain, record_type, nameservers=None, timeout=6.0): + """ + Queries DNS + + Args: + domain (str): The domain or subdomain to query about + record_type (str): The record type to query for + nameservers (list): A list of one or more nameservers to use + timeout (float): Sets the DNS timeout in seconds + + Returns: + list: A list of answers + """ + resolver = dns.resolver.Resolver() + timeout = float(timeout) + if nameservers: + resolver.nameservers = nameservers + resolver.timeout = timeout + resolver.lifetime = timeout + return list(map( + lambda r: r.to_text().replace(' "', '').replace('"', '').rstrip("."), + resolver.query(domain, record_type, tcp=True))) + + +def _get_reverse_dns(ip_address, nameservers=None, timeout=6.0): + """ + Resolves an IP address to a hostname using a reverse DNS query + + Args: + ip_address (str): The IP address to resolve + nameservers (list): A list of nameservers to query + timeout (float): Sets the DNS query timeout in seconds + + Returns: + + """ + hostname = None + try: + address = dns.reversename.from_address(ip_address) + hostname = _query_dns(address, "PTR", + nameservers=nameservers, + timeout=timeout)[0] + + except dns.exception.DNSException: + pass + + return hostname + + +def _timestamp_to_datetime(timestamp): + """ + Converts a UNIX/DMARC timestamp to a Python ``DateTime`` object + + Args: + timestamp: The timestamp + + Returns: + DateTime: The converted timestamp as a Python ``DateTime`` object + """ + return datetime.fromtimestamp(int(timestamp)) + + +def _timestamp_to_human(timestamp): + """ + Converts a UNIX/DMARC timestamp to a human-readable string + + Args: + timestamp: The timestamp + + Returns: + str: The converted timestamp in ``YYYY-MM-DD HH:MM:SS`` format + """ + return _timestamp_to_datetime(timestamp).strftime("%Y-%m-%d %H:%M:%S") + + +def _human_timestamp_to_datetime(human_timestamp): + """ + Converts a human-readable timestamp into a Python ``DateTime`` object + + Args: + human_timestamp (str): A timestamp in `YYYY-MM-DD HH:MM:SS`` format + + Returns: + DateTime: The converted timestamp + """ + return datetime.strptime(human_timestamp, "%Y-%m-%d %H:%M:%S") + + +def _get_ip_address_country(ip_address): + """ + Uses the MaxMind Geolite2 Country database to return the ISO code for the + country associated with the given IPv4 or IPv6 address + + Args: + ip_address (str): The IP address to query for + + Returns: + str: And ISO country code associated with the given IP address + """ + db_filename = "GeoLite2-Country.mmdb" + + def download_country_database(): + """Downloads the MaxMind Geolite2 Country database to the current + working directory""" + url = "https://geolite.maxmind.com/download/geoip/database/" \ + "GeoLite2-Country.tar.gz" + tar_file = tarfile.open(fileobj=BytesIO(get(url).content), mode="r:gz") + tar_dir = tar_file.getnames()[0] + tar_path = "{0}/{1}".format(tar_dir, db_filename) + tar_file.extract(tar_path) + shutil.move(tar_path, ".") + shutil.rmtree(tar_dir) + + system_paths = ["/usr/local/share/GeoIP/GeoLite2-Country.mmdb", + "/usr/share/GeoIP/GeoLite2-Country.mmdb"] + db_path = "" + + for system_path in system_paths: + if path.exists(system_path): + db_path = system_path + break + + if db_path == "": + if not path.exists(db_filename): + download_country_database() + else: + db_age = datetime.now() - datetime.fromtimestamp( + stat(db_filename).st_mtime) + if db_age > timedelta(days=60): + shutil.rmtree(db_path) + download_country_database() + db_path = db_filename + + db_reader = geoip2.database.Reader(db_path) + + country = None + + try: + country = db_reader.country(ip_address).country.iso_code + except geoip2.errors.AddressNotFoundError: + pass + + return country + + +def _parse_report_record(record, nameservers=None, timeout=6.0): + """ + Converts a record from a DMARC aggregate report into a more consistent + format + + Args: + record (OrderedDict): The record to convert + nameservers (list): A list of one or more nameservers to use + timeout (float): Sets the DNS timeout in seconds + + Returns: + OrderedDict: The converted record + """ + record = record.copy() + new_record = OrderedDict() + new_record["source"] = OrderedDict() + new_record["source"]["ip_address"] = record["row"]["source_ip"] + reverse_dns = _get_reverse_dns(new_record["source"]["ip_address"], + nameservers=nameservers, + timeout=timeout) + country = _get_ip_address_country(new_record["source"]["ip_address"]) + new_record["source"]["country"] = country + new_record["source"]["reverse_dns"] = reverse_dns + new_record["source"]["base_domain"] = None + if new_record["source"]["reverse_dns"] is not None: + base_domain = _get_base_domain(new_record["source"]["reverse_dns"]) + new_record["source"]["base_domain"] = base_domain + new_record["count"] = int(record["row"]["count"]) + policy_evaluated = record["row"]["policy_evaluated"].copy() + new_policy_evaluated = OrderedDict([("disposition", "none"), + ("dkim", "fail"), + ("spf", "fail"), + ("policy_override_reasons", []) + ]) + if "disposition" in policy_evaluated: + new_policy_evaluated["disposition"] = policy_evaluated["disposition"] + if "dkim" in policy_evaluated: + new_policy_evaluated["dkim"] = policy_evaluated["dkim"] + if "spf" in policy_evaluated: + new_policy_evaluated["spf"] = policy_evaluated["spf"] + reasons = [] + if "reason" in policy_evaluated: + if type(policy_evaluated["reason"]) == list: + reasons = policy_evaluated["reason"] + else: + reasons = [policy_evaluated["reason"]] + for reason in reasons: + if "comment" not in reason: + reason["comment"] = "none" + reasons.append(reason) + new_policy_evaluated["policy_override_reasons"] = reasons + new_record["policy_evaluated"] = new_policy_evaluated + new_record["identifiers"] = record["identifiers"].copy() + new_record["auth_results"] = OrderedDict([("dkim", []), ("spf", [])]) + auth_results = record["auth_results"].copy() + if "dkim" in auth_results: + if type(auth_results["dkim"]) != list: + auth_results["dkim"] = [auth_results["dkim"]] + for result in auth_results["dkim"]: + if "domain" in result and result["domain"] is not None: + new_result = OrderedDict([("domain", result["domain"])]) + if "selector" in result and result["selector"] is not None: + new_result["selector"] = result["selector"] + else: + new_result["selector"] = "none" + if "result" in result and result["result"] is not None: + new_result["result"] = result["result"] + else: + new_result["result"] = "none" + new_record["auth_results"]["dkim"].append(new_result) + if type(auth_results["spf"]) != list: + auth_results["spf"] = [auth_results["spf"]] + for result in auth_results["spf"]: + new_result = OrderedDict([("domain", result["domain"])]) + if "scope" in result and result["scope"] is not None: + new_result["scope"] = result["scope"] + else: + new_result["scope"] = "mfrom" + if "result" in result and result["result"] is not None: + new_result["result"] = result["result"] + else: + new_result["result"] = "none" + new_record["auth_results"]["spf"].append(new_result) + + if "envelope_from" not in new_record["identifiers"]: + envelope_from = new_record["auth_results"]["spf"][-1]["domain"].lower() + new_record["identifiers"]["envelope_from"] = envelope_from + + elif new_record["identifiers"]["envelope_from"] is None: + envelope_from = new_record["auth_results"]["spf"][-1]["domain"].lower() + new_record["identifiers"]["envelope_from"] = envelope_from + + envelope_to = None + if "envelope_to" in new_record["identifiers"]: + envelope_to = new_record["identifiers"]["envelope_to"] + del new_record["identifiers"]["envelope_to"] + + new_record["identifiers"]["envelope_to"] = envelope_to + + return new_record + + +def parse_aggregate_report_xml(xml, nameservers=None, timeout=6.0): + """Parses a DMARC XML report string and returns a consistent OrderedDict + + Args: + xml (str): A string of DMARC aggregate report XML + nameservers (list): A list of one or more nameservers to use + timeout (float): Sets the DNS timeout in seconds + + Returns: + OrderedDict: The parsed aggregate DMARC report + """ + try: + report = xmltodict.parse(xml)["feedback"] + report_metadata = report["report_metadata"] + schema = "draft" + if "version" in report: + schema = report["version"] + new_report = OrderedDict([("xml_schema", schema)]) + new_report_metadata = OrderedDict() + new_report_metadata["org_name"] = report_metadata["org_name"] + new_report_metadata["org_email"] = report_metadata["email"] + extra = None + if "extra_contact_info" in report_metadata: + extra = report_metadata["extra_contact_info"] + new_report_metadata["org_extra_contact_info"] = extra + new_report_metadata["report_id"] = report_metadata["report_id"] + date_range = report["report_metadata"]["date_range"] + date_range["begin"] = _timestamp_to_human(date_range["begin"]) + date_range["end"] = _timestamp_to_human(date_range["end"]) + new_report_metadata["begin_date"] = date_range["begin"] + new_report_metadata["end_date"] = date_range["end"] + errors = [] + if "error" in report["report_metadata"]: + if type(report["report_metadata"]["error"]) != list: + errors = [report["report_metadata"]["error"]] + else: + errors = report["report_metadata"]["error"] + new_report_metadata["errors"] = errors + new_report["report_metadata"] = new_report_metadata + records = [] + policy_published = report["policy_published"] + new_policy_published = OrderedDict() + new_policy_published["domain"] = policy_published["domain"] + adkim = "r" + if "adkim" in policy_published: + if policy_published["adkim"] is not None: + adkim = policy_published["adkim"] + new_policy_published["adkim"] = adkim + aspf = "r" + if "aspf" in policy_published: + if policy_published["aspf"] is not None: + aspf = policy_published["aspf"] + new_policy_published["aspf"] = aspf + new_policy_published["p"] = policy_published["p"] + sp = new_policy_published["p"] + if "sp" in policy_published: + if policy_published["sp"] is not None: + sp = report["policy_published"]["sp"] + new_policy_published["sp"] = sp + pct = "100" + if "pct" in policy_published: + if policy_published["pct"] is not None: + pct = report["policy_published"]["pct"] + new_policy_published["pct"] = pct + fo = "0" + if "fo" in policy_published: + if policy_published["fo"] is not None: + fo = report["policy_published"]["fo"] + new_policy_published["fo"] = fo + new_report["policy_published"] = new_policy_published + + if type(report["record"]) == list: + for record in report["record"]: + records.append(_parse_report_record(record, + nameservers=nameservers, + timeout=timeout)) + + else: + records.append(_parse_report_record(report["record"])) + + new_report["records"] = records + + return new_report + + except KeyError as error: + raise InvalidAggregateReport("Missing field: " + "{0}".format(error.__str__())) + + +def parse_aggregate_report_file(_input, nameservers=None, timeout=6.0): + """Parses a file at the given path, a file-like object. or bytes as a + aggregate DMARC report + + Args: + _input: A path to a file, a file like object, or bytes + nameservers (list): A list of one or more nameservers to use + timeout (float): Sets the DNS timeout in seconds + + Returns: + OrderedDict: The parsed DMARC aggregate report + """ + if type(_input) == str or type(_input) == unicode: + file_object = open(_input, "rb") + elif type(_input) == bytes: + file_object = BytesIO(_input) + else: + file_object = _input + try: + header = file_object.read(6) + file_object.seek(0) + if header.startswith(b"\x50\x4B\x03\x04"): + _zip = ZipFile(file_object) + xml = _zip.open(_zip.namelist()[0]).read().decode() + elif header.startswith(b"\x1F\x8B"): + xml = GzipFile(fileobj=file_object).read().decode() + elif header.startswith(b"\x3c\x3f\x78\x6d\x6c\x20"): + xml = file_object.read().decode() + else: + file_object.close() + raise InvalidAggregateReport("Not a valid zip, gzip, or xml file") + + file_object.close() + except UnicodeDecodeError: + raise InvalidAggregateReport("File objects must be opened in binary " + "(rb) mode") + + return parse_aggregate_report_xml(xml, + nameservers=nameservers, + timeout=timeout) + + +def parsed_aggregate_report_to_csv(_input): + """ + Converts one or more parsed aggregate reports to flat CSV format, including + headers + + Args: + _input: A parsed aggregate report or list of parsed aggregate reports + + Returns: + str: Parsed aggregate report data in flat CSV format, including headers + """ + fields = ["xml_schema", "org_name", "org_email", + "org_extra_contact_info", "report_id", "begin_date", "end_date", + "errors", "domain", "adkim", "aspf", "p", "sp", "pct", "fo", + "source_ip_address", "source_country", "source_reverse_dns", + "source_base_domain", "count", "disposition", "dkim_alignment", + "spf_alignment", "policy_override_reasons", + "policy_override_comments", "envelope_from", "header_from", + "envelope_to", "dkim_domains", "dkim_selectors", "dkim_results", + "spf_domains", "spf_scopes", "spf_results"] + + csv_file_object = StringIO() + writer = DictWriter(csv_file_object, fields) + writer.writeheader() + + if type(_input) == OrderedDict: + _input = [_input] + + for report in _input: + xml_schema = report["xml_schema"] + org_name = report["report_metadata"]["org_name"] + org_email = report["report_metadata"]["org_email"] + org_extra_contact = report["report_metadata"]["org_extra_contact_info"] + report_id = report["report_metadata"]["report_id"] + begin_date = report["report_metadata"]["begin_date"] + end_date = report["report_metadata"]["end_date"] + errors = report["report_metadata"]["errors"] + domain = report["policy_published"]["domain"] + adkim = report["policy_published"]["adkim"] + aspf = report["policy_published"]["aspf"] + p = report["policy_published"]["p"] + sp = report["policy_published"]["sp"] + pct = report["policy_published"]["pct"] + fo = report["policy_published"]["fo"] + + report_dict = dict(xml_schema=xml_schema, org_name=org_name, + org_email=org_email, + org_extra_contact_info=org_extra_contact, + report_id=report_id, begin_date=begin_date, + end_date=end_date, errors=errors, domain=domain, + adkim=adkim, aspf=aspf, p=p, sp=sp, pct=pct, fo=fo) + + for record in report["records"]: + row = report_dict + row["source_ip_address"] = record["source"]["ip_address"] + row["source_country"] = record["source"]["country"] + row["source_reverse_dns"] = record["source"]["reverse_dns"] + row["source_base_domain"] = record["source"]["base_domain"] + row["count"] = record["count"] + row["disposition"] = record["policy_evaluated"]["disposition"] + row["spf_alignment"] = record["policy_evaluated"]["spf"] + row["dkim_alignment"] = record["policy_evaluated"]["dkim"] + policy_override_reasons = list(map(lambda r: r["type"], + record["policy_evaluated"] + ["policy_override_reasons"])) + policy_override_comments = list(map(lambda r: r["comment"], + record["policy_evaluated"] + ["policy_override_reasons"])) + row["policy_override_reasons"] = ",".join( + policy_override_reasons) + row["policy_override_comments"] = "|".join( + policy_override_comments) + row["envelope_from"] = record["identifiers"]["envelope_from"] + row["header_from"] = record["identifiers"]["header_from"] + envelope_to = record["identifiers"]["envelope_to"] + row["envelope_to"] = envelope_to + dkim_domains = [] + dkim_selectors = [] + dkim_results = [] + for dkim_result in record["auth_results"]["dkim"]: + dkim_domains.append(dkim_result["domain"]) + if "selector" in dkim_result: + dkim_selectors.append(dkim_result["selector"]) + dkim_results.append(dkim_result["result"]) + row["dkim_domains"] = ",".join(dkim_domains) + row["dkim_selectors"] = ",".join(dkim_selectors) + row["dkim_results"] = ",".join(dkim_results) + spf_domains = [] + spf_scopes = [] + spf_results = [] + for spf_result in record["auth_results"]["spf"]: + spf_domains.append(spf_result["domain"]) + spf_scopes.append(spf_result["scope"]) + spf_results.append(spf_result["result"]) + row["spf_domains"] = ",".join(spf_domains) + row["spf_scopes"] = ",".join(spf_scopes) + row["spf_results"] = ",".join(spf_results) + + writer.writerow(row) + csv_file_object.flush() + + return csv_file_object.getvalue() + + +def _main(): + """Called when the module in executed""" + arg_parser = ArgumentParser(description="Parses aggregate DMARC reports") + arg_parser.add_argument("file_path", nargs="+", + help="one or more paths of aggregate report " + "files (compressed or uncompressed)") + arg_parser.add_argument("-f", "--format", default="json", + help="specify JSON or CSV output format") + arg_parser.add_argument("-o", "--output", + help="output to a file path rather than " + "printing to the screen") + arg_parser.add_argument("-n", "--nameserver", nargs="+", + help="nameservers to query") + arg_parser.add_argument("-t", "--timeout", + help="number of seconds to wait for an answer " + "from DNS (default 6.0)", + type=float, + default=6.0) + arg_parser.add_argument("-v", "--version", action="version", + version=__version__) + + args = arg_parser.parse_args() + file_paths = [] + for file_path in args.file_path: + file_paths += glob(file_path) + file_paths = list(set(file_paths)) + + parsed_reports = [] + for file_path in file_paths: + try: + report = parse_aggregate_report_file(file_path, + nameservers=args.nameserver, + timeout=args.timeout) + parsed_reports.append(report) + except InvalidAggregateReport as error: + logger.error("Unable to parse {0}: {1}".format(file_path, + error.__str__())) + output = "" + if args.format.lower() == "json": + if len(parsed_reports) == 1: + parsed_reports = parsed_reports[0] + output = json.dumps(parsed_reports, + ensure_ascii=False, + indent=2) + elif args.format.lower() == "csv": + output = parsed_aggregate_report_to_csv(parsed_reports) + else: + logger.error("Invalid output format: {0}".format(args.format)) + exit(-1) + + if args.output: + with open(args.output, "w", encoding="utf-8", newline="\n") as file: + file.write(output) + else: + print(output) + + +if __name__ == "__main__": + _main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..fdbf831 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +dnspython +requests +publicsuffix +xmltodict +geoip2 +flake8 +sphinx +sphinx_rtd_theme +collective.checkdocs +wheel diff --git a/samples/fastmail.com!example.com!1516060800!1516147199!102675056.xml.gz.sample b/samples/fastmail.com!example.com!1516060800!1516147199!102675056.xml.gz.sample new file mode 100644 index 0000000..c19c7e8 Binary files /dev/null and b/samples/fastmail.com!example.com!1516060800!1516147199!102675056.xml.gz.sample differ diff --git a/samples/old_draft_from_wiki.xml.sample b/samples/old_draft_from_wiki.xml.sample new file mode 100644 index 0000000..1d10a06 --- /dev/null +++ b/samples/old_draft_from_wiki.xml.sample @@ -0,0 +1,53 @@ + + + + + + acme.com + noreply-dmarc-support@acme.com + http://acme.com/dmarc/support + 9391651994964116463 + + 1335571200 + 1335657599 + + + + example.com + r + r +

none

+ none + 100 +
+ + + 72.150.241.94 + 2 + + none + fail + pass + + + + example.com + + + + example.com + fail + + + + example.com + pass + + + +
\ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..79bc678 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,5 @@ +[bdist_wheel] +# This flag says that the code is written to work on both Python 2 and Python +# 3. If at all possible, it is good practice to do this. If you cannot, you +# will need to generate wheels for each Python version that you support. +universal=1 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..a60f003 --- /dev/null +++ b/setup.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +"""A setuptools based setup module. +See: +https://packaging.python.org/en/latest/distributing.html +https://github.com/pypa/sampleproject +""" + +from __future__ import absolute_import + +# Always prefer setuptools over distutils +from setuptools import setup +# To use a consistent encoding +from codecs import open +from os import path + +from parsedmarc import __version__ +from parsedmarc import __doc__ as description + +here = path.abspath(path.dirname(__file__)) + +# Get the long description from the README file +with open(path.join(here, 'README.rst'), encoding='utf-8') as f: + long_description = f.read() + +setup( + name='parsedmarc', + + # Versions should comply with PEP440. For a discussion on single-sourcing + # the version across setup.py and the project code, see + # https://packaging.python.org/en/latest/single_source_version.html + version=__version__, + + description=description, + long_description=long_description, + + # The project's main homepage. + url='https://domainaware.github.io/parsedmarc', + + # Author details + author='Sean Whalen', + author_email='whalenster@gmail.com', + + # Choose your license + license='Apache 2.0', + + # See https://pypi.python.org/pypi?%3Aaction=list_classifiers + classifiers=[ + # How mature is this project? Common values are + # 3 - Alpha + # 4 - Beta + # 5 - Production/Stable + 'Development Status :: 5 - Production/Stable', + + # Indicate who your project is intended for + 'Intended Audience :: Developers', + "Intended Audience :: Information Technology", + 'Operating System :: OS Independent', + + + # Pick your license as you wish (should match "license" above) + 'License :: OSI Approved :: Apache Software License', + + # Specify the Python versions you support here. In particular, ensure + # that you indicate whether you support Python 2, Python 3 or both. + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.0', + 'Programming Language :: Python :: 3.1', + 'Programming Language :: Python :: 3.2', + 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + ], + + # What does your project relate to? + keywords='DMARC, reporting, parser', + + # You can just specify the packages manually here if your project is + # simple. Or you can use find_packages(). + # packages=find_packages(exclude=['contrib', 'docs', 'samples']), + + + # Alternatively, if you want to distribute just a my_module.py, uncomment + # this: + py_modules=["checkdmarc"], + + # List run-time dependencies here. These will be installed by pip when + # your project is installed. For an analysis of "install_requires" vs pip's + # requirements files see: + # https://packaging.python.org/en/latest/requirements.html + install_requires=['dnspython', 'publicsuffix', 'xmltodict', 'geoip2'], + + entry_points={ + 'console_scripts': ['parsedmarc=parsedmarc:_main'], + } +) diff --git a/tests.py b/tests.py new file mode 100644 index 0000000..d40e58a --- /dev/null +++ b/tests.py @@ -0,0 +1,24 @@ +from __future__ import print_function, unicode_literals, absolute_import + +import unittest +from glob import glob +import json + +import parsedmarc + + +class Test(unittest.TestCase): + def testSamples(self): + """Test sample aggregate DMARC reports""" + sample_paths = glob("samples/*.sample") + for sample_path in sample_paths: + print("Testing {0}...\n".format(sample_path)) + parsed_report = parsedmarc.parse_aggregate_report_file(sample_path) + print(json.dumps(parsed_report, ensure_ascii=False, indent=2)) + print("\n") + print(parsedmarc.parsed_aggregate_report_to_csv(parsed_report)) + + +if __name__ == "__main__": + suite = unittest.TestLoader().loadTestsFromTestCase(Test) + unittest.TextTestRunner(verbosity=2).run(suite)