Table of Contents

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, pyclamd.py, written by Alexandre Norman - norman@xael.org. I downloaded it and tried it out and it seemed to work pretty well. What I didn't like was the lack of an 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: 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~~