====== Remote Clamd scanning ====== ===== Intro ===== I recently encountered a need to set up a cluster of clamd servers running on linux. The details of why are irrelevant here. The crux of this need was the fact that you cannot use the ''clamdscan'' that is included with the clamav package to access a remote clamd server. I dug around on the net for a bit and found this Python script, [[http://xael.org/norman/python/pyclamd/pyclamd.py|pyclamd.py]], written by Alexandre Norman - . I downloaded it and tried it out and it seemed to work pretty well. What I didn't like was the lack of an [[wp>Object-oriented|object-oriented]] design. I took that code and rewrote it in a class with a couple of helper functions for instantiation. I then wrote a consumer script to use that library to emulate the behavior and output of ''clamdscan'' to make it pretty much a drop in replacement for the ''clamdscan'' that comes with clamav. ===== Install ===== The package is available for download: {{apps:clamav:general:pyclamdscan-0.1_alpha.tar.gz|pyclamdscan-0.1_alpha.tar.gz}}\\ To install, just unpack and run the setup script: tar -xvzf pyclamdscan-0.1_alpha.tar.gz python setup.py install This will install the following in their appropriate locations.\\ MANIFEST clamdscan.py PyClamd/OPyClamd.py PyClamd/Timer.py PyClamd/__init__.py You will then be able to run the script by typing ''clamdscan.py''. Try typing ''clamdscan.py --help'' to see the available command line options. If you do this, you will notice that there are some other options beyond the normal ''clamdscan'' options, specifically options for specifying a host and port. ===== The Code ===== Here is the actual code used for this package. As you will see, I have not yet thoroughly documented this within the code itself. I tend to write first, document later. ==== clamdscan.py ==== #!/usr/bin/env python """ This is a command line replacement for clamdscan that will allow the use of a remote clamd server """ import sys , os , getopt , math from PyClamd import OPyClamd , Timer class ClamdScan(object): def __init__(self): self.clam = OPyClamd() self.quiet = False self.summary = True self.infectedOnly = False self.remove = False self.sockPath = '' self.host = '127.0.0.1' self.port = 3310 self.fileList = [] self.exitVal = 0 self.setOptions() # Set what we are going to use for the connection to clamd if self.sockPath: self.clam.UseSocket = 'UNIX' self.clam.Socket = self.sockPath else: self.clam.UseSocket = 'NET' self.clam.Host = self.host self.clam.Port = self.port def _usage(self , exitCode=0): print """Usage: clamdscan.py [OPTIONS] file [file , file , ...] -h,--help Show this help -V,--version Show clamd version and exit --quiet Only output error messages --no-summary Disable the summary at the end of scanning --remove Remove the infected files. Be careful! -t HOST,--host=HOST The clamd host to connect to -p PORT,--port=PORT The port to connect to on the clamd host -u SOCKET,--unix-socket=SOCKET Path to the unix socket to use. NOTE: This overrides any setting for host and port. """ sys.exit(exitCode) def _printVersion(self , exitCode=0): print self.clam.clamdVersion() sys.exit(exitCode) def _getFTime(self , timerTime): """ Returns a formatted time string for the float passed in timerTime (float): a floating point representing an amount of secs return: string """ retStr = '%0.3f sec' % timerTime mins = int(math.floor(timerTime / 60.0)) secs = int(round(timerTime - (mins * 60.0))) retStr += ' (%d m %d s)' % (mins , secs) return retStr def _printResults(self , fileName , results , scanTime): summaryHead = '\n----------- SCAN SUMMARY -----------' if fileName == '-': fileName = 'stream' if results: self.exitVal = 1 if self.quiet: return key = results.keys()[0] print '%s: %s FOUND' % (fileName , results[key]) if self.summary: print '%s\nInfected files: 1\nTime: %s' % (summaryHead , self._getFTime(scanTime)) else: if self.quiet: return print '%s: OK' % fileName if self.summary: print '%s\nInfected files: 0\nTime: %s' % (summaryHead , self._getFTime(scanTime)) return def setOptions(self): shortOpts = 'hVt:p:u:' longOpts = ['help' , 'version' , 'quiet' , 'no-summary' 'remove' , 'host=' , 'port=' , 'unix-socket='] try: (optList , fileList) = getopt.getopt(sys.argv[1:] , shortOpts , longOpts) except getopt.GetoptError: self._usage(1) # Set the file list self.fileList = fileList for opt in optList: if opt[0] == '-h' or opt[0] == '--help': self._usage() elif opt[0] == '-V' or opt[0] == '--version': self._printVersion() elif opt[0] == '--quiet': self.quiet = True elif opt[0] == '--no-summary': self.summary = False elif opt[0] == '--remove': self.remove = True elif opt[0] == '-t' or opt[0] == '--host': self.host = opt[1] elif opt[0] == '-p' or opt[0] == '--port': self.port = int(opt[1]) elif opt[0] == '-u' or opt[0] == '--unix-socket': self.sockPath = opt[1] def scanFiles(self): for f in self.fileList: results = None timer = Timer() if f == '-': # read the file from stdin buf = '' while True: block = sys.stdin.read(4096) if not block: break buf += block try: timer.start() results = self.clam.scanStream(buf) timer.stop() except: sys.stderr.write('%s: %s\n' % (sys.exc_type , sys.exc_value)) sys.exit(2) else: try: timer.start() results = self.clam.scanFile(f) timer.stop() except: sys.stderr.write('%s: %s\n' % (sys.exc_type , sys.exc_value)) sys.exit(2) if results and self.remove: try: os.unlink(results.keys()[0]) except: sys.stderr.write('%s: %s\n' %(sys.exc_type , sys.exc_value)) self._printResults(f , results , timer.TotalTime) if __name__ == '__main__': cds = ClamdScan() cds.scanFiles() ==== OPyClamd.py ==== #!/usr/bin/env python """ This is an object oriented approach to pyclamd.py written by Alexandre Norman - norman@xael.org """ import socket , types , os , os.path __version__ = '$Id: OPyClamd.py,v 1.2 2007/05/10 16:13:56 jay Exp $' __all__ = ['BufferTooLong' , 'ScanError' , 'FileNotFound' , 'InvalidConnectionType', 'OPyClamd' , 'initUnixSocket' , 'initNetworkSocket'] def initUnixSocket(self , path=None): """ Sets the appropriate properties to use a unix socket path (string): path to unix socket return: (obj) OPyClamd or None Raises: - ScanError: in case of socket communication problem """ if not os.path.exists(path): raise FileNotFound , 'Invalid socket path: %s' % path clam = OPyClamd(useSocket='UNIX' , socket=path) if clam.ping(): return clam return None def initNetworkSocket(self , host=None , port=None): """ Sets the appropriate properties to use a network socket host (string): clamd server address port (int): clamd server port return: (obj) OPyClamd or None Raises: - ScanError: in case of socket communication problem """ clam = OPyClamd(useSocket='NET' , host=host , port=port) if clam.ping(): return clam return None class BufferTooLong(Exception): pass class ScanError(Exception): pass class FileNotFound(Exception): pass class InvalidConnectionType(Exception): pass class OPyClamd(object): """ OPyClamd.py Author: Alexandre Norman - norman@xael.org Author: Jay Deiman - administrator@splitstreams.com Note: Mr. Norman wrote the original pyclamd.py and this has been rewritten to take a more object oriented approach to his work License: GPL Usage: # Initalize the connection for tcp usage clam = OPyClamd(useSocket='NET' , host='127.0.0.1' , port=3310) # Or initialize the connection for unix socket usage clam = OPyClamd(useSocket='UNIX' , socket='/var/run/clamd') # Get clamd version print clam.clamdVersion() # Scan a string print clam.scanStream(string) # Scan a file on the machine that is running clamd print clam.scanFile('/path/to/file') """ def __init__(self , **dict): """ Initializes all the values of local properties useSocket (string): can be 'UNIX' or 'NET' - default: None socket (string): path to unix socket - default: '/var/run/clamd' host (string): either a dns resolvable name or ip address - default: '127.0.0.1' port (int): the port number that clamd is running on - default: 3310 return: nothing """ try: self.useSocket = dict['useSocket'] except KeyError: self.useSocket = None try: self.socket = dict['socket'] except KeyError: self.socket = '/var/run/clamd' try: self.host = dict['host'] except KeyError: self.host = '127.0.0.1' try: self.port = int(dict['port']) except KeyError: self.port = 3310 return ########################## # Public Methods ########################## def ping(self): """ Send a PING to the clamd server, which should reply with PONG return: True if the server replies to PING Raises: - ScanError: in case of socket communication problem """ s = self._initSocket() try: s.send('PING') result = s.recv(5).strip() s.close() except: raise ScanError , 'Could not ping clamd server: %s:%d' % \ (self.host , self.port) if result == 'PONG': return True else: raise ScanError , 'Could not ping clamd server: %s:%d' % \ (self.host , self.port) return def clamdVersion(self): """ Get clamd version return: (string) clamd version Raises: - ScanError: in case of socket communication problem """ s = self._initSocket() s.send('VERSION') result = s.recv(20000).strip() s.close() return result def clamdReload(self): """ Force clamd to reload signature database return: (string) "RELOADING" Raises: - ScanError: in case of socket communication problems """ s = self._initSocket() s.send('RELOAD') result = s.recv(20000).strip() s.close() return result def clamdShutdown(self): """ Force clamd to shutdown and exit return: nothing Raises: - ScanError: in case of socket communication problems """ s = self._initSocket() s.send('SHUTDOWN') result = s.recv(20000) s.close() return result def scanFile(self , file): """ Scan a file given by filename and stop on virus file MUST BE AN ABSOLUTE PATH! return either: - dict: {filename: 'virusname'} - None if no virus found Raises: - ScanError: in case of socket communication problems - FileNotFound: in case of invalid file, 'file' """ if not os.path.isfile(file): raise FileNotFound , 'Could not find file: %s' % file if not (self.useSocket == 'UNIX' or self.host == '127.0.0.1' or self.host == 'localhost'): raise InvalidConnectionType , 'You must be using a local socket ' \ 'to scan local files' s = self._initSocket() s.send('SCAN %s' % file) result = '' dr = {} while True: block = s.recv(4096) if not block: break result += block if result: (fileName , virusName) = map((lambda s: s.strip()) , result.split(':')) if virusName[-5:] == 'ERROR': raise ScanError , virusName elif virusName[-5:] == 'FOUND': dr[fileName] = virusName[:-6] s.close() if dr: return dr return None def contScanFile(self , file): """ Scans a local file or directory given by string 'file' file MUST BE AN ABSOLUTE PATH! return either: - dict: {filename1: 'virusname' , filename2: 'virusname'} - None if no virus found Raises: - ScanError: in case of socket communication problems - FileNotFound: in case of invalid file, 'file' """ if not os.path.exists(file): raise FileNotFound , 'Could not find path: %s' % file s = self._initSocket() s.send('CONTSCAN %s' % file) result = '' dr = {} while True: block = s.recv(4096) if not block: break result += block if result: results = result.split('\n') for res in results: (fileName , virusName) = map((lambda s: s.strip()) , res.split(':')) if virusName[-5:] == 'ERROR': raise ScanError , virusName elif virusName[-5:] == 'FOUND': dr[fileName] = virusName[:-6] s.close() if dr: return dr return None def scanStream(self , buffer): """ Scans a string buffer returns either: - dict: {filename: 'virusname'} - None if no virus found Raises: - BufferTooLong: if the buffer size exceeds clamd limits - ScanError: in case of socket communication problems """ s = self._initSocket() s.send('STREAM') dataPort = int(s.recv(50).strip().split(' ')[1]) ds = socket.socket(socket.AF_INET , socket.SOCK_STREAM) ds.connect((self.host , dataPort)) sent = ds.send(buffer) ds.close() if sent < len(buffer): raise BufferTooLong , str(len(buffer)) result = '' dr = {} while True: block = s.recv(4096) if not block: break result += block if result: (fileName , virusName) = map((lambda s: s.strip()) , result.split(':')) if virusName[-5:] == 'ERROR': raise ScanError , virusName elif virusName[-5:] == 'FOUND': dr[fileName] = virusName[:-6] s.close() if dr: return dr return None ########################## # Private Methods ########################## def _initSocket(self): """ Private method to initialize a socket and return it Raises: 'ScanError' on connection error """ if self.useSocket == 'UNIX': s = socket.socket(socket.AF_UNIX , socket.SOCK_STREAM) try: s.connect(self.socket) except socket.error: raise ScanError , 'Could not reach clamd using unix socket ' \ '(%s)' % self.socket elif self.useSocket == 'NET': s = socket.socket(socket.AF_INET , socket.SOCK_STREAM) try: s.connect((self.host , self.port)) except socket.error: raise ScanError , 'Could not reach clamd using network ' \ '(%s:%d)' % (self.host , self.port) else: raise ScanError , 'Could not reach clamd: connection not ' \ 'initialized' return s ########################## # Mutators ########################## def _setUseSocket(self , type): if type == 'NET' or type == 'UNIX': self.useSocket = type else: raise InvalidConnectionType , 'UseSocket must be "NET" or "UNIX"' \ ': %s' % type return def _getUseSocket(self): return self.useSocket def _setSocket(self , path): if os.path.exists(path): self.socket = path return def _getSocket(self): return self.socket def _setHost(self , host): self.host = host or self.host return def _getHost(self): return self.host def _setPort(self , port): if port: port = int(port) if port: self.port = port return def _getPort(self): return self.port ########################## # Properties ########################## UseSocket = property(_getUseSocket , _setUseSocket) Socket = property(_getSocket , _setSocket) Host = property(_getHost , _setHost) Port = property(_getPort , _setPort) ==== Timer.py ==== #!/usr/bin/env python import time class Timer(object): def __init__(self): self._startTime = 0.0 self._stopTime = 0.0 self._totalTime = 0.0 def start(self): self._startTime = time.time() def stop(self): self._stopTime = time.time() self._totalTime = self._stopTime - self._startTime def _getStartTime(self): return self._startTime def _getStopTime(self): return self._stopTime def _getTotalTime(self): return self._totalTime StartTime = property(_getStartTime , None) StopTime = property(_getStopTime , None) TotalTime = property(_getTotalTime , None) if __name__ == '__main__': import sys numTimes = 10000 t = Timer() t.start() for i in range(numTimes): sys.stderr.write('hello: %d\n' % i) t.stop() print "TotalTime: %f" % t.TotalTime ~~DISCUSSION~~