├── AUTHORS ├── CHANGELOG ├── LICENSE ├── MANIFEST.in ├── README.rst ├── dmarc ├── __init__.py ├── admin.py ├── apps.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── importdmarcfeedback.py │ │ └── importdmarcreport.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20150303_1606.py │ ├── 0003_auto_20150307_1531.py │ ├── 0004_auto_20150310_1725.py │ ├── 0005_report_report_xml.py │ ├── 0006_auto_20160408_1427.py │ ├── 0007_auto_20161121_1318.py │ ├── 0008_auto_20170108_1428.py │ ├── 0009_auto_20170109_1738.py │ └── __init__.py ├── models.py ├── templates │ ├── admin │ │ └── dmarc │ │ │ └── app_index.html │ └── dmarc │ │ └── report.html ├── test_import.py ├── tests │ └── dmarcreport.xml ├── urls.py └── views.py ├── docs ├── Makefile ├── README.rst ├── changelog.rst ├── conf.py ├── images │ ├── dmarc-dashboard.png │ ├── dmarc-index.png │ ├── dmarc-report.png │ └── dmarc-reportfilter.png └── index.rst └── setup.py /AUTHORS: -------------------------------------------------------------------------------- 1 | * Alan Hicks 2 | * Edward Newman 3 | * Aurélien Tamisier https://github.com/virtualtam 4 | * Bas Van Gaalen https://github.com/webtweakers 5 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | Additional URLs for PyPi #3 from Christian Jensen @jensenbox 2 | 3 | Version 1.0.1 (2021-07-16) 4 | --------------------------- 5 | 6 | Added authors webtweakers and virtualtam for their 7 | Miscellaneous improvements #2 8 | 9 | Version 1.0.0 (2021-07-16) 10 | --------------------------- 11 | 12 | Version bump to 1.0 reflecting django-dmarc is now considered 13 | Production / Stable 14 | 15 | Version 0.7.0 (2021-07-16) 16 | --------------------------- 17 | Report default set to two days 18 | TypeError: string argument expected, got 'bytes' fixed 19 | Copyright updated to 2021 20 | 21 | Version 0.6.0 (2020-06-10) 22 | --------------------------- 23 | 24 | Updated for Django 2.2 25 | Dropped support for Python 2.7 26 | Updated test script 27 | Updated Copyright to 2020 28 | 29 | Version 0.5.2 (2018-06-19) 30 | --------------------------- 31 | 32 | Updated for Django 2.0 33 | Updated Copyright to 2018 34 | Updated links for tls 35 | Changed report icon font from glyphicons to fontawesome 36 | 37 | Version 0.5.1 (2017-02-10) 38 | --------------------------- 39 | 40 | Updated documentation 41 | 42 | Version 0.5.0 (2017-01-19) 43 | --------------------------- 44 | 45 | Added capturing of feedback reports 46 | Updated Copyright to 2017 47 | 48 | Version 0.4.7 (2016-11-30) 49 | --------------------------- 50 | 51 | Added Glossary and FAQ's 52 | 53 | Version 0.4.6 (2016-11-24) 54 | --------------------------- 55 | 56 | Order report 57 | Fix date filtering 58 | 59 | Version 0.4.5 (2016-11-23) 60 | --------------------------- 61 | 62 | Add colour_code and dkim/spf domain columns thanks to Edward Newman 63 | 64 | Version 0.4.4 (2016-11-22) 65 | --------------------------- 66 | 67 | Fix SQL typo (remove public.) and add back domain (Edward Newman) 68 | 69 | Version 0.4.3 (2016-11-22) 70 | --------------------------- 71 | 72 | Update documentation 73 | 74 | Version 0.4.2 (2016-11-22) 75 | --------------------------- 76 | 77 | Add sql filtering checks 78 | Add filtering by Disposition suggestion from Edward Newman 79 | 80 | Show and filter by organisation name 81 | Version 0.4.1 (2016-11-22) 82 | --------------------------- 83 | 84 | Show and filter by organisation name 85 | 86 | Version 0.4.0 (2016-11-21) 87 | --------------------------- 88 | 89 | Create prototype admin dashboard 90 | Create admin Report model editing 91 | Update report to use ajax load 92 | Create Download report option 93 | Database agnostic filter 94 | Use base admin templates 95 | 96 | Version 0.3.3 (2016-11-05) 97 | --------------------------- 98 | 99 | Add csv export 100 | 101 | Version 0.3.2 (2016-09-13) 102 | --------------------------- 103 | 104 | Improve mimepart debug message 105 | 106 | Version 0.3.1 (2016-09-08) 107 | --------------------------- 108 | 109 | Add application/gzip 110 | 111 | Version 0.3.0 (2016-09-07) 112 | --------------------------- 113 | 114 | * Bump version to 0.3.0 115 | * Add gzip handling 116 | * Refactor to use argparse 117 | * Refactor code 118 | 119 | Note change to arguments requiring email or xml 120 | and either can be - for stdin 121 | 122 | Version 0.2.2 (2016-08-31) 123 | --------------------------- 124 | 125 | * Bump version to 0.2.2 126 | * Remove dependency on django.conf.urls.patterns 127 | 128 | Version 0.2.1 (2016-08-31) 129 | --------------------------- 130 | 131 | * Bump minimum Django version to 1.8 132 | * Improve processing descriptions 133 | 134 | Version 0.2.0 (2016-08-03) 135 | --------------------------- 136 | 137 | Add error checking and saving for bad zipfile 138 | 139 | Version 0.1.10 (2015-04-22) 140 | --------------------------- 141 | 142 | * Added reference to Bootstrap styles (Thanks Jan (linjan)) 143 | * Add sample report 144 | 145 | Version 0.1.9 (2015-03-28) 146 | -------------------------- 147 | 148 | Fix AttributeError: 'Element' object has no attribute 'getroot' 149 | 150 | Version 0.1.8 (2015-03-27) 151 | -------------------------- 152 | 153 | * Fix reference before assignment 154 | * Add save original XML report 155 | 156 | Version 0.1.7 (2015-03-17) 157 | -------------------------- 158 | 159 | * Save XML with test 160 | * Add Logging 161 | * Supress email errors 162 | 163 | Version 0.1.6 (2015-03-13) 164 | -------------------------- 165 | 166 | Fix missing self (Lukas Pühringer) 167 | 168 | Version 0.1.5 (2015-03-11) 169 | -------------------------- 170 | 171 | * Optimise prefetch 172 | 173 | Version 0.1.4 (2015-03-10) 174 | -------------------------- 175 | 176 | * Fix report closing tags 177 | 178 | Version 0.1.3 (2015-03-07) 179 | -------------------------- 180 | 181 | * Allow duplicate Records 182 | 183 | Version 0.1.2 (2015-03-03) 184 | -------------------------- 185 | 186 | * Added a dmarc report 187 | 188 | Version 0.1.1 (2015-03-01) 189 | -------------------------- 190 | 191 | * Improved documentation 192 | 193 | Version 0.1.0 (2015-02-28) 194 | -------------------------- 195 | 196 | * Project created 197 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-2021, Persistent Objects Ltd. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY PERSISTENT OBJECTS LTD AND CONTRIBUTORS 'AS IS' AND 15 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 16 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE FREEBSD PROJECT OR CONTRIBUTORS BE LIABLE FOR 18 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 19 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 20 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 21 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 22 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 23 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | The views and conclusions contained in the software and documentation are those of 26 | the authors and should not be interpreted as representing official policies, 27 | either expressed or implied, of Persistent Objects Ltd. 28 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGELOG LICENSE README.rst 2 | recursive-include docs * 3 | recursive-include dmarc * 4 | 5 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | docs/README.rst -------------------------------------------------------------------------------- /dmarc/__init__.py: -------------------------------------------------------------------------------- 1 | #---------------------------------------------------------------------- 2 | # Copyright (c) 2015-2021, Persistent Objects Ltd https://p-o.co.uk/ 3 | # 4 | # License: BSD 5 | #---------------------------------------------------------------------- 6 | 7 | """ 8 | DMARC models for managing Aggregate Reports 9 | http://dmarc.org/resources/specification/ 10 | """ 11 | 12 | __version__ = '1.0.1' 13 | __author__ = 'Alan Hicks' 14 | 15 | default_app_config = 'dmarc.apps.DmarcConfig' 16 | -------------------------------------------------------------------------------- /dmarc/admin.py: -------------------------------------------------------------------------------- 1 | #---------------------------------------------------------------------- 2 | # Copyright (c) 2015-2021, Persistent Objects Ltd https://p-o.co.uk/ 3 | # 4 | # License: BSD 5 | #---------------------------------------------------------------------- 6 | 7 | """ 8 | DMARC models for managing Aggregate Reports 9 | http://dmarc.org/resources/specification/ 10 | """ 11 | 12 | from django.contrib import admin 13 | 14 | from dmarc.models import Report 15 | 16 | class ReportAdmin(admin.ModelAdmin): 17 | actions = [] 18 | model = Report 19 | list_display = ['report_id', 'reporter', 'date_begin'] 20 | list_filter = ['date_begin', 'reporter'] 21 | readonly_fields = [ 22 | 'report_id', 'reporter', 23 | 'date_begin', 'date_end', 'policy_domain', 24 | 'policy_adkim', 'policy_aspf', 25 | 'policy_p', 'policy_sp', 26 | 'policy_pct', 27 | 'report_xml' 28 | ] 29 | order = ['-id'] 30 | 31 | def has_add_permission(self, request): 32 | return False 33 | 34 | admin.site.register(Report, ReportAdmin) 35 | -------------------------------------------------------------------------------- /dmarc/apps.py: -------------------------------------------------------------------------------- 1 | #---------------------------------------------------------------------- 2 | # Copyright (c) 2015-2021, Persistent Objects Ltd https://p-o.co.uk/ 3 | # 4 | # License: BSD 5 | #---------------------------------------------------------------------- 6 | 7 | from django.apps import AppConfig 8 | 9 | class DmarcConfig(AppConfig): 10 | name = 'dmarc' 11 | verbose_name = "DMARC feedback report manager" 12 | -------------------------------------------------------------------------------- /dmarc/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alan-hicks/django-dmarc/c5ad9911f7c3698eeb6ea424fb9e494170e6e96b/dmarc/management/__init__.py -------------------------------------------------------------------------------- /dmarc/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alan-hicks/django-dmarc/c5ad9911f7c3698eeb6ea424fb9e494170e6e96b/dmarc/management/commands/__init__.py -------------------------------------------------------------------------------- /dmarc/management/commands/importdmarcfeedback.py: -------------------------------------------------------------------------------- 1 | #---------------------------------------------------------------------- 2 | # Copyright (c) 2015-2021, Persistent Objects Ltd https://p-o.co.uk/ 3 | # 4 | # License: BSD 5 | #---------------------------------------------------------------------- 6 | """Import DMARC Feedback Reports 7 | """ 8 | import os 9 | import logging 10 | import tempfile 11 | from datetime import datetime 12 | from email import message_from_string 13 | from email.generator import Generator 14 | from email.utils import mktime_tz, parsedate_tz 15 | from io import StringIO 16 | from argparse import FileType 17 | import pytz 18 | 19 | from django.core.exceptions import ObjectDoesNotExist 20 | from django.core.management.base import BaseCommand, CommandError 21 | 22 | from dmarc.models import FBReporter, FBReport 23 | 24 | logger = logging.getLogger(__name__) 25 | 26 | class Command(BaseCommand): 27 | """ 28 | Command class for importing DMARC Feedback Reports 29 | Most errors are not raised to prevent email bounces 30 | """ 31 | help = 'Imports a DMARC Feedback Report from an email' 32 | 33 | def add_arguments(self, parser): 34 | parser.add_argument('-e', '--email', 35 | type=FileType('r'), 36 | default=False, 37 | help='Import feedback from email file, or - for stdin' 38 | ) 39 | 40 | def handle(self, *args, **options): 41 | """ 42 | Handle method to import a DMARC Feedback Report 43 | """ 44 | 45 | logger.info("Importing DMARC Feedback Report") 46 | 47 | if not options['email']: 48 | msg = "Check usage, please supply a single DMARC report file or email" 49 | logger.error(msg) 50 | raise CommandError(msg) 51 | 52 | msg = 'Processing email' 53 | logger.debug(msg) 54 | 55 | try: 56 | email = options['email'].read() 57 | dmarcemail = message_from_string(email) 58 | except: 59 | msg = 'Unable to use email' 60 | logger.debug(msg) 61 | raise CommandError(msg) 62 | 63 | if dmarcemail.is_multipart(): 64 | self.process_multipart(dmarcemail) 65 | else: 66 | self.process_822(dmarcemail) 67 | 68 | def process_multipart(self, dmarcemail): 69 | """Extract multipart report""" 70 | report = FBReport() 71 | dmarc_reporter = None 72 | try: 73 | dmarcemail.get_payload() 74 | dmarc_reporter = dmarcemail.get('from') 75 | report.reporter = FBReporter.objects.get(email=dmarc_reporter) 76 | mimepart = dmarcemail.get_payload() 77 | except ObjectDoesNotExist: 78 | try: 79 | report.reporter = FBReporter.objects.create( 80 | org_name = dmarc_reporter, 81 | email = dmarc_reporter, 82 | ) 83 | except: 84 | msg = 'Failed to find or create reporter {}'.format(dmarc_reporter) 85 | logger.error(msg) 86 | raise CommandError(msg) 87 | except: 88 | msg = 'Unable to get rfc822 report' 89 | logger.error(msg) 90 | tf = tempfile.mkstemp(prefix='dmarc-',suffix='.eml') 91 | tmpf = os.fdopen(tf[0],'w') 92 | tmpf.write(dmarcemail.get_payload()) 93 | tmpf.close() 94 | msg = 'Saved as: {}'.format(tf[1]) 95 | logger.error(msg) 96 | raise CommandError(msg) 97 | fp = StringIO() 98 | g = Generator(fp, maxheaderlen=0) 99 | g.flatten(dmarcemail) 100 | report.feedback_source = fp.getvalue() 101 | g = None 102 | fp = None 103 | 104 | # Get the human readable part 105 | try: 106 | mimepart = dmarcemail.get_payload(0) 107 | if mimepart.get_content_type() == 'text/plain': 108 | # get the human-readable part of the message 109 | report.description = mimepart 110 | except: 111 | msg = 'Unable to get human readable part' 112 | logger.warning(msg) 113 | 114 | # Get the feedback report 115 | try: 116 | mimepart = dmarcemail.get_payload(1) 117 | if mimepart.get_content_type() == 'message/feedback-report': 118 | fp = StringIO() 119 | g = Generator(fp, maxheaderlen=0) 120 | g.flatten(mimepart) 121 | report.feedback_report = fp.getvalue() 122 | g = None 123 | fp = None 124 | else: 125 | msg = 'Found {} instead of message/feedback-report'.format( 126 | mimepart.get_content_type() 127 | ) 128 | logger.error(msg) 129 | except: 130 | msg = 'Unable to get feedback-report part' 131 | logger.error(msg) 132 | 133 | if report.feedback_report: 134 | for line in report.feedback_report.splitlines(): 135 | line = line.lstrip() 136 | (ls0, ls1, ls2) = line.partition(':') 137 | ls0 = ls0.strip() 138 | ls2 = ls2.strip() 139 | if ls1: 140 | if not report.domain: 141 | if ls0 == 'Reported-Domain': 142 | report.domain = ls2 143 | if not report.source_ip: 144 | if ls0 == 'Source-IP': 145 | report.source_ip = ls2 146 | if not report.email_from: 147 | if ls0 == 'Original-Mail-From': 148 | report.email_from = ls2 149 | if not report.date: 150 | if ls0 == 'Arrival-Date': 151 | try: 152 | # get tuples 153 | t = parsedate_tz(ls2) 154 | # get timestamp 155 | t = mktime_tz(t) 156 | report.date = datetime.fromtimestamp(t) 157 | tz_utc = pytz.timezone('UTC') 158 | report.date = report.date.replace(tzinfo=tz_utc) 159 | except: 160 | msg = 'Unable to get date from: {}'.format(ls2) 161 | logger.error(msg) 162 | report.date = datetime.now() 163 | if not report.dmarc_result: 164 | if ls0 == 'Delivery-Result': 165 | report.dmarc_result = ls2 166 | if ls0 == 'Authentication-Results': 167 | ar = ls2.split() 168 | for r in ar: 169 | (r0, r1, r2) = r.partition('=') 170 | if r1: 171 | if not report.dkim_alignment and r0 == 'dkim': 172 | report.dkim_alignment = r2.rstrip(';') 173 | if not report.spf_alignment and r0 == 'spf': 174 | report.spf_alignment = r2.rstrip(';') 175 | 176 | # Get the rfc822 headers and any message 177 | fp = StringIO() 178 | g = Generator(fp, maxheaderlen=0) 179 | try: 180 | mimepart = dmarcemail.get_payload(2, False) 181 | mimepart_type = mimepart.get_content_type() 182 | g.flatten(mimepart) 183 | if mimepart_type == 'message/rfc822': 184 | report.email_source = fp.getvalue() 185 | elif mimepart_type == 'message/rfc822-headers': 186 | report.email_source = fp.getvalue() 187 | elif mimepart_type == 'text/rfc822': 188 | report.email_source = fp.getvalue() 189 | elif mimepart_type == 'text/rfc822-headers': 190 | report.email_source = fp.getvalue() 191 | else: 192 | msg = 'Found {} instead of rfc822'.format(mimepart_type) 193 | logger.debug(msg) 194 | except: 195 | msg = 'Unable to get rfc822 part' 196 | logger.warning(msg) 197 | g = None 198 | fp = None 199 | if report.email_source: 200 | for line in report.email_source.splitlines(): 201 | line = line.lstrip() 202 | (ls0, ls1, ls2) = line.partition(':') 203 | ls0 = ls0.strip() 204 | ls2 = ls2.strip() 205 | if ls1: 206 | if not report.email_subject: 207 | if ls0 == 'Subject': 208 | report.email_subject = ls2 209 | 210 | try: 211 | report.save() 212 | except: 213 | msg = 'Failed save from {}'.format(report.reporter) 214 | logger.error(msg) 215 | tf = tempfile.mkstemp(prefix='dmarc-',suffix='.eml') 216 | tmpf = os.fdopen(tf[0],'w') 217 | tmpf.write(dmarcemail.get_payload()) 218 | tmpf.close() 219 | msg = 'Saved as: {}'.format(tf[1]) 220 | logger.error(msg) 221 | 222 | def process_822(self, dmarcemail): 223 | """Extract report from rfc822 email, non standard""" 224 | report = FBReport() 225 | dmarc_reporter = None 226 | try: 227 | dmarc_reporter = dmarcemail.get('from') 228 | report.reporter = FBReporter.objects.get(email=dmarc_reporter) 229 | except ObjectDoesNotExist: 230 | try: 231 | report.reporter = FBReporter.objects.create( 232 | org_name = dmarc_reporter, 233 | email = dmarc_reporter, 234 | ) 235 | except: 236 | msg = 'Failed to find or create reporter {}'.format(dmarc_reporter) 237 | logger.error(msg) 238 | raise CommandError(msg) 239 | except: 240 | msg = 'Unable to get feedback report' 241 | logger.warning(msg) 242 | tf = tempfile.mkstemp(prefix='dmarc-',suffix='.eml') 243 | tmpf = os.fdopen(tf[0],'w') 244 | tmpf.write(dmarcemail.get_payload()) 245 | tmpf.close() 246 | msg = 'Saved as: {}'.format(tf[1]) 247 | logger.error(msg) 248 | raise CommandError(msg) 249 | report.feedback_source = dmarcemail.get_payload() 250 | fp = StringIO() 251 | g = Generator(fp, maxheaderlen=0) 252 | g.flatten(dmarcemail) 253 | report.email_source = fp.getvalue() 254 | g = None 255 | fp = None 256 | 257 | for line in report.feedback_source.splitlines(): 258 | line = line.lstrip() 259 | (ls0, ls1, ls2) = line.partition(':') 260 | ls0 = ls0.strip() 261 | ls2 = ls2.strip() 262 | if ls1: 263 | if not report.domain: 264 | if ls0 == 'Sender Domain': 265 | report.domain = ls2 266 | if not report.source_ip: 267 | if ls0 == 'Sender IP Address': 268 | report.source_ip = ls2 269 | if not report.date: 270 | if ls0 == 'Received Date': 271 | try: 272 | # get tuples 273 | t = parsedate_tz(ls2) 274 | # get timestamp 275 | t = mktime_tz(t) 276 | report.date = datetime.fromtimestamp(t) 277 | tz_utc = pytz.timezone('UTC') 278 | report.date = report.date.replace(tzinfo=tz_utc) 279 | except: 280 | msg = 'Unable to get date from: {}'.format(ls2) 281 | logger.error(msg) 282 | report.date = datetime.now() 283 | if not report.spf_alignment: 284 | if ls0 == 'SPF Alignment': 285 | report.spf_alignment = ls2 286 | if not report.dkim_alignment: 287 | if ls0 == 'DKIM Alignment': 288 | report.dkim_alignment = ls2 289 | if not report.dmarc_result: 290 | if ls0 == 'DMARC Results': 291 | report.dmarc_result = ls2 292 | if not report.email_from: 293 | if ls0 == 'From': 294 | report.email_from = ls2 295 | if not report.email_subject: 296 | if ls0 == 'Subject': 297 | report.email_subject = ls2 298 | try: 299 | report.save() 300 | except: 301 | msg = 'Failed save from {}'.format(dmarc_reporter) 302 | logger.error(msg) 303 | tf = tempfile.mkstemp(prefix='dmarc-',suffix='.eml') 304 | tmpf = os.fdopen(tf[0],'w') 305 | tmpf.write(dmarcemail.get_payload()) 306 | tmpf.close() 307 | msg = 'Saved as: {}'.format(tf[1]) 308 | logger.error(msg) 309 | -------------------------------------------------------------------------------- /dmarc/management/commands/importdmarcreport.py: -------------------------------------------------------------------------------- 1 | #---------------------------------------------------------------------- 2 | # Copyright (c) 2015-2021, Persistent Objects Ltd https://p-o.co.uk/ 3 | # 4 | # License: BSD 5 | #---------------------------------------------------------------------- 6 | """Import DMARC Aggregate Reports 7 | """ 8 | import os 9 | import xml.etree.ElementTree as ET 10 | import gzip 11 | import zipfile 12 | import logging 13 | import tempfile 14 | 15 | from datetime import datetime 16 | from email import message_from_string 17 | from io import BytesIO 18 | from argparse import FileType 19 | 20 | from django.db.utils import IntegrityError 21 | from django.db import Error 22 | from django.core.exceptions import ObjectDoesNotExist 23 | from django.core.management.base import BaseCommand, CommandError 24 | 25 | import pytz 26 | 27 | from dmarc.models import Reporter, Report, Record, Result 28 | 29 | class Command(BaseCommand): 30 | """ 31 | Command class for importing DMARC Aggregate Reports 32 | Most errors are not raised to prevent email bounces 33 | """ 34 | help = 'Imports a DMARC Aggregate Report from either email or xml' 35 | 36 | def add_arguments(self, parser): 37 | parser.add_argument('-e', '--email', 38 | type=FileType('r'), 39 | default=False, 40 | help='Import dmarc report from email file, or - for stdin' 41 | ) 42 | parser.add_argument('-x', '--xml', 43 | type=FileType('r'), 44 | default=False, 45 | help='Import from xml file, or - for stdin' 46 | ) 47 | 48 | def handle(self, *args, **options): 49 | """ 50 | Handle method to import a DMARC Aggregate Reports 51 | Either pass in 52 | - the email message and the DMARC XML data will be extracted; 53 | - or the xml file. 54 | """ 55 | 56 | logger = logging.getLogger(__name__) 57 | msg = "Importing DMARC Aggregate Reports" 58 | logger.info(msg) 59 | 60 | dmarc_xml = '' 61 | 62 | if options['email']: 63 | email = options['email'].read() 64 | msg = 'Importing from email: {}'.format(email) 65 | logger.debug(msg) 66 | dmarc_xml = self.get_xml_from_email(email) 67 | elif options['xml']: 68 | try: 69 | dmarc_xml = options['xml'].read() 70 | except: 71 | pass 72 | if not dmarc_xml: 73 | try: 74 | # Test returns file name instead of file object 75 | with open(options['xml']) as xml_file: 76 | dmarc_xml = xml_file.read() 77 | except: 78 | pass 79 | if not dmarc_xml: 80 | msg = "Unable to find DMARC file: {}".format(options['xml']) 81 | logger.error(msg) 82 | raise CommandError(msg) 83 | msg = 'Importing from xml: {}'.format(dmarc_xml) 84 | logger.debug(msg) 85 | else: 86 | msg = "Check usage, please supply a single DMARC report file or email" 87 | logger.error(msg) 88 | raise CommandError(msg) 89 | 90 | tz_utc = pytz.timezone('UTC') 91 | try: 92 | root = ET.fromstring(dmarc_xml) 93 | except: 94 | msg = "Processing xml failed: {}".format(dmarc_xml) 95 | logger.error(msg) 96 | return None 97 | 98 | # Report metadata 99 | report_metadata = root.findall('report_metadata') 100 | org_name = None 101 | email = None 102 | report_id = None 103 | report_begin = None 104 | report_end = None 105 | for node in report_metadata[0]: 106 | if node.tag == 'org_name': 107 | org_name = node.text 108 | if node.tag == 'email': 109 | email = node.text 110 | if node.tag == 'report_id': 111 | report_id = node.text 112 | if node.tag == 'date_range': 113 | report_begin = node.find('begin').text 114 | report_end = node.find('end').text 115 | 116 | if org_name is None: 117 | msg = "This DMARC report does not have an org_name" 118 | logger.error(msg) 119 | if report_id is None: 120 | msg = "This DMARC report for {} does not have a report_id".format(org_name) 121 | logger.error(msg) 122 | try: 123 | reporter = Reporter.objects.get(org_name=org_name) 124 | except ObjectDoesNotExist: 125 | try: 126 | reporter = Reporter.objects.create(org_name=org_name, email=email) 127 | except Error as err: 128 | msg = "Unable to create DMARC report for {}: {}".format(org_name, err) 129 | logger.error(msg) 130 | 131 | # Reporting policy 132 | policy_published = root.findall('policy_published') 133 | # Set defaults 134 | policy_domain = None 135 | policy_adkim = 'r' 136 | policy_aspf = 'r' 137 | policy_p = 'none' 138 | policy_sp = 'none' 139 | policy_pct = 0 140 | for node in policy_published[0]: 141 | if node.tag == 'domain': 142 | policy_domain = node.text 143 | if node.tag == 'adkim': 144 | policy_adkim = node.text 145 | if node.tag == 'aspf': 146 | policy_aspf = node.text 147 | if node.tag == 'p': 148 | policy_p = node.text 149 | if node.tag == 'sp': 150 | policy_sp = node.text 151 | if node.tag == 'pct': 152 | policy_pct = int(node.text) 153 | 154 | # Create the report 155 | report = Report() 156 | report.report_id = report_id 157 | report.reporter = reporter 158 | report_date_begin = datetime.fromtimestamp(float(report_begin)).replace(tzinfo=tz_utc) 159 | try: 160 | report_date_begin = datetime.fromtimestamp(float(report_begin)).replace(tzinfo=tz_utc) 161 | report_date_end = datetime.fromtimestamp(float(report_end)).replace(tzinfo=tz_utc) 162 | except: 163 | msg = "Unable to understand DMARC reporting dates" 164 | logger.error(msg) 165 | report.date_begin = report_date_begin 166 | report.date_end = report_date_end 167 | report.policy_domain = policy_domain 168 | report.policy_adkim = policy_adkim 169 | report.policy_aspf = policy_aspf 170 | report.policy_p = policy_p 171 | report.policy_sp = policy_sp 172 | report.policy_pct = policy_pct 173 | report.report_xml = dmarc_xml 174 | try: 175 | report.save() 176 | except IntegrityError as err: 177 | msg = "DMARC duplicate report record: {}".format(err) 178 | logger.error(msg) 179 | return None 180 | except Error as err: 181 | msg = "Unable to save the DMARC report header {}: {}".format(report_id, err) 182 | logger.error(msg) 183 | 184 | # Record 185 | for node in root.findall('record'): 186 | source_ip = None 187 | recordcount = 0 188 | policyevaluated_disposition = None 189 | policyevaluated_dkim = None 190 | policyevaluated_spf = None 191 | policyevaluated_reasontype = '' 192 | policyevaluated_reasoncomment = '' 193 | identifier_headerfrom = None 194 | row = node.find('row') 195 | source_ip = row.find('source_ip').text 196 | if row.find('count') is not None: 197 | recordcount = int(row.find('count').text) 198 | else: 199 | recordcount = 0 200 | policyevaluated = row.find('policy_evaluated') 201 | policyevaluated_disposition = policyevaluated.find('disposition').text 202 | policyevaluated_dkim = policyevaluated.find('dkim').text 203 | policyevaluated_spf = policyevaluated.find('spf').text 204 | if policyevaluated.find('reason') is not None: 205 | reason = policyevaluated.find('reason') 206 | if reason.find('type') is not None: 207 | policyevaluated_reasontype = reason.find('type').text 208 | if reason.find('comment') is not None: 209 | if reason.find('comment').text is not None: 210 | policyevaluated_reasoncomment = reason.find('comment').text 211 | 212 | identifiers = node.find('identifiers') 213 | identifier_headerfrom = identifiers.find('header_from').text 214 | 215 | if len(source_ip) == 0: 216 | msg = "DMARC report record useless without a source ip" 217 | logger.error(msg) 218 | 219 | # Create the record 220 | record = Record() 221 | record.report = report 222 | record.source_ip = source_ip 223 | record.recordcount = recordcount 224 | record.policyevaluated_disposition = policyevaluated_disposition 225 | record.policyevaluated_dkim = policyevaluated_dkim 226 | record.policyevaluated_spf = policyevaluated_spf 227 | record.policyevaluated_reasontype = policyevaluated_reasontype 228 | record.policyevaluated_reasoncomment = policyevaluated_reasoncomment 229 | record.identifier_headerfrom = identifier_headerfrom 230 | try: 231 | record.save() 232 | msg = "DMARC record saved" 233 | logger.debug(msg) 234 | except IntegrityError as err: 235 | msg = "DMARC duplicate record: {}".format(err) 236 | logger.error(msg) 237 | except Error as err: 238 | msg = "Unable to save the DMARC report record: {}".format(err) 239 | logger.error(msg) 240 | 241 | auth_results = node.find('auth_results') 242 | for resulttype in auth_results: 243 | result_domain = resulttype.find('domain').text 244 | if result_domain is None: 245 | # Allow for blank domains 246 | result_domain = '' 247 | result_result = resulttype.find('result').text 248 | 249 | # Create the record 250 | result = Result() 251 | result.record = record 252 | result.record_type = resulttype.tag 253 | result.domain = result_domain 254 | result.result = result_result 255 | try: 256 | result.save() 257 | msg = "DMARC result saved" 258 | logger.debug(msg) 259 | except Error as err: 260 | msg = ("Unable to save the DMARC report result {} for {}: {}" 261 | ).format(resulttype.tag, result_domain, err) 262 | logger.error(msg) 263 | 264 | def get_xml_from_email(self, email): 265 | """Get xml from an email 266 | """ 267 | dmarc_xml = '' 268 | logger = logging.getLogger(__name__) 269 | 270 | msg = 'Processing email' 271 | logger.debug(msg) 272 | try: 273 | dmarcemail = message_from_string(email) 274 | except: 275 | msg = 'Unable to use email' 276 | logger.debug(msg) 277 | return '' 278 | 279 | for mimepart in dmarcemail.walk(): 280 | msg = 'Processing content type: {}'.format(mimepart.get_content_type()) 281 | logger.debug(msg) 282 | if mimepart.get_content_type() == 'application/x-zip-compressed' \ 283 | or mimepart.get_content_type() == 'application/x-zip' \ 284 | or mimepart.get_content_type() == 'application/zip' \ 285 | or mimepart.get_content_type() == 'application/gzip' \ 286 | or mimepart.get_content_type() == 'application/octet-stream': 287 | dmarc_zip = BytesIO() 288 | dmarc_zip.write(mimepart.get_payload(decode=True)) 289 | dmarc_zip.seek(0) 290 | if zipfile.is_zipfile(dmarc_zip): 291 | msg = "DMARC is zipfile" 292 | logger.debug(msg) 293 | try: 294 | ZipFile = zipfile.ZipFile(dmarc_zip, 'r') 295 | files = ZipFile.infolist() 296 | # The DMARC report should only contain a single xml file 297 | for f in files: 298 | dmarc_xml = ZipFile.read(f) 299 | ZipFile.close() 300 | except zipfile.BadZipfile: 301 | msg = 'Unable to unzip mimepart' 302 | logger.error(msg) 303 | tf = tempfile.mkstemp(prefix='dmarc-',suffix='.zip') 304 | dmarc_zip.seek(0) 305 | tmpf = os.fdopen(tf[0],'w') 306 | tmpf.write(dmarc_zip.getvalue()) 307 | tmpf.close() 308 | msg = 'Saved in: {}'.format(tf[1]) 309 | logger.debug(msg) 310 | raise CommandError(msg) 311 | else: 312 | msg = "DMARC trying gzip" 313 | logger.debug(msg) 314 | # Reset zip file 315 | dmarc_zip.seek(0) 316 | try: 317 | ZipFile = gzip.GzipFile(None, 'rb', 0, dmarc_zip) 318 | dmarc_xml = ZipFile.read() 319 | ZipFile = None 320 | msg = "DMARC successfully extracted xml from gzip" 321 | logger.debug(msg) 322 | except: 323 | msg = 'Unable to gunzip mimepart' 324 | logger.error(msg) 325 | tf = tempfile.mkstemp(prefix='dmarc-',suffix='.gz') 326 | dmarc_zip.seek(0) 327 | tmpf = os.fdopen(tf[0],'w') 328 | tmpf.write(dmarc_zip.getvalue()) 329 | tmpf.close() 330 | msg = 'Saved in: {}'.format(tf[1]) 331 | logger.debug(msg) 332 | raise CommandError(msg) 333 | else: 334 | try: 335 | myname = mimepart.get_filename() 336 | except: 337 | myname = 'Not provided' 338 | msg = "DMARC Report is not in mimepart: {}".format(myname) 339 | logger.debug(msg) 340 | return dmarc_xml 341 | -------------------------------------------------------------------------------- /dmarc/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.db import models, migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name='Record', 14 | fields=[ 15 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 16 | ('source_ip', models.GenericIPAddressField()), 17 | ('recordcount', models.IntegerField()), 18 | ('policyevaluated_disposition', models.CharField(max_length=10)), 19 | ('policyevaluated_dkim', models.CharField(max_length=4)), 20 | ('policyevaluated_spf', models.CharField(max_length=4)), 21 | ('policyevaluated_reasontype', models.CharField(max_length=15, blank=True)), 22 | ('policyevaluated_reasoncomment', models.CharField(max_length=100, blank=True)), 23 | ('identifier_headerfrom', models.CharField(max_length=100)), 24 | ], 25 | options={ 26 | }, 27 | bases=(models.Model,), 28 | ), 29 | migrations.CreateModel( 30 | name='Report', 31 | fields=[ 32 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 33 | ('report_id', models.CharField(max_length=100)), 34 | ('date_begin', models.DateTimeField(db_index=True)), 35 | ('date_end', models.DateTimeField()), 36 | ('policy_domain', models.CharField(max_length=100)), 37 | ('policy_adkim', models.CharField(max_length=1, verbose_name=b'DKIM alignment mode')), 38 | ('policy_aspf', models.CharField(max_length=1, verbose_name=b'SPF alignment mode')), 39 | ('policy_p', models.CharField(max_length=10, verbose_name=b'Requested handling policy')), 40 | ('policy_sp', models.CharField(max_length=10, verbose_name=b'Requested handling policy for subdomains')), 41 | ('policy_pct', models.SmallIntegerField(verbose_name=b'Sampling rate')), 42 | ], 43 | options={ 44 | }, 45 | bases=(models.Model,), 46 | ), 47 | migrations.CreateModel( 48 | name='Reporter', 49 | fields=[ 50 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 51 | ('org_name', models.CharField(unique=True, max_length=100, verbose_name=b'Organisation')), 52 | ('email', models.EmailField(max_length=75)), 53 | ], 54 | options={ 55 | }, 56 | bases=(models.Model,), 57 | ), 58 | migrations.CreateModel( 59 | name='Result', 60 | fields=[ 61 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 62 | ('record_type', models.CharField(max_length=4)), 63 | ('domain', models.CharField(max_length=100)), 64 | ('result', models.CharField(max_length=9)), 65 | ('record', models.ForeignKey(to='dmarc.Record', on_delete=models.CASCADE)), 66 | ], 67 | options={ 68 | }, 69 | bases=(models.Model,), 70 | ), 71 | migrations.AlterUniqueTogether( 72 | name='result', 73 | unique_together=set([('record', 'record_type', 'domain')]), 74 | ), 75 | migrations.AddField( 76 | model_name='report', 77 | name='reporter', 78 | field=models.ForeignKey(to='dmarc.Reporter', on_delete=models.CASCADE), 79 | preserve_default=True, 80 | ), 81 | migrations.AlterUniqueTogether( 82 | name='report', 83 | unique_together=set([('reporter', 'report_id', 'date_begin')]), 84 | ), 85 | migrations.AddField( 86 | model_name='record', 87 | name='report', 88 | field=models.ForeignKey(to='dmarc.Report', on_delete=models.CASCADE), 89 | preserve_default=True, 90 | ), 91 | migrations.AlterUniqueTogether( 92 | name='record', 93 | unique_together=set([('report', 'source_ip')]), 94 | ), 95 | ] 96 | -------------------------------------------------------------------------------- /dmarc/migrations/0002_auto_20150303_1606.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.db import models, migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('dmarc', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='record', 15 | name='report', 16 | field=models.ForeignKey(related_name='records', to='dmarc.Report', on_delete=models.CASCADE), 17 | preserve_default=True, 18 | ), 19 | migrations.AlterField( 20 | model_name='result', 21 | name='record', 22 | field=models.ForeignKey(related_name='results', to='dmarc.Record', on_delete=models.CASCADE), 23 | preserve_default=True, 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /dmarc/migrations/0003_auto_20150307_1531.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.db import models, migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('dmarc', '0002_auto_20150303_1606'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterUniqueTogether( 14 | name='result', 15 | unique_together=set([]), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /dmarc/migrations/0004_auto_20150310_1725.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.db import models, migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('dmarc', '0003_auto_20150307_1531'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterUniqueTogether( 14 | name='record', 15 | unique_together=set([]), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /dmarc/migrations/0005_report_report_xml.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.db import models, migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('dmarc', '0004_auto_20150310_1725'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='report', 15 | name='report_xml', 16 | field=models.TextField(blank=True), 17 | preserve_default=True, 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /dmarc/migrations/0006_auto_20160408_1427.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('dmarc', '0005_report_report_xml'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='record', 15 | name='policyevaluated_reasontype', 16 | field=models.CharField(max_length=75, blank=True), 17 | ), 18 | migrations.AlterField( 19 | model_name='reporter', 20 | name='email', 21 | field=models.EmailField(max_length=254), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /dmarc/migrations/0007_auto_20161121_1318.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.7 on 2016-11-21 13:18 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('dmarc', '0006_auto_20160408_1427'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='record', 16 | name='source_ip', 17 | field=models.CharField(max_length=39), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /dmarc/migrations/0008_auto_20170108_1428.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.7 on 2017-01-08 14:28 3 | 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('dmarc', '0007_auto_20161121_1318'), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='FBReport', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('date', models.DateTimeField(db_index=True)), 20 | ('source_ip', models.CharField(max_length=39)), 21 | ('domain', models.CharField(max_length=100)), 22 | ('email_from', models.CharField(blank=True, max_length=100)), 23 | ('email_subject', models.CharField(blank=True, max_length=100)), 24 | ('spf_alignment', models.CharField(blank=True, max_length=10)), 25 | ('dkim_alignment', models.CharField(blank=True, max_length=10)), 26 | ('dmarc_result', models.CharField(blank=True, max_length=10)), 27 | ('description', models.TextField(blank=True)), 28 | ('email_source', models.TextField(blank=True)), 29 | ], 30 | ), 31 | migrations.CreateModel( 32 | name='FBReporter', 33 | fields=[ 34 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 35 | ('org_name', models.CharField(max_length=100, unique=True, verbose_name=b'Organisation')), 36 | ('email', models.EmailField(max_length=254)), 37 | ], 38 | ), 39 | migrations.AddField( 40 | model_name='fbreport', 41 | name='reporter', 42 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dmarc.FBReporter'), 43 | ), 44 | ] 45 | -------------------------------------------------------------------------------- /dmarc/migrations/0009_auto_20170109_1738.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.7 on 2017-01-09 17:38 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('dmarc', '0008_auto_20170108_1428'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='fbreport', 16 | name='feedback_report', 17 | field=models.TextField(blank=True), 18 | ), 19 | migrations.AddField( 20 | model_name='fbreport', 21 | name='feedback_source', 22 | field=models.TextField(default=''), 23 | preserve_default=False, 24 | ), 25 | migrations.AlterField( 26 | model_name='fbreport', 27 | name='description', 28 | field=models.TextField(blank=True, verbose_name=b'human readable feedback'), 29 | ), 30 | migrations.AlterField( 31 | model_name='fbreport', 32 | name='email_source', 33 | field=models.TextField(blank=True, verbose_name=b'source email including rfc822 headers'), 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /dmarc/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alan-hicks/django-dmarc/c5ad9911f7c3698eeb6ea424fb9e494170e6e96b/dmarc/migrations/__init__.py -------------------------------------------------------------------------------- /dmarc/models.py: -------------------------------------------------------------------------------- 1 | #---------------------------------------------------------------------- 2 | # Copyright (c) 2015-2021, Persistent Objects Ltd https://p-o.co.uk/ 3 | # 4 | # License: BSD 5 | #---------------------------------------------------------------------- 6 | 7 | """ 8 | DMARC models for managing Aggregate Reports 9 | http://dmarc.org/resources/specification/ 10 | """ 11 | 12 | from django.db import models 13 | 14 | class Reporter(models.Model): 15 | org_name = models.CharField('Organisation', unique=True, max_length=100) 16 | email = models.EmailField() 17 | 18 | def __str__(self): 19 | ret = "{}".format(self.org_name) 20 | return ret 21 | 22 | class Report(models.Model): 23 | report_id = models.CharField(max_length=100) 24 | reporter = models.ForeignKey(Reporter, on_delete=models.CASCADE) 25 | date_begin = models.DateTimeField(db_index=True) 26 | date_end = models.DateTimeField() 27 | policy_domain = models.CharField(max_length=100) 28 | policy_adkim = models.CharField('DKIM alignment mode', max_length=1) 29 | policy_aspf = models.CharField('SPF alignment mode', max_length=1) 30 | policy_p = models.CharField('Requested handling policy', max_length=10) 31 | policy_sp = models.CharField('Requested handling policy for subdomains', max_length=10) 32 | policy_pct = models.SmallIntegerField('Sampling rate') 33 | report_xml = models.TextField(blank=True) 34 | 35 | def __str__(self): 36 | ret = "{}".format(self.report_id) 37 | return ret 38 | 39 | class Meta: 40 | unique_together = (("reporter", "report_id", "date_begin"),) 41 | 42 | class Record(models.Model): 43 | report = models.ForeignKey(Report, related_name='records', on_delete=models.CASCADE) 44 | source_ip = models.CharField(max_length=39) 45 | recordcount = models.IntegerField() 46 | policyevaluated_disposition = models.CharField(max_length=10) 47 | policyevaluated_dkim = models.CharField(max_length=4) 48 | policyevaluated_spf = models.CharField(max_length=4) 49 | policyevaluated_reasontype = models.CharField(blank=True, max_length=75) 50 | policyevaluated_reasoncomment = models.CharField(blank=True, max_length=100) 51 | identifier_headerfrom = models.CharField(max_length=100) 52 | 53 | def __str__(self): 54 | ret = "{}".format(self.source_ip) 55 | return ret 56 | 57 | class Result(models.Model): 58 | record = models.ForeignKey(Record, related_name='results', on_delete=models.CASCADE) 59 | record_type = models.CharField(max_length=4) 60 | domain = models.CharField(max_length=100) 61 | result = models.CharField(max_length=9) 62 | 63 | def __str__(self): 64 | ret = "{}:{}-{}".format(self.id, self.record_type, self.domain) 65 | return ret 66 | 67 | class FBReporter(models.Model): 68 | org_name = models.CharField('Organisation', unique=True, max_length=100) 69 | email = models.EmailField() 70 | 71 | def __str__(self): 72 | ret = "{}".format(self.email) 73 | return ret 74 | 75 | def save(self, *args, **kwargs): 76 | if not self.org_name: 77 | self.org_name = self.email 78 | super(FBReporter, self).save(*args, **kwargs) 79 | 80 | class FBReport(models.Model): 81 | reporter = models.ForeignKey(FBReporter, on_delete=models.CASCADE) 82 | date = models.DateTimeField(db_index=True) 83 | source_ip = models.CharField(max_length=39) 84 | domain = models.CharField(max_length=100) 85 | email_from = models.CharField(max_length=100, blank=True) 86 | email_subject = models.CharField(max_length=100, blank=True) 87 | spf_alignment = models.CharField(max_length=10, blank=True) 88 | dkim_alignment = models.CharField(max_length=10, blank=True) 89 | dmarc_result = models.CharField(max_length=10, blank=True) 90 | description = models.TextField('human readable feedback', blank=True) 91 | email_source = models.TextField('source email including rfc822 headers', blank=True) 92 | feedback_report = models.TextField(blank=True) 93 | feedback_source = models.TextField() 94 | 95 | def __str__(self): 96 | msg = '{} {} {} {} {}'.format(self.date, self.domain, self.source_ip, 97 | self.email_from, self.email_subject) 98 | return msg 99 | -------------------------------------------------------------------------------- /dmarc/templates/admin/dmarc/app_index.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load i18n %} 3 | 4 | {% block meta_title %}{% trans "DMARC aggregate feedback report" %}{% endblock %} 5 | {% block title %}{% trans "DMARC aggregate feedback report" %}{% endblock %} 6 | 7 | {% block breadcrumbs %} 8 | 14 | {% endblock %} 15 | 16 | {% block content %} 17 |

Welcome to DMARC aggregate feedback reports for Django

18 |

DMARC feedback reports

19 | 20 | {% endblock %} 21 | 22 | -------------------------------------------------------------------------------- /dmarc/templates/dmarc/report.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block meta_title %}{% trans "DMARC aggregate feedback report" %}{% endblock %} 6 | {% block title %}{% trans "DMARC aggregate feedback report" %}{% endblock %} 7 | {% block extrahead %} 8 | 9 | 10 | 11 | 12 | 23 | 205 | {% endblock %} 206 | 207 | {% block breadcrumbs %} 208 | 213 | {% endblock %} 214 | 215 | {% block content %} 216 |
217 |

{% trans "DMARC aggregate feedback report" %}

218 |

219 |
220 |

Report filter

221 |
 
222 |
223 |
    224 |
  • Period: /
  • 225 |
  • Only errors
  • 226 |
  • Filter source organisation / ip address
  • 227 |
  • Disposition
  • 232 |
  • 233 |
234 |
235 |
236 |
237 | 242 |
243 |
244 |
245 |
246 |
247 |
What does DMARC stand for
248 |
Domain-based Message Authentication, Reporting, and Conformance 249 | (DMARC) is a scalable mechanism by which a mail-originating 250 | organization can express domain-level policies and preferences for 251 | message validation, disposition, and reporting, that a mail-receiving 252 | organization can use to improve mail handling.
253 |
What is DMARC aiming to achieve?
254 |
Allows senders and receivers to improve and monitor 255 | protection of their domain from fraudulent email by building 256 | on and adding reporting to the widely deployed SPF and DKIM 257 | protocols.
258 |
How do aggregate feedback reports help?
259 |
Feedback reports provide a mechanism to monitor and 260 | improve mail handling. 261 |
262 |
How does django-dmarc make implementing DMARC easier?
263 |
Collecting feedback reports is automated, allowing easier 264 | filtering, with an option to download and use the data in a 265 | spreadsheet.
266 |
Can I get detailed information about failures?
267 |
Yes, set the ruf attribute and a detailed report will be 268 | sent for each failure. See the 269 | RFC for details.
271 |
272 |

The DMARC specification 274 | https://tools.ietf.org/html/rfc7489

275 |

Sender Policy Framework (SPF) 277 | for Authorizing Use of Domains in Email, Version 1

278 |

DomainKeys Identified Mail (DKIM) Signatures

280 |
281 |
282 |
283 |
DMARC
284 |
Domain-based Message Authentication, Reporting, and Conformance 285 | (DMARC) is a scalable mechanism by which a mail-originating 286 | organization can express domain-level policies and preferences for 287 | message validation, disposition, and reporting, that a mail-receiving 288 | organization can use to improve mail handling.
289 | 290 |
adkim
291 |
Indicates whether 292 | strict or relaxed DKIM Identifier Alignment mode is required by 293 | the Domain Owner. Valid values are as follows: 294 |
  • r: relaxed mode (default)
  • 295 |
  • s: strict mode
296 |
AFRF
297 |
Authentication Failure Reporting Format
298 |
aspf
299 |
Indicates whether 300 | strict or relaxed SPF Identifier Alignment mode is required by the 301 | Domain Owner. Valid values are as follows: 302 |
  • r: relaxed mode (default)
  • 303 |
  • s: strict mode
304 |
Authenticated Identifiers
305 |
Domain-level identifiers that are 306 | validated using authentication technologies are referred to as 307 | "Authenticated Identifiers".
308 |
Author Domain
309 |
The domain name of the apparent author, as extracted 310 | from the RFC5322 From field.
311 | 312 |
DomainKeys Identified Mail (DKIM)
313 |
DKIM is an email authentication method designed to detect 314 | email spoofing. It allows the receiver to check that an email 315 | claimed to come from a specific domain was indeed authorized 316 | by the owner of that domain. It is intended to prevent forged 317 | sender addresses in emails, a technique often used in phishing 318 | and email spam.
319 |
DMARC Policy Record
320 |
Domain Owner DMARC preferences are stored as DNS TXT records in 321 | subdomains named "_dmarc". For example, the Domain Owner of 322 | "example.com" would post DMARC preferences in a TXT record at 323 | "_dmarc.example.com". Similarly, a Mail Receiver wishing to query 324 | for DMARC preferences regarding mail with an RFC5322.From domain of 325 | "example.com" would issue a TXT query to the DNS for the subdomain of 326 | "_dmarc.example.com".
327 |
Domain Owner
328 |
An entity or organization that owns a DNS domain. The 329 | term "owns" here indicates that the entity or organization being 330 | referenced holds the registration of that DNS domain. Domain 331 | Owners range from complex, globally distributed organizations, to 332 | service providers working on behalf of non-technical clients, to 333 | individuals responsible for maintaining personal domains. This 334 | specification uses this term as analogous to an Administrative 335 | Management Domain as defined in [EMAIL-ARCH]. It can also refer 336 | to delegates, such as Report Receivers, when those are outside of 337 | their immediate management domain.
338 | 339 |
fo
340 |

Failure reporting options.

341 |

Provides requested options for generation of failure reports. 342 | Report generators MAY choose to adhere to the requested options. 343 | This tag's content MUST be ignored if a "ruf" tag (below) is not 344 | also specified. The value of this tag is a colon-separated list 345 | of characters that indicate failure reporting options as follows:

346 |
  • 0: Generate a DMARC failure report if all underlying 347 | authentication mechanisms fail to produce an aligned "pass" 348 | result. (default)
  • 349 |
  • 1: Generate a DMARC failure report if any underlying 350 | authentication mechanism produced something other than an 351 | aligned "pass" result.
  • 352 |
  • d: Generate a DKIM failure report if the message had a signature 353 | that failed evaluation, regardless of its alignment.
  • 354 |
  • s: Generate an SPF failure report if the message failed SPF 355 | evaluation, regardless of its alignment.
356 | 357 |
Identifier Alignment
358 |
When the domain in the RFC5322.From address 359 | matches a domain validated by SPF or DKIM (or both), it has 360 | Identifier Alignment.
361 | 362 |
Mail Receiver
363 |
The entity or organization that receives and 364 | processes email. Mail Receivers operate one or more Internet- 365 | facing Mail Transport Agents (MTAs).
366 | 367 |
Organizational Domain
368 |
The domain that was registered with a domain 369 | name registrar. In the absence of more accurate methods, 370 | heuristics are used to determine this, since it is not always the 371 | case that the registered domain name is simply a top-level DNS 372 | domain plus one component (e.g., "example.com", where "com" is a 373 | top-level domain).
374 | 375 |
p
376 |
Requested Mail Receiver policy. 377 | Indicates the policy to be enacted by the Receiver at 378 | the request of the Domain Owner. Policy applies to the domain 379 | queried and to subdomains, unless subdomain policy is explicitly 380 | described using the "sp" tag. This tag is mandatory for policy 381 | records only, but not for third-party reporting records. 382 | Possible values are as follows: 383 |
  • none: The Domain Owner requests no specific action be taken 384 | regarding delivery of messages.
  • 385 |
  • quarantine: The Domain Owner wishes to have email that fails the 386 | DMARC mechanism check be treated by Mail Receivers as 387 | suspicious. Depending on the capabilities of the Mail 388 | Receiver, this can mean "place into spam folder", "scrutinize 389 | with additional intensity", and/or "flag as suspicious".
  • 390 |
  • reject: The Domain Owner wishes for Mail Receivers to reject 391 | email that fails the DMARC mechanism check. Rejection SHOULD 392 | occur during the SMTP transaction. See Section 10.3 for some 393 | discussion of SMTP rejection methods and their implications.
  • 394 |
395 |
pct
396 |
Percentage of messages from the Domain Owner's 397 | mail stream to which the DMARC policy is to be applied. However, 398 | this MUST NOT be applied to the DMARC-generated reports, all of 399 | which must be sent and received unhindered. The purpose of the 400 | "pct" tag is to allow Domain Owners to enact a slow rollout 401 | enforcement of the DMARC mechanism. The prospect of "all or 402 | nothing" is recognized as preventing many organizations from 403 | experimenting with strong authentication-based mechanisms.
404 | 405 |
Report Receiver
406 |
An operator that receives reports from another 407 | operator implementing the reporting mechanism described in this 408 | document. Such an operator might be receiving reports about its 409 | own messages, or reports about messages related to another 410 | operator. This term applies collectively to the system components 411 | that receive and process these reports and the organizations that 412 | operate them.
413 |
rf
414 |
Format to be used for message-specific failure reports. 415 | The value of this tag is a list of one or more report formats as 416 | requested by the Domain Owner to be used when a message fails both 417 | [SPF] and [DKIM] tests to report details of the individual 418 | failure. For this version, only "afrf" (the auth-failure report 419 | type defined in [AFRF]) is presently supported.
420 |
ri
421 |
Interval requested between aggregate reports. 422 | The default is 86400 (one day). Indicates a 423 | request to Receivers to generate aggregate reports separated by no 424 | more than the requested number of seconds. DMARC implementations 425 | MUST be able to provide daily reports and SHOULD be able to 426 | provide hourly reports when requested. However, anything other 427 | than a daily report is understood to be accommodated on a best- 428 | effort basis.
429 |
rua
430 |
Addresses to which aggregate feedback is to be sent.
431 |
ruf
432 |
Addresses to which message-specific failure information is to 433 | be reported. If present, the Domain Owner is requesting Mail 434 | Receivers to send detailed failure reports about messages that 435 | fail the DMARC evaluation in specific ways (see the "fo" tag).
436 | 437 |
Sender Policy Framework (SPF)
438 |
Sender Policy Framework is a simple email-validation system 439 | designed to detect email spoofing by providing a mechanism to 440 | allow receiving mail exchangers to check that incoming mail from 441 | a domain comes from a host authorized by that domain's 442 | administrators.
443 |
sp
444 |
Requested Mail Receiver policy for all subdomains. 445 | Indicates the policy to be enacted by the Receiver at 446 | the request of the Domain Owner. It applies only to subdomains of 447 | the domain queried and not to the domain itself. Its syntax is 448 | identical to that of the "p" tag. If absent, the 449 | policy specified by the "p" tag MUST be applied for subdomains. 450 | Note that "sp" will be ignored for DMARC records published on 451 | subdomains of Organizational Domains due to the effect of the 452 | DMARC policy discovery mechanism
453 | 454 |
v
455 |
Version. Identifies the record retrieved 456 | as a DMARC record. It MUST have the value of "DMARC1".
457 |
458 |

The DMARC specification 460 | https://tools.ietf.org/html/rfc7489

461 |

Sender Policy Framework (SPF) 463 | for Authorizing Use of Domains in Email, Version 1

464 |

DomainKeys Identified Mail (DKIM) Signatures

466 |
467 |
468 |
469 | {% endblock %} 470 | -------------------------------------------------------------------------------- /dmarc/test_import.py: -------------------------------------------------------------------------------- 1 | #---------------------------------------------------------------------- 2 | # Copyright (c) 2015-2021, Persistent Objects Ltd https://p-o.co.uk/ 3 | # 4 | # License: BSD 5 | #---------------------------------------------------------------------- 6 | 7 | """ 8 | DMARC tests for importing Aggregate Reports 9 | http://dmarc.org/resources/specification/ 10 | """ 11 | import os 12 | from datetime import datetime 13 | import pytz 14 | from django.conf import settings 15 | from django.core.management import call_command 16 | from django.core.management.base import CommandError 17 | from django.test import TestCase 18 | from django.utils.six import StringIO 19 | 20 | from dmarc.models import Reporter, Report, Record, Result 21 | 22 | class ImportDMARCReportTestCase(TestCase): 23 | """ 24 | Standard core tests 25 | """ 26 | 27 | def setUp(self): 28 | """Set up test environment""" 29 | pass 30 | 31 | def test_importdmarcreport_withoutargs(self): 32 | """Test importing withuot args""" 33 | msg = 'Check usage, please supply a single DMARC report file or email' 34 | out = StringIO() 35 | try: 36 | call_command('importdmarcreport', stdout=out) 37 | except CommandError as cmderror: 38 | msgerror = str(cmderror) 39 | self.assertIn(msg, msgerror) 40 | 41 | def test_importdmarcreport_filenotfound(self): 42 | """Test importing xml file not found""" 43 | msg = 'Unable to find DMARC file: filenotfound.xml' 44 | out = StringIO() 45 | msgerror = '' 46 | try: 47 | call_command( 48 | 'importdmarcreport', 49 | xml='filenotfound.xml', 50 | stderr=out) 51 | except CommandError as cmderror: 52 | msgerror = str(cmderror) 53 | self.assertEqual(msgerror, msg) 54 | 55 | def test_importdmarcreport_file(self): 56 | """Test importing xml file""" 57 | out = StringIO() 58 | data = Reporter.objects.all() 59 | self.assertEqual(len(data), 0) 60 | dmarcreport = os.path.dirname(os.path.realpath(__file__)) 61 | dmarcreport = os.path.join(dmarcreport, 'tests/dmarcreport.xml') 62 | call_command('importdmarcreport', xml=dmarcreport, stderr=out) 63 | self.assertIn('', out.getvalue()) 64 | # Reporter object 65 | data = Reporter.objects.all() 66 | self.assertEqual(len(data), 1) 67 | self.assertEqual(data[0].org_name, 'Persistent Objects') 68 | self.assertEqual(data[0].email, 'ahicks@p-o.co.uk') 69 | # Report object 70 | data = Report.objects.all() 71 | self.assertEqual(len(data), 1) 72 | self.assertEqual(data[0].report_id, '5edbe461-ccda-1e41-abdb-00c0af3f9715@p-o.co.uk') 73 | if settings.USE_TZ: 74 | tz_utc = pytz.timezone(settings.TIME_ZONE) 75 | self.assertEqual(data[0].date_begin, datetime(2015, 2, 25, 12, 0, tzinfo=tz_utc)) 76 | self.assertEqual(data[0].date_end, datetime(2015, 2, 26, 12, 0, tzinfo=tz_utc)) 77 | else: 78 | self.assertEqual(data[0].date_begin, datetime(2015, 2, 25, 12, 0)) 79 | self.assertEqual(data[0].date_end, datetime(2015, 2, 26, 12, 0)) 80 | self.assertEqual(data[0].policy_domain, 'p-o.co.uk') 81 | self.assertEqual(data[0].policy_adkim, 'r') 82 | self.assertEqual(data[0].policy_aspf, 'r') 83 | self.assertEqual(data[0].policy_p, 'quarantine') 84 | self.assertEqual(data[0].policy_sp, 'none') 85 | self.assertEqual(data[0].policy_pct, 100) 86 | self.assertIn("", data[0].report_xml) 87 | self.assertIn("", data[0].report_xml) 88 | # Record 89 | data = Record.objects.all() 90 | self.assertEqual(len(data), 1) 91 | self.assertEqual(data[0].source_ip, '80.229.143.200') 92 | self.assertEqual(data[0].recordcount, 1) 93 | self.assertEqual(data[0].policyevaluated_disposition, 'none') 94 | self.assertEqual(data[0].policyevaluated_dkim, 'pass') 95 | self.assertEqual(data[0].policyevaluated_spf, 'pass') 96 | self.assertEqual(data[0].policyevaluated_reasontype, '') 97 | self.assertEqual(data[0].policyevaluated_reasoncomment, '') 98 | self.assertEqual(data[0].identifier_headerfrom, 'p-o.co.uk') 99 | 100 | # Result 101 | data = Result.objects.all() 102 | self.assertEqual(len(data), 2) 103 | self.assertEqual(data[0].record_type, 'spf') 104 | self.assertEqual(data[0].domain, 'p-o.co.uk') 105 | self.assertEqual(data[0].result, 'pass') 106 | self.assertEqual(data[1].record_type, 'dkim') 107 | self.assertEqual(data[1].domain, 'p-o.co.uk') 108 | self.assertEqual(data[1].result, 'pass') 109 | -------------------------------------------------------------------------------- /dmarc/tests/dmarcreport.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Persistent Objects 5 | ahicks@p-o.co.uk 6 | 5edbe461-ccda-1e41-abdb-00c0af3f9715@p-o.co.uk 7 | 8 | 1424865600 9 | 1424952000 10 | 11 | 12 | 13 | p-o.co.uk 14 | r 15 | r 16 |

quarantine

17 | none 18 | 100 19 |
20 | 21 | 22 | 80.229.143.200 23 | 1 24 | 25 | none 26 | pass 27 | pass 28 | 29 | 30 | 31 | p-o.co.uk 32 | 33 | 34 | 35 | p-o.co.uk 36 | pass 37 | 38 | 39 | p-o.co.uk 40 | pass 41 | 42 | 43 | 44 |
45 | -------------------------------------------------------------------------------- /dmarc/urls.py: -------------------------------------------------------------------------------- 1 | #---------------------------------------------------------------------- 2 | # Copyright (c) 2015-2021, Persistent Objects Ltd https://p-o.co.uk/ 3 | # 4 | # License: BSD 5 | #---------------------------------------------------------------------- 6 | """ 7 | DMARC urls 8 | http://dmarc.org/resources/specification/ 9 | """ 10 | from django.conf.urls import url 11 | from dmarc import views 12 | 13 | app_name = 'dmarc' 14 | urlpatterns = [ 15 | url("^report/$", views.dmarc_report, name='dmarc_report'), 16 | url("^report/csv/$", views.dmarc_csv, name='dmarc_csv'), 17 | url("^report/json/$", views.dmarc_json, name='dmarc_json'), 18 | ] 19 | -------------------------------------------------------------------------------- /dmarc/views.py: -------------------------------------------------------------------------------- 1 | #---------------------------------------------------------------------- 2 | # Copyright (c) 2015-2021, Persistent Objects Ltd https://p-o.co.uk/ 3 | # 4 | # License: BSD 5 | #---------------------------------------------------------------------- 6 | """ 7 | DMARC views 8 | https://dmarc.org/resources/specification/ 9 | """ 10 | import csv 11 | import datetime 12 | 13 | from django.contrib.admin.views.decorators import staff_member_required 14 | from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger 15 | from django.db import connection 16 | from django.http import JsonResponse, StreamingHttpResponse 17 | from django.shortcuts import render 18 | 19 | from dmarc.models import Report 20 | 21 | class Echo(object): 22 | """An object that implements just the write method of the file-like 23 | interface for csv.writer. 24 | """ 25 | def write(self, value): 26 | """Write the value by returning it, instead of storing in a buffer.""" 27 | return value 28 | 29 | def _sql_cursor(request_args): 30 | """Returns a cursor according to users request""" 31 | sql_where = [] 32 | sql_orderby = [] 33 | sql_params = [] 34 | if 'dmarc_date_from' in request_args: 35 | val = request_args['dmarc_date_from'] 36 | try: 37 | val = datetime.datetime.strptime(val, '%Y-%m-%d') 38 | except: 39 | val = datetime.date.today() 40 | sql_where.append('dmarc_report.date_begin >= %s') 41 | sql_params.append(val) 42 | if 'dmarc_date_to' in request_args: 43 | val = request_args['dmarc_date_to'] 44 | try: 45 | val = datetime.datetime.strptime(val, '%Y-%m-%d') 46 | except: 47 | val = datetime.date.today() 48 | td = datetime.timedelta(days=1) 49 | val = val + td 50 | sql_where.append('dmarc_report.date_end < %s') 51 | sql_params.append(val) 52 | if 'dmarc_disposition' in request_args and request_args['dmarc_disposition']: 53 | val = request_args['dmarc_disposition'] 54 | sql_where.append('dmarc_record.policyevaluated_disposition = %s') 55 | sql_params.append(val) 56 | if 'dmarc_onlyerror' in request_args: 57 | s = '(' 58 | s = s + "dmarc_record.policyevaluated_dkim = 'fail'" 59 | s = s + " OR " 60 | s = s + "dmarc_record.policyevaluated_spf = 'fail'" 61 | s = s + ')' 62 | sql_where.append(s) 63 | if 'dmarc_filter' in request_args and request_args['dmarc_filter']: 64 | val = request_args['dmarc_filter'] + '%' 65 | s = '(' 66 | s = s + "lower(dmarc_reporter.org_name) LIKE lower(%s)" 67 | s = s + " OR " 68 | s = s + "dmarc_record.source_ip LIKE %s" 69 | s = s + ')' 70 | sql_where.append(s) 71 | sql_params.append(val) 72 | sql_params.append(val) 73 | 74 | sql = """ 75 | SELECT 76 | dmarc_reporter.org_name, 77 | dmarc_reporter.email, 78 | dmarc_report.date_begin, 79 | dmarc_report.date_end, 80 | dmarc_report.policy_domain, 81 | dmarc_report.policy_adkim, 82 | dmarc_report.policy_aspf, 83 | dmarc_report.policy_p, 84 | dmarc_report.policy_sp, 85 | dmarc_report.policy_pct, 86 | dmarc_report.report_id, 87 | dmarc_record.source_ip, 88 | dmarc_record.recordcount, 89 | dmarc_record.policyevaluated_disposition, 90 | dmarc_record.policyevaluated_dkim, 91 | dmarc_record.policyevaluated_spf, 92 | dmarc_record.policyevaluated_reasontype, 93 | dmarc_record.policyevaluated_reasoncomment, 94 | dmarc_record.identifier_headerfrom, 95 | spf_dmarc_result.record_type AS spf_record_type, 96 | spf_dmarc_result.domain AS spf_domain, 97 | spf_dmarc_result.result AS spf_result, 98 | dkim_dmarc_result.record_type AS dkim_record_type, 99 | dkim_dmarc_result.domain AS dkim_domain, 100 | dkim_dmarc_result.result AS dkim_result 101 | FROM dmarc_reporter 102 | INNER JOIN dmarc_report 103 | ON dmarc_report.reporter_id = dmarc_reporter.id 104 | INNER JOIN dmarc_record 105 | ON dmarc_record.report_id = dmarc_report.id 106 | LEFT OUTER JOIN dmarc_result AS spf_dmarc_result 107 | ON spf_dmarc_result.record_id = dmarc_record.id 108 | AND spf_dmarc_result.record_type = 'spf' 109 | LEFT OUTER JOIN dmarc_result AS dkim_dmarc_result 110 | ON dkim_dmarc_result.record_id = dmarc_record.id 111 | AND dkim_dmarc_result.record_type = 'dkim' 112 | """ 113 | 114 | if sql_where: 115 | sql = sql + " WHERE " + "\nAND ".join(sql_where) 116 | 117 | sql_orderby.append('LOWER(dmarc_reporter.org_name)') 118 | sql_orderby.append('dmarc_report.date_begin') 119 | sql_orderby.append('dmarc_record.source_ip') 120 | sql = sql + "\nORDER BY " + ", ".join(sql_orderby) 121 | 122 | cursor = connection.cursor() 123 | cursor.execute(sql, sql_params) 124 | 125 | return cursor 126 | 127 | @staff_member_required 128 | def dmarc_index(request): 129 | 130 | context = { 131 | "reports": 'TODO', 132 | } 133 | return render(request, 'dmarc/report.html', context) 134 | 135 | @staff_member_required 136 | def dmarc_report(request): 137 | report_list = Report.objects.select_related( 138 | 'reporter', 139 | ).prefetch_related( 140 | 'records__results' 141 | ).order_by('-date_begin', 'reporter__org_name').all() 142 | paginator = Paginator(report_list, 2) 143 | 144 | page = request.GET.get('page') 145 | try: 146 | reports = paginator.page(page) 147 | except PageNotAnInteger: 148 | # If page is not an integer, deliver first page. 149 | reports = paginator.page(1) 150 | except EmptyPage: 151 | # If page is out of range (e.g. 9999), deliver last page of results. 152 | reports = paginator.page(paginator.num_pages) 153 | 154 | context = { 155 | "reports": reports, 156 | } 157 | return render(request, 'dmarc/report.html', context) 158 | 159 | @staff_member_required 160 | def dmarc_csv(request): 161 | """Export dmarc data as a csv""" 162 | # Inspired by https://code.djangoproject.com/ticket/21179 163 | def stream(): 164 | """Generator function to yield cursor rows.""" 165 | pseudo_buffer = Echo() 166 | writer = csv.writer(pseudo_buffer) 167 | columns = True 168 | for row in cursor.fetchall(): 169 | data = '' 170 | if columns: 171 | # Write the columns if this is the first row 172 | columns = [col[0] for col in cursor.description] 173 | data = writer.writerow(columns) 174 | columns = False 175 | data = data + writer.writerow(row) 176 | yield data 177 | 178 | dt = datetime.datetime.now() 179 | cd = 'attachment; filename="dmarc-{}.csv"'.format(dt.strftime('%Y-%m-%d-%H%M%S')) 180 | 181 | cursor = _sql_cursor(request.GET) 182 | 183 | response = StreamingHttpResponse(stream(), content_type="text/csv") 184 | response['Content-Disposition'] = cd 185 | 186 | return response 187 | 188 | @staff_member_required 189 | def dmarc_json(request): 190 | """Export dmarc data as json""" 191 | 192 | cursor = _sql_cursor(request.GET) 193 | 194 | data = JsonResponse(cursor.fetchall(), safe=False) 195 | return data 196 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/DjangoDmarc.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/DjangoDmarc.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/DjangoDmarc" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/DjangoDmarc" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/README.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | Documentation 3 | ============= 4 | 5 | **Managing DMARC aggregate and feedback reports** 6 | 7 | Designed to quickly and easily manage DMARC aggregate and feedback reports. 8 | 9 | Description 10 | =========== 11 | 12 | This Django DMARC project aims to ease implementating DMARC 13 | "Domain-based Message Authentication, Reporting & Conformance" and 14 | ongoing monitoring by importing aggregate and feedback reports about messages 15 | that pass and/or fail DMARC evaluation into a more easily digested format. 16 | 17 | Perhaps one of the main reasons DMARC is gaining traction amongst 18 | organisations of all sizes is a desire to protect their people, brand and 19 | reputation. 20 | By defining and implementing a DMARC policy, an organization can help combat 21 | phishing, protect users and their reputation. 22 | 23 | At beta stage, the application is stable, with most efforts on improving 24 | usability and documentation. 25 | 26 | Choosing Django was an easy choice as it offers an easily built import 27 | mechanism and transformation from xml to database through to presentation. 28 | 29 | Although it has options for importing either xml or email files, zero 30 | maintenance is achieved by fully automating import of feedback and reports. 31 | 32 | Quick start 33 | =========== 34 | 35 | 1. Install the app 36 | 37 | 2. Add "dmarc" to your INSTALLED_APPS setting:: 38 | 39 | INSTALLED_APPS = ( 40 | ... 41 | 'dmarc', 42 | ) 43 | 44 | 3. Add dmarc.urls to your urls:: 45 | 46 | from dmarc import urls as dmarc_urls 47 | 48 | urlpatterns = [ 49 | ... 50 | url(r"^dmarc/", include(dmarc_urls)), 51 | ] 52 | 53 | 4. Run 'python manage.py migrate' to create the database models. 54 | 55 | 5. Import a report with:: 56 | 57 | python manage.py importdmarcreport --email 58 | 59 | 6. See your aggregated feedback reports from the Admin page at admin/dmarc 60 | 61 | Usage 62 | ===== 63 | python manage.py importdmarcreport --email 64 | 65 | You can choose to import an xml or email file, alternatively with "--email -" 66 | you can pipe an email and it will do the right thing. 67 | 68 | Installation 69 | ============ 70 | 71 | Install the app 72 | 73 | Configuration 74 | ------------- 75 | 76 | Add "dmarc" to your INSTALLED_APPS setting:: 77 | 78 | INSTALLED_APPS = ( 79 | ... 80 | 'dmarc', 81 | ) 82 | 83 | Add dmarc.urls to your urls:: 84 | 85 | from dmarc import urls as dmarc_urls 86 | 87 | urlpatterns = [ 88 | ... 89 | url(r"^dmarc/", include(dmarc_urls)), 90 | ] 91 | 92 | DMARC reports are namespaced so if you're using django version 1.8 you will 93 | need to add the namespace 'dmarc':: 94 | 95 | urlpatterns = [ 96 | ... 97 | url(r"^dmarc/", include(dmarc_urls, namespace='dmarc')), 98 | ] 99 | 100 | Install tables 101 | -------------- 102 | 103 | Run 'python manage.py migrate' to create the database tables. 104 | 105 | Import feedback report 106 | ---------------------- 107 | 108 | Import an email DMARC aggregate report with:: 109 | 110 | python manage.py importdmarcreport --email 111 | 112 | Alternatively the xml report can be imported with:: 113 | 114 | python manage.py importdmarcreport --xml 115 | 116 | The process of importing DMARC aggregate reports can be fully automated. At 117 | Persistent Objects we use Exim and the configuration couldn't be easier. 118 | 119 | Router:: 120 | 121 | dmarcreports: 122 | driver = accept 123 | condition = ${if eq{$local_part}{dmarc_report}} 124 | transport = trans_dmarcreports 125 | 126 | Transport:: 127 | 128 | trans_dmarcreports: 129 | driver = pipe 130 | command = "/usr/local/bin/python2.7 /path/to/manage.py importdmarcreport --email -" 131 | freeze_exec_fail = true 132 | return_fail_output = true 133 | 134 | Congratulations, you have django-dmarc installed and ready to import DMARC 135 | aggregate feedback reports and start implementing DMARC and protecting your 136 | emails. 137 | 138 | DMARC reporting 139 | =============== 140 | 141 | Aggregated feedback reports are available from the Admin page at admin/dmarc. 142 | 143 | .. image:: https://p-o.co.uk/media/content/dmarc-index.png 144 | :alt: Django Administration showing this DMARC application 145 | 146 | From the DMARC dashboard at 'Site administration/DMARC' where the intention is 147 | to highlight a summary of recent reports, there is one report 'DMARC feedback 148 | reports' and is available to any user with staff members authorization. 149 | 150 | .. image:: https://p-o.co.uk/media/content/dmarc-dashboard.png 151 | :alt: DMARC dashboard 152 | 153 | This is an example report, it can also be downloaded as a csv file suitable 154 | for importing into your favourite spreadsheet. 155 | 156 | .. image:: https://p-o.co.uk/media/content/dmarc-report.png 157 | :alt: Example DMARC aggregate feedback report 158 | 159 | The report can be filtered by clicking on the filter and changing any of the 160 | reporting period, only showing errors/failures, disposition (quarantine, 161 | rejection or any) and by source ip address/reporting organisation. 162 | 163 | .. image:: https://p-o.co.uk/media/content/dmarc-reportfilter.png 164 | :alt: Example DMARC aggregate feedback report 165 | 166 | These reports can help ease any DMARC implementation. 167 | 168 | Maintenance 169 | =========== 170 | 171 | Although there is usually no need to remove old records, access to the report 172 | table is offered to allow for record deletion. 173 | 174 | Dependencies 175 | ============ 176 | 177 | * `Django`_ 2.2+ 178 | 179 | Resources 180 | ========= 181 | 182 | * `DMARC`_ 183 | * `Django`_ 184 | * `Google gmail DMARC`_ 185 | * `Download from PyPI`_ 186 | 187 | Support 188 | ======= 189 | 190 | To report a security issue, please send an email privately to 191 | `ahicks@p-o.co.uk`_. This gives us a chance to fix the issue and 192 | create an official release prior to the issue being made 193 | public. 194 | 195 | For general questions or comments, please contact `ahicks@p-o.co.uk`_. 196 | 197 | `Project website`_ 198 | 199 | Communications are expected to conform to the `Django Code of Conduct`_. 200 | 201 | .. GENERAL LINKS 202 | 203 | .. _`Django`: https://djangoproject.com/ 204 | .. _`Django Code of Conduct`: https://www.djangoproject.com/conduct/ 205 | .. _`Python`: https://python.org/ 206 | .. _`Persistent Objects Ltd`: https://p-o.co.uk/ 207 | .. _`Project website`: https://p-o.co.uk/tech-articles/django-dmarc/ 208 | .. _`DMARC`: https://dmarc.org/ 209 | .. _`Google gmail DMARC`: https://support.google.com/a/answer/2466580 210 | .. _`Download from PyPI`: https://pypi.org/project/django-dmarc/ 211 | 212 | .. PEOPLE WITH QUOTES 213 | 214 | .. _`Alan Hicks`: https://twitter.com/AlanHicksLondon 215 | .. _`ahicks@p-o.co.uk`: mailto:ahicks@p-o.co.uk?subject=django-dmarc+Security+Issue 216 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | =========== 2 | Change Log 3 | =========== 4 | 5 | **Managing DMARC aggregate and feedback reports** 6 | 7 | Designed to quickly and easily manage DMARC aggregate and feedback reports. 8 | 9 | Change Log 10 | ========== 11 | 12 | .. include:: ../CHANGELOG 13 | 14 | Copyright 15 | ========= 16 | 17 | Django DMARC and this documentation is 18 | Copyright (c) 2015-2021, Persistent Objects Ltd. 19 | All rights reserved. 20 | 21 | License 22 | ======= 23 | 24 | This documentation is licensed under the Creative Commons Attribution 4.0 25 | International License. To view a copy of this license, visit 26 | http://creativecommons.org/licenses/by/4.0/. 27 | 28 | .. include:: ../LICENSE 29 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Django DMARC documentation build configuration file, created by 4 | # sphinx-quickstart on Mon May 12 11:13:16 2014. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | 18 | # If extensions (or modules to document with autodoc) are in another directory, 19 | # add these directories to sys.path here. If the directory is relative to the 20 | # documentation root, use os.path.abspath to make it absolute, like shown here. 21 | ##sys.path.insert(0, os.path.abspath('.')) 22 | 23 | # -- General configuration ------------------------------------------------ 24 | 25 | # If your documentation needs a minimal Sphinx version, state it here. 26 | #needs_sphinx = '1.0' 27 | 28 | # Add any Sphinx extension module names here, as strings. They can be 29 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 30 | # ones. 31 | extensions = [ 32 | 'sphinx.ext.autodoc', 33 | 'sphinx.ext.doctest', 34 | 'sphinx.ext.todo', 35 | 'sphinx.ext.coverage', 36 | ] 37 | 38 | # Add any paths that contain templates here, relative to this directory. 39 | templates_path = ['_templates'] 40 | 41 | # The suffix of source filenames. 42 | source_suffix = '.rst' 43 | 44 | # The encoding of source files. 45 | #source_encoding = 'utf-8-sig' 46 | 47 | # The master toctree document. 48 | master_doc = 'index' 49 | 50 | # General information about the project. 51 | project = u'Django DMARC' 52 | copyright = u'2015-2021, Persistent Objects Ltd' 53 | 54 | # The version info for the project you're documenting, acts as replacement for 55 | # |version| and |release|, also used in various other places throughout the 56 | # built documents. 57 | # 58 | # The short X.Y version. 59 | version = '1.0' 60 | # The full version, including alpha/beta/rc tags. 61 | release = '1.0.1' 62 | 63 | # The language for content autogenerated by Sphinx. Refer to documentation 64 | # for a list of supported languages. 65 | #language = None 66 | 67 | # There are two options for replacing |today|: either, you set today to some 68 | # non-false value, then it is used: 69 | #today = '' 70 | # Else, today_fmt is used as the format for a strftime call. 71 | #today_fmt = '%B %d, %Y' 72 | 73 | # List of patterns, relative to source directory, that match files and 74 | # directories to ignore when looking for source files. 75 | exclude_patterns = ['_build'] 76 | 77 | # The reST default role (used for this markup: `text`) to use for all 78 | # documents. 79 | #default_role = None 80 | 81 | # If true, '()' will be appended to :func: etc. cross-reference text. 82 | #add_function_parentheses = True 83 | 84 | # If true, the current module name will be prepended to all description 85 | # unit titles (such as .. function::). 86 | #add_module_names = True 87 | 88 | # If true, sectionauthor and moduleauthor directives will be shown in the 89 | # output. They are ignored by default. 90 | #show_authors = False 91 | 92 | # The name of the Pygments (syntax highlighting) style to use. 93 | pygments_style = 'sphinx' 94 | 95 | # A list of ignored prefixes for module index sorting. 96 | #modindex_common_prefix = [] 97 | 98 | # If true, keep warnings as "system message" paragraphs in the built documents. 99 | #keep_warnings = False 100 | 101 | 102 | # -- Options for HTML output ---------------------------------------------- 103 | 104 | # The theme to use for HTML and HTML Help pages. See the documentation for 105 | # a list of builtin themes. 106 | html_theme = 'default' 107 | 108 | # Theme options are theme-specific and customize the look and feel of a theme 109 | # further. For a list of options available for each theme, see the 110 | # documentation. 111 | #html_theme_options = {} 112 | 113 | # Add any paths that contain custom themes here, relative to this directory. 114 | #html_theme_path = [] 115 | 116 | # The name for this set of Sphinx documents. If None, it defaults to 117 | # " v documentation". 118 | #html_title = None 119 | 120 | # A shorter title for the navigation bar. Default is the same as html_title. 121 | #html_short_title = None 122 | 123 | # The name of an image file (relative to this directory) to place at the top 124 | # of the sidebar. 125 | #html_logo = None 126 | 127 | # The name of an image file (within the static path) to use as favicon of the 128 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 129 | # pixels large. 130 | #html_favicon = None 131 | 132 | # Add any paths that contain custom static files (such as style sheets) here, 133 | # relative to this directory. They are copied after the builtin static files, 134 | # so a file named "default.css" will overwrite the builtin "default.css". 135 | html_static_path = ['_static'] 136 | 137 | # Add any extra paths that contain custom files (such as robots.txt or 138 | # .htaccess) here, relative to this directory. These files are copied 139 | # directly to the root of the documentation. 140 | #html_extra_path = [] 141 | 142 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 143 | # using the given strftime format. 144 | #html_last_updated_fmt = '%b %d, %Y' 145 | 146 | # If true, SmartyPants will be used to convert quotes and dashes to 147 | # typographically correct entities. 148 | #html_use_smartypants = True 149 | 150 | # Custom sidebar templates, maps document names to template names. 151 | #html_sidebars = {} 152 | 153 | # Additional templates that should be rendered to pages, maps page names to 154 | # template names. 155 | #html_additional_pages = {} 156 | 157 | # If false, no module index is generated. 158 | #html_domain_indices = True 159 | 160 | # If false, no index is generated. 161 | #html_use_index = True 162 | 163 | # If true, the index is split into individual pages for each letter. 164 | #html_split_index = False 165 | 166 | # If true, links to the reST sources are added to the pages. 167 | #html_show_sourcelink = True 168 | 169 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 170 | #html_show_sphinx = True 171 | 172 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 173 | #html_show_copyright = True 174 | 175 | # If true, an OpenSearch description file will be output, and all pages will 176 | # contain a tag referring to it. The value of this option must be the 177 | # base URL from which the finished HTML is served. 178 | #html_use_opensearch = '' 179 | 180 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 181 | #html_file_suffix = None 182 | 183 | # Output file base name for HTML help builder. 184 | htmlhelp_basename = 'DjangoDmarcdoc' 185 | 186 | 187 | # -- Options for LaTeX output --------------------------------------------- 188 | 189 | latex_elements = { 190 | # The paper size ('letterpaper' or 'a4paper'). 191 | #'papersize': 'letterpaper', 192 | 193 | # The font size ('10pt', '11pt' or '12pt'). 194 | #'pointsize': '10pt', 195 | 196 | # Additional stuff for the LaTeX preamble. 197 | #'preamble': '', 198 | } 199 | 200 | # Grouping the document tree into LaTeX files. List of tuples 201 | # (source start file, target name, title, 202 | # author, documentclass [howto, manual, or own class]). 203 | latex_documents = [ 204 | ('index', 'DjangoDmarcdoc.tex', u'Django Dmarc Documentation', 205 | u'Alan Hicks', 'manual'), 206 | ] 207 | 208 | # The name of an image file (relative to this directory) to place at the top of 209 | # the title page. 210 | #latex_logo = None 211 | 212 | # For "manual" documents, if this is true, then toplevel headings are parts, 213 | # not chapters. 214 | #latex_use_parts = False 215 | 216 | # If true, show page references after internal links. 217 | #latex_show_pagerefs = False 218 | 219 | # If true, show URL addresses after external links. 220 | #latex_show_urls = False 221 | 222 | # Documents to append as an appendix to all manuals. 223 | #latex_appendices = [] 224 | 225 | # If false, no module index is generated. 226 | #latex_domain_indices = True 227 | 228 | 229 | # -- Options for manual page output --------------------------------------- 230 | 231 | # One entry per manual page. List of tuples 232 | # (source start file, name, description, authors, manual section). 233 | man_pages = [ 234 | ('index', 'django_dmarc', u'Django DMARC Documentation', 235 | [u'Alan Hicks'], 1) 236 | ] 237 | 238 | # If true, show URL addresses after external links. 239 | #man_show_urls = False 240 | 241 | 242 | # -- Options for Texinfo output ------------------------------------------- 243 | 244 | # Grouping the document tree into Texinfo files. List of tuples 245 | # (source start file, target name, title, author, 246 | # dir menu entry, description, category) 247 | texinfo_documents = [ 248 | ('index', 'django-dmarc', u'Django DMARC Documentation', 249 | u'Alan Hicks', 'django-dmarc', 250 | 'Making it easier to manage DMARC reports', 251 | 'Miscellaneous'), 252 | ] 253 | 254 | # Documents to append as an appendix to all manuals. 255 | #texinfo_appendices = [] 256 | 257 | # If false, no module index is generated. 258 | #texinfo_domain_indices = True 259 | 260 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 261 | #texinfo_show_urls = 'footnote' 262 | 263 | # If true, do not generate a @detailmenu in the "Top" node's menu. 264 | #texinfo_no_detailmenu = False 265 | -------------------------------------------------------------------------------- /docs/images/dmarc-dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alan-hicks/django-dmarc/c5ad9911f7c3698eeb6ea424fb9e494170e6e96b/docs/images/dmarc-dashboard.png -------------------------------------------------------------------------------- /docs/images/dmarc-index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alan-hicks/django-dmarc/c5ad9911f7c3698eeb6ea424fb9e494170e6e96b/docs/images/dmarc-index.png -------------------------------------------------------------------------------- /docs/images/dmarc-report.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alan-hicks/django-dmarc/c5ad9911f7c3698eeb6ea424fb9e494170e6e96b/docs/images/dmarc-report.png -------------------------------------------------------------------------------- /docs/images/dmarc-reportfilter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alan-hicks/django-dmarc/c5ad9911f7c3698eeb6ea424fb9e494170e6e96b/docs/images/dmarc-reportfilter.png -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Django DMARC documentation master file, created by 2 | sphinx-quickstart on Mon May 12 11:13:16 2014. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | ============ 7 | Django DMARC 8 | ============ 9 | 10 | **Managing DMARC aggregate and feedback reports** 11 | 12 | Designed to quickly and easily manage DMARC aggregate and feedback reports. 13 | 14 | Contents 15 | ======== 16 | 17 | .. toctree:: 18 | :maxdepth: 1 19 | 20 | README 21 | changelog 22 | 23 | Description 24 | =========== 25 | 26 | This Django DMARC project aims to ease implementating DMARC 27 | "Domain-based Message Authentication, Reporting & Conformance" and 28 | ongoing monitoring by importing aggregate and feedback reports about messages 29 | that pass and/or fail DMARC evaluation into a more easily digested format. 30 | 31 | Perhaps one of the main reasons DMARC is gaining traction amongst 32 | organisations of all sizes is a desire to protect their people, brand and 33 | reputation. 34 | By defining and implementing a DMARC policy, an organization can help combat 35 | phishing, protect users and their reputation. 36 | 37 | This project is stable, with most efforts on improving usability and 38 | documentation. 39 | 40 | Choosing Django was an easy choice as it offers an easily built import 41 | mechanism and transformation from xml to database through to presentation. 42 | 43 | Although there are options for importing either xml or email files, zero 44 | maintenance is achieved by fully automating import of feedback and reports. 45 | 46 | Copyright 47 | ========= 48 | 49 | Django DMARC and this documentation is 50 | Copyright (c) 2015-2021, Persistent Objects Ltd. 51 | All rights reserved. 52 | 53 | Contributors 54 | ============ 55 | 56 | This list is not complete and not in any useful order, but I would 57 | like to thank everybody who contributed in any way, with code, hints, 58 | bug reports, ideas, moral support, endorsement, or even complaints... 59 | You have made django-dmarc what it is today. 60 | 61 | | Thank you, 62 | | Alan Hicks 63 | 64 | .. include:: ../AUTHORS 65 | 66 | License 67 | ======= 68 | 69 | This documentation is licensed under the Creative Commons Attribution 4.0 70 | International License. To view a copy of this license, visit 71 | https://creativecommons.org/licenses/by/4.0/. 72 | 73 | The software is licensed under the BSD two clause license. 74 | 75 | .. include:: ../LICENSE 76 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Managing DMARC aggregate and feedback reports 2 | """ 3 | from setuptools import setup 4 | from codecs import open 5 | from os import path 6 | 7 | here = path.abspath(path.dirname(__file__)) 8 | # Get the long description from the README file 9 | with open(path.join(here, 'README.rst'), encoding='utf-8') as f: 10 | long_description = f.read() 11 | 12 | setup( 13 | name='django-dmarc', 14 | version='1.0.1', 15 | packages=['dmarc'], 16 | include_package_data=True, 17 | license='BSD', 18 | description='Managing DMARC aggregate and feedback reports', 19 | long_description=long_description, 20 | long_description_content_type='text/x-rst', 21 | url='https://github.com/alan-hicks/django-dmarc', 22 | project_urls={ 23 | "About": "https://p-o.co.uk/tech-articles/django-dmarc/", 24 | }, 25 | download_url='https://pypi.python.org/pypi/django-dmarc', 26 | author='Alan Hicks', 27 | author_email='ahicks@p-o.co.uk', 28 | requires=['django'], 29 | classifiers=[ 30 | 'Development Status :: 5 - Production/Stable', 31 | 'Environment :: Web Environment', 32 | 'Framework :: Django', 33 | 'Framework :: Django :: 2.2', 34 | 'Intended Audience :: Developers', 35 | 'License :: OSI Approved :: BSD License', 36 | 'Natural Language :: English', 37 | 'Operating System :: OS Independent', 38 | 'Programming Language :: Python :: 3', 39 | 'Programming Language :: Python :: 3.7', 40 | 'Programming Language :: Python :: 3.8', 41 | 'Topic :: Internet :: WWW/HTTP', 42 | 'Topic :: Office/Business', 43 | 'Topic :: Software Development :: Libraries :: Python Modules', 44 | ], 45 | keywords='dmarc email spf dkim', 46 | ) 47 | --------------------------------------------------------------------------------