forked from pool/python-checkdmarc
7f1ac1cbd4
* Ignore UnicodeDecodeError exceptions when querying for TXT records * Check DNSSEC on MX hostnames * USE DNSSEC when requesting DNSKEY records * Do not require an RRSIG answer when querying for DNSKEY records * Pass in nameservers and timeout when running get_dnskey recursively * Properly cache DNSKEY answers * Fix exception handling for query_mta_sts_record * Check for TLSA records * Add support for parsing SMTP TLS Reporting (RFC8460) DNS records * Add missing import dns.dnssec * Always use the actual subdomain or domain provided * Include MTA-STS and BIMI results in CSV output * Added the include_tag_descriptions parameter to checkdmarc.bimi.check_bimi() * Added the exception class MTASTSPolicyDownloadError * Major refactoring: Change from a single module to a package of modules, with each checked standard as its own package * Add support for MTA-STS RFC 8461 * Add support for BIMI * Specify a BIMI selector using the --bimi-selector/-b option * Fix SPF query error and warning messages * Add support for null MX records - RFC 7505 * Make DMARC retorting URI error messages more clear * Fix compatibility with Python 3.8 * SPFRecordNotFound exception now includes a domain argument * The DMARC missing authorization error message now includes the full expected DNS record * Properly parse DMARC and BIMI records for domains that do not have an identified base domain OBS-URL: https://build.opensuse.org/package/show/devel:languages:python/python-checkdmarc?expand=0&rev=5
280 lines
11 KiB
Python
280 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
|
|
"""Automated tests"""
|
|
|
|
import unittest
|
|
from collections import OrderedDict
|
|
|
|
import checkdmarc
|
|
import checkdmarc.utils
|
|
import checkdmarc.spf
|
|
import checkdmarc.dmarc
|
|
import checkdmarc.dnssec
|
|
|
|
known_good_domains = [
|
|
"fbi.gov",
|
|
"pm.me"
|
|
]
|
|
|
|
|
|
class Test(unittest.TestCase):
|
|
@unittest.skip
|
|
def testKnownGood(self):
|
|
"""Domains with known good STARTTLS support, SPF and DMARC records"""
|
|
|
|
results = checkdmarc.check_domains(known_good_domains)
|
|
for result in results:
|
|
spf_error = None
|
|
dmarc_error = None
|
|
for mx in result["mx"]["hosts"]:
|
|
self.assertEqual(
|
|
mx["starttls"], True,
|
|
"Host of known good domain {0} failed STARTTLS check: {1}"
|
|
"\n\n{0}".format(result["domain"], mx["hostname"])
|
|
)
|
|
if "error" in result["spf"]:
|
|
spf_error = result["spf"]["error"]
|
|
if "error" in result["dmarc"]:
|
|
dmarc_error = result["dmarc"]["error"]
|
|
self.assertEqual(result["spf"]["valid"], True,
|
|
"Known good domain {0} failed SPF check:"
|
|
"\n\n{1}".format(result["domain"], spf_error))
|
|
self.assertEqual(result["dmarc"]["valid"], True,
|
|
"Known good domain {0} failed DMARC check:"
|
|
"\n\n{1}".format(result["domain"], dmarc_error))
|
|
|
|
def testDMARCMixedFormatting(self):
|
|
"""DMARC records with extra spaces and mixed case are still valid"""
|
|
examples = [
|
|
"v=DMARC1;p=ReJect",
|
|
"v = DMARC1;p=reject;",
|
|
"v = DMARC1\t;\tp=reject\t;",
|
|
"v = DMARC1\t;\tp\t\t\t=\t\t\treject\t;",
|
|
"V=DMARC1;p=reject;"
|
|
]
|
|
|
|
for example in examples:
|
|
parsed_record = checkdmarc.dmarc.parse_dmarc_record(example, "")
|
|
self.assertIsInstance(parsed_record, OrderedDict)
|
|
|
|
def testGetBaseDomain(self):
|
|
subdomain = "foo.example.com"
|
|
result = checkdmarc.utils.get_base_domain(subdomain)
|
|
assert result == "example.com"
|
|
|
|
# Test reserved domains
|
|
subdomain = "_dmarc.nonauth-rua.invalid.example"
|
|
result = checkdmarc.utils.get_base_domain(subdomain)
|
|
assert result == "invalid.example"
|
|
|
|
subdomain = "_dmarc.nonauth-rua.invalid.test"
|
|
result = checkdmarc.utils.get_base_domain(subdomain)
|
|
assert result == "invalid.test"
|
|
|
|
subdomain = "_dmarc.nonauth-rua.invalid.invalid"
|
|
result = checkdmarc.utils.get_base_domain(subdomain)
|
|
assert result == "invalid.invalid"
|
|
|
|
subdomain = "_dmarc.nonauth-rua.invalid.localhost"
|
|
result = checkdmarc.utils.get_base_domain(subdomain)
|
|
assert result == "invalid.localhost"
|
|
|
|
# Test newer PSL entries
|
|
subdomain = "e3191.c.akamaiedge.net"
|
|
result = checkdmarc.utils.get_base_domain(subdomain)
|
|
assert result == "c.akamaiedge.net"
|
|
|
|
def testUppercaseSPFMechanism(self):
|
|
"""Treat uppercase SPF"SPF mechanisms as valid"""
|
|
spf_record = "v=spf1 IP4:147.75.8.208 -ALL"
|
|
domain = "example.no"
|
|
|
|
results = checkdmarc.spf.parse_spf_record(spf_record, domain)
|
|
|
|
self.assertEqual(len(results["warnings"]), 0)
|
|
|
|
def testSplitSPFRecord(self):
|
|
"""Split SPF records are parsed properly"""
|
|
|
|
rec = '"v=spf1 ip4:147.75.8.208 " "include:_spf.salesforce.com -all"'
|
|
|
|
parsed_record = checkdmarc.spf.parse_spf_record(rec, "example.com")
|
|
|
|
self.assertEqual(parsed_record["parsed"]["all"], "fail")
|
|
|
|
def testJunkAfterAll(self):
|
|
"""Ignore any mechanisms after the all mechanism, but warn about it"""
|
|
rec = "v=spf1 ip4:213.5.39.110 -all MS=83859DAEBD1978F9A7A67D3"
|
|
domain = "avd.dk"
|
|
|
|
parsed_record = checkdmarc.spf.parse_spf_record(rec, domain)
|
|
self.assertEqual(len(parsed_record["warnings"]), 1)
|
|
|
|
@unittest.skip
|
|
def testDNSSEC(self):
|
|
"""Test known good DNSSEC"""
|
|
self.assertEqual(checkdmarc.dnssec.test_dnssec("fbi.gov"), True)
|
|
|
|
def testIncludeMissingSPF(self):
|
|
"""SPF records that include domains that are missing SPF records
|
|
raise SPFRecordNotFound"""
|
|
|
|
spf_record = '"v=spf1 include:spf.comendosystems.com ' \
|
|
'include:bounce.peytz.dk include:etrack.indicia.dk ' \
|
|
'include:etrack1.com include:mail1.dialogportal.com ' \
|
|
'include:mail2.dialogportal.com a:mailrelay.jppol.dk ' \
|
|
'a:sendmail.jppol.dk ?all"'
|
|
domain = "ekstrabladet.dk"
|
|
self.assertRaises(checkdmarc.spf.SPFRecordNotFound,
|
|
checkdmarc.spf.parse_spf_record, spf_record, domain)
|
|
|
|
def testTooManySPFDNSLookups(self):
|
|
"""SPF records with > 10 SPF mechanisms that cause DNS lookups raise
|
|
SPFTooManyDNSLookups"""
|
|
|
|
spf_record = "v=spf1 a include:_spf.salesforce.com " \
|
|
"include:spf.protection.outlook.com " \
|
|
"include:spf.constantcontact.com " \
|
|
"include:_spf.elasticemail.com " \
|
|
"include:servers.mcsv.net " \
|
|
"include:_spf.google.com " \
|
|
"~all"
|
|
domain = "example.com"
|
|
self.assertRaises(checkdmarc.spf.SPFTooManyDNSLookups,
|
|
checkdmarc.spf.parse_spf_record, spf_record, domain)
|
|
|
|
def testTooManySPFVoidDNSLookups(self):
|
|
"""SPF records with > 2 void DNS lookups"""
|
|
|
|
spf_record = "v=spf1 a:13Mk4olS9VWhQqXRl90fKJrD.example.com " \
|
|
"mx:SfGiqBnQfRbOMapQJhozxo2B.example.com " \
|
|
"a:VAFeyU9N2KJX518aGsN3w6VS.example.com " \
|
|
"~all"
|
|
domain = "example.com"
|
|
self.assertRaises(checkdmarc.spf.SPFTooManyVoidDNSLookups,
|
|
checkdmarc.spf.parse_spf_record, spf_record, domain)
|
|
|
|
def testSPFSyntaxErrors(self):
|
|
"""SPF record syntax errors raise SPFSyntaxError"""
|
|
|
|
spf_record = '"v=spf1 mx a:mail.cohaesio.net ' \
|
|
'include: trustpilotservice.com ~all"'
|
|
domain = "2021.ai"
|
|
self.assertRaises(checkdmarc.spf.SPFSyntaxError,
|
|
checkdmarc.spf.parse_spf_record, spf_record, domain)
|
|
|
|
def testSPFInvalidIPv4(self):
|
|
"""Invalid ipv4 SPF mechanism values raise SPFSyntaxError"""
|
|
spf_record = "v=spf1 ip4:78.46.96.236 +a +mx +ip4:138.201.239.158 " \
|
|
"+ip4:78.46.224.83 " \
|
|
"+ip4:relay.mailchannels.net +ip4:138.201.60.20 ~all"
|
|
domain = "surftown.dk"
|
|
self.assertRaises(checkdmarc.spf.SPFSyntaxError,
|
|
checkdmarc.spf.parse_spf_record, spf_record, domain)
|
|
|
|
def testSPFInvalidIPv6inIPv4(self):
|
|
"""Invalid ipv4 SPF mechanism values raise SPFSyntaxError"""
|
|
spf_record = "v=spf1 ip4:1200:0000:AB00:1234:0000:2552:7777:1313 ~all"
|
|
domain = "surftown.dk"
|
|
self.assertRaises(checkdmarc.spf.SPFSyntaxError,
|
|
checkdmarc.spf.parse_spf_record, spf_record, domain)
|
|
|
|
def testSPFInvalidIPv4Range(self):
|
|
"""Invalid ipv4 SPF mechanism values raise SPFSyntaxError"""
|
|
spf_record = "v=spf1 ip4:78.46.96.236/99 ~all"
|
|
domain = "surftown.dk"
|
|
self.assertRaises(checkdmarc.spf.SPFSyntaxError,
|
|
checkdmarc.spf.parse_spf_record, spf_record, domain)
|
|
|
|
def testSPFInvalidIPv6(self):
|
|
"""Invalid ipv6 SPF mechanism values raise SPFSyntaxError"""
|
|
spf_record = "v=spf1 ip6:1200:0000:AB00:1234:O000:2552:7777:1313 ~all"
|
|
domain = "surftown.dk"
|
|
self.assertRaises(checkdmarc.spf.SPFSyntaxError,
|
|
checkdmarc.spf.parse_spf_record, spf_record, domain)
|
|
|
|
def testSPFInvalidIPv4inIPv6(self):
|
|
"""Invalid ipv6 SPF mechanism values raise SPFSyntaxError"""
|
|
spf_record = "v=spf1 ip6:78.46.96.236 ~all"
|
|
domain = "surftown.dk"
|
|
self.assertRaises(checkdmarc.spf.SPFSyntaxError,
|
|
checkdmarc.spf.parse_spf_record, spf_record, domain)
|
|
|
|
def testSPFInvalidIPv6Range(self):
|
|
"""Invalid ipv6 SPF mechanism values raise SPFSyntaxError"""
|
|
record = "v=spf1 ip6:1200:0000:AB00:1234:0000:2552:7777:1313/130 ~all"
|
|
domain = "surftown.dk"
|
|
self.assertRaises(checkdmarc.spf.SPFSyntaxError,
|
|
checkdmarc.spf.parse_spf_record, record, domain)
|
|
|
|
def testSPFIncludeLoop(self):
|
|
"""SPF record with include loop raises SPFIncludeLoop"""
|
|
|
|
spf_record = '"v=spf1 include:example.com"'
|
|
domain = "example.com"
|
|
self.assertRaises(checkdmarc.spf.SPFIncludeLoop,
|
|
checkdmarc.spf.parse_spf_record, spf_record, domain)
|
|
|
|
def testSPFMissingMXRecord(self):
|
|
"""A warning is issued if an SPF record contains a mx mechanism
|
|
pointing to a domain that has no MX records"""
|
|
|
|
spf_record = '"v=spf1 mx ~all"'
|
|
domain = "seanthegeek.net"
|
|
results = checkdmarc.spf.parse_spf_record(spf_record, domain)
|
|
self.assertIn("{0} does not have any MX records".format(domain),
|
|
results["warnings"])
|
|
|
|
def testSPFMissingARecord(self):
|
|
"""A warning is issued if an SPF record contains a mx mechanism
|
|
pointing to a domain that has no A records"""
|
|
|
|
spf_record = '"v=spf1 a ~all"'
|
|
domain = "cardinalhealth.net"
|
|
results = checkdmarc.spf.parse_spf_record(spf_record, domain)
|
|
self.assertIn("cardinalhealth.net does not have any A/AAAA records",
|
|
results["warnings"])
|
|
|
|
def testDMARCPctLessThan100Warning(self):
|
|
"""A warning is issued if the DMARC pvt value is less than 100"""
|
|
|
|
dmarc_record = "v=DMARC1; p=none; sp=none; fo=1; pct=50; adkim=r; " \
|
|
"aspf=r; rf=afrf; ri=86400; " \
|
|
"rua=mailto:eits.dmarcrua@energy.gov; " \
|
|
"ruf=mailto:eits.dmarcruf@energy.gov"
|
|
domain = "energy.gov"
|
|
results = checkdmarc.dmarc.parse_dmarc_record(dmarc_record, domain)
|
|
self.assertIn("pct value is less than 100",
|
|
results["warnings"][0])
|
|
|
|
def testInvalidDMARCURI(self):
|
|
"""An invalid DMARC report URI raises InvalidDMARCReportURI"""
|
|
|
|
dmarc_record = "v=DMARC1; p=none; rua=reports@dmarc.cyber.dhs.gov," \
|
|
"mailto:dmarcreports@usdoj.gov"
|
|
domain = "dea.gov"
|
|
self.assertRaises(checkdmarc.dmarc.InvalidDMARCReportURI,
|
|
checkdmarc.dmarc.parse_dmarc_record, dmarc_record,
|
|
domain)
|
|
|
|
dmarc_record = "v=DMARC1; p=none; rua=__" \
|
|
"mailto:reports@dmarc.cyber.dhs.gov," \
|
|
"mailto:dmarcreports@usdoj.gov"
|
|
self.assertRaises(checkdmarc.dmarc.InvalidDMARCReportURI,
|
|
checkdmarc.dmarc.parse_dmarc_record, dmarc_record,
|
|
domain)
|
|
|
|
def testInvalidDMARCPolicyValue(self):
|
|
"""An invalid DMARC policy value raises InvalidDMARCTagValue """
|
|
dmarc_record = "v=DMARC1; p=foo; rua=mailto:dmarc@example.com"
|
|
domain = "example.com"
|
|
self.assertRaises(checkdmarc.dmarc.InvalidDMARCTagValue,
|
|
checkdmarc.dmarc.parse_dmarc_record,
|
|
dmarc_record,
|
|
domain)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|