Bogofilter und Exim über local_scan()

Im Netz finden sich meistens nur Anleitungen, wie man Bogofilter in einen Exim-Transport einbindet. Leider werden dadurch Spam-E-Mails erst einmal angenommen. Da späteres bouncen durch die meist gefakten Absender-Adressen nicht infrage kommt, steht man vor einem Dilemma. Abhilfe schafft man sich dadurch, dass man Bogofilter in einer local_scan()-Routine noch während des SMTP-Dialogs aufruft und anhand des Ergebnisses die Spam-E-Mail von vorneherein nicht annimmt.

Warum Bogofilter und nicht Spamassassin?

Bogofilter performt erheblich besser als Spamassassin. In der Regel läuft die Überprüfung einer E-Mail in t < 1sec ab. Der Speicherbedarf dürfte ebenfalls geringer sein. Ist Bogofilter gut trainiert, nehmen sich die Erkennungsraten gegenüber Spamassassin ebenfalls nichts.

Das Konzept

Exim hat leider keine Schnittstelle, um in acl_smtp_data eine komplette Mail an ein externes Kommando zu pipen und die Rückgabe auszuwerten. Stattdessen muss man eine local_scan()-Funktion schreiben, die dies erledigt.

Aus Bequemlichkeitsgründen habe ich py-exim-localscan verwendet, welches mir die Möglichkeit gibt, ein externes Python-Script über local_scan() laufen zu lassen. Damit vergibt man sich ein wenig Geschwindigkeit und Speichereffizienz, gewinnt aber viel beim Schreiben und Debuggen des Scriptes. Performanceengpässe sind bei meinen ∅ 2 Spam-Mails pro Minute nicht zu beobachten (V-Server mit FreeBSD und 512MB RAM).

Das Script macht nun nichts anderes, als die Nachricht von Exim abzugreifen, Bogofilter drüberlaufen zu lassen, die Rückgabe auszuwerten und entsprechende Werte zurückzuliefern. Dies sind zusätzliche Mailheader, Zeilen fürs Logfile und Returncodes (ACCEPT, REJECT).

Das Script

Produktiv läuft das Script mit Exim 4.69 und Python 2.6.1. Leider kann ich auf meinem System (FreeBSD 7.1) momentan subprocess.Popen nicht verwenden, da ein Bug dies effektiv verhindert. Daher der Umweg über temporäre Dateien.

# -*- coding: utf-8
 
import exim, os, re, time, traceback, sys, commands, hashlib, tempfile
from collections import defaultdict
 
hostname = os.uname()[1]
timestamp = str(time.time())
salt = 'Please replace with some random characters.'
debug = False
d = []
 
def local_scan():
    try:
	ret = bogoscan_message()
 
	if 'returnheader' in ret:
	    exim.add_header(ret['returnheader'])
	if 'logline' in ret:
	    exim.log(ret['logline'], ret['logdestination'])
 
	save_debug()
	return ret['local_scan_return'], ret['local_scan_return_message']
 
    except Exception as e:
	exim.log(str(e), exim.LOG_PANIC)
 
	tb = sys.exc_info()[2]
	tb_lines = traceback.format_list(traceback.extract_tb(tb))
	for line in tb_lines:
	    exim.log(line, exim.LOG_PANIC)
 
	return exim.LOCAL_SCAN_ACCEPT
 
def bogoscan_message():
    header, hashheader = get_header(['message-id', 'x-bogosity'])
 
    if already_scanned(hashheader):
	return {'local_scan_return': exim.LOCAL_SCAN_ACCEPT,
		'local_scan_return_message': 'Message already scanned by this host.'}
 
    body = get_body()
 
    scanresult, classification = get_scan_result(header, body)
 
    if classification == 'Spam':
	if exim.received_protocol == 'local':
	    return {'returnheader': build_returnheader(scanresult, hashheader),
		    'logline': 'Local injected Message classified as Spam.',
		    'logdestination': exim.LOG_MAIN,
		    'local_scan_return': exim.LOCAL_SCAN_ACCEPT,
		    'local_scan_return_message': 'Local injected Message classified as Spam.'}
	else:
	    return {'logline': 'Rejected by Bogofilter, %s' % scanresult,
		    'logdestination': exim.LOG_REJECT,
		    'local_scan_return': exim.LOCAL_SCAN_REJECT,
		    'local_scan_return_message': 'Message classified as Spam.'}
    elif classification == 'Ham' or classification == 'Unsure':
	return {'returnheader': build_returnheader(scanresult, hashheader),
		'local_scan_return': exim.LOCAL_SCAN_ACCEPT,
		'local_scan_return_message': 'Message passed through Spam-Filter.'}
    else:
	return {'logline': 'Bogofilter sent us an unexspected scanresult.',
		'logdestination':  exim.LOG_PANIC,
		'local_scan_return': exim.LOCAL_SCAN_ACCEPT,
		'local_scan_return_message': 'Bogofilter sent us an unexspected scanresult.'}
 
def get_scan_result(header, body):
#    can't communicate directly over pipes with subprocess.Popen,
#    until the following is fixed: http://bugs.python.org/issue1731717
 
    message_fd, message_file = tempfile.mkstemp(prefix='message')
    f = os.fdopen(message_fd, 'w')
    f.write(header + '\n' + body)
    f.close()
 
    result_fd, result_file = tempfile.mkstemp(prefix='result')
 
    os.system('/usr/local/bin/bogofilter -t -M < %s > %s' % (message_file, result_file))
 
    f = os.fdopen(result_fd, 'r')
    scanresult = f.read().strip()
    f.close()
 
    os.remove(message_file)
    os.remove(result_file)
 
    pattern = re.compile('(Ham|Spam|Unsure)(.*)', re.I)
    match = pattern.match(scanresult)
    if match:
	return scanresult, match.group(1)
    else:
	return None, ''
 
def get_header(extractheader):
    header = []
    hashheader = defaultdict(str)
    pattern = re.compile('(%s):(.*)' % '|'.join(extractheader), re.I | re.S)
    for h in exim.headers:
	if h.type != '*':
	    header.append(h.text)
	    match = pattern.match(h.text)
	    if match:
		hashheader[match.group(1).lower()] = match.group(2).strip()
 
    return ''.join(header), hashheader
 
def save_debug():
    if debug:
        debug_fd, debug_file = tempfile.mkstemp(prefix='debug')
        f = os.fdopen(debug_fd, 'w')
        f.write('\n'.join(d))
        f.close()
 
def get_body():
    body = []
    while 1:
	s = os.read(exim.fd, 16384)  # steps of 16KByte
	if not s or len(body) > 64: # feed only the first 1MByte into bogofilter
	    break
        body.append(s)
    return ''.join(body)
 
def already_scanned(hashheader):
    bogoheaderexists, bogoheaderparts = get_bogoheaderparts(hashheader['x-bogosity'])
    if bogoheaderexists and bogoheaderparts['hash'] != '' and bogoheaderparts['hash'] == get_hash(hashheader, bogoheaderparts):
	return True
    else:
	return False
 
def build_returnheader(scanresult, hashheader):
    bogoheaderexists, bogoheaderparts = get_bogoheaderparts(scanresult)
    bogoheaderparts['host'] = hostname 
    bogoheaderparts['timestamp'] = timestamp 
    return 'X-Bogosity: %s, host=%s, timestamp=%s, hash=%s' % (scanresult, hostname, timestamp, get_hash(hashheader, bogoheaderparts))
 
def get_bogoheaderparts(bogoheader):
    pattern = re.compile(r'(?P<classification>Ham|Spam|Unsure),(\s+)spamicity=(?P<spamicity>[01]\.\d{6}),(\s+)version=(?P<version>\d\.\d\.\d)(?:,(\s+)host=(?P<host>[a-z\.]+),(\s+)timestamp=(?P<timestamp>\d+\.\d+),(\s+)hash=(?P<hash>[a-f0-9]+))?', re.I | re.S)
    match = pattern.match(bogoheader)
    if match:
	bogoheaderparts = match.groupdict()
	for key in bogoheaderparts.iterkeys():
	    if bogoheaderparts[key] == None:
		bogoheaderparts[key] = ''
 
	return True, bogoheaderparts
 
    return False, None    
 
def get_hash(hashheader, bogoheaderparts):
    hashparts = [salt,
		 hashheader['message-id'],
		 bogoheaderparts['classification'], 
		 bogoheaderparts['spamicity'], 
		 bogoheaderparts['version'], 
		 bogoheaderparts['host'], 
		 bogoheaderparts['timestamp']]
    return hashlib.sha256('_'.join(hashparts)).hexdigest()
 
dokumentation/bogofilter_und_exim.txt · Zuletzt geändert: 2010/04/09 00:01 von alex