PKxMddmailspoof/__init__.py"""Scans SPF and DMARC records for issues that could allow email spoofing.""" from .scanners import SPFScan, DMARCScan, Scan __version__ = '0.1.1' def get_args(): import argparse parser = argparse.ArgumentParser(prog='mailspoof', description='scans SPF and DMARC records for issues that could allow ' 'email spoofing') parser.add_argument('-o', '--output', type=str, default='-', help='json output file, default is stdout') parser.add_argument('-d', '--domain', type=str, action='append', help='a target domain to check, can be passed multiple times') parser.add_argument('-iL', '--input-list', type=str, help='list of domains to check') args = parser.parse_args() args.domains = [] if args.input_list: with open(args.input_list) as fh: args.domains += fh.read().splitlines() if args.domain: args.domains += args.domain return args def main(): import json args = get_args() spoof_check = Scan() results = [] for domain in args.domains: results.append({ 'domain': domain, 'issues': spoof_check(domain) }) if args.output == '-': print(json.dumps(results, indent=2)) else: with open(args.output, 'w+') as fh: print(json.dumps(results, indent=2), file=fh) PKTM~>mailspoof/__main__.pyfrom . import main main() PKCMIg g mailspoof/issues.py""" Contains a dictionary potential SPF and DMARC issues to be used as template to populate the main module's outputs. """ ISSUES = { 'NX_DOMAIN': { 'code': 0, 'title': 'Non-existent domain', 'detail': 'The DNS resolver raised an NXDomain error for \'{domain}\'' }, 'NO_SPF': { 'code': 1, 'title': 'No SPF', 'detail': 'There is no SPF DNS record for the domain.' }, 'SPF_NO_ALL': { 'code': 2, 'title': 'No \'all\' mechanism', 'detail': 'There is no all mechanism in the record. It may be possible' ' to spoof the domain without causing an SPF failure.' }, 'SPF_PASS_ALL': { 'code': 3, 'title': '\'Pass\' qualifer for \'all\' mechanism', 'detail': 'The \'all\' mechanism uses the \'Pass\' qualifer \'+\'. ' 'It should be possible to spoof the domain without causing ' 'an SPF failure.' }, 'SPF_SOFT_FAIL_ALL': { 'code': 4, 'title': '\'SoftFail\' qualifer for \'all\' mechanism', 'detail': 'The \'all\' mechanism uses the \'SoftFail\' qualifer \'~\'.' ' It should be possible to spoof the domain by only causing ' 'a soft SPF failure. Most filters will let this through by ' 'only raising the total spam score.' }, 'SPF_LOOKUP_ERROR': { 'code': 5, 'title': 'Too many lookups for SPF validation', 'detail': 'The SPF record requires more than 10 DNS lookups for the ' 'validation process. The RFC states that maximum 10 lookups ' 'are allowed. As a result, recipients may throw a PermError ' 'instead of proceeding with SPF validation. Recipients will ' 'treat these errors differently than a hard or soft SPF fail' ' , and some will continue processing the mail.' }, 'SPF_UNREGISTERED_DOMAINS': { 'code': 6, 'title': 'Unregistered domains in SPF validation chain', 'detail': 'One or more domains used in the SPF validation process are ' 'presently unregistered. An attacker could register these ' 'and configure his own SPF record to be included in the ' 'validation logic. The affected domains are: {domains}' }, 'NO_DMARC': { 'code': 1, 'title': 'No DMARC', 'detail': 'There is no DMARC DNS record associated for the domain.' }, 'DMARC_LAX_POLICY': { 'code': 7, 'title': 'Lax DMARC policy', 'detail': 'The DMARC policy is set to \'{policy}\'. If the DMARC ' 'policy is neither \'reject\' nor \'quarantine\', spoofed ' 'emails are likely to be accepted.' }, 'DMARC_LAX_SUBDOMAIN_POLICY': { 'code': 8, 'title': 'Lax DMARC subdomain policy', 'detail': 'The DMARC policy for subdomains is set to \'{policy}\'. If ' 'the DMARC policy is neither \'reject\' nor \'quarantine\', ' 'spoofed emails from subdomains are likely to be accepted.' }, 'DMARC_NOT_100_PCT': { 'code': 9, 'title': 'Partial DMARC coverage', 'detail': 'The DMARC \'pct\' value is \'{pct}\', meaning the DMARC ' 'policy will only be applied to {pct}% of incoming mail.' }, } PK%Mَs33mailspoof/scanners.py""" `mailspoof` provides callable classes for checking SPF and DMARC records for common issues. """ import os import re import dns.resolver import tldextract import requests from .issues import ISSUES WHOAPI_URL = 'https://api.whoapi.com/?domain={domain}&r=taken&apikey={key}' if 'WHOAPI_KEY' in os.environ: WHOAPI_KEY = os.environ['WHOAPI_KEY'] else: WHOAPI_KEY = None class SPFScan(): """ A callable for extracting SPF security fails for a domain. Returns an SPFResult """ def __init__(self, whoapi_key=None): self.fetch = TXTFetch('v=spf1 ') self.whoapi_key = whoapi_key def __call__(self, domain): """ Returns a list of dictionaries ("issues") highlighting security concerns with the SPF record. """ try: spf_record = self.fetch(domain) except ValueError: return [ISSUES['NO_SPF']] except dns.resolver.NXDOMAIN: issue = dict(ISSUES['NX_DOMAIN']) issue['detail'] = issue['detail'].format(domain=domain) return [issue] issues = [] terms = spf_record.split(' ') # check the 'all' mechanism all_qualifier = None all_match = re.match(r'^([-?~+])all$', terms[-1]) if all_match: all_qualifier = all_match.group(1) if not all_qualifier: issues.append(ISSUES['SPF_NO_ALL']) elif all_qualifier == '+': issues.append(ISSUES['SPF_PASS_ALL']) elif all_qualifier == '~': issues.append(ISSUES['SPF_SOFT_FAIL_ALL']) # recursively count the number of lookups and get the domains used included_domains, nb_lookups = self._get_include_domains(domain) if nb_lookups > 10: issues.append(ISSUES['SPF_LOOKUP_ERROR']) # check for any free domains free_domains = set() if self.whoapi_key: for included_domain in included_domains: if not self._domain_taken(included_domain): free_domains.add(included_domain) if free_domains: issue = dict(ISSUES['SPF_UNREGISTERED_DOMAINS']) issue['detail'] = issue['detail'].format(domains=', '.join( list(free_domains))) issues.append(issue) return issues def _get_include_domains(self, domain): """ Recursively goes through the domain's SPF record and included SPF records. Returns a tuple of the root domains encountered and Recursively count the number of DNS lookups needed for a recipient to validate the SPF record """ domains = set() nb_lookups = 0 def _recurse(domain): nonlocal nb_lookups nonlocal domains try: spf_record = self.fetch(domain) except ValueError: return except dns.resolver.NXDOMAIN: return terms = spf_record.split(' ') includes = [] for term in terms: if ':' not in term: continue mechanism, value = term.split(':', 1) if mechanism == 'include': nb_lookups += 1 includes.append(value) domains.add(self._get_registered_domain(value)) elif mechanism in ['a', 'mx']: nb_lookups += 1 domains.add(self._get_registered_domain(value)) elif mechanism in ['ptr', 'exists', 'redirect']: nb_lookups += 1 for include in includes: _recurse(include) _recurse(domain) return domains, nb_lookups def _domain_taken(self, domain): """ Returns True if the domain is already registered. False means the domain is open for registration and could be registered by an attacker. """ response = requests.get(WHOAPI_URL.format(domain=domain, key=self.whoapi_key)) data = response.json() if data['status'] != '0': raise Exception(data['status_desc']) return True if data['taken'] else False @staticmethod def _get_registered_domain(domain): """ Returns the "registered domain" from a given (sub)domain. >>> _get_registered_domain('foo.bar.com') bar.com """ parsed_domain = tldextract.extract(domain) return '.'.join([parsed_domain.domain, parsed_domain.suffix]) class DMARCScan(): """ Callable that return a list of dictionaries ("issues") highlighting security concerns with the DMARC record. """ def __init__(self): self.fetch = TXTFetch('v=DMARC1; ') def __call__(self, domain): """ Returns a list of Issues highlighting potential security issues with the DMARC record. """ dmarc_domain = f'_dmarc.{domain}' try: dmarc_record = self.fetch(dmarc_domain) except ValueError: return [ISSUES['NO_DMARC']] except dns.resolver.NXDOMAIN: return [ISSUES['NO_DMARC']] issues = [] terms = [term.strip(' ') for term in dmarc_record.split(';')] for term in terms: if '=' not in term: continue tag, value = term.split('=') if tag == 'p' and value not in ['quarantine', 'reject']: issue = dict(ISSUES['DMARC_LAX_POLICY']) issue['detail'] = issue['detail'].format(policy=value) issues.append(issue) elif tag == 'sp' and value not in ['quarantine', 'reject']: # default for 'sp' if not present is the same as 'p' issue = dict(ISSUES['DMARC_LAX_SUBDOMAIN_POLICY']) issue['detail'] = issue['detail'].format(policy=value) issues.append(issue) elif tag == 'pct' and int(value) < 100: # default for 'pct' if not present is '100' issue = dict(ISSUES['DMARC_NOT_100_PCT']) issue['detail'] = issue['detail'].format(pct=value) issues.append(issue) return issues class TXTFetch(): """ A callable for fetching a DNS TXT record with a certain prefix for a given domain. """ def __init__(self, txt_prefix, timeout=5, lifetime=5): # txt_prefix should be `v=DMARC1; ` or `v=spf1 ` self.txt_prefix = txt_prefix self.resolver = dns.resolver.Resolver() self.resolver.timeout = timeout self.resolver.lifetime = lifetime def __call__(self, domain): """ Fetches a DNS TXT record with a certain prefix for a given domain. """ txt_records = self.resolver.query(domain, 'TXT') for txt_record in txt_records: value = str(txt_record).strip('"') if value.startswith(self.txt_prefix): return value raise ValueError(f'No record with prefix {self.txt_prefix} for domain ' '{domain}') class Scan(): """ Callable that return a list of dictionaries ("issues") highlighting security concerns with the SPF and DMARC records. """ def __init__(self): self.spf_check = SPFScan(WHOAPI_KEY) self.dmarc_check = DMARCScan() def __call__(self, domain): """ Returns a list of Issues highlighting potential security issues with the SPF and DMARC records. """ return self.spf_check(domain) + self.dmarc_check(domain) PK!H%,*mailspoof-0.1.1.dist-info/entry_points.txtN+I/N.,()M).O<..PK9Mkn99!mailspoof-0.1.1.dist-info/LICENSEThe MIT License (MIT) Copyright (c) 2018 Alex Kaskasoli Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. PK!Hd BUcmailspoof-0.1.1.dist-info/WHEEL HM K-*ϳR03rOK-J,/RH,rzd&Y)r$[)T&UD"PK!HJ?"mailspoof-0.1.1.dist-info/METADATAen0E8ԍ%FЪ& z±SסE+y3sϕ#fox`&Žth,o8Ca8+t iXn401#4~Z46 œ(J{VyN򈜞48պڼWb냂"!KQseZ6 fo()nP 6 8[,v1@- GqNesHwHN1`Gf&gFW ]XlSщx*;PK!H mailspoof-0.1.1.dist-info/RECORD}=@| #( ˈlB" :L:鷺 wm{ â)H.y_ϒ=`XQ} ħ]$ry87qh)zxf8_UGaV!@sq#UYyϨ4 s];KZ}E6`y,cHgu)sI4}F<5>jy`.xqM[,_Q4hf\`BͥLr®-d$?#SjX [z-fQ?;,ӎ#xR1BJ)MUpELS,r6cEbΑ(oT z` <701]n硴b< rodp:d?d{wMQPKxMddmailspoof/__init__.pyPKTM~>mailspoof/__main__.pyPKCMIg g mailspoof/issues.pyPK%Mَs33}mailspoof/scanners.pyPK!H%,*1mailspoof-0.1.1.dist-info/entry_points.txtPK9Mkn99!P2mailspoof-0.1.1.dist-info/LICENSEPK!Hd BUc6mailspoof-0.1.1.dist-info/WHEELPK!HJ?"Z7mailspoof-0.1.1.dist-info/METADATAPK!H 8mailspoof-0.1.1.dist-info/RECORDPK :