diff --git a/COPYING b/COPYING new file mode 100644 index 0000000000000000000000000000000000000000..5b6e7c66c276e7610d4a73c70ec1a1f7c1003259 --- /dev/null +++ b/COPYING @@ -0,0 +1,340 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc. + 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Library General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + <signature of Ty Coon>, 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Library General +Public License instead of this License. diff --git a/CREDITS b/CREDITS index f32fdc3cad6aba02739047c5dd15dae18526fd38..18b92ea65f279bc18216e7756c4a6988c190a193 100644 --- a/CREDITS +++ b/CREDITS @@ -9,6 +9,9 @@ Other contributors: Terence Way for providing a Python port of SPF +Scott Kitterman + for doing lots of testing and debugging of SPF against draft standard, + and for putting up a web page that validates SPF records using spf.py Alexander Kourakos for plugging several memory leaks George Graf at Vienna University of Economics and Business Administration @@ -22,6 +25,9 @@ John Draper then pointing out that it would be easier to just write the MTA in Python. Eric S. Johansson for helpful design discussions while working on camram +Alex Savguira + for finding bugs with international headers and + suggesting the scan_zip option. Business Management Systems - http://www.bmsi.com for hosting the website, and providing paying clients who need milter service so I can work on it as part of my day job. diff --git a/MANIFEST.in b/MANIFEST.in index a7faa283c48c67e13a55a64eed6462baeb8ae3b9..db0ec94f5606230016e780d89ab65c11c99762f7 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -11,13 +11,17 @@ include testdspam.py include rejects.py include bms.py include spf.py +include cid2spf.py include spfquery.py include test.py include sample.py include test/* +include Milter/*.py include *.spec include start.sh include milter.rc include milter.rc7 include milter.cfg include rhsbl.m4 +include softfail.txt +include strike3.txt diff --git a/Milter/__init__.py b/Milter/__init__.py index 88a2214a374a5cbcb9d913b4483a178cfe85f4be..834359e6a2ec21f49ea1c425777e2c7b51e8dd95 100755 --- a/Milter/__init__.py +++ b/Milter/__init__.py @@ -1,7 +1,8 @@ - # Author: Stuart D. Gathman <stuart@bmsi.com> # Copyright 2001 Business Management Systems, Inc. -# This code is under GPL. See COPYING for details. +# This code is under the GNU General Public License. See COPYING for details. + +# A thin OO wrapper for the milter module import os import milter @@ -140,16 +141,28 @@ def closecallback(ctx): m._setctx(None) # release milterContext return rc +def dictfromlist(args): + "Convert ESMTP parm list to keyword dictionary." + kw = {} + for s in args: + pos = s.find('=') + if pos > 0: + kw[s[:pos].upper()] = s[pos+1:] + return kw + def envcallback(c,args): - """Convert ESMTP parms to keyword parameters. + """Call function c with ESMTP parms converted to keyword parameters. Can be used in the envfrom and/or envrcpt callbacks to process ESMTP parameters as python keyword parameters.""" kw = {} + pargs = [args[0]] for s in args[1:]: pos = s.find('=') if pos > 0: - kw[s[:pos]] = s[pos+1:] - return apply(c,args,kw) + kw[s[:pos].upper()] = s[pos+1:] + else: + pargs.append(s) + return c(*pargs,**kw) def runmilter(name,socketname,timeout = 0): # This bit is here on the assumption that you will be starting this filter @@ -176,14 +189,13 @@ def runmilter(name,socketname,timeout = 0): # milter.set_flags(milter.ADDHDRS) milter.set_connect_callback(connectcallback) milter.set_helo_callback(lambda ctx, host: ctx.getpriv().hello(host)) - milter.set_envfrom_callback(lambda ctx,*str: - ctx.getpriv().envfrom(*str)) -# envcallback(ctx.getpriv().envfrom,str)) - milter.set_envrcpt_callback(lambda ctx,*str: - ctx.getpriv().envrcpt(*str)) -# envcallback(ctx.getpriv().envrcpt,str)) - milter.set_header_callback(lambda ctx,fld,val: - ctx.getpriv().header(fld,val)) + # For envfrom and envrcpt, we would like to convert ESMTP parms to keyword + # parms, but then all existing users would have to include **kw to accept + # arbitrary keywords without crashing. We do provide envcallback and + # dictfromlist to make parsing the ESMTP args convenient. + milter.set_envfrom_callback(lambda ctx,*str: ctx.getpriv().envfrom(*str)) + milter.set_envrcpt_callback(lambda ctx,*str: ctx.getpriv().envrcpt(*str)) + milter.set_header_callback(lambda ctx,fld,val: ctx.getpriv().header(fld,val)) milter.set_eoh_callback(lambda ctx: ctx.getpriv().eoh()) milter.set_body_callback(lambda ctx,chunk: ctx.getpriv().body(chunk)) milter.set_eom_callback(lambda ctx: ctx.getpriv().eom()) diff --git a/Milter/dsn.py b/Milter/dsn.py index 18656895d04005c03fd72be7a089564ca88f11af..961858ee540d725b92d446d66b4d07270ceffa6a 100644 --- a/Milter/dsn.py +++ b/Milter/dsn.py @@ -1,9 +1,18 @@ +# Author: Stuart D. Gathman <stuart@bmsi.com> +# Copyright 2005 Business Management Systems, Inc. +# This code is under the GNU General Public License. See COPYING for details. + +# Send DSNs, do call back verification, +# and generate DSN messages from a template + import smtplib import spf import socket from email.Message import Message -nospf_msg = """This is an automatically generated Delivery Status Notification. +nospf_msg = """Subject: Critical mail server configuration error + +This is an automatically generated Delivery Status Notification. THIS IS A WARNING MESSAGE ONLY. @@ -65,11 +74,12 @@ If you need further assistance, please do not hesitate to contact me again. Kind regards, -Stuart D. Gathman + postmaster@%(receiver)s """ -softfail_msg = """ +softfail_msg = """Subject: SPF softfail (POSSIBLE FORGERY) + This is an automatically generated Delivery Status Notification. THIS IS A WARNING MESSAGE ONLY. @@ -85,7 +95,10 @@ Received-SPF: %(spf_result)s """ def send_dsn(mailfrom,receiver,msg=None): - "Send DSN. If msg is None, do callback verification." + """Send DSN. If msg is None, do callback verification. + Mailfrom is original sender we are sending DSN or CBV to. + Receiver is the MTA sending the DSN. + Return None for success or (code,msg) for failure.""" user,domain = mailfrom.split('@') q = spf.query(None,None,None) mxlist = q.dns(domain,'MX') @@ -102,7 +115,7 @@ def send_dsn(mailfrom,receiver,msg=None): if resp.split()[0] == receiver: return (553,'Fraudulent MX for %s' % domain) if not (200 <= code <= 299): - raise SMTPHeloError(code, resp) + raise smtplib.SMTPHeloError(code, resp) if msg: try: smtp.sendmail('<>',mailfrom,msg) @@ -112,7 +125,7 @@ def send_dsn(mailfrom,receiver,msg=None): else: # CBV code,resp = smtp.docmd('MAIL FROM: <>') if code != 250: - raise SMTPSenderRefused(code, resp, '<>') + raise smtplib.SMTPSenderRefused(code, resp, '<>') code,resp = smtp.rcpt(mailfrom) if code not in (250,251): return (code,resp) # permanent error @@ -121,9 +134,9 @@ def send_dsn(mailfrom,receiver,msg=None): except smtplib.SMTPRecipientsRefused,x: return x.recipients[mailfrom] # permanent error except smtplib.SMTPSenderRefused,x: - return x # does not accept DSN + return x.args[:2] # does not accept DSN except smtplib.SMTPDataError,x: - return x # permanent error + return x.args # permanent error except smtplib.SMTPException: pass # any other error, try next MX except socket.error: @@ -131,7 +144,8 @@ def send_dsn(mailfrom,receiver,msg=None): smtp.close() return (450,'No MX servers available') # temp error -def create_msg(q,rcptlist,origmsg): +def create_msg(q,rcptlist,origmsg=None,template=None): + "Create a DSN message from a template. Template must be '\n' separated." heloname = q.h sender = q.s connectip = q.i @@ -145,24 +159,30 @@ def create_msg(q,rcptlist,origmsg): if not spf_result.startswith('softfail'): spf_result = None except: spf_result = None + msg = Message() + msg.add_header('To',sender) msg.add_header('From','postmaster@%s'%receiver) msg.add_header('Auto-Submitted','auto-generated (configuration error)') msg.set_type('text/plain') - if spf_result: - msg.add_header('Subject','SPF softfail (POSSIBLE FORGERY)') - msg.set_payload(softfail_msg % locals()) - else: - msg.add_header('Subject','Critical mail server configuration error') - msg.set_payload(nospf_msg % locals()) + + if not template: + if spf_result: template = softfail_msg + else: template = nospf_msg + hdrs,body = template.split('\n',1) + for ln in hdrs.splitlines(): + name,val = ln.split(':',1) + msg.add_header(name,(val % locals()).strip()) + msg.set_payload(body % locals()) + return msg if __name__ == '__main__': q = spf.query('192.168.9.50', 'SRS0=pmeHL=RH=bmsi.com=stuart@bmsi.com', 'bmsred.bmsi.com',receiver='mail.bmsi.com') - msg = create_msg(q,'charlie@jsconnor.com') - #print msg.as_string() + msg = create_msg(q,['charlie@jsconnor.com'],None,None) + print msg.as_string() # print send_dsn(f,msg.as_string()) print send_dsn(q.s,'mail.bmsi.com',msg.as_string()) diff --git a/Milter/dynip.py b/Milter/dynip.py index 1337c036ff532b1d3f6d42aaa8449b2931df728e..8cf9602370a16338abbe712be6310f0c29ff91e1 100644 --- a/Milter/dynip.py +++ b/Milter/dynip.py @@ -1,3 +1,9 @@ +# Author: Stuart D. Gathman <stuart@bmsi.com> +# Copyright 2005 Business Management Systems, Inc. +# This code is under the GNU General Public License. See COPYING for details. + +# Heuristically determine whether a domain name is for a dynamic IP. + # examples we don't yet recognize: # # wiley-268-8196.roadrunner.nf.net at ('205.251.174.46', 4810) diff --git a/NEWS b/NEWS index 5ec27710ac50685ef3835d1502f1c7c2f11c218f..b201aa6451649fe85e839dafa335bacb03d4ca92 100644 --- a/NEWS +++ b/NEWS @@ -1,5 +1,25 @@ Here is a history of user visible changes to Python milter. +0.8.2 Strict processing limits per SPF RFC + Fixed several parsing bugs under RFC + Support official IANA SPF record (type99) + Honeypot support (requires pydspam-1.1.9) + Extended SPF processing results beyond strict RFC limits + Support original SES for bounce protection (requires pysrs-0.30.10) + Callback exception processing option in milter module + Handle corrupt ZIP attachments +0.8.1 Fix zip in zip loop in mime.py + Fix HeaderParseError in bms.py header callback + Check internal_domains for outgoing mail + Fix inconsistent results from send_dsn +0.8.0 Move Milter module to subpackage. + DSN support for Three strikes rule and SPF SOFTFAIL + Move /*mime*/ and dynip to Milter subpackage + Fix SPF unknown mechanism list not cleared + Make banned extensions configurable. + Option to scan zipfiles for bad extensions. + Properly log pydspam exceptions +0.7.3 Experimental release with python2.4 support 0.7.2 Return unknown for invalid ip address in mechanism Recognize dynamic PTR names, and don't count them as authentication. Three strikes and yer out rule. diff --git a/TODO b/TODO index 0389f45103bccbbf5901a7b2bab27df9a5271a3b..55d0edc953ecb0189d7648dcfb499e8cc0e689ec 100644 --- a/TODO +++ b/TODO @@ -1,12 +1,8 @@ Defer TEMPERROR in SPF evaluation - give precedence to security (only defer for PASS mechanisms). -Allow multiple recipients for MAIL FROM: <> by default. - Option to add Received-SPF header, but never reject on SPF. -Option to configure banned extension list for mime.py. Default to empty. - Create null config that does nothing - except maybe add Received-SPF headers. Many admins would like to turn features on one at a time. @@ -27,8 +23,6 @@ or recipient prefix. Can't output messages with malformed rfc822 attachments. -Use python exceptions in SPF to cleanly handle unknown and error results. - Example malformed SPF: onvunvuvvx.usafisnews.org text "v=spf1 mx ptr ip4:207.44.199.970 -all" diff --git a/bms.py b/bms.py index 946056bcfe01f2c00cb06fe5a4b302eb72d8e952..3b8fde03696a93b6376e216712167fe09fefb6dc 100644 --- a/bms.py +++ b/bms.py @@ -1,6 +1,58 @@ #!/usr/bin/env python -# A simple milter. +# A simple milter that has grown quite a bit. # $Log$ +# Revision 1.18 2005/07/17 01:25:44 customdesigned +# Log as well as use extended result for best guess. +# +# Revision 1.17 2005/07/15 20:25:36 customdesigned +# Use extended results processing for best_guess. +# +# Revision 1.16 2005/07/14 03:23:33 customdesigned +# Make SES package optional. Initial honeypot support. +# +# Revision 1.15 2005/07/06 04:05:40 customdesigned +# Initial SES integration. +# +# Revision 1.14 2005/07/02 23:27:31 customdesigned +# Don't match hostnames for internal connects. +# +# Revision 1.13 2005/07/01 16:30:24 customdesigned +# Always log trusted Received and Received-SPF headers. +# +# Revision 1.12 2005/06/20 22:35:35 customdesigned +# Setreply for rejectvirus. +# +# Revision 1.11 2005/06/17 02:07:20 customdesigned +# Release 0.8.1 +# +# Revision 1.10 2005/06/16 18:35:51 customdesigned +# Ignore HeaderParseError decoding header +# +# Revision 1.9 2005/06/14 21:55:29 customdesigned +# Check internal_domains for outgoing mail. +# +# Revision 1.8 2005/06/06 18:24:59 customdesigned +# Properly log exceptions from pydspam +# +# Revision 1.7 2005/06/04 19:41:16 customdesigned +# Fix bugs from testing RPM +# +# Revision 1.6 2005/06/03 04:57:05 customdesigned +# Organize config reader by section. Create defang section. +# +# Revision 1.5 2005/06/02 15:00:17 customdesigned +# Configure banned extensions. Scan zipfile option with test case. +# +# Revision 1.4 2005/06/02 04:18:55 customdesigned +# Update copyright notices after reading article on /. +# +# Revision 1.3 2005/06/02 02:09:00 customdesigned +# Record timestamp in send_dsn.log +# +# Revision 1.2 2005/06/02 01:00:36 customdesigned +# Support configurable templates for DSNs. +# +# # Revision 1.134 2005/05/25 15:36:43 stuart # Use dynip module. # Support smart aliasing of wiretap destination. @@ -179,8 +231,8 @@ # Release 0.6.4 # # Author: Stuart D. Gathman <stuart@bmsi.com> -# Copyright 2001 Business Management Systems, Inc. -# This code is under GPL. See COPYING for details. +# Copyright 2001,2002,2003,2004,2005 Business Management Systems, Inc. +# This code is under the GNU General Public License. See COPYING for details. import sys import os @@ -190,6 +242,7 @@ import mime import email.Errors import Milter import tempfile +import traceback import ConfigParser import time import re @@ -204,6 +257,9 @@ try: import SRS srsre = re.compile(r'^SRS[01][+-=]',re.IGNORECASE) except: SRS = None +try: + import SES +except: SES = None # Import spf if available try: import spf @@ -229,6 +285,8 @@ log_headers = False block_chinese = False spam_words = () porn_words = () +banned_exts = mime.extlist.split(',') +scan_zip = False scan_html = True scan_rfc822 = True internal_connect = () @@ -246,17 +304,28 @@ dspam_internal = True # True if internal mail should be dspammed dspam_reject = () dspam_sizelimit = 180000 srs = None +ses = None srs_reject_spoofed = False -srs_fwdomain = None +srs_domain = None spf_reject_neutral = () spf_accept_softfail = () spf_best_guess = False spf_reject_noptr = False +multiple_bounce_recipients = True +time_format = '%Y%b%d %H:%M:%S %Z' timeout = 600 cbv_cache = {} try: - for rcpt in open('send_dsn.log'): - cbv_cache[rcpt.strip()] = None + too_old = time.time() - 30*24*60*60 # 30 days + for ln in open('send_dsn.log'): + try: + rcpt,ts = ln.strip().split(None,1) + l = time.strptime(ts,time_format) + t = time.mktime(l) + if t > too_old: + cbv_cache[rcpt] = None + except: + cbv_cache[ln.strip()] = None except IOError: pass class MilterConfigParser(ConfigParser.ConfigParser): @@ -264,7 +333,7 @@ class MilterConfigParser(ConfigParser.ConfigParser): def getlist(self,sect,opt): if self.has_option(sect,opt): return [q.strip() for q in self.get(sect,opt).split(',')] - return () + return [] def getaddrset(self,sect,opt): if not self.has_option(sect,opt): @@ -311,6 +380,7 @@ def read_config(list): 'timeout': '600', 'scan_html': 'no', 'scan_rfc822': 'yes', + 'scan_zip': 'no', 'block_chinese': 'no', 'log_headers': 'no', 'blind_wiretap': 'yes', @@ -322,19 +392,44 @@ def read_config(list): 'dspam_internal': 'yes' }) cp.read(list) + + # milter section tempfile.tempdir = cp.get('milter','tempdir') - global socketname, scan_rfc822, scan_html, block_chinese, timeout + global socketname, timeout, check_user, log_headers + global internal_connect, internal_domains, trusted_relay, hello_blacklist 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') + check_user = cp.getaddrset('milter','check_user') + log_headers = cp.getboolean('milter','log_headers') + internal_connect = cp.getlist('milter','internal_connect') + internal_domains = cp.getlist('milter','internal_domains') + trusted_relay = cp.getlist('milter','trusted_relay') + hello_blacklist = cp.getlist('milter','hello_blacklist') + + # defang section + global scan_rfc822, scan_html, block_chinese, scan_zip, block_forward + global banned_exts, porn_words, spam_words + if cp.has_section('defang'): + section = 'defang' + # for backward compatibility, + # banned extensions defaults to empty only when defang section exists + banned_exts = cp.getlist(section,'banned_exts') + else: # use milter section if no defang section for compatibility + section = 'milter' + scan_rfc822 = cp.getboolean(section,'scan_rfc822') + scan_zip = cp.getboolean(section,'scan_zip') + scan_html = cp.getboolean(section,'scan_html') + block_chinese = cp.getboolean(section,'block_chinese') + block_forward = cp.getaddrset(section,'block_forward') + porn_words = cp.getlist(section,'porn_words') + spam_words = cp.getlist(section,'spam_words') - global hide_path, block_forward, log_headers + # scrub section + global hide_path, reject_virus_from hide_path = cp.getlist('scrub','hide_path') - block_forward = cp.getaddrset('milter','block_forward') - log_headers = cp.getboolean('milter','log_headers') + reject_virus_from = cp.getlist('scrub','reject_virus_from') + # wiretap section global blind_wiretap, wiretap_users, wiretap_dest, discard_users blind_wiretap = cp.getboolean('wiretap','blind') wiretap_users = cp.getaddrset('wiretap','users') @@ -342,17 +437,7 @@ def read_config(list): wiretap_dest = cp.getdefault('wiretap','dest') if wiretap_dest: wiretap_dest = '<%s>' % wiretap_dest - global check_user, reject_virus_from, internal_connect, internal_domains - check_user = cp.getaddrset('milter','check_user') - reject_virus_from = cp.getlist('scrub','reject_virus_from') - internal_connect = cp.getlist('milter','internal_connect') - internal_domains = cp.getlist('milter','internal_domains') - - global porn_words, spam_words, smart_alias, trusted_relay, hello_blacklist - trusted_relay = cp.getlist('milter','trusted_relay') - porn_words = cp.getlist('milter','porn_words') - spam_words = cp.getlist('milter','spam_words') - hello_blacklist = cp.getlist('milter','hello_blacklist') + global smart_alias for sa in cp.getlist('wiretap','smart_alias'): sm = cp.getlist('wiretap',sa) if len(sm) < 2: @@ -362,10 +447,9 @@ def read_config(list): key = (sm[0],sm[1]) smart_alias[key] = sm[2:] + # dspam section 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 dspam_dict = cp.getdefault('dspam','dspam_dict') dspam_exempt = cp.getaddrset('dspam','dspam_exempt') dspam_whitelist = cp.getaddrset('dspam','dspam_whitelist') @@ -377,6 +461,9 @@ def read_config(list): if cp.has_option('dspam','dspam_sizelimit'): dspam_sizelimit = cp.getint('dspam','dspam_sizelimit') + # spf section + global spf_reject_neutral,spf_best_guess,SRS,spf_reject_noptr + global spf_accept_softfail if spf: spf.DELEGATE = cp.getdefault('spf','delegate') spf_reject_neutral = cp.getlist('spf','reject_neutral') @@ -387,7 +474,7 @@ def read_config(list): if srs_config: cp.read([srs_config]) srs_secret = cp.getdefault('srs','secret') if SRS and srs_secret: - global srs,srs_reject_spoofed,srs_fwdomain + global ses,srs,srs_reject_spoofed,srs_domain database = cp.getdefault('srs','database') srs_reject_spoofed = cp.getboolean('srs','reject_spoofed') maxage = cp.getint('srs','maxage') @@ -400,16 +487,22 @@ def read_config(list): else: srs = SRS.Guarded.Guarded(secret=srs_secret, maxage=maxage,hashlength=hashlength,separator=separator) - srs_fwdomain = cp.getdefault('srs','fwdomain') + if SES: + ses = SES.new(secret=srs_secret,expiration=maxage) + srs_domain = cp.getlist('srs','ses') + else: + srs_domain = [] + srs_domain.append(cp.getdefault('srs','fwdomain')) + #print srs_domain def parse_addr(t): if t.startswith('<') and t.endswith('>'): t = t[1:-1] return t.split('@') def parse_header(val): - h = decode_header(val) - if not len(h) or (not h[0][1] and len(h) == 1): return val try: + h = decode_header(val) + if not len(h) or (not h[0][1] and len(h) == 1): return val u = [] for s,enc in h: if enc: @@ -426,6 +519,7 @@ def parse_header(val): except UnicodeError: continue except UnicodeDecodeError: pass except LookupError: pass + except email.Errors.HeaderParseError: pass return val class bmsMilter(Milter.Milter): @@ -483,10 +577,6 @@ class bmsMilter(Milter.Milter): else: ipaddr = '' self.connectip = ipaddr self.missing_ptr = dynip(hostname,self.connectip) - for pat in internal_connect: - if fnmatchcase(hostname,pat): - self.internal_connection = True - break if self.internal_connection: connecttype = 'INTERNAL' else: @@ -570,6 +660,17 @@ class bmsMilter(Milter.Milter): self.log("REJECT: spam from self",pat) self.setreply('550','5.7.1','I hate talking to myself.') return Milter.REJECT + elif internal_domains: + for pat in internal_domains: + if fnmatchcase(domain,pat): break + else: + self.log("REJECT: zombie PC at ",self.connectip," sending MAIL FROM ", + self.canon_from) + self.setreply('550','5.7.1', + 'Your PC is using an unauthorized MAIL FROM.', + 'It is either badly misconfigured or controlled by organized crime.' + ) + return Milter.REJECT self.rejectvirus = domain in reject_virus_from if user in wiretap_users.get(domain,()): self.add_recipient(wiretap_dest) @@ -595,7 +696,8 @@ class bmsMilter(Milter.Milter): if len(t) == 2: t[1] = t[1].lower() 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') + q.set_default_explanation( + 'SPF fail: see http://spf.pobox.com/why.html?sender=%s&ip=%s' % (q.s,q.i)) res,code,txt = q.check() if res in ('none', 'softfail'): if self.mailfrom != '<>': @@ -619,6 +721,7 @@ class bmsMilter(Milter.Milter): #self.log('SPF: no record published, guessing') q.set_default_explanation( 'SPF guess: see http://spf.pobox.com/why.html') + q.strict = False # best_guess should not result in fail if self.missing_ptr: # ignore dynamic PTR for best guess @@ -626,6 +729,9 @@ class bmsMilter(Milter.Milter): else: res,code,txt = q.best_guess() receiver += ': guessing' + if q.perm_error: + res,code,txt = q.perm_error.ext # extended result + txt = 'EXT: ' + txt if self.missing_ptr and res in ('neutral', 'none') and hres != 'pass': if spf_reject_noptr: self.log('REJECT: no PTR, HELO or SPF') @@ -637,6 +743,7 @@ class bmsMilter(Milter.Milter): ) return Milter.REJECT if self.mailfrom != '<>': + q.result = res self.cbv_needed = q if res in ('deny', 'fail'): self.log('REJECT: SPF %s %i %s' % (res,code,txt)) @@ -658,6 +765,7 @@ class bmsMilter(Milter.Milter): ) return Milter.REJECT if self.mailfrom != '<>': + q.result = res self.cbv_needed = q if res == 'neutral' and q.o in spf_reject_neutral: self.log('REJECT: SPF neutral for',q.s) @@ -670,11 +778,12 @@ class bmsMilter(Milter.Milter): 'servers for %s should accomplish this.' % q.o ) return Milter.REJECT + if res == 'unknown': + self.log('REJECT: SPF %s %i %s' % (res,code,txt)) + # latest SPF draft recommends 5.5.2 instead of 5.7.1 + self.setreply(str(code),'5.5.2',txt) + return Milter.REJECT if res == 'error': - if code >= 500: - self.log('REJECT: SPF %s %i %s' % (res,code,txt)) - self.setreply(str(code),'5.7.1',txt) - return Milter.REJECT self.log('TEMPFAIL: SPF %s %i %s' % (res,code,txt)) self.setreply(str(code),'4.3.0',txt) return Milter.TEMPFAIL @@ -695,20 +804,31 @@ class bmsMilter(Milter.Milter): user,domain = t if self.mailfrom == '<>' or self.canon_from.startswith('postmaster@') \ or self.canon_from.startswith('mailer-daemon@'): - if self.recipients: + if self.recipients and not multiple_bounce_recipients: self.data_allowed = False - if srs and domain == srs_fwdomain: + if srs and domain in srs_domain: oldaddr = '@'.join(parse_addr(to)) try: - newaddr = srs.reverse(oldaddr) - # Currently, a sendmail map reverses SRS. We just log it here. - self.log("srs rcpt:",newaddr) + if ses: + newaddr = ses.verify(oldaddr) + else: + newaddr = oldaddr, + if len(newaddr) > 1: + self.log("ses rcpt:",newaddr[0]) + else: + newaddr = srs.reverse(oldaddr) + # Currently, a sendmail map reverses SRS. We just log it here. + self.log("srs rcpt:",newaddr) except: if not (self.internal_connection or self.trusted_relay): if srsre.match(oldaddr): self.log("REJECT: srs spoofed:",oldaddr) self.setreply('550','5.7.1','Invalid SRS signature') return Milter.REJECT + if oldaddr.startswith('SES='): + self.log("REJECT: ses spoofed:",oldaddr) + self.setreply('550','5.7.1','Invalid SES signature') + return Milter.REJECT self.data_allowed = not srs_reject_spoofed # non DSN mail to SRS address will bounce due to invalid local part self.recipients.append('@'.join(t)) @@ -784,12 +904,6 @@ class bmsMilter(Milter.Milter): or mailer.find('optin') >= 0: self.log('REJECT: %s: %s' % (name,val)) return Milter.REJECT - 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): @@ -824,6 +938,12 @@ class bmsMilter(Milter.Milter): # log selected headers if log_headers or lname in ('subject','x-mailer'): self.log('%s: %s' % (name,val)) + 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])) if self.fp: try: val = val.encode('us-ascii') @@ -843,8 +963,9 @@ class bmsMilter(Milter.Milter): # copy headers to a temp file for scanning the body headers = self.fp.getvalue() self.fp.close() - self.tempname = fname = tempfile.mktemp(".defang") - self.fp = open(fname,"w+b") + fd,fname = tempfile.mkstemp(".defang") + self.tempname = fname + self.fp = os.fdopen(fd,"w+b") self.fp.write(headers) # IOError (e.g. disk full) causes TEMPFAIL # check if headers are really spammy if dspam_dict and not self.internal_connection: @@ -876,11 +997,22 @@ class bmsMilter(Milter.Milter): for i in range(len(h),0,-1): self.chgheader(name,i-1,'') + def _chk_ext(self,name): + "Check a name for dangerous Winblows extensions." + if not name: return name + lname = name.lower() + for ext in self.bad_extensions: + if lname.endswith(ext): return name + return None + + def _chk_attach(self,msg): "Filter attachments by content." - mime.check_name(msg,self.tempname) # check for bad extensions + # check for bad extensions + mime.check_name(msg,self.tempname,ckname=self._chk_ext,scan_zip=scan_zip) + # remove scripts from HTML if scan_html: - mime.check_html(msg,self.tempname) # remove scripts from HTML + mime.check_html(msg,self.tempname) # don't let a tricky virus slip one past us if scan_rfc822: msg = msg.get_submsg() @@ -942,6 +1074,11 @@ class bmsMilter(Milter.Milter): if len(txt) > dspam_sizelimit: self.log("Large message:",len(txt)) return False + if user == 'honeypot' and Dspam.VERSION >= '1.1.9': + ds.check_spam(user,txt,force_result=dspam.DSR_ISSPAM) + self.log("HONEYPOT:",rcpt) + self.fp = None + return False txt = ds.check_spam(user,txt,self.recipients) if not txt: # DISCARD if quarrantined for any recipient. It @@ -953,7 +1090,8 @@ class bmsMilter(Milter.Milter): self.fp = StringIO.StringIO(txt) modified = True except Exception,x: - print x + self.log("check_spam:",x) + traceback.print_exc() # screen if no recipients are dspam_users if not modified and dspam_screener and not self.internal_connection \ and self.dspam: @@ -993,6 +1131,7 @@ class bmsMilter(Milter.Milter): # filter leaf attachments through _chk_attach assert not msg.ismodified() + self.bad_extensions = ['.' + x for x in banned_exts] rc = mime.check_attachments(msg,self._chk_attach) except: # milter crashed trying to analyze mail exc_type,exc_value = sys.exc_info()[0:2] @@ -1048,14 +1187,21 @@ class bmsMilter(Milter.Milter): self.addheader(name,val) if self.cbv_needed: - sender = self.cbv_needed.s + q = self.cbv_needed + sender = q.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) + try: + if q.result == 'softfail': + template = file('softfail.txt').read() + else: + template = file('strike3.txt').read() + except IOError: template = None + m = dsn.create_msg(q,self.recipients,msg,template) m = m.as_string() print >>open('last_dsn','w'),m res = dsn.send_dsn(sender,self.receiver,m) @@ -1065,13 +1211,15 @@ class bmsMilter(Milter.Milter): self.log('TEMPFAIL:',desc) self.setreply('450','4.2.0',*desc.splitlines()) return Milter.TEMPFAIL + if len(res) < 3: res += time.time(), 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 + s = time.strftime(time_format,time.localtime()) + print >>open('send_dsn.log','a'),sender,s # log who we sent DSNs to self.cbv_needed = None if not defanged and not spam_checked: @@ -1084,6 +1232,8 @@ class bmsMilter(Milter.Milter): if defanged: if self.rejectvirus and not self.hidepath: self.log("REJECT virus from",self.mailfrom) + self.setreply('550','5.7.1','Attachment type not allowed.', + 'You attempted to send an attachment with a banned extension.') self.tempname = None return Milter.REJECT self.log("Temp file:",self.tempname) diff --git a/faq.html b/faq.html index 3f51084fb4a09cbedd9665d912cc3af1ef88903a..96da784f5b6b6cc7a3a5a64a39bf9ecca00a5b5b 100644 --- a/faq.html +++ b/faq.html @@ -72,6 +72,9 @@ milter-0.4.5 or later to remove this dependency. <code>set_flags()</code> before calling <code>runmilter()</code>. For instance, <code>Milter.set_flags(Milter.ADDRCPT)</code>. You must add together all of <code>ADDHDRS, CHGBODY, ADDRCPT, DELRCPT, CHGHDRS</code> that apply. +<p> NOTE - recent versions default flags to enabling all features. You +must now call <code>set_flags()</code> if you wish to disable features for +efficiency. <p> <li> Q. Why does sendmail sometimes print something like: @@ -94,14 +97,19 @@ for your specific needs. We will of course continue to move generic code out of the sample as the project evolves. Think of sample.py as an active config file. <p> +If you are running bms.py, then the block_chinese option in +<code>/etc/mail/pymilter.cfg</code> controls this feature. +<p> <li> Q. Why does sendmail coredump with milters on OpenBSD? -<p> A. Sendmail has a problem with unix sockets on OpenBSD. Use -an internet domain socket instead. For example, in <code>sendmail.cf</code> use +<p> A. Sendmail has a problem with unix sockets on old versions of OpenBSD. +Use an internet domain socket instead. For example, in +<code>sendmail.cf</code> use <pre> Xpythonfilter, S=inet:1234@localhost </pre> and change sample.py accordingly. +<p> OpenBSD users report that this problem has been fixed. <p> <li> Q. How can I change the bounce message for an invalid recipient? @@ -133,6 +141,36 @@ is a milter declaration for sendmail.cf with all timeouts specified: <pre> Xpythonfilter, S=local:/var/log/milter/pythonsock, F=T, T=C:5m;S:20s;R:60s;E:5m </pre> +<li> Q. There is a Python traceback in the log file! What happened to + my email? +<p> A. When the milter fails with an untrapped exception, a TEMPFAIL +result (451) is returned to the sender. The sender will then retry every +hour or so for several days. Hopefully, someone will notice the +traceback, and workaround or fix the problem. + +<li> Q. I read some notes such as "Check valid domains allowed by internal + senders to detect PCs infected with spam trojans." but could not + understand the idea. Could you clarify the content ? + +<p> A. The <code>internal_domains</code> configuration specifies which +MAIL FROM domains are used by internal connections. If an internal +PC tries to use some other domain, it is assumed to be a "Zombie". +<p> +Here is a sample log line: +<pre> +2005Jun22 12:01:04 [12430] REJECT: zombie PC at 192.168.100.171 sending MAIL FROM debby@fedex.com +</pre> +No, fedex.com does not use pymilter, and there is no one named debby at my +client. But the idiot using the PC at 192.168.100.171 has downloaded and +installed some stupid weatherbar/hotbar/aquariumscreensaver that is actually a +spam bot. +<p> +The <code>internal_domains</code> option is simplistic, it assumes all +valid senders of the domains are internal. SPF provides a much more general +check of IP and MAIL FROM for external email. Pymilter should soon +have a local policy feature for more general checking of internal mail. + +<h3> Using SPF </h3> <a name="spf"> <li> Q. So how do I use the SPF support? The sample.py milter doesn't seem @@ -143,8 +181,8 @@ everything up for you. For other systems: <li> Arrange to run bms.py in the background (as a service perhaps) and redirect output and errors to a logfile. For instance, on AIX you'll want to use SRC (System Resource Controller). -<li> Copy milter.cfg to the directory you run bms.py in, and edit it. The - comments should explain the options. +<li> Copy pymilter.cfg to the /etc/mail or the directory you run bms.py in, + and edit it. The comments should explain the options. <li> Start bms.py in the background as arranged. <li> Add Xpythonfilter to sendmail.cf or add an INPUT_MAIL_FILTER to sendmail.mc. Regen sendmail.cf if you use sendmail.mc and restart diff --git a/milter.cfg b/milter.cfg index 43f0e53a45121536519db89991a202b0d4eec714..6326a1beaa6c916cf1dae92e1b1678c761c1e7cf 100644 --- a/milter.cfg +++ b/milter.cfg @@ -1,50 +1,63 @@ [milter] # the socket used to communicate with sendmail. Must match sendmail.cf -;socket=/var/run/milter/pythonsock +socket=/var/run/milter/pythonsock # where to save original copies of defanged and failed messages tempdir = /var/log/milter/save # how long to wait for a response from sendmail before giving up ;timeout=600 log_headers = 0 # connection ips and hostnames are matched against this glob style list -# to recognize internal senders +# to recognize internal senders. ;internal_connect = 192.168.*.* # mail that is not an internal_connect and claims to be from an -# internal domain is rejected. You should enable SPF instead if you can. -# SPF is much more comprehensive and flexible. +# internal domain is rejected. Furthermore, internal mail that +# does not claim to be from an internal domain is rejected. +# You should enable SPF instead if you can. SPF is much more comprehensive and +# flexible. However, SPF is not currently checked for outgoing +# (internal_connect) mail because it doesn't yet handle authorizing +# internal IPs locally. ;internal_domains = mycorp.com # connections from a trusted relay can trust the first Received header # SPF checks are bypassed for internal connections and trusted relays. ;trusted_relay = 1.2.3.4, 66.12.34.56 -# reject external senders with hello names no legit external sender would use +# Reject external senders with hello names no legit external sender would use. # SPF will do this also, but listing your own domain and mailserver here # will save some DNS lookups when rejecting certain viruses. ;hello_blacklist = mycorp.com, 66.12.34.56 +# Reject mail for domains mentioned unless user is mentioned here also +;check_user = joe@mycorp.com, mary@mycorp.com, file:bigcorp.com + # features intended to filter or block incoming mail -;[defang] +[defang] + # do virus scanning on attached messages also scan_rfc822 = 1 +# do virus scanning on attached zipfiles also +scan_zip = 0 # Comment out scripts in HTML attachments. Can be CPU intensive. scan_html = 0 # reject messages with asian fonts because we can't read them block_chinese = 1 # list users who hate forwarded mail ;block_forward = egghead@mycorp.com, busybee@mycorp.com -# Reject mail for domains mentioned unless user is mentioned here also -;check_user = joe@mycorp.com, mary@mycorp.com, file:bigcorp.com # reject mail with these case insensitive strings in the subject porn_words = penis, breast, pussy, horse cock, porn, xenical, diet pill, d1ck, vi*gra, vi-a-gra, viag, tits, p0rn, hunza, horny, sexy, c0ck, xanaax, 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, c0d1n, phentermine, en1arge, dip1oma, v1codin, - valium, rolex, sexual + valium, rolex, sexual, fuck, adv1t # reject mail with these case sensitive strings in the subject spam_words = $$$, !!!, XXX, FREE, HGH +# attachments with these extensions will be replaced with a warning +# message. A copy of the original will be saved. +banned_exts = ade,adp,asd,asx,asp,bas,bat,chm,cmd,com,cpl,crt,dll,exe,hlp,hta, + inf,ins,isp,js,jse,lnk,mdb,mde,msc,msi,msp,mst,ocx,pcd,pif,reg,scr,sct, + shs,url,vb,vbe,vbs,wsc,wsf,wsh # See http://bmsi.com/python/pysrs.html for details [srs] @@ -67,9 +80,10 @@ reject_spoofed = 0 ;reject_neutral = aol.com # use a default (v=spf1 a/24 mx/24 ptr) when no SPF records are published ;best_guess = 0 -# reject senders that have neither PTR nor SPF records +# Reject senders that have neither PTR nor valid HELO nor SPF records, or send +# DSN otherwise ;reject_noptr = 0 -# always accept softfail from these domains +# always accept softfail from these domains, or send DSN otherwise ;accept_softfail = bounces.amazon.com # features intended to clean up outgoing mail @@ -104,6 +118,8 @@ blind = 1 # additional copies can be added ;walter1 = cust@othercorp.com,walter@bigcorp.com,boss@bigcorp.com, ; walter@bigcorp.com +;bulk = soruce@telex.com,bob@jsconnor.com +;bulk = soruce@telex.com,larry@jsconnor.com # See http://bmsi.com/python/dspam.html [dspam] @@ -136,6 +152,8 @@ blind = 1 ;spam=spam@foocorp.com # address to forward false positives to. milter will process and not deliver ;falsepositive=ham@foocorp.com +# account which receives only spam: all received messages are marked as spam. +;honeypot=spam-me@example.com # the dspam_screener is a list of dspam users who screen mail for all # recipients who are not dspam_users. Spam goes to the screeners quarantine, # and the original recipients are saved so that false positives can be properly diff --git a/milter.html b/milter.html index 11d035367f24bb9771df825d9097f7f4eeabcfb6..8c5eecec2aa8f713c3ac36a56def5129cd245ef0 100644 --- a/milter.html +++ b/milter.html @@ -24,11 +24,13 @@ 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 Jan 05, 2005</h4> +Last updated Jun 09, 2005</h4> -See the <a href="faq.html">FAQ</a> | <a href="#download">Download now</a> | +See the <a href="faq.html">FAQ</a> | <a href="http://sourceforge.net/project/showfiles.php?group_id=139894">Download now</a> | <a href="/mailman/listinfo/pymilter">Subscribe to mailing list</a> | -<a href="#overview">Overview</a> +<a href="#overview">Overview</a> | +<a href="/python/dspam.html">pydspam</a> | +<a href="/libdspam/dspam.html">libdspam</a> <p> <a href="//www.python.org"> <img src="python55.gif" align=left alt="A Python"></a> @@ -39,13 +41,26 @@ provides a python interface to libmilter that exploits all its features. <p> Sendmail 8.12 officially releases libmilter. Version 8.12 seems to be more robust, and includes new privilege -separation features to enhance security. -I recommend upgrading. +separation features to enhance security. Even better, sendmail 8.13 +supports socket maps, which makes <a href="pysrs.html">pysrs</a> much more +efficient and secure. I recommend upgrading. <h2> Recent Changes </h2> +Python milter is being moved to +<a href="http://sourceforge.net/projects/pymilter/">pymilter Sourceforge +project</a> for development. +<p> +Release 0.8.0 is the first <a href="http://sourceforge.net/">Sourceforge</a> +release. It supports Python-2.4, and provides an option to accept mail +that gets an SPF softfail or fails the 3 strikes rule, provided the +alleged sender accepts a DSN explaining the problem. Python-2.3 is +no longer supported by the reworked mime.py module, although API changes +could be backported. There are too many incompatible changes to the +python email package. +<p> Release 0.7.2 tightens the authentication screws with a "3 strikes and -your out" policy. A sender must have a valid PTR, HELO, or SPF record +you're out" policy. A sender must have a valid PTR, HELO, or SPF record to send email. Specific senders can be whitelisted using the "delegate" option in the spf configuration section by adding a default SPF record for them. The PTR and HELO are required @@ -109,9 +124,9 @@ recommend ignoring it and continuing to implement and improve SPF until a working and unencumbered proposal for RFC2822 headers surfaces. <p> -<a href="http://spf.pobox.com"> +<a href="http://openspf.com"> <img src="SPF.gif" align=left alt="SPF logo"></a> -Release 0.6.6 adds support for <a href="http://spf.pobox.com/">SPF</a>, +Release 0.6.6 adds support for <a href="http://openspf.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 @@ -207,7 +222,7 @@ methods that do nothing, and also provides wrappers for the libmilter methods to mutate the message. <p> -The 'spf' module provides an implementation of <a href="http://spf.pobox.com"> +The 'spf' module provides an implementation of <a href="http://openspf.com"> SPF</a> useful for detecting email forgery. <p> The 'mime' module provides a wrapper for the Python email package that @@ -222,300 +237,9 @@ content filtering. SPF checking requires <a href="http://pydns.sourceforge.net/"> pydns</a>. Configuration documentation is currently included as comments in the <a href="milter.cfg">sample config file</a> for the bms.py milter. - -<h3><a name=download>Downloading</a></h3> - -The latest stable release is <a href="#stable">0.7.2</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.7.2-2. See the <a href=NEWS>Change Log</a>. -PLEASE NOTE - if you are using the modules, but not the bms milter application, -then ignore the RPMs and milter.spec. Use 'python setup.py bdist_rpm' to -build source and binary rpms that do not include the milter application. -<p> -I want to split the bms milter application to a new project once I figure -out the renaming. The current plan is to rename 'milter' to 'pymilter', which -will have the Python modules. The bms milter application will still be named -'milter' and depend on pymilter (so that my installs won't notice anything). -<p> -<a name="stable"><b>Stable</b></a> -<a href="http://bmsi.com/python/milter-0.7.2.tar.gz"> -milter-0.7.2.tar.gz</a> Three strikes and your out policy. Some SPF fixes. -Recognizes PTR records for dynamic IPs. -<br> -<a href="http://bmsi.com/linux/rh72/milter-0.7.2-2.i386.rpm"> -milter-0.7.2-2.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.7.2-2rh9.i386.rpm"> -milter-0.7.2-2rh9.i386.rpm</a> Binary RPM for Redhat 9, 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.7.2-2.src.rpm"> -milter-0.7.2-2.src.rpm</a> Source RPM for Redhat 9,7.x. -<p> -<a href="http://bmsi.com/python/milter-0.7.1.tar.gz"> -milter-0.7.1.tar.gz</a> Support setmlreply, handle some more exceptions -for malformed spam. Compiling pymilter with sendmail-8.12.10, requires -sendmail-devel with _FFR_MULTILINE set. The binary will work with older -sendmails. The _FFR_MULTILINE option only affects libmilter.a. -<br> -<a href="http://bmsi.com/linux/rh72/milter-0.7.1-1.i386.rpm"> -milter-0.7.1-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.7.1-1.src.rpm"> -milter-0.7.1-1.src.rpm</a> Source RPM for Redhat 9,7.x. -<p> -<a href="http://bmsi.com/python/milter-0.7.0.tar.gz"> -milter-0.7.0.tar.gz</a> Move config file and default socket location. -Parse M$ CID records. -<br> -<a href="http://bmsi.com/linux/rh72/milter-0.7.0-1.i386.rpm"> -milter-0.7.0-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.7.0-1rh9.i386.rpm"> -milter-0.7.0-1rh9.i386.rpm</a> Binary RPM for Redhat 9, 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/aix/milter-0.7.0-1.ppc.rpm"> -milter-0.7.0-1.ppc.rpm</a> Binary RPM for AIX, requires sendmail-8.13.1. -<br> -<a href="http://bmsi.com/linux/rh9/milter-0.7.0-1.src.rpm"> -milter-0.7.0-1.src.rpm</a> Source RPM for Redhat 9,7.x. -<p> -<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, -<a href="http://spf.pobox.com/srs.html">SRS</a> forgery detection, -thread resource starvation detection. -SRS support requires <a href="http://bmsi.com/python/pysrs.html">pysrs</a>. -<p> -<a href="http://bmsi.com/linux/rh72/milter-0.6.7-3.i386.rpm"> -milter-0.6.7-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/rh72/milter-0.6.7-3.src.rpm"> -milter-0.6.7-3.src.rpm</a> Source RPM for Redhat 7.x. -Release 0.6.7-3 patches: -<ul> -<li> Defang message/rfc822 content_type with boundary -<li> Support SPF delegation -<li> Reject neutral SPF result for selected domains -</ul> -<p> -<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. -SPF support requires <a href="http://pydns.sourceforge.net/">pydns</a>. -NOTE - the spf.py module included is modified from the official 1.6 -version at <a href="http://www.wayforward.net/spf/">wayforward.net</a>. -I neglected to add the CVS log. The changes are expanded result codes -and tolerating common method misspellings in SPF records. I have notified the -author, but haven't heard back. At some point, the RPM will -include the official pyspf tarball and apply patches. -<p> -<a href="http://bmsi.com/linux/rh72/milter-0.6.6-2.i386.rpm"> -milter-0.6.6-2.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>. Release 2 fixes sysv init script bug for python2.3. -<br> -<a href="http://bmsi.com/linux/rh72/milter-0.6.6-2.src.rpm"> -milter-0.6.6-2.src.rpm</a> Source RPM for Redhat 7.x -<p> -<a href="http://bmsi.com/python/milter-0.6.5.tar.gz"> -milter-0.6.5.tar.gz</a> Plug memory leak, progress reporting, trusted relay. -Redhat RPM now requires sendmail-8.12. -<p> -<a href="http://bmsi.com/linux/rh72/milter-0.6.5-2.i386.rpm"> -milter-0.6.5-2.i386.rpm</a> Binary RPM for Redhat 7.x -<br> -<a href="http://bmsi.com/linux/rh72/milter-0.6.5-2.src.rpm"> -milter-0.6.5-2.src.rpm</a> Source RPM for Redhat 7.x -<p> -<a href="http://bmsi.com/python/milter-0.6.4.tar.gz"> -milter-0.6.4.tar.gz</a> Numerous Dspam fixes. Requires -<a href="dspam.html">pydspam-1.1.5</a> and -<a href="/libdspam/dspam.html">dspam-2.6.5.2</a> -for Dspam features. The dspam-python RPM has been replaced by pydspam. -<p> -<a href="http://bmsi.com/linux/rh72/milter-0.6.4-1.i386.rpm"> -milter-0.6.4-1.i386.rpm</a> Binary RPM for Redhat 7.x -<p> -<a href="http://bmsi.com/python/milter-0.6.3.1.tar.gz"> -milter-0.6.3.1.tar.gz</a> New dspam SCREENER feature with pydspam-1.1.4. -Don't save a defang copy of false positives. Fixed an oops from last fix, -rejecting false positives. BUG: sendmail-8.11 doesn't invoke milter -when sending mail via sendmail from command line (8.12 works). Therefore, -the supplied falsepositive script for milter based dspam doesn't work -with stock RedHat 7.x. I am writing a HOWTO for configuring milter -based dspam that will address this (and a fix in the next version). -<p> -<a href="http://bmsi.com/linux/rh72/milter-0.6.3-1.i386.rpm"> -milter-0.6.3-1.i386.rpm</a> Binary RPM for Redhat 7.x -<p> -<a href="http://bmsi.com/python/milter-0.6.2.tar.gz"> -milter-0.6.2.tar.gz</a> work around email.Message.get_filename bug, -dspam_exempt list, REJECT messages with missing MIME boundaries (which -are almost always spam), -DISCARD messages which any dspam user flags as spam, -start.sh was calling python instead of python2 on Linux. -<p> -<a href="http://bmsi.com/linux/rh72/milter-0.6.2-1.src.rpm"> -milter-0.6.2-1.src.rpm</a> Source RPM for Redhat 7.x (and likely -higher versions) -<p> -<a href="http://bmsi.com/python/milter-0.6.1.tar.gz"> -milter-0.6.1.tar.gz</a> dspam milter application, python-2.2.3 support. -<p> -You must have <a href=dspam.html>dspam and dspam-python</a> loaded for -the dspam feature to work. Brief instructions for configuring are -in the default config file. This is working at a customer, but I'm -sure a few more iterations will be required to make setup as smooth -as possible. -<p> -NOTE: Outlook destroys dspam tags when forwarding mail (while converting -HTML to text). Perhaps some config option will turn this abominable -"feature" off. Working around this by making dspam tags visble on -HTML mail is ugly. My suggestion is to not use Outlook, for this and -many other reasons - especially security. Any other suggestions for -those married to Microsoft are welcome. The DSPAM LDA works around this -by making the tags visible in HTML attachments. This is ugly, and -occasionally corrupts attachments. -<p> -We have to supply workarounds for bugs in the email module (reported -to sourceforge). The workarounds reference some internal variables -which change with python versions. -<p> -<a href="http://bmsi.com/linux/rh72/milter-0.6.1-1.i386.rpm"> -milter-0.6.1-1.i386.rpm</a> Binary RPM for Redhat 7.x -<p> -<a href="http://bmsi.com/linux/rh72/milter-0.6.1-1.src.rpm"> -milter-0.6.1-1.src.rpm</a> Source RPM for Redhat 7.x (and likely -higher versions) -<p> -<a href="http://bmsi.com/python/milter-0.6.0.tar.gz"> -milter-0.6.0.tar.gz</a> simple dspam pre-filtering, use email module, -requires python >= 2.2.2. -<ul> -<li> The milter.so module from 0.5.4 -is needed to run this release on AIX. Haven't tracked this down yet. -<li> The patches to fix the email packages in mime.py don't work -on python-2.2.3. The email package is still broken in 2.3, and patches -required for that will likely be different still. -</ul> - -<p> -<a href="http://bmsi.com/linux/rh72/milter-0.6.0-1.i386.rpm"> -milter-0.6.0-1.i386.rpm</a> Binary RPM for Redhat 7.x -<p> -<a href="http://bmsi.com/linux/rh72/milter-0.6.0-1.src.rpm"> -milter-0.6.0-1.src.rpm</a> Source RPM for Redhat 7.x (and likely -higher versions) -<p> -<a href="http://www.bmsi.com/python/milter-0.5.5.tar.gz"> -milter-0.5.5.tar.gz</a> IPV6 support, passing None to set_XXX_callback, -set_reply, chg_header, detect internal connections. Note, this release -did not work on AIX4.1.5, probably due to IPV6 support breaking something. -The milter.so module from 0.5.4 can be installed to use this release -with AIX. -<p> -<a href="http://www.bmsi.com/python/milter-0.5.4.tar.gz"> -milter-0.5.4.tar.gz</a> wiretap, smart alias features, quarantine support. -<p> -The name of the production "sample" milter "bms.py" now -stands for "Basic Milter System" until someone suggests a better name. -The test coverage is rather - sparse at present. - Please <a href="mailto:%73%74%75%61%72%74%40%62%6D%73%69%2E%63%6F%6D">email</a> with proposals for what - to name the milter application. -<h4>NOTES</h4> -<ul> -<li> - Quarantine support requires that you define _FFR_QUARANTINE - when compiling miltermodule.c. I am not sure how to make setup.py - do that for you iff sendmail was actually compiled with _FFR_QUARANTINE. -<li> - While 0.6.0 will use the new email package in Python-2.2, that - package seems to be buggy in Python-2.2.1. The list example in the docs - doesn't find all MIME parts. Update: Python-2.2.2 has fixed the email - package. It can now parse my test cases. -<li> - Preliminary testing with python-2.2 shows that most things work after - adding <code>self.readahead = ""</code> to <code>mimepart.seek</code>. - Python-2.2 <code>multifile</code> reads one less newline per section than - 2.1. I'm not not sure which is correct. After adding some calls to - <code>rstrip()</code> in testmime.py, all milter modules pass unit testing - with python-2.2. Python-2.2 patches have been released since 0.5.3. -<li> - sgmlop-1.1a3 has a memory leak (at least Python milter has a - memory leak when using sgmlop instead of sgmllib). Do not make Python - milter use sgmlop-1.1a2 or a3 in a production - system unless you can restart your milter periodically. The amount - of memory leaked seems roughly proportional to the amount of HTML - parsed. -<li> - There are a number of ways that malformed MIME attachments - can cause a python traceback. Uncaught exceptions cause a 415 - error to be returned to sendmail. So far, all the malformed messages - I've investigated have been SPAM - so good riddance. I would prefer, - however, that the mime handling libraries were more precise. Beginning - with 0.5.1, bms.py will save messages that cause a traceback during - scanning in the tempfile directory with a ".fail" extension. This - makes it easier to get samples of mail that causes parsing problems - for incorporation into the unit tests. -</ul> -<p> -<a href="http://www.bmsi.com/python/milter-0.5.2.tar.gz"> -milter-0.5.2.tar.gz</a> Fix and unittest another HTML parsing bug.<br> -<a href="http://www.bmsi.com/python/milter-0.5.1.tar.gz"> -milter-0.5.1.tar.gz</a> Handle encoded rfc822 attachments.<br> -<a href="http://www.bmsi.com/python/milter-0.5.0.tar.gz"> -milter-0.5.0.tar.gz</a> Use a config file so users don't have to -keep syncing with bms.py. <br> -<a href="http://www.bmsi.com/python/milter-0.4.5.tar.gz"> -milter-0.4.5.tar.gz</a> Work with sgmlop. Reduce local hacks to config variables. <p> Python milter is under GPL. The authors can probably be convinced to -change this to LGPL. +change this to LGPL if needed. <h3>What is a <a name="milter">milter</a>?</h3> diff --git a/milter.spec b/milter.spec index f71ef4a7168dca993095a823f1eebc622833ef8a..11b1db7e6bd24e36d313d65f2f5da6b3ce85ae1a 100644 --- a/milter.spec +++ b/milter.spec @@ -1,10 +1,25 @@ %define name milter -%define version 0.8.0 -%define release 2.EL3 -# Redhat 7.x and earlier (multiple ps lines per thread) -#define sysvinit milter.rc7 -# RH9, other systems (single ps line per process) +%define version 0.8.2 +%define release 2.RH7 +# what version of RH are we building for? +%define redhat9 0 +%define redhat7 1 +%define redhat6 0 + +# Options for Redhat version 6.x: +# rpm -ba|--rebuild --define "rh6 1" +%{?rh6:%define redhat7 0} +%{?rh6:%define redhat6 1} + +# some systems dont have initrddir defined +%{?_initrddir:%define _initrddir /etc/rc.d/init.d} + +%if %{redhat9} %define sysvinit milter.rc +%else # Redhat 7.x and earlier (multiple ps lines per thread) +%define sysvinit milter.rc7 +%endif +# RH9, other systems (single ps line per process) %ifos Linux %define python python2.4 %else @@ -25,10 +40,10 @@ Vendor: Stuart D. Gathman <stuart@bmsi.com> Packager: Stuart D. Gathman <stuart@bmsi.com> Url: http://www.bmsi.com/python/milter.html Requires: %{python} >= 2.4, sendmail >= 8.12.10 -%ifnos aix4.1 +%ifos Linux Requires: chkconfig %endif -BuildRequires: %{python}-devel >= 2.2.2, sendmail-devel >= 8.12.10 +BuildRequires: %{python}-devel , sendmail-devel >= 8.12.10 %description This is a python extension module to enable python scripts to @@ -48,7 +63,7 @@ rm -rf $RPM_BUILD_ROOT mkdir -p $RPM_BUILD_ROOT/var/log/milter mkdir -p $RPM_BUILD_ROOT/etc/mail mkdir $RPM_BUILD_ROOT/var/log/milter/save -cp bms.py $RPM_BUILD_ROOT/var/log/milter +cp bms.py strike3.txt softfail.txt $RPM_BUILD_ROOT/var/log/milter cp milter.cfg $RPM_BUILD_ROOT/etc/mail/pymilter.cfg # logfile rotation @@ -145,12 +160,39 @@ rm -rf $RPM_BUILD_ROOT %dir /var/log/milter/save %config /var/log/milter/start.sh %config /var/log/milter/bms.py +%config /var/log/milter/strike3.txt +%config /var/log/milter/softfail.txt %config(noreplace) /etc/mail/pymilter.cfg /usr/share/sendmail-cf/hack/rhsbl.m4 %changelog +* Fri Jul 15 2005 Stuart Gathman <stuart@bmsi.com> 0.8.2-1 +- Strict processing limits per SPF RFC +- Fixed several parsing bugs under RFC +- Support official IANA SPF record (type99) +- Honeypot support (requires pydspam-1.1.9) +- Extended SPF processing results beyond strict RFC limits +- Support original SES for local bounce protection (requires pysrs-0.30.10) +- Callback exception processing option in milter module +- Handle corrupt ZIP attachments +* Thu Jun 16 2005 Stuart Gathman <stuart@bmsi.com> 0.8.1-1 +- Fix zip in zip loop in mime.py +- Fix HeaderParseError in bms.py header callback +- Check internal_domains for outgoing mail +- Fix inconsistent results from send_dsn +* Mon Jun 06 2005 Stuart Gathman <stuart@bmsi.com> 0.8.0-3 +- properly log pydspam exceptions +* Sat Jun 04 2005 Stuart Gathman <stuart@bmsi.com> 0.8.0-2 +- Include default softfail, strike3 templates +* Wed May 25 2005 Stuart Gathman <stuart@bmsi.com> 0.8.0-1 +- Move Milter module to subpackage. +- DSN support for Three strikes rule and SPF SOFTFAIL +- Move /*mime*/ and dynip to Milter subpackage +- Fix SPF unknown mechanism list not cleared +- Make banned extensions configurable. +- Option to scan zipfiles for bad extensions. * Tue Feb 08 2005 Stuart Gathman <stuart@bmsi.com> 0.7.3-1.EL3 -- Compile for EL3 and Python4 +- Support EL3 and Python2.4 (some scanning/defang support broken) * Mon Aug 30 2004 Stuart Gathman <stuart@bmsi.com> 0.7.2-1 - Fix various SPF bugs - Recognize dynamic PTR names, and don't count them as authentication. diff --git a/miltermodule.c b/miltermodule.c index 29d23583c539e0a8c1b16d2990656d8d88ac1af5..f9f0801b086cf293d927d887a5612e4e8951e4f2 100644 --- a/miltermodule.c +++ b/miltermodule.c @@ -1,4 +1,5 @@ /* Copyright (C) 2001 James Niemira (niemira@colltech.com, urmane@urmane.org) + * Portions Copyright (C) 2001,2002,2003,2004 Stuart Gathman (stuart@bmsi.com) * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License @@ -33,6 +34,21 @@ $ python setup.py help libraries=["milter","smutil","resolv"] * $Log$ + * Revision 1.5 2005/06/24 04:20:07 customdesigned + * Report context allocation error. + * + * Revision 1.4 2005/06/24 04:12:43 customdesigned + * Remove unused name argument to generic wrappers. + * + * Revision 1.3 2005/06/24 03:57:35 customdesigned + * Handle close called before connect. + * + * Revision 1.2 2005/06/02 04:18:55 customdesigned + * Update copyright notices after reading article on /. + * + * Revision 1.1.1.2 2005/05/31 18:09:06 customdesigned + * Release 0.7.1 + * * Revision 2.31 2004/08/23 02:24:36 stuart * Support setbacklog * @@ -190,7 +206,7 @@ $ python setup.py help /* Yes, these are static. If you need multiple different callbacks, */ -/* it's cleaner to use multiple filters. */ +/* it's cleaner to use multiple filters, or convert to OO method calls. */ static PyObject *connect_callback = NULL; static PyObject *helo_callback = NULL; static PyObject *envfrom_callback = NULL; @@ -235,8 +251,11 @@ _get_context(SMFICTX *ctx) { PyEval_AcquireThread(t); /* lock interp */ self = PyObject_New(milter_ContextObject,&milter_ContextType); if (!self) { - /* Can't pass on exception since we are called from libmilter */ - PyErr_Clear(); + /* Report and clear exception since we are called from libmilter */ + if (PyErr_Occurred()) { + PyErr_Print(); + PyErr_Clear(); + } PyThreadState_Clear(t); PyEval_ReleaseThread(t); PyThreadState_Delete(t); @@ -327,7 +346,8 @@ CHGHDRS - filter may change/delete headers"; static PyObject * milter_set_flags(PyObject *self, PyObject *args) { - if (!PyArg_ParseTuple(args, "i", &description.xxfi_flags)) return NULL; + if (!PyArg_ParseTuple(args, "i:set_flags", &description.xxfi_flags)) + return NULL; Py_INCREF(Py_None); return Py_None; } @@ -493,6 +513,28 @@ milter_set_close_callback(PyObject *self, PyObject *args) { return generic_set_callback(args, "O:set_close_callback", &close_callback); } +static int exception_policy = SMFIS_TEMPFAIL; + +static char milter_set_exception_policy__doc__[] = +"set_exception_policy(i) -> None\n\ +Sets the policy for untrapped Python exceptions during a callback.\n\ +Must be one of TEMPFAIL,REJECT,CONTINUE"; + +static PyObject * +milter_set_exception_policy(PyObject *self, PyObject *args) { + int i; + if (!PyArg_ParseTuple(args, "i:set_exception_policy", &i)) + return NULL; + switch (i) { + case SMFIS_REJECT: case SMFIS_TEMPFAIL: case SMFIS_CONTINUE: + exception_policy = i; + Py_INCREF(Py_None); + return Py_None; + } + PyErr_SetString(MilterError,"invalid exception policy"); + return NULL; +} + /** Report and clear any python exception before returning to libmilter. The interpreter is locked when we are called, and we unlock it. */ static int _report_exception(milter_ContextObject *self) { @@ -500,8 +542,15 @@ static int _report_exception(milter_ContextObject *self) { PyErr_Print(); PyErr_Clear(); /* must clear since not returning to python */ PyEval_ReleaseThread(self->t); - smfi_setreply(self->ctx, "451", "4.3.0", "Filter failure"); - return SMFIS_TEMPFAIL; + switch (exception_policy) { + case SMFIS_REJECT: + smfi_setreply(self->ctx, "554", "5.3.0", "Filter failure"); + return SMFIS_REJECT; + case SMFIS_TEMPFAIL: + smfi_setreply(self->ctx, "451", "4.3.0", "Filter failure"); + return SMFIS_TEMPFAIL; + } + return SMFIS_CONTINUE; } PyEval_ReleaseThread(self->t); return SMFIS_CONTINUE; @@ -612,7 +661,7 @@ milter_wrap_helo(SMFICTX *ctx, char *helohost) { } static int -generic_env_wrapper(SMFICTX *ctx, PyObject*cb, char **argv, const char *name) { +generic_env_wrapper(SMFICTX *ctx, PyObject*cb, char **argv) { PyObject *arglist; milter_ContextObject *self; int count = 0; @@ -649,12 +698,12 @@ generic_env_wrapper(SMFICTX *ctx, PyObject*cb, char **argv, const char *name) { static int milter_wrap_envfrom(SMFICTX *ctx, char **argv) { - return generic_env_wrapper(ctx,envfrom_callback,argv,"milter_wrap_envfrom"); + return generic_env_wrapper(ctx,envfrom_callback,argv); } static int milter_wrap_envrcpt(SMFICTX *ctx, char **argv) { - return generic_env_wrapper(ctx,envrcpt_callback,argv,"milter_wrap_envrcpt"); + return generic_env_wrapper(ctx,envrcpt_callback,argv); } static int @@ -670,7 +719,7 @@ milter_wrap_header(SMFICTX *ctx, char *headerf, char *headerv) { } static int -generic_noarg_wrapper(SMFICTX *ctx,PyObject *cb,const char *name) { +generic_noarg_wrapper(SMFICTX *ctx,PyObject *cb) { PyObject *arglist; milter_ContextObject *c; if (cb == NULL) return SMFIS_CONTINUE; @@ -682,7 +731,7 @@ generic_noarg_wrapper(SMFICTX *ctx,PyObject *cb,const char *name) { static int milter_wrap_eoh(SMFICTX *ctx) { - return generic_noarg_wrapper(ctx,eoh_callback,"milter_wrap_eoh"); + return generic_noarg_wrapper(ctx,eoh_callback); } static int @@ -700,18 +749,31 @@ milter_wrap_body(SMFICTX *ctx, u_char *bodyp, size_t bodylen) { static int milter_wrap_eom(SMFICTX *ctx) { - return generic_noarg_wrapper(ctx,eom_callback,"milter_wrap_eom"); + return generic_noarg_wrapper(ctx,eom_callback); } static int milter_wrap_abort(SMFICTX *ctx) { /* libmilter still calls close after abort */ - return generic_noarg_wrapper(ctx,abort_callback,"milter_wrap_abort"); + return generic_noarg_wrapper(ctx,abort_callback); } static int milter_wrap_close(SMFICTX *ctx) { - int r = generic_noarg_wrapper(ctx,close_callback,"milter_wrap_close"); + /* xxfi_close can be called out of order - even before connect. + * There may not yet be a private context pointer. To avoid + * creating a ThreadContext and allocating a milter context only + * to destroy them, and to avoid invoking the python close_callback when + * connect has never been called, we don't use generic_noarg_wrapper here. */ + PyObject *cb = close_callback; + milter_ContextObject *self = smfi_getpriv(ctx); + int r = SMFIS_CONTINUE; + if (self != NULL && cb != NULL && self->ctx == ctx) { + PyObject *arglist; + PyEval_AcquireThread(self->t); + arglist = Py_BuildValue("(O)", self); + r = _generic_wrapper(self, cb, arglist); + } /* FIXME: It is inefficient to have released the interp lock only to acquire it again in _clear_context. We can tell _generic_return and friends not to release the lock by, for instance, setting self->t to NULL. @@ -1151,6 +1213,8 @@ static PyMethodDef milter_methods[] = { { "set_eom_callback", milter_set_eom_callback, METH_VARARGS, milter_set_eom_callback__doc__}, { "set_abort_callback", milter_set_abort_callback, METH_VARARGS, milter_set_abort_callback__doc__}, { "set_close_callback", milter_set_close_callback, METH_VARARGS, milter_set_close_callback__doc__}, + { "set_exception_policy", milter_set_exception_policy,METH_VARARGS, milter_set_exception_policy__doc__}, + { "register", milter_register, METH_VARARGS, milter_register__doc__}, { "register", milter_register, METH_VARARGS, milter_register__doc__}, { "main", milter_main, METH_VARARGS, milter_main__doc__}, { "setdbg", milter_setdbg, METH_VARARGS, milter_setdbg__doc__}, diff --git a/mime.py b/mime.py index 95d8e9a4c0dd6ae55376b8b37003ac1ec39cc4ed..6ceb6bdadf12cc7af80ad835242bbd508323919b 100644 --- a/mime.py +++ b/mime.py @@ -1,4 +1,16 @@ # $Log$ +# Revision 1.4 2005/06/17 01:49:39 customdesigned +# Handle zip within zip. +# +# Revision 1.3 2005/06/02 15:00:17 customdesigned +# Configure banned extensions. Scan zipfile option with test case. +# +# Revision 1.2 2005/06/02 04:18:55 customdesigned +# Update copyright notices after reading article on /. +# +# Revision 1.1.1.4 2005/05/31 18:23:49 customdesigned +# Development changes since 0.7.2 +# # Revision 1.62 2005/02/14 22:31:17 stuart # _parseparam replacement not needed for python2.4 # @@ -62,12 +74,13 @@ # with a warning message. # Author: Stuart D. Gathman <stuart@bmsi.com> -# Copyright 2001 Business Management Systems, Inc. -# This code is under GPL. See COPYING for details. +# Copyright 2001,2002,2003,2004,2005 Business Management Systems, Inc. +# This code is under the GNU General Public License. See COPYING for details. import StringIO import socket import Milter +import zipfile import email import email.Message @@ -80,6 +93,16 @@ from email import Errors from types import ListType,StringType +def zipnames(txt): + fp = StringIO.StringIO(txt) + zipf = zipfile.ZipFile(fp,'r') + names = [] + for nm in zipf.namelist(): + names.append(('zipname',nm)) + if nm.lower().endswith('.zip'): + names += zipnames(zipf.read(nm)) + return names + class MimeGenerator(Generator): def _dispatch(self, msg): # Get the Content-Type: for the message, then try to dispatch to @@ -153,7 +176,7 @@ class MimeMessage(Message): def getname(self): return self.get_param('name') - def getnames(self): + def getnames(self,scan_zip=False): """Return a list of (attr,name) pairs of attributes that IE might interpret as a name - and hence decide to execute this message.""" names = [] @@ -168,7 +191,14 @@ class MimeMessage(Message): else: val = _unquotevalue(val.strip()) names.append((attr,val)) - return names + [("filename",self.get_filename())] + names += [("filename",self.get_filename())] + if scan_zip: + for key,name in tuple(names): # copy by converting to tuple + if name and name.lower().endswith('.zip'): + txt = self.get_payload(decode=True) + if txt.strip(): + names += zipnames(txt) + return names def ismodified(self): "True if this message or a subpart has been modified." @@ -276,19 +306,27 @@ A copy of your original message was saved as '%s:%s'. See your administrator. """ -def check_name(msg,savname=None,ckname=check_ext): +def check_name(msg,savname=None,ckname=check_ext,scan_zip=False): "Replace attachment with a warning if its name is suspicious." - for key,name in msg.getnames(): - badname = ckname(name) - if badname: - hostname = socket.gethostname() - msg.set_payload(virus_msg % (badname,hostname,savname)) - del msg["content-type"] - del msg["content-disposition"] - del msg["content-transfer-encoding"] - name = "WARNING.TXT" - msg["Content-Type"] = "text/plain; name="+name - break + try: + for key,name in msg.getnames(scan_zip): + badname = ckname(name) + if badname: + if key == 'zipname': + badname = msg.get_filename() + break + else: + return Milter.CONTINUE + except zipfile.BadZipfile: + # a ZIP that is not a zip is very suspicious + badname = msg.get_filename() + hostname = socket.gethostname() + msg.set_payload(virus_msg % (badname,hostname,savname)) + del msg["content-type"] + del msg["content-disposition"] + del msg["content-transfer-encoding"] + name = "WARNING.TXT" + msg["Content-Type"] = "text/plain; name="+name return Milter.CONTINUE import email.Iterators @@ -309,11 +347,11 @@ check function(MimeMessage): int # save call context for Python without nested_scopes class _defang: - def __init__(self): - self.scan_html = True + def __init__(self,scan_html=True): + self.scan_html = scan_html def _chk_name(self,msg): - rc = check_name(msg,self._savname,self._check) + rc = check_name(msg,self._savname,self._check,self.scan_zip) if self.scan_html: check_html(msg,self._savname) # remove scripts from HTML if self.scan_rfc822: @@ -322,12 +360,14 @@ class _defang: return check_attachments(msg,self._chk_name) return rc - def __call__(self,msg,savname=None,check=check_ext,scan_rfc822=True): + def __call__(self,msg,savname=None,check=check_ext,scan_rfc822=True, + scan_zip=False): """Compatible entry point. Replace all attachments with dangerous names.""" self._savname = savname self._check = check self.scan_rfc822 = scan_rfc822 + self.scan_zip = scan_zip check_attachments(msg,self._chk_name) if msg.ismodified(): return True diff --git a/setup.cfg b/setup.cfg index 1b9f4f1e13221805f8b60f797b0a2004c6cecea1..7503af5b7bb87080df10978e80a603dde6bbad21 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,3 +2,4 @@ python=python2 doc_files=README NEWS TODO packager=Stuart D. Gathman <stuart@bmsi.com> +release=2.4 diff --git a/setup.py b/setup.py index 775cee78417ad8417fad45bd1f47b88d9ddee0f3..a45932581ce77a678659e2a83d3eae731199ea6f 100644 --- a/setup.py +++ b/setup.py @@ -12,7 +12,7 @@ if sys.version < '2.2.3': DistributionMetadata.classifiers = None DistributionMetadata.download_url = None -setup(name = "milter", version = "0.8.0", +setup(name = "milter", version = "0.8.2", description="Python interface to sendmail milter API", long_description="""\ This is a python extension module to enable python scripts to @@ -26,7 +26,8 @@ querying SPF records. maintainer_email="stuart@bmsi.com", license="GPL", url="http://www.bmsi.com/python/milter.html", - py_modules=["Milter","mime","spf"], + py_modules=["mime","spf"], + packages = ['Milter'], ext_modules=[ Extension("milter", ["miltermodule.c"], libraries=libs, @@ -42,6 +43,7 @@ querying SPF records. 'Natural Language :: English', 'Operating System :: POSIX', 'Programming Language :: Python', - 'Topic :: Communications :: Email :: Mail Transport Agents' + 'Topic :: Communications :: Email :: Mail Transport Agents', + 'Topic :: Communications :: Email :: Filters' ] ) diff --git a/softfail.txt b/softfail.txt new file mode 100644 index 0000000000000000000000000000000000000000..263cecf6424eadd2ecfad7ecc3a15bfe1c938d9d --- /dev/null +++ b/softfail.txt @@ -0,0 +1,23 @@ +Subject: SPF softfail (POSSIBLE FORGERY) + +This is an automatically generated Delivery Status Notification. + +THIS IS A WARNING MESSAGE ONLY. + +YOU DO *NOT* NEED TO RESEND YOUR MESSAGE. + +Delivery to the following recipients has been delayed. + + %(rcpt)s + +Subject: %(subject)s +Received-SPF: %(spf_result)s + +Your sender policy indicated that the above email was likely forged and that +feedback was desired. + +If you need further assistance, please do not hesitate to contact me. + +Kind regards, + +postmaster@%(receiver)s diff --git a/spf.py b/spf.py index 450f149945b3ae72288a07926b254527a7919e0a..e8f209660f7a344bf90e668de76dffeb712fe204 100755 --- a/spf.py +++ b/spf.py @@ -1,7 +1,8 @@ #!/usr/bin/env python -"""SPF (Sender-Permitted From) implementation. +"""SPF (Sender Policy Framework) implementation. Copyright (c) 2003, Terence Way +Portions Copyright (c) 2004,2005 Stuart Gathman <stuart@bmsi.com> This module is free software, and you may redistribute it and/or modify it under the same terms as Python itself, so long as this copyright message and disclaimer are retained in their original form. @@ -18,10 +19,11 @@ AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. For more information about SPF, a tool against email forgery, see - http://spf.pobox.com + http://spf.pobox.com/ For news, bugfixes, etc. visit the home page for this implementation at http://www.wayforward.net/spf/ + http://sourceforge.net/projects/pymilter/ """ # Changes: @@ -45,6 +47,117 @@ For news, bugfixes, etc. visit the home page for this implementation at # Terrence is not responding to email. # # $Log$ +# Revision 1.26 2005/07/20 03:12:40 customdesigned +# When not in strict mode, don't give PermErr for bad mechanism until +# encountered during evaluation. +# +# Revision 1.25 2005/07/19 23:24:42 customdesigned +# Validate all mechanisms before evaluating. +# +# Revision 1.24 2005/07/19 18:11:52 kitterma +# Fix to change that compares type TXT and type SPF records. Bug in the change +# prevented records from being returned if it was published as TXT, but not SPF. +# +# Revision 1.23 2005/07/19 15:22:50 customdesigned +# MX and PTR limits are MUST NOT check limits, and do not result in PermErr. +# Also, check belongs in mx and ptr specific methods, not in dns() method. +# +# Revision 1.22 2005/07/19 05:02:29 customdesigned +# FQDN test was broken. Added test case. Move FQDN test to after +# macro expansion. +# +# Revision 1.21 2005/07/18 20:46:27 kitterma +# Fixed reference problem in 1.20 +# +# Revision 1.20 2005/07/18 20:21:47 kitterma +# Change to dns_spf to go ahead and check for a type 99 (SPF) record even if a +# TXT record is found and make sure if type SPF is present that they are +# identical when using strict processing. +# +# Revision 1.19 2005/07/18 19:36:00 kitterma +# Change to require at least one dot in a domain name. Added PermError +# description to indicate FQDN should be used. This is a common error. +# +# Revision 1.18 2005/07/18 17:13:37 kitterma +# Change macro processing to raise PermError on an unknown macro. +# schlitt-spf-classic-02 para 8.1. Change exp modifier processing to ignore +# exp strings with syntax errors. schlitt-spf-classic-02 para 6.2. +# +# Revision 1.17 2005/07/18 14:35:34 customdesigned +# Remove debugging printf +# +# Revision 1.16 2005/07/18 14:34:14 customdesigned +# Forgot to remove debugging print +# +# Revision 1.15 2005/07/15 21:17:36 customdesigned +# Recursion limit raises AssertionError in strict mode, PermError otherwise. +# +# Revision 1.14 2005/07/15 20:34:11 customdesigned +# Check whether DNS package already supports SPF before patching +# +# Revision 1.13 2005/07/15 20:01:22 customdesigned +# Allow extended results for MX limit +# +# Revision 1.12 2005/07/15 19:12:09 customdesigned +# Official IANA SPF record (type 99) support. +# +# Revision 1.11 2005/07/15 18:03:02 customdesigned +# Fix unknown Received-SPF header broken by result changes +# +# Revision 1.10 2005/07/15 16:17:05 customdesigned +# Start type99 support. +# Make Scott's "/" support in parse_mechanism more elegant as requested. +# Add test case for "/" support. +# +# Revision 1.9 2005/07/15 03:33:14 kitterma +# Fix for bug 1238403 - Crash if non-CIDR / present. Also added +# validation check for valid IPv4 CIDR range. +# +# Revision 1.8 2005/07/14 04:18:01 customdesigned +# Bring explanations and Received-SPF header into line with +# the unknown=PermErr and error=TempErr convention. +# Hope my case-sensitive mech fix doesn't clash with Scotts. +# +# Revision 1.7 2005/07/12 21:43:56 kitterma +# Added processing to clarify some cases of unknown +# qualifier errors (to distinguish between unknown qualifier and +# unknown mechanism). +# Also cleaned up comments from previous updates. +# +# Revision 1.6 2005/06/29 14:46:26 customdesigned +# Distinguish trivial recursion from missing arg for diagnostic purposes. +# +# Revision 1.5 2005/06/28 17:48:56 customdesigned +# Support extended processing results when a PermError should strictly occur. +# +# Revision 1.4 2005/06/22 15:54:54 customdesigned +# Correct spelling. +# +# Revision 1.3 2005/06/22 00:08:24 kitterma +# Changes from draft-mengwong overall DNS lookup and recursion +# depth limits to draft-schlitt-spf-classic-02 DNS lookup, MX lookup, and +# PTR lookup limits. Recursion code is still present and functioning, but +# it should be impossible to trip it. +# +# Revision 1.2 2005/06/21 16:46:09 kitterma +# Updated definition of SPF, added reference to the sourceforge project site, +# and deleted obsolete Microsoft Caller ID for Email XML translation routine. +# +# Revision 1.1.1.1 2005/06/20 19:57:32 customdesigned +# Move Python SPF to its own module. +# +# Revision 1.5 2005/06/14 20:31:26 customdesigned +# fix pychecker nits +# +# Revision 1.4 2005/06/02 04:18:55 customdesigned +# Update copyright notices after reading article on /. +# +# Revision 1.3 2005/06/02 02:08:12 customdesigned +# Reject on PermErr +# +# Revision 1.2 2005/05/31 18:57:59 customdesigned +# Clear unknown mechanism list at proper time. +# # Revision 1.24 2005/03/16 21:58:39 stuart # Change Milter module to package. # @@ -134,135 +247,11 @@ import struct # for pack() and unpack() import time # for time() import DNS # http://pydns.sourceforge.net -import xml.sax - -# ------------------------------------------------------------------------- -# Convert a MS Caller-ID entry (XML) to a SPF entry -# -# (c) 2004 by Ernesto Baschny -# (c) 2004 Python version by Stuart Gathman -# -# Date: 2004-02-25 -# -# A complete reverse translation (SPF -> CID) might be impossible, since -# there are no ways to handle: -# - PTR and EXISTS mechanism -# - MX mechanism with an different domain as argument -# - macros -# -# References: -# http://www.microsoft.com/mscorp/twc/privacy/spam_callerid.mspx -# http://spf.pobox.com/ -# -# Known bugs: -# - Currently it won't handle the exclusions provided in the A and R -# tags (prefix '!'). They will show up "as-is" in the SPF record -# - I really haven't read the MS-CID specs in-depth, so there are probably -# other bugs too :) -# -# Ernesto Baschny <ernst@baschny.de> -# - -class CIDParser(xml.sax.ContentHandler): - "Convert a MS Caller-ID entry (XML) to a SPF entry." - - def __init__(self,q=None): - self.spf = [] - self.action = '-all' - self.has_servers = None - self.spf_entry = None - if q: - self.spf_query = q - else: - self.spf_query = query(i='127.0.0.1', s='localhost', h='unknown') - - def startElement(self,tag,attr): - if tag == 'm': - if self.has_servers != None and not self.has_servers: - raise ValueError( - "Declared <noMailServers\> and later <m>, this CID entry is not valid." - ) - self.has_servers = True - elif tag == 'noMailServers': - if self.has_servers: - raise ValueError( - "Declared <m> and later <noMailServers\>, this CID entry is not valid." - ) - self.has_servers = False - elif tag == 'ep': - if attr.has_key('testing') and attr.getValue('testing') == 'true': - # A CID with 'testing' found: - # From the MS-specs: - # "Documents in which such attribute is present with a true - # value SHOULD be entirely ignored (one should act as if the - # document were absent)" - # From the SPF-specs: - # "Neutral (?): The SPF client MUST proceed as if a domain did - # not publish SPF data." - # So we set SPF action to "neutral": - self.action = '?all' - elif tag == 'mx': - # The empty MX-tag, same as SPF's MX-mechanism - self.spf.append('mx') - self.tag = tag - - def characters(self,text): - tag = self.tag - # Remove starting and trailing spaces from text: - text = text.strip() - - if tag == 'a' or tag == 'r': - # The A and R tags from MS-CID are both handled by the - # ipv4/6-mechanisms from SPF: - if text.find(':') < 0: - mechanism = 'ip4' - else: - mechanism = 'ip6' - self.spf.append(mechanism + ':' + text) - elif tag == 'indirect': - # MS-CID's indirect is "sort of" the include from SPF: - # Not really true, because the <indirect> tag from MS-CID also - # provides a fallback in case the included domain doesn't provide - # _ep-records: The inbound MX-servers of the included domains - # are added to the list of allowed outgoing mailservers for the - # domain that declared the _ep-record with the <indirect> tag. - # In SPF you would use the 'mx:domain' to handle this, but this - # wouldn't depend on referred domain having or not SPF-records. - cid_xml = self.cid_txt(text) - if cid_xml: - p = CIDParser() - xml.sax.parseString(cid_xml,p) - if p.has_servers != False: - self.spf += p.spf - else: - self.spf.append('mx:' + text) - - def cid_txt(self,domain): - q = self.spf_query - domain='_ep.' + domain - a = q.dns_txt(domain) - if not a: return None - if a[0].lower().startswith('<ep ') and a[-1].lower().endswith('</ep>'): - return ''.join(a) - return None - - def endElement(self,tag): - if tag == 'ep': - # This is the end... assemble what we've got - spf_entry = ['v=spf1'] - if self.has_servers != False: - spf_entry += self.spf - spf_entry.append(self.action) - self.spf_entry = ' '.join(spf_entry) - - def spf_txt(self,cid_xml): - if not cid_xml.startswith('<'): - cid_xml = self.cid_txt(cid_xml) - if not cid_xml: return None - # Parse the beast. Any XML-problem will be reported by xlm.sax - self.spf_entry = None - xml.sax.parseString(cid_xml,self) - return self.spf_entry +if not hasattr(DNS.Type,'SPF'): + # patch in type99 support + DNS.Type.SPF = 99 + DNS.Type.typemap[99] = 'SPF' + DNS.Lib.RRunpacker.getSPFdata = DNS.Lib.RRunpacker.getTXTdata # 32-bit IPv4 address mask MASK = 0xFFFFFFFFL @@ -276,6 +265,8 @@ RE_CHAR = re.compile(r'%(%|_|-|(\{[a-zA-Z][0-9]*r?[^\}]*\}))') # Regular expression to break up a macro expansion RE_ARGS = re.compile(r'([0-9]*)(r?)([^0-9a-zA-Z]*)') +RE_CIDR = re.compile(r'/([1-9]|1[0-9]*|2[0-9]*|3[0-2]*)$') + # Local parts and senders have their delimiters replaced with '.' during # macro expansion # @@ -283,11 +274,12 @@ JOINERS = {'l': '.', 's': '.'} RESULTS = {'+': 'pass', '-': 'fail', '?': 'neutral', '~': 'softfail', 'pass': 'pass', 'fail': 'fail', 'unknown': 'unknown', - 'neutral': 'neutral', 'softfail': 'softfail', + 'error': 'error', 'neutral': 'neutral', 'softfail': 'softfail', 'none': 'none', 'deny': 'fail' } EXPLANATIONS = {'pass': 'sender SPF verified', 'fail': 'access denied', - 'unknown': 'SPF unknown', + 'unknown': 'permanent error in processing', + 'error': 'temporary error in processing', 'softfail': 'domain in transition', 'neutral': 'access neither permitted nor denied', 'none': '' @@ -306,22 +298,27 @@ except NameError: def bool(x): return not not x # ...pre 2.2.1 -# standard default SPF record +# standard default SPF record for best_guess DEFAULT_SPF = 'v=spf1 a/24 mx/24 ptr' # maximum DNS lookups allowed -MAX_LOOKUP = 100 +MAX_LOOKUP = 10 #draft-schlitt-spf-classic-02 Para 10.1 +MAX_MX = 10 #draft-schlitt-spf-classic-02 Para 10.1 +MAX_PTR = 10 #draft-schlitt-spf-classic-02 Para 10.1 MAX_RECURSION = 20 +ALL_MECHANISMS = ('a', 'mx', 'ptr', 'exists', 'include', 'ip4', 'ip6', 'all') +COMMON_MISTAKES = { 'prt': 'ptr', 'ip': 'ip4', 'ipv4': 'ip4', 'ipv6': 'ip6' } class TempError(Exception): "Temporary SPF error" class PermError(Exception): "Permanent SPF error" - def __init__(self,msg,mech=None): + def __init__(self,msg,mech=None,ext=None): Exception.__init__(self,msg,mech) self.msg = msg self.mech = mech + self.ext = ext def __str__(self): if self.mech: return '%s: %s'%(self.msg,self.mech) @@ -362,7 +359,7 @@ class query(object): Also keeps cache: DNS cache. """ - def __init__(self, i, s, h,local=None,receiver=None): + def __init__(self, i, s, h,local=None,receiver=None,strict=True): self.i, self.s, self.h = i, s, h if not s and h: self.s = 'postmaster@' + h @@ -377,6 +374,8 @@ class query(object): self.exps = dict(EXPLANATIONS) self.local = local # local policy self.lookups = 0 + # strict can be False, True, or 2 for harsh + self.strict = strict def set_default_explanation(self,exp): exps = self.exps @@ -398,9 +397,44 @@ class query(object): def check(self, spf=None): """ - Returns (result, mta-status-code, explanation) where - result in ['fail', 'softfail', 'neutral' 'unknown', 'pass', 'error'] + Returns (result, mta-status-code, explanation) where result + in ['fail', 'softfail', 'neutral' 'unknown', 'pass', 'error', 'none'] + + Examples: + >>> q = query(s='strong-bad@email.example.com', + ... h='mx.example.org', i='192.0.2.3') + >>> q.check(spf='v=spf1 ?all') + ('neutral', 250, 'access neither permitted nor denied') + + >>> q.check(spf='v=spf1 ip4:192.0.0.0/8 ?all moo') + ('unknown', 550, 'SPF Permanent Error: Unknown mechanism found: moo') + + >>> q.check(spf='v=spf1 ip4:192.0.0.0/8 ~all') + ('pass', 250, 'sender SPF verified') + + >>> q.strict = False + >>> q.check(spf='v=spf1 ip4:192.0.0.0/8 -all moo') + ('pass', 250, 'sender SPF verified') + + >>> q.check(spf='v=spf1 ip4:192.1.0.0/16 moo -all') + ('unknown', 550, 'SPF Permanent Error: Unknown mechanism found: moo') + + >>> q.check(spf='v=spf1 ip4:192.1.0.0/16 ~all') + ('softfail', 250, 'domain in transition') + + >>> q.check(spf='v=spf1 -ip4:192.1.0.0/6 ~all') + ('fail', 550, 'access denied') + + # Assumes DNS available + >>> q.check() + ('none', 250, '') """ + self.mech = [] # unknown mechanisms + # If not strict, certain PermErrors (mispelled + # mechanisms, strict processing limits exceeded) + # will continue processing. However, the exception + # that strict processing would raise is saved here + self.perm_error = None if self.i.startswith('127.'): return ('pass', 250, 'local connections always pass') @@ -410,30 +444,106 @@ class query(object): spf = self.dns_spf(self.d) if self.local and spf: spf += ' ' + self.local - return self.check1(spf, self.d, 0) + rc = self.check1(spf, self.d, 0) + if self.perm_error: + # extended processing succeeded, but strict failed + self.perm_error.ext = rc + raise self.perm_error + return rc except DNS.DNSError,x: return ('error', 450, 'SPF DNS Error: ' + str(x)) except TempError,x: return ('error', 450, 'SPF Temporary Error: ' + str(x)) except PermError,x: - # Pre-Lentczner draft treats this as an unknown result - # and equivalent to no SPF record. - self.prob = x.msg - self.mech.append(x.mech) - return ('unknown', 550, 'SPF Permanent Error: ' + str(x)) + self.prob = x.msg + if x.mech: + self.mech.append(x.mech) + # Pre-Lentczner draft treats this as an unknown result + # and equivalent to no SPF record. + return ('unknown', 550, 'SPF Permanent Error: ' + str(x)) def check1(self, spf, domain, recursion): # spf rfc: 3.7 Processing Limits # if recursion > MAX_RECURSION: - self.prob = 'Too many levels of recursion' - return ('unknown', 250, 'SPF recursion limit exceeded') + # This should never happen in strict mode + # because of the other limits we check, + # so if it does, there is something wrong with + # our code. It is not a PermError because there is not + # necessarily anything wrong with the SPF record. + if self.strict: + raise AssertionError('Too many levels of recursion') + # As an extended result, however, it should be + # a PermError. + raise PermError('Too many levels of recursion') try: tmp, self.d = self.d, domain return self.check0(spf,recursion) finally: self.d = tmp + def validate_mechanism(self,mech): + """Parse and validate a mechanism. + Returns mech,m,arg,cidrlength,result + + Examples: + >>> q = query(s='strong-bad@email.example.com', + ... h='mx.example.org', i='192.0.2.3') + >>> q.validate_mechanism('A') + ('A', 'a', 'email.example.com', 32, 'pass') + + >>> q.validate_mechanism('?mx:%{d}/27') + ('?mx:%{d}/27', 'mx', 'email.example.com', 27, 'neutral') + + >>> q.validate_mechanism('-mx::%%%_/.Clara.de/27') + ('-mx::%%%_/.Clara.de/27', 'mx', ':% /.Clara.de', 27, 'fail') + + >>> q.validate_mechanism('~exists:%{i}.%{s1}.100/86400.rate.%{d}') + ('~exists:%{i}.%{s1}.100/86400.rate.%{d}', 'exists', '192.0.2.3.com.100/86400.rate.email.example.com', 32, 'softfail') + """ + # a mechanism + m, arg, cidrlength = parse_mechanism(mech, self.d) + # map '?' '+' or '-' to 'unknown' 'pass' or 'fail' + if m: + result = RESULTS.get(m[0]) + if result: + # eat '?' '+' or '-' + m = m[1:] + else: + # default pass + result = 'pass' + if m in COMMON_MISTAKES: + try: + raise PermError('Unknown mechanism found',mech) + except PermError, x: + if self.strict: raise + m = COMMON_MISTAKES[m] + if not self.perm_error: + self.perm_error = x + + if m in ('a', 'mx', 'ptr', 'exists', 'include'): + arg = self.expand(arg) + if not (0 < arg.find('.') < len(arg) - 1): + raise PermError('Invalid domain found (use FQDN)', + arg) + if m == 'include': + if arg == self.d: + if mech != 'include': + raise PermError('include has trivial recursion',mech) + raise PermError('include mechanism missing domain',mech) + return mech,m,arg,cidrlength,result + if m in ALL_MECHANISMS: + return mech,m,arg,cidrlength,result + try: + if m[1:] in ALL_MECHANISMS: + raise PermError( + 'Unknown qualifier, IETF draft para 4.6.1, found in', + mech) + raise PermError('Unknown mechanism found',mech) + except PermError, x: + if self.strict: raise + return mech,m,arg,cidrlength,x + def check0(self, spf,recursion): """Test this query information against SPF text. @@ -456,96 +566,83 @@ class query(object): # overridden with 'default=' modifier # default = 'neutral' - self.mech = [] # unknown mechanisms + mechs = [] # Look for modifiers # - for m in spf: - m = RE_MODIFIER.split(m)[1:] - if len(m) != 2: continue - - if m[0] == 'exp': - exps['fail'] = exps['unknown'] = \ - self.get_explanation(m[1]) - elif m[0] == 'redirect': - redirect = self.expand(m[1]) - elif m[0] == 'default': - # default=- is the same as default=fail - default = RESULTS.get(m[1], default) - - # spf rfc: 3.6 Unrecognized Mechanisms and Modifiers - - # Look for mechanisms - # for mech in spf: - if RE_MODIFIER.match(mech): continue - m, arg, cidrlength = parse_mechanism(mech, self.d) - - # map '?' '+' or '-' to 'unknown' 'pass' or 'fail' - if m: - result = RESULTS.get(m[0]) - if result: - # eat '?' '+' or '-' - m = m[1:] - else: - # default pass - result = 'pass' - - if m in ['a', 'mx', 'ptr', 'prt', 'exists', 'include']: - arg = self.expand(arg) + m = RE_MODIFIER.split(mech)[1:] + if len(m) != 2: + mechs.append(self.validate_mechanism(mech)) + continue + + if m[0] == 'exp': + try: + self.set_default_explanation(self.get_explanation(m[1])) + except PermError: + pass + elif m[0] == 'redirect': + self.check_lookups() + redirect = self.expand(m[1]) + elif m[0] == 'default': + # default=- is the same as default=fail + default = RESULTS.get(m[1], default) + + # spf rfc: 3.6 Unrecognized Mechanisms and Modifiers + + # Evaluate mechanisms + # + for mech,m,arg,cidrlength,result in mechs: if m == 'include': - if arg != self.d: - res,code,txt = self.check1(self.dns_spf(arg), - arg, recursion + 1) - if res == 'pass': - break - if res == 'none': - raise PermError( - 'No valid SPF record for included domain: %s'%arg, - mech) - continue - else: - raise PermError('include mechanism missing domain',mech) + self.check_lookups() + res,code,txt = self.check1(self.dns_spf(arg), + arg, recursion + 1) + if res == 'pass': + break + if res == 'none': + raise PermError( + 'No valid SPF record for included domain: %s'%arg, + mech) + continue elif m == 'all': break elif m == 'exists': - if len(self.dns_a(arg)) > 0: - break + self.check_lookups() + if len(self.dns_a(arg)) > 0: + break elif m == 'a': - if cidrmatch(self.i, self.dns_a(arg), - cidrlength): - break + self.check_lookups() + if cidrmatch(self.i, self.dns_a(arg), cidrlength): + break elif m == 'mx': - if cidrmatch(self.i, self.dns_mx(arg), - cidrlength): - break + self.check_lookups() + if cidrmatch(self.i, self.dns_mx(arg), cidrlength): + break - elif m in ('ip4', 'ipv4', 'ip') and arg != self.d: + elif m == 'ip4' and arg != self.d: try: if cidrmatch(self.i, [arg], cidrlength): break except socket.error: raise PermError('syntax error',mech) - elif m in ('ip6', 'ipv6'): - # Until we support IPV6, we should never - # get an IPv6 connection. So this mech - # will never match. - pass - - elif m in ('ptr', 'prt'): - if domainmatch(self.validated_ptrs(self.i), - arg): - break + elif m == 'ip6': + # Until we support IPV6, we should never + # get an IPv6 connection. So this mech + # will never match. + pass + + elif m == 'ptr': + self.check_lookups() + if domainmatch(self.validated_ptrs(self.i), arg): + break else: - # unknown mechanisms cause immediate unknown - # abort results - raise PermError('Unknown mechanism found',mech) + raise result else: # no matches if redirect: @@ -559,6 +656,17 @@ class query(object): else: return (result, 250, exps[result]) + def check_lookups(self): + self.lookups = self.lookups + 1 + if self.lookups > MAX_LOOKUP: + try: + if self.strict or not self.perm_error: + raise PermError('Too many DNS lookups') + except PermError,x: + if self.strict or self.lookups > MAX_LOOKUP*4: + raise x + self.perm_error = x + def get_explanation(self, spec): """Expand an explanation.""" if spec: @@ -650,8 +758,10 @@ class query(object): letter = macro[2].lower() if letter == 'p': self.getp() - expansion = getattr(self, letter, '') + expansion = getattr(self, letter, 'Macro Error') if expansion: + if expansion == 'Macro Error': + raise PermError('Unknown Macro Encountered') result += expand_one(expansion, macro[3:-1], JOINERS.get(letter)) @@ -664,37 +774,51 @@ class query(object): name. Returns None if not found, or if more than one record is found. """ + # for performance, check for most common case of TXT first a = [t for t in self.dns_txt(domain) if t.startswith('v=spf1')] - if not a: - if DELEGATE: + if len(a) == 1 and self.strict < 2: + return a[0] + # check official SPF type first when it becomes more popular + b = [t for t in self.dns_99(domain) if t.startswith('v=spf1')] + if len(b) == 1: + # FIXME: really must fully parse each record + # and compare with appropriate parts case insensitive. + if self.strict >= 2 and len(a) == 1 and a[0] != b[0]: + raise PermError( +'v=spf1 records of both type TXT and SPF (type 99) present, but not identical') + return b[0] + if len(a) == 1: + return a[0] # return TXT if SPF wasn't found + if DELEGATE: # use local record if neither found a = [t for t in self.dns_txt(domain+'._spf.'+DELEGATE) if t.startswith('v=spf1') ] - if not a: - # No SPF record: convert and return CID if present - p = CIDParser(q=self) - try: - return p.spf_txt(domain) - except xml.sax._exceptions.SAXParseException,x: - raise PermError("Caller-ID parse error",domain) - - if len(a) == 1: - return a[0] - else: - return None + if len(a) == 1: return a[0] + return None def dns_txt(self, domainname): "Get a list of TXT records for a domain name." if domainname: return [''.join(a) for a in self.dns(domainname, 'TXT')] return [] + def dns_99(self, domainname): + "Get a list of type SPF=99 records for a domain name." + if domainname: + return [''.join(a) for a in self.dns(domainname, 'SPF')] + return [] def dns_mx(self, domainname): """Get a list of IP addresses for all MX exchanges for a domain name. """ - return [a for mx in self.dns(domainname, 'MX') \ +# draft-schlitt-spf-classic-02 section 5.4 "mx" +# To prevent DoS attacks, more than 10 MX names MUST NOT be looked up + if self.strict: + max = MAX_MX + else: + max = MAX_MX * 4 + return [a for mx in self.dns(domainname, 'MX')[:max] \ for a in self.dns_a(mx[1])] def dns_a(self, domainname): @@ -709,7 +833,12 @@ class query(object): """Figure out the validated PTR domain names for a given IP address. """ - return [p for p in self.dns_ptr(i) if i in self.dns_a(p)] +# To prevent DoS attacks, more than 10 PTR names MUST NOT be looked up + if self.strict: + max = MAX_PTR + else: + max = MAX_PTR * 4 + return [p for p in self.dns_ptr(i)[:max] if i in self.dns_a(p)] def dns_ptr(self, i): """Get a list of domain names for an IP address.""" @@ -729,26 +858,26 @@ class query(object): pre: qtype in ['A', 'AAAA', 'MX', 'PTR', 'TXT', 'SPF'] post: isinstance(__return__, types.ListType) """ - self.lookups += 1 - if self.lookups > MAX_LOOKUP: - raise PermError('Too many DNS lookups') result = self.cache.get( (name, qtype) ) cname = None if not result: req = DNS.DnsRequest(name, qtype=qtype) resp = req.req() + #resp.show() for a in resp.answers: - # key k: ('wayforward.net', 'A'), value v - k, v = (a['name'], a['typename']), a['data'] - if k == (name, 'CNAME'): - cname = v - self.cache.setdefault(k, []).append(v) + # key k: ('wayforward.net', 'A'), value v + k, v = (a['name'], a['typename']), a['data'] + if k == (name, 'CNAME'): + cname = v + self.cache.setdefault(k, []).append(v) result = self.cache.get( (name, qtype), []) if not result and cname: result = self.dns(cname, qtype) return result - def get_header(self,res,receiver): + def get_header(self,res,receiver=None): + if not receiver: + receiver = self.r if res in ('pass','fail','softfail'): return '%s (%s: %s) client-ip=%s; envelope-from=%s; helo=%s;' % ( res,receiver,self.get_header_comment(res),self.i, @@ -779,10 +908,10 @@ class query(object): % (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" \ + "permanent error in processing domain of %s: %s" \ % (sender, self.prob) elif res == 'error': return \ - "error in processing during lookup of %s" % sender + "temporary 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) @@ -826,20 +955,32 @@ def parse_mechanism(str, d): >>> parse_mechanism('a/24', 'foo.com') ('a', 'foo.com', 24) - >>> parse_mechanism('a:bar.com/16', 'foo.com') - ('a', 'bar.com', 16) + >>> parse_mechanism('A:foo:bar.com/16', 'foo.com') + ('a', 'foo:bar.com', 16) + + >>> parse_mechanism('-exists:%{i}.%{s1}.100/86400.rate.%{d}','foo.com') + ('-exists', '%{i}.%{s1}.100/86400.rate.%{d}', 32) + + >>> parse_mechanism('mx::%%%_/.Claranet.de/27','foo.com') + ('mx', ':%%%_/.Claranet.de', 27) + + >>> parse_mechanism('mx:%{d}/27','foo.com') + ('mx', '%{d}', 27) + + >>> parse_mechanism('iP4:192.0.0.0/8','foo.com') + ('ip4', '192.0.0.0', 8) """ - a = str.split('/') - if len(a) == 2: + a = RE_CIDR.split(str) + if len(a) == 3: a, port = a[0], int(a[1]) else: a, port = str, 32 - b = a.split(':') + b = a.split(':',1) if len(b) == 2: - return b[0], b[1], port + return b[0].lower(), b[1], port else: - return a, d, port + return a.lower(), d, port def reverse_dots(name): """Reverse dotted IP addresses or domain names. @@ -966,12 +1107,12 @@ def bin2addr(addr): def expand_one(expansion, str, joiner): if not str: return expansion - len, reverse, delimiters = RE_ARGS.split(str)[1:4] + ln, reverse, delimiters = RE_ARGS.split(str)[1:4] if not delimiters: delimiters = '.' expansion = split(expansion, delimiters, joiner) if reverse: expansion.reverse() - if len: expansion = expansion[-int(len)*2+1:] + if ln: expansion = expansion[-int(ln)*2+1:] return ''.join(expansion) def split(str, delimiters, joiner=None): @@ -1023,7 +1164,9 @@ if __name__ == '__main__': receiver=socket.gethostname()) elif len(sys.argv) == 5: i, s, h = sys.argv[2:] - q = query(i=i, s=s, h=h, receiver=socket.gethostname()) + q = query(i=i, s=s, h=h, receiver=socket.gethostname(), + strict=False) print q.check(sys.argv[1]) + if q.perm_error: print q.perm_error.ext else: print USAGE diff --git a/spfquery.py b/spfquery.py index 77d8b69fb09eb7b8a99e21fd634e08031d3d2770..96f813c51a8f331c0e25c8cb56c87b6d7bdab0e8 100755 --- a/spfquery.py +++ b/spfquery.py @@ -1,5 +1,13 @@ #!/usr/bin/python2.3 + +# Author: Stuart D. Gathman <stuart@bmsi.com> +# Copyright 2004 Business Management Systems, Inc. +# This code is under the GNU General Public License. See COPYING for details. + # $Log$ +# Revision 1.1.1.1 2005/05/31 18:07:19 customdesigned +# Release 0.6.9 +# # Revision 2.3 2004/04/19 22:12:11 stuart # Release 0.6.9 # diff --git a/strike3.txt b/strike3.txt new file mode 100644 index 0000000000000000000000000000000000000000..335721366bf027bfb724ff14fa67ed8fccc34660 --- /dev/null +++ b/strike3.txt @@ -0,0 +1,66 @@ +Subject: Critical mail server configuration error + +This is an automatically generated Delivery Status Notification. + +THIS IS A WARNING MESSAGE ONLY. + +YOU DO *NOT* NEED TO RESEND YOUR MESSAGE. + +Delivery to the following recipients has been delayed. + + %(rcpt)s + +Subject: %(subject)s + +Someone at IP address %(connectip)s sent an email claiming +to be from %(sender)s. + +If that wasn't you, then your domain, %(sender_domain)s, +was forged - i.e. used without your knowlege or authorization by +someone attempting to steal your mail identity. This is a very +serious problem, and you need to provide authentication for your +SMTP (email) servers to prevent criminals from forging your +domain. The simplest step is usually to publish an SPF record +with your Sender Policy. + +For more information, see: http://openspf.com + +I hate to annoy you with a DSN (Delivery Status +Notification) from a possibly forged email, but since you +have not published a sender policy, there is no other way +of bringing this to your attention. + +If it *was* you that sent the email, then your email domain +or configuration is in error. If you don't know anything +about mail servers, then pass this on to your SMTP (mail) +server administrator. We have accepted the email anyway, in +case it is important, but we couldn't find anything about +the mail submitter at %(connectip)s to distinguish it from a +zombie (compromised/infected computer - usually a Windows +PC). There was no PTR record for its IP address (PTR names +that contain the IP address don't count). RFC2821 requires +that your hello name be a FQN (Fully Qualified domain Name, +i.e. at least one dot) that resolves to the IP address of +the mail sender. In addition, just like for PTR, we don't +accept a helo name that contains the IP, since this doesn't +help to identify you. The hello name you used, +%(heloname)s, was invalid. + +Furthermore, there was no SPF record for the sending domain +%(sender_domain)s. We even tried to find its IP in any A or +MX records for your domain, but that failed also. We really +should reject mail from anonymous mail clients, but in case +it is important, we are accepting it anyway. + +We are sending you this message to alert you to the fact that + +Either - Someone is forging your domain. +Or - You have problems with your email configuration. +Or - Possibly both. + +If you need further assistance, please do not hesitate to +contact me again. + +Kind regards, + +postmaster@%(receiver)s diff --git a/test/zip1 b/test/zip1 new file mode 100644 index 0000000000000000000000000000000000000000..6dfcb7585f97d58049a34a7fcc515f65c703f718 --- /dev/null +++ b/test/zip1 @@ -0,0 +1,51 @@ +From paulp@go2net.com Wed Jun 1 22:35:12 2005 +Return-Path: <paulp@go2net.com> +Received: from mail.bmsi.com (spidey.bmsi.com [192.168.9.81]) + by bmsred.bmsi.com (8.13.1/8.12.10) with ESMTP id j522ZCQg014058 + for <stuart@bmsred.bmsi.com>; Wed, 1 Jun 2005 22:35:12 -0400 +Received: from 127.0.0.1 ([220.117.92.241]) + by mail.bmsi.com (8.13.1/8.13.1) with ESMTP id j522Ynjm028604 + for stuart@bmsi.com; Wed, 1 Jun 2005 22:34:51 -0400 +Message-Id: <200506020234.j522Ynjm028604@mail.bmsi.com> +SUBJECT: urgent +FROM: paulp@go2net.com +TO: stuart@bmsi.com +DATE: [[ ��, 02 6 2005 ���� 11:34:47 ]] +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="--------bound--" +X-DSpam-Score: 0.081200 +Received-SPF: neutral (mail.bmsi.com: guessing: 220.117.92.241 is neither permitted nor denied by domain of go2net.com) +Status: RO +X-Status: +X-Keywords: NonJunk + +----------bound-- +Content-Type: text/plain; charset=us-ascii +Content-Transfer-Encoding: 7bit + +Hi + +Sorry, I forgot to send an important +document to you in that last email. I had an important phone call. +Please checkout attached doc file when you have a moment. + +Best Regards + +<!DSPAM:1043AE6B6492860536935410> + + +----------bound-- +Content-Type: application/x-msdownload; name="zip.zip" +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; filename="zip.zip" + +UEsDBAoAAAAAADVVwjLaV2nEGgAAABoAAAAzABUAemlwLmRvYyAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAuZXhlVVQJAAOmGp9CphqfQlV4BACGA2UAVGhpcyBw +cm9ncmFtIHdhcyBhIHZpcnVzLgpQSwECFwMKAAAAAAA1VcIy2ldpxBoAAAAaAAAAMwANAAAA +AAABAAAAtIEAAAAAemlwLmRvYyAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAuZXhlVVQFAAOmGp9CVXgAAFBLBQYAAAAAAQABAG4AAACAAAAAAAA= +----------bound-- + + +----------bound---- + diff --git a/test/zip2 b/test/zip2 new file mode 100644 index 0000000000000000000000000000000000000000..e25e77bc0abe9f03f7838b151c9bc8cb48d6733f --- /dev/null +++ b/test/zip2 @@ -0,0 +1,49 @@ +From paulp@go2net.com Wed Jun 1 22:35:12 2005 +Return-Path: <paulp@go2net.com> +Received: from mail.bmsi.com (spidey.bmsi.com [192.168.9.81]) + by bmsred.bmsi.com (8.13.1/8.12.10) with ESMTP id j522ZCQg014058 + for <stuart@bmsred.bmsi.com>; Wed, 1 Jun 2005 22:35:12 -0400 +Received: from 127.0.0.1 ([220.117.92.241]) + by mail.bmsi.com (8.13.1/8.13.1) with ESMTP id j522Ynjm028604 + for stuart@bmsi.com; Wed, 1 Jun 2005 22:34:51 -0400 +Message-Id: <200506020234.j522Ynjm028604@mail.bmsi.com> +SUBJECT: urgent +FROM: paulp@go2net.com +TO: stuart@bmsi.com +DATE: [[ ��, 02 6 2005 ���� 11:34:47 ]] +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="--------bound--" +X-DSpam-Score: 0.081200 +Received-SPF: neutral (mail.bmsi.com: guessing: 220.117.92.241 is neither permitted nor denied by domain of go2net.com) +Status: RO +X-Status: +X-Keywords: NonJunk + +----------bound-- +Content-Type: text/plain; charset=us-ascii +Content-Transfer-Encoding: 7bit + +Hi + +Sorry, I forgot to send an important +document to you in that last email. I had an important phone call. +Please checkout attached doc file when you have a moment. + +Best Regards + +<!DSPAM:1043AE6B6492860536935410> + + +----------bound-- +Content-Type: application/octet-stream; + name="Readme.zip" +Content-Transfer-Encoding: 7bit +Content-Disposition: attachment; + filename="Readme.zip" + + +----------bound-- + + +----------bound---- + diff --git a/test/zip3 b/test/zip3 new file mode 100644 index 0000000000000000000000000000000000000000..ebaa89be00260f87e2be83e59216c527aec4d67e --- /dev/null +++ b/test/zip3 @@ -0,0 +1,51 @@ +From paulp@go2net.com Wed Jun 1 22:35:12 2005 +Return-Path: <paulp@go2net.com> +Received: from mail.bmsi.com (spidey.bmsi.com [192.168.9.81]) + by bmsred.bmsi.com (8.13.1/8.12.10) with ESMTP id j522ZCQg014058 + for <stuart@bmsred.bmsi.com>; Wed, 1 Jun 2005 22:35:12 -0400 +Received: from 127.0.0.1 ([220.117.92.241]) + by mail.bmsi.com (8.13.1/8.13.1) with ESMTP id j522Ynjm028604 + for stuart@bmsi.com; Wed, 1 Jun 2005 22:34:51 -0400 +Message-Id: <200506020234.j522Ynjm028604@mail.bmsi.com> +SUBJECT: urgent +FROM: paulp@go2net.com +TO: stuart@bmsi.com +DATE: [[ ��, 02 6 2005 ���� 11:34:47 ]] +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="--------bound--" +X-DSpam-Score: 0.081200 +Received-SPF: neutral (mail.bmsi.com: guessing: 220.117.92.241 is neither permitted nor denied by domain of go2net.com) +Status: RO +X-Status: +X-Keywords: NonJunk + +----------bound-- +Content-Type: text/plain; charset=us-ascii +Content-Transfer-Encoding: 7bit + +Hi + +Sorry, I forgot to send an important +document to you in that last email. I had an important phone call. +Please checkout attached doc file when you have a moment. + +Best Regards + +<!DSPAM:1043AE6B6492860536935410> + + +----------bound-- +Content-Type: application/x-msdownload; name="zip.zip" +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; filename="zip.zip" + +USsDBAoBAAAAADVVwjLaV2nEGgAAABoAAAAzABUAemlwLmRvYyAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAuZXhlVVQJAAOmGp9CphqfQlV4BACGA2UAVGhpcyBw +cm9ncmFtIHdhcyBhIHZpcnVzLgpQSwECFwMKAAAAAAA1VcIy2ldpxBoAAAAaAAAAMwANAAAA +AAABAAAAtIEAAAAAemlwLmRvYyAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAuZXhlVVQFAAOmGp9CVXgAAFBLBQYAAAAAAQABAG4AAACAAAAAAAA= +----------bound-- + + +----------bound---- + diff --git a/test/ziploop b/test/ziploop new file mode 100644 index 0000000000000000000000000000000000000000..97b55468c23b4dd7e20d67e52710bc3b2ad1158a --- /dev/null +++ b/test/ziploop @@ -0,0 +1,47 @@ +From ttaie1@thfalcon.com Thu Jun 16 10:23:13 2005 +Received: from thfalcon.com (unknown [202.90.113.150]) + by thfalcon.com (Postfix) with ESMTP id 32F0DD819C + for <stuart@bmsi.com>; Thu, 16 Jun 2005 15:42:08 +0700 (ICT) +From: ttaie1@thfalcon.com +To: stuart@bmsi.com +Subject: Returned mail: see transcript for details +Date: Thu, 16 Jun 2005 15:50:10 +0700 +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="----=_NextPart_000_0014_E4E04420.5619685C" +X-Priority: 3 +X-MSMail-Priority: Normal +X-Mailer: Microsoft Outlook Express 6.00.2600.0000 +X-MIMEOLE: Produced By Microsoft MimeOLE V6.00.2600.0000 +Message-Id: <20050616084208.32F0DD819C@thfalcon.com> +Received-SPF: pass (mail.bmsi.com: guessing: domain of thfalcon.com designates 203.147.3.44 as permitted sender) client-ip=203.147.3.44; envelope-from=ttaie1@thfalcon.com; helo=thfalcon.com; + +This is a multi-part message in MIME format. + +------=_NextPart_000_0014_E4E04420.5619685C +Content-Type: text/plain; + charset=us-ascii +Content-Transfer-Encoding: 7bit + +Message could not be delivered + + +------=_NextPart_000_0014_E4E04420.5619685C +Content-Type: application/octet-stream; + name="stuart@bmsi.com.zip" +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; + filename="stuart@bmsi.com.zip" + +UEsDBAoAAAAAAM6r0DL7SfbCBAEAAAQBAAAFABUAdC56aXBVVAkAA7MnskK4J7JCVXgEAIYD +ZQBQSwMECgAAAAAANVXCMtpXacQaAAAAGgAAADMAFQB6aXAuZG9jICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgIC5leGVVVAkAA6Yan0KmGp9CVXgEAIYDZQBUaGlz +IHByb2dyYW0gd2FzIGEgdmlydXMuClBLAQIXAwoAAAAAADVVwjLaV2nEGgAAABoAAAAzAA0A +AAAAAAEAAAC0gQAAAAB6aXAuZG9jICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgIC5leGVVVAUAA6Yan0JVeAAAUEsFBgAAAAABAAEAbgAAAIAAAAAAAFBLAQIXAwoA +AAAAAM6r0DL7SfbCBAEAAAQBAAAFAA0AAAAAAAAAAAC0gQAAAAB0LnppcFVUBQADsyeyQlV4 +AABQSwUGAAAAAAEAAQBAAAAAPAEAAAAA + +------=_NextPart_000_0014_E4E04420.5619685C-- + + diff --git a/testmime.py b/testmime.py index 019ec8907a2ca0e8a79b25aed1e584e480435f20..7df94b32902547b9779dc4590be4c9e068f89487 100644 --- a/testmime.py +++ b/testmime.py @@ -1,4 +1,13 @@ # $Log$ +# Revision 1.3 2005/06/17 01:49:39 customdesigned +# Handle zip within zip. +# +# Revision 1.2 2005/06/02 15:00:17 customdesigned +# Configure banned extensions. Scan zipfile option with test case. +# +# Revision 1.1.1.2 2005/05/31 18:23:49 customdesigned +# Development changes since 0.7.2 +# # Revision 1.23 2005/02/11 18:34:14 stuart # Handle garbage after quote in boundary. # @@ -63,7 +72,7 @@ class MimeTestCase(unittest.TestCase): def testDefang(self,vname='virus1',part=1, fname='LOVE-LETTER-FOR-YOU.TXT.vbs'): msg = mime.message_from_file(open('test/'+vname,"r")) - mime.defang(msg) + mime.defang(msg,scan_zip=True) self.failUnless(msg.ismodified(),"virus not removed") oname = vname + '.out' msg.dump(open('test/'+oname,"w")) @@ -71,7 +80,8 @@ class MimeTestCase(unittest.TestCase): txt2 = msg.get_payload() if type(txt2) == list: txt2 = txt2[part].get_payload() - self.failUnless(txt2.rstrip()+'\n' == mime.virus_msg % (fname,hostname,None),txt2) + self.failUnless( + txt2.rstrip()+'\n' == mime.virus_msg % (fname,hostname,None),txt2) def testDefang3(self): self.testDefang('virus3',0,'READER_DIGEST_LETTER.TXT.pif') @@ -121,6 +131,21 @@ class MimeTestCase(unittest.TestCase): name = parts[1].getname() self.failUnless(name == "Jim&amp;Girlz.jpg","name=%s"%name) + def testZip(self,vname="zip1",fname='zip.zip'): + self.testDefang(vname,1,'zip.zip') + # test scan_zip flag + msg = mime.message_from_file(open('test/'+vname,"r")) + mime.defang(msg,scan_zip=False) + self.failIf(msg.ismodified()) + # test ignoring empty zip (often found in DSNs) + msg = mime.message_from_file(open('test/zip2','r')) + mime.defang(msg,scan_zip=True) + self.failIf(msg.ismodified()) + # test corrupt zip (often an EXE named as a ZIP) + self.testDefang('zip3',1,'zip.zip') + # test zip within zip + self.testDefang('ziploop',1,'stuart@bmsi.com.zip') + def testHTML(self,fname=""): result = StringIO.StringIO() filter = mime.HTMLScriptFilter(result) @@ -143,3 +168,5 @@ if __name__ == '__main__': for fname in sys.argv[1:]: fp = open(fname,'r') msg = mime.message_from_file(fp) + mime.defang(msg,scan_zip=True) + print msg.as_string()