From 6fc508c91688073577df4f5e519ab48e5197413c Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Sat, 10 Aug 2024 19:24:27 -0700 Subject: [PATCH 01/36] remove library format --- libs/__init__.py | 0 libs/bimi.py | 46 ------------------ libs/dmarc.py | 81 -------------------------------- libs/dns.py | 58 ----------------------- libs/logic.py | 83 --------------------------------- libs/report.py | 119 ----------------------------------------------- libs/spf.py | 49 ------------------- 7 files changed, 436 deletions(-) delete mode 100644 libs/__init__.py delete mode 100755 libs/bimi.py delete mode 100644 libs/dmarc.py delete mode 100755 libs/dns.py delete mode 100755 libs/logic.py delete mode 100755 libs/report.py delete mode 100644 libs/spf.py diff --git a/libs/__init__.py b/libs/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/libs/bimi.py b/libs/bimi.py deleted file mode 100755 index 59b53e6..0000000 --- a/libs/bimi.py +++ /dev/null @@ -1,46 +0,0 @@ -import dns.resolver - - -def get_bimi_record(domain, dns_server): - """Returns the BIMI record for a given domain.""" - try: - resolver = dns.resolver.Resolver() - resolver.nameservers = [dns_server, '1.1.1.1', '8.8.8.8'] - query_result = resolver.resolve('default._bimi.' + domain, 'TXT') - for record in query_result: - if 'v=BIMI' in str(record): - return record - return None - except: - return None - - -def get_bimi_details(bimi_record): - """Returns a tuple containing policy, pct, aspf, subdomain policy, - forensic report uri, and aggregate report uri from a BIMI record""" - version = get_bimi_version(bimi_record) - location = get_bimi_location(bimi_record) - authority = get_bimi_authority(bimi_record) - return version, location, authority - - -def get_bimi_version(bimi_record): - """Returns the version value from a BIMI record.""" - if "v=" in str(bimi_record): - return str(bimi_record).split("v=")[1].split(";")[0] - else: - return None - -def get_bimi_location(bimi_record): - """Returns the location value from a BIMI record.""" - if "l=" in str(bimi_record): - return str(bimi_record).split("l=")[1].split(";")[0] - else: - return None - -def get_bimi_authority(bimi_record): - """Returns the authority value from a BIMI record.""" - if "a=" in str(bimi_record): - return str(bimi_record).split("a=")[1].split(";")[0] - else: - return None \ No newline at end of file diff --git a/libs/dmarc.py b/libs/dmarc.py deleted file mode 100644 index b9e8937..0000000 --- a/libs/dmarc.py +++ /dev/null @@ -1,81 +0,0 @@ -import dns.resolver -import tldextract - - -def get_dmarc_record(domain, dns_server): - """Returns the DMARC record for a given domain.""" - subdomain = tldextract.extract(domain).registered_domain - if subdomain != domain: - return get_dmarc_record(subdomain, dns_server) - try: - resolver = dns.resolver.Resolver() - resolver.nameservers = [dns_server] - dmarc = resolver.resolve(f"_dmarc.{domain}", "TXT") - except: - return None - - for dns_data in dmarc: - if "DMARC1" in str(dns_data): - dmarc_record = str(dns_data).replace('"', '') - return dmarc_record - return None - - -def get_dmarc_details(dmarc_record): - """Returns a tuple containing policy, pct, aspf, subdomain policy, - forensic report uri, and aggregate report uri from a DMARC record""" - p = get_dmarc_policy(dmarc_record) - pct = get_dmarc_pct(dmarc_record) - aspf = get_dmarc_aspf(dmarc_record) - sp = get_dmarc_subdomain_policy(dmarc_record) - fo = get_dmarc_forensic_reports(dmarc_record) - rua = get_dmarc_aggregate_reports(dmarc_record) - return p, pct, aspf, sp, fo, rua - - -def get_dmarc_policy(dmarc_record): - """Returns the policy value from a DMARC record.""" - if "p=" in str(dmarc_record): - return str(dmarc_record).split("p=")[1].split(";")[0] - else: - return None - - -def get_dmarc_pct(dmarc_record): - """Returns the pct value from a DMARC record.""" - if "pct=" in str(dmarc_record): - return str(dmarc_record).split("pct=")[1].split(";")[0] - else: - return None - - -def get_dmarc_aspf(dmarc_record): - """Returns the aspf value from a DMARC record""" - if "aspf=" in str(dmarc_record): - return str(dmarc_record).split("aspf=")[1].split(";")[0] - else: - return None - - -def get_dmarc_subdomain_policy(dmarc_record): - """Returns the policy to apply for subdomains from a DMARC record.""" - if "sp=" in str(dmarc_record): - return str(dmarc_record).split("sp=")[1].split(";")[0] - else: - return None - - -def get_dmarc_forensic_reports(dmarc_record): - """Returns the email addresses to which forensic reports should be sent.""" - if "ruf=" in str(dmarc_record) and "fo=1" in str(dmarc_record): - return str(dmarc_record).split("ruf=")[1].split(";")[0] - else: - return None - - -def get_dmarc_aggregate_reports(dmarc_record): - """Returns the email addresses to which aggregate reports should be sent.""" - if "rua=" in str(dmarc_record): - return str(dmarc_record).split("rua=")[1].split(";")[0] - else: - return None diff --git a/libs/dns.py b/libs/dns.py deleted file mode 100755 index 0fe823f..0000000 --- a/libs/dns.py +++ /dev/null @@ -1,58 +0,0 @@ -import dns.resolver -import socket -from . import spf, dmarc, bimi - - -def get_soa_record(domain): - """Returns the SOA record of a given domain.""" - resolver = dns.resolver.Resolver() - resolver.nameservers = ['1.1.1.1'] - try: - query = resolver.resolve(domain, 'SOA') - except: - return None - if query: - for data in query: - dns_server = str(data.mname) - try: - return socket.gethostbyname(dns_server) - except: - return None - return None - - -def get_dns_server(domain): - """Finds the DNS server that serves the domain and returns it, along with any SPF or DMARC records.""" - SOA = get_soa_record(domain) - spf_record = dmarc_record = partial_spf_record = partial_dmarc_record = bimi_record = None - - if SOA: - spf_record = spf.get_spf_record(domain, SOA) - dmarc_record = dmarc.get_dmarc_record(domain, SOA) - bimi_record = bimi.get_bimi_record(domain, SOA) - if spf_record and dmarc_record: - return SOA, spf_record, dmarc_record, bimi_record - - for ip_address in ['1.1.1.1', '8.8.8.8', '9.9.9.9']: - spf_record = spf.get_spf_record(domain, ip_address) - dmarc_record = dmarc.get_dmarc_record(domain, ip_address) - bimi_record = bimi.get_bimi_record(domain, SOA) - if spf_record and dmarc_record: - return ip_address, spf_record, dmarc_record, bimi_record - if spf_record: - partial_spf_record = spf_record - if dmarc_record: - partial_dmarc_record = dmarc_record - - return '1.1.1.1', partial_spf_record, partial_dmarc_record, bimi_record - - -def get_txt_record(domain, record_type): - """Returns the TXT record of a given type for a given domain.""" - resolver = dns.resolver.Resolver() - resolver.nameservers = [get_dns_server(domain)] - try: - query = resolver.query(domain, record_type) - return str(query[0]) - except: - return None diff --git a/libs/logic.py b/libs/logic.py deleted file mode 100755 index 43e6812..0000000 --- a/libs/logic.py +++ /dev/null @@ -1,83 +0,0 @@ -def is_spoofable(domain, p, aspf, spf_record, spf_all, spf_includes, sp, pct): - """This function takes in DMARC and SPF data for a domain, as well as subdomain policy and percentage options, - and determines if the domain is vulnerable to email spoofing. The function returns an integer value indicating - the class of vulnerability. - ID Handler: - 0: Indicates that spoofing is possible for the domain. - 1: Indicates that subdomain spoofing is possible for the domain. - 2: Indicates that organizational domain spoofing is possible for the domain. - 3: Indicates that spoofing might be possible for the domain. - 4: Indicates that spoofing might be possible (mailbox dependent) for the domain. - 5: Indicates that organizational domain spoofing may be possible for the domain. - 6: Indicates that subdomain spoofing might be possible (mailbox dependent) for the domain. - 7: Indicates that subdomain spoofing is possible, and organizational domain spoofing might be possible. - 8: Indicates that spoofing is not possible for the domain. - """ - try: - if pct and int(pct) != 100: - return 3 - elif spf_record is None: - if p is None: - return 0 - else: - return 8 - elif spf_includes > 10 and p is None: - return 0 - elif spf_all == "2many": - if p == "none": - return 3 - else: - return 8 - elif spf_all and p is None: - return 0 - elif spf_all == "-all": - if p and aspf and sp == "none": - return 1 - elif aspf is None and sp == "none": - return 1 - elif p == "none" and (aspf == "r" or aspf is None) and sp is None: - return 4 - elif p == "none" and aspf == "r" and (sp == "reject" or sp == "quarentine"): - return 2 - elif p == "none" and aspf is None and (sp == "reject" or sp == "quarentine"): - return 5 - elif p == "none" and aspf is None and sp == "none": - return 7 - else: - return 8 - elif spf_all == "~all": - if p == "none" and sp == "reject" or sp == "quarentine": - return 2 - elif p == "none" and sp is None: - return 0 - elif p == "none" and sp == "none": - return 7 - elif (p == "reject" or p == "quarentine") and aspf is None and sp == "none": - return 1 - elif (p == "reject" or p == "quarentine") and aspf and sp == "none": - return 1 - else: - return 8 - elif spf_all == "?all": - if (p == "reject" or p == "quarentine") and aspf and sp == "none": - return 6 - elif (p == "reject" or p == "quarentine") and aspf is None and sp == "none": - return 6 - elif p == "none" and aspf == "r" and sp is None: - return 0 - elif p == "none" and aspf == "r" and sp == "none": - return 7 - elif p == "none" and aspf == "s" or None and sp == "none": - return 7 - elif p == "none" and aspf == "s" or None and sp is None: - return 6 - elif p == "none" and aspf and (sp == "reject" or sp == "quarentine"): - return 5 - elif p == "none" and aspf is None and sp == "reject": - return 5 - else: - return 8 - else: - return 8 - except: - print("If you hit this error message, Open an issue with your testcase.") diff --git a/libs/report.py b/libs/report.py deleted file mode 100755 index d88d17c..0000000 --- a/libs/report.py +++ /dev/null @@ -1,119 +0,0 @@ -from colorama import Fore, Style -from colorama import init as color_init -import os -import pandas as pd - -color_init() - - -def output_good(line): - print(Fore.GREEN + Style.BRIGHT + "[+]" + Style.RESET_ALL, line) - - -def output_warning(line): - print(Fore.YELLOW + Style.BRIGHT + "[?]" + Style.RESET_ALL, line) - - -def output_bad(line): - print(Fore.RED + Style.BRIGHT + "[-]" + Style.RESET_ALL, line) - - -def output_indifferent(line): - print(Fore.BLUE + Style.BRIGHT + "[*]" + Style.RESET_ALL, line) - - -def output_error(line): - print(Fore.RED + Style.BRIGHT + "[-] !!! " + - Style.NORMAL, line, Style.BRIGHT + "!!!\n") - - -def output_info(line): - print(Fore.WHITE + Style.BRIGHT + "[*]" + Style.RESET_ALL, line) - - -def write_to_excel(data): - """This function writes a DataFrame of data to an Excel file. - If the file does not already exist, it creates the file, and if the file already exists, it appends the new data to the existing data.""" - file_name = "report.xlsx" - if not os.path.exists(file_name): - open(file_name, 'w').close() - - if os.path.getsize(file_name) > 0: - existing_df = pd.read_excel(file_name) - new_df = pd.DataFrame(data) - combined_df = pd.concat([existing_df, new_df]) - combined_df.to_excel(file_name, index=False) - else: - df = pd.DataFrame(data) - df.to_excel(file_name, index=False) - - -def printer(domain, subdomain, dns_server, spf_record, spf_all, spf_includes, dmarc_record, p, pct, aspf, sp, fo, rua, bimi_record, vbimi, location, authority, spoofable): - """This function is a utility function that takes in various parameters related to the - results of DMARC and SPF checks and outputs the results to the console in a human-readable format. - - Printer ID Handler: - 0: Indicates that spoofing is possible for the domain. - 1: Indicates that subdomain spoofing is possible for the domain. - 2: Indicates that organizational domain spoofing is possible for the domain. - 3: Indicates that spoofing might be possible for the domain. - 4: Indicates that spoofing might be possible (mailbox dependent) for the domain. - 5: Indicates that organizational domain spoofing may be possible for the domain. - 6: Indicates that subdomain spoofing might be possible (mailbox dependent) for the domain. - 7: Indicates that subdomain spoofing is possible, and organizational domain spoofing might be possible. - 8: Indicates that spoofing is not possible for the domain. - """ - output_indifferent(f"Domain: {domain}") - output_indifferent(f"Is subdomain: {subdomain}") - output_indifferent(f"DNS Server: {dns_server}") - - if spf_record: - output_info(f"SPF record: {spf_record}") - if spf_all is None: - output_info("SPF does not contain an `All` items.") - elif spf_all == "2many": - output_warning("SPF record contains multiple `All` items.") - else: - output_info(f"SPF all record: {spf_all}") - output_info(f"SPF include count: {spf_includes}" if spf_includes <= - 10 else f"Too many SPF include lookups {spf_includes}.") - else: - output_warning("No SPF record found.") - - if dmarc_record: - output_info(f"DMARC record: {dmarc_record}") - output_info( - f"Found DMARC policy: {p}" if p else "No DMARC policy found.") - output_info( - f"Found DMARC pct: {pct}" if pct else "No DMARC pct found.") - output_info( - f"Found DMARC aspf: {aspf}" if aspf else "No DMARC aspf found.") - output_info( - f"Found DMARC subdomain policy: {sp}" if sp else "No DMARC subdomain policy found.") - output_indifferent( - f"Forensics reports will be sent: {fo}" if fo else "No DMARC forensics report location found.") - output_indifferent( - f"Aggregate reports will be sent to: {rua}" if rua else "No DMARC aggregate report location found.") - else: - output_warning("No DMARC record found.") - - if(bimi_record): - output_info(f"BIMI record : {bimi_record}") - output_info(f"BIMI version : {vbimi}") - output_info(f"BIMI location : {location}") - output_info(f"BIMI authority : {authority}") - - if spoofable in [0, 1, 2, 3, 4, 5, 6, 7, 8]: - if spoofable == 8: - output_bad("Spoofing not possible for " + domain) - else: - output_good("Spoofing possible for " + domain - if spoofable == 0 else "Subdomain spoofing possible for " + domain - if spoofable == 1 else "Organizational domain spoofing possible for " + domain - if spoofable == 2 else "Spoofing might be possible for " + domain - if spoofable == 3 else "Spoofing might be possible (Mailbox dependant) for " + domain - if spoofable == 4 else "Organizational domain spoofing may be possible for " + domain - if spoofable == 5 else "Subdomain spoofing might be possible (Mailbox dependant) for " + domain - if spoofable == 6 else "Subdomain spoofing might be possible (Mailbox dependant) for " + domain - if spoofable == 7 else "") - print() # padding diff --git a/libs/spf.py b/libs/spf.py deleted file mode 100644 index c8923ef..0000000 --- a/libs/spf.py +++ /dev/null @@ -1,49 +0,0 @@ -import re -import dns.resolver - - -def get_spf_record(domain, dns_server): - """Returns the SPF record for a given domain.""" - try: - resolver = dns.resolver.Resolver() - resolver.nameservers = [dns_server, '1.1.1.1', '8.8.8.8'] - query_result = resolver.resolve(domain, 'TXT') - for record in query_result: - if 'spf1' in str(record): - spf_record = str(record).replace('"', '') - return spf_record - return None - except: - return None - - -def get_spf_all_string(spf_record): - """Returns the string value of the all mechanism in the SPF record.""" - all_matches = re.findall(r'[-~?] ?a ?l ?l', spf_record) - if len(all_matches) == 1: - return all_matches[0] - elif len(all_matches) > 1: - return '2many' - else: - return None - - -def get_spf_includes(domain, count=0): - """Returns the number of includes in the SPF record for a given domain.""" - if count > 10: - return count - try: - spf_record = get_spf_record(domain, '1.1.1.1') - if spf_record: - count += len(re.compile("[ ,+]a[ ,:]").findall(spf_record)) - count += len(re.compile("[ ,+]mx[ ,:]").findall(spf_record)) - count += len(re.compile("[ ]ptr[ ]").findall(spf_record)) - count += len(re.compile("exists[:]").findall(spf_record)) - for item in spf_record.split(' '): - if "include:" in item: - url = item.replace('include:', '') - count = get_spf_includes(url, count + 1) - except: - pass - # print("Could not find SPF record for " + domain) - return count From 5f15988224dadfff0996ac36c06327bb5ef040e2 Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Sat, 10 Aug 2024 19:24:44 -0700 Subject: [PATCH 02/36] implement modules --- modules/__init__.py | 1 + modules/bimi.py | 45 +++++++++++++++++ modules/dmarc.py | 82 ++++++++++++++++++++++++++++++ modules/dns.py | 59 ++++++++++++++++++++++ modules/logic.py | 84 +++++++++++++++++++++++++++++++ modules/report.py | 120 ++++++++++++++++++++++++++++++++++++++++++++ modules/spf.py | 50 ++++++++++++++++++ spoofy.py | 3 +- test.py | 2 +- 9 files changed, 443 insertions(+), 3 deletions(-) create mode 100644 modules/__init__.py create mode 100755 modules/bimi.py create mode 100644 modules/dmarc.py create mode 100755 modules/dns.py create mode 100755 modules/logic.py create mode 100755 modules/report.py create mode 100644 modules/spf.py diff --git a/modules/__init__.py b/modules/__init__.py new file mode 100644 index 0000000..5a3656f --- /dev/null +++ b/modules/__init__.py @@ -0,0 +1 @@ +# modules/__init__.py \ No newline at end of file diff --git a/modules/bimi.py b/modules/bimi.py new file mode 100755 index 0000000..b7e9a6c --- /dev/null +++ b/modules/bimi.py @@ -0,0 +1,45 @@ +# modules/bimi.py +import dns.resolver +def get_bimi_record(domain, dns_server): + """Returns the BIMI record for a given domain.""" + try: + resolver = dns.resolver.Resolver() + resolver.nameservers = [dns_server, '1.1.1.1', '8.8.8.8'] + query_result = resolver.resolve('default._bimi.' + domain, 'TXT') + for record in query_result: + if 'v=BIMI' in str(record): + return record + return None + except: + return None + + +def get_bimi_details(bimi_record): + """Returns a tuple containing policy, pct, aspf, subdomain policy, + forensic report uri, and aggregate report uri from a BIMI record""" + version = get_bimi_version(bimi_record) + location = get_bimi_location(bimi_record) + authority = get_bimi_authority(bimi_record) + return version, location, authority + + +def get_bimi_version(bimi_record): + """Returns the version value from a BIMI record.""" + if "v=" in str(bimi_record): + return str(bimi_record).split("v=")[1].split(";")[0] + else: + return None + +def get_bimi_location(bimi_record): + """Returns the location value from a BIMI record.""" + if "l=" in str(bimi_record): + return str(bimi_record).split("l=")[1].split(";")[0] + else: + return None + +def get_bimi_authority(bimi_record): + """Returns the authority value from a BIMI record.""" + if "a=" in str(bimi_record): + return str(bimi_record).split("a=")[1].split(";")[0] + else: + return None \ No newline at end of file diff --git a/modules/dmarc.py b/modules/dmarc.py new file mode 100644 index 0000000..997e34d --- /dev/null +++ b/modules/dmarc.py @@ -0,0 +1,82 @@ +# modules/dmarc.py +import dns.resolver +import tldextract + + +def get_dmarc_record(domain, dns_server): + """Returns the DMARC record for a given domain.""" + subdomain = tldextract.extract(domain).registered_domain + if subdomain != domain: + return get_dmarc_record(subdomain, dns_server) + try: + resolver = dns.resolver.Resolver() + resolver.nameservers = [dns_server] + dmarc = resolver.resolve(f"_dmarc.{domain}", "TXT") + except: + return None + + for dns_data in dmarc: + if "DMARC1" in str(dns_data): + dmarc_record = str(dns_data).replace('"', '') + return dmarc_record + return None + + +def get_dmarc_details(dmarc_record): + """Returns a tuple containing policy, pct, aspf, subdomain policy, + forensic report uri, and aggregate report uri from a DMARC record""" + p = get_dmarc_policy(dmarc_record) + pct = get_dmarc_pct(dmarc_record) + aspf = get_dmarc_aspf(dmarc_record) + sp = get_dmarc_subdomain_policy(dmarc_record) + fo = get_dmarc_forensic_reports(dmarc_record) + rua = get_dmarc_aggregate_reports(dmarc_record) + return p, pct, aspf, sp, fo, rua + + +def get_dmarc_policy(dmarc_record): + """Returns the policy value from a DMARC record.""" + if "p=" in str(dmarc_record): + return str(dmarc_record).split("p=")[1].split(";")[0] + else: + return None + + +def get_dmarc_pct(dmarc_record): + """Returns the pct value from a DMARC record.""" + if "pct=" in str(dmarc_record): + return str(dmarc_record).split("pct=")[1].split(";")[0] + else: + return None + + +def get_dmarc_aspf(dmarc_record): + """Returns the aspf value from a DMARC record""" + if "aspf=" in str(dmarc_record): + return str(dmarc_record).split("aspf=")[1].split(";")[0] + else: + return None + + +def get_dmarc_subdomain_policy(dmarc_record): + """Returns the policy to apply for subdomains from a DMARC record.""" + if "sp=" in str(dmarc_record): + return str(dmarc_record).split("sp=")[1].split(";")[0] + else: + return None + + +def get_dmarc_forensic_reports(dmarc_record): + """Returns the email addresses to which forensic reports should be sent.""" + if "ruf=" in str(dmarc_record) and "fo=1" in str(dmarc_record): + return str(dmarc_record).split("ruf=")[1].split(";")[0] + else: + return None + + +def get_dmarc_aggregate_reports(dmarc_record): + """Returns the email addresses to which aggregate reports should be sent.""" + if "rua=" in str(dmarc_record): + return str(dmarc_record).split("rua=")[1].split(";")[0] + else: + return None diff --git a/modules/dns.py b/modules/dns.py new file mode 100755 index 0000000..3e7739d --- /dev/null +++ b/modules/dns.py @@ -0,0 +1,59 @@ +# modules/dns.py +import dns.resolver +import socket +from . import spf, dmarc, bimi + + +def get_soa_record(domain): + """Returns the SOA record of a given domain.""" + resolver = dns.resolver.Resolver() + resolver.nameservers = ['1.1.1.1'] + try: + query = resolver.resolve(domain, 'SOA') + except: + return None + if query: + for data in query: + dns_server = str(data.mname) + try: + return socket.gethostbyname(dns_server) + except: + return None + return None + + +def get_dns_server(domain): + """Finds the DNS server that serves the domain and returns it, along with any SPF or DMARC records.""" + SOA = get_soa_record(domain) + spf_record = dmarc_record = partial_spf_record = partial_dmarc_record = bimi_record = None + + if SOA: + spf_record = spf.get_spf_record(domain, SOA) + dmarc_record = dmarc.get_dmarc_record(domain, SOA) + bimi_record = bimi.get_bimi_record(domain, SOA) + if spf_record and dmarc_record: + return SOA, spf_record, dmarc_record, bimi_record + + for ip_address in ['1.1.1.1', '8.8.8.8', '9.9.9.9']: + spf_record = spf.get_spf_record(domain, ip_address) + dmarc_record = dmarc.get_dmarc_record(domain, ip_address) + bimi_record = bimi.get_bimi_record(domain, SOA) + if spf_record and dmarc_record: + return ip_address, spf_record, dmarc_record, bimi_record + if spf_record: + partial_spf_record = spf_record + if dmarc_record: + partial_dmarc_record = dmarc_record + + return '1.1.1.1', partial_spf_record, partial_dmarc_record, bimi_record + + +def get_txt_record(domain, record_type): + """Returns the TXT record of a given type for a given domain.""" + resolver = dns.resolver.Resolver() + resolver.nameservers = [get_dns_server(domain)] + try: + query = resolver.query(domain, record_type) + return str(query[0]) + except: + return None diff --git a/modules/logic.py b/modules/logic.py new file mode 100755 index 0000000..334d2c0 --- /dev/null +++ b/modules/logic.py @@ -0,0 +1,84 @@ +# modules/logic.py +def is_spoofable(domain, p, aspf, spf_record, spf_all, spf_includes, sp, pct): + """This function takes in DMARC and SPF data for a domain, as well as subdomain policy and percentage options, + and determines if the domain is vulnerable to email spoofing. The function returns an integer value indicating + the class of vulnerability. + ID Handler: + 0: Indicates that spoofing is possible for the domain. + 1: Indicates that subdomain spoofing is possible for the domain. + 2: Indicates that organizational domain spoofing is possible for the domain. + 3: Indicates that spoofing might be possible for the domain. + 4: Indicates that spoofing might be possible (mailbox dependent) for the domain. + 5: Indicates that organizational domain spoofing may be possible for the domain. + 6: Indicates that subdomain spoofing might be possible (mailbox dependent) for the domain. + 7: Indicates that subdomain spoofing is possible, and organizational domain spoofing might be possible. + 8: Indicates that spoofing is not possible for the domain. + """ + try: + if pct and int(pct) != 100: + return 3 + elif spf_record is None: + if p is None: + return 0 + else: + return 8 + elif spf_includes > 10 and p is None: + return 0 + elif spf_all == "2many": + if p == "none": + return 3 + else: + return 8 + elif spf_all and p is None: + return 0 + elif spf_all == "-all": + if p and aspf and sp == "none": + return 1 + elif aspf is None and sp == "none": + return 1 + elif p == "none" and (aspf == "r" or aspf is None) and sp is None: + return 4 + elif p == "none" and aspf == "r" and (sp == "reject" or sp == "quarentine"): + return 2 + elif p == "none" and aspf is None and (sp == "reject" or sp == "quarentine"): + return 5 + elif p == "none" and aspf is None and sp == "none": + return 7 + else: + return 8 + elif spf_all == "~all": + if p == "none" and sp == "reject" or sp == "quarentine": + return 2 + elif p == "none" and sp is None: + return 0 + elif p == "none" and sp == "none": + return 7 + elif (p == "reject" or p == "quarentine") and aspf is None and sp == "none": + return 1 + elif (p == "reject" or p == "quarentine") and aspf and sp == "none": + return 1 + else: + return 8 + elif spf_all == "?all": + if (p == "reject" or p == "quarentine") and aspf and sp == "none": + return 6 + elif (p == "reject" or p == "quarentine") and aspf is None and sp == "none": + return 6 + elif p == "none" and aspf == "r" and sp is None: + return 0 + elif p == "none" and aspf == "r" and sp == "none": + return 7 + elif p == "none" and aspf == "s" or None and sp == "none": + return 7 + elif p == "none" and aspf == "s" or None and sp is None: + return 6 + elif p == "none" and aspf and (sp == "reject" or sp == "quarentine"): + return 5 + elif p == "none" and aspf is None and sp == "reject": + return 5 + else: + return 8 + else: + return 8 + except: + print("If you hit this error message, Open an issue with your testcase.") diff --git a/modules/report.py b/modules/report.py new file mode 100755 index 0000000..d3a6aaa --- /dev/null +++ b/modules/report.py @@ -0,0 +1,120 @@ +# modules/report.py +from colorama import Fore, Style +from colorama import init as color_init +import os +import pandas as pd + +color_init() + + +def output_good(line): + print(Fore.GREEN + Style.BRIGHT + "[+]" + Style.RESET_ALL, line) + + +def output_warning(line): + print(Fore.YELLOW + Style.BRIGHT + "[?]" + Style.RESET_ALL, line) + + +def output_bad(line): + print(Fore.RED + Style.BRIGHT + "[-]" + Style.RESET_ALL, line) + + +def output_indifferent(line): + print(Fore.BLUE + Style.BRIGHT + "[*]" + Style.RESET_ALL, line) + + +def output_error(line): + print(Fore.RED + Style.BRIGHT + "[-] !!! " + + Style.NORMAL, line, Style.BRIGHT + "!!!\n") + + +def output_info(line): + print(Fore.WHITE + Style.BRIGHT + "[*]" + Style.RESET_ALL, line) + + +def write_to_excel(data): + """This function writes a DataFrame of data to an Excel file. + If the file does not already exist, it creates the file, and if the file already exists, it appends the new data to the existing data.""" + file_name = "report.xlsx" + if not os.path.exists(file_name): + open(file_name, 'w').close() + + if os.path.getsize(file_name) > 0: + existing_df = pd.read_excel(file_name) + new_df = pd.DataFrame(data) + combined_df = pd.concat([existing_df, new_df]) + combined_df.to_excel(file_name, index=False) + else: + df = pd.DataFrame(data) + df.to_excel(file_name, index=False) + + +def printer(domain, subdomain, dns_server, spf_record, spf_all, spf_includes, dmarc_record, p, pct, aspf, sp, fo, rua, bimi_record, vbimi, location, authority, spoofable): + """This function is a utility function that takes in various parameters related to the + results of DMARC and SPF checks and outputs the results to the console in a human-readable format. + + Printer ID Handler: + 0: Indicates that spoofing is possible for the domain. + 1: Indicates that subdomain spoofing is possible for the domain. + 2: Indicates that organizational domain spoofing is possible for the domain. + 3: Indicates that spoofing might be possible for the domain. + 4: Indicates that spoofing might be possible (mailbox dependent) for the domain. + 5: Indicates that organizational domain spoofing may be possible for the domain. + 6: Indicates that subdomain spoofing might be possible (mailbox dependent) for the domain. + 7: Indicates that subdomain spoofing is possible, and organizational domain spoofing might be possible. + 8: Indicates that spoofing is not possible for the domain. + """ + output_indifferent(f"Domain: {domain}") + output_indifferent(f"Is subdomain: {subdomain}") + output_indifferent(f"DNS Server: {dns_server}") + + if spf_record: + output_info(f"SPF record: {spf_record}") + if spf_all is None: + output_info("SPF does not contain an `All` items.") + elif spf_all == "2many": + output_warning("SPF record contains multiple `All` items.") + else: + output_info(f"SPF all record: {spf_all}") + output_info(f"SPF include count: {spf_includes}" if spf_includes <= + 10 else f"Too many SPF include lookups {spf_includes}.") + else: + output_warning("No SPF record found.") + + if dmarc_record: + output_info(f"DMARC record: {dmarc_record}") + output_info( + f"Found DMARC policy: {p}" if p else "No DMARC policy found.") + output_info( + f"Found DMARC pct: {pct}" if pct else "No DMARC pct found.") + output_info( + f"Found DMARC aspf: {aspf}" if aspf else "No DMARC aspf found.") + output_info( + f"Found DMARC subdomain policy: {sp}" if sp else "No DMARC subdomain policy found.") + output_indifferent( + f"Forensics reports will be sent: {fo}" if fo else "No DMARC forensics report location found.") + output_indifferent( + f"Aggregate reports will be sent to: {rua}" if rua else "No DMARC aggregate report location found.") + else: + output_warning("No DMARC record found.") + + if(bimi_record): + output_info(f"BIMI record : {bimi_record}") + output_info(f"BIMI version : {vbimi}") + output_info(f"BIMI location : {location}") + output_info(f"BIMI authority : {authority}") + + if spoofable in [0, 1, 2, 3, 4, 5, 6, 7, 8]: + if spoofable == 8: + output_bad("Spoofing not possible for " + domain) + else: + output_good("Spoofing possible for " + domain + if spoofable == 0 else "Subdomain spoofing possible for " + domain + if spoofable == 1 else "Organizational domain spoofing possible for " + domain + if spoofable == 2 else "Spoofing might be possible for " + domain + if spoofable == 3 else "Spoofing might be possible (Mailbox dependant) for " + domain + if spoofable == 4 else "Organizational domain spoofing may be possible for " + domain + if spoofable == 5 else "Subdomain spoofing might be possible (Mailbox dependant) for " + domain + if spoofable == 6 else "Subdomain spoofing might be possible (Mailbox dependant) for " + domain + if spoofable == 7 else "") + print() # padding diff --git a/modules/spf.py b/modules/spf.py new file mode 100644 index 0000000..0cf8ed9 --- /dev/null +++ b/modules/spf.py @@ -0,0 +1,50 @@ +# modules/spf.py +import re +import dns.resolver + + +def get_spf_record(domain, dns_server): + """Returns the SPF record for a given domain.""" + try: + resolver = dns.resolver.Resolver() + resolver.nameservers = [dns_server, '1.1.1.1', '8.8.8.8'] + query_result = resolver.resolve(domain, 'TXT') + for record in query_result: + if 'spf1' in str(record): + spf_record = str(record).replace('"', '') + return spf_record + return None + except: + return None + + +def get_spf_all_string(spf_record): + """Returns the string value of the all mechanism in the SPF record.""" + all_matches = re.findall(r'[-~?] ?a ?l ?l', spf_record) + if len(all_matches) == 1: + return all_matches[0] + elif len(all_matches) > 1: + return '2many' + else: + return None + + +def get_spf_includes(domain, count=0): + """Returns the number of includes in the SPF record for a given domain.""" + if count > 10: + return count + try: + spf_record = get_spf_record(domain, '1.1.1.1') + if spf_record: + count += len(re.compile("[ ,+]a[ ,:]").findall(spf_record)) + count += len(re.compile("[ ,+]mx[ ,:]").findall(spf_record)) + count += len(re.compile("[ ]ptr[ ]").findall(spf_record)) + count += len(re.compile("exists[:]").findall(spf_record)) + for item in spf_record.split(' '): + if "include:" in item: + url = item.replace('include:', '') + count = get_spf_includes(url, count + 1) + except: + pass + # print("Could not find SPF record for " + domain) + return count diff --git a/spoofy.py b/spoofy.py index accf68f..75c78f5 100755 --- a/spoofy.py +++ b/spoofy.py @@ -2,8 +2,7 @@ import argparse import tldextract import threading -import os -from libs import bimi, dmarc, dns, logic, spf, report +from modules import bimi, dmarc, dns, logic, spf, report print_lock = threading.Lock() diff --git a/test.py b/test.py index ce5cc6b..aa134d3 100755 --- a/test.py +++ b/test.py @@ -1,5 +1,5 @@ import unittest -from libs import logic +from modules import logic class TestSpoofy(unittest.TestCase): From a95d28b440a84677936dfad49e3e8fceda4aad1f Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Sat, 10 Aug 2024 19:49:57 -0700 Subject: [PATCH 03/36] restructure to a class --- modules/dmarc.py | 137 +++++++++++++++++++++++++---------------------- 1 file changed, 73 insertions(+), 64 deletions(-) diff --git a/modules/dmarc.py b/modules/dmarc.py index 997e34d..7a7b027 100644 --- a/modules/dmarc.py +++ b/modules/dmarc.py @@ -1,82 +1,91 @@ # modules/dmarc.py + import dns.resolver import tldextract +class DMARC: + def __init__(self, domain, dns_server=None): + self.domain = domain + self.dns_server = dns_server + self.dmarc_record = self.get_dmarc_record() + self.policy = None + self.pct = None + self.aspf = None + self.sp = None + self.fo = None + self.rua = None -def get_dmarc_record(domain, dns_server): - """Returns the DMARC record for a given domain.""" - subdomain = tldextract.extract(domain).registered_domain - if subdomain != domain: - return get_dmarc_record(subdomain, dns_server) - try: - resolver = dns.resolver.Resolver() - resolver.nameservers = [dns_server] - dmarc = resolver.resolve(f"_dmarc.{domain}", "TXT") - except: + if self.dmarc_record: + self.policy = self.get_dmarc_policy() + self.pct = self.get_dmarc_pct() + self.aspf = self.get_dmarc_aspf() + self.sp = self.get_dmarc_subdomain_policy() + self.fo = self.get_dmarc_forensic_reports() + self.rua = self.get_dmarc_aggregate_reports() + + def get_dmarc_record(self): + """Returns the DMARC record for the domain.""" + subdomain = tldextract.extract(self.domain).registered_domain + if subdomain != self.domain: + return self.get_dmarc_record_for_domain(subdomain) + + return self.get_dmarc_record_for_domain(self.domain) + + def get_dmarc_record_for_domain(self, domain): + try: + resolver = dns.resolver.Resolver() + if self.dns_server: + resolver.nameservers = [self.dns_server] + dmarc = resolver.resolve(f"_dmarc.{domain}", "TXT") + except: + return None + + for dns_data in dmarc: + if "DMARC1" in str(dns_data): + return str(dns_data).replace('"', '') return None - for dns_data in dmarc: - if "DMARC1" in str(dns_data): - dmarc_record = str(dns_data).replace('"', '') - return dmarc_record - return None - - -def get_dmarc_details(dmarc_record): - """Returns a tuple containing policy, pct, aspf, subdomain policy, - forensic report uri, and aggregate report uri from a DMARC record""" - p = get_dmarc_policy(dmarc_record) - pct = get_dmarc_pct(dmarc_record) - aspf = get_dmarc_aspf(dmarc_record) - sp = get_dmarc_subdomain_policy(dmarc_record) - fo = get_dmarc_forensic_reports(dmarc_record) - rua = get_dmarc_aggregate_reports(dmarc_record) - return p, pct, aspf, sp, fo, rua - - -def get_dmarc_policy(dmarc_record): - """Returns the policy value from a DMARC record.""" - if "p=" in str(dmarc_record): - return str(dmarc_record).split("p=")[1].split(";")[0] - else: + def get_dmarc_policy(self): + """Returns the policy value from a DMARC record.""" + if "p=" in str(self.dmarc_record): + return str(self.dmarc_record).split("p=")[1].split(";")[0] return None - -def get_dmarc_pct(dmarc_record): - """Returns the pct value from a DMARC record.""" - if "pct=" in str(dmarc_record): - return str(dmarc_record).split("pct=")[1].split(";")[0] - else: + def get_dmarc_pct(self): + """Returns the pct value from a DMARC record.""" + if "pct=" in str(self.dmarc_record): + return str(self.dmarc_record).split("pct=")[1].split(";")[0] return None - -def get_dmarc_aspf(dmarc_record): - """Returns the aspf value from a DMARC record""" - if "aspf=" in str(dmarc_record): - return str(dmarc_record).split("aspf=")[1].split(";")[0] - else: + def get_dmarc_aspf(self): + """Returns the aspf value from a DMARC record""" + if "aspf=" in str(self.dmarc_record): + return str(self.dmarc_record).split("aspf=")[1].split(";")[0] return None - -def get_dmarc_subdomain_policy(dmarc_record): - """Returns the policy to apply for subdomains from a DMARC record.""" - if "sp=" in str(dmarc_record): - return str(dmarc_record).split("sp=")[1].split(";")[0] - else: + def get_dmarc_subdomain_policy(self): + """Returns the policy to apply for subdomains from a DMARC record.""" + if "sp=" in str(self.dmarc_record): + return str(self.dmarc_record).split("sp=")[1].split(";")[0] return None - -def get_dmarc_forensic_reports(dmarc_record): - """Returns the email addresses to which forensic reports should be sent.""" - if "ruf=" in str(dmarc_record) and "fo=1" in str(dmarc_record): - return str(dmarc_record).split("ruf=")[1].split(";")[0] - else: + def get_dmarc_forensic_reports(self): + """Returns the email addresses to which forensic reports should be sent.""" + if "ruf=" in str(self.dmarc_record) and "fo=1" in str(self.dmarc_record): + return str(self.dmarc_record).split("ruf=")[1].split(";")[0] return None - -def get_dmarc_aggregate_reports(dmarc_record): - """Returns the email addresses to which aggregate reports should be sent.""" - if "rua=" in str(dmarc_record): - return str(dmarc_record).split("rua=")[1].split(";")[0] - else: + def get_dmarc_aggregate_reports(self): + """Returns the email addresses to which aggregate reports should be sent.""" + if "rua=" in str(self.dmarc_record): + return str(self.dmarc_record).split("rua=")[1].split(";")[0] return None + + def __str__(self): + return (f"DMARC Record: {self.dmarc_record}\n" + f"Policy: {self.policy}\n" + f"Pct: {self.pct}\n" + f"ASPF: {self.aspf}\n" + f"Subdomain Policy: {self.sp}\n" + f"Forensic Report URI: {self.fo}\n" + f"Aggregate Report URI: {self.rua}") From 1dd152f9d34deec717dc37b20f7e1bf4453d1329 Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Sat, 10 Aug 2024 20:12:45 -0700 Subject: [PATCH 04/36] rework to a class --- modules/bimi.py | 92 ++++++++++++++++++++++++++++--------------------- 1 file changed, 53 insertions(+), 39 deletions(-) mode change 100755 => 100644 modules/bimi.py diff --git a/modules/bimi.py b/modules/bimi.py old mode 100755 new mode 100644 index b7e9a6c..955a177 --- a/modules/bimi.py +++ b/modules/bimi.py @@ -1,45 +1,59 @@ # modules/bimi.py + import dns.resolver -def get_bimi_record(domain, dns_server): - """Returns the BIMI record for a given domain.""" - try: - resolver = dns.resolver.Resolver() - resolver.nameservers = [dns_server, '1.1.1.1', '8.8.8.8'] - query_result = resolver.resolve('default._bimi.' + domain, 'TXT') - for record in query_result: - if 'v=BIMI' in str(record): - return record - return None - except: + +class BIMI: + def __init__(self, domain, dns_server=None): + self.domain = domain + self.dns_server = dns_server + self.bimi_record = self.get_bimi_record() + self.version = None + self.location = None + self.authority = None + + if self.bimi_record: + self.version = self.get_bimi_version() + self.location = self.get_bimi_location() + self.authority = self.get_bimi_authority() + + def get_bimi_record(self): + """Returns the BIMI record for the domain.""" + try: + resolver = dns.resolver.Resolver() + if self.dns_server: + resolver.nameservers = [self.dns_server] + bimi = resolver.resolve(f'default._bimi.{self.domain}', 'TXT') + for record in bimi: + if 'v=BIMI' in str(record): + return record + return None + except: + return None + + def get_bimi_version(self): + """Returns the version value from a BIMI record.""" + if "v=" in str(self.bimi_record): + return str(self.bimi_record).split("v=")[1].split(";")[0] return None + def get_bimi_location(self): + """Returns the location value from a BIMI record.""" + if "l=" in str(self.bimi_record): + return str(self.bimi_record).split("l=")[1].split(";")[0] + return None -def get_bimi_details(bimi_record): - """Returns a tuple containing policy, pct, aspf, subdomain policy, - forensic report uri, and aggregate report uri from a BIMI record""" - version = get_bimi_version(bimi_record) - location = get_bimi_location(bimi_record) - authority = get_bimi_authority(bimi_record) - return version, location, authority - - -def get_bimi_version(bimi_record): - """Returns the version value from a BIMI record.""" - if "v=" in str(bimi_record): - return str(bimi_record).split("v=")[1].split(";")[0] - else: + def get_bimi_authority(self): + """Returns the authority value from a BIMI record.""" + if "a=" in str(self.bimi_record): + return str(self.bimi_record).split("a=")[1].split(";")[0] return None - -def get_bimi_location(bimi_record): - """Returns the location value from a BIMI record.""" - if "l=" in str(bimi_record): - return str(bimi_record).split("l=")[1].split(";")[0] - else: - return None - -def get_bimi_authority(bimi_record): - """Returns the authority value from a BIMI record.""" - if "a=" in str(bimi_record): - return str(bimi_record).split("a=")[1].split(";")[0] - else: - return None \ No newline at end of file + + def get_bimi_details(self): + """Returns a tuple containing version, location, and authority from a BIMI record.""" + return self.version, self.location, self.authority + + def __str__(self): + return (f"BIMI Record: {self.bimi_record}\n" + f"Version: {self.version}\n" + f"Location: {self.location}\n" + f"Authority: {self.authority}") From 61751c73b3e3ecd070e09aad15b3d53945779520 Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Sat, 10 Aug 2024 20:12:48 -0700 Subject: [PATCH 05/36] rework to a class --- modules/dns.py | 114 +++++++++++++++++++++++++++---------------------- 1 file changed, 64 insertions(+), 50 deletions(-) mode change 100755 => 100644 modules/dns.py diff --git a/modules/dns.py b/modules/dns.py old mode 100755 new mode 100644 index 3e7739d..e845553 --- a/modules/dns.py +++ b/modules/dns.py @@ -1,59 +1,73 @@ # modules/dns.py + import dns.resolver import socket -from . import spf, dmarc, bimi +from .spf import SPF +from .dmarc import DMARC +from .bimi import BIMI +class DNS: + def __init__(self, domain): + self.domain = domain + self.soa_record = None + self.dns_server = None + self.spf_record = None + self.dmarc_record = None + self.bimi_record = None -def get_soa_record(domain): - """Returns the SOA record of a given domain.""" - resolver = dns.resolver.Resolver() - resolver.nameservers = ['1.1.1.1'] - try: - query = resolver.resolve(domain, 'SOA') - except: - return None - if query: - for data in query: - dns_server = str(data.mname) + self.get_soa_record() + self.get_dns_server() + + def get_soa_record(self): + """Sets the SOA record and DNS server of a given domain.""" + resolver = dns.resolver.Resolver() + resolver.nameservers = ['1.1.1.1'] try: - return socket.gethostbyname(dns_server) + query = resolver.resolve(self.domain, 'SOA') + except: + return + if query: + for data in query: + dns_server = str(data.mname) + try: + self.soa_record = socket.gethostbyname(dns_server) + self.dns_server = self.soa_record + except: + self.soa_record = None + + def get_dns_server(self): + """Finds the DNS server that serves the domain and retrieves associated SPF, DMARC, and BIMI records.""" + if self.soa_record: + self.spf_record = SPF(self.domain, self.soa_record) + self.dmarc_record = DMARC(self.domain, self.soa_record) + self.bimi_record = BIMI(self.domain, self.soa_record) + if self.spf_record.spf_record and self.dmarc_record.dmarc_record: + return + + for ip_address in ['1.1.1.1', '8.8.8.8', '9.9.9.9']: + self.spf_record = SPF(self.domain, ip_address) + self.dmarc_record = DMARC(self.domain, ip_address) + self.bimi_record = BIMI(self.domain, ip_address) + if self.spf_record.spf_record and self.dmarc_record.dmarc_record: + self.dns_server = ip_address + return + + self.dns_server = '1.1.1.1' + + def get_txt_record(self, record_type): + """Returns the TXT record of a given type for the domain.""" + resolver = dns.resolver.Resolver() + resolver.nameservers = [self.dns_server] + try: + query = resolver.query(self.domain, record_type) + return str(query[0]) except: return None - return None - -def get_dns_server(domain): - """Finds the DNS server that serves the domain and returns it, along with any SPF or DMARC records.""" - SOA = get_soa_record(domain) - spf_record = dmarc_record = partial_spf_record = partial_dmarc_record = bimi_record = None - - if SOA: - spf_record = spf.get_spf_record(domain, SOA) - dmarc_record = dmarc.get_dmarc_record(domain, SOA) - bimi_record = bimi.get_bimi_record(domain, SOA) - if spf_record and dmarc_record: - return SOA, spf_record, dmarc_record, bimi_record - - for ip_address in ['1.1.1.1', '8.8.8.8', '9.9.9.9']: - spf_record = spf.get_spf_record(domain, ip_address) - dmarc_record = dmarc.get_dmarc_record(domain, ip_address) - bimi_record = bimi.get_bimi_record(domain, SOA) - if spf_record and dmarc_record: - return ip_address, spf_record, dmarc_record, bimi_record - if spf_record: - partial_spf_record = spf_record - if dmarc_record: - partial_dmarc_record = dmarc_record - - return '1.1.1.1', partial_spf_record, partial_dmarc_record, bimi_record - - -def get_txt_record(domain, record_type): - """Returns the TXT record of a given type for a given domain.""" - resolver = dns.resolver.Resolver() - resolver.nameservers = [get_dns_server(domain)] - try: - query = resolver.query(domain, record_type) - return str(query[0]) - except: - return None + def __str__(self): + return (f"Domain: {self.domain}\n" + f"SOA Record: {self.soa_record}\n" + f"DNS Server: {self.dns_server}\n" + f"SPF Record: {self.spf_record.spf_record}\n" + f"DMARC Record: {self.dmarc_record.dmarc_record}\n" + f"BIMI Record: {self.bimi_record.bimi_record}") From 9f77d99423a93061a40246a55f516095fd5ec25a Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Sat, 10 Aug 2024 20:13:01 -0700 Subject: [PATCH 06/36] shift into spoofing class --- modules/logic.py | 84 ------------------------------------------------ 1 file changed, 84 deletions(-) delete mode 100755 modules/logic.py diff --git a/modules/logic.py b/modules/logic.py deleted file mode 100755 index 334d2c0..0000000 --- a/modules/logic.py +++ /dev/null @@ -1,84 +0,0 @@ -# modules/logic.py -def is_spoofable(domain, p, aspf, spf_record, spf_all, spf_includes, sp, pct): - """This function takes in DMARC and SPF data for a domain, as well as subdomain policy and percentage options, - and determines if the domain is vulnerable to email spoofing. The function returns an integer value indicating - the class of vulnerability. - ID Handler: - 0: Indicates that spoofing is possible for the domain. - 1: Indicates that subdomain spoofing is possible for the domain. - 2: Indicates that organizational domain spoofing is possible for the domain. - 3: Indicates that spoofing might be possible for the domain. - 4: Indicates that spoofing might be possible (mailbox dependent) for the domain. - 5: Indicates that organizational domain spoofing may be possible for the domain. - 6: Indicates that subdomain spoofing might be possible (mailbox dependent) for the domain. - 7: Indicates that subdomain spoofing is possible, and organizational domain spoofing might be possible. - 8: Indicates that spoofing is not possible for the domain. - """ - try: - if pct and int(pct) != 100: - return 3 - elif spf_record is None: - if p is None: - return 0 - else: - return 8 - elif spf_includes > 10 and p is None: - return 0 - elif spf_all == "2many": - if p == "none": - return 3 - else: - return 8 - elif spf_all and p is None: - return 0 - elif spf_all == "-all": - if p and aspf and sp == "none": - return 1 - elif aspf is None and sp == "none": - return 1 - elif p == "none" and (aspf == "r" or aspf is None) and sp is None: - return 4 - elif p == "none" and aspf == "r" and (sp == "reject" or sp == "quarentine"): - return 2 - elif p == "none" and aspf is None and (sp == "reject" or sp == "quarentine"): - return 5 - elif p == "none" and aspf is None and sp == "none": - return 7 - else: - return 8 - elif spf_all == "~all": - if p == "none" and sp == "reject" or sp == "quarentine": - return 2 - elif p == "none" and sp is None: - return 0 - elif p == "none" and sp == "none": - return 7 - elif (p == "reject" or p == "quarentine") and aspf is None and sp == "none": - return 1 - elif (p == "reject" or p == "quarentine") and aspf and sp == "none": - return 1 - else: - return 8 - elif spf_all == "?all": - if (p == "reject" or p == "quarentine") and aspf and sp == "none": - return 6 - elif (p == "reject" or p == "quarentine") and aspf is None and sp == "none": - return 6 - elif p == "none" and aspf == "r" and sp is None: - return 0 - elif p == "none" and aspf == "r" and sp == "none": - return 7 - elif p == "none" and aspf == "s" or None and sp == "none": - return 7 - elif p == "none" and aspf == "s" or None and sp is None: - return 6 - elif p == "none" and aspf and (sp == "reject" or sp == "quarentine"): - return 5 - elif p == "none" and aspf is None and sp == "reject": - return 5 - else: - return 8 - else: - return 8 - except: - print("If you hit this error message, Open an issue with your testcase.") From 70e866547f4d5de6b9dfe01422fc3532b0d79bb6 Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Sat, 10 Aug 2024 20:13:07 -0700 Subject: [PATCH 07/36] rework to a class --- modules/spoofing.py | 125 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 modules/spoofing.py diff --git a/modules/spoofing.py b/modules/spoofing.py new file mode 100644 index 0000000..bd8a5eb --- /dev/null +++ b/modules/spoofing.py @@ -0,0 +1,125 @@ +# modules/spoofing.py + +import tldextract + +class Spoofing: + def __init__(self, domain, p, aspf, spf_record, spf_all, spf_includes, sp, pct): + self.domain = domain + self.p = p + self.aspf = aspf + self.spf_record = spf_record + self.spf_all = spf_all + self.spf_includes = spf_includes + self.sp = sp + self.pct = pct + self.domain_type = self.get_domain_type() + self.spoofable = self.is_spoofable() + self.spoofing_possible, self.spoofing_type = self.evaluate_spoofing() + + def get_domain_type(self): + """Determines whether the domain is a domain or subdomain.""" + subdomain = bool(tldextract.extract(self.domain).subdomain) + return "subdomain" if subdomain else "domain" + + def is_spoofable(self): + """Determines the spoofability based on DMARC and SPF data.""" + try: + if self.pct and int(self.pct) != 100: + return 3 + elif self.spf_record is None: + if self.p is None: + return 0 + else: + return 8 + elif self.spf_includes > 10 and self.p is None: + return 0 + elif self.spf_all == "2many": + if self.p == "none": + return 3 + else: + return 8 + elif self.spf_all and self.p is None: + return 0 + elif self.spf_all == "-all": + if self.p and self.aspf and self.sp == "none": + return 1 + elif self.aspf is None and self.sp == "none": + return 1 + elif self.p == "none" and (self.aspf == "r" or self.aspf is None) and self.sp is None: + return 4 + elif self.p == "none" and self.aspf == "r" and (self.sp == "reject" or self.sp == "quarantine"): + return 2 + elif self.p == "none" and self.aspf is None and (self.sp == "reject" or self.sp == "quarantine"): + return 5 + elif self.p == "none" and self.aspf is None and self.sp == "none": + return 7 + else: + return 8 + elif self.spf_all == "~all": + if self.p == "none" and self.sp == "reject" or self.sp == "quarantine": + return 2 + elif self.p == "none" and self.sp is None: + return 0 + elif self.p == "none" and self.sp == "none": + return 7 + elif (self.p == "reject" or self.p == "quarantine") and self.aspf is None and self.sp == "none": + return 1 + elif (self.p == "reject" or self.p == "quarantine") and self.aspf and self.sp == "none": + return 1 + else: + return 8 + elif self.spf_all == "?all": + if (self.p == "reject" or self.p == "quarantine") and self.aspf and self.sp == "none": + return 6 + elif (self.p == "reject" or self.p == "quarantine") and self.aspf is None and self.sp == "none": + return 6 + elif self.p == "none" and self.aspf == "r" and self.sp is None: + return 0 + elif self.p == "none" and self.aspf == "r" and self.sp == "none": + return 7 + elif self.p == "none" and self.aspf == "s" or None and self.sp == "none": + return 7 + elif self.p == "none" and self.aspf == "s" or None and self.sp is None: + return 6 + elif self.p == "none" and self.aspf and (self.sp == "reject" or self.sp == "quarantine"): + return 5 + elif self.p == "none" and self.aspf is None and self.sp == "reject": + return 5 + else: + return 8 + else: + return 8 + except: + print("If you hit this error message, Open an issue with your testcase.") + return 8 + + def evaluate_spoofing(self): + """Evaluates and returns whether spoofing is possible and the type of spoofing.""" + spoofing_types = { + 0: f"Spoofing possible for {self.domain}.", + 1: f"Subdomain spoofing possible for {self.domain}.", + 2: f"Organizational domain spoofing possible for {self.domain}.", + 3: f"Spoofing might be possible for {self.domain}.", + 4: f"Spoofing might be possible (Mailbox dependent) for {self.domain}.", + 5: f"Organizational domain spoofing might be possible for {self.domain}.", + 6: f"Subdomain spoofing might be possible (Mailbox dependent) for {self.domain}.", + 7: f"Subdomain spoofing is possible and organizational domain spoofing might be possible for {self.domain}.", + 8: f"Spoofing is not possible for {self.domain}." + } + + spoofing_type = spoofing_types.get(self.spoofable, f"Unknown spoofing type for {self.domain}.") + + if self.spoofable in {0, 1, 3, 7}: + spoofing_possible = True + elif self.spoofable == 8: + spoofing_possible = False + else: + spoofing_possible = None # "maybe" + + return spoofing_possible, spoofing_type + + def __str__(self): + return (f"Domain: {self.domain}\n" + f"Domain Type: {self.domain_type}\n" + f"Spoofing Possible: {self.spoofing_possible}\n" + f"Spoofing Type: {self.spoofing_type}") From be137eeb28319ac5300e7886f2a430aad3615408 Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Sat, 10 Aug 2024 20:13:11 -0700 Subject: [PATCH 08/36] rework to a class --- modules/spf.py | 102 ++++++++++++++++++++++++++++--------------------- 1 file changed, 59 insertions(+), 43 deletions(-) diff --git a/modules/spf.py b/modules/spf.py index 0cf8ed9..1b117b4 100644 --- a/modules/spf.py +++ b/modules/spf.py @@ -1,50 +1,66 @@ # modules/spf.py -import re + import dns.resolver +import re +class SPF: + def __init__(self, domain, dns_server=None): + self.domain = domain + self.dns_server = dns_server + self.spf_record = self.get_spf_record() + self.all_mechanism = None + self.num_includes = 0 + self.too_many_includes = False -def get_spf_record(domain, dns_server): - """Returns the SPF record for a given domain.""" - try: - resolver = dns.resolver.Resolver() - resolver.nameservers = [dns_server, '1.1.1.1', '8.8.8.8'] - query_result = resolver.resolve(domain, 'TXT') - for record in query_result: - if 'spf1' in str(record): - spf_record = str(record).replace('"', '') - return spf_record - return None - except: + if self.spf_record: + self.all_mechanism = self.get_spf_all_string() + self.num_includes = self.get_spf_includes() + self.too_many_includes = self.num_includes > 10 + + def get_spf_record(self): + """Returns the SPF record for the domain.""" + try: + resolver = dns.resolver.Resolver() + if self.dns_server: + resolver.nameservers = [self.dns_server] + query_result = resolver.resolve(self.domain, 'TXT') + for record in query_result: + if 'v=spf1' in str(record): + return str(record).replace('"', '') + return None + except: + return None + + def get_spf_all_string(self): + """Returns the string value of the 'all' mechanism in the SPF record.""" + if self.spf_record: + all_matches = re.findall(r'[-~?+]all', self.spf_record) + if len(all_matches) == 1: + return all_matches[0] + elif len(all_matches) > 1: + return '2many' return None + def get_spf_includes(self, count=0): + """Returns the number of includes in the SPF record for the domain.""" + if count > 10: # Assuming a maximum of 10 includes as a threshold + return count + try: + if self.spf_record: + count += self.spf_record.count("include:") + # Recursively check includes + for item in self.spf_record.split(' '): + if "include:" in item: + included_domain = item.replace('include:', '') + # Instantiate SPF class for the included domain to get its includes + include_spf = SPF(included_domain, self.dns_server) + count += include_spf.get_spf_includes(count) + return count + except: + return count -def get_spf_all_string(spf_record): - """Returns the string value of the all mechanism in the SPF record.""" - all_matches = re.findall(r'[-~?] ?a ?l ?l', spf_record) - if len(all_matches) == 1: - return all_matches[0] - elif len(all_matches) > 1: - return '2many' - else: - return None - - -def get_spf_includes(domain, count=0): - """Returns the number of includes in the SPF record for a given domain.""" - if count > 10: - return count - try: - spf_record = get_spf_record(domain, '1.1.1.1') - if spf_record: - count += len(re.compile("[ ,+]a[ ,:]").findall(spf_record)) - count += len(re.compile("[ ,+]mx[ ,:]").findall(spf_record)) - count += len(re.compile("[ ]ptr[ ]").findall(spf_record)) - count += len(re.compile("exists[:]").findall(spf_record)) - for item in spf_record.split(' '): - if "include:" in item: - url = item.replace('include:', '') - count = get_spf_includes(url, count + 1) - except: - pass - # print("Could not find SPF record for " + domain) - return count + def __str__(self): + return (f"SPF Record: {self.spf_record}\n" + f"All Mechanism: {self.all_mechanism}\n" + f"Number of Includes: {self.num_includes}\n" + f"Too Many Includes: {self.too_many_includes}") \ No newline at end of file From 8ba9344f57198065b09494f7311f4ee4be250771 Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Sat, 10 Aug 2024 20:14:17 -0700 Subject: [PATCH 09/36] easier to read and work with --- spoofy.py | 149 ++++++++++++++++++++++-------------------------------- 1 file changed, 61 insertions(+), 88 deletions(-) diff --git a/spoofy.py b/spoofy.py index 75c78f5..502a67b 100755 --- a/spoofy.py +++ b/spoofy.py @@ -1,99 +1,72 @@ #! /usr/bin/env python3 +from modules.dns import DNS +from modules.spf import SPF +from modules.dmarc import DMARC +from modules.bimi import BIMI +from modules.spoofing import Spoofing + import argparse import tldextract import threading -from modules import bimi, dmarc, dns, logic, spf, report -print_lock = threading.Lock() +def process_domain(domain): + + dns_info = DNS(domain) + spf = SPF(domain, dns_info.dns_server) + dmarc = DMARC(domain, dns_info.dns_server) + bimi_info = BIMI(domain, dns_info.dns_server) + + spf_record = spf.spf_record + spf_all = spf.all_mechanism + spf_num_includes = spf.num_includes + spf_too_many_includes = spf.too_many_includes + + dmarc_record = dmarc.dmarc_record + dmarc_p = dmarc.policy + dmarc_pct = dmarc.pct + dmarc_aspf = dmarc.aspf + dmarc_sp = dmarc.sp + dmarc_fo = dmarc.fo + dmarc_rua = dmarc.rua + + bimi_record = bimi_info.bimi_record + bimi_version = bimi_info.version + bimi_location = bimi_info.location + bimi_authority = bimi_info.authority -def process_domain(domain, output): - """This function takes a list of domains and an output format (either 'xls' or 'stdout') - as arguments. It processes each domain, collects its relevant details, - and outputs the results to the console or an Excel file.""" - try: - dns_server = spf_record = dmarc_record = None - spf_all = spf_includes = p = pct = aspf = sp = fo = rua = vbimi = location = authority = None - subdomain = bool(tldextract.extract(domain).subdomain) - with print_lock: - dns_server, spf_record, dmarc_record, bimi_record = dns.get_dns_server(domain) - if spf_record: - spf_all = spf.get_spf_all_string(spf_record) - spf_includes = spf.get_spf_includes(domain) - if dmarc_record: - p, pct, aspf, sp, fo, rua = dmarc.get_dmarc_details(dmarc_record) - if bimi_record: - vbimi, location, authority = bimi.get_bimi_details(bimi_record) - spoofable = logic.is_spoofable( - domain, p, aspf, spf_record, spf_all, spf_includes, sp, pct) - if output == "xls": - with print_lock: - data = [{'DOMAIN': domain, - 'SUBDOMAIN': subdomain, - 'SPF': spf_record, - 'SPF MULTIPLE ALLS': spf_all, - 'SPF TOO MANY INCLUDES': spf_includes, - 'DMARC': dmarc_record, - 'DMARC POLICY': p, - 'DMARC PCT': pct, - 'DMARC ASPF': aspf, - 'DMARC SP': sp, - 'DMARC FORENSIC REPORT': fo, - 'DMARC AGGREGATE REPORT': rua, - 'BIMI_RECORD': bimi_record, - 'BIMI_VERSION': vbimi, - 'BIMI_LOCATION': location, - 'BIMI_AUTHORITY': authority, - 'SPOOFING POSSIBLE': spoofable}] - report.write_to_excel(data) - else: - with print_lock: - report.printer(domain, subdomain, dns_server, spf_record, spf_all, spf_includes, dmarc_record, p, pct, aspf, - sp, fo, rua, bimi_record, vbimi, location, authority, spoofable) - except Exception as e: - raise e - with print_lock: - report.output_error( - f"Domain {domain} is offline or format cannot be interpreted.") + spoofing_info = Spoofing(domain, dmarc_p, dmarc_aspf, spf_record, spf_all, spf_num_includes, dmarc_sp, dmarc_pct) + + domain_type = spoofing_info.domain_type + spoofing_possible = spoofing_info.spoofing_possible + spoofing_type = spoofing_info.spoofing_type -def process_domains(domains, output): - """ - This function is for multithreading woot woot! - """ - threads = [] - - for domain in domains: - thread = threading.Thread(target=process_domain, args=(domain, output)) - thread.start() - threads.append(thread) - - for thread in threads: - thread.join() - + result = { + 'DOMAIN_TYPE': domain_type, + 'DNS_SERVER': dns_info.dns_server, + 'SPF': spf_record, + 'SPF_MULTIPLE_ALLS': spf_all, + 'SPF_NUM_INCLUDES': spf_num_includes, + 'SPF_TOO_MANY_INCLUDES': spf_too_many_includes, + 'DMARC': dmarc_record, + 'DMARC_POLICY': dmarc_p, + 'DMARC_PCT': dmarc_pct, + 'DMARC_ASPF': dmarc_aspf, + 'DMARC_SP': dmarc_sp, + 'DMARC_FORENSIC_REPORT': dmarc_fo, + 'DMARC_AGGREGATE_REPORT': dmarc_rua, + 'BIMI_RECORD': bimi_record, + 'BIMI_VERSION': bimi_version, + 'BIMI_LOCATION': bimi_location, + 'BIMI_AUTHORITY': bimi_authority, + 'SPOOFING_POSSIBLE': spoofing_possible, + 'SPOOFING_TYPE': spoofing_type + } + return result if __name__ == "__main__": - parser = argparse.ArgumentParser() - group = parser.add_mutually_exclusive_group() - group.add_argument("-iL", type=str, required=False, - help="Provide an input list.") - group.add_argument("-d", type=str, required=False, - help="Provide a single domain.") - parser.add_argument("-o", type=str, choices=['xls', 'stdout'], - default='stdout', help="Output format: stdout or xls (default: stdout)") - options = parser.parse_args() - options = parser.parse_args() - if not any(vars(options).values()): - parser.error( - "No arguments provided. Usage: `spoofy.py -d [DOMAIN] -o [stdout or xls]` OR `spoofy.py -iL [DOMAIN_LIST] -o [stdout or xls]`") - domains = [] - if options.iL: - try: - with open(options.iL, "r") as f: - for line in f: - domains.append(line.strip('\n')) - except IOError: - report.output_error("File doesnt exist or cannot be read.") - if options.d: - domains.append(options.d) - process_domains(domains, options.o) + # for domain in domains + # process the domain, get the results. Store those results in batches of 100 + # if the 100 mark is hit, write it to stdout, clear the stoage and work on next batch + pass \ No newline at end of file From 94db7dc63f1216306f6a8ec4281804ac694ed298 Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Sat, 10 Aug 2024 20:46:57 -0700 Subject: [PATCH 10/36] ruff --- modules/bimi.py | 2 +- modules/dmarc.py | 2 +- modules/dns.py | 6 +++--- modules/spf.py | 4 ++-- modules/spoofing.py | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/modules/bimi.py b/modules/bimi.py index 955a177..80e8c44 100644 --- a/modules/bimi.py +++ b/modules/bimi.py @@ -27,7 +27,7 @@ class BIMI: if 'v=BIMI' in str(record): return record return None - except: + except Exception: return None def get_bimi_version(self): diff --git a/modules/dmarc.py b/modules/dmarc.py index 7a7b027..226ff22 100644 --- a/modules/dmarc.py +++ b/modules/dmarc.py @@ -37,7 +37,7 @@ class DMARC: if self.dns_server: resolver.nameservers = [self.dns_server] dmarc = resolver.resolve(f"_dmarc.{domain}", "TXT") - except: + except Exception: return None for dns_data in dmarc: diff --git a/modules/dns.py b/modules/dns.py index e845553..600def8 100644 --- a/modules/dns.py +++ b/modules/dns.py @@ -24,7 +24,7 @@ class DNS: resolver.nameservers = ['1.1.1.1'] try: query = resolver.resolve(self.domain, 'SOA') - except: + except Exception: return if query: for data in query: @@ -32,7 +32,7 @@ class DNS: try: self.soa_record = socket.gethostbyname(dns_server) self.dns_server = self.soa_record - except: + except Exception: self.soa_record = None def get_dns_server(self): @@ -61,7 +61,7 @@ class DNS: try: query = resolver.query(self.domain, record_type) return str(query[0]) - except: + except Exception: return None def __str__(self): diff --git a/modules/spf.py b/modules/spf.py index 1b117b4..7f9b6a3 100644 --- a/modules/spf.py +++ b/modules/spf.py @@ -28,7 +28,7 @@ class SPF: if 'v=spf1' in str(record): return str(record).replace('"', '') return None - except: + except Exception: return None def get_spf_all_string(self): @@ -56,7 +56,7 @@ class SPF: include_spf = SPF(included_domain, self.dns_server) count += include_spf.get_spf_includes(count) return count - except: + except Exception: return count def __str__(self): diff --git a/modules/spoofing.py b/modules/spoofing.py index bd8a5eb..ce0abc5 100644 --- a/modules/spoofing.py +++ b/modules/spoofing.py @@ -89,7 +89,7 @@ class Spoofing: return 8 else: return 8 - except: + except Exception: print("If you hit this error message, Open an issue with your testcase.") return 8 From e531a61d25bb15412c1bcaeb645aa387b5a8f0f9 Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Sat, 10 Aug 2024 20:47:22 -0700 Subject: [PATCH 11/36] reimplement to work better as a library --- modules/report.py | 168 +++++++++++++++++++--------------------------- 1 file changed, 70 insertions(+), 98 deletions(-) mode change 100755 => 100644 modules/report.py diff --git a/modules/report.py b/modules/report.py old mode 100755 new mode 100644 index d3a6aaa..efb693b --- a/modules/report.py +++ b/modules/report.py @@ -1,120 +1,92 @@ # modules/report.py -from colorama import Fore, Style -from colorama import init as color_init import os import pandas as pd +from colorama import init, Fore, Style -color_init() +# Initialize colorama +init() +def output_message(symbol, message, level="info"): + """Generic function to print messages with different colors and symbols based on the level.""" + colors = { + "good": Fore.GREEN + Style.BRIGHT, + "warning": Fore.YELLOW + Style.BRIGHT, + "bad": Fore.RED + Style.BRIGHT, + "indifferent": Fore.BLUE + Style.BRIGHT, + "error": Fore.RED + Style.BRIGHT + "!!! ", + "info": Fore.WHITE + Style.BRIGHT + } + color = colors.get(level, Fore.WHITE + Style.BRIGHT) + print(color + f"{symbol} {message}" + Style.RESET_ALL) -def output_good(line): - print(Fore.GREEN + Style.BRIGHT + "[+]" + Style.RESET_ALL, line) - - -def output_warning(line): - print(Fore.YELLOW + Style.BRIGHT + "[?]" + Style.RESET_ALL, line) - - -def output_bad(line): - print(Fore.RED + Style.BRIGHT + "[-]" + Style.RESET_ALL, line) - - -def output_indifferent(line): - print(Fore.BLUE + Style.BRIGHT + "[*]" + Style.RESET_ALL, line) - - -def output_error(line): - print(Fore.RED + Style.BRIGHT + "[-] !!! " + - Style.NORMAL, line, Style.BRIGHT + "!!!\n") - - -def output_info(line): - print(Fore.WHITE + Style.BRIGHT + "[*]" + Style.RESET_ALL, line) - - -def write_to_excel(data): - """This function writes a DataFrame of data to an Excel file. - If the file does not already exist, it creates the file, and if the file already exists, it appends the new data to the existing data.""" - file_name = "report.xlsx" - if not os.path.exists(file_name): - open(file_name, 'w').close() - - if os.path.getsize(file_name) > 0: +def write_to_excel(data, file_name="report.xlsx"): + """Writes a DataFrame of data to an Excel file, appending if the file exists.""" + if os.path.exists(file_name) and os.path.getsize(file_name) > 0: existing_df = pd.read_excel(file_name) new_df = pd.DataFrame(data) combined_df = pd.concat([existing_df, new_df]) combined_df.to_excel(file_name, index=False) else: - df = pd.DataFrame(data) - df.to_excel(file_name, index=False) + pd.DataFrame(data).to_excel(file_name, index=False) +def printer(**kwargs): + """Utility function to print the results of DMARC, SPF, and BIMI checks in the original format.""" + domain = kwargs.get('DOMAIN_TYPE') + subdomain = kwargs.get('DOMAIN_TYPE') == 'subdomain' + dns_server = kwargs.get('DNS_SERVER') + spf_record = kwargs.get('SPF') + spf_all = kwargs.get('SPF_MULTIPLE_ALLS') + spf_includes = kwargs.get('SPF_NUM_INCLUDES') + dmarc_record = kwargs.get('DMARC') + p = kwargs.get('DMARC_POLICY') + pct = kwargs.get('DMARC_PCT') + aspf = kwargs.get('DMARC_ASPF') + sp = kwargs.get('DMARC_SP') + fo = kwargs.get('DMARC_FORENSIC_REPORT') + rua = kwargs.get('DMARC_AGGREGATE_REPORT') + bimi_record = kwargs.get('BIMI_RECORD') + vbimi = kwargs.get('BIMI_VERSION') + location = kwargs.get('BIMI_LOCATION') + authority = kwargs.get('BIMI_AUTHORITY') + spoofable = kwargs.get('SPOOFING_POSSIBLE') + spoofing_type = kwargs.get('SPOOFING_TYPE') -def printer(domain, subdomain, dns_server, spf_record, spf_all, spf_includes, dmarc_record, p, pct, aspf, sp, fo, rua, bimi_record, vbimi, location, authority, spoofable): - """This function is a utility function that takes in various parameters related to the - results of DMARC and SPF checks and outputs the results to the console in a human-readable format. - - Printer ID Handler: - 0: Indicates that spoofing is possible for the domain. - 1: Indicates that subdomain spoofing is possible for the domain. - 2: Indicates that organizational domain spoofing is possible for the domain. - 3: Indicates that spoofing might be possible for the domain. - 4: Indicates that spoofing might be possible (mailbox dependent) for the domain. - 5: Indicates that organizational domain spoofing may be possible for the domain. - 6: Indicates that subdomain spoofing might be possible (mailbox dependent) for the domain. - 7: Indicates that subdomain spoofing is possible, and organizational domain spoofing might be possible. - 8: Indicates that spoofing is not possible for the domain. - """ - output_indifferent(f"Domain: {domain}") - output_indifferent(f"Is subdomain: {subdomain}") - output_indifferent(f"DNS Server: {dns_server}") + output_message("[*]", f"Domain: {domain}", "indifferent") + output_message("[*]", f"Is subdomain: {subdomain}", "indifferent") + output_message("[*]", f"DNS Server: {dns_server}", "indifferent") if spf_record: - output_info(f"SPF record: {spf_record}") + output_message("[*]", f"SPF record: {spf_record}", "info") if spf_all is None: - output_info("SPF does not contain an `All` items.") + output_message("[*]", "SPF does not contain an `All` item.", "info") elif spf_all == "2many": - output_warning("SPF record contains multiple `All` items.") + output_message("[?]", "SPF record contains multiple `All` items.", "warning") else: - output_info(f"SPF all record: {spf_all}") - output_info(f"SPF include count: {spf_includes}" if spf_includes <= - 10 else f"Too many SPF include lookups {spf_includes}.") + output_message("[*]", f"SPF all record: {spf_all}", "info") + output_message("[*]", f"SPF include count: {spf_includes}" if spf_includes <= 10 else f"Too many SPF include lookups {spf_includes}.", "info") else: - output_warning("No SPF record found.") + output_message("[?]", "No SPF record found.", "warning") if dmarc_record: - output_info(f"DMARC record: {dmarc_record}") - output_info( - f"Found DMARC policy: {p}" if p else "No DMARC policy found.") - output_info( - f"Found DMARC pct: {pct}" if pct else "No DMARC pct found.") - output_info( - f"Found DMARC aspf: {aspf}" if aspf else "No DMARC aspf found.") - output_info( - f"Found DMARC subdomain policy: {sp}" if sp else "No DMARC subdomain policy found.") - output_indifferent( - f"Forensics reports will be sent: {fo}" if fo else "No DMARC forensics report location found.") - output_indifferent( - f"Aggregate reports will be sent to: {rua}" if rua else "No DMARC aggregate report location found.") + output_message("[*]", f"DMARC record: {dmarc_record}", "info") + output_message("[*]", f"Found DMARC policy: {p}" if p else "No DMARC policy found.", "info") + output_message("[*]", f"Found DMARC pct: {pct}" if pct else "No DMARC pct found.", "info") + output_message("[*]", f"Found DMARC aspf: {aspf}" if aspf else "No DMARC aspf found.", "info") + output_message("[*]", f"Found DMARC subdomain policy: {sp}" if sp else "No DMARC subdomain policy found.", "info") + output_message("[*]", f"Forensics reports will be sent: {fo}" if fo else "No DMARC forensics report location found.", "indifferent") + output_message("[*]", f"Aggregate reports will be sent to: {rua}" if rua else "No DMARC aggregate report location found.", "indifferent") else: - output_warning("No DMARC record found.") - - if(bimi_record): - output_info(f"BIMI record : {bimi_record}") - output_info(f"BIMI version : {vbimi}") - output_info(f"BIMI location : {location}") - output_info(f"BIMI authority : {authority}") - - if spoofable in [0, 1, 2, 3, 4, 5, 6, 7, 8]: - if spoofable == 8: - output_bad("Spoofing not possible for " + domain) - else: - output_good("Spoofing possible for " + domain - if spoofable == 0 else "Subdomain spoofing possible for " + domain - if spoofable == 1 else "Organizational domain spoofing possible for " + domain - if spoofable == 2 else "Spoofing might be possible for " + domain - if spoofable == 3 else "Spoofing might be possible (Mailbox dependant) for " + domain - if spoofable == 4 else "Organizational domain spoofing may be possible for " + domain - if spoofable == 5 else "Subdomain spoofing might be possible (Mailbox dependant) for " + domain - if spoofable == 6 else "Subdomain spoofing might be possible (Mailbox dependant) for " + domain - if spoofable == 7 else "") - print() # padding + output_message("[?]", "No DMARC record found.", "warning") + + if bimi_record: + output_message("[*]", f"BIMI record: {bimi_record}", "info") + output_message("[*]", f"BIMI version: {vbimi}", "info") + output_message("[*]", f"BIMI location: {location}", "info") + output_message("[*]", f"BIMI authority: {authority}", "info") + + if spoofing_type: + level = "good" if spoofable else "bad" + symbol = "[+]" if level == "good" else "[-]" + output_message(symbol, spoofing_type, level) + + print() # Padding From 35b9c8880d137c32dc4760ffd8cca516c7ee8b98 Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Sat, 10 Aug 2024 21:34:30 -0700 Subject: [PATCH 12/36] Update report.py --- modules/report.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/report.py b/modules/report.py index efb693b..7ef24e3 100644 --- a/modules/report.py +++ b/modules/report.py @@ -19,7 +19,7 @@ def output_message(symbol, message, level="info"): color = colors.get(level, Fore.WHITE + Style.BRIGHT) print(color + f"{symbol} {message}" + Style.RESET_ALL) -def write_to_excel(data, file_name="report.xlsx"): +def write_to_excel(data, file_name="output.xlsx"): """Writes a DataFrame of data to an Excel file, appending if the file exists.""" if os.path.exists(file_name) and os.path.getsize(file_name) > 0: existing_df = pd.read_excel(file_name) @@ -31,7 +31,7 @@ def write_to_excel(data, file_name="report.xlsx"): def printer(**kwargs): """Utility function to print the results of DMARC, SPF, and BIMI checks in the original format.""" - domain = kwargs.get('DOMAIN_TYPE') + domain = kwargs.get('DOMAIN') subdomain = kwargs.get('DOMAIN_TYPE') == 'subdomain' dns_server = kwargs.get('DNS_SERVER') spf_record = kwargs.get('SPF') From c79622f279d19bf1a71e393f4cf69867c9dd1012 Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Sat, 10 Aug 2024 21:34:36 -0700 Subject: [PATCH 13/36] Update test.py --- test.py | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/test.py b/test.py index aa134d3..0731a9e 100755 --- a/test.py +++ b/test.py @@ -1,5 +1,5 @@ import unittest -from modules import logic +from modules.spoofing import Spoofing class TestSpoofy(unittest.TestCase): @@ -16,34 +16,44 @@ class TestSpoofy(unittest.TestCase): ''' def test_spoofing_is_possible(self): - unittest.TestCase().assertEqual(logic.is_spoofable('test_0.com', 'none', 'r', 'v=spf1 include:fake.gov', '~all', 3, None, 100), 0) + spoofing = Spoofing('test_0.com', 'none', 'r', 'v=spf1 include:fake.gov', '~all', 3, None, 100) + self.assertEqual(spoofing.spoofable, 0) def test_subdomain_spoofing(self): - unittest.TestCase().assertEqual(logic.is_spoofable("test_1.com", 'reject', None, 'v=spf1 include:fakest.domain.com', '-all', 3, 'none', None), 1) + spoofing = Spoofing("test_1.com", 'reject', None, 'v=spf1 include:fakest.domain.com', '-all', 3, 'none', None) + self.assertEqual(spoofing.spoofable, 1) def test_organizational_domain_spoofing(self): - unittest.TestCase().assertEqual(logic.is_spoofable('test_2.com', 'none', 'r', 'v=spf1 include:fakest.domain.com include:faker.domain.com', '-all', 2, 'reject', 100), 2) + spoofing = Spoofing('test_2.com', 'none', 'r', 'v=spf1 include:fakest.domain.com include:faker.domain.com', '-all', 2, 'reject', 100) + self.assertEqual(spoofing.spoofable, 2) def test_spoofing_might_be_possible(self): - unittest.TestCase().assertEqual(logic.is_spoofable('test_3.com', 'none', None, 'v=spf1 include:fakest.domain.com', '~all', 1, 'quarantine', 90), 3) + spoofing = Spoofing('test_3.com', 'none', None, 'v=spf1 include:fakest.domain.com', '~all', 1, 'quarantine', 90) + self.assertEqual(spoofing.spoofable, 3) def test_spoofing_might_be_possible_mbd(self): - unittest.TestCase().assertEqual(logic.is_spoofable('test_4.com', 'none', None, 'v=spf1 include:fakest.domain.com', '-all', 1, None, 100), 4) + spoofing = Spoofing('test_4.com', 'none', None, 'v=spf1 include:fakest.domain.com', '-all', 1, None, 100) + self.assertEqual(spoofing.spoofable, 4) def test_org_domain_spoofing_might_be_possible(self): - unittest.TestCase().assertEqual(logic.is_spoofable('test_5.com', 'none', None, 'v=spf1 include:fakest.domain.com', '-all', 1, 'reject', 100), 5) + spoofing = Spoofing('test_5.com', 'none', None, 'v=spf1 include:fakest.domain.com', '-all', 1, 'reject', 100) + self.assertEqual(spoofing.spoofable, 5) def test_subdomain_spoofing_might_be_possible_mbd(self): - unittest.TestCase().assertEqual(logic.is_spoofable('test_6.com', 'reject', 'r', 'v=spf1 include:fakest.domain.com', '?all', 1, 'none', 100), 6) + spoofing = Spoofing('test_6.com', 'reject', 'r', 'v=spf1 include:fakest.domain.com', '?all', 1, 'none', 100) + self.assertEqual(spoofing.spoofable, 6) def test_subdomain_spoofing_and_org_spoofing_might_be_possible(self): - unittest.TestCase().assertEqual(logic.is_spoofable('test_7.com', 'none', None, 'v=spf1 include:fakest.domain.com', '~all', 3, 'none', 100), 7) + spoofing = Spoofing('test_7.com', 'none', None, 'v=spf1 include:fakest.domain.com', '~all', 3, 'none', 100) + self.assertEqual(spoofing.spoofable, 7) def test_spoofing_not_possible(self): - unittest.TestCase().assertEqual(logic.is_spoofable('test_8.com', 'none', 's', 'v=spf1 include:fakest.domain.com', '~all', 1, 'quarantine', 100), 8) + spoofing = Spoofing('test_8.com', 'none', 's', 'v=spf1 include:fakest.domain.com', '~all', 1, 'quarantine', 100) + self.assertEqual(spoofing.spoofable, 8) def test_possible_bug_fix1(self): - unittest.TestCase().assertEqual(logic.is_spoofable('sub.test_9.com', None, None, None, None, None, None, None), 0) + spoofing = Spoofing('sub.test_9.com', None, None, None, None, None, None, None) + self.assertEqual(spoofing.spoofable, 0) if __name__ == '__main__': unittest.main() From 0f6a4a311bebb8cb44be9b04afc0bb9c16cd6730 Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Sat, 10 Aug 2024 21:35:09 -0700 Subject: [PATCH 14/36] Update spoofy.py --- spoofy.py | 108 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 78 insertions(+), 30 deletions(-) diff --git a/spoofy.py b/spoofy.py index 502a67b..e649ef3 100755 --- a/spoofy.py +++ b/spoofy.py @@ -1,16 +1,16 @@ #! /usr/bin/env python3 +import argparse +import threading from modules.dns import DNS from modules.spf import SPF from modules.dmarc import DMARC from modules.bimi import BIMI from modules.spoofing import Spoofing - -import argparse -import tldextract -import threading +from modules import report +from queue import Queue def process_domain(domain): - + """Process a domain to gather DNS, SPF, DMARC, and BIMI records, and evaluate spoofing potential.""" dns_info = DNS(domain) spf = SPF(domain, dns_info.dns_server) dmarc = DMARC(domain, dns_info.dns_server) @@ -34,39 +34,87 @@ def process_domain(domain): bimi_location = bimi_info.location bimi_authority = bimi_info.authority - spoofing_info = Spoofing(domain, dmarc_p, dmarc_aspf, spf_record, spf_all, spf_num_includes, dmarc_sp, dmarc_pct) domain_type = spoofing_info.domain_type spoofing_possible = spoofing_info.spoofing_possible spoofing_type = spoofing_info.spoofing_type - result = { - 'DOMAIN_TYPE': domain_type, - 'DNS_SERVER': dns_info.dns_server, - 'SPF': spf_record, - 'SPF_MULTIPLE_ALLS': spf_all, - 'SPF_NUM_INCLUDES': spf_num_includes, - 'SPF_TOO_MANY_INCLUDES': spf_too_many_includes, - 'DMARC': dmarc_record, - 'DMARC_POLICY': dmarc_p, - 'DMARC_PCT': dmarc_pct, - 'DMARC_ASPF': dmarc_aspf, - 'DMARC_SP': dmarc_sp, - 'DMARC_FORENSIC_REPORT': dmarc_fo, - 'DMARC_AGGREGATE_REPORT': dmarc_rua, - 'BIMI_RECORD': bimi_record, - 'BIMI_VERSION': bimi_version, - 'BIMI_LOCATION': bimi_location, - 'BIMI_AUTHORITY': bimi_authority, - 'SPOOFING_POSSIBLE': spoofing_possible, - 'SPOOFING_TYPE': spoofing_type + 'DOMAIN': domain, + 'DOMAIN_TYPE': domain_type, + 'DNS_SERVER': dns_info.dns_server, + 'SPF': spf_record, + 'SPF_MULTIPLE_ALLS': spf_all, + 'SPF_NUM_INCLUDES': spf_num_includes, + 'SPF_TOO_MANY_INCLUDES': spf_too_many_includes, + 'DMARC': dmarc_record, + 'DMARC_POLICY': dmarc_p, + 'DMARC_PCT': dmarc_pct, + 'DMARC_ASPF': dmarc_aspf, + 'DMARC_SP': dmarc_sp, + 'DMARC_FORENSIC_REPORT': dmarc_fo, + 'DMARC_AGGREGATE_REPORT': dmarc_rua, + 'BIMI_RECORD': bimi_record, + 'BIMI_VERSION': bimi_version, + 'BIMI_LOCATION': bimi_location, + 'BIMI_AUTHORITY': bimi_authority, + 'SPOOFING_POSSIBLE': spoofing_possible, + 'SPOOFING_TYPE': spoofing_type } return result +def worker(domain_queue, result_queue): + """Worker function to process domains and put results into the result queue.""" + while not domain_queue.empty(): + domain = domain_queue.get() + if domain is None: + break + result = process_domain(domain) + result_queue.put(result) + domain_queue.task_done() +def process_domain_and_output(domain, output, results): + """Process a domain and handle output based on the specified format.""" + result = process_domain(domain) + if output == 'stdout': + report.printer(**result) + else: + results.append(result) + +def main(): + parser = argparse.ArgumentParser(description="Process domains to gather DNS, SPF, DMARC, and BIMI records.") + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("-d", type=str, help="Single domain to process.") + group.add_argument("-iL", type=str, help="File containing a list of domains to process.") + parser.add_argument("-o", type=str, choices=['stdout', 'xls'], default='stdout', help="Output format: stdout or xls (default: stdout).") + + args = parser.parse_args() + + # Load domains + if args.d: + domains = [args.d] + elif args.iL: + with open(args.iL, "r") as file: + domains = [line.strip() for line in file] + + # Prepare for processing + results = [] + threads = [] + + # Start threads to process each domain + for domain in domains: + thread = threading.Thread(target=process_domain_and_output, args=(domain, args.o, results)) + thread.start() + threads.append(thread) + + # Wait for all threads to complete + for thread in threads: + thread.join() + + # Handle output for xls format + if args.o == 'xls' and results: + report.write_to_excel(results) + print("Results written to output.xlsx") + if __name__ == "__main__": - # for domain in domains - # process the domain, get the results. Store those results in batches of 100 - # if the 100 mark is hit, write it to stdout, clear the stoage and work on next batch - pass \ No newline at end of file + main() \ No newline at end of file From 6448585913a40563b3ec8ff4b60f1bff92c01f0d Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Sat, 10 Aug 2024 21:37:50 -0700 Subject: [PATCH 15/36] formatting --- modules/report.py | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/report.py b/modules/report.py index 7ef24e3..17471f6 100644 --- a/modules/report.py +++ b/modules/report.py @@ -1,4 +1,5 @@ # modules/report.py + import os import pandas as pd from colorama import init, Fore, Style From 3e801745f25d95e7a5a143118fd1c54648bd52be Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Sat, 10 Aug 2024 22:54:22 -0700 Subject: [PATCH 16/36] fix the include counts --- modules/spf.py | 55 ++++++++++++++++++++++++++++++-------------------- 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/modules/spf.py b/modules/spf.py index 7f9b6a3..1c07650 100644 --- a/modules/spf.py +++ b/modules/spf.py @@ -18,15 +18,15 @@ class SPF: self.too_many_includes = self.num_includes > 10 def get_spf_record(self): - """Returns the SPF record for the domain.""" + """Returns the SPF record for a given domain.""" try: resolver = dns.resolver.Resolver() - if self.dns_server: - resolver.nameservers = [self.dns_server] + resolver.nameservers = [self.dns_server, '1.1.1.1', '8.8.8.8'] query_result = resolver.resolve(self.domain, 'TXT') for record in query_result: - if 'v=spf1' in str(record): - return str(record).replace('"', '') + if 'spf1' in str(record): + spf_record = str(record).replace('"', '') + return spf_record return None except Exception: return None @@ -40,25 +40,36 @@ class SPF: elif len(all_matches) > 1: return '2many' return None - - def get_spf_includes(self, count=0): - """Returns the number of includes in the SPF record for the domain.""" - if count > 10: # Assuming a maximum of 10 includes as a threshold - return count - try: - if self.spf_record: - count += self.spf_record.count("include:") - # Recursively check includes - for item in self.spf_record.split(' '): - if "include:" in item: - included_domain = item.replace('include:', '') - # Instantiate SPF class for the included domain to get its includes - include_spf = SPF(included_domain, self.dns_server) - count += include_spf.get_spf_includes(count) - return count - except Exception: + + def get_spf_includes(self): + """Returns the number of includes and other mechanisms in the SPF record for a given domain.""" + def count_includes(spf_record): + count = 0 + for item in spf_record.split(): + if item.startswith("include:"): + url = item.replace('include:', '') + count += 1 + try: + # Recursively fetch and count includes in the SPF record of the included domain + answers = dns.resolver.resolve(url, 'TXT') + for rdata in answers: + for txt_string in rdata.strings: + txt_record = txt_string.decode('utf-8') + if txt_record.startswith('v=spf1'): + count += count_includes(txt_record) + except Exception as e: + pass + + # Count occurrences of 'a', 'mx', 'ptr', and 'exists' mechanisms + count += len(re.findall(r"[ ,+]a[ ,:]", spf_record)) + count += len(re.findall(r"[ ,+]mx[ ,:]", spf_record)) + count += len(re.findall(r"[ ]ptr[ ]", spf_record)) + count += len(re.findall(r"exists[:]", spf_record)) + return count + return count_includes(self.spf_record) + def __str__(self): return (f"SPF Record: {self.spf_record}\n" f"All Mechanism: {self.all_mechanism}\n" From 5e93f7f9d1b14ae2aeda67f5b9be7c60d6a4032a Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Sat, 10 Aug 2024 22:54:28 -0700 Subject: [PATCH 17/36] unused import --- spoofy.py | 1 - 1 file changed, 1 deletion(-) diff --git a/spoofy.py b/spoofy.py index e649ef3..6ccbbed 100755 --- a/spoofy.py +++ b/spoofy.py @@ -7,7 +7,6 @@ from modules.dmarc import DMARC from modules.bimi import BIMI from modules.spoofing import Spoofing from modules import report -from queue import Queue def process_domain(domain): """Process a domain to gather DNS, SPF, DMARC, and BIMI records, and evaluate spoofing potential.""" From aa13836d0e5a188395d51a8fa9b9d99bf1e97972 Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Sat, 10 Aug 2024 23:08:05 -0700 Subject: [PATCH 18/36] fix redirect bug --- list.txt | 1 + modules/spf.py | 29 +++++++++++++++++++++++------ 2 files changed, 24 insertions(+), 6 deletions(-) create mode 100644 list.txt diff --git a/list.txt b/list.txt new file mode 100644 index 0000000..140dbdc --- /dev/null +++ b/list.txt @@ -0,0 +1 @@ +reddit.com diff --git a/modules/spf.py b/modules/spf.py index 1c07650..ea9e155 100644 --- a/modules/spf.py +++ b/modules/spf.py @@ -17,12 +17,14 @@ class SPF: self.num_includes = self.get_spf_includes() self.too_many_includes = self.num_includes > 10 - def get_spf_record(self): - """Returns the SPF record for a given domain.""" + def get_spf_record(self, domain=None): + """Fetches the SPF record for the specified domain.""" try: + if not domain: + domain = self.domain resolver = dns.resolver.Resolver() resolver.nameservers = [self.dns_server, '1.1.1.1', '8.8.8.8'] - query_result = resolver.resolve(self.domain, 'TXT') + query_result = resolver.resolve(domain, 'TXT') for record in query_result: if 'spf1' in str(record): spf_record = str(record).replace('"', '') @@ -33,12 +35,27 @@ class SPF: def get_spf_all_string(self): """Returns the string value of the 'all' mechanism in the SPF record.""" - if self.spf_record: - all_matches = re.findall(r'[-~?+]all', self.spf_record) + + spf_record = self.spf_record + visited_domains = set() + + while spf_record: + all_matches = re.findall(r'[-~?+]all', spf_record) if len(all_matches) == 1: return all_matches[0] elif len(all_matches) > 1: return '2many' + + redirect_match = re.search(r'redirect=([\w.-]+)', spf_record) + if redirect_match: + redirect_domain = redirect_match.group(1) + if redirect_domain in visited_domains: + break # Prevent infinite loops in case of circular redirects + visited_domains.add(redirect_domain) + spf_record = self.get_spf_record(redirect_domain) + else: + break + return None def get_spf_includes(self): @@ -57,7 +74,7 @@ class SPF: txt_record = txt_string.decode('utf-8') if txt_record.startswith('v=spf1'): count += count_includes(txt_record) - except Exception as e: + except Exception: pass # Count occurrences of 'a', 'mx', 'ptr', and 'exists' mechanisms From de684b8019fbb863f0b837449425707ad8a2546f Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Sat, 10 Aug 2024 23:16:12 -0700 Subject: [PATCH 19/36] include redirects on the include count --- modules/spf.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/modules/spf.py b/modules/spf.py index ea9e155..9a61ffa 100644 --- a/modules/spf.py +++ b/modules/spf.py @@ -59,15 +59,19 @@ class SPF: return None def get_spf_includes(self): - """Returns the number of includes and other mechanisms in the SPF record for a given domain.""" + """Returns the number of includes, redirects, and other mechanisms in the SPF record for a given domain.""" def count_includes(spf_record): count = 0 for item in spf_record.split(): - if item.startswith("include:"): - url = item.replace('include:', '') + if item.startswith("include:") or item.startswith("redirect="): + if item.startswith("include:"): + url = item.replace('include:', '') + elif item.startswith("redirect="): + url = item.replace('redirect=', '') + count += 1 try: - # Recursively fetch and count includes in the SPF record of the included domain + # Recursively fetch and count includes or redirects in the SPF record of the referenced domain answers = dns.resolver.resolve(url, 'TXT') for rdata in answers: for txt_string in rdata.strings: From 95dee5b0fb976c04acdca106d76c6de73ce12b88 Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Sat, 10 Aug 2024 23:23:46 -0700 Subject: [PATCH 20/36] update naming to be more accurate --- modules/report.py | 4 ++-- modules/spf.py | 24 ++++++++++++------------ modules/spoofing.py | 6 +++--- spoofy.py | 10 +++++----- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/modules/report.py b/modules/report.py index 17471f6..a277739 100644 --- a/modules/report.py +++ b/modules/report.py @@ -37,7 +37,7 @@ def printer(**kwargs): dns_server = kwargs.get('DNS_SERVER') spf_record = kwargs.get('SPF') spf_all = kwargs.get('SPF_MULTIPLE_ALLS') - spf_includes = kwargs.get('SPF_NUM_INCLUDES') + spf_dns_query_count = kwargs.get('SPF_NUM_DNS_QUERIES') dmarc_record = kwargs.get('DMARC') p = kwargs.get('DMARC_POLICY') pct = kwargs.get('DMARC_PCT') @@ -64,7 +64,7 @@ def printer(**kwargs): output_message("[?]", "SPF record contains multiple `All` items.", "warning") else: output_message("[*]", f"SPF all record: {spf_all}", "info") - output_message("[*]", f"SPF include count: {spf_includes}" if spf_includes <= 10 else f"Too many SPF include lookups {spf_includes}.", "info") + output_message("[*]", f"SPF DNS query count: {spf_dns_query_count}" if spf_dns_query_count <= 10 else f"Too many SPF DNS query lookups {spf_dns_query_count}.", "info") else: output_message("[?]", "No SPF record found.", "warning") diff --git a/modules/spf.py b/modules/spf.py index 9a61ffa..0c6b68c 100644 --- a/modules/spf.py +++ b/modules/spf.py @@ -9,13 +9,13 @@ class SPF: self.dns_server = dns_server self.spf_record = self.get_spf_record() self.all_mechanism = None - self.num_includes = 0 - self.too_many_includes = False + self.spf_dns_query_count = 0 + self.too_many_dns_queries = False if self.spf_record: self.all_mechanism = self.get_spf_all_string() - self.num_includes = self.get_spf_includes() - self.too_many_includes = self.num_includes > 10 + self.spf_dns_query_count = self.get_spf_dns_queries() + self.too_many_dns_queries = self.spf_dns_query_count > 10 def get_spf_record(self, domain=None): """Fetches the SPF record for the specified domain.""" @@ -58,9 +58,9 @@ class SPF: return None - def get_spf_includes(self): - """Returns the number of includes, redirects, and other mechanisms in the SPF record for a given domain.""" - def count_includes(spf_record): + def get_spf_dns_queries(self): + """Returns the number of dns queries, redirects, and other mechanisms in the SPF record for a given domain.""" + def count_dns_queries(spf_record): count = 0 for item in spf_record.split(): if item.startswith("include:") or item.startswith("redirect="): @@ -71,13 +71,13 @@ class SPF: count += 1 try: - # Recursively fetch and count includes or redirects in the SPF record of the referenced domain + # Recursively fetch and count dns queries or redirects in the SPF record of the referenced domain answers = dns.resolver.resolve(url, 'TXT') for rdata in answers: for txt_string in rdata.strings: txt_record = txt_string.decode('utf-8') if txt_record.startswith('v=spf1'): - count += count_includes(txt_record) + count += count_dns_queries(txt_record) except Exception: pass @@ -89,10 +89,10 @@ class SPF: return count - return count_includes(self.spf_record) + return count_dns_queries(self.spf_record) def __str__(self): return (f"SPF Record: {self.spf_record}\n" f"All Mechanism: {self.all_mechanism}\n" - f"Number of Includes: {self.num_includes}\n" - f"Too Many Includes: {self.too_many_includes}") \ No newline at end of file + f"DNS Query Count: {self.spf_dns_query_count}\n" + f"Too Many DNS Queries: {self.too_many_dns_queries}") \ No newline at end of file diff --git a/modules/spoofing.py b/modules/spoofing.py index ce0abc5..747f310 100644 --- a/modules/spoofing.py +++ b/modules/spoofing.py @@ -3,13 +3,13 @@ import tldextract class Spoofing: - def __init__(self, domain, p, aspf, spf_record, spf_all, spf_includes, sp, pct): + def __init__(self, domain, p, aspf, spf_record, spf_all, spf_dns_queries, sp, pct): self.domain = domain self.p = p self.aspf = aspf self.spf_record = spf_record self.spf_all = spf_all - self.spf_includes = spf_includes + self.spf_dns_queries = spf_dns_queries self.sp = sp self.pct = pct self.domain_type = self.get_domain_type() @@ -31,7 +31,7 @@ class Spoofing: return 0 else: return 8 - elif self.spf_includes > 10 and self.p is None: + elif self.spf_dns_queries > 10 and self.p is None: return 0 elif self.spf_all == "2many": if self.p == "none": diff --git a/spoofy.py b/spoofy.py index 6ccbbed..9e25553 100755 --- a/spoofy.py +++ b/spoofy.py @@ -17,8 +17,8 @@ def process_domain(domain): spf_record = spf.spf_record spf_all = spf.all_mechanism - spf_num_includes = spf.num_includes - spf_too_many_includes = spf.too_many_includes + spf_dns_query_count = spf.spf_dns_query_count + spf_too_many_dns_queries = spf.too_many_dns_queries dmarc_record = dmarc.dmarc_record dmarc_p = dmarc.policy @@ -33,7 +33,7 @@ def process_domain(domain): bimi_location = bimi_info.location bimi_authority = bimi_info.authority - spoofing_info = Spoofing(domain, dmarc_p, dmarc_aspf, spf_record, spf_all, spf_num_includes, dmarc_sp, dmarc_pct) + spoofing_info = Spoofing(domain, dmarc_p, dmarc_aspf, spf_record, spf_all, spf_dns_query_count, dmarc_sp, dmarc_pct) domain_type = spoofing_info.domain_type spoofing_possible = spoofing_info.spoofing_possible @@ -45,8 +45,8 @@ def process_domain(domain): 'DNS_SERVER': dns_info.dns_server, 'SPF': spf_record, 'SPF_MULTIPLE_ALLS': spf_all, - 'SPF_NUM_INCLUDES': spf_num_includes, - 'SPF_TOO_MANY_INCLUDES': spf_too_many_includes, + 'SPF_NUM_DNS_QUERIES': spf_dns_query_count, + 'SPF_TOO_MANY_DNS_QUERIES': spf_too_many_dns_queries, 'DMARC': dmarc_record, 'DMARC_POLICY': dmarc_p, 'DMARC_PCT': dmarc_pct, From 5bf8a196482e58205e7072d8c1ab93f79d432ce1 Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Sat, 10 Aug 2024 23:30:38 -0700 Subject: [PATCH 21/36] Update test.py --- test.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/test.py b/test.py index 0731a9e..da6c34c 100755 --- a/test.py +++ b/test.py @@ -51,9 +51,5 @@ class TestSpoofy(unittest.TestCase): spoofing = Spoofing('test_8.com', 'none', 's', 'v=spf1 include:fakest.domain.com', '~all', 1, 'quarantine', 100) self.assertEqual(spoofing.spoofable, 8) - def test_possible_bug_fix1(self): - spoofing = Spoofing('sub.test_9.com', None, None, None, None, None, None, None) - self.assertEqual(spoofing.spoofable, 0) - if __name__ == '__main__': unittest.main() From eeff00fcbdd663af87d9a0dbb01eeb4d07b2b69d Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Sat, 10 Aug 2024 23:42:33 -0700 Subject: [PATCH 22/36] black formatting --- modules/__init__.py | 2 +- modules/bimi.py | 15 +++++--- modules/dmarc.py | 21 +++++----- modules/dns.py | 23 ++++++----- modules/report.py | 93 +++++++++++++++++++++++++++++++-------------- modules/spf.py | 48 ++++++++++++----------- modules/spoofing.py | 69 +++++++++++++++++++++++++-------- spoofy.py | 83 ++++++++++++++++++++++++++-------------- 8 files changed, 235 insertions(+), 119 deletions(-) diff --git a/modules/__init__.py b/modules/__init__.py index 5a3656f..f307358 100644 --- a/modules/__init__.py +++ b/modules/__init__.py @@ -1 +1 @@ -# modules/__init__.py \ No newline at end of file +# modules/__init__.py diff --git a/modules/bimi.py b/modules/bimi.py index 80e8c44..240c604 100644 --- a/modules/bimi.py +++ b/modules/bimi.py @@ -2,6 +2,7 @@ import dns.resolver + class BIMI: def __init__(self, domain, dns_server=None): self.domain = domain @@ -22,9 +23,9 @@ class BIMI: resolver = dns.resolver.Resolver() if self.dns_server: resolver.nameservers = [self.dns_server] - bimi = resolver.resolve(f'default._bimi.{self.domain}', 'TXT') + bimi = resolver.resolve(f"default._bimi.{self.domain}", "TXT") for record in bimi: - if 'v=BIMI' in str(record): + if "v=BIMI" in str(record): return record return None except Exception: @@ -53,7 +54,9 @@ class BIMI: return self.version, self.location, self.authority def __str__(self): - return (f"BIMI Record: {self.bimi_record}\n" - f"Version: {self.version}\n" - f"Location: {self.location}\n" - f"Authority: {self.authority}") + return ( + f"BIMI Record: {self.bimi_record}\n" + f"Version: {self.version}\n" + f"Location: {self.location}\n" + f"Authority: {self.authority}" + ) diff --git a/modules/dmarc.py b/modules/dmarc.py index 226ff22..63144f8 100644 --- a/modules/dmarc.py +++ b/modules/dmarc.py @@ -3,6 +3,7 @@ import dns.resolver import tldextract + class DMARC: def __init__(self, domain, dns_server=None): self.domain = domain @@ -28,7 +29,7 @@ class DMARC: subdomain = tldextract.extract(self.domain).registered_domain if subdomain != self.domain: return self.get_dmarc_record_for_domain(subdomain) - + return self.get_dmarc_record_for_domain(self.domain) def get_dmarc_record_for_domain(self, domain): @@ -42,7 +43,7 @@ class DMARC: for dns_data in dmarc: if "DMARC1" in str(dns_data): - return str(dns_data).replace('"', '') + return str(dns_data).replace('"', "") return None def get_dmarc_policy(self): @@ -82,10 +83,12 @@ class DMARC: return None def __str__(self): - return (f"DMARC Record: {self.dmarc_record}\n" - f"Policy: {self.policy}\n" - f"Pct: {self.pct}\n" - f"ASPF: {self.aspf}\n" - f"Subdomain Policy: {self.sp}\n" - f"Forensic Report URI: {self.fo}\n" - f"Aggregate Report URI: {self.rua}") + return ( + f"DMARC Record: {self.dmarc_record}\n" + f"Policy: {self.policy}\n" + f"Pct: {self.pct}\n" + f"ASPF: {self.aspf}\n" + f"Subdomain Policy: {self.sp}\n" + f"Forensic Report URI: {self.fo}\n" + f"Aggregate Report URI: {self.rua}" + ) diff --git a/modules/dns.py b/modules/dns.py index 600def8..68a75b5 100644 --- a/modules/dns.py +++ b/modules/dns.py @@ -6,6 +6,7 @@ from .spf import SPF from .dmarc import DMARC from .bimi import BIMI + class DNS: def __init__(self, domain): self.domain = domain @@ -21,9 +22,9 @@ class DNS: def get_soa_record(self): """Sets the SOA record and DNS server of a given domain.""" resolver = dns.resolver.Resolver() - resolver.nameservers = ['1.1.1.1'] + resolver.nameservers = ["1.1.1.1"] try: - query = resolver.resolve(self.domain, 'SOA') + query = resolver.resolve(self.domain, "SOA") except Exception: return if query: @@ -44,7 +45,7 @@ class DNS: if self.spf_record.spf_record and self.dmarc_record.dmarc_record: return - for ip_address in ['1.1.1.1', '8.8.8.8', '9.9.9.9']: + for ip_address in ["1.1.1.1", "8.8.8.8", "9.9.9.9"]: self.spf_record = SPF(self.domain, ip_address) self.dmarc_record = DMARC(self.domain, ip_address) self.bimi_record = BIMI(self.domain, ip_address) @@ -52,7 +53,7 @@ class DNS: self.dns_server = ip_address return - self.dns_server = '1.1.1.1' + self.dns_server = "1.1.1.1" def get_txt_record(self, record_type): """Returns the TXT record of a given type for the domain.""" @@ -65,9 +66,11 @@ class DNS: return None def __str__(self): - return (f"Domain: {self.domain}\n" - f"SOA Record: {self.soa_record}\n" - f"DNS Server: {self.dns_server}\n" - f"SPF Record: {self.spf_record.spf_record}\n" - f"DMARC Record: {self.dmarc_record.dmarc_record}\n" - f"BIMI Record: {self.bimi_record.bimi_record}") + return ( + f"Domain: {self.domain}\n" + f"SOA Record: {self.soa_record}\n" + f"DNS Server: {self.dns_server}\n" + f"SPF Record: {self.spf_record.spf_record}\n" + f"DMARC Record: {self.dmarc_record.dmarc_record}\n" + f"BIMI Record: {self.bimi_record.bimi_record}" + ) diff --git a/modules/report.py b/modules/report.py index a277739..4116fa5 100644 --- a/modules/report.py +++ b/modules/report.py @@ -7,6 +7,7 @@ from colorama import init, Fore, Style # Initialize colorama init() + def output_message(symbol, message, level="info"): """Generic function to print messages with different colors and symbols based on the level.""" colors = { @@ -15,11 +16,12 @@ def output_message(symbol, message, level="info"): "bad": Fore.RED + Style.BRIGHT, "indifferent": Fore.BLUE + Style.BRIGHT, "error": Fore.RED + Style.BRIGHT + "!!! ", - "info": Fore.WHITE + Style.BRIGHT + "info": Fore.WHITE + Style.BRIGHT, } color = colors.get(level, Fore.WHITE + Style.BRIGHT) print(color + f"{symbol} {message}" + Style.RESET_ALL) + def write_to_excel(data, file_name="output.xlsx"): """Writes a DataFrame of data to an Excel file, appending if the file exists.""" if os.path.exists(file_name) and os.path.getsize(file_name) > 0: @@ -30,27 +32,28 @@ def write_to_excel(data, file_name="output.xlsx"): else: pd.DataFrame(data).to_excel(file_name, index=False) + def printer(**kwargs): """Utility function to print the results of DMARC, SPF, and BIMI checks in the original format.""" - domain = kwargs.get('DOMAIN') - subdomain = kwargs.get('DOMAIN_TYPE') == 'subdomain' - dns_server = kwargs.get('DNS_SERVER') - spf_record = kwargs.get('SPF') - spf_all = kwargs.get('SPF_MULTIPLE_ALLS') - spf_dns_query_count = kwargs.get('SPF_NUM_DNS_QUERIES') - dmarc_record = kwargs.get('DMARC') - p = kwargs.get('DMARC_POLICY') - pct = kwargs.get('DMARC_PCT') - aspf = kwargs.get('DMARC_ASPF') - sp = kwargs.get('DMARC_SP') - fo = kwargs.get('DMARC_FORENSIC_REPORT') - rua = kwargs.get('DMARC_AGGREGATE_REPORT') - bimi_record = kwargs.get('BIMI_RECORD') - vbimi = kwargs.get('BIMI_VERSION') - location = kwargs.get('BIMI_LOCATION') - authority = kwargs.get('BIMI_AUTHORITY') - spoofable = kwargs.get('SPOOFING_POSSIBLE') - spoofing_type = kwargs.get('SPOOFING_TYPE') + domain = kwargs.get("DOMAIN") + subdomain = kwargs.get("DOMAIN_TYPE") == "subdomain" + dns_server = kwargs.get("DNS_SERVER") + spf_record = kwargs.get("SPF") + spf_all = kwargs.get("SPF_MULTIPLE_ALLS") + spf_dns_query_count = kwargs.get("SPF_NUM_DNS_QUERIES") + dmarc_record = kwargs.get("DMARC") + p = kwargs.get("DMARC_POLICY") + pct = kwargs.get("DMARC_PCT") + aspf = kwargs.get("DMARC_ASPF") + sp = kwargs.get("DMARC_SP") + fo = kwargs.get("DMARC_FORENSIC_REPORT") + rua = kwargs.get("DMARC_AGGREGATE_REPORT") + bimi_record = kwargs.get("BIMI_RECORD") + vbimi = kwargs.get("BIMI_VERSION") + location = kwargs.get("BIMI_LOCATION") + authority = kwargs.get("BIMI_AUTHORITY") + spoofable = kwargs.get("SPOOFING_POSSIBLE") + spoofing_type = kwargs.get("SPOOFING_TYPE") output_message("[*]", f"Domain: {domain}", "indifferent") output_message("[*]", f"Is subdomain: {subdomain}", "indifferent") @@ -61,21 +64,55 @@ def printer(**kwargs): if spf_all is None: output_message("[*]", "SPF does not contain an `All` item.", "info") elif spf_all == "2many": - output_message("[?]", "SPF record contains multiple `All` items.", "warning") + output_message( + "[?]", "SPF record contains multiple `All` items.", "warning" + ) else: output_message("[*]", f"SPF all record: {spf_all}", "info") - output_message("[*]", f"SPF DNS query count: {spf_dns_query_count}" if spf_dns_query_count <= 10 else f"Too many SPF DNS query lookups {spf_dns_query_count}.", "info") + output_message( + "[*]", + f"SPF DNS query count: {spf_dns_query_count}" + if spf_dns_query_count <= 10 + else f"Too many SPF DNS query lookups {spf_dns_query_count}.", + "info", + ) else: output_message("[?]", "No SPF record found.", "warning") if dmarc_record: output_message("[*]", f"DMARC record: {dmarc_record}", "info") - output_message("[*]", f"Found DMARC policy: {p}" if p else "No DMARC policy found.", "info") - output_message("[*]", f"Found DMARC pct: {pct}" if pct else "No DMARC pct found.", "info") - output_message("[*]", f"Found DMARC aspf: {aspf}" if aspf else "No DMARC aspf found.", "info") - output_message("[*]", f"Found DMARC subdomain policy: {sp}" if sp else "No DMARC subdomain policy found.", "info") - output_message("[*]", f"Forensics reports will be sent: {fo}" if fo else "No DMARC forensics report location found.", "indifferent") - output_message("[*]", f"Aggregate reports will be sent to: {rua}" if rua else "No DMARC aggregate report location found.", "indifferent") + output_message( + "[*]", f"Found DMARC policy: {p}" if p else "No DMARC policy found.", "info" + ) + output_message( + "[*]", f"Found DMARC pct: {pct}" if pct else "No DMARC pct found.", "info" + ) + output_message( + "[*]", + f"Found DMARC aspf: {aspf}" if aspf else "No DMARC aspf found.", + "info", + ) + output_message( + "[*]", + f"Found DMARC subdomain policy: {sp}" + if sp + else "No DMARC subdomain policy found.", + "info", + ) + output_message( + "[*]", + f"Forensics reports will be sent: {fo}" + if fo + else "No DMARC forensics report location found.", + "indifferent", + ) + output_message( + "[*]", + f"Aggregate reports will be sent to: {rua}" + if rua + else "No DMARC aggregate report location found.", + "indifferent", + ) else: output_message("[?]", "No DMARC record found.", "warning") diff --git a/modules/spf.py b/modules/spf.py index 0c6b68c..2365e96 100644 --- a/modules/spf.py +++ b/modules/spf.py @@ -3,6 +3,7 @@ import dns.resolver import re + class SPF: def __init__(self, domain, dns_server=None): self.domain = domain @@ -23,11 +24,11 @@ class SPF: if not domain: domain = self.domain resolver = dns.resolver.Resolver() - resolver.nameservers = [self.dns_server, '1.1.1.1', '8.8.8.8'] - query_result = resolver.resolve(domain, 'TXT') + resolver.nameservers = [self.dns_server, "1.1.1.1", "8.8.8.8"] + query_result = resolver.resolve(domain, "TXT") for record in query_result: - if 'spf1' in str(record): - spf_record = str(record).replace('"', '') + if "spf1" in str(record): + spf_record = str(record).replace('"', "") return spf_record return None except Exception: @@ -35,18 +36,18 @@ class SPF: def get_spf_all_string(self): """Returns the string value of the 'all' mechanism in the SPF record.""" - + spf_record = self.spf_record visited_domains = set() while spf_record: - all_matches = re.findall(r'[-~?+]all', spf_record) + all_matches = re.findall(r"[-~?+]all", spf_record) if len(all_matches) == 1: return all_matches[0] elif len(all_matches) > 1: - return '2many' + return "2many" - redirect_match = re.search(r'redirect=([\w.-]+)', spf_record) + redirect_match = re.search(r"redirect=([\w.-]+)", spf_record) if redirect_match: redirect_domain = redirect_match.group(1) if redirect_domain in visited_domains: @@ -57,42 +58,45 @@ class SPF: break return None - + def get_spf_dns_queries(self): """Returns the number of dns queries, redirects, and other mechanisms in the SPF record for a given domain.""" + def count_dns_queries(spf_record): count = 0 for item in spf_record.split(): if item.startswith("include:") or item.startswith("redirect="): if item.startswith("include:"): - url = item.replace('include:', '') + url = item.replace("include:", "") elif item.startswith("redirect="): - url = item.replace('redirect=', '') - + url = item.replace("redirect=", "") + count += 1 try: # Recursively fetch and count dns queries or redirects in the SPF record of the referenced domain - answers = dns.resolver.resolve(url, 'TXT') + answers = dns.resolver.resolve(url, "TXT") for rdata in answers: for txt_string in rdata.strings: - txt_record = txt_string.decode('utf-8') - if txt_record.startswith('v=spf1'): + txt_record = txt_string.decode("utf-8") + if txt_record.startswith("v=spf1"): count += count_dns_queries(txt_record) except Exception: pass - + # Count occurrences of 'a', 'mx', 'ptr', and 'exists' mechanisms count += len(re.findall(r"[ ,+]a[ ,:]", spf_record)) count += len(re.findall(r"[ ,+]mx[ ,:]", spf_record)) count += len(re.findall(r"[ ]ptr[ ]", spf_record)) count += len(re.findall(r"exists[:]", spf_record)) - + return count return count_dns_queries(self.spf_record) - + def __str__(self): - return (f"SPF Record: {self.spf_record}\n" - f"All Mechanism: {self.all_mechanism}\n" - f"DNS Query Count: {self.spf_dns_query_count}\n" - f"Too Many DNS Queries: {self.too_many_dns_queries}") \ No newline at end of file + return ( + f"SPF Record: {self.spf_record}\n" + f"All Mechanism: {self.all_mechanism}\n" + f"DNS Query Count: {self.spf_dns_query_count}\n" + f"Too Many DNS Queries: {self.too_many_dns_queries}" + ) diff --git a/modules/spoofing.py b/modules/spoofing.py index 747f310..3a4ace8 100644 --- a/modules/spoofing.py +++ b/modules/spoofing.py @@ -2,6 +2,7 @@ import tldextract + class Spoofing: def __init__(self, domain, p, aspf, spf_record, spf_all, spf_dns_queries, sp, pct): self.domain = domain @@ -45,11 +46,23 @@ class Spoofing: return 1 elif self.aspf is None and self.sp == "none": return 1 - elif self.p == "none" and (self.aspf == "r" or self.aspf is None) and self.sp is None: + elif ( + self.p == "none" + and (self.aspf == "r" or self.aspf is None) + and self.sp is None + ): return 4 - elif self.p == "none" and self.aspf == "r" and (self.sp == "reject" or self.sp == "quarantine"): + elif ( + self.p == "none" + and self.aspf == "r" + and (self.sp == "reject" or self.sp == "quarantine") + ): return 2 - elif self.p == "none" and self.aspf is None and (self.sp == "reject" or self.sp == "quarantine"): + elif ( + self.p == "none" + and self.aspf is None + and (self.sp == "reject" or self.sp == "quarantine") + ): return 5 elif self.p == "none" and self.aspf is None and self.sp == "none": return 7 @@ -62,26 +75,48 @@ class Spoofing: return 0 elif self.p == "none" and self.sp == "none": return 7 - elif (self.p == "reject" or self.p == "quarantine") and self.aspf is None and self.sp == "none": + elif ( + (self.p == "reject" or self.p == "quarantine") + and self.aspf is None + and self.sp == "none" + ): return 1 - elif (self.p == "reject" or self.p == "quarantine") and self.aspf and self.sp == "none": + elif ( + (self.p == "reject" or self.p == "quarantine") + and self.aspf + and self.sp == "none" + ): return 1 else: return 8 elif self.spf_all == "?all": - if (self.p == "reject" or self.p == "quarantine") and self.aspf and self.sp == "none": + if ( + (self.p == "reject" or self.p == "quarantine") + and self.aspf + and self.sp == "none" + ): return 6 - elif (self.p == "reject" or self.p == "quarantine") and self.aspf is None and self.sp == "none": + elif ( + (self.p == "reject" or self.p == "quarantine") + and self.aspf is None + and self.sp == "none" + ): return 6 elif self.p == "none" and self.aspf == "r" and self.sp is None: return 0 elif self.p == "none" and self.aspf == "r" and self.sp == "none": return 7 - elif self.p == "none" and self.aspf == "s" or None and self.sp == "none": + elif ( + self.p == "none" and self.aspf == "s" or None and self.sp == "none" + ): return 7 elif self.p == "none" and self.aspf == "s" or None and self.sp is None: return 6 - elif self.p == "none" and self.aspf and (self.sp == "reject" or self.sp == "quarantine"): + elif ( + self.p == "none" + and self.aspf + and (self.sp == "reject" or self.sp == "quarantine") + ): return 5 elif self.p == "none" and self.aspf is None and self.sp == "reject": return 5 @@ -104,10 +139,12 @@ class Spoofing: 5: f"Organizational domain spoofing might be possible for {self.domain}.", 6: f"Subdomain spoofing might be possible (Mailbox dependent) for {self.domain}.", 7: f"Subdomain spoofing is possible and organizational domain spoofing might be possible for {self.domain}.", - 8: f"Spoofing is not possible for {self.domain}." + 8: f"Spoofing is not possible for {self.domain}.", } - spoofing_type = spoofing_types.get(self.spoofable, f"Unknown spoofing type for {self.domain}.") + spoofing_type = spoofing_types.get( + self.spoofable, f"Unknown spoofing type for {self.domain}." + ) if self.spoofable in {0, 1, 3, 7}: spoofing_possible = True @@ -119,7 +156,9 @@ class Spoofing: return spoofing_possible, spoofing_type def __str__(self): - return (f"Domain: {self.domain}\n" - f"Domain Type: {self.domain_type}\n" - f"Spoofing Possible: {self.spoofing_possible}\n" - f"Spoofing Type: {self.spoofing_type}") + return ( + f"Domain: {self.domain}\n" + f"Domain Type: {self.domain_type}\n" + f"Spoofing Possible: {self.spoofing_possible}\n" + f"Spoofing Type: {self.spoofing_type}" + ) diff --git a/spoofy.py b/spoofy.py index 9e25553..db534d9 100755 --- a/spoofy.py +++ b/spoofy.py @@ -8,6 +8,7 @@ from modules.bimi import BIMI from modules.spoofing import Spoofing from modules import report + def process_domain(domain): """Process a domain to gather DNS, SPF, DMARC, and BIMI records, and evaluate spoofing potential.""" dns_info = DNS(domain) @@ -33,36 +34,46 @@ def process_domain(domain): bimi_location = bimi_info.location bimi_authority = bimi_info.authority - spoofing_info = Spoofing(domain, dmarc_p, dmarc_aspf, spf_record, spf_all, spf_dns_query_count, dmarc_sp, dmarc_pct) + spoofing_info = Spoofing( + domain, + dmarc_p, + dmarc_aspf, + spf_record, + spf_all, + spf_dns_query_count, + dmarc_sp, + dmarc_pct, + ) domain_type = spoofing_info.domain_type spoofing_possible = spoofing_info.spoofing_possible spoofing_type = spoofing_info.spoofing_type result = { - 'DOMAIN': domain, - 'DOMAIN_TYPE': domain_type, - 'DNS_SERVER': dns_info.dns_server, - 'SPF': spf_record, - 'SPF_MULTIPLE_ALLS': spf_all, - 'SPF_NUM_DNS_QUERIES': spf_dns_query_count, - 'SPF_TOO_MANY_DNS_QUERIES': spf_too_many_dns_queries, - 'DMARC': dmarc_record, - 'DMARC_POLICY': dmarc_p, - 'DMARC_PCT': dmarc_pct, - 'DMARC_ASPF': dmarc_aspf, - 'DMARC_SP': dmarc_sp, - 'DMARC_FORENSIC_REPORT': dmarc_fo, - 'DMARC_AGGREGATE_REPORT': dmarc_rua, - 'BIMI_RECORD': bimi_record, - 'BIMI_VERSION': bimi_version, - 'BIMI_LOCATION': bimi_location, - 'BIMI_AUTHORITY': bimi_authority, - 'SPOOFING_POSSIBLE': spoofing_possible, - 'SPOOFING_TYPE': spoofing_type + "DOMAIN": domain, + "DOMAIN_TYPE": domain_type, + "DNS_SERVER": dns_info.dns_server, + "SPF": spf_record, + "SPF_MULTIPLE_ALLS": spf_all, + "SPF_NUM_DNS_QUERIES": spf_dns_query_count, + "SPF_TOO_MANY_DNS_QUERIES": spf_too_many_dns_queries, + "DMARC": dmarc_record, + "DMARC_POLICY": dmarc_p, + "DMARC_PCT": dmarc_pct, + "DMARC_ASPF": dmarc_aspf, + "DMARC_SP": dmarc_sp, + "DMARC_FORENSIC_REPORT": dmarc_fo, + "DMARC_AGGREGATE_REPORT": dmarc_rua, + "BIMI_RECORD": bimi_record, + "BIMI_VERSION": bimi_version, + "BIMI_LOCATION": bimi_location, + "BIMI_AUTHORITY": bimi_authority, + "SPOOFING_POSSIBLE": spoofing_possible, + "SPOOFING_TYPE": spoofing_type, } return result + def worker(domain_queue, result_queue): """Worker function to process domains and put results into the result queue.""" while not domain_queue.empty(): @@ -72,20 +83,33 @@ def worker(domain_queue, result_queue): result = process_domain(domain) result_queue.put(result) domain_queue.task_done() + + def process_domain_and_output(domain, output, results): """Process a domain and handle output based on the specified format.""" result = process_domain(domain) - if output == 'stdout': + if output == "stdout": report.printer(**result) else: results.append(result) + def main(): - parser = argparse.ArgumentParser(description="Process domains to gather DNS, SPF, DMARC, and BIMI records.") + parser = argparse.ArgumentParser( + description="Process domains to gather DNS, SPF, DMARC, and BIMI records." + ) group = parser.add_mutually_exclusive_group(required=True) group.add_argument("-d", type=str, help="Single domain to process.") - group.add_argument("-iL", type=str, help="File containing a list of domains to process.") - parser.add_argument("-o", type=str, choices=['stdout', 'xls'], default='stdout', help="Output format: stdout or xls (default: stdout).") + group.add_argument( + "-iL", type=str, help="File containing a list of domains to process." + ) + parser.add_argument( + "-o", + type=str, + choices=["stdout", "xls"], + default="stdout", + help="Output format: stdout or xls (default: stdout).", + ) args = parser.parse_args() @@ -102,7 +126,9 @@ def main(): # Start threads to process each domain for domain in domains: - thread = threading.Thread(target=process_domain_and_output, args=(domain, args.o, results)) + thread = threading.Thread( + target=process_domain_and_output, args=(domain, args.o, results) + ) thread.start() threads.append(thread) @@ -111,9 +137,10 @@ def main(): thread.join() # Handle output for xls format - if args.o == 'xls' and results: + if args.o == "xls" and results: report.write_to_excel(results) print("Results written to output.xlsx") + if __name__ == "__main__": - main() \ No newline at end of file + main() From 030412f7bb0e8718ec0a696df5bfdeb8509d9580 Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Sun, 11 Aug 2024 12:57:01 -0700 Subject: [PATCH 23/36] add ruff --- .github/workflows/ci.yml | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1491246..b332a18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,12 +6,20 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: '3.x' - - name: Install dependencies - run: pip install -r requirements.txt - - name: Run tests - run: python3 test.py \ No newline at end of file + - uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: "3.x" + + - name: Install dependencies + run: | + pip install -r requirements.txt + pip install ruff + + - name: Run Ruff + run: ruff check . + + - name: Run tests + run: python3 test.py From 4094b7d60bd121234a44a618d1d10842d5350b02 Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Sun, 11 Aug 2024 13:09:44 -0700 Subject: [PATCH 24/36] implement accurate multi-threadding --- spoofy.py | 48 ++++++++++++++++++++++++++---------------------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/spoofy.py b/spoofy.py index db534d9..de46941 100755 --- a/spoofy.py +++ b/spoofy.py @@ -1,6 +1,7 @@ #! /usr/bin/env python3 import argparse import threading +from queue import Queue from modules.dns import DNS from modules.spf import SPF from modules.dmarc import DMARC @@ -8,6 +9,8 @@ from modules.bimi import BIMI from modules.spoofing import Spoofing from modules import report +print_lock = threading.Lock() + def process_domain(domain): """Process a domain to gather DNS, SPF, DMARC, and BIMI records, and evaluate spoofing potential.""" @@ -74,26 +77,21 @@ def process_domain(domain): return result -def worker(domain_queue, result_queue): - """Worker function to process domains and put results into the result queue.""" - while not domain_queue.empty(): +def worker(domain_queue, print_lock, output, results): + """Worker function to process domains and output results.""" + while True: domain = domain_queue.get() if domain is None: break result = process_domain(domain) - result_queue.put(result) + with print_lock: + if output == "stdout": + report.printer(**result) + else: + results.append(result) domain_queue.task_done() -def process_domain_and_output(domain, output, results): - """Process a domain and handle output based on the specified format.""" - result = process_domain(domain) - if output == "stdout": - report.printer(**result) - else: - results.append(result) - - def main(): parser = argparse.ArgumentParser( description="Process domains to gather DNS, SPF, DMARC, and BIMI records." @@ -110,37 +108,43 @@ def main(): default="stdout", help="Output format: stdout or xls (default: stdout).", ) + parser.add_argument( + "-t", type=int, default=4, help="Number of threads to use (default: 4)" + ) args = parser.parse_args() - # Load domains if args.d: domains = [args.d] elif args.iL: with open(args.iL, "r") as file: domains = [line.strip() for line in file] - # Prepare for processing + domain_queue = Queue() results = [] - threads = [] - # Start threads to process each domain for domain in domains: + domain_queue.put(domain) + + threads = [] + for _ in range(min(args.t, len(domains))): thread = threading.Thread( - target=process_domain_and_output, args=(domain, args.o, results) + target=worker, args=(domain_queue, print_lock, args.o, results) ) thread.start() threads.append(thread) - # Wait for all threads to complete - for thread in threads: - thread.join() + domain_queue.join() - # Handle output for xls format if args.o == "xls" and results: report.write_to_excel(results) print("Results written to output.xlsx") + for _ in range(len(threads)): + domain_queue.put(None) + for thread in threads: + thread.join() + if __name__ == "__main__": main() From a63504f5c93af0b5d6fd9f82fa869eda4c1fbd6e Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Mon, 12 Aug 2024 11:35:16 -0700 Subject: [PATCH 25/36] issue 26 fix --- modules/spoofing.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/modules/spoofing.py b/modules/spoofing.py index 3a4ace8..b14c751 100644 --- a/modules/spoofing.py +++ b/modules/spoofing.py @@ -30,6 +30,8 @@ class Spoofing: elif self.spf_record is None: if self.p is None: return 0 + elif self.p == "none": + return 4 else: return 8 elif self.spf_dns_queries > 10 and self.p is None: From ce128d5e890c4b48010009abc0aec091d3567380 Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Mon, 12 Aug 2024 11:35:37 -0700 Subject: [PATCH 26/36] update test cases --- test.py | 134 ++++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 111 insertions(+), 23 deletions(-) diff --git a/test.py b/test.py index da6c34c..9eea00d 100755 --- a/test.py +++ b/test.py @@ -1,55 +1,143 @@ import unittest from modules.spoofing import Spoofing + class TestSpoofy(unittest.TestCase): - ''' - 0: Indicates that spoofing is possible for the domain. - 1: Indicates that subdomain spoofing is possible for the domain. - 2: Indicates that organizational domain spoofing is possible for the domain. - 3: Indicates that spoofing might be possible for the domain. - 4: Indicates that spoofing might be possible (mailbox dependent) for the domain. - 5: Indicates that organizational domain spoofing may be possible for the domain. - 6: Indicates that subdomain spoofing might be possible (mailbox dependent) for the domain. - 7: Indicates that subdomain spoofing is possible, and organizational domain spoofing might be possible. - 8: Indicates that spoofing is not possible for the domain. - ''' + """ + 0: Indicates that spoofing is possible for the domain. + 1: Indicates that subdomain spoofing is possible for the domain. + 2: Indicates that organizational domain spoofing is possible for the domain. + 3: Indicates that spoofing might be possible for the domain. + 4: Indicates that spoofing might be possible (mailbox dependent) for the domain. + 5: Indicates that organizational domain spoofing may be possible for the domain. + 6: Indicates that subdomain spoofing might be possible (mailbox dependent) for the domain. + 7: Indicates that subdomain spoofing is possible, and organizational domain spoofing might be possible. + 8: Indicates that spoofing is not possible for the domain. + """ + + """ + --- Format --- + domain: str, + p: str, + aspf: str, + spf_record: str, + spf_all: str, + spf_dns_queries: int, + sp: None, + pct: int + """ def test_spoofing_is_possible(self): - spoofing = Spoofing('test_0.com', 'none', 'r', 'v=spf1 include:fake.gov', '~all', 3, None, 100) + spoofing = Spoofing( + "test_0.com", "none", "r", "v=spf1 include:fake.gov", "~all", 3, None, 100 + ) self.assertEqual(spoofing.spoofable, 0) def test_subdomain_spoofing(self): - spoofing = Spoofing("test_1.com", 'reject', None, 'v=spf1 include:fakest.domain.com', '-all', 3, 'none', None) + spoofing = Spoofing( + "test_1.com", + "reject", + None, + "v=spf1 include:fakest.domain.com", + "-all", + 3, + "none", + None, + ) self.assertEqual(spoofing.spoofable, 1) - + def test_organizational_domain_spoofing(self): - spoofing = Spoofing('test_2.com', 'none', 'r', 'v=spf1 include:fakest.domain.com include:faker.domain.com', '-all', 2, 'reject', 100) + spoofing = Spoofing( + "test_2.com", + "none", + "r", + "v=spf1 include:fakest.domain.com include:faker.domain.com", + "-all", + 2, + "reject", + 100, + ) self.assertEqual(spoofing.spoofable, 2) - + def test_spoofing_might_be_possible(self): - spoofing = Spoofing('test_3.com', 'none', None, 'v=spf1 include:fakest.domain.com', '~all', 1, 'quarantine', 90) + spoofing = Spoofing( + "test_3.com", + "none", + None, + "v=spf1 include:fakest.domain.com", + "~all", + 1, + "quarantine", + 90, + ) self.assertEqual(spoofing.spoofable, 3) def test_spoofing_might_be_possible_mbd(self): - spoofing = Spoofing('test_4.com', 'none', None, 'v=spf1 include:fakest.domain.com', '-all', 1, None, 100) + spoofing = Spoofing( + "test_4.com", + "none", + None, + "v=spf1 include:fakest.domain.com", + "-all", + 1, + None, + 100, + ) self.assertEqual(spoofing.spoofable, 4) def test_org_domain_spoofing_might_be_possible(self): - spoofing = Spoofing('test_5.com', 'none', None, 'v=spf1 include:fakest.domain.com', '-all', 1, 'reject', 100) + spoofing = Spoofing( + "test_5.com", + "none", + None, + "v=spf1 include:fakest.domain.com", + "-all", + 1, + "reject", + 100, + ) self.assertEqual(spoofing.spoofable, 5) def test_subdomain_spoofing_might_be_possible_mbd(self): - spoofing = Spoofing('test_6.com', 'reject', 'r', 'v=spf1 include:fakest.domain.com', '?all', 1, 'none', 100) + spoofing = Spoofing( + "test_6.com", + "reject", + "r", + "v=spf1 include:fakest.domain.com", + "?all", + 1, + "none", + 100, + ) self.assertEqual(spoofing.spoofable, 6) def test_subdomain_spoofing_and_org_spoofing_might_be_possible(self): - spoofing = Spoofing('test_7.com', 'none', None, 'v=spf1 include:fakest.domain.com', '~all', 3, 'none', 100) + spoofing = Spoofing( + "test_7.com", + "none", + None, + "v=spf1 include:fakest.domain.com", + "~all", + 3, + "none", + 100, + ) self.assertEqual(spoofing.spoofable, 7) def test_spoofing_not_possible(self): - spoofing = Spoofing('test_8.com', 'none', 's', 'v=spf1 include:fakest.domain.com', '~all', 1, 'quarantine', 100) + spoofing = Spoofing( + "test_8.com", + "none", + "s", + "v=spf1 include:domain.com", + "-all", + 1, + "reject", + 100, + ) self.assertEqual(spoofing.spoofable, 8) -if __name__ == '__main__': + +if __name__ == "__main__": unittest.main() From 80a72b2bc169a310758daedc5ceddce77ad391ef Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Mon, 12 Aug 2024 11:41:04 -0700 Subject: [PATCH 27/36] Update docs! --- README.md | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index a44c0ed..edbca36 100644 --- a/README.md +++ b/README.md @@ -5,42 +5,56 @@ Spoofy - [![forthebadge](https://forthebadge.com/images/badges/made-with-python.svg)](https://www.python.org/) [![forthebadge](https://forthebadge.com/images/badges/contains-tasty-spaghetti-code.svg)](https://www.thewholesomedish.com/spaghetti/) [![forthebadge](https://forthebadge.com/images/badges/it-works-why.svg)](https://www.youtube.com/watch?v=kyti25ol438) - ## WHAT + `Spoofy` is a program that checks if a list of domains can be spoofed based on SPF and DMARC records. You may be asking, "Why do we need another tool that can check if a domain can be spoofed?" Well, Spoofy is different and here is why: + > 1. Authoritative lookups on all lookups with known fallback (Cloudflare DNS) > 2. Accurate bulk lookups -> 3. Custom, manually tested spoof logic (No guessing or speculating, real world test results) -> 4. SPF lookup counter +> 3. Custom, manually tested spoof logic (No guessing or speculating, real world test results) +> 4. SPF DNS query counter ## PASSING TESTS + [![Spoofy CI](https://github.com/MattKeeley/Spoofy/actions/workflows/ci.yml/badge.svg)](https://github.com/MattKeeley/Spoofy/actions/workflows/ci.yml) ## HOW TO USE + `Spoofy` requires **Python 3+**. Python 2 is not supported. Usage is shown below: ```console Usage: - ./spoofy.py -d [DOMAIN] -o [stdout or xls] + ./spoofy.py -d [DOMAIN] -o [stdout or xls] -t [NUMBER_OF_THREADS] OR - ./spoofy.py -iL [DOMAIN_LIST] -o [stdout or xls] - + ./spoofy.py -iL [DOMAIN_LIST] -o [stdout or xls] -t [NUMBER_OF_THREADS] + +Options: + -d : Process a single domain. + -iL : Provide a file containing a list of domains to process. + -o : Specify the output format: stdout (default) or xls. + -t : Set the number of threads to use (default: 4). + +Examples: + ./spoofy.py -d example.com -t 10 + ./spoofy.py -iL domains.txt -o xls + Install Dependencies: pip3 install -r requirements.txt ``` ## HOW DO YOU KNOW ITS SPOOFABLE + (The spoofability table lists every combination of SPF and DMARC configurations that impact deliverability to the inbox, except for DKIM modifiers.) [Download Here](/files/Master_Table.xlsx) -## METHODOLOGY +## METHODOLOGY + The creation of the spoofability table involved listing every relevant SPF and DMARC configuration, combining them, and then conducting SPF and DMARC information collection using an early version of Spoofy on a large number of US government domains. Testing if an SPF and DMARC combination was spoofable or not was done using the email security pentesting suite at [emailspooftest](https://emailspooftest.com/) using Microsoft 365. However, the initial testing was conducted using Protonmail and Gmail, but these services were found to utilize reverse lookup checks that affected the results, particularly for subdomain spoof testing. As a result, Microsoft 365 was used for the testing, as it offered greater control over the handling of mail. After the initial testing using Microsoft 365, some combinations were retested using Protonmail and Gmail due to the differences in their handling of banners in emails. Protonmail and Gmail can place spoofed mail in the inbox with a banner or in spam without a banner, leading to some SPF and DMARC combinations being reported as "Mailbox Dependent" when using Spoofy. In contrast, Microsoft 365 places both conditions in spam. The testing and data collection process took several days to complete, after which a good master table was compiled and used as the basis for the Spoofy spoofability logic. @@ -63,7 +77,6 @@ Logo: cobracode Tool was inspired by [Bishop Fox's](https://github.com/BishopFox/) project called [spoofcheck](https://github.com/BishopFox/spoofcheck/). - ## LICENSE This project is licensed under the Creative Commons Zero v1.0 Universal - see the [LICENSE](LICENSE) file for details From 774aecabd59fa078c3edf6eb3c5b6725eb0c67dd Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Mon, 12 Aug 2024 15:06:19 -0700 Subject: [PATCH 28/36] add dmarc record --- modules/spoofing.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/modules/spoofing.py b/modules/spoofing.py index b14c751..0aff536 100644 --- a/modules/spoofing.py +++ b/modules/spoofing.py @@ -1,11 +1,24 @@ # modules/spoofing.py import tldextract +from .syntax import validate_record_syntax class Spoofing: - def __init__(self, domain, p, aspf, spf_record, spf_all, spf_dns_queries, sp, pct): + def __init__( + self, + domain, + dmarc_record, + p, + aspf, + spf_record, + spf_all, + spf_dns_queries, + sp, + pct, + ): self.domain = domain + self.dmarc_record = dmarc_record self.p = p self.aspf = aspf self.spf_record = spf_record From 221fbd5a6f59a9bd498ec1e06ebf66604b02569b Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Mon, 12 Aug 2024 15:06:34 -0700 Subject: [PATCH 29/36] catch edge case --- modules/spoofing.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/modules/spoofing.py b/modules/spoofing.py index 0aff536..1fb9f29 100644 --- a/modules/spoofing.py +++ b/modules/spoofing.py @@ -142,6 +142,16 @@ class Spoofing: except Exception: print("If you hit this error message, Open an issue with your testcase.") return 8 + # If you are here, this means you caught a domain with a syntax error! + spf_valid = validate_record_syntax(self.spf_record, "SPF") + dmarc_valid = validate_record_syntax(self.dmarc_record, "DMARC") + + if (not spf_valid and not dmarc_valid) or (spf_valid and not dmarc_valid): + return 0 + elif not spf_valid and dmarc_valid and self.p == "none": + return 3 + else: + return 8 def evaluate_spoofing(self): """Evaluates and returns whether spoofing is possible and the type of spoofing.""" From c36a8cff1e6d0f8d34744953cc4c54ed018492ab Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Mon, 12 Aug 2024 15:06:38 -0700 Subject: [PATCH 30/36] Update spoofing.py --- modules/spoofing.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/modules/spoofing.py b/modules/spoofing.py index 1fb9f29..c09a1f9 100644 --- a/modules/spoofing.py +++ b/modules/spoofing.py @@ -140,8 +140,6 @@ class Spoofing: else: return 8 except Exception: - print("If you hit this error message, Open an issue with your testcase.") - return 8 # If you are here, this means you caught a domain with a syntax error! spf_valid = validate_record_syntax(self.spf_record, "SPF") dmarc_valid = validate_record_syntax(self.dmarc_record, "DMARC") From 256671d8d0bd24c1b015ddffbd0f38e5edcb1907 Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Mon, 12 Aug 2024 15:06:42 -0700 Subject: [PATCH 31/36] Create syntax.py --- modules/syntax.py | 102 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 modules/syntax.py diff --git a/modules/syntax.py b/modules/syntax.py new file mode 100644 index 0000000..cbc929e --- /dev/null +++ b/modules/syntax.py @@ -0,0 +1,102 @@ +# modules/syntax.py +import re + + +def validate_record_syntax(record, record_type): + """Validate the syntax of a DNS record (SPF or DMARC).""" + + if record_type == "SPF": + mechanism_patterns = { + "all": r"^all$", + "include": r"^include:[\w\.\-]+\.[a-zA-Z]{2,}$", + "a": r"^a(:[\w\.\-]+)?$", + "mx": r"^mx(:[\w\.\-]+)?$", + "ptr": r"^ptr(:[\w\.\-]+)?$", + "ip4": r"^ip4:(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$", + "ip6": r"^ip6:[a-fA-F0-9:]+(\/\d{1,3})?$", + "exists": r"^exists:[\w\.\-]+\.[a-zA-Z]{2,}$", + } + + modifier_patterns = { + "redirect": r"^redirect=[\w\.\-]+\.[a-zA-Z]{2,}$", + "exp": r"^exp=[\w\.\-]+\.[a-zA-Z]{2,}$", + } + + elements = record.split() + + if len(elements) == 0 or elements[0].strip().lower() != "v=spf1": + return False + + for element in elements[1:]: + element = element.strip() + + if element[0] in ["+", "-", "~", "?"]: + qualifier = element[0] + element = element[1:] + else: + qualifier = "+" + + if ":" in element: + mechanism, value = element.split(":", 1) + elif "=" in element: + modifier, value = element.split("=", 1) + mechanism = modifier + else: + mechanism = element + value = None + + if mechanism in mechanism_patterns: + pattern = mechanism_patterns[mechanism] + if value: + if not re.match(pattern, element): + return False + elif not re.match(r"^" + mechanism + r"$", element): + return False + + elif mechanism in modifier_patterns: + pattern = modifier_patterns[mechanism] + if not re.match(pattern, element): + return False + else: + return False + elif record_type == "DMARC": + tag_patterns = { + "v": r"^DMARC1$", + "p": r"^(none|quarantine|reject)$", + "sp": r"^(none|quarantine|reject)$", + "pct": r"^(100|[1-9]?[0-9])$", + "rua": r"^[\w\.\-]+@[\w\.\-]+\.[a-zA-Z]{2,}$", + "ruf": r"^[\w\.\-]+@[\w\.\-]+\.[a-zA-Z]{2,}$", + "rf": r"^(afrf)$", + "fo": r"^(0|1|d|s)$", + "ri": r"^\d+$", + "aspf": r"^(r|s)$", + "adkim": r"^(r|s)$", + } + + tags = record.split(";") + + if len(tags) < 2 or not tags[0].strip().startswith("v=DMARC1"): + return False + + for tag in tags: + tag = tag.strip() + if not tag: + continue + tag_key_value = tag.split("=") + + if len(tag_key_value) != 2: + return False + + tag_key, tag_value = tag_key_value + tag_key = tag_key.lower().strip() + tag_value = tag_value.strip() + + if tag_key in tag_patterns: + if not re.match(tag_patterns[tag_key], tag_value): + return False + else: + return False + else: + return False + return True From c7541e3c065a38bd1746eac72f12806c4533477c Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Mon, 12 Aug 2024 15:06:46 -0700 Subject: [PATCH 32/36] Update spoofy.py --- spoofy.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/spoofy.py b/spoofy.py index de46941..701a714 100755 --- a/spoofy.py +++ b/spoofy.py @@ -1,4 +1,6 @@ #! /usr/bin/env python3 + +# spoofy.py import argparse import threading from queue import Queue @@ -39,6 +41,7 @@ def process_domain(domain): spoofing_info = Spoofing( domain, + dmarc_record, dmarc_p, dmarc_aspf, spf_record, From 04228d163599665509692134ba8b83a266711009 Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Mon, 12 Aug 2024 15:13:34 -0700 Subject: [PATCH 33/36] better formatting --- test.py | 159 +++++++++++++++++++++++++++++--------------------------- 1 file changed, 81 insertions(+), 78 deletions(-) diff --git a/test.py b/test.py index 9eea00d..27cf318 100755 --- a/test.py +++ b/test.py @@ -3,19 +3,6 @@ from modules.spoofing import Spoofing class TestSpoofy(unittest.TestCase): - - """ - 0: Indicates that spoofing is possible for the domain. - 1: Indicates that subdomain spoofing is possible for the domain. - 2: Indicates that organizational domain spoofing is possible for the domain. - 3: Indicates that spoofing might be possible for the domain. - 4: Indicates that spoofing might be possible (mailbox dependent) for the domain. - 5: Indicates that organizational domain spoofing may be possible for the domain. - 6: Indicates that subdomain spoofing might be possible (mailbox dependent) for the domain. - 7: Indicates that subdomain spoofing is possible, and organizational domain spoofing might be possible. - 8: Indicates that spoofing is not possible for the domain. - """ - """ --- Format --- domain: str, @@ -30,111 +17,127 @@ class TestSpoofy(unittest.TestCase): def test_spoofing_is_possible(self): spoofing = Spoofing( - "test_0.com", "none", "r", "v=spf1 include:fake.gov", "~all", 3, None, 100 + domain="test_0.com", + dmarc_record="v=DMARC1; p=none;", + p="none", + aspf="r", + spf_record="v=spf1 include:fake.gov", + spf_all="~all", + spf_dns_queries=3, + sp=None, + pct=100 ) self.assertEqual(spoofing.spoofable, 0) def test_subdomain_spoofing(self): spoofing = Spoofing( - "test_1.com", - "reject", - None, - "v=spf1 include:fakest.domain.com", - "-all", - 3, - "none", - None, + domain="test_1.com", + dmarc_record="v=DMARC1; p=reject;", + p="none", + aspf=None, + spf_record="v=spf1 include:fakest.domain.com", + spf_all="-all", + spf_dns_queries=3, + sp="none", + pct=100, ) self.assertEqual(spoofing.spoofable, 1) def test_organizational_domain_spoofing(self): spoofing = Spoofing( - "test_2.com", - "none", - "r", - "v=spf1 include:fakest.domain.com include:faker.domain.com", - "-all", - 2, - "reject", - 100, + domain="test_2.com", + dmarc_record="v=DMARC1; p=none;", + p="none", + aspf="r", + spf_record="v=spf1 include:fakest.domain.com include:faker.domain.com", + spf_all="-all", + spf_dns_queries=2, + sp="reject", + pct=100, ) self.assertEqual(spoofing.spoofable, 2) def test_spoofing_might_be_possible(self): spoofing = Spoofing( - "test_3.com", - "none", - None, - "v=spf1 include:fakest.domain.com", - "~all", - 1, - "quarantine", - 90, + domain="test_3.com", + dmarc_record="v=DMARC1; p=none;", + p="none", + aspf=None, + spf_record="v=spf1 include:fakest.domain.com", + spf_all="~all", + spf_dns_queries=1, + sp="quarantine", + pct=90, ) self.assertEqual(spoofing.spoofable, 3) def test_spoofing_might_be_possible_mbd(self): spoofing = Spoofing( - "test_4.com", - "none", - None, - "v=spf1 include:fakest.domain.com", - "-all", - 1, - None, - 100, + domain="test_4.com", + dmarc_record="v=DMARC1; p=none;", + p="none", + aspf=None, + spf_record="v=spf1 include:fakest.domain.com", + spf_all="-all", + spf_dns_queries=1, + sp=None, + pct=100, ) self.assertEqual(spoofing.spoofable, 4) def test_org_domain_spoofing_might_be_possible(self): spoofing = Spoofing( - "test_5.com", - "none", - None, - "v=spf1 include:fakest.domain.com", - "-all", - 1, - "reject", - 100, + domain="test_5.com", + dmarc_record="v=DMARC1; p=none;", + p="none", + aspf=None, + spf_record="v=spf1 include:fakest.domain.com", + spf_all="-all", + spf_dns_queries=1, + sp="reject", + pct=100, ) self.assertEqual(spoofing.spoofable, 5) def test_subdomain_spoofing_might_be_possible_mbd(self): spoofing = Spoofing( - "test_6.com", - "reject", - "r", - "v=spf1 include:fakest.domain.com", - "?all", - 1, - "none", - 100, + domain="test_6.com", + dmarc_record="v=DMARC1; p=reject;", + p="reject", + aspf="r", + spf_record="v=spf1 include:fakest.domain.com", + spf_all="?all", + spf_dns_queries=1, + sp="none", + pct=100, ) self.assertEqual(spoofing.spoofable, 6) def test_subdomain_spoofing_and_org_spoofing_might_be_possible(self): spoofing = Spoofing( - "test_7.com", - "none", - None, - "v=spf1 include:fakest.domain.com", - "~all", - 3, - "none", - 100, + domain="test_7.com", + dmarc_record="v=DMARC1; p=none;", + p="none", + aspf=None, + spf_record="v=spf1 include:fakest.domain.com", + spf_all="~all", + spf_dns_queries=3, + sp="none", + pct=100, ) self.assertEqual(spoofing.spoofable, 7) def test_spoofing_not_possible(self): spoofing = Spoofing( - "test_8.com", - "none", - "s", - "v=spf1 include:domain.com", - "-all", - 1, - "reject", - 100, + domain="test_8.com", + dmarc_record="v=DMARC1; p=none;", + p="none", + aspf="s", + spf_record="v=spf1 include:domain.com", + spf_all="-all", + spf_dns_queries=1, + sp="reject", + pct=100, ) self.assertEqual(spoofing.spoofable, 8) From 4703d1608856728dd833010a0e7ca5d4bf013c1c Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Mon, 12 Aug 2024 15:13:46 -0700 Subject: [PATCH 34/36] Update test.py --- test.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/test.py b/test.py index 27cf318..4b5ef22 100755 --- a/test.py +++ b/test.py @@ -3,18 +3,6 @@ from modules.spoofing import Spoofing class TestSpoofy(unittest.TestCase): - """ - --- Format --- - domain: str, - p: str, - aspf: str, - spf_record: str, - spf_all: str, - spf_dns_queries: int, - sp: None, - pct: int - """ - def test_spoofing_is_possible(self): spoofing = Spoofing( domain="test_0.com", From c99233b94a4a32087f8a0aff242281df6652cbb0 Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Mon, 12 Aug 2024 15:14:58 -0700 Subject: [PATCH 35/36] redundant --- modules/syntax.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/modules/syntax.py b/modules/syntax.py index cbc929e..ec2a18a 100644 --- a/modules/syntax.py +++ b/modules/syntax.py @@ -30,12 +30,6 @@ def validate_record_syntax(record, record_type): for element in elements[1:]: element = element.strip() - if element[0] in ["+", "-", "~", "?"]: - qualifier = element[0] - element = element[1:] - else: - qualifier = "+" - if ":" in element: mechanism, value = element.split(":", 1) elif "=" in element: From b3fd5e9a20a1ccb4aeb981907c62e6369e1ff96c Mon Sep 17 00:00:00 2001 From: Matt Keeley Date: Mon, 12 Aug 2024 15:46:41 -0700 Subject: [PATCH 36/36] black formatting --- test.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/test.py b/test.py index 4b5ef22..bbaf332 100755 --- a/test.py +++ b/test.py @@ -5,15 +5,15 @@ from modules.spoofing import Spoofing class TestSpoofy(unittest.TestCase): def test_spoofing_is_possible(self): spoofing = Spoofing( - domain="test_0.com", - dmarc_record="v=DMARC1; p=none;", - p="none", - aspf="r", - spf_record="v=spf1 include:fake.gov", - spf_all="~all", - spf_dns_queries=3, - sp=None, - pct=100 + domain="test_0.com", + dmarc_record="v=DMARC1; p=none;", + p="none", + aspf="r", + spf_record="v=spf1 include:fake.gov", + spf_all="~all", + spf_dns_queries=3, + sp=None, + pct=100, ) self.assertEqual(spoofing.spoofable, 0)