''' Policy scanners for sagator (c) 2005-2020 Jan ONDREJ (SAL) 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. ''' from __future__ import absolute_import from __future__ import print_function from avlib import * from .match import match_any __all__ = [ 'set_action', 'greylist', 'not_listed', 'listed', 'elisted', 'list_cleanup', 'auto_whitelist', 'spf_check', 'dns_check', 'rbl_check', 'add_listed', 'policy_quota_auth_limit', 'policy_quota_cleanup', 'geoip_country', 'geoip2_country' ] class set_action(match_any): ''' An interscanner to return a status to set an action to return. You can user this scanner to set default policy returned by policy scanner, or you can set policy according to scanner reply. Usage: set_action(action, *scanners) Where: action can be an action defined in postfix's SMTPD_POLICY_README, for example: dunno, defer_if_permit, ok, reject, ... New in version 0.8.0. ''' is_policy_scanner = True name = 'set_action()' def __init__(self, action, *scanners): self.ACTION = tobytes(action) match_any.__init__(self, scanners) def scanbuffer(self, buffer, args={}): if not self.scanners: globals.ACTION = self.ACTION return 0.0, b'', [] level, detected, ret = match_any.scanbuffer(self, buffer, args) if is_infected(level, detected): globals.ACTION = self.ACTION return level, detected, ret class greylist(ascanner): ''' A greylist policy scanner. This is an greylist implementation for sagator. You can learn more about greylisting at: http://www.salstar.sk/sagator/greylisting/ Usage: greylist(min=300, expire=48*3600, info_url="http://www.salstar.sk/sagator/greylisting/") Where: min is minimum required seconds after an email is cleanly sent (default: 5 minutes) expire is record expiration time in seconds (default 48 hours). If this record will be passed after min time, expiration time will be disabled. info_url is an string, which is displayed to greylisted clients Example: greylist() New in version 0.8.0. ''' is_policy_scanner = True name = 'greylist()' def __init__(self, min=300, expire=5*3600, info_url="http://www.salstar.sk/sagator/greylisting/"): self.MIN_DELAY = min self.EXPIRE_TIME = expire self.INFO_URL = info_url def get_key(self, dbc): key = [b'', b'', b''] try: key = [mail.policy_request.get(b'client_address'), mail.policy_request.get(b'sender'), mail.policy_request.get(b'recipient')] except: debug.echo(4, '%s: wrong request: %s' % (self.name, mail.policy_request)) raise return key def update_counter(self, dbc, cntr, flags, t0, key): dbc.execute_cycle( "UPDATE greylist SET %s_count=%s_count+1, last_update=%d" " WHERE flags=%%s AND ip=%%s AND sender=%%s AND recipient=%%s" % (cntr, cntr, t0), [flags]+key) def scanbuffer(self, buffer, args={}): key = self.get_key(args['dbc']) t0 = time.time() args['dbc'].refresh() q = args['dbc'].query( "SELECT timestamp,pass_count,expire FROM greylist" " WHERE flags='GL' AND ip=%s AND sender=%s AND recipient=%s", key) debug.echo(8, "%s: %s %s" % (self.name, key, q)) if q and (q[0][2]>t0 or q[0][2]<1): # one not-expired row returned # found a time in database t = q[0][0] if t0-t > self.MIN_DELAY: # delay is acceptable args['dbc'].execute_cycle( "UPDATE greylist SET expire=-1, last_update=%d," " pass_count=pass_count+1" " WHERE flags='GL' AND ip=%%s AND sender=%%s" " AND recipient=%%s" % (t0), key) # add header if this is first pass if q[0][1]==0: globals.PREPEND='X-Sagator-Greylist: delayed %d seconds at %s; %s' \ % (t0-t, socket.gethostname(), time.strftime("%c")) return 0.0, b'', [] else: self.update_counter(args['dbc'], 'block', 'GL', t0, key) elif q: # one row, but expired # remove all possible expired records t = t0 q = args['dbc'].execute_cycle( "UPDATE greylist SET" " timestamp=%d, expire=%d, created=%d, last_update=%d," " block_count=1, pass_count=1" " WHERE flags='GL' AND ip=%%s AND sender=%%s AND recipient=%%s" % (t0, t0+self.EXPIRE_TIME, t0, t0), key) else: # no rows (expired or not) # add new record for nonexistent key t = t0 try: args['dbc'].execute_cycle( "INSERT INTO greylist " "(flags,timestamp,expire,created,last_update," "ip,sender,recipient,block_count,pass_count)" " VALUES ('GL',%d,%d,%d,%d,%%s,%%s,%%s,1,0)" % (t0, t0+self.EXPIRE_TIME, t0, t0), key) except args['dbc'].IntegrityError: globals.ACTION = 'defer_if_permit Greylisted (parallel connect)' return 1.0, b'greylisted', ['%s greylisted' % tostr(key[2])] globals.ACTION = 'defer_if_permit Greylisted for %d seconds (see %s)' \ % (self.MIN_DELAY+t-t0, self.INFO_URL) return 1.0, b'greylisted', ['%s greylisted' % tostr(key[2])] class listed(greylist): ''' SQL blacklist for a policy. You can use this scanner to chcek, if an record is present in SQL table. Usage: listed(flags='B', table='greylist') Where: flags is an character, which defines which flag will be searched in SQL table. By default 'B' for this scanner. table can define table alternative Returns: level=1.0, when client is in blacklist level=0.0, when client is not in blacklist Example: listed() New in version 0.8.0. ''' name = 'listed()' def __init__(self, flags='B', repeats=0, table='greylist'): self.FLAGS = flags.upper() self.TABLE = table self.REPEATS = repeats def scanbuffer(self, buffer, args={}): args['dbc'].refresh() key = self.get_key(args['dbc']) t0 = time.time() args['dbc'].execute_cycle( "UPDATE %s SET block_count=block_count+1, last_update=%d" " WHERE (expire<%d) AND (repeats>=%d) AND (" " (flags='%sA' AND %%s LIKE ip) OR" " (flags='%sS' AND %%s LIKE sender) OR" " (flags='%sR' AND %%s LIKE recipient))" % (self.TABLE, t0, t0, self.REPEATS, self.FLAGS, self.FLAGS, self.FLAGS), key) if args['dbc'].rowcount>0: globals.ACTION = 'reject You are blacklisted!' return 1.0, b'blacklisted', [b'blacklisted'] return 0.0, b'', [] class elisted(greylist): ''' SQL blacklist for a policy. Enhanced version. You can use this scanner to chcek, if an record is present in SQL table. Usage: elisted(flags='B', table='greylist') Where: flags is an character, which defines which flag will be searched in SQL table. By default 'B' for this scanner. table can define table alternative Returns: level=1.0, when client is in blacklist level=0.0, when client is not in blacklist This is experimental and may change in future releases. Example: elisted() New in version 1.1.1. ''' name = 'elisted()' def __init__(self, flags='B', repeats=0, table='greylist'): self.FLAGS = flags.upper() self.TABLE = table self.REPEATS = repeats def scanbuffer(self, buffer, args={}): args['dbc'].refresh() key = self.get_key(args['dbc']) t0 = time.time() args['dbc'].execute_cycle( "UPDATE %s SET block_count=block_count+1, last_update=%d" " WHERE (expire<%d) AND (repeats>=%d) AND (flags='%s')" " AND (%%s LIKE ip)" " AND (%%s LIKE sender)" " AND (%%s LIKE recipient)" % (self.TABLE, t0, t0, self.REPEATS, self.FLAGS), key) if args['dbc'].rowcount>0: globals.ACTION = 'reject You are blacklisted!' return 1.0, b'blacklisted', [b'blacklisted'] return 0.0, b'', [] class not_listed(greylist): ''' SQL whitelist for a policy. Usage: not_listed(flags='W', table='greylist') Where: flags is an character, which defines which flag will be searched in SQL table. By default 'B' for this scanner. table can define table alternative Returns: level=1.0, when client is not in whitelist level=0.0, when client is in whitelist Example: not_listed() New in version 0.8.0. ''' name = 'not_listed()' def __init__(self, flags='W', repeats=0, table='greylist'): self.FLAGS = flags.upper() self.TABLE = table self.REPEATS = repeats def scanbuffer(self, buffer, args={}): args['dbc'].refresh() key = self.get_key(args['dbc']) t0 = time.time() args['dbc'].execute_cycle( "UPDATE %s SET pass_count=pass_count+1, last_update=%d" " WHERE (expire<%d) AND (repeats>=%d) AND (" " (flags='%sA' AND %%s LIKE ip) OR" " (flags='%sS' AND %%s LIKE sender) OR" " (flags='%sR' AND %%s LIKE recipient))" % (self.TABLE, t0, t0, self.REPEATS, self.FLAGS, self.FLAGS, self.FLAGS), key) if args['dbc'].rowcount>0: debug.echo(4, "%s: %s whitelisted" % (self.name, tostr_list(key))) return 0.0, b'', [] return 1.0, b'not_listed', [b'not listed'] class list_cleanup(greylist): ''' Cleanup obsolete records from an SQL list. This cleanup is done not more than every 10 minutes to avoid server overload. Usage: list_cleanup(lifetime=36*24*3600, table='greylist') Where: lifetime is number of seconds after which records are removed (36 days) table can define table alternative Example: list_cleanup() New in version 0.8.0. ''' name = 'list_cleanup()' INTERVAL = 600 # every 10-20 minutes (randomized) NEXT_ACTION = 0 # immediatelly def __init__(self, lifetime=36*24*3600, table='greylist'): self.LIFETIME = lifetime self.TABLE = table random.seed() def scanbuffer(self, buffer, args={}): t0 = time.time() if t0>self.NEXT_ACTION: self.NEXT_ACTION = t0 + self.INTERVAL + self.INTERVAL*random.random() args['dbc'].refresh() args['dbc'].execute_cycle( "DELETE FROM %s WHERE " "((last_update>1) AND (last_update<%d))" " OR ((expire>1) AND (expire<%d))" % (self.TABLE, t0-self.LIFETIME, t0)) if args['dbc'].rowcount: debug.echo(3, "%s: %d expired rows deleted in %5.3f s" % (self.name, args['dbc'].rowcount, time.time()-t0)) return 0.0, b'', [] class auto_whitelist(greylist): ''' Whitelist IP addresses after sending some emails properly. This whitelisting is done not more than every 10 minutes to avoid server overload. This function is slow for large number of records, use it carefully. Usage: auto_whitelist(match_count=10, table='greylist') Where: match_count is number of record which will must match table can define table alternative Example: auto_whitelist(10) New in version 0.8.0. ''' name = 'auto_whitelist()' INTERVAL = 600 # every 10 minutes def __init__(self, match_count=10, table='greylist'): self.MATCH_COUNT = match_count self.TABLE = table self.NEXT_ACTION = time.time() def scanbuffer(self, buffer, args={}): args['dbc'].refresh() t0 = time.time() if t0%d" \ % (self.TABLE, self.TABLE, self.MATCH_COUNT)): try: debug.echo(3, "%s: Whitelisting %s, repeats=%d" % (self.name, ip, repeats)) args['dbc'].execute_cycle( "INSERT INTO %s (flags,timestamp,expire,ip,created,last_update)" " VALUES ('WA',%d,-1,%%s,%d,%d)" % (self.TABLE, t0, t0, t0), [ip]) except Exception as e: # May be this record has been whitelisted from another process. # Ignore this error. debug.echo(8, "%s: Error auto-whitelisting %s: %s" \ % (self.name, ip, e)) return 0.0, b'', [] # DNS and SPF tests class dns_check(ascanner): ''' IP to domain (and back) resolving checker. This scanner can be used to check senders IP address to resolve via DNS service. It can be uses asn real scanner or policy scanner. Usage: dns_check(reverse=False) Where: reverse can fe used to check also reverse records Example: dns_check() New in version 0.8.0. ''' name = 'dns()' is_policy_scanner = True def __init__(self, reverse=False): self.REVERSE = reverse def getsender(self): # first try to use policy_request for policy scanners try: ip = mail.policy_request[b'client_address'] sender = mail.policy_request[b'sender'] helo = mail.policy_request[b'helo_name'] except KeyError: # use standard method for content scanner ip = mail.getsender()['ADDR'] sender = mail.sender try: helo = smtp.reg_helo_ehlo.search(mail.comm).group(1) except: helo = 'localhost' return ip, sender, helo def scanbuffer(self, buffer, args={}): ip, sender, helo = self.getsender() try: host = socket.gethostbyaddr(ip) debug.echo(4, "%s: %s" % (self.name, host)) if self.REVERSE: rhost = socket.gethostbyname(host[0]) debug.echo(4, "%s: %s" % (self.name, rhost)) except socket.herror as err: (ec, es) = err.args if ec==1: # Unknown host return 1.0, b"%s for %s" % (tobytes(es), tobytes(ip)), \ [es, ip, sender, helo] return 0.0, b'', [] class spf_check(dns_check): ''' SPF (Sender Permitted From) checker. This scanner can be used to scan for sender's SPF records in content scanner or policy scanner. Usage: spf_check(hard_error=False) Where: hard_error can be set to True, if you want errors raise as exceptions, by default it is turned off. Example: spf_check() New in version 0.8.0. ''' name = 'spf()' is_policy_scanner = True def __init__(self, hard_error=False): try: import spf except ImportError: print("You need pyspf (or python-spf) package to run spf_check() scanner.") sys.exit(1) self.SPF = spf self.HARD_ERROR = hard_error # preload IDNA encoding import encodings.idna def scanbuffer(self, buffer, args={}): ip, sender, helo = self.getsender() ip = tostr(ip) sender = tostr(sender) helo = tostr(helo) debug.echo(6, 'spf_check(): %s[%s] %s' % (ip, helo, sender)) result, status_code, explanation = self.SPF.check(ip, sender, helo) debug.echo(4, 'spf_check(): %s[%s] %s: %s %d %s' \ % (ip, helo, sender, result, status_code, explanation)) if result=='deny': return 1.0, b'spf_hard_fail', ['%s: %s' % (result, explanation)] elif result=='unknown': return 0.8, b'spf_soft_fail', ['%s: %s' % (result, explanation)] elif self.HARD_ERROR and (result=='error'): raise ScannerError('%s: %s' % (result, explanation)) return 0.0, b'', [] class rbl_check(dns_check): ''' RBL (Real-time Blackhole list) checker. This scanner can be used to scan for sender's IP address contained in a blacklist. Usage: rbl_check(domains) Where: domains are blacklist domain names (multiple parameters allowed). Example: rbl_check('zen.spamhaus.org') New in version 1.1.0. ''' name = 'rbl()' is_policy_scanner = True def __init__(self, *domains): self.DOMAINS = [tobytes(x) for x in domains] def scanbuffer(self, buffer, args={}): ip, sender, helo = self.getsender() debug.echo(6, '%s: %s' % (self.name, tostr(ip))) reversed_ip = b'.'.join(reversed(ip.split(b'.'))) addrs = [] for domain in self.DOMAINS: host = reversed_ip + b'.' + domain debug.echo(6, '%s: Testing %s ...' % (self.name, tostr(host))) try: addrs.append( "%s: %s" % ( tostr(domain), #socket.gethostbyname(host) socket.getaddrinfo(host, 25, socket.AF_INET)[0][4][0] ) ) except socket.gaierror as e: debug.echo(4, "%s: socket.gaierror: %s" % (self.name, e)) except socket.timeout as e: debug.echo(4, "%s: socket.timeout: %s" % (self.name, e)) except socket.error as e: debug.echo(4, "%s: socket.error: %s" % (self.name, e)) debug.echo(6, '%s: Result: %s' % (self.name, addrs)) if addrs: return len(addrs), b'Found_in_RBL', addrs return 0.0, b'', [] # This scanner is not a policy scanner, but it is a content scanner. # It can set a whitelist or blacklist for policy scanners. class add_listed(match_any): ''' Add one record into SQL list for a policy. Usage: add_listed(dbc, flags, expire, table, scanners) Where: dbc is an database connection flags is an string, which defines what and where to add. It can be: BA - to blacklist sender's IP address BS - to blacklist sender's email address BR - to blacklist recipients WA - to whitelist sender's IP address WS - to whitelist sender's email address WR - to whitelist recipients expire is an integer, which defines validity of a new record in seconds. Set it to -1 for infinite time. table can define table alternative Example: add_listed(db.sqlite(), 'BA', -1, 'greylist', b2f(libclam())) New in version 0.8.0. ''' name = 'add_listed()' def __init__(self, dbc, flags, expire=-1, table='greylist', *scanners): self.dbc = dbc self.FLAGS = flags.upper() self.EXPIRE_TIME = expire self.TABLE = table match_any.__init__(self, scanners) def scanbuffer(self, buffer, args={}): level, detected, ret = match_any.scanbuffer(self, buffer, args) if not is_infected(level, detected): return level, detected, ret if mail.getsender()['ADDR'].lower()=='unknown': debug.echo(4, "add_listed(): no sender address specified: %s" \ % (mail.getsender()['ADDR'])) return level, detected, ret try: keys = { 'A': [[mail.getsender()['ADDR'], '', '']], 'S': [['', mail.sender, '']], 'R': [['', '', x] for x in mail.recip] }[self.FLAGS[1]] except KeyError: raise for key in keys: self.dbc.refresh() q = self.dbc.query( "SELECT count(*) FROM "+self.TABLE+" WHERE \ flags=%s AND ip=%s AND sender=%s and recipient=%s", [self.FLAGS]+key) t0 = time.time() if self.EXPIRE_TIME<0: t_expire = 0 else: t_expire = t0+self.EXPIRE_TIME if q[0][0]==0: self.dbc.execute_cycle( "INSERT INTO %s " "(flags,timestamp,expire,created,last_update,ip,sender,recipient)" " VALUES ('%s',%d,%d,%d,%d,%%s,%%s,%%s)" % (self.TABLE, self.FLAGS, t0, t_expire, t0, t0), key) else: self.dbc.execute_cycle( "UPDATE %s SET last_update=%d, expire=%d, repeats=repeats+1" " WHERE flags='%s' AND ip=%%s AND sender=%%s and recipient=%%s" % (self.TABLE, t0, t_expire, self.FLAGS), key) return level, detected, ret ## Policy quota ################# class policy_quota_auth_limit(ascanner): ''' A policy quota for authorized users. Limit number of emails from an authorized user. Do not use multiple of policy_quota_auth_limit tests in one policy service, use arrays for interval, max_conn, max_rcpt instead. Usage: policy_quota_auth_limit(interval, max_conn, max_rcpt) Where: interval - counting interval in seconds max_conn - maximum number of connections to server max_rcpt - maximum number of recipients Example: policy_quota_auth_limit(300, 10, 150) Postfix configuration example: smtpd_data_restrictions = check_policy_service inet:127.0.0.1:30 SQL command, which can help you to set proper parameters. Change 3600 with your current interval settings. SELECT username, sum(1) AS count, sum(recipient_count) AS rcpt, from_unixtime(timestamp/1000) AS time, group_concat(DISTINCT ip) FROM policy_quota GROUP BY username, floor(timestamp/1000/3600) ORDER BY count DESC, rcpt DESC; New in version 1.3.0. ''' is_policy_scanner = True name = 'policy_quota_auth_limit()' precision = 1000 # milisecond def __init__(self, interval, max_conn, max_rcpt): if type(interval)==list or type(interval)==tuple: self.INTERVALS = list(zip(interval, max_conn, max_rcpt)) else: self.INTERVALS = [[interval, max_conn, max_rcpt]] def scanbuffer(self, buffer, args={}): sasl_username = mail.policy_request.get(b"sasl_username") if not sasl_username: return 0.0, b'', [] recipient_count = mail.policy_request.get(b"recipient_count", 1) ip = mail.policy_request.get(b'client_address') t0 = self.precision*time.time() args['dbc'].refresh() for interval, max_conn, max_rcpt in self.INTERVALS: q = args['dbc'].query( "SELECT sum(1), sum(recipient_count)" " FROM policy_quota" " WHERE username=%s AND timestamp>=%s", [sasl_username, t0-self.precision*interval] ) debug.echo(8, "%s: %s %s" % (self.name, sasl_username, q)) if not q: return 0.0, b'', [] else: sum_emails, sum_recipients = q[0] # If negative exceptions are used in SQL, recpient_count # should be lower line sum of emails sent. Use lower value. if sum_recipients is not None and sum_emails is not None \ and sum_recipients=max_conn: return 1.0, b"POLICY_QUOTA_EXCEEDED_%d_%d_FOR_%s" % ( max_conn, interval, sasl_username ), [ "Over connection limit for %s [%d>%d/%d]" % (sasl_username, sum_emails, max_conn, interval), mail.policy_request ] if (sum_recipients or 0)>=max_rcpt: return 1.0, b"RCPT_QUOTA_EXCEEDED_%d_%d_FOR_%s" % ( max_rcpt, interval, sasl_username ), [ "Over recipient limit for %s [%d>%d/%d]" % (sasl_username, sum_recipients, max_rcpt, interval), tostr(mail.policy_request) ] # insert try: args['dbc'].execute_cycle( "INSERT INTO policy_quota (timestamp, username, recipient_count, ip)" " VALUES (%s, %s, %s, %s)", (t0, sasl_username, recipient_count, ip) ) except args['dbc'].IntegrityError as e: # This can happen only if request comes faster than precision. globals.ACTION = 'defer_if_permit Too fast, try again later!' return 1.0, b"TOO_FAST", [e] return 0.0, b'', [] class policy_quota_cleanup(policy_quota_auth_limit): ''' Clean old records from SQL policy database table. Usage: policy_quota_cleanup(age=7*24, table="policy_quota") Where: age - record age in hours (by default 7 days) table - name of policy quota SQL table New in version 1.3.0. ''' name='policy_quota_cleanup()' def __init__(self, age=7*24, table="policy_quota"): self.age = 3600000*age self.table_name = table def scanbuffer(self, buffer, args={}): t0 = time.time() args['dbc'].execute_cycle( "DELETE FROM %s WHERE timestamp<%%s" % self.table_name, [self.precision*t0 - self.age] ) if args['dbc'].rowcount: debug.echo(3, "%s: %d old records deleted in %5.3f s" % (self.name, args['dbc'].rowcount, time.time()-t0)) return 0.0, b'', [] class geoip_country(dns_check): ''' GeoIP country match. Usage: geoip_country(country_codes, allow=True, db="/usr/share/GeoIP/GeoIP.dat") Where: country_codes is a list of allowed/denied countries. Use upper case country names only! allow is an boolean, which defines if countries should be allowed or denied db is a full pathname to GeoIP.dat file Example: geoip_country(["SK"]) New in version 1.3.1. ''' def __init__(self, country_codes, allow=True, db="/usr/share/GeoIP/GeoIP.dat"): import GeoIP self.gi = GeoIP.open(db, GeoIP.GEOIP_STANDARD) self.country_codes = [x.upper() for x in country_codes] self.allow = allow def scanbuffer(self, buffer, args={}): ip, sender, helo = self.getsender() if ":" in ip: client_country = self.gi.country_code_by_addr_v6(ip) else: client_country = self.gi.country_code_by_addr(ip) debug.echo(4, "Client coutry: %s" % client_country) if client_country: if (client_country in self.country_codes)!=self.allow: return 0.0, b'', [] else: return 1.0, b'Country_%s' % client_country, \ ['Country %s match' % client_country.decode()] return 0.0, b'', [] class geoip2_country(dns_check): ''' GeoIP2 country match (using mmdb files). Usage: geoip2_country(country_codes, allow=True, db="/usr/share/GeoIP/GeoLite2-Country.mmdb") Where: country_codes is a list of allowed/denied countries. Use upper case country codes only! allow is an boolean, which defines if countries should be allowed or denied db is a full pathname to an GeoIP.mmdb file Example: geoip2_country(["SK"]) New in version 2.0.0. ''' def __init__(self, country_codes, allow=True, db="/usr/share/GeoIP/GeoLite2-Country.mmdb"): from geoip2.database import Reader self.gip = Reader(db) self.country_codes = [x.upper() for x in country_codes] self.allow = allow def scanbuffer(self, buffer, args={}): ip, sender, helo = self.getsender() try: client_country = self.gip.country(ip).country.iso_code except Exception as err: debug.echo(4, "GeoIP2 error: %s" % err) client_country = "" # no match debug.echo(4, "Client coutry: %s" % client_country) if client_country: if (client_country in self.country_codes)!=self.allow: return 0.0, b'', [] else: return 1.0, b'Country_%s' % client_country, \ ['Country %s match' % client_country.decode()] return 0.0, b'', []