diff --git a/app/config.py b/app/config.py index 4e89537b..aedbb743 100644 --- a/app/config.py +++ b/app/config.py @@ -504,6 +504,15 @@ if not RECOVERY_CODE_HMAC_SECRET or len(RECOVERY_CODE_HMAC_SECRET) < 16: "Please define RECOVERY_CODE_HMAC_SECRET in your configuration with a random string at least 16 chars long" ) + +# the minimum rspamd spam score above which emails that fail DMARC should be quarantined +if "MIN_RSPAMD_SCORE_FOR_FAILED_DMARC" in os.environ: + MIN_RSPAMD_SCORE_FOR_FAILED_DMARC = float( + os.environ["MIN_RSPAMD_SCORE_FOR_FAILED_DMARC"] + ) +else: + MIN_RSPAMD_SCORE_FOR_FAILED_DMARC = None + # run over all reverse alias for an alias and replace them with sender address ENABLE_ALL_REVERSE_ALIAS_REPLACEMENT = ( "ENABLE_ALL_REVERSE_ALIAS_REPLACEMENT" in os.environ diff --git a/app/handler/dmarc.py b/app/handler/dmarc.py index b5fb52fa..73c1fb60 100644 --- a/app/handler/dmarc.py +++ b/app/handler/dmarc.py @@ -5,7 +5,7 @@ from typing import Optional, Tuple from aiosmtpd.handlers import Message from aiosmtpd.smtp import Envelope -from app import s3 +from app import s3, config from app.config import ( DMARC_CHECK_ENABLED, ALERT_QUARANTINE_DMARC, @@ -34,6 +34,37 @@ def apply_dmarc_policy_for_forward_phase( from_header = get_header_unicode(msg[headers.FROM]) + warning_plain_text = f"""This email failed anti-phishing checks when it was received by SimpleLogin, be careful with its content. +More info on https://simplelogin.io/docs/getting-started/anti-phishing/ + """ + warning_html = f""" +

+ This email failed anti-phishing checks when it was received by SimpleLogin, be careful with its content. + More info on anti-phishing measure +

+ """ + + # do not quarantine an email if fails DMARC but has a small rspamd score + if ( + config.MIN_RSPAMD_SCORE_FOR_FAILED_DMARC is not None + and spam_result.rspamd_score < config.MIN_RSPAMD_SCORE_FOR_FAILED_DMARC + and spam_result.dmarc + in ( + DmarcCheckResult.quarantine, + DmarcCheckResult.reject, + ) + ): + LOG.w( + f"email fails DMARC but has a small rspamd score, from contact {contact.email} to alias {alias.email}." + f"mail_from:{envelope.mail_from}, from_header: {from_header}" + ) + changed_msg = add_header( + msg, + warning_plain_text, + warning_html, + ) + return changed_msg, None + if spam_result.dmarc == DmarcCheckResult.soft_fail: LOG.w( f"dmarc forward: soft_fail from contact {contact.email} to alias {alias.email}." @@ -41,15 +72,8 @@ def apply_dmarc_policy_for_forward_phase( ) changed_msg = add_header( msg, - f"""This email failed anti-phishing checks when it was received by SimpleLogin, be careful with its content. -More info on https://simplelogin.io/docs/getting-started/anti-phishing/ - """, - f""" -

- This email failed anti-phishing checks when it was received by SimpleLogin, be careful with its content. - More info on anti-phishing measure -

- """, + warning_plain_text, + warning_html, ) return changed_msg, None @@ -133,6 +157,7 @@ def apply_dmarc_policy_for_reply_phase( DmarcCheckResult.soft_fail, ): return None + LOG.w( f"dmarc reply: Put email from {alias_from.email} to {contact_recipient} into quarantine. {spam_result.event_data()}, " f"mail_from:{envelope.mail_from}, from_header: {msg[headers.FROM]}" diff --git a/app/handler/spamd_result.py b/app/handler/spamd_result.py index 771eabc3..f2ff9a6d 100644 --- a/app/handler/spamd_result.py +++ b/app/handler/spamd_result.py @@ -4,6 +4,7 @@ from typing import Dict, Optional import newrelic.agent from app.email import headers +from app.log import LOG from app.models import EnumE, Phase from email.message import Message @@ -55,6 +56,7 @@ class SpamdResult: self.phase: Phase = phase self.dmarc: DmarcCheckResult = DmarcCheckResult.not_available self.spf: SPFCheckResult = SPFCheckResult.not_available + self.rspamd_score = -1 def set_dmarc_result(self, dmarc_result: DmarcCheckResult): self.dmarc = dmarc_result @@ -85,6 +87,7 @@ class SpamdResult: spam_entries = [ entry.strip() for entry in str(spam_result_header[-1]).split("\n") ] + for entry_pos in range(len(spam_entries)): sep = spam_entries[entry_pos].find("(") if sep > -1: @@ -101,6 +104,17 @@ class SpamdResult: spamd_result.set_spf_result(spf_result) break + # parse the rspamd score + try: + score_line = spam_entries[0] # e.g. "default: False [2.30 / 13.00];" + spamd_result.rspamd_score = float( + score_line[(score_line.find("[") + 1) : score_line.find("]")] + .split("/")[0] + .strip() + ) + except (IndexError, ValueError): + LOG.e("cannot parse rspamd score") + cls._store_in_message(spamd_result, msg) return spamd_result diff --git a/tests/example_emls/dmarc_cannot_parse_rspamd_score.eml b/tests/example_emls/dmarc_cannot_parse_rspamd_score.eml new file mode 100644 index 00000000..07856e31 --- /dev/null +++ b/tests/example_emls/dmarc_cannot_parse_rspamd_score.eml @@ -0,0 +1,41 @@ +X-SimpleLogin-Client-IP: 54.39.200.130 +Received-SPF: Softfail (mailfrom) identity=mailfrom; client-ip=34.59.200.130; + helo=relay.somewhere.net; envelope-from=everwaste@gmail.com; + receiver= +Received: from relay.somewhere.net (relay.somewhere.net [34.59.200.130]) + (using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits)) + (No client certificate requested) + by mx1.sldev.ovh (Postfix) with ESMTPS id 6D8C13F069 + for ; Thu, 17 Mar 2022 16:50:20 +0000 (UTC) +Date: Thu, 17 Mar 2022 16:50:18 +0000 +To: {{ alias_email }} +From: spoofedemailsource@gmail.com +Subject: test Thu, 17 Mar 2022 16:50:18 +0000 +Message-Id: <20220317165018.000191@somewhere-5488dd4b6b-7crp6> +X-Mailer: swaks v20201014.0 jetmore.org/john/code/swaks/ +X-Rspamd-Queue-Id: 6D8C13F069 +X-Rspamd-Server: staging1 +X-Spamd-Result: default: False [WRONGLY_FORMATTED / 13.00]; + MID_RHS_NOT_FQDN(0.50)[]; + DMARC_POLICY_SOFTFAIL(0.10)[gmail.com : No valid SPF, No valid DKIM,none]; + MIME_GOOD(-0.10)[text/plain]; + MIME_TRACE(0.00)[0:+]; + FROM_EQ_ENVFROM(0.00)[]; + ASN(0.00)[asn:16276, ipnet:34.59.0.0/16, country:FR]; + R_DKIM_NA(0.00)[]; + RCVD_COUNT_ZERO(0.00)[0]; + FREEMAIL_ENVFROM(0.00)[gmail.com]; + FROM_NO_DN(0.00)[]; + R_SPF_SOFTFAIL(0.00)[~all]; + FORCE_ACTION_SL_SPF_FAIL_ADD_HEADER(0.00)[add header]; + RCPT_COUNT_ONE(0.00)[1]; + FREEMAIL_FROM(0.00)[gmail.com]; + TO_DN_NONE(0.00)[]; + TO_MATCH_ENVRCPT_ALL(0.00)[]; + ARC_NA(0.00)[] +X-Rspamd-Pre-Result: action=add header; + module=force_actions; + unknown reason +X-Spam: Yes + +This is a test mailing diff --git a/tests/handler/test_spamd_result.py b/tests/handler/test_spamd_result.py index 641a8fab..cf77c5e1 100644 --- a/tests/handler/test_spamd_result.py +++ b/tests/handler/test_spamd_result.py @@ -5,6 +5,7 @@ from tests.utils import load_eml_file def test_dmarc_result_softfail(): msg = load_eml_file("dmarc_gmail_softfail.eml") assert DmarcCheckResult.soft_fail == SpamdResult.extract_from_headers(msg).dmarc + assert SpamdResult.extract_from_headers(msg).rspamd_score == 0.5 def test_dmarc_result_quarantine(): @@ -32,3 +33,14 @@ def test_dmarc_result_bad_policy(): assert SpamdResult._get_from_message(msg) is None assert DmarcCheckResult.bad_policy == SpamdResult.extract_from_headers(msg).dmarc assert SpamdResult._get_from_message(msg) is not None + + +def test_parse_rspamd_score(): + msg = load_eml_file("dmarc_gmail_softfail.eml") + assert SpamdResult.extract_from_headers(msg).rspamd_score == 0.5 + + +def test_cannot_parse_rspamd_score(): + msg = load_eml_file("dmarc_cannot_parse_rspamd_score.eml") + # use the default score when cannot parse + assert SpamdResult.extract_from_headers(msg).rspamd_score == -1