Skip to content

Commit ca2740f

Browse files
author
Raphaël Droz
authored
class-based rewrite
- use argparse & logging - ability to handle an inlined SPF record (root domain name is optional) - Get rid of all the globals that made variable/state tracking impossible and handle recursion in a clean way - Rewritten on top of #3
1 parent 9389dd2 commit ca2740f

File tree

1 file changed

+185
-141
lines changed

1 file changed

+185
-141
lines changed

SPFlatten.py

Lines changed: 185 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -1,148 +1,192 @@
1-
#!/usr/bin/env python
2-
from __future__ import print_function
3-
import re, dns.resolver
1+
#!/usr/bin/env python3
42

5-
#-----------------------------------------------------
3+
import argparse
4+
import logging
5+
import re
6+
import dns.resolver
7+
8+
# -----------------------------------------------------
69
# SPFlattener - Because who needs limits??
710
# Requires: dnspython
8-
# Usage: edit the "root_domain" variable below and run
9-
#-----------------------------------------------------
11+
# Usage: SPFlatten.py yourdmomain.com and-optional-others.net etc.org
12+
# -----------------------------------------------------
1013
# To-do:
1114
# Confirm that SPF doesn't follow CNAMES (I don't think it does)
1215
# Should we consider Sender ID? ie spf2.0 (probably not)
1316

14-
#---------------------------------
15-
root_domains = ['facebook.com','salesforce.com','zendesk.com','google.com','airbus.com','boeing.com']
16-
17-
#---------------------------------
18-
19-
20-
def main():
21-
global all_mechanism
22-
global root_domain
23-
global spf_nonflat_mechanisms
24-
global spf_ip_list
25-
26-
for root_domain in root_domains:
27-
flat_spf= ''
28-
all_mechanism = ''
29-
spf_nonflat_mechanisms= []
30-
spf_ip_list= []
31-
32-
print("#### SPF for %s ####" % root_domain)
33-
34-
flatten_spf(root_domain)
35-
36-
dedupe_spf_ip_list = list(set(spf_ip_list))
37-
38-
flat_spf = "v=spfv1"
39-
for ip in dedupe_spf_ip_list:
40-
if re.match(r'.*:.*', ip):
41-
flat_spf += (" ip6:" + ip)
42-
else:
43-
flat_spf += (" ip4:" + ip)
44-
45-
for mechanism in spf_nonflat_mechanisms:
46-
flat_spf += mechanism
47-
48-
flat_spf += all_mechanism
49-
50-
print("\nFlattened SPF:\n----------------------\n", flat_spf)
51-
52-
53-
# Recursively flatten the SPF record for the specified domain
54-
def flatten_spf(domain):
55-
print("--- Flattening:", domain, "---")
56-
try:
57-
txt_records = dns.resolver.query(domain, "TXT")
58-
except dns.exception.DNSException:
59-
print("No TXT records for:", domain)
60-
return
61-
62-
for record in txt_records:
63-
print("TXT record for:", domain, ":", str(record))
64-
joinrecord= ''.join([ x for x in str(record).split('"') if x.strip()])
65-
fields = joinrecord.split(' ')
66-
67-
if re.match(r'v=spf1', fields[0]):
68-
for field in fields:
69-
parse_mechanism(field, domain)
70-
71-
72-
# Parse the given mechansim, and dispatch it accordintly
73-
def parse_mechanism(mechanism, domain):
74-
if re.match(r'^a$', mechanism):
75-
convert_domain_to_ipv4(domain)
76-
elif re.match(r'^mx$', mechanism):
77-
print("MX found for", root_domain, ":", mechanism)
78-
convert_mx_to_ipv4(root_domain)
79-
elif re.match(r'^a:.*$', mechanism):
80-
match = re.match(r'^a:(.*)$', mechanism)
81-
convert_domain_to_ipv4(match.group(1))
82-
elif re.match(r'^ip4:.*$', mechanism):
83-
match = re.match(r'^ip4:(.*)$', mechanism)
84-
print("IPv4 address found for", domain, ":", match.group(1))
85-
spf_ip_list.append(match.group(1))
86-
elif re.match(r'^ip6:.*$', mechanism):
87-
match = re.match(r'^ip6:(.*)$', mechanism)
88-
print("IPv6 address found for", domain, ":", match.group(1))
89-
spf_ip_list.append(match.group(1))
90-
elif re.match(r'^ptr.*$', mechanism):
91-
print("PTR found for", domain, ":", mechanism)
92-
spf_nonflat_mechanisms.append(mechanism)
93-
elif re.match(r'^exists:$', mechanism):
94-
print("Exists found for", domain, ":", mechanism)
95-
spf_nonflat_mechanisms.append(mechanism)
96-
elif re.match(r'^redirect(?:[\=\:])\ ?(.*)$', mechanism):
97-
print("Redirect found for", domain, ":", mechanism)
98-
spf_nonflat_mechanisms.append(mechanism)
99-
flatten_spf(re.match(r'^redirect(?:[\=\:])\ ?(.*)$', mechanism).group(1))
100-
elif re.match(r'^exp:$', mechanism):
101-
print("EXP found for", domain, ":", mechanism)
102-
spf_nonflat_mechanisms.append(mechanism)
103-
elif re.match(r'^.all$', mechanism):
104-
if domain == root_domain:
105-
match = re.match(r'^(.all)$', mechanism)
106-
print("All found for", domain, ":", match.group(1))
107-
all_mechanism = " " + str(match.group(1))
108-
elif re.match(r'^include:.*$', mechanism):
109-
match = re.match(r'^include:(.*)', mechanism)
110-
flatten_spf(match.group(1)) # recursion
111-
112-
113-
# Convert A/AAAA records to IPs and adds them to the SPF master list
114-
def convert_domain_to_ipv4(domain):
115-
a_records = []
116-
aaaa_records = []
117-
118-
try:
119-
a_records = dns.resolver.query(domain, "A")
120-
for ip in a_records:
121-
print("A record for", domain, ":", str(ip))
122-
spf_ip_list.append(str(ip))
123-
except dns.exception.DNSException:
124-
pass
125-
126-
try:
127-
aaaa_records = dns.resolver.query(domain, "AAAA")
128-
for ip in aaaa_records:
129-
print("A record for", domain, ":", str(ip))
130-
spf_ip_list.append(str(ip))
131-
except dns.exception.DNSException:
132-
pass
133-
134-
135-
# Convert MX records to IPs and adds them to the SPF master list
136-
def convert_mx_to_ipv4(domain):
137-
try:
138-
mx_records = dns.resolver.query(domain, "MX")
139-
except dns.exception.DNSException:
140-
import pdb; pdb.set_trace()
141-
return
142-
143-
for record in mx_records:
144-
mx = str(record).split(' ')
145-
print("MX record found for ", domain, ": ", mx[1])
146-
convert_domain_to_ipv4(mx[1])
147-
148-
if __name__ == "__main__": main()
17+
# ---------------------------------
18+
19+
logger = logging.getLogger('spflat')
20+
21+
class Flattener:
22+
def __init__(self, domain=None, root_domain=None):
23+
self.all_mechanism = ''
24+
self.spf_nonflat_mechanisms = []
25+
self.spf_ip_list = []
26+
self.domain = domain
27+
self.root_domain = root_domain
28+
29+
def dump(self):
30+
'''
31+
Deduplicate (possibly nested) records
32+
'''
33+
dedupe_spf_ip_list = list(set(self.spf_ip_list))
34+
flat_spf = "v=spf1"
35+
for ip in dedupe_spf_ip_list:
36+
if re.match(r'.*:.*', ip):
37+
flat_spf += (" ip6:" + ip)
38+
else:
39+
flat_spf += (" ip4:" + ip)
40+
41+
for mechanism in self.spf_nonflat_mechanisms:
42+
flat_spf += " " + mechanism
43+
flat_spf += self.all_mechanism
44+
45+
logger.info("%s flattened SPF", self.domain)
46+
print(flat_spf)
47+
48+
def flatten_domain(self):
49+
logger.debug("Flattening: %s", self.domain)
50+
try:
51+
txt_records = dns.resolver.resolve(self.domain, "TXT")
52+
except dns.exception.DNSException:
53+
logger.debug("No TXT records for: %s", self.domain)
54+
return
55+
56+
for record in txt_records:
57+
logger.debug("TXT record for: %s:%s", self.domain, str(record))
58+
joinrecord = ''.join([x for x in str(record).split('"') if x.strip()])
59+
fields = joinrecord.split(' ')
60+
61+
if re.match(r'v=spf1', fields[0]):
62+
self.flatten_record(joinrecord)
63+
64+
# Recursively flatten the SPF record for the specified domain
65+
def flatten_record(self, record):
66+
for field in record.split(' '):
67+
self.parse_mechanism(field)
68+
69+
# Parse the given mechansim, and dispatch it accordintly
70+
def parse_mechanism(self, mechanism):
71+
if re.match(r'^a$', mechanism):
72+
self.spf_ip_list.extend(Flattener.convert_domain_to_ipv4(self.domain))
73+
elif re.match(r'^mx$', mechanism):
74+
logger.debug("MX found for %s:%s", self.domain, mechanism)
75+
self.spf_ip_list.extend(Flattener.convert_mx_to_ipv4(self.domain))
76+
elif re.match(r'^a:.*$', mechanism):
77+
match = re.match(r'^a:(.*)$', mechanism)
78+
self.spf_ip_list.extend(Flattener.convert_domain_to_ipv4(match.group(1)))
79+
elif re.match(r'^ip4:.*$', mechanism):
80+
match = re.match(r'^ip4:(.*)$', mechanism)
81+
logger.debug("IPv4 address found for %s:%s", self.domain, match.group(1))
82+
self.spf_ip_list.append(match.group(1))
83+
elif re.match(r'^ip6:.*$', mechanism):
84+
match = re.match(r'^ip6:(.*)$', mechanism)
85+
logger.debug("IPv6 address found for %s:%s", self.domain, match.group(1))
86+
self.spf_ip_list.append(match.group(1))
87+
elif re.match(r'^ptr.*$', mechanism):
88+
logger.debug("PTR found for %s:%s", self.domain, mechanism)
89+
self.spf_nonflat_mechanisms.append(mechanism)
90+
elif re.match(r'^exists:$', mechanism):
91+
logger.debug("Exists found for %s:%s", self.domain, mechanism)
92+
self.spf_nonflat_mechanisms.append(mechanism)
93+
elif re.match(r'^redirect(?:[=:]) ?(.*)$', mechanism):
94+
logger.debug("Redirect found for %s:%s", self.domain, mechanism)
95+
match = re.match(r'^redirect(?:[=:]) ?(.*)', mechanism)
96+
newdom = Flattener(match.group(1), self.root_domain)
97+
newdom.flatten_domain() # recursion
98+
self.spf_nonflat_mechanisms.extend(newdom.spf_nonflat_mechanisms)
99+
self.spf_ip_list.extend(newdom.spf_ip_list)
100+
#
101+
# self.spf_nonflat_mechanisms.append(mechanism)
102+
# flatten_domain(re.match(r'^redirect(?:[\=\:])\ ?(.*)$', mechanism).group(1))
103+
elif re.match(r'^exp:$', mechanism):
104+
logger.debug("EXP found for %s:%s", self.domain, mechanism)
105+
self.spf_nonflat_mechanisms.append(mechanism)
106+
elif re.match(r'^.all$', mechanism):
107+
if self.domain == self.root_domain or self.all_mechanism == '':
108+
match = re.match(r'^(.all)$', mechanism)
109+
logger.debug("All found for %s:%s", self.domain, match.group(1))
110+
self.all_mechanism = " " + str(match.group(1))
111+
elif re.match(r'^include:.*$', mechanism):
112+
match = re.match(r'^include:(.*)', mechanism)
113+
newdom = Flattener(match.group(1), self.root_domain)
114+
newdom.flatten_domain() # recursion
115+
self.spf_nonflat_mechanisms.extend(newdom.spf_nonflat_mechanisms)
116+
self.spf_ip_list.extend(newdom.spf_ip_list)
117+
118+
119+
# Convert A/AAAA records to IPs and adds them to the SPF master list
120+
@staticmethod
121+
def convert_domain_to_ipv4(domain):
122+
if not domain:
123+
logger.warning("Can't resolve \"a\" or \"mx\" mechanism without specifying a domain. Results will be partial")
124+
return []
125+
126+
spf_ip_list = []
127+
try:
128+
a_records = dns.resolver.resolve(domain, "A")
129+
for ip in a_records:
130+
logger.debug("A record for %s:%s", domain, str(ip))
131+
spf_ip_list.append(str(ip))
132+
except dns.exception.DNSException:
133+
pass
134+
135+
try:
136+
aaaa_records = dns.resolver.resolve(domain, "AAAA")
137+
for ip in aaaa_records:
138+
logger.debug("A record for %s:%s", domain, str(ip))
139+
spf_ip_list.append(str(ip))
140+
except dns.exception.DNSException:
141+
pass
142+
143+
return spf_ip_list
144+
145+
# Convert MX records to IPs and adds them to the SPF master list
146+
@staticmethod
147+
def convert_mx_to_ipv4(domain):
148+
try:
149+
mx_records = dns.resolver.resolve(domain, "MX")
150+
except dns.exception.DNSException:
151+
import pdb
152+
pdb.set_trace()
153+
return []
154+
155+
spf_ip_list = []
156+
for record in mx_records:
157+
mx = str(record).split(' ')
158+
logger.debug("MX record found for %s:%s ", domain, mx[1])
159+
spf_ip_list.extend(Flattener.convert_domain_to_ipv4(mx[1]))
160+
161+
return spf_ip_list
162+
163+
164+
if __name__ == "__main__":
165+
parser = argparse.ArgumentParser(
166+
description="Flatten SPF records",
167+
formatter_class=argparse.RawTextHelpFormatter,
168+
epilog='foo')
169+
parser.add_argument('values', nargs='+',
170+
help='One or more domains from which SPF will be fetched and flattened.')
171+
parser.add_argument('-i', '--inlined', action='store_true',
172+
help='If set, the argument is expect to be an inlined SPF records.')
173+
parser.add_argument('-d', '--domain',
174+
help='With -i, specify the domain in order to resolve "a" or "mx" records.')
175+
parser.add_argument('-v', '--verbose', action='count', default=0,
176+
help='More verbose')
177+
178+
args = parser.parse_args()
179+
180+
levels = [logging.WARNING, logging.INFO, logging.DEBUG]
181+
level = levels[min(len(levels)-1, args.verbose)]
182+
logging.basicConfig(level=level)
183+
184+
if args.inlined:
185+
f = Flattener(args.domain)
186+
f.flatten_record(args.values[0])
187+
f.dump()
188+
else:
189+
for dom in args.values:
190+
f = Flattener(dom)
191+
f.flatten_domain()
192+
f.dump()

0 commit comments

Comments
 (0)