33"""
44
55import re
6- from typing import Dict , List
6+ from typing import Dict , List , Optional , Tuple
77
88import dns .resolver
99
@@ -17,6 +17,137 @@ def normalize_txt_value(value: str) -> str:
1717 return re .sub (r"\;$" , "" , re .sub (r"\s*\;\s*" , ";" , value .strip ('"' )))
1818
1919
20+ def parse_dkim_tags (value : str ) -> Optional [Dict [str , str ]]:
21+ """Parse a DKIM record into a dict of tag=value pairs.
22+
23+ Per RFC 6376, tags are separated by semicolons, with tag=value format.
24+ The v= tag MUST be first and equal to DKIM1.
25+ Returns None if the record is not a valid DKIM record.
26+ """
27+ parts = [p .strip () for p in value .split (";" ) if p .strip ()]
28+ if not parts :
29+ return None
30+ # v= must be first
31+ first = parts [0 ]
32+ if not first .startswith ("v=" ) or first .split ("=" , 1 )[1 ].strip () != "DKIM1" :
33+ return None
34+ tags = {}
35+ for part in parts :
36+ if "=" not in part :
37+ continue
38+ key , val = part .split ("=" , 1 )
39+ tags [key .strip ()] = val .strip ()
40+ return tags
41+
42+
43+ def parse_spf_terms (value : str ) -> Optional [Tuple [str , set ]]:
44+ """Parse an SPF record into its qualifier-all and set of other terms.
45+
46+ Per RFC 7208, v=spf1 must be first. Returns (all_mechanism, other_terms)
47+ where all_mechanism is e.g. "-all", "~all", "+all", "?all" or None,
48+ and other_terms is the set of remaining mechanisms/modifiers.
49+ Returns None if not a valid SPF record.
50+ """
51+ if not value .startswith ("v=spf1" ):
52+ return None
53+ rest = value [len ("v=spf1" ) :].strip ()
54+ terms = rest .split ()
55+ all_mechanism = None
56+ other_terms = set ()
57+ for term in terms :
58+ if term in ("-all" , "~all" , "+all" , "?all" , "all" ):
59+ all_mechanism = term
60+ else :
61+ other_terms .add (term )
62+ return (all_mechanism , other_terms )
63+
64+
65+ def _check_dkim_semantic (
66+ expected_value : str , found_values : List [str ]
67+ ) -> Optional [Dict [str , any ]]:
68+ """Semantic comparison for DKIM records (tag order doesn't matter per RFC 6376)."""
69+ expected_tags = parse_dkim_tags (expected_value )
70+ if not expected_tags :
71+ return None
72+ for found_value in found_values :
73+ found_tags = parse_dkim_tags (found_value )
74+ if not found_tags :
75+ continue
76+ if not all (found_tags .get (k ) == v for k , v in expected_tags .items ()):
77+ continue
78+ # Check for t=y (testing mode) → insecure
79+ if found_tags .get ("t" ) and "y" in found_tags ["t" ].split (":" ):
80+ return {"status" : "insecure" , "found" : found_values }
81+ return {"status" : "correct" , "found" : found_values }
82+ return None
83+
84+
85+ def _check_spf_semantic (
86+ expected_value : str , found_values : List [str ]
87+ ) -> Optional [Dict [str , any ]]:
88+ """Semantic comparison for SPF records (term order doesn't matter)."""
89+ expected_spf = parse_spf_terms (expected_value )
90+ if not expected_spf :
91+ return None
92+ expected_all , expected_terms = expected_spf
93+ for found_value in found_values :
94+ found_spf = parse_spf_terms (found_value )
95+ if not found_spf :
96+ continue
97+ found_all , found_terms = found_spf
98+ if not expected_terms <= found_terms :
99+ continue
100+ if expected_all == found_all :
101+ return {"status" : "correct" , "found" : found_values }
102+ if expected_all == "-all" and found_all == "~all" :
103+ return {"status" : "correct" , "found" : found_values }
104+ return None
105+
106+
107+ def _resolve_dns_values (record_type , target , query_name ):
108+ """Resolve DNS and return found values and normalized expected value flag."""
109+ if record_type .upper () == "MX" :
110+ answers = dns .resolver .resolve (query_name , "MX" )
111+ return [f"{ answer .preference } { answer .exchange } " for answer in answers ]
112+
113+ if record_type .upper () == "TXT" :
114+ answers = dns .resolver .resolve (query_name , "TXT" )
115+ if target .endswith ("._domainkey" ):
116+ return [
117+ normalize_txt_value (answer .to_text ().strip ('"' ).replace ('" "' , "" ))
118+ for answer in answers
119+ ]
120+ return [normalize_txt_value (answer .to_text ()) for answer in answers ]
121+
122+ answers = dns .resolver .resolve (query_name , record_type )
123+ return [answer .to_text () for answer in answers ]
124+
125+
126+ def _check_txt_security (expected_value , found_values ):
127+ """Check for duplicate/insecure SPF and DMARC records. Returns result or None."""
128+ # SPF duplicate and insecure checks
129+ if expected_value .startswith ("v=spf1" ):
130+ spf_records = [v for v in found_values if v .startswith ("v=spf1" )]
131+ if len (spf_records ) > 1 :
132+ return {"status" : "duplicate" , "found" : found_values }
133+ if expected_value .endswith ("-all" ):
134+ for spf in spf_records :
135+ if spf .endswith ("+all" ) or spf .endswith ("?all" ):
136+ return {"status" : "insecure" , "found" : found_values }
137+
138+ # DMARC duplicate and insecure checks
139+ if expected_value .startswith ("v=DMARC1" ):
140+ dmarc_records = [v for v in found_values if v .startswith ("v=DMARC1" )]
141+ if len (dmarc_records ) > 1 :
142+ return {"status" : "duplicate" , "found" : found_values }
143+ if "p=none" not in expected_value :
144+ for dmarc in dmarc_records :
145+ if "p=none" in dmarc :
146+ return {"status" : "insecure" , "found" : found_values }
147+
148+ return None
149+
150+
20151def check_single_record (
21152 maildomain : MailDomain , expected_record : Dict [str , any ]
22153) -> Dict [str , any ]:
@@ -35,60 +166,20 @@ def check_single_record(
35166 expected_value = expected_record ["value" ]
36167
37168 # Build the query name
38- if target :
39- query_name = f"{ target } .{ maildomain .name } "
40- else :
41- query_name = maildomain .name
169+ query_name = f"{ target } .{ maildomain .name } " if target else maildomain .name
42170
43171 try :
44- # Query DNS records
45- if record_type .upper () == "MX" :
46- answers = dns .resolver .resolve (query_name , "MX" )
47- found_values = [
48- f"{ answer .preference } { answer .exchange } " for answer in answers
49- ]
50- elif record_type .upper () == "TXT" :
51- answers = dns .resolver .resolve (query_name , "TXT" )
52- if target .endswith ("._domainkey" ):
53- found_values = [
54- normalize_txt_value (answer .to_text ().strip ('"' ).replace ('" "' , "" ))
55- for answer in answers
56- ]
57- else :
58- found_values = [
59- normalize_txt_value (answer .to_text ()) for answer in answers
60- ]
172+ found_values = _resolve_dns_values (record_type , target , query_name )
173+ if record_type .upper () == "TXT" :
61174 expected_value = normalize_txt_value (expected_value )
62- else :
63- # For other record types, try to resolve them as-is
64- answers = dns .resolver .resolve (query_name , record_type )
65- found_values = [answer .to_text () for answer in answers ]
66175
67- # For SPF records, check for duplicates (multiple "v=spf1" TXT records
68- # is invalid per RFC 7208 and causes delivery issues)
69- if record_type .upper () == "TXT" and expected_value .startswith ("v=spf1" ):
70- spf_records = [v for v in found_values if v .startswith ("v=spf1" )]
71- if len (spf_records ) > 1 :
72- return {"status" : "duplicate" , "found" : found_values }
73- # Check for insecure SPF: if we expect -all but found +all or ?all
74- if expected_value .endswith ("-all" ):
75- for spf in spf_records :
76- if spf .endswith ("+all" ) or spf .endswith ("?all" ):
77- return {"status" : "insecure" , "found" : found_values }
78-
79- # For DMARC records, check for duplicates and insecure policy
80- if record_type .upper () == "TXT" and expected_value .startswith ("v=DMARC1" ):
81- dmarc_records = [v for v in found_values if v .startswith ("v=DMARC1" )]
82- if len (dmarc_records ) > 1 :
83- return {"status" : "duplicate" , "found" : found_values }
84- # Check for insecure DMARC: if we expect p=reject or p=quarantine
85- # but found p=none
86- if "p=none" not in expected_value :
87- for dmarc in dmarc_records :
88- if "p=none" in dmarc :
89- return {"status" : "insecure" , "found" : found_values }
90-
91- # Check if expected value is in found values
176+ # Check for duplicate/insecure SPF and DMARC
177+ if record_type .upper () == "TXT" :
178+ security_result = _check_txt_security (expected_value , found_values )
179+ if security_result :
180+ return security_result
181+
182+ # Exact match
92183 if expected_value in found_values :
93184 return {"status" : "correct" , "found" : found_values }
94185
@@ -98,25 +189,30 @@ def check_single_record(
98189 if softfail_variant in found_values :
99190 return {"status" : "correct" , "found" : found_values }
100191
192+ # Semantic fallback comparisons for DKIM and SPF
193+ if record_type .upper () == "TXT" and target .endswith ("._domainkey" ):
194+ result = _check_dkim_semantic (expected_value , found_values )
195+ if result :
196+ return result
197+
198+ if record_type .upper () == "TXT" and expected_value .startswith ("v=spf1" ):
199+ result = _check_spf_semantic (expected_value , found_values )
200+ if result :
201+ return result
202+
101203 return {"status" : "incorrect" , "found" : found_values }
102204
103205 except dns .resolver .NXDOMAIN :
104- # Domain doesn't exist
105206 return {"status" : "missing" , "error" : "Domain not found" }
106207 except dns .resolver .NoAnswer :
107- # No records found for this query
108208 return {"status" : "missing" , "error" : "No records found" }
109209 except dns .resolver .NoNameservers :
110- # No nameservers found
111210 return {"status" : "missing" , "error" : "No nameservers found" }
112211 except dns .resolver .Timeout :
113- # DNS query timed out
114212 return {"status" : "error" , "error" : "DNS query timeout" }
115213 except dns .resolver .YXDOMAIN :
116- # Domain name is too long
117214 return {"status" : "error" , "error" : "Domain name too long" }
118215 except Exception as e : # pylint: disable=broad-exception-caught
119- # Other DNS errors
120216 return {"status" : "error" , "error" : f"DNS query failed: { str (e )} " }
121217
122218
0 commit comments