diff options
| -rw-r--r-- | src/mailman/handlers/dmarc.py | 97 | ||||
| -rw-r--r-- | src/mailman/handlers/docs/dmarc-mitigations.rst | 121 | ||||
| -rw-r--r-- | src/mailman/handlers/tests/test_dmarc.py | 2 | ||||
| -rw-r--r-- | src/mailman/interfaces/mailinglist.py | 45 | ||||
| -rw-r--r-- | src/mailman/rules/dmarc.py | 121 | ||||
| -rw-r--r-- | src/mailman/rules/tests/test_dmarc.py | 38 |
6 files changed, 232 insertions, 192 deletions
diff --git a/src/mailman/handlers/dmarc.py b/src/mailman/handlers/dmarc.py index 99d8e08df..13707d397 100644 --- a/src/mailman/handlers/dmarc.py +++ b/src/mailman/handlers/dmarc.py @@ -43,6 +43,7 @@ from zope.interface import implementer log = logging.getLogger('mailman.error') COMMASPACE = ', ' +EMPTYSTRING = '' MAXLINELEN = 78 NONASCII = re.compile('[^\s!-~]') # Headers from the original that we want to keep in the wrapper. These are @@ -84,48 +85,49 @@ def munged_headers(mlist, msg, msgdata): # goals is priority order. # # Be as robust as possible here. - faddrs = getaddresses(msg.get_all('from', [])) + all_froms = getaddresses(msg.get_all('from', [])) # Strip the nulls and bad emails. - faddrs = [x for x in faddrs if x[1].find('@') > 0] - if len(faddrs) == 1: - realname, email = o_from = faddrs[0] + froms = [email for email in all_froms if '@' in email[1]] + if len(froms) == 1: + realname, email = original_from = froms[0] else: # No From: or multiple addresses. Just punt and take # the get_sender result. realname = '' email = msgdata['original_sender'] - o_from = (realname, email) + original_from = (realname, email) + # If there was no display name in the email header, see if we have a + # matching member with a display name. if len(realname) == 0: member = mlist.members.get_member(email) if member: realname = member.display_name or email else: realname = email - # Remove domain from realname if it looks like an email address. + # Remove the domain from realname if it looks like an email address. realname = re.sub(r'@([^ .]+\.)+[^ .]+$', '---', realname) # Make a display name and RFC 2047 encode it if necessary. This is - # difficult and kludgy. If the realname came from From: it should be - # ascii or RFC 2047 encoded. If it came from the list, it should be - # a string. If it's from the email address, it should be an ascii string. - # In any case, ensure it's an unencoded string. - srn = '' - for frag, cs in decode_header(realname): - if not cs: - # Character set should be ascii, but use iso-8859-1 anyway. - cs = 'iso-8859-1' - if not isinstance(frag, str): - srn += str(frag, cs, errors='replace') + # difficult and kludgy. If the realname came from From: it should be + # ASCII or RFC 2047 encoded. If it came from the member record, it should + # be a string. If it's from the email address, it should be an ASCII + # string. In any case, ensure it's an unencoded string. + realname_bits = [] + for fragment, charset in decode_header(realname): + if not charset: + # Character set should be ASCII, but use iso-8859-1 anyway. + charset = 'iso-8859-1' + if not isinstance(fragment, str): + realname_bits.append(str(fragment, charset, errors='replace')) else: - srn += frag - # The list's real_name is a string. - lrn = mlist.display_name # noqa F841 - realname = srn + realname_bits.append(fragment) + # The member's display name is a string. + realname = EMPTYSTRING.join(realname_bits) # Ensure the i18n context is the list's preferred_language. with _.using(mlist.preferred_language.code): - via = _('$realname via $lrn') + via = _('$realname via $mlist.display_name') # Get an RFC 2047 encoded header string. - dn = str(Header(via, mlist.preferred_language.charset)) - retn = [('From', formataddr((dn, mlist.posting_address)))] + display_name = str(Header(via, mlist.preferred_language.charset)) + value = [('From', formataddr((display_name, mlist.posting_address)))] # We've made the munged From:. Now put the original in Reply-To: or Cc: if mlist.reply_goes_to_list is ReplyToMunging.no_munging: # Add original from to Reply-To: @@ -133,52 +135,54 @@ def munged_headers(mlist, msg, msgdata): else: # Add original from to Cc: add_to = 'Cc' - orig = getaddresses(msg.get_all(add_to, [])) - if o_from[1] not in [x[1] for x in orig]: - orig.append(o_from) - retn.append((add_to, COMMASPACE.join(formataddr(x) for x in orig))) - return retn + original = getaddresses(msg.get_all(add_to, [])) + if original_from[1] not in [x[1] for x in original]: + original.append(original_from) + value.append((add_to, COMMASPACE.join(formataddr(x) for x in original))) + return value def munge_from(mlist, msg, msgdata): - for k, v in munged_headers(mlist, msg, msgdata): - del msg[k] - msg[k] = v + for key, value in munged_headers(mlist, msg, msgdata): + del msg[key] + msg[key] = value return def wrap_message(mlist, msg, msgdata): # Create a wrapper message around the original. + # # There are various headers in msg that we don't want, so we basically - # make a copy of the msg, then delete almost everything and set/copy + # make a copy of the message, then delete almost everything and set/copy # what we want. - omsg = copy.deepcopy(msg) + original_msg = copy.deepcopy(msg) for key in msg: keep = False for keeper in KEEPERS: - if re.match(keeper, key, re.I): + if re.match(keeper, key, re.IGNORECASE): keep = True break if not keep: del msg[key] msg['MIME-Version'] = '1.0' msg['Message-ID'] = make_msgid() - for k, v in munged_headers(mlist, omsg, msgdata): - msg[k] = v + for key, value in munged_headers(mlist, original_msg, msgdata): + msg[key] = value # Are we including dmarc_wrapped_message_text? if len(mlist.dmarc_wrapped_message_text) > 0: - part1 = MIMEText(wrap(mlist.dmarc_wrapped_message_text), - 'plain', - mlist.preferred_language.charset) + part1 = MIMEText( + wrap(mlist.dmarc_wrapped_message_text), + 'plain', + mlist.preferred_language.charset) part1['Content-Disposition'] = 'inline' - part2 = MIMEMessage(omsg) + part2 = MIMEMessage(original_msg) part2['Content-Disposition'] = 'inline' msg['Content-Type'] = 'multipart/mixed' msg.set_payload([part1, part2]) else: msg['Content-Type'] = 'message/rfc822' msg['Content-Disposition'] = 'inline' - msg.set_payload([omsg]) + msg.set_payload([original_msg]) return @@ -186,7 +190,7 @@ def process(mlist, msg, msgdata): # If we're mitigating on policy and we have no hit, return. if not msgdata.get('dmarc') and not mlist.dmarc_mitigate_unconditionally: return - # if we're not mitigating, return. + # If we're not mitigating, return. if mlist.dmarc_mitigate_action is DMARCMitigateAction.no_mitigation: return if mlist.anonymous_list: @@ -198,10 +202,11 @@ def process(mlist, msg, msgdata): elif mlist.dmarc_mitigate_action is DMARCMitigateAction.wrap_message: wrap_message(mlist, msg, msgdata) else: - # We can get here if DMARCMitigateAction is reject or discard - # but the From: domain has no reject or quarantine policy and - # mlist.dmarc_mitigate_unconditionally is True. We just ignore + # We can get here if DMARCMitigateAction is reject or discard but + # the From: domain has no reject or quarantine policy and + # mlist.dmarc_mitigate_unconditionally is True. Log and ignore # this. + log.error('Invalid DMARC combination for list: %s', mlist) return else: raise AssertionError( diff --git a/src/mailman/handlers/docs/dmarc-mitigations.rst b/src/mailman/handlers/docs/dmarc-mitigations.rst index d26fc6bdd..12a0de0ff 100644 --- a/src/mailman/handlers/docs/dmarc-mitigations.rst +++ b/src/mailman/handlers/docs/dmarc-mitigations.rst @@ -4,128 +4,138 @@ DMARC Mitigations In order to mitigate the effects of DMARC_ on mailing list traffic, list administrators have the ability to apply transformations to messages delivered -to list members. These transformations are applied only to individual messages -sent to list members and not to messages in digests, archives, or gated to -USENET. +to list members. These transformations are applied only to individual +messages sent to list members and not to messages in digests, archives, or +gated via NNTP. -The messages can be transformed by either munging the From: header and putting -original From: in Cc: or Reply-To: or by wrapping the original message in an -outer message From: the list. +The messages can be transformed by either munging the ``From:`` header and +putting original ``From:`` in ``Cc:`` or ``Reply-To:``, or by wrapping the +original message in an outer message ``From:`` the list. -Exactly what transformations are applied depends on a number of list settings. +Exactly which transformations are applied depends on a number of list settings. The settings and their effects are: ``anonymous_list`` - If True, no mitigations are ever applied because the message - is already From: the list. + If True, no mitigations are ever applied because the message is already + ``From:`` the list. ``dmarc_mitigate_action`` - The action to apply to messages From: a domain - publishing a DMARC policy of reject or quarantine or to all messages - depending on the next setting. + The action to apply to messages ``From:`` a domain publishing a DMARC + policy of **reject** or **quarantine**, or to all messages depending on the + setting of ``dmarc_mitigate_unconditionally``. ``dmarc_mitigate_unconditionally`` - A Flag to apply dmarc_mitigate_action to all messages, but only if - dmarc_mitigate_action is neither reject or discard. + If True, apply ``dmarc_mitigate_action`` to all messages, but only if + ``dmarc_mitigate_action`` is neither ``reject`` or ``discard``. ``dmarc_moderation_notice`` - Text to include in any rejection notice to be sent - when dmarc_policy_mitigation of reject applies. This overrides the bult-in - default text. + Text to include in any rejection notice to be sent when + ``dmarc_policy_mitigation`` of ``reject`` applies. This overrides the + built-in default text. ``dmarc_wrapped_message_text`` - Text to be added as a separate text/plain MIME - part preceding the original message part in the wrapped message when a - wrap_message mitigation applies. If this is not provided the separate - text/plain MIME part is not added. + Text to be added as a separate ``text/plain`` MIME part preceding the + original message part in the wrapped message when a ``wrap_message`` + mitigation applies. If this is not provided the separate ``text/plain`` + MIME part is not added. ``reply_goes_to_list`` - If this is set to other than no_munging of Reply-To, - the original From: goes in Cc: rather than Reply-To:. This is intended to - make MUA functions of reply and reply-all have the same effect with + If this is set to other than no-munging of ``Reply-To:``, the original + ``From:`` goes in ``Cc:`` rather than ``Reply-To:``. This is intended to + make MUA functions of *reply* and *reply-all* have the same effect with messages to which mitigations have been applied as they do with other messages. -The possible actions for dmarc_mitigate_action are: +The possible actions for ``dmarc_mitigate_action`` are: ``no_mitigation`` Make no transformation to the message. ``munge_from`` - Change the From: header and put the original From: in Reply-To: - or in some cases Cc: + Change the ``From:`` header and put the original ``From:`` in ``Reply-To:`` + or in some cases ``Cc:``. ``wrap_message`` - Wrap the message in an outer message with headers as in - munge_from. + Wrap the message in an outer message with headers from the original message + as in ``munge_from``. ``reject`` - Bounce the message back to the sender with a default reason or one - supplied in dmarc_moderation_notice. + Bounce the message back to the sender with a default reason or one supplied + in ``dmarc_moderation_notice``. ``discard`` Silently discard the message. -Here's what happens when we munge the From. +Here's what happens when we munge the ``From:``. +:: - >>> from mailman.interfaces.mailinglist import (DMARCMitigateAction, - ... ReplyToMunging) - >>> mlist = create_list('test@example.com') + >>> from mailman.interfaces.mailinglist import ( + ... DMARCMitigateAction, ReplyToMunging) + + >>> mlist = create_list('ant@example.com') >>> mlist.dmarc_mitigate_action = DMARCMitigateAction.munge_from >>> mlist.reply_goes_to_list = ReplyToMunging.no_munging + >>> msg = message_from_string("""\ - ... From: A Person <aperson@example.com> - ... To: test@example.com + ... From: Anne Person <aperson@example.com> + ... To: ant@example.com ... ... A message of great import. ... """) >>> msgdata = dict(dmarc=True, original_sender='aperson@example.com') + >>> from mailman.handlers.dmarc import process >>> process(mlist, msg, msgdata) >>> print(msg.as_string()) - To: test@example.com - From: A Person via Test <test@example.com> - Reply-To: A Person <aperson@example.com> + To: ant@example.com + From: Anne Person via Ant <ant@example.com> + Reply-To: Anne Person <aperson@example.com> <BLANKLINE> A message of great import. <BLANKLINE> - + Here we wrap the message without adding a text part. +:: >>> mlist.dmarc_mitigate_action = DMARCMitigateAction.wrap_message >>> mlist.dmarc_wrapped_message_text = '' + >>> msg = message_from_string("""\ - ... From: A Person <aperson@example.com> - ... To: test@example.com + ... From: Anne Person <aperson@example.com> + ... To: ant@example.com ... ... A message of great import. ... """) >>> msgdata = dict(dmarc=True, original_sender='aperson@example.com') + >>> process(mlist, msg, msgdata) >>> print(msg.as_string()) - To: test@example.com + To: ant@example.com MIME-Version: 1.0 Message-ID: <...> - From: A Person via Test <test@example.com> - Reply-To: A Person <aperson@example.com> + From: Anne Person via Ant <ant@example.com> + Reply-To: Anne Person <aperson@example.com> Content-Type: message/rfc822 Content-Disposition: inline <BLANKLINE> - From: A Person <aperson@example.com> - To: test@example.com + From: Anne Person <aperson@example.com> + To: ant@example.com <BLANKLINE> A message of great import. <BLANKLINE> And here's a wrapped message with an added text part. +:: >>> mlist.dmarc_wrapped_message_text = 'The original message is attached.' + >>> msg = message_from_string("""\ - ... From: A Person <aperson@example.com> - ... To: test@example.com + ... From: Anne Person <aperson@example.com> + ... To: ant@example.com ... ... A message of great import. ... """) >>> msgdata = dict(dmarc=True, original_sender='aperson@example.com') + >>> process(mlist, msg, msgdata) >>> print(msg.as_string()) - To: test@example.com + To: ant@example.com MIME-Version: 1.0 Message-ID: <...> - From: A Person via Test <test@example.com> - Reply-To: A Person <aperson@example.com> + From: Anne Person via Ant <ant@example.com> + Reply-To: Anne Person <aperson@example.com> Content-Type: multipart/mixed; boundary="..." <BLANKLINE> --... @@ -140,12 +150,13 @@ And here's a wrapped message with an added text part. MIME-Version: 1.0 Content-Disposition: inline <BLANKLINE> - From: A Person <aperson@example.com> - To: test@example.com + From: Anne Person <aperson@example.com> + To: ant@example.com <BLANKLINE> A message of great import. <BLANKLINE> --...-- <BLANKLINE> + .. _DMARC: https://wikipedia.org/wiki/DMARC diff --git a/src/mailman/handlers/tests/test_dmarc.py b/src/mailman/handlers/tests/test_dmarc.py index 6fe15bd18..bd1e2a1d6 100644 --- a/src/mailman/handlers/tests/test_dmarc.py +++ b/src/mailman/handlers/tests/test_dmarc.py @@ -15,7 +15,7 @@ # You should have received a copy of the GNU General Public License along with # GNU Mailman. If not, see <http://www.gnu.org/licenses/>. -"""Test the dmarc handler.""" +"""Test the DMARC handler.""" import unittest diff --git a/src/mailman/interfaces/mailinglist.py b/src/mailman/interfaces/mailinglist.py index 0f1fbace1..44376a7b2 100644 --- a/src/mailman/interfaces/mailinglist.py +++ b/src/mailman/interfaces/mailinglist.py @@ -25,21 +25,21 @@ from zope.interface import Attribute, Interface @public class DMARCMitigateAction(Enum): - # Mitigation to apply to messages From: domains publishing an applicable - # DMARC policy or unconditionally depending on settings. - no_mitigation = 0 + # Mitigations to apply to messages From: domains publishing an applicable + # DMARC policy, or unconditionally depending on settings. + # # No DMARC mitigations. + no_mitigation = 0 + # Messages From: domains with DMARC policy will have From: replaced by the + # list posting address and the original From: added to Reply-To: or Cc:. munge_from = 1 - # Messages From: domains with DMARC policy will have From: replaced by - # the list posting address and the original From: added to Reply-To: - # or Cc:. - wrap_message = 2 # Messages From: domains with DMARC policy will be wrapped in an outer # message From: the list posting address. - reject = 3 + wrap_message = 2 # Messages From: domains with DMARC policy will be rejected. - discard = 4 + reject = 3 # Messages From: domains with DMARC policy will be discarded. + discard = 4 @public @@ -238,24 +238,33 @@ class IMailingList(Interface): # DMARC attributes. dmarc_mitigate_action = Attribute( - """The DMARCMitigateAction to be applied to messages From: a domain - publishing DMARC p=reject or quarantine and possibly unconditionally. + """The mitigation to apply to messages from a DMARC matching domain. + + This is a DMARCMitigateAction to be applied to messages From: a domain + publishing DMARC p=reject or quarantine, and possibly unconditionally + depending on the setting of dmarc_mitigate_unconditionally. """) dmarc_mitigate_unconditionally = Attribute( - """A Flag to apply dmarc_mitigate_action to all messages but only if - dmarc_mitigate_action is other than reject or discard. + """Should DMARC mitigations apply unconditionally? + + A flag indicating whether to apply dmarc_mitigate_action to all + messages, but only if dmarc_mitigate_action is other than reject or + discard. """) dmarc_moderation_notice = Attribute( - """Text to include in any rejection notice to be sent when - DMARCMitigateAction of reject applies. + """Text to include in any DMARC rejected message. + + Rejection notices are sent when DMARCMitigateAction of reject applies. """) dmarc_wrapped_message_text = Attribute( - """Text to be added as a separate text/plain MIME part preceding the - original message part in the wrapped message when - DMARCMitigateAction of wrap_message applies. + """Additional MIME text to include in DMARC wrapped messages. + + This text is added as a separate text/plain MIME part preceding the + original message part in the wrapped message when DMARCMitigateAction + of wrap_message applies. """) # Rosters and subscriptions. diff --git a/src/mailman/rules/dmarc.py b/src/mailman/rules/dmarc.py index a213a235b..928914a29 100644 --- a/src/mailman/rules/dmarc.py +++ b/src/mailman/rules/dmarc.py @@ -43,6 +43,7 @@ elog = logging.getLogger('mailman.error') vlog = logging.getLogger('mailman.vette') DOT = '.' +EMPTYSTRING = '' KEEP_LOOKING = object() LOCAL_FILE_NAME = 'public_suffix_list.dat' @@ -126,44 +127,53 @@ def parse_suffix_list(filename=None): suffix_cache[key] = exception -def _get_dom(d, l): - # A helper to get a domain name consisting of the first l+1 labels - # in d. - dom = d[:min(l+1, len(d))] - dom.reverse() - return '.'.join(dom) +def get_domain(parts, label): + # A helper to get a domain name consisting of the first label+1 labels in + # parts. + domain = parts[:min(label+1, len(parts))] + domain.reverse() + return DOT.join(domain) -def _get_org_dom(domain): +def get_organizational_domain(domain): # Given a domain name, this returns the corresponding Organizational # Domain which may be the same as the input. if len(suffix_cache) == 0: parse_suffix_list() hits = [] - d = domain.lower().split('.') - d.reverse() - for k in suffix_cache: - ks = k.split('.') - if len(d) >= len(ks): - for i in range(len(ks)-1): - if d[i] != ks[i] and ks[i] != '*': + parts = domain.lower().split('.') + parts.reverse() + for key in suffix_cache: + key_parts = key.split('.') + if len(parts) >= len(key_parts): + for i in range(len(key_parts) - 1): + if parts[i] != key_parts[i] and key_parts[i] != '*': break else: - if d[len(ks)-1] == ks[-1] or ks[-1] == '*': - hits.append(k) + if (parts[len(key_parts) - 1] == key_parts[-1] or + key_parts[-1] == '*'): + hits.append(key) if not hits: - return _get_dom(d, 1) - l = 0 - for k in hits: - if suffix_cache[k]: - # It's an exception - return _get_dom(d, len(k.split('.'))-1) - if len(k.split('.')) > l: - l = len(k.split('.')) - return _get_dom(d, l) + return get_domain(parts, 1) + label = 0 + for key in hits: + key_parts = key.split('.') + if suffix_cache[key]: + # It's an exception. + return get_domain(parts, len(key_parts) - 1) + if len(key_parts) > label: + label = len(key_parts) + return get_domain(parts, label) -def _DMARCProhibited(mlist, email, dmarc_domain, org=False): +def is_reject_or_quarantine(mlist, email, dmarc_domain, org=False): + # This takes a mailing list, an email address as in the From: header, the + # _dmarc host name for the domain in question, and a flag stating whether + # we should check the organizational domains. It returns one of three + # values: + # * True if the DMARC policy is reject or quarantine; + # * False if is not; + # * A special sentinel if we should continue looking resolver = dns.resolver.Resolver() resolver.timeout = as_timedelta( config.dmarc.resolver_timeout).total_seconds() @@ -173,51 +183,56 @@ def _DMARCProhibited(mlist, email, dmarc_domain, org=False): txt_recs = resolver.query(dmarc_domain, dns.rdatatype.TXT) except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer): return KEEP_LOOKING - except DNSException as e: + except DNSException as error: elog.error( 'DNSException: Unable to query DMARC policy for %s (%s). %s', - email, dmarc_domain, e.__doc__) + email, dmarc_domain, error.__doc__) return KEEP_LOOKING # Be as robust as possible in parsing the result. results_by_name = {} cnames = {} want_names = set([dmarc_domain + '.']) + # Check all the TXT records returned by DNS. Keep track of the CNAMEs for + # checking later on. Ignore any other non-TXT records. for txt_rec in txt_recs.response.answer: if txt_rec.rdtype == dns.rdatatype.CNAME: cnames[txt_rec.name.to_text()] = ( txt_rec.items[0].target.to_text()) if txt_rec.rdtype != dns.rdatatype.TXT: continue - results_by_name.setdefault( - txt_rec.name.to_text(), []).append( - "".join( - [str(x, encoding='utf-8') - for x in txt_rec.items[0].strings])) + result = EMPTYSTRING.join( + str(record, encoding='utf-8') + for record in txt_rec.items[0].strings) + name = txt_rec.name.to_text() + results_by_name.setdefault(name, []).append(result) expands = list(want_names) seen = set(expands) while expands: item = expands.pop(0) if item in cnames: if cnames[item] in seen: - continue # cname loop + # CNAME loop. + continue expands.append(cnames[item]) seen.add(cnames[item]) want_names.add(cnames[item]) want_names.discard(item) - assert len(want_names) == 1, """\ - Error in CNAME processing for {}; want_names != 1.""".format( - dmarc_domain) + assert len(want_names) == 1, ( + 'Error in CNAME processing for {}; want_names != 1.'.format( + dmarc_domain)) for name in want_names: if name not in results_by_name: continue - dmarcs = [x for x in results_by_name[name] - if x.startswith('v=DMARC1;')] + dmarcs = [ + record for record in results_by_name[name] + if record.startswith('v=DMARC1;') + ] if len(dmarcs) == 0: return KEEP_LOOKING if len(dmarcs) > 1: elog.error( - """RRset of TXT records for %s has %d v=DMARC1 entries; - testing them all""", + 'RRset of TXT records for %s has %d v=DMARC1 entries; ' + 'testing them all', dmarc_domain, len(dmarcs)) for entry in dmarcs: mo = re.search(r'\bsp=(\w*)\b', entry, re.IGNORECASE) @@ -237,8 +252,7 @@ def _DMARCProhibited(mlist, email, dmarc_domain, org=False): continue # pragma: no cover if policy in ('reject', 'quarantine'): vlog.info( - """%s: DMARC lookup for %s (%s) - found p=%s in %s = %s""", + '%s: DMARC lookup for %s (%s) found p=%s in %s = %s', mlist.list_name, email, dmarc_domain, @@ -249,23 +263,24 @@ def _DMARCProhibited(mlist, email, dmarc_domain, org=False): return False -def _IsDMARCProhibited(mlist, email): +def maybe_mitigate(mlist, email): # This takes an email address, and returns True if DMARC policy is - # p=reject or quarantine. + # p=reject or p=quarantine. email = email.lower() # Scan from the right in case quoted local part has an '@'. local, at, from_domain = email.rpartition('@') if at != '@': return False - x = _DMARCProhibited(mlist, email, '_dmarc.{}'.format(from_domain)) - if x is not KEEP_LOOKING: - return x - org_dom = _get_org_dom(from_domain) + answer = is_reject_or_quarantine( + mlist, email, '_dmarc.{}'.format(from_domain)) + if answer is not KEEP_LOOKING: + return answer + org_dom = get_organizational_domain(from_domain) if org_dom != from_domain: - x = _DMARCProhibited( + answer = is_reject_or_quarantine( mlist, email, '_dmarc.{}'.format(org_dom), org=True) - if x is not KEEP_LOOKING: - return x + if answer is not KEEP_LOOKING: + return answer return False @@ -284,7 +299,7 @@ class DMARCMitigation: # Don't bother to check if we're not going to do anything. return False dn, addr = parseaddr(msg.get('from')) - if _IsDMARCProhibited(mlist, addr): + if maybe_mitigate(mlist, addr): # If dmarc_mitigate_action is discard or reject, this rule fires # and jumps to the 'moderation' chain to do the actual discard. # Otherwise, the rule misses but sets a flag for the dmarc handler diff --git a/src/mailman/rules/tests/test_dmarc.py b/src/mailman/rules/tests/test_dmarc.py index 52c231439..f15a39b13 100644 --- a/src/mailman/rules/tests/test_dmarc.py +++ b/src/mailman/rules/tests/test_dmarc.py @@ -59,7 +59,7 @@ def get_dns_resolver( It only implements those classes and attributes used by the dmarc rule. """ class Name: - # mock answer.name + # Mock answer.name. def __init__(self, name='_dmarc.example.biz.'): self.name = name @@ -67,14 +67,14 @@ def get_dns_resolver( return self.name class Item: - # mock answer.items + # Mock answer.items. def __init__(self, rdata=rdata, cname='_dmarc.example.com.'): self.strings = [rdata] - # for CNAMES + # For CNAMEs. self.target = Name(cname) class Ans_e: - # mock answer element + # Mock answer element. def __init__( self, rtype=rtype, @@ -86,7 +86,7 @@ def get_dns_resolver( self.name = Name(name) class Answer: - # mock answer + # Mock answer. def __init__(self): if cloop: self.answer = [ @@ -143,10 +143,7 @@ def get_dns_resolver( self.answer = [Ans_e()] class Resolver: - # mock dns.resolver.Resolver class. - def __init__(self): - pass - + # Mock dns.resolver.Resolver class. def query(self, domain, data_type): if data_type != TXT: raise NoAnswer @@ -191,17 +188,17 @@ class TestDMARCRules(TestCase): def test_no_data_for_domain(self): self.assertEqual( - dmarc._get_org_dom('sub.dom.example.nxtld'), + dmarc.get_organizational_domain('sub.dom.example.nxtld'), 'example.nxtld') def test_domain_with_wild_card(self): self.assertEqual( - dmarc._get_org_dom('ssub.sub.foo.kobe.jp'), + dmarc.get_organizational_domain('ssub.sub.foo.kobe.jp'), 'sub.foo.kobe.jp') def test_exception_to_wild_card(self): self.assertEqual( - dmarc._get_org_dom('ssub.sub.city.kobe.jp'), + dmarc.get_organizational_domain('ssub.sub.city.kobe.jp'), 'city.kobe.jp') def test_no_at_sign_in_from_address(self): @@ -305,9 +302,9 @@ To: ant@example.com self.assertTrue(rule.check(mlist, msg, {})) line = mark.readline() self.assertEqual( - line[-68:], - 'RRset of TXT records for _dmarc.example.biz has 2 v=DMARC1 ' - 'entries;\n') + line[-85:], + 'RRset of TXT records for _dmarc.example.biz has 2 ' + 'v=DMARC1 entries; testing them all\n') def test_multiple_cnames(self): mlist = create_list('ant@example.com') @@ -324,8 +321,10 @@ To: ant@example.com self.assertTrue(rule.check(mlist, msg, {})) line = mark.readline() self.assertEqual( - line[-60:], - 'ant: DMARC lookup for anne@example.biz (_dmarc.example.biz)\n') + line[-128:], + 'ant: DMARC lookup for anne@example.biz (_dmarc.example.biz) ' + 'found p=quarantine in _dmarc.example.com. = v=DMARC1; ' + 'p=quarantine;\n') def test_looping_cnames(self): mlist = create_list('ant@example.com') @@ -342,8 +341,9 @@ To: ant@example.com self.assertTrue(rule.check(mlist, msg, {})) line = mark.readline() self.assertEqual( - line[-60:], - 'ant: DMARC lookup for anne@example.biz (_dmarc.example.biz)\n') + line[-120:], + 'ant: DMARC lookup for anne@example.biz (_dmarc.example.biz) ' + 'found p=reject in _dmarc.example.org. = v=DMARC1; p=reject;\n') def test_no_policy(self): mlist = create_list('ant@example.com') |
