#!/usr/bin/env python2
#
#     Name:          malmon
#     Version:       0.3
#     Author:        ShadowX
#     Contact:       georgi.kolev AT gmail.com
#
#  ABOUT:
#   This daemon will monitor file change activity in directory
#   (and sub dirs) and scan (md5 sum check, hex signature check)
#   files and try to match them agains a database of known malware.
#
#  DEPENDS ON:
#     * pyinotify module [ http://pyinotify.sourceforge.net/ ]
#     * inotify kernel support [ CONFIG_INOTIFY_USER=y ]
#
#
#   Copyright 2011 [ShadowX] Georgi Kolev <georgi.kolev at gmail.com>
#
#
#   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.
#
#   This program is distributed in the hope that it will be useful,
#   but WITHOUT ANY WARRANTY; without even the implied warranty of
#   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#   GNU General Public License for more details.
#
#   You should have received a copy of the GNU General Public License
#   along with this program; if not, write to the Free Software
#   Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
#   MA 02110-1301, USA.
#

confFile = '/etc/malmon/malmon.conf'

#import sys
import os
import string
import socket
import logging
import threading
import pyinotify
from time import sleep
from Queue import Queue
from shutil import move
from hashlib import md5
from urllib import urlopen
from atexit import register
from binascii import hexlify
from ConfigParser import ConfigParser
#from pyinotify import WatchManager, Notifier, ThreadedNotifier, \
#		ProcessEvent, IN_MOVED_TO, IN_CLOSE_WRITE, IN_CREATE

# What event to monitor ?
mask = pyinotify.IN_CLOSE_WRITE | pyinotify.IN_MOVED_TO | pyinotify.IN_CREATE


class fAnal(threading.Thread):
	"""
	    File analayzer thread.
	    This thread will get (from the Queue)
	    all new/modified files and analyze them.

	    parm @logger     - logger object
	    type @logger     - logger object
	    parm @Q          - Queue (we get new tasks from here)
	    type @Q          - Queue object
	    parm @quarantine - Where to move malware files [path to dir]
	    type @quarantine - string
	    parm @backlist   - List of forbien filenames [path to file]
	    type @blacklist  - string
	    parm @maxsize    - Max file size (check only files below this size)
	    type @maxsize    - integer
	    parm @warn_only  - Warn only mode (do not move threads)
	    type @warn_only  - boolen/int
	    parm @cachedir   - The deamon cache directory [path to dir]
	    type @cachedir   - string
	"""
	def __init__(self, logger, Q, quarantine, blacklist, updateEvent,
			maxsize, warn_only, cachedir='/var/cache/malmon'):
		threading.Thread.__init__(self)
		self.maxsize = (float(maxsize) * (1024*1024))
		self.updateEvent = updateEvent
		self.warn_only = warn_only
		self.blacklist = blacklist
		self.cachedir = cachedir
		self.quar = quarantine
		self.logger = logger
		self.alive = False
		self.removed = []
		self.md5dict = {}
		self.hexdict = {}
		self.daemon = True
		self.Q = Q

	def run(self):
		self.alive = True
		# First we load the md5 and hex files
		self.load_md5hex()
		self.shitload()

		while self.alive:
			# Get new files
			newfile = self.Q.get()
			self.logger.debug('Checking %s' % newfile)
			
			# First blacklist checks.
			filename = newfile.split('/')[-1]
			if filename in self.shitlist:
				self.logger.info('[shitlist] Moving %s' % newfile)
				self.move_file(newfile)
				continue
			
			# Look only files smaller then 1Mb!
			try:
				if os.path.getsize(newfile) < self.maxsize:
					filecontent = self.read_file(newfile)
					if not filecontent:
						self.logger.debug('Empty file? %s' % newfile)
						continue

					# md5 check
					md5r = self.check_md5(filecontent)
					if md5r:
						self.logger.warning('[md5] %s is %s. Moving!' % (newfile, md5r.rstrip()))
						if not self.warn_only:
							self.move_file(newfile)
						self.removed.append(newfile)
						continue
				
					# hex signature check
					hexr = self.check_hex(filecontent)
					if hexr:
						self.logger.warning('[hex] %s is %s. Moving!' % (newfile, hexr.rstrip()))
						if not self.warn_only:
							self.move_file(newfile)
						self.removed.append(newfile)
						continue
			except OSError:
				if not newfile in self.removed:
					self.logger.debug(
						'OSError rised while checking file size.Maybe file is missing %s ?' % newfile)
			except:
				self.logger.critical('Unknown error while analyzing file %s' % newfile)
			
			if self.updateEvent.isSet():
				update_definitions(self.logger, self.cachedir)
				self.load_md5hex()
				self.shitload()
				self.updateEvent.clear()
			
			# Remove task from queue
			self.Q.task_done()

	def shitload(self):
		""" This funciton will read the
		    blacklist file and put it into a list
		"""
		try:
			shitf = open(self.shitlist, 'r')
			self.shitlist = shitf.read().split('\n')
			shitf.close()
		except:
			self.shitlist = []
		return


	def move_file(self, newfile):
		""" Move files to quaranty dir
		    
		    parm @newfile - source file
		    type @newfile - string
		"""
		try:
			dstfile = os.path.join(self.quar, newfile.split('/')[-1])
			move(newfile, dstfile)
		except IOError:
			self.logger.error('IOError while moving %s' % newfile)
		except:
			self.logger.error('Unknown error while moving %s' % newfile)
		return

	def read_file(self, newfile):
		""" This function just reads the file
		
		    parm @newfile - path to file
		    type @newfile - string
		"""
		try:
			newfl = open(newfile, 'r')
			newfl_cont = newfl.read()
			newfl.close()
		except:
			self.logger.warning('Error while opening/reading %s' % newfile)
			return False
		else:
			return newfl_cont

	def check_hex(self, filecontent):
		"""
		    This function matches the file
		    for known hex signatures of malware

		    parm @filecontent - File content
		    type @fielcontent - string
		"""
		# Get file contetnt in hex
		hexcontent = str(hexlify(filecontent))
		# Try to match hex paterns
		for hex_patern in self.hexdict:
			if hex_patern in hexcontent:
				return self.hexdict[hex_patern]
		return False

	def check_md5(self, filecontent):
		"""
		    This function checks the md5sum of the
		    new/modified file and compares it
		    to the md5sums in md5.dat file

		    parm @filecontent - File content
		    type @filecontent - string
		    
		    @return - True [if we match something]
		"""
		md5hash_sum = md5(filecontent).hexdigest()
		if md5hash_sum in self.md5dict:
			return self.md5dict[md5hash_sum]
		else:
			return False

	def load_md5hex(self):
		""" This functions loads the the
		    md5 sum and hex data files
		"""
		md5file = os.path.join(self.cachedir, 'md5.dat')
		hexfile = os.path.join(self.cachedir, 'hex.dat')
		# Load md5 sums
		if os.path.exists(md5file):
			infile = open(md5file, 'r')
			n = 0
			for infile_line in infile.readlines():
				if len(infile_line) > 1:
					self.md5dict[infile_line.split(':')[0]] = infile_line.split(':')[1]
					n += 1
			infile.close()
			self.logger.info('[md5] %s rules added' % (n))
			del n
		else:
			self.logger.warning('%s not found.' % md5file)
		# Load hex signatures
		if os.path.exists(hexfile):
			infile = open(hexfile, 'r')
			n = 0
			for infile_line in infile.readlines():
				if len(infile_line) > 1:
					self.hexdict[infile_line.split(':')[0]] = infile_line.split(':')[1]
					n += 1
			infile.close()
			self.logger.info('[hex] %s rules added.' % (n))
			del n
		else:
			self.logger.warning('%s not found.' % hexfile)

		return

	def finish(self):
		self.alive = False


class UnixSocket(threading.Thread):
	""" This thread is listening on unix socket
	    file for incomming commands.
	    This way we are able to get more control
	    over the daemon, schedule new checks and
	    so one, just by using simple socket.
	    
	    We'll be able to give the daemon list of
	    files to scan while monitoring for file
	    changes in one (or more) dirs.
	    
	    In laster relases we'll add the option to
	    dynamicly add/remove dirs to watch while
	    the daemon is running.
	"""
	def __init__(self, logger, Q, wdd, updateEvent, wm, excl, mask, sockfile='/var/run/malmon.sock'):
		"""
		    parm @logger    - logger object
		    type @logger    - logger object
		    parm @Q         - Queue
		    type @Q         - Queue object
		    parm @wdd       - object
		    parm @wm        - object
		    parm @mask      - event mask
		    parm @sockfile  - path to socket file
		    type @sockfile  - string
		"""
		threading.Thread.__init__(self)
		self.name = 'UnixSock'
		self.uEvent = updateEvent
		self.sockfile = sockfile
		self.logger = logger
		self.socket = None
		self.alive = False
		self.excl = excl
		self.mask = mask
		self.wdd = wdd
		self.wm = wm
		self.Q = Q

	def run(self):
		self.alive = True
		
		self.socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
		if os.path.exists(self.sockfile):
			try: os.remove(self.sockfile)
			except OSError:
				self.logger.error('OSError while removing %s' % self.sockfile)
		self.socket.bind(self.sockfile)
		self.socket.listen(1)
		
		while self.alive:
			connection, details = self.socket.accept()
			data = connection.recv(1024)
			if data: self.read_packet(data)

	def read_packet(self, data):
		data = data.split(':')
		if len(data) == 2:
			if data[0] == 'a':
				# Add directory watch
				if os.path.exists(data[1]):
					self.wdd = self.wm.add_watch(data[1], self.mask, rec=True,
								auto_add=True, exclude_filter=self.excl)
				self.logger.info('Monitoring %s' % data[1])
			elif data[0] == 'f':
				# Add file for analyze
				self.logger.debug('Added file %s for check.' % data[1])
				self.Q.put(data[1])
			elif (data[0] == 'd') or (data[0] == 'r'):
				# Remove directory watch
				if self.wdd[data[1]] > 0:
					self.wm.rm_watch(self.wdd[data[1]], rec=True)
					self.logger.info('Stop monitoring %s' % data[1])
			elif data[0] == 'u':
				# Update
				self.uEvent.set()
				self.Q.put('')
				self.logger.info('Force update...')
			else:
				return

	def finish(self):
		self.alive = False


class updateController(threading.Thread):
	""" This thread is controling update schedule.
	    It will just restart the fAnal thread. :)
	    This was the easyers way to go :P
	"""
	def __init__(self, logger, Q, updatetime, updateEvent):
		threading.Thread.__init__(self)
		self.name = 'updateController'
		self.updateEvent = updateEvent
		self.updatetime = updatetime
		self.logger = logger
		self.alive = False
		self.Q = Q

	def run(self):
		self.alive = True
		while self.alive:
			sleep((int(self.updatetime) * 60 * 60))
			self.updateEvent.set()
			self.Q.put('')
			self.logger.debug('updateEvent.set()')

	def finish(self):
		self.alive = False


class EventHandler(pyinotify.ProcessEvent):
	"""
	    This class handles create/modify events
	    and puts event.pathname into queue so
	    the other thread can analyze the file
	"""
	def process_default(self, event):
		if not event.dir:
			Q.put(event.pathname)


def update_definitions(logger, cachedir,
	url='http://93.183.131.3/malmon/'):
	"""
	    This function will download and update
	    the md5 sum and hex signature files.
	    
	    parm @logger   - logger object
	    type @logger   - object
	    parm @cachedir - the daemon cache dir
	    type @cachedir - string
	    parm @newver   - the new version id
	    type @newver   - string/int
	    parm @url      - url -> where md5/hex files can be found
	    type @url      - string
	"""
	# URLs
	md5sumurl = '%s/md5.dat' % (url)
	hexsigurl = '%s/hex.dat' % (url)
	verdaturl = '%s/version' % (url)
	# Full pathnames
	md5path = '%s/md5.dat' % (cachedir)
	hexpath = '%s/hex.dat' % (cachedir)
	verpath = '%s/version' % (cachedir)
	
	try:
		webFile = urlopen(verdaturl)
		localfile = open(verpath, 'w')
		localfile.write(str(webFile.read()).strip())
		webFile.close()
		localfile.close()
	except IOError:
		logger.error('IOError rised while saving version file to cache dir')
	try:
		webFile = urlopen(md5sumurl)
		localfile = open(md5path, 'w')
		localfile.write(webFile.read())
		webFile.close()
		localfile.close()
	except IOError:
		logger.error('IOError rised while saving md5.dat to cache dir')
	try:
		webFile = urlopen(hexsigurl)
		localfile = open(hexpath, 'w')
		localfile.write(webFile.read())
		webFile.close()
		localfile.close()
	except IOError:
		logger.error('IOError rised while saving hex.dat to cache dir')
	
	return


def chk_def_ver(logger, cachedir,
		url='http://93.183.131.3/malmon/version'):
	"""
	    This function will compare the version
	    of the md5sum and hex files and update
	    them if needed.
	    
	    parm @logger   - logger object
	    type @logger   - object
	    parm @cachedir - the daemon cache dir
	    type @cachedir - string
	    parm @url      - url to definitions version file
	    type @url      - string
	"""
	verfilepath = os.path.join(cachedir, 'version')
	# If we dont find file -> update!
	if not os.path.exists(verfilepath):
		logger.info('No definition verion file found. Downloading new ones...')
		update_definitions(logger, cachedir)
		return
	# Else -> compare versions
	try:
		verf = open(verfilepath, 'r')
		curver = verf.read()
		verf.close()
	except:
		logger.error('Error while opening %s' % verfilepath)
	else:
		versionfile = urlopen(url)
		remotever = versionfile.read().strip()
		if not str(curver).strip() == remotever:
			logger.info(
				'Local version: %s Remote version: %s. Starting update...' % (curver.strip(),
												remotever))
			update_definitions(logger, cachedir)
		else:
			logger.info('No updates found.')
	return

def forkme(pidfile, cachedir):
	""" double fork
	    
	    parm @pidfile  - path to pidfile
	    type @pidfile  - string
	    parm @cachedir - path to cachedir
	    parm @cachedir - string
	"""
	try:
		pid = os.fork() # fork first child
	except OSError, e:
		raise Exception, "%s [%d]" % (e.strerror, e.errno)

	if pid == 0:
		os.setsid()
		try:
			pid = os.fork() # fork second child
		except OSError, e:
			raise Exception, "%s [%d]" % (e.strerror, e.errno)

		if pid == 0:
			os.chdir(cachedir) # change current dir to /tmp
			os.umask(0)	 # umask for files created by the daemon
		else:
			os._exit(0)	 # Exit parent (the first child) of the second child.
	else:
		os._exit(0)	 # Exit parent of the first child.

	# Iterate through and close all file descriptors.
	try:
		maxfd = os.sysconf("SC_OPEN_MAX")
	except (AttributeError, ValueError):
		maxfd = 1024

	#for fd in range(0, maxfd):
	#    try: os.close(fd)
	#    except OSError: pass # ERROR, fd wasn't open to begin with (ignored)

	#sys.stdin.close()
	#sys.stdout = NullDevice()
	#sys.stderr = NullDevice()

	mypid = str(os.getpid()) # get current pid

	# Check if program is already running
	try:
		pfl = file(pidfile, 'r')
		thepid = int(pfl.read().strip())
		pfl.close()
	except IOError:
		thepid = False

	# Check if another copy is running
	if thepid:
		if int(thepid) != int(mypid):
			logger.warning('Another copy of the program is running.\n\t\t\t\t  Sending SIGTERM signal to pid: %s' % thepid)
			try:
				os.kill(thepid, SIGTERM); time.sleep(0.1)
			except OSError, err:
				try:
					os.remove(pidfile)
				except:
					logger.error('Unable to remove pidfile')
	return

def onquit():
	global sockfile, pidfile
	remfiles = [sockfile, pidfile]
	
	for fl in remfiles:
		try: os.remove(fl)
		except: pass
	return


# Read config file
config = ConfigParser()
config.read(confFile)
# Global options
watch_dir = config.get("global", "watch_dir")
cachedir  = config.get("global", "cache_dir")
pidfile   = config.get("global", "pidfile")
demonize  = config.get("global", "demonize")
sockfile  = config.get("global", "sockfile")
updatetime= config.get("global", "updatetime")
# Scan options
excludeFile = config.get("scan", "exclude_file")
quarantine  = config.get("scan", "quarantine")
warn_only   = config.get("scan", "warn_only")
blacklist   = config.get("scan", "blacklist")
maxfilesize = config.get("scan", "maxsize")
# Logging options
logfile  = config.get("logging", "logfile")
loglevel = config.get("logging", "level")

# pharse watch_dir
watch_dir = watch_dir.split(';')

# loglevel sanity check
if loglevel in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']:
	loglevel = eval('logging.' + loglevel)
else:
	print 'Using logging default level [INFO]'
	loglevel = logging.INFO
# Daemon flag sanity check
if not str(demonize).isdigit():
	print 'Unrecodnized value in config file: demonize = %s' % demonize
	print 'The program will run in default (daemon) mode.'
	demonize = 1
else:
    demonize = int(demonize)
# warn_only mode?
if not str(warn_only).isdigit():
	print 'Unrecodnized value in config file: warn_only = %s' % warn_only
	print 'Using default mode: warn_only = 0 [False]'
	warn_only = 0
else:
	warn_only = int(warn_only)
# Update time sanity check
if not str(updatetime).isdigit():
	print 'Unrecodnized value in config file: updatetime = %s' % updatetime
	print 'Using defualt value: updatetime = 12 [hours]'
	updatetime = 12
else:
	updatetime = int(updatetime)

# Setup logging
logger = logging.getLogger()
logger.setLevel(loglevel)
logger.setLevel(logging.DEBUG)
if demonize:
	handler = logging.FileHandler(logfile)
else:
	handler = logging.StreamHandler()
handler.setLevel(loglevel)
formatter = logging.Formatter(
	'[%(asctime)s]-[%(levelname)s] %(message)s')
handler.setFormatter(formatter)
logger.addHandler(handler)


# Queue object
Q = Queue()
updateEvent = threading.Event()
# Handle quit/exit
register(onquit)

# daemon mode ?
if demonize:
	forkme(pidfile, cachedir)

# Check/Update definitions
chk_def_ver(logger, cachedir)

# Lower process priority
os.nice(10)

# Update controller thread
updateController(logger, Q,
	updatetime, updateEvent).start()
# file analayzer thread
fAnal(logger, Q, quarantine, blacklist, updateEvent,
		maxfilesize, warn_only, cachedir).start()
# Create watch manager object
wm = pyinotify.WatchManager()
# We need to this :]
eventHandler = EventHandler()
# add handler class
notifier = pyinotify.Notifier(wm, eventHandler, read_freq=None)
# Exclude patterns from file
excl = pyinotify.ExcludeFilter(excludeFile)
# rec      - recursivly watch directory and sub-directories
# auto_add - auto add new directories
wdd = wm.add_watch(watch_dir, mask, rec=True,
		auto_add=True, exclude_filter=excl)
# UnixSocket Thread
UnixSocket(logger, Q, wdd, updateEvent, wm,
		excl, mask, sockfile).start()

# OxFF we go :}
notifier.loop(daemonize=False, force_kill=True)
