diff --git a/MANIFEST.in b/MANIFEST.in index 8f11081e401757f88e7fbb32f8f09452db0afa6e..15409d7b9c09be682a36d6bd5c887ddc2ad383c6 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -10,6 +10,7 @@ include testbms.py include testdspam.py include bms.py include spf.py +include spfquery.py include test.py include sample.py include test/* diff --git a/NEWS b/NEWS index 2e84905a092ac633eea9099089ecd357d76c1270..402af62a015c544845082a3f74010f3a5f6da3db 100644 --- a/NEWS +++ b/NEWS @@ -1,5 +1,12 @@ Here is a history of user visible changes to Python milter. +0.6.9 Reject invalid SRS immediately for benefit of callback verifiers + Fix include bug in spf.py + Fix check_header bug + Fix setup.py to work with python < 2.2.3, thanks to Eric S. Johansson + Test driver for SPF test suite. Fix bugs and add features to + pass most of test suite. + Use best_guess() and get_header() in bms.py for SPF support 0.6.8 Defang message/rfc822 content_type with boundary Support SPF delegation Reject neutral SPF result for selected domains @@ -7,6 +14,7 @@ Here is a history of user visible changes to Python milter. Don't report "spoofed" unless rcpt looks like SRS Check for bounce with multiple rcpts Make dspam see Received-SPF headers + Fix sysv init for Redhat 9 and other single ps line per process systems 0.6.7 Fix failure to remove explicit unix socket thanks to Alexander again. Support SRS forgery detection. Detect thread resource starvation in Milter.py. diff --git a/TODO b/TODO index b5d96991c49159ac64b939f0016679a0c2aa271c..4b0781914c3eda01e6bb4abdb34fc930b6db6d4f 100644 --- a/TODO +++ b/TODO @@ -1,13 +1,21 @@ +Web admin interface +RHBL +Check valid domains allowed by internal senders to detect PCs infected +with spam trojans. +Do CBV (callback verification) for mail with no published SPF record. +message log for automated stats and blacklisting +adapt init script to work on RH9 +Skip dspam when SPF pass? +Report 551 with rcpt on SPF fail? +check spam keywords with character classes, e.g. + {a}=[a@��], {i}=[i1�], {e}=[e�], {o}=[o0�] + Implement RRS - a backdoor for non-SRS forwarders. User lists non-SRS forwarder accounts, and a util provides a special local alias for the user to give to the forwarder. Alias only works for mail from that forwarder. Milter gets forwarder domain from alias and uses it to SPF check forwarder. -adapt init script to work on RH9 -Skip dspam when SPF pass? -Report 551 with rcpt on SPF fail? - Another special dspam user, 'honeypot', can be listed in innoculations. All email to those addresses is treated as known spam. diff --git a/bms.py b/bms.py index f6e7135ba438496e31b5b57bb1eb10942eb1086f..32504dd1069460e35a4de8ffcce5bb43c23b8b58 100644 --- a/bms.py +++ b/bms.py @@ -1,6 +1,33 @@ #!/usr/bin/env python # A simple milter. # $Log$ +# Revision 1.105 2004/04/20 15:16:00 stuart +# Release 0.6.9 +# +# Revision 1.104 2004/04/19 21:56:26 stuart +# Support SPF best_guess and get_header +# +# Revision 1.103 2004/04/10 02:31:01 stuart +# Fix timeout config +# +# Revision 1.102 2004/04/08 20:25:11 stuart +# Make libmilter timeout a config option +# +# Revision 1.101 2004/04/08 19:18:16 stuart +# Preserve case of local part in sender +# +# Revision 1.100 2004/04/08 18:41:15 stuart +# Reject numeric hello names +# +# Revision 1.99 2004/04/06 19:46:39 stuart +# Reject invalid SRS immediately for benefit of CallBack Verifiers. +# +# Revision 1.98 2004/04/06 15:28:20 stuart +# Release 0.6.8-2 +# +# Revision 1.97 2004/04/06 13:07:43 stuart +# Pass original header name to check_header +# # Revision 1.96 2004/04/06 03:27:03 stuart # bugs from Redhat 9 testing # @@ -154,90 +181,6 @@ # Revision 1.47 2003/08/26 05:01:38 stuart # Release 0.6.0 # -# Revision 1.46 2003/08/26 04:45:16 stuart -# Modest dspam control -# -# Revision 1.43 2003/06/25 17:00:02 stuart -# fix hostaddr test -# -# Revision 1.42 2003/06/25 16:45:59 stuart -# Not using checking hostaddr properly -# -# Revision 1.41 2003/06/25 15:57:54 stuart -# Ready for 5.5 release. -# -# Revision 1.40 2003/06/25 15:41:41 stuart -# recognize internal connections. -# Give legitimate users a clue about banned subject keywords. -# -# Revision 1.39 2002/12/14 00:36:59 stuart -# Smart alias feature -# -# Revision 1.38 2002/11/14 17:52:53 stuart -# Redirection feature for wiretap -# -# Revision 1.37 2002/11/07 23:52:09 stuart -# config fixes -# -# Revision 1.36 2002/10/04 05:27:38 stuart -# Add get_submsg to allow modifying rfc822 attachment. -# -# Revision 1.35 2002/10/03 01:31:18 stuart -# Test encoded rfc822 attachment -# -# Revision 1.34 2002/10/03 00:55:42 stuart -# Decode rfc822 attachments -# -# Revision 1.33 2002/10/02 18:49:02 stuart -# Save and log messages which cause an exception while parsing attachments. -# -# Revision 1.32 2002/09/24 01:38:05 stuart -# Doc updates. -# -# Revision 1.31 2002/09/13 22:14:06 stuart -# Release 0.5.0 wrapup -# -# Revision 1.30 2002/09/13 20:22:37 stuart -# Additional config items -# -# Revision 1.29 2002/08/20 04:40:46 stuart -# Use config file -# -# Revision 1.28 2002/07/12 19:40:38 stuart -# Update docs, minor bugs. -# -# Revision 1.27 2002/06/16 02:06:24 stuart -# SPAM tweaks -# -# Revision 1.26 2002/06/07 22:07:30 stuart -# Isolate local hacks to configuration data. -# -# Revision 1.25 2002/05/02 20:41:00 stuart -# Top level virus needs top level header change. -# -# Revision 1.24 2002/05/02 20:31:43 stuart -# Handle quoted-printable HTML attachments. -# Remove entire attachment when HTML can't be parsed by sgmllib. -# -# Revision 1.23 2002/05/02 03:42:31 stuart -# base64 no longer needed -# -# Revision 1.22 2002/05/02 03:12:39 stuart -# Move check_html to mime module. -# -# Revision 1.21 2002/05/02 02:48:22 stuart -# Remove scripts from HTML even with base64 encoding. -# -# Revision 1.20 2002/05/02 00:21:01 stuart -# Test filtering HTML attachments. -# -# Revision 1.19 2002/05/01 22:12:41 stuart -# Remove scripts from HTML attachments. -# -# Revision 1.18 2002/03/01 20:29:00 stuart -# Ready for release. -# - # Author: Stuart D. Gathman <stuart@bmsi.com> # Copyright 2001 Business Management Systems, Inc. # This code is under GPL. See COPYING for details. @@ -252,17 +195,22 @@ import Milter import tempfile import ConfigParser import time +import re + from fnmatch import fnmatchcase from email.Header import decode_header # Import pysrs if available try: import SRS - import re srsre = re.compile(r'^SRS[01][+-=]',re.IGNORECASE) except: SRS = None + +# Import spf if available try: import spf except: spf = None + +ip4re = re.compile(r'^[1-9][0-9]*\.[1-9][0-9]*\.[1-9][0-9]*\.[1-9][0-9]*$') #import syslog #syslog.openlog('milter') @@ -297,10 +245,12 @@ dspam_whitelist = {} dspam_screener = None dspam_internal = True # True if internal mail should be dspammed dspam_reject = () -dspam_sizelimit = 80000 +dspam_sizelimit = 180000 srs = None srs_reject_spoofed = False spf_reject_neutral = () +spf_best_guess = False +timeout = 600 class MilterConfigParser(ConfigParser.ConfigParser): @@ -351,6 +301,7 @@ def read_config(list): cp = MilterConfigParser({ 'tempdir': "/var/log/milter/save", 'socket': "/var/log/milter/pythonsock", + 'timeout': '600', 'scan_html': 'no', 'scan_rfc822': 'yes', 'block_chinese': 'no', @@ -358,12 +309,14 @@ def read_config(list): 'blind_wiretap': 'yes', 'maxage': '8', 'hashlength': '8', - 'reject_spoofed': 'no' + 'reject_spoofed': 'no', + 'best_guess': 'no' }) cp.read(list) tempfile.tempdir = cp.get('milter','tempdir') - global socketname, scan_rfc822, scan_html, block_chinese + global socketname, scan_rfc822, scan_html, block_chinese, timeout socketname = cp.get('milter','socket') + timeout = cp.getint('milter','timeout') scan_rfc822 = cp.getboolean('milter','scan_rfc822') scan_html = cp.getboolean('milter','scan_html') block_chinese = cp.getboolean('milter','block_chinese') @@ -402,7 +355,7 @@ def read_config(list): global dspam_dict, dspam_users, dspam_userdir, dspam_exempt global dspam_screener,dspam_whitelist,dspam_reject,dspam_sizelimit - global spf_reject_neutral,SRS + global spf_reject_neutral,spf_best_guess,SRS dspam_dict = cp.getdefault('dspam','dspam_dict') dspam_exempt = cp.getaddrset('dspam','dspam_exempt') dspam_whitelist = cp.getaddrset('dspam','dspam_whitelist') @@ -416,6 +369,7 @@ def read_config(list): if spf: spf.DELEGATE = cp.getdefault('spf','delegate') spf_reject_neutral = cp.getlist('spf','reject_neutral') + spf_best_guess = cp.getboolean('spf','best_guess') srs_config = cp.getdefault('srs','config') if srs_config: cp.read([srs_config]) srs_secret = cp.getdefault('srs','secret') @@ -526,6 +480,10 @@ class bmsMilter(Milter.Milter): def hello(self,hostname): self.hello_name = hostname self.log("hello from %s" % hostname) + if ip4re.match(hostname): + self.log("REJECT: numeric hello name:",hostname) + self.setreply('550','5.7.1','hello name cannot be numeric ip') + return Milter.REJECT if not self.internal_connection and hostname in hello_blacklist: self.log("REJECT: spam from self:",hostname) self.setreply('550','5.7.1','I hate talking to myself.') @@ -579,73 +537,32 @@ class bmsMilter(Milter.Milter): return Milter.CONTINUE def check_spf(self): - user,host = spf.split_email(self.canon_from,self.hello_name) - self.sender = '@'.join((user,host)) - res,code,txt = spf.check(self.connectip,self.canon_from,self.hello_name) + t = parse_addr(self.mailfrom) + if len(t) == 2: t[1] = t[1].lower() + q = spf.query(self.connectip,'@'.join(t),self.hello_name) + q.set_default_explanation('SPF fail: see http://spf.pobox.com/why.html') + res,code,txt = q.check() + receiver = self.receiver + if res == 'none' and spf_best_guess: + #self.log('SPF: no record published, guessing') + q.set_default_explanation('SPF guess: see http://spf.pobox.com/why.html') + # best_guess should not result in fail + res,code,txt = q.best_guess() + receiver += ': guessing' if res in ('deny', 'fail'): self.log('REJECT: SPF %s %i %s' % (res,code,txt)) - # improve default explanation, but don't wipe out text from SPF record - if txt == 'access denied': - txt = 'SPF fail: see http://spf.pobox.com/why.html' self.setreply(str(code),'5.7.1',txt) return Milter.REJECT - if res == 'pass': -# Received-SPF: pass (mybox.example.org: domain of -# myname@example.com designates 192.0.2.1 as -# permitted sender); -# receiver=mybox.example.org; -# client_ip=192.0.2.1; -# envelope-from=myname@example.com; - self.add_header('Received-SPF',"""pass (%(receiver)s: domain of - %(sender)s designates %(connectip)s as permitted sender); - receiver=%(receiver)s; client_ip=%(connectip)s; - envelope-from=%(canon_from)s;""" % self.__dict__) - elif res == 'none' or res == 'unknown' and txt == 'no SPF record': -# Received-SPF: none (mybox.example.org: myname@example.com does -# not designated permitted sender hosts) - self.add_header('Received-SPF',"""none (%(receiver)s: %(sender)s does - not designate permitted sender hosts)""" % self.__dict__) - elif res == 'softfail': -# Received-SPF: softfail (mybox.example.org: domain of transitioning -# myname@example.com does not designate -# 192.0.2.1 as permitted sender) - self.add_header('Received-SPF', - """softfail (%(receiver)s: domain of transitioning - %(sender)s does not designate - %(connectip)s as permitted sender)""" % self.__dict__) - elif res == 'neutral': - if host in spf_reject_neutral: - self.log('REJECT: SPF neutral for',self.sender) - self.setreply('550','5.7.1', - 'mail from %s must pass SPF: http://spf.pobox.com/why.html' % host - ) - return Milter.REJECT -# Received-SPF: neutral (mybox.example.org: 192.0.2.1 is neither -# permitted nor denied by domain of -# myname@example.com) - self.add_header('Received-SPF', - """neutral (%(receiver)s: %(connectip)s is neither - permitted nor denied by domain of %(sender)s)""" % self.__dict__) - elif res == 'unknown': -# Received-SPF: unknown -extension:foo (mybox.example.org: domain -# of myname@example.com uses mechanism -# not recognized by this client) - self.spf_mech = txt - self.add_header('Received-SPF', - """unknown %(spf_mech)s (%(receiver)s: domain - of %(sender)s uses mechanism not recognized by this client)""" - % self.__dict__) - elif res == 'error': -# Received-SPF: error (mybox.example.org: error in processing -# during lookup of myname@example.com: DNS -# timeout) - self.add_header('Received-SPF', - """error (%s: error in processing - during lookup of %s: %s)""" % (self.receiver,self.sender,txt)) + if res == 'neutral' and q.o in spf_reject_neutral: + self.log('REJECT: SPF neutral for',q.s) + self.setreply('550','5.7.1', + 'mail from %s must pass SPF: http://spf.pobox.com/why.html' % q.o + ) + return Milter.REJECT + if res == 'error': self.setreply(str(code),'4.3.0',txt) return Milter.TEMPFAIL - else: - self.log('SPF: %s %i %s' % (res,code,txt)) + self.add_header('Received-SPF',q.get_header(res,receiver)) return Milter.CONTINUE # hide_path causes a copy of the message to be saved - until we @@ -671,7 +588,9 @@ class bmsMilter(Milter.Milter): self.log("srs rcpt:",newaddr) except: if srsre.match(oldaddr): - self.log("srs spoofed:",oldaddr) + self.log("REJECT: srs spoofed:",oldaddr) + self.setreply('550','5.7.1','Invalid SRS signature') + return Milter.REJECT self.data_allowed = not srs_reject_spoofed self.recipients.append('@'.join(t)) user,domain = t @@ -708,7 +627,8 @@ class bmsMilter(Milter.Milter): return Milter.CONTINUE # Heuristic checks for spam headers - def check_header(self,lname,val): + def check_header(self,name,val): + lname = name.lower() # val is decoded header value if lname == 'subject': @@ -743,6 +663,7 @@ class bmsMilter(Milter.Milter): if not self.forward: if lval.startswith("fwd:") or lval.startswith("[fw"): self.log('REJECT: %s: %s' % (name,val)) + self.setreply('550','5.7.1','I find unedited forwards annoying') return Milter.REJECT # check for invalid message id @@ -777,7 +698,7 @@ class bmsMilter(Milter.Milter): self.log('REJECT: %s: %s' % (name,hval)) self.setreply('550','5.7.1',"We don't understand chinese") return Milter.REJECT - rc = self.check_header(lname,val) + rc = self.check_header(name,val) if rc != Milter.CONTINUE: return rc # log selected headers if log_headers or lname in ('subject','x-mailer'): @@ -1031,6 +952,7 @@ class bmsMilter(Milter.Milter): os.remove(self.tempname) # remove in case session aborted if self.fp: self.fp.close() + sys.stdout.flush() return Milter.CONTINUE def abort(self): @@ -1047,7 +969,7 @@ def main(): Milter.set_flags(flags) print "bms milter startup" sys.stdout.flush() - Milter.runmilter("pythonfilter",socketname,600) + Milter.runmilter("pythonfilter",socketname,timeout) print "bms milter shutdown" if __name__ == "__main__": diff --git a/milter.cfg b/milter.cfg index 33094e8e091e320781373b3ae6b00e50e8e20b59..12a266ac746c8615096f8f4be072d4cb6d1db294 100644 --- a/milter.cfg +++ b/milter.cfg @@ -1,10 +1,15 @@ # features intended to filter or block incoming mail [milter] +;socket=/var/log/milter/pythonsock tempdir = /var/log/milter/save +;timeout=600 + scan_rfc822 = 1 # can be CPU intensive scan_html = 0 +# reject asian fonts because we can't read them block_chinese = 1 +# users who hate forwarded mail ;block_forward = egghead@mycorp.com, busybee@mycorp.com log_headers = 0 # Reject mail for domains mentioned unless user is mentioned here also @@ -12,7 +17,9 @@ log_headers = 0 # porn words are case insensitive porn_words = penis, breast, pussy, horse cock, porn, xenical, diet pill, d1ck, vi*gra, vi-a-gra, viag, tits, p0rn, hunza, horny, sexy, c0ck, - p-e-n-i-s, hydrocodone, vicodin, xanax, vicod1n, x@nax + p-e-n-i-s, hydrocodone, vicodin, xanax, vicod1n, x@nax, diazepam, + v1@gra, xan@x, cialis, ci@lis, fr�e, x�nax, val�um, v�lium, via-gra, + x@n3x, vicod3n, pen�s, v|c0d1n, phentermine, en1arge, dip1oma, v1codin # spam words are case sensitive spam_words = $$$, !!!, XXX, FREE, HGH @@ -43,6 +50,8 @@ reject_spoofed = 0 ;delegate = domain.com # domains where a neutral SPF result should cause mail to be rejected ;reject_neutral = aol.com +# use a default (v=spf1 a/24 mx/24 ptr) when no SPF records are published +;best_guess = 0 # features intended to clean up outgoing mail [scrub] @@ -93,6 +102,8 @@ blind = 1 # defining this activates the dspam application # dspam and dspam-python must be installed ;dspam_userdir=/var/lib/dspam +# do not dspam messages larger than this +;dspam_sizelimit=180000 # Map email addresses and aliases to dspam users ;dspam_users=david,goliath,spam,falsepositive diff --git a/milter.html b/milter.html index 931aecd825a33ad694395f1df12f4a219e5237c7..42153ab8375e74a8f6fc19260da5cdcd6f21b5bf 100644 --- a/milter.html +++ b/milter.html @@ -24,7 +24,7 @@ ALT="Viewable With Any Browser" BORDER="0"></A> Stuart D. Gathman</a><br> This web page is written by Stuart D. Gathman<br>and<br>sponsored by <a href="http://www.bmsi.com">Business Management Systems, Inc.</a> <br> -Last updated Apr 05, 2004</h4> +Last updated Apr 21, 2004</h4> See the <a href="faq.html">FAQ</a> | <a href="#download">Download now</a> | <a href="/mailman/listinfo/pymilter">Subscribe to mailing list</a> @@ -45,6 +45,13 @@ I recommend upgrading. I have selected the <a href="http://www.nuclearelephant.com/projects/dspam/"> dspam bayes filter project</a> and <a href="dspam.html"> packaged it for python</a>. +Release 0.6.6 adds support for <a href="http://spf.pobox.com/">SPF</a>, +a protocol to prevent forging of the envelope from address. +SPF support requires <a href="http://pydns.sourceforge.net/">pydns</a>. +The included spf.py module is an updated version of the original 1.6 +version at <a href="http://www.wayforward.net/spf/">wayforward.net</a>. +The updated version tracks the draft RFC and test suite. +<p> Release 0.6.0 offers a simple application of dspam I call "header triage", which rejects messages with spammy headers. Since sendmail has to read the entire message anyway once we start reading headers, it @@ -140,14 +147,43 @@ wiretapping, and Win32 virus protection milter. <h3><a name=download>Downloading</a></h3> -The latest stable release is <a href="#stable">0.6.6</a>. A stable +The latest stable release is <a href="#stable">0.6.9</a>. A stable release is one which has been installed (and working correctly) on production systems long enough to convince me that it is stable. As the package gains more features and complexity, stable will mean no bug reports from outside users either. <p> -The latest version is 0.6.7. See the <a href=NEWS>Change Log</a>. - +The latest version is 0.6.9-1. See the <a href=NEWS>Change Log</a>. +<p> +<a name="stable"><b>Stable</b></a> +<a href="http://bmsi.com/python/milter-0.6.9.tar.gz"> +milter-0.6.9.tar.gz</a> Add SPF test suite driver, and validate +spf.py against test suite. Add best_guess and get_header to spf.py. +Libmilter timeout option in config. +<br> +<a href="http://bmsi.com/linux/rh72/milter-0.6.9-1.i386.rpm"> +milter-0.6.9-1.i386.rpm</a> Binary RPM for Redhat 7.x, now requires + sendmail-8.12 and <a href="http://www.python.org/2.3.3/rpms.html"> + python2.3</a>. +<br> +<a href="http://bmsi.com/linux/rh9/milter-0.6.9-1.src.rpm"> +milter-0.6.9-1.src.rpm</a> Source RPM for Redhat 9,7.x. +<p> +<a href="http://bmsi.com/python/milter-0.6.8.tar.gz"> +milter-0.6.8.tar.gz</a> Include Received-SPF headers in Dspam analysis. +Fix sysv init for Redhat 9 and later. Reject bounces with multiple +recipients. +<br> +<a href="http://bmsi.com/python/milter-0.6.8.patch">milter-0.6.8.patch</a> +Last minutes fixes from production testing. +<p> +<a href="http://bmsi.com/linux/rh72/milter-0.6.8-3.i386.rpm"> +milter-0.6.8-3.i386.rpm</a> Binary RPM for Redhat 7.x, now requires + sendmail-8.12 and <a href="http://www.python.org/2.3.3/rpms.html"> + python2.3</a>. +<br> +<a href="http://bmsi.com/linux/rh9/milter-0.6.8-3.src.rpm"> +milter-0.6.8-3.src.rpm</a> Source RPM for Redhat 9,7.x. <p> <a href="http://bmsi.com/python/milter-0.6.7.tar.gz"> milter-0.6.7.tar.gz</a> Explicit local socket bug, @@ -169,7 +205,6 @@ Release 0.6.7-3 patches: <li> Reject neutral SPF result for selected domains </ul> <p> -<a name="stable"><b>Stable</b></a> <a href="http://bmsi.com/python/milter-0.6.6.tar.gz"> milter-0.6.6.tar.gz</a> Plug another memory leak, <a href="http://spf.pobox.com/">SPF</a> support, hello blacklist. diff --git a/milter.spec b/milter.spec index 5241c5ca7374f2d693749b1336c8bb16ead99568..0c1cd539fec3cbd1c54c6f899fc939c30832ffe2 100644 --- a/milter.spec +++ b/milter.spec @@ -1,10 +1,10 @@ %define name milter -%define version 0.6.8 +%define version 0.6.9 %define release 1 # Redhat 7.x and earlier (multiple ps lines per thread) -#%define sysvinit rc7 +%define sysvinit milter.rc7 # RH9, other systems (single ps line per process) -%define sysvinit rc +#define sysvinit milter.rc %ifos Linux %define python python2.3 %else @@ -16,7 +16,7 @@ Name: %{name} Version: %{version} Release: %{release} Source: %{name}-%{version}.tar.gz -#Patch: %{name}.patch +#Patch: %{name}-%{version}.patch Copyright: GPL Group: Development/Libraries BuildRoot: %{_tmppath}/%{name}-buildroot @@ -81,7 +81,7 @@ exec >>milter.log 2>&1 echo $! >/var/run/milter/milter.pid EOF mkdir -p $RPM_BUILD_ROOT/etc/rc.d/init.d -cp milter.%{sysvinit} $RPM_BUILD_ROOT/etc/rc.d/init.d/milter +cp %{sysvinit} $RPM_BUILD_ROOT/etc/rc.d/init.d/milter ed $RPM_BUILD_ROOT/etc/rc.d/init.d/milter <<'EOF' /^python=/ c @@ -127,6 +127,18 @@ rm -rf $RPM_BUILD_ROOT %config /var/log/milter/milter.cfg %changelog +* Fri Apr 09 2004 Stuart Gathman <stuart@bmsi.com> 0.6.9-1 +- Validate spf.py against test suite, and add Received-SPF support to spf.py +- Support best_guess for SPF +- Reject numeric hello names +- Preserve case of local part in sender +- Make libmilter timeout a config option +- Fix setup.py to work with python < 2.2.3 +* Tue Apr 06 2004 Stuart Gathman <stuart@bmsi.com> 0.6.8-3 +- Reject invalid SRS immediately for benefit of callback verifiers +- Fix include bug in spf.py +* Tue Apr 06 2004 Stuart Gathman <stuart@bmsi.com> 0.6.8-2 +- Bug in check_header * Mon Apr 05 2004 Stuart Gathman <stuart@bmsi.com> 0.6.8-1 - Don't report spoofed unless rcpt looks like SRS - Check for bounce with multiple rcpts diff --git a/setup.py b/setup.py index 05342032d8d71e8b89e7cbd32d83493ec95f3d59..68a76e717447d1f882acbd39fb2f2a1acff73d9f 100644 --- a/setup.py +++ b/setup.py @@ -1,10 +1,18 @@ import os +import sys from distutils.core import setup, Extension # FIXME: on some versions of sendmail, smutil is renamed to sm libs = ["milter", "smutil"] -setup(name = "milter", version = "0.6.8", +# patch distutils if it can't cope with the "classifiers" or +# "download_url" keywords +if sys.version < '2.2.3': + from distutils.dist import DistributionMetadata + DistributionMetadata.classifiers = None + DistributionMetadata.download_url = None + +setup(name = "milter", version = "0.6.9", description="Python interface to sendmail milter API", long_description="""\ This is a python extension module to enable python scripts to diff --git a/spf.py b/spf.py index aa1133601ba807524dcdff2fe0d4eb91dafe544d..0ba28e1a1f3b853f6eb9d70f3503ac856e36ab21 100755 --- a/spf.py +++ b/spf.py @@ -40,7 +40,26 @@ For news, bugfixes, etc. visit the home page for this implementation at # ditch the annoying Python 2.4 FutureWarning # 18-dec-2003, v1.6, Failures on Intel hardware: endianness. Use ! on # struct.pack(), struct.unpack(). +# +# Development taken over by Stuart Gathman <stuart@bmsi.com> since +# Terrence is not responding to email. +# # $Log$ +# Revision 1.10 2004/04/19 22:12:11 stuart +# Release 0.6.9 +# +# Revision 1.9 2004/04/18 03:29:35 stuart +# Pass most tests except -local and -rcpt-to +# +# Revision 1.8 2004/04/17 22:17:55 stuart +# Header comment method. +# +# Revision 1.7 2004/04/17 18:22:48 stuart +# Support default explanation. +# +# Revision 1.6 2004/04/06 20:18:02 stuart +# Fix bug in include +# # Revision 1.5 2004/04/05 22:29:46 stuart # SPF best_guess, # @@ -99,12 +118,13 @@ JOINERS = {'l': '.', 's': '.'} RESULTS = {'+': 'pass', '-': 'fail', '?': 'neutral', '~': 'softfail', 'pass': 'pass', 'fail': 'fail', 'unknown': 'unknown', 'neutral': 'neutral', 'softfail': 'softfail', - 'none': 'none' } + 'none': 'none', 'deny': 'fail' } EXPLANATIONS = {'pass': 'sender SPF verified', 'fail': 'access denied', - 'unknown': 'SPF unknown', 'softfail': 'domain in transition', + 'unknown': 'SPF unknown', + 'softfail': 'domain in transition', 'neutral': 'access neither permitted nor denied', - 'none': 'no SPF records' + 'none': '' } # if set to a domain name, search _spf.domain namespace if no SPF record @@ -123,7 +143,7 @@ except NameError: # standard default SPF record DEFAULT_SPF = 'v=spf1 a/24 mx/24 ptr' -def check(i, s, h,default=None): +def check(i, s, h,local=None): """Test an incoming MAIL FROM:<s>, from a client with ip address i. h is the HELO/EHLO domain name. @@ -137,21 +157,7 @@ def check(i, s, h,default=None): #>>> check(i='61.51.192.42', s='liukebing@bcc.com', h='bmsi.com') """ - if i.startswith('127.'): - return ('pass', 250, 'local connections always pass') - - try: - q = query(i=i, s=s, h=h) - spf = q.dns_spf(q.d) - if not spf and default: - spf = default - return q.check(spf) - except DNS.DNSError: - return ('error', 450, 'SPF DNS Error') - -def best_guess(i, s, h,spf=DEFAULT_SPF): - q = query(i=i, s=s, h=h) - return q.check(spf) + return query(i=i, s=s, h=h,local=local).check() class query(object): """A query object keeps the relevant information about a single SPF @@ -172,7 +178,7 @@ class query(object): Also keeps cache: DNS cache. """ - def __init__(self, i, s, h): + def __init__(self, i, s, h,local=None): self.i, self.s, self.h = i, s, h self.l, self.o = split_email(s, h) self.t = str(int(time.time())) @@ -180,6 +186,13 @@ class query(object): self.d = self.o self.p = None self.cache = {} + self.exps = dict(EXPLANATIONS) + self.local = local # local policy + + def set_default_explanation(self,exp): + exps = self.exps + for i in 'softfail','fail','unknown': + exps[i] = exp def getp(self): if not self.p: @@ -190,17 +203,32 @@ class query(object): self.p = self.i return self.p - def check(self, spf): + def best_guess(self,spf=DEFAULT_SPF): + """Return a best guess based on a default SPF record""" + return self.check(spf) + + def check(self, spf=None): """ - Returns (result, mta-status-code, explanation) where - result in ['fail', 'unknown', 'pass'] + Returns (result, mta-status-code, explanation) where + result in ['fail', 'softfail', 'neutral' 'unknown', 'pass', 'error'] """ - return self.check1(spf, self.d, 0) + if self.i.startswith('127.'): + return ('pass', 250, 'local connections always pass') + + try: + if not spf: + spf = self.dns_spf(self.d) + if self.local and spf: + spf += ' ' + self.local + return self.check1(spf, self.d, 0) + except DNS.DNSError: + return ('error', 450, 'SPF DNS Error') def check1(self, spf, domain, recursion): # spf rfc: 3.7 Processing Limits # - if recursion > 10: + if recursion > 20: + self.prob = 'Mechanisms used too many DNS lookups' return ('unknown', 250, 'SPF recursion limit exceeded') try: tmp, self.d = self.d, domain @@ -216,20 +244,21 @@ class query(object): """ if not spf: - return ('none', 250, 'no SPF records') + return ('none', 250, EXPLANATIONS['none']) # split string by whitespace, drop the 'v=spf1' # spf = spf.split()[1:] # copy of explanations to be modified by exp= - exps = dict(EXPLANATIONS) + exps = self.exps redirect = None # no mechanisms at all cause unknown result, unless # overridden with 'default=' modifier # default = 'neutral' + self.mech = [] # unknown mechanisms # Look for modifiers # @@ -267,13 +296,22 @@ class query(object): arg = self.expand(arg) if m == 'include': - if arg != self.d: - tmp = self.check1(self.dns_spf(arg), - arg, recursion + 1) - if tmp[0] == 'pass': - break - if tmp[0] != 'fail': - return tmp + if arg != self.d: + res,code,txt = self.check1(self.dns_spf(arg), + arg, recursion + 1) + if res == 'pass': + break + if res in ('fail','neutral','softfail'): + continue + if res == 'none': + self.prob = \ + 'Could not find a valid SPF record' + res = 'unknown' + return res,code,txt + else: + self.prob = 'Required option is missing' + self.mech.append(mech) + return ('unknown', 250, 'missing SPF option') elif m == 'all': break @@ -304,7 +342,9 @@ class query(object): else: # unknown mechanisms cause immediate unknown # abort results - return ('unknown', 250, mech) + self.mech.append(mech) + self.prob = 'Unknown mechanism found' + return ('unknown',250,'unknown SPF mechanism') else: # no matches @@ -321,7 +361,10 @@ class query(object): def get_explanation(self, spec): """Expand an explanation.""" - return self.expand(''.join(self.dns_txt(self.expand(spec)))) + if spec: + return self.expand(''.join(self.dns_txt(self.expand(spec)))) + else: + return 'explanation : Required option is missing' def expand(self, str): """Do SPF RFC macro expansion. @@ -433,7 +476,9 @@ class query(object): return None def dns_txt(self, domainname): - return [t for a in self.dns(domainname, 'TXT') for t in a] + if domainname: + return [t for a in self.dns(domainname, 'TXT') for t in a] + return [] def dns_mx(self, domainname): """Get a list of IP addresses for all MX exchanges for a @@ -490,6 +535,46 @@ class query(object): result = self.dns(cname, qtype) return result + def get_header(self,res,receiver): + if res in ('pass','fail'): + return '%s (%s: %s) client-ip=%s; envelope-from=%s; helo=%s;' % ( + res,receiver,self.get_header_comment(res),self.i, + self.l + '@' + self.o, self.h) + if res == 'unknown': + return '%s (%s: %s)' % (' '.join([res] + self.mech), + receiver,self.get_header_comment(res)) + return '%s (%s: %s)' % (res,receiver,self.get_header_comment(res)) + + def get_header_comment(self,res): + """Return comment for Received-SPF header. + """ + sender = self.o + if res == 'pass': + if self.i.startswith('127.'): + return "localhost is always allowed." + else: return \ + "domain of %s designates %s as permitted sender" \ + % (sender,self.i) + elif res == 'softfail': return \ + "transitioning domain of %s does not designate %s as permitted sender" \ + % (sender,self.i) + elif res == 'neutral': return \ + "%s is neither permitted nor denied by domain of %s" \ + % (self.i,sender) + elif res == 'none': return \ + "%s is neither permitted nor denied by domain of %s" \ + % (self.i,sender) + #"%s does not designate permitted sender hosts" % sender + elif res == 'unknown': return \ + "error in processing during lookup of domain of %s: %s" \ + % (sender, self.prob) + elif res == 'error': return \ + "error in processing during lookup of %s" % sender + elif res == 'fail': return \ + "domain of %s does not designate %s as permitted sender" \ + % (sender,self.i) + raise ValueError("invalid SPF result for header comment: "+res) + def split_email(s, h): """Given a sender email s and a HELO domain h, create a valid tuple (l, d) local-part and domain-part. diff --git a/spfquery.py b/spfquery.py new file mode 100755 index 0000000000000000000000000000000000000000..77d8b69fb09eb7b8a99e21fd634e08031d3d2770 --- /dev/null +++ b/spfquery.py @@ -0,0 +1,91 @@ +#!/usr/bin/python2.3 +# $Log$ +# Revision 2.3 2004/04/19 22:12:11 stuart +# Release 0.6.9 +# +# Revision 2.2 2004/04/18 03:29:35 stuart +# Pass most tests except -local and -rcpt-to +# +# Revision 2.1 2004/04/08 18:41:15 stuart +# Reject numeric hello names +# +# Driver for SPF test system + +import spf +import sys + +from optparse import OptionParser + +class PerlOptionParser(OptionParser): + def _process_args (self, largs, rargs, values): + """_process_args(largs : [string], + rargs : [string], + values : Values) + + Process command-line arguments and populate 'values', consuming + options and arguments from 'rargs'. If 'allow_interspersed_args' is + false, stop at the first non-option argument. If true, accumulate any + interspersed non-option arguments in 'largs'. + """ + while rargs: + arg = rargs[0] + # We handle bare "--" explicitly, and bare "-" is handled by the + # standard arg handler since the short arg case ensures that the + # len of the opt string is greater than 1. + if arg == "--": + del rargs[0] + return + elif arg[0:2] == "--": + # process a single long option (possibly with value(s)) + self._process_long_opt(rargs, values) + elif arg[:1] == "-" and len(arg) > 1: + # process a single perl style long option + rargs[0] = '-' + arg + self._process_long_opt(rargs, values) + elif self.allow_interspersed_args: + largs.append(arg) + del rargs[0] + else: + return + +def format(q): + res,code,txt = q.check() + print res + if res in ('pass','neutral','unknown'): print + else: print txt + print 'spfquery:',q.get_header_comment(res) + print 'Received-SPF:',q.get_header(res,'spfquery') + +def main(argv): + parser = PerlOptionParser() + parser.add_option("--file",dest="file") + parser.add_option("--ip",dest="ip") + parser.add_option("--sender",dest="sender") + parser.add_option("--helo",dest="hello_name") + parser.add_option("--local",dest="local_policy") + parser.add_option("--rcpt-to",dest="rcpt") + parser.add_option("--default-explanation",dest="explanation") + parser.add_option("--sanitize",type="int",dest="sanitize") + parser.add_option("--debug",type="int",dest="debug") + opts,args = parser.parse_args(argv) + if opts.ip: + q = spf.query(opts.ip,opts.sender,opts.hello_name,local=opts.local_policy) + if opts.explanation: + q.set_default_explanation(opts.explanation) + format(q) + if opts.file: + if opts.file == '0': + fp = sys.stdin + else: + fp = open(opts.file,'r') + for ln in fp: + ip,sender,helo,rcpt = ln.split(None,3) + q = spf.query(ip,sender,helo,local=opts.local_policy) + if opts.explanation: + q.set_default_explanation(opts.explanation) + format(q) + fp.close() + +if __name__ == "__main__": + import sys + main(sys.argv[1:])