From 1205d50bc469255a05c8edaabb10644bfa3952e5 Mon Sep 17 00:00:00 2001
From: Stuart Gathman <stuart@gathman.org>
Date: Tue, 31 May 2005 18:07:19 +0000
Subject: [PATCH] Release 0.6.9

---
 MANIFEST.in |   1 +
 NEWS        |   8 ++
 TODO        |  16 +++-
 bms.py      | 230 +++++++++++++++++-----------------------------------
 milter.cfg  |  13 ++-
 milter.html |  45 ++++++++--
 milter.spec |  22 +++--
 setup.py    |  10 ++-
 spf.py      | 159 +++++++++++++++++++++++++++---------
 spfquery.py |  91 +++++++++++++++++++++
 10 files changed, 388 insertions(+), 207 deletions(-)
 create mode 100755 spfquery.py

diff --git a/MANIFEST.in b/MANIFEST.in
index 8f11081..15409d7 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 2e84905..402af62 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 b5d9699..4b07819 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 f6e7135..32504dd 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 33094e8..12a266a 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 931aecd..42153ab 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 5241c5c..0c1cd53 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 0534203..68a76e7 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 aa11336..0ba28e1 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 0000000..77d8b69
--- /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:])
-- 
GitLab