Skip to content

Commit 370b702

Browse files
CopilotseanthegeekCopilot
authored
DMARCbis support: new tags, removed tag warnings, DNS tree walk, bug fixes (#236)
* Initial plan * Add DMARCbis support: new tags (np, psd, t), removed tag warnings, optional p tag, DNS tree walk Co-authored-by: seanthegeek <44679+seanthegeek@users.noreply.github.com> * Add comprehensive unit tests for DMARCbis support Co-authored-by: seanthegeek <44679+seanthegeek@users.noreply.github.com> * Address code review feedback: fix np default logic, simplify conditionals Co-authored-by: seanthegeek <44679+seanthegeek@users.noreply.github.com> * Fix pre-existing bugs: populate allowed_values in get_dmarc_tag_description, fix f-string/format mix-up Co-authored-by: seanthegeek <44679+seanthegeek@users.noreply.github.com> * Fix missing raise keywords in mta_sts.py and smtp_tls_reporting.py, add comprehensive tests Co-authored-by: seanthegeek <44679+seanthegeek@users.noreply.github.com> * Address code review: fix fragile string parsing, move import to top of file Co-authored-by: seanthegeek <44679+seanthegeek@users.noreply.github.com> * Replace RFCPLACEHOLDER DMARCbis * Remove unused import of MagicMock from unittest.mock in tests.py * Fix tests * Update checkdmarc/dmarc.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update tests.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Apply optional review fixes: fix test docstrings, deterministic warning order, remove network dependency from tree walk test Co-authored-by: seanthegeek <44679+seanthegeek@users.noreply.github.com> * Normalize domain at start of query_dmarc_record() to handle unicode, zero-width chars, and trailing dots Co-authored-by: seanthegeek <44679+seanthegeek@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: seanthegeek <44679+seanthegeek@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 103000a commit 370b702

File tree

4 files changed

+1346
-30
lines changed

4 files changed

+1346
-30
lines changed

checkdmarc/dmarc.py

Lines changed: 203 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -162,13 +162,16 @@ class DMARCTagMap(TypedDict):
162162
adkim: DMARCTagMapItemWithDefault
163163
aspf: DMARCTagMapItemWithDefault
164164
fo: DMARCTagMapItemWithDefaultAndValues
165+
np: DMARCTagMapItemWithValues
165166
p: DMARCTagMapItemWithValues
166167
pct: DMARCTagMapItemWithDefault
168+
psd: DMARCTagMapItemWithDefaultAndValues
167169
rf: DMARCTagMapItemWithDefaultAndValues
168170
ri: DMARCTagMapItemWithDefault
169171
rua: DMARCTagMapItem
170172
ruf: DMARCTagMapItem
171173
sp: DMARCTagMapItemWithValues
174+
t: DMARCTagMapItemWithDefaultAndValues
172175
v: DMARCTagMapItem
173176

174177

@@ -384,9 +387,54 @@ class DMARCErrorData(TypedDict, total=False):
384387
),
385388
},
386389
},
390+
"np": {
391+
"name": "Non-existent Subdomain Policy",
392+
"required": False,
393+
"description": (
394+
"Indicates the policy to "
395+
"be enacted by the "
396+
"Receiver at the request "
397+
"of the Domain Owner for "
398+
"non-existent subdomains "
399+
"of the domain queried. "
400+
"Its syntax is identical "
401+
'to that of the "p" tag. '
402+
"If absent, the policy "
403+
'specified by the "sp" tag '
404+
"(if present) or the "
405+
'"p" tag MUST be applied '
406+
"for non-existent subdomains."
407+
),
408+
"values": {
409+
"none": (
410+
"The Domain Owner requests "
411+
"no specific action be "
412+
"taken regarding delivery "
413+
"of messages."
414+
),
415+
"quarantine": (
416+
"The Domain Owner "
417+
"wishes to have "
418+
"email that fails "
419+
"the DMARC mechanism "
420+
"check be treated by "
421+
"Mail Receivers as "
422+
"suspicious."
423+
),
424+
"reject": (
425+
"The Domain Owner wishes "
426+
"for Mail Receivers to "
427+
"reject email that fails "
428+
"the DMARC mechanism check. "
429+
"Rejection SHOULD occur "
430+
"during the SMTP "
431+
"transaction."
432+
),
433+
},
434+
},
387435
"p": {
388436
"name": "Requested Mail Receiver Policy",
389-
"required": True,
437+
"required": False,
390438
"description": (
391439
"Specifies the policy to "
392440
"be enacted by the "
@@ -455,8 +503,46 @@ class DMARCErrorData(TypedDict, total=False):
455503
"Domain Owners to enact "
456504
"a slow rollout of "
457505
"enforcement of the "
458-
"DMARC mechanism."
506+
"DMARC mechanism. "
507+
"Removed in DMARCbis."
508+
),
509+
},
510+
"psd": {
511+
"name": "PSD Flag",
512+
"required": False,
513+
"default": "u",
514+
"description": (
515+
"A flag indicating "
516+
"whether the domain is a "
517+
"Public Suffix Domain (PSD)."
459518
),
519+
"values": {
520+
"y": (
521+
"The domain is a PSD. "
522+
"This tag is included "
523+
"by PSOs to indicate "
524+
"that the domain is a "
525+
"Public Suffix Domain."
526+
),
527+
"n": (
528+
"The DMARC Policy Record "
529+
"is published for a domain "
530+
"that is not a PSD, but it "
531+
"is the Organizational "
532+
"Domain for itself and "
533+
"its subdomains."
534+
),
535+
"u": (
536+
"The default indicates "
537+
"that the DMARC Policy "
538+
"Record is published for "
539+
"a domain that is not a "
540+
"PSD, and may or may not "
541+
"be an Organizational "
542+
"Domain for itself and "
543+
"its subdomains."
544+
),
545+
},
460546
},
461547
"rf": {
462548
"name": "Report Format",
@@ -476,7 +562,8 @@ class DMARCErrorData(TypedDict, total=False):
476562
"(the auth-failure report "
477563
"type) is currently "
478564
"supported in the "
479-
"DMARC standard."
565+
"DMARC standard. "
566+
"Removed in DMARCbis."
480567
),
481568
"values": {
482569
"afrf": (
@@ -509,7 +596,8 @@ class DMARCErrorData(TypedDict, total=False):
509596
"than a daily report is "
510597
"understood to be "
511598
"accommodated on a "
512-
"best-effort basis."
599+
"best-effort basis. "
600+
"Removed in DMARCbis."
513601
),
514602
},
515603
"rua": {
@@ -585,6 +673,46 @@ class DMARCErrorData(TypedDict, total=False):
585673
),
586674
},
587675
},
676+
"t": {
677+
"name": "DMARC Policy Test Mode",
678+
"required": False,
679+
"default": "n",
680+
"description": (
681+
"Signals whether or not "
682+
"the Domain Owner wishes "
683+
"the Domain Owner Assessment "
684+
"Policy declared in the "
685+
'"p", "sp", and/or "np" tags '
686+
"to actually be applied. "
687+
"This tag does not affect "
688+
"the generation of DMARC "
689+
"reports, and it has no "
690+
"effect on any policy that "
691+
'is "none".'
692+
),
693+
"values": {
694+
"y": (
695+
"A request that the actor "
696+
"performing the DMARC "
697+
"validation check not "
698+
"apply the policy, but "
699+
"instead apply any special "
700+
"handling rules it might "
701+
"have in place. The Domain "
702+
"Owner is currently testing "
703+
"its specified DMARC "
704+
"assessment policy."
705+
),
706+
"n": (
707+
"The default is a request "
708+
"to apply the Domain Owner "
709+
"Assessment Policy as "
710+
"specified to any message "
711+
"that produces a DMARC "
712+
'"fail" result.'
713+
),
714+
},
715+
},
588716
"v": {
589717
"name": "Version",
590718
"required": True,
@@ -692,7 +820,7 @@ def _query_dmarc_record(
692820
except dns.resolver.NoAnswer:
693821
pass
694822
except dns.resolver.NXDOMAIN:
695-
raise DMARCRecordNotFound(f"The domain {0} does not exist.".format(domain))
823+
raise DMARCRecordNotFound(f"The domain {domain} does not exist.")
696824
except Exception as error:
697825
raise DMARCRecordNotFound(error)
698826

@@ -744,10 +872,10 @@ def query_dmarc_record(
744872
:exc:`checkdmarc.dmarc.SPFRecordFoundWhereDMARCRecordShouldBe`
745873
746874
"""
875+
domain = normalize_domain(domain).rstrip(".")
747876
logging.debug(f"Checking for a DMARC record on {domain}")
748877
warnings = []
749-
base_domain = get_base_domain(domain)
750-
location = domain.lower()
878+
location = domain
751879

752880
try:
753881
record = _query_dmarc_record(
@@ -759,7 +887,7 @@ def query_dmarc_record(
759887
ignore_unrelated_records=ignore_unrelated_records,
760888
)
761889
except DMARCRecordNotFound:
762-
# Skip this exception as we want to query the base domain. If we fail
890+
# Skip this exception as we want to perform a tree walk. If we fail
763891
# at that, at the end of this function we will raise another
764892
# DMARCRecordNotFound.
765893
record = None
@@ -781,22 +909,51 @@ def query_dmarc_record(
781909
except dns.exception.DNSException:
782910
pass
783911

784-
if record is None and domain != base_domain:
785-
record = _query_dmarc_record(
786-
base_domain,
787-
nameservers=nameservers,
788-
resolver=resolver,
789-
timeout=timeout,
790-
timeout_retries=timeout_retries,
791-
ignore_unrelated_records=ignore_unrelated_records,
792-
)
793-
location = base_domain
912+
# DMARCbis DNS tree walk for DMARC policy discovery
913+
if record is None:
914+
labels = domain.split(".")
915+
num_labels = len(labels)
916+
if num_labels > 1:
917+
# Determine starting point for tree walk
918+
if num_labels <= 8:
919+
# Start from the parent domain (remove leftmost label)
920+
start = 1
921+
else:
922+
# Skip to 7 labels
923+
start = num_labels - 7
924+
# Walk up the tree
925+
for i in range(start, num_labels):
926+
parent = ".".join(labels[i:])
927+
if "." not in parent:
928+
# Don't query TLDs
929+
break
930+
try:
931+
record = _query_dmarc_record(
932+
parent,
933+
nameservers=nameservers,
934+
resolver=resolver,
935+
timeout=timeout,
936+
timeout_retries=timeout_retries,
937+
ignore_unrelated_records=ignore_unrelated_records,
938+
)
939+
if record is not None:
940+
location = parent
941+
break
942+
except DMARCRecordNotFound:
943+
# No DMARC record at this parent; continue walking up the tree
944+
continue
945+
except DMARCError:
946+
# A DMARC record exists but is invalid or otherwise problematic;
947+
# re-raise so the caller can surface the actual configuration error.
948+
raise
949+
794950
if record is None:
795951
error_str = "A DMARC record does not exist"
796-
if domain == base_domain:
952+
labels = domain.split(".")
953+
if len(labels) <= 2:
797954
error_str += "."
798955
else:
799-
error_str += " for this subdomain or its base domain."
956+
error_str += " for this domain or its parent domains."
800957
raise DMARCRecordNotFound(error_str)
801958

802959
return {"record": record, "location": location, "warnings": warnings}
@@ -825,6 +982,8 @@ def get_dmarc_tag_description(
825982
allowed_values = {}
826983
if "default" in dmarc_tags[tag]:
827984
default = dmarc_tags[tag]["default"]
985+
if "values" in dmarc_tags[tag]:
986+
allowed_values = dmarc_tags[tag]["values"]
828987
if type(value) is str and value in allowed_values:
829988
description = allowed_values[value]
830989
elif type(value) is list and len(allowed_values):
@@ -1185,15 +1344,36 @@ def parse_dmarc_record(
11851344
if tag not in tags and "default" in dmarc_tags[tag]:
11861345
tags[tag] = {"value": dmarc_tags[tag]["default"], "explicit": False}
11871346
if "p" not in tags:
1188-
raise DMARCSyntaxError('The record is missing the required policy ("p") tag.')
1347+
tags["p"] = {"value": "none", "explicit": False}
1348+
warnings.append(
1349+
"The p tag is optional in DMARCbis, but is required "
1350+
"in older versions of DMARC."
1351+
)
11891352
tags["p"]["value"] = tags["p"]["value"].lower()
11901353
if "sp" not in tags:
11911354
tags["sp"] = {"value": tags["p"]["value"], "explicit": False}
11921355
# Normalize sp value for validation consistency (mirrors p behavior)
11931356
tags["sp"]["value"] = tags["sp"]["value"].lower()
1194-
if list(tags.keys())[1] != "p":
1195-
raise DMARCSyntaxError("the p tag must immediately follow the v tag.")
1357+
if "np" not in tags:
1358+
if tags["sp"]["explicit"]:
1359+
tags["np"] = {"value": tags["sp"]["value"], "explicit": False}
1360+
else:
1361+
tags["np"] = {"value": tags["p"]["value"], "explicit": False}
1362+
tags["np"]["value"] = tags["np"]["value"].lower()
1363+
# The p tag must immediately follow v if it is explicit
1364+
if tags["p"]["explicit"]:
1365+
if list(tags.keys())[1] != "p":
1366+
raise DMARCSyntaxError("the p tag must immediately follow the v tag.")
11961367
tags["v"]["value"] = tags["v"]["value"].upper()
1368+
1369+
# Warn about tags removed in DMARCbis
1370+
removed_tags = ("pct", "rf", "ri")
1371+
for tag in removed_tags:
1372+
if tag in tags and tags[tag]["explicit"]:
1373+
warnings.append(
1374+
f"Support for the {tag} tag was removed in DMARCbis."
1375+
)
1376+
11971377
# Validate tag values
11981378
for tag in tags:
11991379
tag_value = tags[tag]["value"]

checkdmarc/mta_sts.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,7 @@ def parse_mta_sts_policy(policy: str) -> MTASTSPolicyParsingResults:
472472
required_keys = ["version", "mode", "max_age"]
473473
acceptable_keys = required_keys.copy()
474474
acceptable_keys.append("mx")
475+
seen_keys: set[str] = set()
475476
if "\n" in policy and "\r\n" not in policy:
476477
policy = policy.replace("\n", "\r\n")
477478
lines = policy.split("\r\n")
@@ -486,12 +487,13 @@ def parse_mta_sts_policy(policy: str) -> MTASTSPolicyParsingResults:
486487
value = key_value[1].strip()
487488
if key not in acceptable_keys:
488489
raise MTASTSPolicySyntaxError(f"Line {line}: Unexpected key: {key}")
489-
if key in parsed_policy and key != "mx":
490-
MTASTSPolicySyntaxError(f"Line {line}: Duplicate key: {key}")
491-
elif key == "version" and value not in versions:
492-
MTASTSPolicySyntaxError(f"Line {line}: Invalid version: {value}")
490+
if key in seen_keys and key != "mx":
491+
raise MTASTSPolicySyntaxError(f"Line {line}: Duplicate key: {key}")
492+
seen_keys.add(key)
493+
if key == "version" and value not in versions:
494+
raise MTASTSPolicySyntaxError(f"Line {line}: Invalid version: {value}")
493495
elif key == "mode" and value not in modes:
494-
MTASTSPolicySyntaxError(f"Line {line}: Invalid mode: {value}")
496+
raise MTASTSPolicySyntaxError(f"Line {line}: Invalid mode: {value}")
495497
elif key == "max_age":
496498
error_msg = "max_age must be an integer value between 0 and 31557600."
497499
if "." in value:
@@ -501,7 +503,7 @@ def parse_mta_sts_policy(policy: str) -> MTASTSPolicyParsingResults:
501503
if value < 0 or value > 31557600:
502504
raise MTASTSPolicySyntaxError(error_msg)
503505
except ValueError:
504-
MTASTSPolicySyntaxError(error_msg)
506+
raise MTASTSPolicySyntaxError(error_msg)
505507
if key != "mx":
506508
parsed_policy[key] = value
507509
else:

checkdmarc/smtp_tls_reporting.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,7 @@ def parse_smtp_tls_reporting_record(
372372
if include_tag_descriptions:
373373
tags[tag]["description"] = smtp_rpt_tags[tag]["description"]
374374
if "rua" not in tags:
375-
SMTPTLSReportingSyntaxError("The record is missing the required rua tag.")
375+
raise SMTPTLSReportingSyntaxError("The record is missing the required rua tag.")
376376
tags["rua"]["value"] = tags["rua"]["value"].split(",")
377377
for uri in tags["rua"]["value"]:
378378
if len(SMTPTLSREPORTING_URI_REGEX.findall(uri)) != 1:

0 commit comments

Comments
 (0)