Development changes since 0.7.2

This commit is contained in:
Stuart Gathman
2005-05-31 18:23:49 +00:00
parent 20fb6efab0
commit 9fb3ad70d4
17 changed files with 969 additions and 596 deletions
+153 -185
View File
@@ -1,6 +1,33 @@
#!/usr/bin/env python
# A simple milter.
# $Log$
# Revision 1.134 2005/05/25 15:36:43 stuart
# Use dynip module.
# Support smart aliasing of wiretap destination.
# Always send DSN for SOFTFAIL.
# Close forged bounce loophole when there are no headers.
#
# Revision 1.133 2005/03/16 21:58:04 stuart
# Auto DSN feature.
#
# Revision 1.132 2005/02/12 02:11:10 stuart
# Pass unit tests with python2.4.
#
# Revision 1.131 2005/02/11 18:34:13 stuart
# Handle garbage after quote in boundary.
#
# Revision 1.130 2005/02/10 01:10:58 stuart
# Fixed MimeMessage.ismodified()
#
# Revision 1.129 2005/02/10 00:56:48 stuart
# Runs with python2.4. Defang not working correctly - more work needed.
#
# Revision 1.128 2005/02/09 17:53:34 stuart
# Optionally run dspam on internal mail.
#
# Revision 1.127 2004/12/03 14:26:21 stuart
# Mark DYN PTR, REJECT softfail, log Received-SPF from trusted MTA.
#
# Revision 1.126 2004/11/24 14:39:38 stuart
# Also accept softfail if valid PTR or HELO.
#
@@ -151,102 +178,6 @@
# Revision 1.79 2003/12/04 23:46:06 stuart
# Release 0.6.4
#
# Revision 1.78 2003/12/04 23:20:24 stuart
# Make headerChange handle deleting absent header
#
# Revision 1.77 2003/12/04 22:01:40 stuart
# Limit size of messages which will be dspammed. This works around a bug
# in dspam-2.6.5.2 where it scans large binary attachments. I've never
# seen really big spam anyway.
#
# Revision 1.76 2003/12/04 21:44:33 stuart
# Pass header changes from Dspam to sendmail
#
# Revision 1.75 2003/11/25 17:43:07 stuart
# Update FAQ.
#
# Revision 1.74 2003/11/25 17:36:58 stuart
# dspam_reject
#
# Revision 1.73 2003/11/24 15:46:00 stuart
# Missing global for dspam_whitelist
#
# Revision 1.72 2003/11/22 02:52:07 stuart
# Handle multiple x-dspam-recipients properly on false positive
#
# Revision 1.71 2003/11/22 02:49:57 stuart
# dspam whitelist
#
# Revision 1.70 2003/11/09 03:53:34 stuart
# Don't block delivery of defanged false positives.
#
# Revision 1.69 2003/11/08 22:47:04 stuart
# Exempt entire domains with '@domain.com'
#
# Revision 1.68 2003/11/02 03:06:16 stuart
# Adjust error codes again.
#
# Revision 1.67 2003/11/02 03:01:46 stuart
# Adjust SMTP error codes after careful reading of standard.
#
# Revision 1.66 2003/11/02 01:56:43 stuart
# Use busy SMTP code.
#
# Revision 1.65 2003/11/02 01:44:11 stuart
# Suppress traceback for Dspam lock timeouts
#
# Revision 1.64 2003/10/28 01:00:19 stuart
# Dspam internal mail for dspam users
#
# Revision 1.63 2003/10/25 02:10:34 stuart
# Match hostname for internal connection test, even if no ipaddr.
#
# Revision 1.62 2003/10/24 04:34:52 stuart
# Fix for not saving defang of false positive triggered rejecting it
# as a virus from self.
#
# Revision 1.61 2003/10/22 22:03:14 stuart
# Apply dspam_exempt to screening
#
# Revision 1.60 2003/10/22 21:58:42 stuart
# Don't save false positives as defang file.
#
# Revision 1.59 2003/10/22 05:02:27 stuart
# Add support for dspam screeners
#
# Revision 1.58 2003/10/16 22:19:24 stuart
# Redirect Dspam logging to bms milter
#
# Revision 1.57 2003/10/10 00:15:04 stuart
# DISCARD message if quarrantined for any recipient.
#
# Revision 1.56 2003/10/06 19:30:27 stuart
# REJECT messages with boundard errors
#
# Revision 1.55 2003/10/03 18:20:31 stuart
# Opt-out feature to exempt certain recipients from header filtering.
#
# Revision 1.54 2003/09/22 13:36:04 stuart
# Release 0.6.1
#
# Revision 1.53 2003/09/06 07:08:36 stuart
# dspam support improvements.
#
# Revision 1.51 2003/09/02 00:27:27 stuart
# Should have full milter based dspam support working
#
# Revision 1.50 2003/08/26 06:08:17 stuart
# Use new python boolean since we now require 2.2.2
#
# Revision 1.49 2003/08/26 05:45:51 stuart
# Fix conditional import of dspam. Update web page.
#
# Revision 1.48 2003/08/26 05:10:43 stuart
# Readability tweaks
#
# Revision 1.47 2003/08/26 05:01:38 stuart
# Release 0.6.0
#
# Author: Stuart D. Gathman <stuart@bmsi.com>
# Copyright 2001 Business Management Systems, Inc.
# This code is under GPL. See COPYING for details.
@@ -262,6 +193,8 @@ import tempfile
import ConfigParser
import time
import re
import Milter.dsn as dsn
from Milter.dynip import is_dynip as dynip
from fnmatch import fnmatchcase
from email.Header import decode_header
@@ -320,6 +253,11 @@ spf_accept_softfail = ()
spf_best_guess = False
spf_reject_noptr = False
timeout = 600
cbv_cache = {}
try:
for rcpt in open('send_dsn.log'):
cbv_cache[rcpt.strip()] = None
except IOError: pass
class MilterConfigParser(ConfigParser.ConfigParser):
@@ -380,7 +318,8 @@ def read_config(list):
'hashlength': '8',
'reject_spoofed': 'no',
'reject_noptr': 'no',
'best_guess': 'no'
'best_guess': 'no',
'dspam_internal': 'yes'
})
cp.read(list)
tempfile.tempdir = cp.get('milter','tempdir')
@@ -423,7 +362,7 @@ def read_config(list):
key = (sm[0],sm[1])
smart_alias[key] = sm[2:]
global dspam_dict, dspam_users, dspam_userdir, dspam_exempt
global dspam_dict, dspam_users, dspam_userdir, dspam_exempt, dspam_internal
global dspam_screener,dspam_whitelist,dspam_reject,dspam_sizelimit
global spf_reject_neutral,spf_best_guess,SRS,spf_reject_noptr
global spf_accept_softfail
@@ -434,6 +373,7 @@ def read_config(list):
dspam_userdir = cp.getdefault('dspam','dspam_userdir')
dspam_screener = cp.getlist('dspam','dspam_screener')
dspam_reject = cp.getlist('dspam','dspam_reject')
dspam_internal = cp.getboolean('dspam','dspam_internal')
if cp.has_option('dspam','dspam_sizelimit'):
dspam_sizelimit = cp.getint('dspam','dspam_sizelimit')
@@ -488,43 +428,6 @@ def parse_header(val):
except LookupError: pass
return val
ip3 = re.compile('([0-9]{1,3})[.-]([0-9]{1,3})[.-]([0-9]{1,3})')
rehmac = re.compile('h[0-9a-f]{12}[.]|pcp[0-9]{6,10}pcs[.]|no-reverse')
def dynip(host,addr):
"""Return True if hostname is for a dynamic ip.
Examples:
>>> is_dynip('post3.fabulousdealz.com','69.60.99.112')
False
>>> is_dynip('adsl-69-208-201-177.dsl.emhril.ameritech.net','69.208.201.177')
True
"""
if host.startswith('[') and host.endswith(']'):
return True
if addr:
if host.find(addr) >= 0: return True
a = addr.split('.')
m = ip3.search(host)
if m:
g = list(m.groups())
if g == a[1:] or g == a[:3]: return True
g.reverse()
if g == a[1:] or g == a[:3]: return True
if rehmac.search(host): return True
if host.find("-%s." % '-'.join(a[2:])) >= 0: return True
if host.find("w%s." % '-'.join(a[:2])) >= 0: return True
if host.find(''.join(a[:3])) >= 0: return True
if host.find(''.join(a[1:])) >= 0: return True
x = "%02x%02x%02x%02x" % tuple(map(int,a))
if host.lower().find(x) >= 0: return True
z = [n.zfill(3) for n in a]
if host.find('-'.join(z)) >= 0: return True
if host.find("-%s." % '-'.join(z[2:])) >= 0: return True
if host.find("%s." % ''.join(z[2:])) >= 0: return True
if host.find(''.join(z)) >= 0: return True
return False
class bmsMilter(Milter.Milter):
"""Milter to replace attachments poisonous to Windows with a WARNING message,
check SPF, and other anti-forgery features, and implement wiretapping
@@ -577,9 +480,8 @@ class bmsMilter(Milter.Milter):
if fnmatchcase(ipaddr,pat):
self.trusted_relay = True
break
self.connectip = ipaddr
else:
self.connectip = None
else: ipaddr = ''
self.connectip = ipaddr
self.missing_ptr = dynip(hostname,self.connectip)
for pat in internal_connect:
if fnmatchcase(hostname,pat):
@@ -591,9 +493,16 @@ class bmsMilter(Milter.Milter):
connecttype = 'EXTERNAL'
if self.trusted_relay:
connecttype += ' TRUSTED'
if self.missing_ptr:
connecttype += ' DYN'
self.log("connect from %s at %s %s" % (hostname,hostaddr,connecttype))
self.hello_name = None
self.connecthost = hostname
if hostname == 'localhost' and not ipaddr.startswith('127.') \
or hostname == '.':
self.log("REJECT: PTR is",hostname)
self.setreply('550','5.7.1', '"%s" is not a reasonable PTR name'%hostname)
return Milter.REJECT
return Milter.CONTINUE
def hello(self,hostname):
@@ -609,6 +518,25 @@ class bmsMilter(Milter.Milter):
return Milter.REJECT
return Milter.CONTINUE
def smart_alias(self,to):
if smart_alias:
t = parse_addr(to.lower())
if len(t) == 2:
ct = '@'.join(t)
else:
ct = t[0]
cf = self.canon_from
cf0 = cf.split('@',1)
if len(cf0) == 2:
cf0 = '@' + cf0[1]
else:
cf0 = cf
for key in ((cf,ct),(cf0,ct)):
if smart_alias.has_key(key):
self.del_recipient(to)
for t in smart_alias[key]:
self.add_recipient('<%s>'%t)
# multiple messages can be received on a single connection
# envfrom (MAIL FROM in the SMTP protocol) seems to mark the start
# of each message.
@@ -625,10 +553,12 @@ class bmsMilter(Milter.Milter):
self.reject_spam = True
self.data_allowed = True
self.trust_received = self.trusted_relay
self.trust_spf = self.trusted_relay
self.redirect_list = []
self.discard_list = []
self.new_headers = []
self.recipients = []
self.cbv_needed = None
t = parse_addr(f.lower())
self.canon_from = '@'.join(t)
self.fp.write('From %s %s\n' % (self.canon_from,time.ctime()))
@@ -643,6 +573,7 @@ class bmsMilter(Milter.Milter):
self.rejectvirus = domain in reject_virus_from
if user in wiretap_users.get(domain,()):
self.add_recipient(wiretap_dest)
self.smart_alias(wiretap_dest)
if user in discard_users.get(domain,()):
self.discard = True
exempt_users = dspam_whitelist.get(domain,())
@@ -662,14 +593,14 @@ class bmsMilter(Milter.Milter):
def check_spf(self):
t = parse_addr(self.mailfrom)
if len(t) == 2: t[1] = t[1].lower()
q = spf.query(self.connectip,'@'.join(t),self.hello_name)
receiver = self.receiver
q = spf.query(self.connectip,'@'.join(t),self.hello_name,receiver=receiver)
q.set_default_explanation('SPF fail: see http://spf.pobox.com/why.html')
res,code,txt = q.check()
receiver = self.receiver
if res in ('none', 'softfail'):
if self.mailfrom != '<>':
# check hello name via spf
h = spf.query(self.connectip,'',self.hello_name)
h = spf.query(self.connectip,'',self.hello_name,receiver=receiver)
hres,hcode,htxt = h.check()
if hres in ('deny','fail','neutral','softfail'):
self.log('REJECT: hello SPF: %s 550 %s' % (hres,htxt))
@@ -695,32 +626,39 @@ class bmsMilter(Milter.Milter):
else:
res,code,txt = q.best_guess()
receiver += ': guessing'
if self.missing_ptr and res in ('neutral', 'none') \
and spf_reject_noptr and hres != 'pass':
self.log('REJECT: no PTR, HELO or SPF')
self.setreply('550','5.7.1',
'You must have a reverse lookup or publish SPF: http://spf.pobox.com',
'Contact your mail administrator IMMEDIATELY! Your mail server is',
'severely misconfigured. It has no PTR record (dynamic PTR records',
"that contain your IP don't count), an invalid HELO, and no SPF record."
)
return Milter.REJECT
if self.missing_ptr and res in ('neutral', 'none') and hres != 'pass':
if spf_reject_noptr:
self.log('REJECT: no PTR, HELO or SPF')
self.setreply('550','5.7.1',
'You must have a reverse lookup or publish SPF: http://spf.pobox.com',
'Contact your mail administrator IMMEDIATELY! Your mail server is',
'severely misconfigured. It has no PTR record (dynamic PTR records',
"that contain your IP don't count), an invalid HELO, and no SPF record."
)
return Milter.REJECT
if self.mailfrom != '<>':
self.cbv_needed = q
if res in ('deny', 'fail'):
self.log('REJECT: SPF %s %i %s' % (res,code,txt))
self.setreply(str(code),'5.7.1',txt)
# A proper SPF fail error message would read:
# forger.biz [1.2.3.4] is not allowed to send mail with the domain
# "forged.org" in the sender address. Contact <postmaster@forged.org>.
return Milter.REJECT
if res == 'softfail' and not q.o in spf_accept_softfail:
if self.missing_ptr and spf_reject_noptr and hres != 'pass':
self.log('TEMPFAIL: SPF %s 450 %s' % (res,txt))
self.setreply('450','4.3.0',
'SPF softfail: will keep trying until your SPF record is fixed.',
'If you get this Delivery Status Notice, your email was probably',
'legitimate. Your administrator has published SPF records in a',
'testing mode. The SPF record reported your email as a forgery,',
'which is a mistake if you are reading this. Please notify your',
'administrator of the problem immediately.'
)
return Milter.TEMPFAIL
if self.missing_ptr and hres != 'pass':
if spf_reject_noptr or q.o in spf_reject_neutral:
self.log('REJECT: SPF %s %i %s' % (res,code,txt))
self.setreply('550','5.7.1',
'SPF softfail: If you get this Delivery Status Notice, your email',
'was probably legitimate. Your administrator has published SPF',
'records in a testing mode. The SPF record reported your email as',
'a forgery, which is a mistake if you are reading this. Please',
'notify your administrator of the problem immediately.'
)
return Milter.REJECT
if self.mailfrom != '<>':
self.cbv_needed = q
if res == 'neutral' and q.o in spf_reject_neutral:
self.log('REJECT: SPF neutral for',q.s)
self.setreply('550','5.7.1',
@@ -789,19 +727,7 @@ class bmsMilter(Milter.Milter):
self.hidepath = True
if not domain in dspam_reject:
self.reject_spam = False
if smart_alias:
cf = self.canon_from
cf0 = cf.split('@',1)
if len(cf0) == 2:
cf0 = '@' + cf0[1]
else:
cf0 = cf
ct = '@'.join(t)
for key in ((cf,ct),(cf0,ct)):
if smart_alias.has_key(key):
self.del_recipient(to)
for t in smart_alias[key]:
self.add_recipient('<%s>'%t)
self.smart_alias(to)
#rcpt = self.getsymval("{rcpt_addr}")
#self.log("rcpt-addr",rcpt);
return Milter.CONTINUE
@@ -811,7 +737,7 @@ class bmsMilter(Milter.Milter):
lname = name.lower()
# val is decoded header value
if lname == 'subject':
# check for common spam keywords
for wrd in spam_words:
if val.find(wrd) >= 0:
@@ -861,17 +787,28 @@ class bmsMilter(Milter.Milter):
elif self.trust_received and lname == 'received':
self.trust_received = False
self.log('%s: %s' % (name,val.splitlines()[0]))
elif self.trust_spf and lname == 'received-spf':
self.trust_spf = False
self.log('%s: %s' % (name,val.splitlines()[0]))
return Milter.CONTINUE
def forged_bounce(self):
if len(self.recipients) > 1:
self.log('REJECT: Multiple bounce recipients')
self.setreply('550','5.7.1','Multiple bounce recipients')
else:
self.log('REJECT: bounce with no SRS encoding')
self.setreply('550','5.7.1',
"I did not send you that message. Please consider implementing SPF",
"(http://spf.pobox.com) to avoid bouncing mail to spoofed senders.",
"Thank you."
)
return Milter.REJECT
def header(self,name,hval):
if not self.data_allowed:
if len(self.recipients) > 1:
self.log('REJECT: Multiple bounce recipients')
self.setreply('550','5.7.1','Multiple bounce recipients')
else:
self.log('REJECT: bounce with no SRS encoding')
self.setreply('550','5.7.1',"I did not send you that message.")
return Milter.REJECT
return self.forged_bounce()
lname = name.lower()
# decode near ascii text to unobfuscate
val = parse_header(hval)
@@ -897,6 +834,8 @@ class bmsMilter(Milter.Milter):
def eoh(self):
if not self.fp: return Milter.TEMPFAIL # not seen by envfrom
if not self.data_allowed:
return self.forged_bounce()
for name,val in self.new_headers:
self.fp.write("%s: %s\n" % (name,val)) # add new headers to buffer
self.fp.write("\n") # terminate headers
@@ -945,7 +884,8 @@ class bmsMilter(Milter.Milter):
# don't let a tricky virus slip one past us
if scan_rfc822:
msg = msg.get_submsg()
if msg: return mime.check_attachments(msg,self._chk_attach)
if isinstance(msg,email.Message.Message):
return mime.check_attachments(msg,self._chk_attach)
return Milter.CONTINUE
def alter_recipients(self,discard_list,redirect_list):
@@ -1047,11 +987,12 @@ class bmsMilter(Milter.Milter):
# analyze all mail for dangerous attachments and scripts
self.fp.seek(0)
msg = mime.MimeMessage(self.fp)
msg = mime.message_from_file(self.fp)
# pass header changes in top level message to sendmail
msg.headerchange = self._headerChange
# filter leaf attachments through _chk_attach
assert not msg.ismodified()
rc = mime.check_attachments(msg,self._chk_attach)
except: # milter crashed trying to analyze mail
exc_type,exc_value = sys.exc_info()[0:2]
@@ -1106,6 +1047,33 @@ class bmsMilter(Milter.Milter):
for name,val in self.new_headers:
self.addheader(name,val)
if self.cbv_needed:
sender = self.cbv_needed.s
cached = cbv_cache.has_key(sender)
if cached:
self.log('CBV:',sender,'(cached)')
res = cbv_cache[sender]
else:
self.log('CBV:',sender)
m = dsn.create_msg(self.cbv_needed,self.recipients,msg)
m = m.as_string()
print >>open('last_dsn','w'),m
res = dsn.send_dsn(sender,self.receiver,m)
if res:
desc = "CBV: %d %s" % res[:2]
if 400 <= res[0] < 500:
self.log('TEMPFAIL:',desc)
self.setreply('450','4.2.0',*desc.splitlines())
return Milter.TEMPFAIL
cbv_cache[sender] = res
self.log('REJECT:',desc)
self.setreply('550','5.7.1',*desc.splitlines())
return Milter.REJECT
cbv_cache[sender] = res
if not cached:
print >>open('send_dsn.log','a'),sender # log who we sent DSNs to
self.cbv_needed = None
if not defanged and not spam_checked:
os.remove(self.tempname)
self.tempname = None # prevent re-removal