''' Antivir library for Sagator (c) 2003-2010 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. ''' import os, sys, re, time, random, gettext import traceback, signal, resource, struct, socket import pwd, grp from cStringIO import StringIO from email import message_from_string import version KB = 1024 MB = KB*KB MINUTE = 60 HOUR = MINUTE*60 DAY = HOUR*24 WEEK = DAY*7 MONTH = DAY*31 YEAR = DAY*365 SG_VER_REL = version.VERSION+'-'+version.RELEASE BUFSIZE = 10240 AZaz09 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'\ 'abcdefghijklmnopqrstuvwxyz'\ '0123456789' allowed_chars = AZaz09+'._-' # python-2.3 socket has no SHUT_* constants if not hasattr(socket, 'SHUT_WR'): socket.SHUT_RD = 0 socket.SHUT_WR = 1 socket.SHUT_RDWR = 2 ######################################################################### ### Translations class trans_class: def __init__(self, domain='sagator', locale_dir='/usr/share/locale'): self.TR = {} self.LANG = 'en' self.DOMAIN = domain self.LOCALE_DIR = locale_dir def __call__(self, msg): return self.gettext(msg) def gettext(self, msg): return msg def lgettext(self, msg): return self.TR[self.LANG].gettext(msg).decode('UTF-8') def set_lang(self, lang): self.LANG = lang if not self.TR.has_key(self.LANG): self.TR[self.LANG] = gettext.translation( self.DOMAIN, self.LOCALE_DIR, [self.LANG], fallback=True, codeset='UTF-8') self.gettext = self.lgettext _ = trans_class() ######################################################################### ### Debuging support class debug_class: ''' Debug class. Used for debugging informations. ''' debug_level = 2 trace_level = 9 fd = 1 def __init__(self): self.set_logfile('-') def dup(self): if self.fd>2: os.dup2(self.fd, 1) os.dup2(self.fd, 2) def set_logfile(self, logfile, silent=False): self.logfile = logfile if self.fd>2: # try to close an old file descriptor opened by sagator self.logfd.close() if logfile=='-': try: self.fd = os.dup(1) except OSError: self.fd = 1 else: try: # open a new logfile self.fd = os.open(logfile, os.O_CREAT|os.O_WRONLY|os.O_APPEND, 0640) except OSError, (ec, es): self.fd = 1 if not silent: self.echo(2, "WARNING: Can't open logfile: ", logfile, ": ", es) self.logfd = os.fdopen(self.fd, 'a', 0) sys.stdout = self.logfd if logfile!="-": # redirect stderr only for non-standard logging sys.stderr = self.logfd def reopen(self): if (self.logfile!='-') and \ (self.logfile[0:len(safe.ROOT_PATH)]==safe.ROOT_PATH): lfn = self.logfile[len(safe.ROOT_PATH):] if lfn[0]!="/": lfn = "/"+lfn self.echo(7, "Reopening log %s ..." % lfn) try: oldumask = os.umask(0002) self.fd = os.open(lfn, os.O_CREAT|os.O_WRONLY|os.O_APPEND, 0660) os.umask(oldumask) self.logfd = os.fdopen(self.fd, 'a', 0) self.dup() except Exception, es: self.echo(2, "WARNING: Log is not reopened! [%s]" % es) debug.traceback(4, 'debug.reopen()') # try to fix ownership try: os.chown(lfn, globals.UID, globals.GID) except OSError: pass else: self.echo(1, "ERROR: Logrotation not possible! (log isn't in chroot?)") def set_level(self, level): self.debug_level = level def echo(self, level, *s): if self.debug_level>=level: if self.debug_level>=9: o = time.strftime("%c")+': ' else: o = '' for i in s: if type(i)==type([]): o += ''.join([str(x) for x in i]) else: o += str(i) p = os.getpid() while 1: try: self.logfd.write("%5d: %s\n" % (p, o.rstrip('\r\n'))) break except IOError, (ec, es): if ec!=4: break except UnicodeEncodeError: o = re.sub('[^\x00-\x7F]', '_', o) # leave only ascii chars break def stack(self, level, arg): self.echo(level, arg, traceback.format_stack()) def traceback(self, level, arg=''): e_type, e_value, e_tb = sys.exc_info() self.echo(level, arg, traceback.format_exception(e_type, e_value, e_tb)) def traceback_value_str(self): return ', '.join(traceback.format_exception_only( sys.exc_type, sys.exc_value)) debug = debug_class() ######################################################################### ### Useful functions def is_infected(level, detected=''): if level>=1.0: return True else: return False def iret(level, vname, ret): if vname: return level, vname, ret else: return 0.0, vname, ret class safe_class: ROOT_PATH = '/' def fn(self, f): if (f[0]=='/') and (self.ROOT_PATH): return os.path.join(self.ROOT_PATH, f.lstrip('/')) else: return f def open(self, name, mode='', buffering=-1): if buffering>=0: return open(self.fn(name), mode, buffering) if mode: return open(self.fn(name), mode) return open(self.fn(name)) def osopen(self, filename, flag, mode=0777): return os.open(self.fn(filename), flag, mode) safe = safe_class() def normalize_filename(sfn): '''replace !allowed_chars from filename with _''' ofn = '' for chp in range(len(sfn)): if not sfn[chp] in allowed_chars: ofn = ofn+'_' else: ofn = ofn+sfn[chp] return str(ofn) def quote(s): return re.sub("([\\\\'])", "\\\\\\1", s) class core_count(object): ''' Return number of cores on this system. You can define minimal value as first argument. ''' def __init__(self): self.detected = None def __call__(self, minimum=1): if self.detected is None: try: self.detected = len([ x for x in open('/proc/cpuinfo').read().split('\n') if x.startswith('processor\t:') ]) except IOError: self.detected = 0 n = self.detected # minimum number of cores is 1 if n=self.delta: # count averages t = ctime-self.last self.avg = [count*self.delta/t for count in self.data] # zero current data and time self.data = [0]*self.count self.last = ctime def get(self): ''' return countet averages ''' return self.avg def socket_settimeout(sock, seconds): ''' Set a timeout for a socket. Timeout is in seconds. ''' try: # try to use settimeout sock.settimeout(seconds) except AttributeError: # use this for python < 2.3 sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVTIMEO, struct.pack('ll', seconds, 0)) ######################################################################### ### SMTP and mail classes S_OK = _('SENT') S_TEMPFAIL = _('TEMP-FAILED') S_REJECT = _('REJECTED') S_DROP = _('DROPPED') S_CUSTOM = _('CUSTOM') S_FORCE_SEND = _('SEND-FORCED') RECV_SMTP = 0 RECV_HEADER = 1 # obsolete RECV_BODY = 2 RECV_QUIT = 3 # waiting for quit class SmtpcError(Exception): """SMTP Client Error""" ConnectError = 'ConnectError' WrongReturnCode = 'WrongReturnCode' SendmailError = 'SendmailError' class smtp: ''' Basic SMTP class. All SMTP clases are descended from this class. ''' f = False bufsize = BUFSIZE SMTP_SERVER = ('127.0.0.1', 25) reg_cmd = re.compile("^([A-Z]+) ", re.IGNORECASE) reg_smtp_reply = re.compile("^([0-9]{3}) ", re.M) reg_smtp_reply_ok = re.compile("^[23][0-9][0-9] ", re.M) reg_mailfrom = re.compile("^MAIL +FROM: *(.*?)\r?\n?$", re.IGNORECASE) reg_rcptto = re.compile("^RCPT +TO: *(.*?)\r?\n?$", re.IGNORECASE) reg_data = re.compile("^DATA", re.IGNORECASE) reg_quit = re.compile("^QUIT", re.IGNORECASE) reg_helo_ehlo = re.compile('^(?:HELO|EHLO) +(.*?)[ \r\n]', re.I|re.M) reg_xforward_addr = re.compile(r"^XFORWARD.* ADDR=\(?'?([0-9.]+)'?[, \r\n]", re.I|re.M) reg_xforward_name = re.compile(r"^XFORWARD.* NAME=([^ ]+)[ \r\n]", re.I|re.M) reg_xforward = re.compile(r"^XFORWARD ", re.IGNORECASE) reg_rset = re.compile(r"^RSET", re.IGNORECASE) class smtpc(smtp): ''' SMTP client class. ''' def __init__(self, mail_from=''): try: self.conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM) socket_settimeout(self.conn, 120) self.conn.connect(self.SMTP_SERVER) except socket.error, (ec, es): debug.echo(0, "SMTPC: ERROR: connect: ", es) raise SmtpcError, (SmtpcError.ConnectError, "451 SMTP connection refused\r\n") self.f = self.conn.makefile('rw', self.bufsize) if mail_from: ret = self.send('', '2..') ret = self.send('EHLO %s' % socket.gethostname(), '2..') ret = self.send('MAIL FROM:%s' % mail_from, '2..') def __del__(self): self.close() def send(self, data, rc='...'): if data!='': debug.echo(5, "SMTPC>: ", data) self.conn.sendall(data+"\r\n") ret = '' while 1: r = self.readline() ret += r smtp_reply = self.reg_smtp_reply.search(r) if smtp_reply: break debug.echo(5, "SMTPC<: ", ret) if not re.compile("^%s$" % rc).search(smtp_reply.group(1)): raise SmtpcError, (SmtpcError.WrongReturnCode, "%s!=%s" % (smtp_reply.group(1), rc)) return ret def readline(self): while True: try: return self.f.readline() except IOError, (ec, es): if ec!=11: # Try again raise return '' # required by pychecker def close(self): try: self.conn.shutdown(socket.SHUT_RDWR) except: pass try: self.conn.close() self.f.close() except: pass def sendmail(self, sender, recipients, ldata=''): ret = "451 SMTP connection error\r\n" try: self.send('', '220') self.send("HELO "+socket.gethostname(), '250') self.send("MAIL FROM:"+(sender or '<>'), '250') for rec in recipients: self.send("RCPT TO:"+rec, '250') self.send("DATA", '354') self.conn.sendall(ldata) debug.echo(5, "SMTPC>: DATA SENT") ret = self.send(".", "250") except SmtpcError.WrongReturnCode, ret: raise SmtpcError, (SmtpcError.SendmailError, ret) try: self.send("QUIT", "221") self.close() except SmtpcError.WrongReturnCode, ret: debug.echo(5, "SMTPC: quit error", ret) return ret class mail_class: ''' EMail structure with some functions. ''' reg_header = re.compile('^[!-9;-~]+:').search reg_header_cont = re.compile('^[ \t]+').search reg_recv = re.compile( '^Received: +from +([^ ]+) +\(([^ ]+) +\[([0-9.]+)\]\)', re.M|re.I ) reg_xforward = smtp.reg_xforward_addr def __init__(self): self.addr = ('0.0.0.0', 0, '') # Connection address, port, name self.comm = '' # SMTP communication self.data = '' # Email Data (with header too) self.bodypos = 0 # Body position self.xheader = '' # Extended header self.xhdra = {} # Extended header array self.headers = {} # parsed message headers self.recip = [] # recipient emails self.sender = '' # sender self.policy_request = {} # smtpd_policy request self.saved = () # not saved yet self.df = StringIO() def close(self): self.df.flush() self.data = self.df.getvalue() self.df.close() del self.df # not required now self.findbody() def store(self): self.saved = (self.comm, self.data, self.bodypos, self.xheader, self.xhdra, self.headers, self.sender, self.recip) def restore(self): if self.saved: (self.comm, self.data, self.bodypos, self.xheader, self.xhdra, self.headers, self.sender, self.recip) = self.saved def findbody(self): # find end of header (beginnig of email body) df = StringIO(self.data) lineno = 0 while True: lineno += 1 l = df.readline() if (not l) | (l=='\r\n') | (l=='\n'): break if self.reg_header(l): continue if lineno==1: break if not self.reg_header_cont(l): break self.bodypos = df.tell() df.close() # also create an headers attribute try: self.headers = message_from_string(self.data[:self.bodypos]) except: # unable to parse headers pass def normalize_header(self, lines, value=None): ''' normalize to <80 chars per line ''' if value!=None: lines += ': '+value+globals.EOL SPACE8 = ' '*8 MAXLINELEN2 = 78 MAXLINELEN = MAXLINELEN2-len(SPACE8) sublines_semi = [] for line in lines.splitlines(): if len(line.replace("\t", SPACE8)) <= MAXLINELEN2: sublines_semi.append(line.rstrip()) else: # split line by semicolons while len(line) > MAXLINELEN: i = line.rfind(';', 0, MAXLINELEN) if i<0: break sublines_semi.append(line[:i+1].lstrip()) line = line[i+1:] sublines_semi.append(line.lstrip()) # now split sublines by spaces sublines_space = [] for line in sublines_semi: if len(line.replace("\t", SPACE8)) <= MAXLINELEN2: sublines_space.append(line) else: while len(line) > MAXLINELEN: i = line.rfind(' ', 0, MAXLINELEN) if i<0: break sublines_space.append(line[:i]) line = line[i+1:] sublines_space.append(line) # and now join it sublines = '' current = sublines_space.pop(0) while sublines_space: line = sublines_space.pop(0) nline = current+' '+line if len(nline.replace("\t", SPACE8)) <= MAXLINELEN2: current = nline else: sublines = sublines+current+globals.EOL current = "\t"+line sublines = sublines+current+globals.EOL return sublines def addheader(self, desc, value=None): if value==None: hdr = desc desc, value = desc.split(":", 1) value = value.strip() else: hdr = desc+': '+value+globals.EOL self.xhdra[desc] = value self.xheader += self.normalize_header(hdr) def modheader(self, frm, to): ''' Modifies header by regular expression. Returns number of modifications (max. 1). ''' h = re.compile(frm, re.MULTILINE).subn(to, self.data[:self.bodypos], 1) if h[1]>0: self.data = h[0]+self.data[self.bodypos:] return h[1] def delheader(self, hdr): reg_hdr = re.compile('^%s: .*?(^[!-9;-~])' % hdr, re.I|re.M|re.DOTALL) while True: reg1 = reg_hdr.search(self.data) if reg1: if reg1.end()': if l1>l2: return 1.0, self._join(d1,d2), v1+v2 else: return 0.0, '', [] elif op=='=': if l1==l2: return 1.0, self._join(d1,d2), v1+v2 else: return 0.0, '', [] elif op=='<=': if l1<=l2: return 1.0, self._join(d1,d2), v1+v2 else: return 0.0, '', [] elif op=='>=': if l1>=l2: return 1.0, self._join(d1,d2), v1+v2 else: return 0.0, '', [] elif op=='!=': if l1!=l2: return 1.0, self._join(d1,d2), v1+v2 else: return 0.0, '', [] else: raise ScannerError, "Unknown operator: "+self._oper[0] def param(self, key, value=None): if self.scanner.param(key, value): return self.scannerb.param(key, value) return '' def help(self): h = self.scanner.help() h.update(self.scannerb.help()) return h def rcpt_signature(self, rcpt): siga = self.scanner.rcpt_signature(rcpt) if self.scannerb: sigb = self.scannerb.rcpt_signature(rcpt) if siga and sigb: return siga+self._oper[0]+sigb else: if siga: return self._oper[0]+siga return '' def scanbuffer(self, buffer, args={}): l, d, v = {}, {}, {} if self._oper[0] in ('+','-','*','/','<','>','=','<=','>=','!='): for i in [1, 2]: self._oper[i].prescan() l[i], d[i], v[i] = self._oper[i].scanbuffer(buffer, args) self._oper[i].postscan(l[i], d[i], v[i]) return self._eval(l[1], d[1], v[1], l[2], d[2], v[2]) elif self._oper[0]=='|': try: self._oper[1].prescan() l, d, v = self._oper[1].scanbuffer(buffer, args) self._oper[1].postscan(l, d, v) return l, d, v except: self._oper[2].prescan() l, d, v = self._oper[2].scanbuffer(buffer, args) self._oper[2].postscan(l, d, v) return l, d, v elif self._oper[0]=='&': self._oper[1].prescan() l1, d1, v1 = self._oper[1].scanbuffer(buffer, args) self._oper[1].postscan(l1, d1, v1) if is_infected(l1): self._oper[2].prescan() l2, d2, v2 = self._oper[2].scanbuffer(buffer, args) self._oper[2].postscan(l2, d2, v2) return l1*l2, self._join(d1,d2,'&'), v1+['&']+v2 else: return 0.0, '', [] elif self._oper[0]=='~': try: self._oper[1].prescan() l, d, v = self._oper[1].scanbuffer(buffer, args) except: debug.echo(4, "Scanner %s failed, recovering..." % self._oper[1].name) if self._oper[1].is_interscanner: l, d, v = self._oper[1].get('child_status') else: return 0.0, '', [] self._oper[1].postscan(l, d, v) return l, d, v else: raise ScannerError, "Unknown operator: "+self._oper[0] def scanfile(self, files, dirname='', args={}): l, d, v = {}, {}, {} if self._oper[0] in ('+','-','*','/','<','>','=','<=','>=','!='): for i in [1, 2]: self._oper[i].prescan() l[i], d[i], v[i] = self._oper[i].scanfile(files, dirname) self._oper[i].postscan(l[i], d[i], v[i]) return self._eval(l[1], d[1], v[1], l[2], d[2], v[2]) elif self._oper[0]=='|': try: self._oper[1].prescan() l, d, v = self._oper[1].scanfile(files, dirname) self._oper[1].postscan(l, d, v) return l, d, v except: self._oper[2].prescan() l, d, v = self._oper[2].scanfile(files, dirname) self._oper[2].postscan(l, d, v) return l, d, v elif self._oper[0]=='&': self._oper[1].prescan() l1, d1, v1 = self._oper[1].scanfile(files, dirname) self._oper[1].postscan(l1, d1, v1) if is_infected(l1): self._oper[2].prescan() l2, d2, v2 = self._oper[2].scanfile(files, dirname) self._oper[2].postscan(l2, d2, v2) return l1*l2, self._join(d1,d2,'&'), v1+['&']+v2 else: return 0.0, '', [] elif self._oper[0]=='~': try: self._oper[1].prescan() l, d, v = self._oper[1].scanfile(files, dirname) except: debug.echo(4, "Scanner %s failed, recovering..." % self._oper[1].name) if self._oper[1].is_interscanner: l, d, v = self._oper[1].get('child_status') else: return 0.0, '', [] self._oper[1].postscan(l, d, v) return l, d, v else: raise ScannerError, "Unknown operator: "+self._oper[0] def prescan(self): pass def postscan(self, level, vir, ret): pass def destroy(self): self._oper[1].destroy() if self._oper[2]: self._oper[2].destroy() def reinit(self): self._oper[1].reinit() if self._oper[2]: self._oper[2].reinit() def get(self, var): try: return getattr(self._oper[1], var) except AttributeError: try: return eval(self._oper[2], var) except AttributeError: return "UNKNOWN" def __add__(self, second): return scanoper('+', self, second) def __sub__(self, second): return scanoper('-', self, second) def __mul__(self, second): return scanoper('*', self, second) def __div__(self, second): return scanoper('/', self, second) def __or__(self, second): return scanoper('|', self, second) def __and__(self, second): return scanoper('&', self, second) def __invert__(self): return scanoper('~', self) def __lt__(self, second): return scanoper('<', self, second) def __gt__(self, second): return scanoper('>', self, second) def __eq__(self, second): return scanoper('=', self, second) def __le__(self, second): return scanoper('<=', self, second) def __ge__(self, second): return scanoper('>=', self, second) def __ne__(self, second): return scanoper('!=', self, second) class ascanner(scanoper): ''' Default scanner user for building all other realscanners. ''' name = 'AScanner()' is_spamscan = False is_policy_scanner = False is_interscanner = False ignore_name = False scanner = 0 def __init__(self): pass def destroy(self): pass def help(self): return {} def param(self, key, value=None): return 'Unknown parameter: %s' % key def prescan(self): '''This function is called before running a scanner''' debug.echo(5, "Running: ", self.name) def postscan(self, level, vir, ret): '''This function is called after running a scanner''' self.child_status = (level, vir, ret) debug.echo(4, "Values: %f, '%s', %s" % (level, vir, str(ret))) if is_infected(level): if not self.ignore_name: globals.found_by = self debug.echo(5, "Found %s by %s, level %f" % (vir, self.name, level)) def rcpt_signature(self, rcpt): return self.name def scanbuffer(self, buffer, args={}): raise ScannerError, 'Not implemented' def scanfile(self, files, dirname='', args={}): raise ScannerError, 'Not implemented' def reinit(self): pass def get(self, var): try: return getattr(self, var) except AttributeError: return "UNKNOWN" class interscanner(ascanner): ''' Default scanner used for building all other interscanners. ''' name = 'AInterScanner()' is_interscanner = True scanners = [] def rename(self, scanners): n = [s.name for s in scanners] self.name = self.name.replace("()", "("+', '.join(n)+")") if self.name=="": self.name = ','.join(n) def destroy(self): self.scanner.destroy() def prescan(self): '''This function is called before running a scanner''' debug.echo(5, "Running: ", self.name) def postscan(self, level, vir, ret): '''This function is called after running a scanner''' self.child_status = (level, vir, ret) def rcpt_signature(self, rcpt): return "%s(%s)" % ( self.name.split('(', 1)[0], # only it's name self.scanner.rcpt_signature(rcpt) ) def reinit(self): self.scanner.reinit() def param(self, key, value=None): return self.scanner.param(key, value) def help(self): return self.scanner.help() def get(self, var): try: r = getattr(self, var) if not r: r = self.scanner.get(var) return r except AttributeError: return self.scanner.get(var) class globals_class: QFNAME = '' # quarantine filename (generated) USER = '' GROUP = '' UID = 0 GID = 0 SRV = [] EOL = '\r\n' DBC = None # a policy database connection id = None # sagator's message ID scan_only = False # only scan or also send reports? daemon = True # daemonize or not? fork_id = 0 # Fork internal ID pid_file = None # PID filename pidf = None # PID file (as python file object) # policy specifications sender_policy = [] recipient_policy = [] def __init__(self): self.reset() def reset(self, action=S_REJECT): self.found_by = ascanner self.QFNAME = '' self.ACTION = action self.PREPEND = '' self.RCPT_MATCH = {} def action(self, level, detected=''): if is_infected(level): return self.ACTION else: return S_OK def gen_id(self, time1, time2): self.id = "%s-%04d-%05d-%s@%s" \ % (time1, time2, os.getpid(), randomchars(6), socket.gethostname()) def setuidgid(self, user, group): if user: try: globals.UID = pwd.getpwnam(user)[2] globals.USER = user except KeyError, err_str: debug.echo(0, "ERROR, getpwnam: %s %s, using current user" % ([user], err_str)) globals.UID = os.getuid() globals.USER = pwd.getpwuid(globals.UID)[0] if group: try: globals.GID = grp.getgrnam(group)[2] globals.GROUP = group except KeyError, err_str: debug.echo(0, "ERROR, getgrnam: %s %s, using current group" % ([group], err_str)) globals.GID = os.getgid() globals.GROUP = grp.getgrgid(globals.GID)[0] globals = globals_class()