├── emailprotectionslib ├── __init__.py ├── Resolver.py ├── dmarc.py └── spf.py ├── tests ├── __init__.py ├── test_dmarc.py └── test_spf.py ├── requirements.txt ├── .travis.yml ├── MANIFEST ├── README.md ├── setup.py └── .gitignore /emailprotectionslib/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'alex' 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | dnslib 2 | tldextract 3 | future -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | 5 | install: "pip install -r requirements.txt" 6 | script: nosetests -------------------------------------------------------------------------------- /MANIFEST: -------------------------------------------------------------------------------- 1 | # file GENERATED by distutils, do NOT edit 2 | setup.py 3 | emailprotectionslib/Resolver.py 4 | emailprotectionslib/__init__.py 5 | emailprotectionslib/dmarc.py 6 | emailprotectionslib/spf.py 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `pyemailprotectionslib` 2 | [![Build Status](https://travis-ci.org/lunarca/pyemailprotectionslib.svg?branch=master)](https://travis-ci.org/lunarca/pyemailprotectionslib) 3 | 4 | This is a simple library designed to assist people with finding email protections. 5 | 6 | ## Usage 7 | 8 | The simplest use of this library is to find and process SPF and DMARC records for domains. This is easiest with the `SpfRecord.from_domain(domain)` and `DmarcRecord.from_domain(domain)` factory methods. 9 | 10 | Example: 11 | 12 | import emailprotectionslib.spf as spf 13 | import emailprotectionslib.dmarc as dmarc 14 | 15 | spf_record = spf.SpfRecord.from_domain("google.com") 16 | dmarc_record = dmarc.DmarcRecord.from_domain("google.com") 17 | 18 | print spf_record.record 19 | print dmarc_record.policy -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | 3 | setup( 4 | name="emailprotectionslib", 5 | packages=["emailprotectionslib"], 6 | version="0.8.4", 7 | description="Python library to interact with SPF and DMARC", 8 | author="Alex DeFreese", 9 | author_email="alexdefreese@gmail.com", 10 | url="https://github.com/lunarca/pyemailprotectionslib", 11 | requires=['dnslib', 'tldextract', 'future'], 12 | classifiers=[ 13 | "Development Status :: 1 - Planning", 14 | "Environment :: Plugins", 15 | "Intended Audience :: Developers", 16 | "Operating System :: OS Independent", 17 | "Programming Language :: Python", 18 | "Programming Language :: Python :: 2", 19 | "Programming Language :: Python :: 3", 20 | "License :: OSI Approved :: MIT License", 21 | ] 22 | ) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | 47 | # Translations 48 | *.mo 49 | *.pot 50 | 51 | # Django stuff: 52 | *.log 53 | 54 | # Sphinx documentation 55 | docs/_build/ 56 | 57 | # PyBuilder 58 | target/ 59 | 60 | # PyCharm 61 | .idea/ -------------------------------------------------------------------------------- /tests/test_dmarc.py: -------------------------------------------------------------------------------- 1 | import emailprotectionslib.dmarc as dmarclib 2 | 3 | 4 | def test_find_record_from_answers_valid(): 5 | dmarc_string = '"v=DMARC1; p=reject; rua=mailto:mailauth-reports@google.com"' 6 | dmarc_string_without_quotes = 'v=DMARC1; p=reject; rua=mailto:mailauth-reports@google.com' 7 | txt_records = [("google.com", "txt", dmarc_string), 8 | ("google.com", "txt", "asdf"), 9 | ("google.com", "txt", "not dmarc")] 10 | 11 | assert dmarclib._find_record_from_answers(txt_records) == dmarc_string_without_quotes 12 | 13 | 14 | def test_find_record_from_answers_invalid(): 15 | txt_records = ["asdf", "another", "yetanother"] 16 | 17 | assert dmarclib._find_record_from_answers(txt_records) is None 18 | 19 | 20 | def test_match_dmarc_record(): 21 | valid_dmarc_string = ('"v=DMARC1; p=quarantine; rua=mailto:' 22 | 'mailauth-reports@google.com"') 23 | 24 | assert dmarclib._match_dmarc_record(valid_dmarc_string) is not None 25 | 26 | 27 | def test_match_dmarc_record_invalid(): 28 | invalid_dmarc = ('"vMARC1; p=quarantine; rua=mailto:' 29 | 'mailauth-reports@google.com"') 30 | assert dmarclib._match_dmarc_record(invalid_dmarc) is None 31 | 32 | 33 | def test_extract_tags_pass(): 34 | dmarc_string = ("v=DMARC1; p=quarantine; rua=mailto:" 35 | "mailauth-reports@google.com") 36 | dmarc_tags = [("v", "DMARC1"), ("p", "quarantine"), 37 | ("rua", "mailto:mailauth-reports@google.com")] 38 | assert dmarclib._extract_tags(dmarc_string) == dmarc_tags 39 | 40 | 41 | def test_from_dmarc_record_pass(): 42 | dmarc_string = "v=DMARC1; p=quarantine" 43 | dmarc_record = dmarclib.DmarcRecord("google.com") 44 | dmarc_record.version = "DMARC1" 45 | dmarc_record.policy = "quarantine" 46 | dmarc_record.domain = "google.com" 47 | dmarc_record.record = dmarc_string 48 | assert dmarclib.DmarcRecord.from_dmarc_string(dmarc_string, "google.com") == dmarc_record 49 | 50 | 51 | def test_from_domain_pass(): 52 | assert dmarclib.DmarcRecord.from_domain("google.com") is not None 53 | 54 | 55 | def test_record_strength_quarantine(): 56 | dmarc_string = ("v=DMARC1; p=quarantine; rua=mailto:" 57 | "mailauth-reports@google.com") 58 | record = dmarclib.DmarcRecord.from_dmarc_string(dmarc_string, "google.com") 59 | 60 | assert record.is_record_strong() is True 61 | 62 | 63 | def test_record_strength_none(): 64 | dmarc_string = ("v=DMARC1; p=none; rua=mailto:" 65 | "mailauth-reports@google.com") 66 | record = dmarclib.DmarcRecord.from_dmarc_string(dmarc_string ,"google.com") 67 | 68 | assert record.is_record_strong() is False 69 | 70 | 71 | def test_record_strength_reject(): 72 | dmarc_string = ("v=DMARC1; p=reject; rua=mailto:" 73 | "mailauth-reports@google.com") 74 | record = dmarclib.DmarcRecord.from_dmarc_string(dmarc_string, "google.com") 75 | 76 | assert record.is_record_strong() is True 77 | 78 | 79 | def test_record_strength_no_policy(): 80 | dmarc_string = ("v=DMARC1; rua=mailto:" 81 | "mailauth-reports@google.com") 82 | record = dmarclib.DmarcRecord.from_dmarc_string(dmarc_string, "google.com") 83 | 84 | assert record.is_record_strong() is False 85 | -------------------------------------------------------------------------------- /tests/test_spf.py: -------------------------------------------------------------------------------- 1 | import emailprotectionslib.spf as spflib 2 | 3 | 4 | def test_find_record_from_answers_valid(): 5 | spf_string = '"v=spf1 include:_spf.google.com ~all"' 6 | spf_string_noquotes = "v=spf1 include:_spf.google.com ~all" 7 | txt_records = [("google.com", "txt", spf_string), 8 | ("google.com", "txt", "asdf"), ("google.com", "txt", "not dmarc")] 9 | 10 | assert spflib._find_record_from_answers(txt_records) == spf_string_noquotes 11 | 12 | 13 | def test_find_record_from_answers_invalid(): 14 | txt_records = ["asdf", "another", "yetanother"] 15 | 16 | assert spflib._find_record_from_answers(txt_records) is None 17 | 18 | 19 | def test_match_spf_record(): 20 | valid_spf_string = '"v=spf1 include:_spf.google.com ~all"' 21 | assert spflib._match_spf_record(valid_spf_string) is not None 22 | 23 | 24 | def test_match_spf_record_invalid(): 25 | invalid_spf = "vsf1 include:_spf.google.com ~all" 26 | assert spflib._match_spf_record(invalid_spf) is None 27 | 28 | 29 | def test_extract_all_mechanism(): 30 | mechanisms = ["a", "mx", "~all"] 31 | assert str(spflib._extract_all_mechanism(mechanisms)) == "~all" 32 | 33 | 34 | def test_extract_version(): 35 | spf_string = "v=spf1 include:_spf.google.com ~all" 36 | assert spflib._extract_version(spf_string) == "spf1" 37 | 38 | 39 | def test_extract_mechanisms(): 40 | spf_string = "v=spf1 include:_spf.google.com mx ~all" 41 | mechanisms = ["include:_spf.google.com", "mx", "~all"] 42 | assert spflib._extract_mechanisms(spf_string) == mechanisms 43 | 44 | 45 | def test_from_spf_string(): 46 | spf_string = "v=spf1 include:_spf.google.com mx ~all" 47 | spf_record = spflib.SpfRecord("google.com") 48 | spf_record.all_string = "~all" 49 | spf_record.version = "spf1" 50 | spf_record.domain = "google.com" 51 | spf_record.record = spf_string 52 | spf_record.mechanisms = ["include:_spf.google.com", 53 | "mx", 54 | "~all", 55 | ] 56 | assert spflib.SpfRecord.from_spf_string(spf_string, "google.com") == \ 57 | spf_record 58 | 59 | 60 | def test_get_redirect_domain(): 61 | spf_string = "v=spf1 redirect=_spf.google.com" 62 | spf_record = spflib.SpfRecord.from_spf_string(spf_string, "google.com") 63 | assert spf_record.get_redirect_domain() == "_spf.google.com" 64 | 65 | 66 | def test_get_include_domains(): 67 | spf_string = "v=spf1 include:_spf.google.com include:nonexistentdomain.com" 68 | spf_record = spflib.SpfRecord.from_spf_string(spf_string, "google.com") 69 | assert spf_record.get_include_domains() == ["_spf.google.com", "nonexistentdomain.com"] 70 | 71 | 72 | def test_from_domain_pass(): 73 | assert spflib.SpfRecord.from_domain("google.com") is not None 74 | 75 | 76 | def test_is_all_mechanism_strong(): 77 | spf_string = "v=spf1 include:_spf.google.com mx ~all" 78 | spf_record = spflib.SpfRecord.from_spf_string(spf_string, "google.com") 79 | assert spf_record._is_all_mechanism_strong() is True 80 | 81 | 82 | def test_is_all_mechanism_strong_fail(): 83 | spf_string = "v=spf1 include:_spf.google.com mx" 84 | spf_record = spflib.SpfRecord.from_spf_string(spf_string, "google.com") 85 | assert spf_record._is_all_mechanism_strong() is False 86 | 87 | 88 | def test_no_mechanisms_include_domains(): 89 | spf_string = "v=spf1" 90 | spf_record = spflib.SpfRecord.from_spf_string(spf_string, "google.com") 91 | assert spf_record.get_include_domains() == [] 92 | 93 | 94 | def test_no_mechanisms_redirect_domains(): 95 | spf_string = "v=spf1" 96 | spf_record = spflib.SpfRecord.from_spf_string(spf_string, "google.com") 97 | assert spf_record.get_redirect_domain() is None 98 | -------------------------------------------------------------------------------- /emailprotectionslib/Resolver.py: -------------------------------------------------------------------------------- 1 | from builtins import str 2 | from builtins import object 3 | 4 | import dnslib 5 | import sys 6 | 7 | #A resolver wrapper around dnslib.py 8 | # stolen wholesale from https://github.com/TheRook/subbrute 9 | # thanks Rook 10 | class resolver(object): 11 | #Google's DNS servers are only used if zero resolvers are specified by the user. 12 | pos = 0 13 | rcode = "" 14 | wildcards = {} 15 | failed_code = False 16 | last_resolver = "" 17 | 18 | def __init__(self, nameservers = ['8.8.8.8','8.8.4.4']): 19 | self.nameservers = nameservers 20 | 21 | def query(self, hostname, query_type = 'ANY', name_server = False, use_tcp = True): 22 | ret = [] 23 | response = None 24 | if name_server == False: 25 | name_server = self.get_ns() 26 | else: 27 | self.wildcards = {} 28 | self.failed_code = None 29 | self.last_resolver = name_server 30 | query = dnslib.DNSRecord.question(hostname, query_type.upper().strip()) 31 | try: 32 | response_q = query.send(name_server, 53, use_tcp) 33 | if response_q: 34 | response = dnslib.DNSRecord.parse(response_q) 35 | else: 36 | raise IOError("Empty Response") 37 | except Exception as e: 38 | #IOErrors are all conditions that require a retry. 39 | raise IOError(str(e)) 40 | if response: 41 | self.rcode = dnslib.RCODE[response.header.rcode] 42 | for r in response.rr: 43 | try: 44 | rtype = str(dnslib.QTYPE[r.rtype]) 45 | except:#Server sent an unknown type: 46 | rtype = str(r.rtype) 47 | #Fully qualified domains may cause problems for other tools that use subbrute's output. 48 | rhost = str(r.rname).rstrip(".") 49 | ret.append((rhost, rtype, str(r.rdata))) 50 | #What kind of response did we get? 51 | if self.rcode not in ['NOERROR', 'NXDOMAIN', 'SERVFAIL', 'REFUSED']: 52 | trace('!Odd error code:', self.rcode, hostname, query_type) 53 | #Is this a perm error? We will have to retry to find out. 54 | if self.rcode in ['SERVFAIL', 'REFUSED', 'FORMERR', 'NOTIMP', 'NOTAUTH']: 55 | raise IOError('DNS Failure: ' + hostname + " - " + self.rcode) 56 | #Did we get an empty body and a non-error code? 57 | elif not len(ret) and self.rcode != "NXDOMAIN": 58 | raise IOError("DNS Error - " + self.rcode + " - for:" + hostname) 59 | return ret 60 | 61 | def was_successful(self): 62 | ret = False 63 | if self.failed_code and self.rcode != self.failed_code: 64 | ret = True 65 | elif self.rcode == 'NOERROR': 66 | ret = True 67 | return ret 68 | 69 | def get_returncode(self): 70 | return self.rcode 71 | 72 | def get_ns(self): 73 | if self.pos >= len(self.nameservers): 74 | self.pos = 0 75 | ret = self.nameservers[self.pos] 76 | # we may have metadata on how this resolver fails 77 | try: 78 | ret, self.wildcards, self.failed_code = ret 79 | except: 80 | self.wildcards = {} 81 | self.failed_code = None 82 | self.pos += 1 83 | return ret 84 | 85 | def add_ns(self, resolver): 86 | if resolver: 87 | self.nameservers.append(resolver) 88 | 89 | def get_authoritative(self, hostname): 90 | ret = [] 91 | while not ret and hostname.count(".") >= 1: 92 | try: 93 | trace("Looking for nameservers:", hostname) 94 | nameservers = self.query(hostname, 'NS') 95 | except IOError:#lookup failed. 96 | nameservers = [] 97 | for n in nameservers: 98 | #A DNS server could return anything. 99 | rhost, record_type, record = n 100 | if record_type == "NS": 101 | #Return all A records for this NS lookup. 102 | a_lookup = self.query(record.rstrip("."), 'A') 103 | for a_host, a_type, a_record in a_lookup: 104 | ret.append(a_record) 105 | #If a nameserver wasn't found try the parent of this sub. 106 | hostname = hostname[hostname.find(".") + 1:] 107 | return ret 108 | 109 | def get_last_resolver(self): 110 | return self.last_resolver 111 | 112 | #Toggle debug output 113 | verbose = False 114 | def trace(*args, **kwargs): 115 | if verbose: 116 | for a in args: 117 | sys.stderr.write(str(a)) 118 | sys.stderr.write(" ") 119 | sys.stderr.write("\n") 120 | -------------------------------------------------------------------------------- /emailprotectionslib/dmarc.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from builtins import str 3 | from builtins import object 4 | 5 | import re 6 | import logging 7 | from . import Resolver 8 | import tldextract 9 | 10 | 11 | class DmarcRecord(object): 12 | 13 | def __init__(self, domain): 14 | self.domain = domain 15 | self.version = None 16 | self.policy = None 17 | self.pct = None 18 | self.rua = None 19 | self.ruf = None 20 | self.subdomain_policy = None 21 | self.dkim_alignment = None 22 | self.spf_alignment = None 23 | self.record = None 24 | 25 | def __str__(self): 26 | return self.record 27 | 28 | def __eq__(self, other): 29 | return self.__dict__ == other.__dict__ 30 | 31 | def _store_tag_data(self, tag_name, tag_value): 32 | if tag_name == "v": 33 | self.version = tag_value 34 | elif tag_name == "p": 35 | self.policy = tag_value 36 | elif tag_name == "pct": 37 | self.pct = tag_value 38 | elif tag_name == "rua": 39 | self.rua = tag_value 40 | elif tag_name == "ruf": 41 | self.ruf = tag_value 42 | elif tag_name == "sp": 43 | self.subdomain_policy = tag_value 44 | elif tag_name == "adkim": 45 | self.dkim_alignment = tag_value 46 | elif tag_name == "aspf": 47 | self.spf_alignment = tag_value 48 | 49 | def process_tags(self, dmarc_string): 50 | TAG_NAME, TAG_VALUE = (0, 1) 51 | tags = _extract_tags(dmarc_string) 52 | for tag in tags: 53 | self._store_tag_data(tag[TAG_NAME], tag[TAG_VALUE]) 54 | 55 | def is_record_strong(self): 56 | record_strong = False 57 | if self.policy is not None and (self.policy == "reject" or self.policy == "quarantine"): 58 | record_strong = True 59 | 60 | if not record_strong: 61 | try: 62 | record_strong = self.is_org_domain_strong() 63 | except OrgDomainException: 64 | record_strong = False 65 | 66 | return record_strong 67 | 68 | def is_subdomain_policy_strong(self): 69 | if self.subdomain_policy is not None: 70 | return self.subdomain_policy == "reject" or self.subdomain_policy == "quarantine" 71 | 72 | def is_org_domain_strong(self): 73 | org_record = self.get_org_record() 74 | subdomain_policy_strong = org_record.is_subdomain_policy_strong() 75 | if subdomain_policy_strong is not None: 76 | return subdomain_policy_strong 77 | else: 78 | return org_record.is_record_strong() 79 | 80 | def get_org_record(self): 81 | org_domain = self.get_org_domain() 82 | if org_domain == self.domain: 83 | raise OrgDomainException 84 | else: 85 | return DmarcRecord.from_domain(org_domain) 86 | 87 | def get_org_domain(self): 88 | try: 89 | domain_parts = tldextract.extract(self.domain) 90 | return "%(domain)s.%(tld)s" % {'domain': domain_parts.domain, 'tld': domain_parts.suffix} 91 | except TypeError: 92 | return None 93 | 94 | @staticmethod 95 | def from_dmarc_string(dmarc_string, domain): 96 | if dmarc_string is not None: 97 | dmarc_record = DmarcRecord(domain) 98 | dmarc_record.record = dmarc_string 99 | dmarc_record.process_tags(dmarc_string) 100 | return dmarc_record 101 | else: 102 | return DmarcRecord(domain) 103 | 104 | @staticmethod 105 | def from_domain(domain): 106 | dmarc_string = get_dmarc_string_for_domain(domain) 107 | if dmarc_string is not None: 108 | return DmarcRecord.from_dmarc_string(dmarc_string, domain) 109 | else: 110 | return DmarcRecord(domain) 111 | 112 | 113 | def _extract_tags(dmarc_record): 114 | dmarc_pattern = "(\w+)=(.*?)(?:; ?|$)" 115 | return re.findall(dmarc_pattern, dmarc_record) 116 | 117 | 118 | def _merge_txt_record_strings(txt_record): 119 | # DMARC spec requires that TXT records containing multiple strings be cat'd together. 120 | string_pattern = re.compile('"([^"]*)"') 121 | txt_record_strings = string_pattern.findall(txt_record) 122 | return "".join(txt_record_strings) 123 | 124 | 125 | def _match_dmarc_record(txt_record): 126 | merged_txt_record = _merge_txt_record_strings(txt_record) 127 | dmarc_pattern = re.compile('^(v=DMARC.*)') 128 | potential_dmarc_match = dmarc_pattern.match(str(merged_txt_record)) 129 | return potential_dmarc_match 130 | 131 | 132 | def _find_record_from_answers(txt_records): 133 | dmarc_record = None 134 | for record in txt_records: 135 | potential_match = _match_dmarc_record(record[2]) 136 | if potential_match is not None: 137 | dmarc_record = potential_match.group(1) 138 | return dmarc_record 139 | 140 | 141 | def get_dmarc_string_for_domain(domain): 142 | try: 143 | txt_records = Resolver.resolver().query("_dmarc." + domain, query_type="TXT") 144 | return _find_record_from_answers(txt_records) 145 | except IOError: 146 | # This is returned usually as a NXDOMAIN, which is expected. 147 | return None 148 | except TypeError as error: 149 | logging.exception(error) 150 | return None 151 | 152 | 153 | class OrgDomainException(Exception): 154 | pass 155 | -------------------------------------------------------------------------------- /emailprotectionslib/spf.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from builtins import str 3 | from builtins import object 4 | 5 | import re 6 | import logging 7 | from . import Resolver 8 | 9 | 10 | class SpfRecord(object): 11 | 12 | def __init__(self, domain): 13 | self.version = None 14 | self.record = None 15 | self.mechanisms = None 16 | self.all_string = None 17 | self.domain = domain 18 | self.recursion_depth = 0 19 | 20 | def __str__(self): 21 | return self.record 22 | 23 | def __eq__(self, other): 24 | return self.__dict__ == other.__dict__ 25 | 26 | def get_redirected_record(self): 27 | if self.recursion_depth >= 10: 28 | return SpfRecord(self.get_redirect_domain()) 29 | else: 30 | redirect_domain = self.get_redirect_domain() 31 | if redirect_domain is not None: 32 | redirect_record = SpfRecord.from_domain(redirect_domain) 33 | redirect_record.recursion_depth = self.recursion_depth + 1 34 | return redirect_record 35 | 36 | def get_redirect_domain(self): 37 | redirect_domain = None 38 | if self.mechanisms is not None: 39 | for mechanism in self.mechanisms: 40 | redirect_mechanism = re.match('redirect=(.*)', mechanism) 41 | if redirect_mechanism is not None: 42 | redirect_domain = redirect_mechanism.group(1) 43 | return redirect_domain 44 | 45 | def get_include_domains(self): 46 | include_domains = [] 47 | if self.mechanisms is not None: 48 | for mechanism in self.mechanisms: 49 | include_mechanism = re.match('include:(.*)', mechanism) 50 | if include_mechanism is not None: 51 | include_domains.append(include_mechanism.group(1)) 52 | return include_domains 53 | else: 54 | return [] 55 | 56 | def get_include_records(self): 57 | if self.recursion_depth >= 10: 58 | return {} 59 | else: 60 | include_domains = self.get_include_domains() 61 | include_records = {} 62 | for domain in include_domains: 63 | try: 64 | include_records[domain] = SpfRecord.from_domain(domain) 65 | include_records[domain].recursion_depth = self.recursion_depth + 1 66 | except IOError as e: 67 | logging.exception(e) 68 | include_records[domain] = None 69 | return include_records 70 | 71 | def _is_all_mechanism_strong(self): 72 | strong_spf_all_string = True 73 | if self.all_string is not None: 74 | if not (self.all_string == "~all" or self.all_string == "-all"): 75 | strong_spf_all_string = False 76 | else: 77 | strong_spf_all_string = False 78 | return strong_spf_all_string 79 | 80 | def _is_redirect_mechanism_strong(self): 81 | redirect_domain = self.get_redirect_domain() 82 | 83 | if redirect_domain is not None: 84 | redirect_mechanism = SpfRecord.from_domain(redirect_domain) 85 | 86 | if redirect_mechanism is not None: 87 | return redirect_mechanism.is_record_strong() 88 | else: 89 | return False 90 | else: 91 | return False 92 | 93 | def _are_include_mechanisms_strong(self): 94 | include_records = self.get_include_records() 95 | for record in include_records: 96 | if include_records[record] is not None and include_records[record].is_record_strong(): 97 | return True 98 | return False 99 | 100 | def is_record_strong(self): 101 | strong_spf_record = self._is_all_mechanism_strong() 102 | if strong_spf_record is False: 103 | 104 | redirect_strength = self._is_redirect_mechanism_strong() 105 | include_strength = self._are_include_mechanisms_strong() 106 | 107 | strong_spf_record = False 108 | 109 | if redirect_strength is True: 110 | strong_spf_record = True 111 | 112 | if include_strength is True: 113 | strong_spf_record = True 114 | return strong_spf_record 115 | 116 | @staticmethod 117 | def from_spf_string(spf_string, domain): 118 | if spf_string is not None: 119 | spf_record = SpfRecord(domain) 120 | spf_record.record = spf_string 121 | spf_record.mechanisms = _extract_mechanisms(spf_string) 122 | spf_record.version = _extract_version(spf_string) 123 | spf_record.all_string = _extract_all_mechanism(spf_record.mechanisms) 124 | return spf_record 125 | else: 126 | return SpfRecord(domain) 127 | 128 | @staticmethod 129 | def from_domain(domain): 130 | spf_string = get_spf_string_for_domain(domain) 131 | if spf_string is not None: 132 | return SpfRecord.from_spf_string(spf_string, domain) 133 | else: 134 | return SpfRecord(domain) 135 | 136 | 137 | def _extract_version(spf_string): 138 | version_pattern = "^v=(spf.)" 139 | version_match = re.match(version_pattern, spf_string) 140 | if version_match is not None: 141 | return version_match.group(1) 142 | else: 143 | return None 144 | 145 | 146 | def _extract_all_mechanism(mechanisms): 147 | all_mechanism = None 148 | for mechanism in mechanisms: 149 | if re.match(".all", mechanism): 150 | all_mechanism = mechanism 151 | return all_mechanism 152 | 153 | 154 | def _find_unique_mechanisms(initial_mechanisms, redirected_mechanisms): 155 | return [x for x in redirected_mechanisms if x not in initial_mechanisms] 156 | 157 | 158 | def _extract_mechanisms(spf_string): 159 | spf_mechanism_pattern = ("(?:((?:\+|-|~)?(?:a|mx|ptr|include" 160 | "|ip4|ip6|exists|redirect|exp|all)" 161 | "(?:(?::|=|/)?(?:\S*))?) ?)") 162 | spf_mechanisms = re.findall(spf_mechanism_pattern, spf_string) 163 | 164 | return spf_mechanisms 165 | 166 | 167 | def _merge_txt_record_strings(txt_record): 168 | # SPF spec requires that TXT records containing multiple strings be cat'd together. 169 | string_pattern = re.compile('"([^"]*)"') 170 | txt_record_strings = string_pattern.findall(txt_record) 171 | return "".join(txt_record_strings) 172 | 173 | 174 | def _match_spf_record(txt_record): 175 | clean_txt_record = _merge_txt_record_strings(txt_record) 176 | spf_pattern = re.compile('^(v=spf.*)') 177 | potential_spf_match = spf_pattern.match(str(clean_txt_record)) 178 | return potential_spf_match 179 | 180 | 181 | def _find_record_from_answers(txt_records): 182 | spf_record = None 183 | for record in txt_records: 184 | potential_match = _match_spf_record(record[2]) 185 | if potential_match is not None: 186 | spf_record = potential_match.group(1) 187 | return spf_record 188 | 189 | 190 | def get_spf_string_for_domain(domain): 191 | try: 192 | txt_records = Resolver.resolver().query(domain, query_type="TXT") 193 | return _find_record_from_answers(txt_records) 194 | except IOError as e: 195 | # This is returned usually as a NXDOMAIN, which is expected. 196 | return None 197 | --------------------------------------------------------------------------------