Commits

jduc...@DSITOC713.ccip.fr  committed b4f997a

First functional version.

  • Participants

Comments (0)

Files changed (8)

+syntax: glob
+*.pyc

File __init__.py

Empty file added.

File management/__init__.py

Empty file added.

File management/commands/__init__.py

Empty file added.

File management/commands/filewatch.py

+# -*- coding: utf-8 -*-
+"""
+execution de la surveillance
+parcours de la liste des fichiers ŕ surveiller
+generation / envoi de report si modifs
+
+manage.py filewatch
+
+without option
+    to build and save report
+    
+--print
+    prints report to console (standard output)
+
+--email
+    sends report by email email to settings.ADMINS
+
+--add path
+    add a path to watch list, either single file
+    or any .php(4|5)? .py .html .js within path
+    
+--update path
+    update files signature within path
+
+--remove path
+    remove files in path from watch list
+    
+--list
+    prints watch list to standard output
+    
+--clear
+    removes all files from watchlist
+"""
+##
+# @author J.Ducastel <nospam0@ducastel.name>
+
+# imports
+import os, re
+from optparse import make_option
+
+from django.conf import settings
+from django.core.management.base import BaseCommand, CommandError
+from django.core.mail import send_mail
+
+from filewatcher.models import FileWatch, Report
+
+# exceptions
+
+# class et fonctions
+class Command(BaseCommand):
+    args = 'None'
+    help = "Filewatch admin command. "
+    re_filter = re.compile('(\.py$)|(\.php\d?$)|(\.html$)|(\.js$)')
+    added, skipped, updated, deleted = 0, 0, 0, 0
+    
+    option_list = BaseCommand.option_list + (
+        make_option('--email',
+            action='store_true',
+            dest='email',
+            default=False,
+            help='Sends report by email to admins from settings.ADMINS'),
+        make_option('--print',
+            action='store_true',
+            dest='print',
+            default=False,
+            help='Prints report to console'),
+        make_option('--list',
+            action='store_true',
+            dest='list',
+            default=False,
+            help='Prints watch list to console'),
+        make_option('--add',
+            # action='store_true',
+            dest='add',
+            default=None,
+            help='Add path to watchlist'),
+        make_option('--update',
+            # action='store_true',
+            dest='add',
+            default=None,
+            help='Add path to watchlist'),
+        make_option('--remove',
+            # action='store_true',
+            dest='remove',
+            default=None,
+            help='Remove file(s) in path from watchlist'),
+        make_option('--clear',
+            action='store_true',
+            dest='clear',
+            default=False,
+            help='Remove all files from watchlist'),
+        )
+
+    def handle(self, *args, **options):
+        # self.stdout.write("%s" % options)
+        add_path = options.get('add', None)
+        update_path = options.get('update', None)
+        remove_path = options.get('remove', None)
+        do_list = options.get('list', False)
+        do_email = options.get('email', False)
+        do_print = options.get('print', False)
+        do_clear = options.get('clear', False)
+        if add_path:
+            self.add(add_path)
+        elif update_path:
+            self.update(update_path)
+        elif remove_path:
+            self.remove(remove_path)
+        elif do_clear:
+            self.clear()
+        elif do_list:
+            self.list()
+        else:
+            self.report(do_print=do_print, do_email=do_email)
+            
+    def report(self, do_print=False, do_email=False):
+        """ saves and print report, may email it to admins """
+        report = Report()
+        report.build()
+        report.save()
+        self.stdout.write("Report saved with %s changes\n" % report.changes)
+        if do_print:
+            self.stdout.write(report.content)
+        if report.changes > 0 and do_email:
+            self.email_report(report)
+        
+    def add(self, path):
+        """ add file(s) to watchlist """
+        for file in self.files_in_path(path):
+            self.add_file(file, False)
+        self.stdout.write("Added %s files (skipped %s listed) \n" % (
+            self.added, self.skipped))
+        
+    def update(self, path):
+        """ update files within watchlist """
+        for file in self.files_in_path(path):
+            self.add_file(file, False)
+        self.stdout.write("Updated %s files, added %s\n" % (
+            self.updated, self.added))
+        
+    def remove(self, path):
+        for file in self.files_in_path(path):
+            if FileWatch.objects.filter(path=file).exists():
+                FileWatch.objects.filter(path=file).delete()
+                self.deleted += 1
+        self._print("Deleted %s files from watchlist" % self.deleted)
+        
+    def list(self):
+        for watch in FileWatch.objects.all().order_by('path'):
+            self._print("%s\n" % watch.path)
+        self._print("%s files in watchlist." % FileWatch.objects.count())
+        
+    def clear(self):
+        self.deleted = FileWatch.objects.count()
+        FileWatch.objects.all().delete()
+        self._print("Cleared %s files from watchlist" % self.deleted)
+        
+    def email_report(self, report):
+        """ email report to admins """
+        if not settings.ADMINS or len(settings.ADMINS) == 0:
+            self._print("could not send report by email, please set ADMINS in settings")
+            return
+        # self._print("about to send email")
+        # send email to admins
+        subject = "Alert : %s changes" % report.changes
+        content = u"%s files watched, report issued %s\n\n" % (report.watched, report.started)
+        content += report.content
+
+        email_to = [email for name, email in settings.ADMINS]
+        email_from = settings.EMAIL_FROM
+        # self._print("subject: %s\ncontent: %s\nfrom: %s\nto: %s" % (subject, content, email_from, email_to))
+        send_mail(subject, content, email_from, email_to)
+    
+    def files_in_path(self, path):
+        """ return a list of all watchable files in path
+        (filter them if path is a directory) """
+        results = []
+        path = self.clean_path(path)
+        if os.path.isdir(path): #directory, recursive listing
+            for folder, subfolders, files in os.walk(path, True):
+                for file in files:
+                    subpath = os.path.join(folder, file)
+                    # self.stdout.write("%s\n" % (subpath))
+                    if self.filter(file):
+                        results.append(subpath)
+        elif os.path.isfile(path): # single file, adding to watchlist
+            results.append(path)
+        else:
+            raise ValueError("path isn't a file or directory")
+        return results
+        
+    def add_file(self, path, update=False):
+        """ add or update a single file's path to watch list
+        if not listed yet"""
+        if FileWatch.objects.filter(path=path).exists():
+            if not update:
+                self.skipped += 1
+                return
+            else:
+                watch = FileWatch.objects.get(path=path)
+                self.updated += 1
+        else:
+            watch = FileWatch(path=path)
+            self.added += 1
+        watch.set_stats()
+        watch.set_checksum()
+        watch.save()
+        return watch
+    
+    def filter(self, filename):
+        """ defines if filename is to be watched (.php .py .html) """
+        result = self.re_filter.search(filename)
+        # self.stdout.write("%s  = %s\n" % (filename, result))
+        return result is not None
+    
+    def clean_path(self, path):
+        path = os.path.abspath(path) # converting to absolute path
+        if not os.path.exists(path):
+            self.stdout.write("path %s does not exist, aborting" % path)
+        return path
+    
+    def _print(self, msg):
+        self.stdout.write(msg)
+#end
+# -*- coding: utf-8 -*-
+"""
+
+"""
+##
+# @author J.Ducastel <nospam0@ducastel.name>
+
+# imports
+import hashlib
+import os
+import stat
+from datetime import datetime, timedelta
+
+from django.db import models
+
+# exceptions
+
+# class et fonctions
+
+class FileWatch(models.Model):
+    """ file to watch """
+    
+    # file to watch changes
+    path = models.FilePathField(unique=True)
+    # file checksum algo$hash ex. md5$the_hash_string
+    checksum = models.CharField(max_length=250)
+    # file permissions
+    permissions = models.CharField(max_length=50)
+    # file size in bytes
+    size = models.IntegerField(default=0)
+    # file creation date
+    created = models.DateTimeField()
+    # file modification date
+    modified = models.DateTimeField()
+    watch_started = models.DateTimeField(auto_now_add=True)
+    watch_updated = models.DateTimeField(auto_now=True)
+    
+    @classmethod
+    def add_file(cls, file_path):
+        if not FileWatch.objects.filter(path=file_path).exists():
+            watch = FileWatch(path=file_path)
+            watch.set_stats()
+            watch.set_checksum()
+            watch.save()
+            return watch
+    
+    #def update_file(cls, file_path):
+    #    watch = 
+    
+    def get_checksum(self):
+        return 'md5$' + self.file_md5()
+    
+    def set_checksum(self):
+        """ computes and sets check sum"""
+        self.checksum = self.get_checksum()
+        
+    def get_stats(self):
+        return os.stat(self.path)
+    
+    def get_modified(self, file_stat):
+        return datetime.fromtimestamp(file_stat.st_mtime)
+    
+    def set_stats(self):
+        """ sets file stats (size, modified, permissions etc) """
+        file_stat = self.get_stats()
+        self.size = file_stat.st_size
+        self.permissions = file_stat.st_mode
+        self.permissions = self.get_permissions(file_stat)
+        self.created = datetime.fromtimestamp(file_stat.st_ctime)
+        self.modified = self.get_modified(file_stat)
+    
+    # @link https://stomp.colorado.edu/blog/blog/2010/10/22/on-python-stat-octal-and-file-system-permissions/
+    def get_permissions(self, file_stat):
+        """ converts permissions to octal """
+        return oct(stat.S_IMODE(file_stat.st_mode))
+    
+    def check_changes(self):
+        """ check for changes :
+        return dict of changes key: old, new """
+        checksum = self.get_checksum()
+        file_stat = self.get_stats()
+        permissions = self.get_permissions(file_stat)
+        modified = self.get_modified(file_stat)
+        changes = {}
+        if self.checksum != checksum:
+            changes['checksum'] = self.checksum, checksum
+        if self.permissions != permissions:
+            changes['permissions'] = self.permissions, permissions
+        if not self.cmp_datetimes(self.modified, modified):
+            changes['modified'] = self.modified, modified
+        return changes
+    
+    # @link http://stackoverflow.com/questions/1131220/get-md5-hash-of-a-files-without-open-it-in-python
+    def file_md5(self):
+        """ get the md5 hexdigest of the file """
+        md5 = hashlib.md5()
+        f = open(self.path) 
+        for chunk in iter(lambda: f.read(8192), ''): 
+            md5.update(chunk)
+        return md5.hexdigest()
+        
+    def cmp_datetimes(self, a, b):
+        """ compare datetimes excluding microsecond
+        True if equals"""
+        for attr in ('year', 'month', 'day', 'hour', 'minute', 'second'):
+            if getattr(a, attr) != getattr(b, attr):
+                return False
+        return True
+
+#class FileChange(models.Model):
+#    """ file change event """
+#    
+#    file = models.ForeignKey(FileWatch)
+#    # checksum of new version
+#    checksum = models.CharField(max_length=250)
+#    # file size in bytes
+#    size = models.IntegerField(default=0)
+#    # file modification date
+#    modified = models.DateTimeField()
+#    
+
+
+class Report(models.Model):
+    """ watch execution report """
+    
+    started = models.DateTimeField()
+    # in microseconds
+    time_taken = models.IntegerField(default=0)
+    watched = models.IntegerField(default=0)
+    changes = models.IntegerField(default=0)
+    content = models.TextField()
+    
+    def build(self):
+        """ walks all FileWatch, check for changes, build itself """
+        self.started = datetime.now()
+        self.watched = FileWatch.objects.count()
+        for watch in FileWatch.objects.all():
+            file_changes = watch.check_changes()
+            if file_changes != {}:
+                self.changes += 1
+                self.content += self.file_changes_summary(watch, file_changes)
+        delta = datetime.now() - self.started
+        self.time_taken = delta.microseconds
+    
+    def file_changes_summary(self, filewatch, changes):
+        txt = u"%s :" % filewatch.path
+        for label in ('checksum', 'permissions', 'modified'):
+            change = changes.get(label, None)
+            if change:
+                txt += u" %s %s > %s" % (label, change[0], change[1])
+        txt += u"\n"
+        return txt
+
+#end
+===========
+filewatcher
+===========
+
+This django's app is a command-line tool to watch a web app files modification.
+It stores files description in database. It provides no view or form.
+
+Typical usage
+=============
+
+1. Execute manage.py filewatch --add path to add files in path to watchlist
+    May be a single file. If path is a directory, it will add files in path
+    in watchlist if they have a .py, .php, .php4, .php5, .html or .js extension.
+2. Add manage.py filewatch --email as a cronjob. If one or more file has been modifed,
+    it will sends a report by email to settings.ADMINS
+    
+Reports are saved in database too. See management/commands/filewatch.py
+docstring for more options (update, list, remove, clear etc)
+"""
+Filewatch command tests
+"""
+
+import os
+
+from django.test import TestCase
+from django.core import mail
+from django.conf import settings
+from django.core.management import call_command
+
+from .models import FileWatch, Report
+from .management.commands.filewatch import Command as filewatch_command
+
+class FileWatchCommandTest(TestCase):
+    
+    def setUp(self):
+        """ creates a file to watch """
+        self.folder = os.path.dirname(__file__)
+        self.txt_file = os.path.join(self.folder, 'test.txt')
+        f = open(self.txt_file, 'w')
+        f.write('I am a sensitive file')
+        f.close()
+        
+    def tearDown(self):
+        """ remove files """
+        f = os.remove(self.txt_file)
+        
+    def test_add_single(self):
+        call_command('filewatch', add=self.txt_file)
+        self.assertTrue(FileWatch.objects.filter(path=self.txt_file).exists())
+        
+    def test_remove(self):
+        # adding a file to watch list
+        FileWatch.add_file(self.txt_file)
+        call_command('filewatch', remove=self.txt_file)
+        self.assertFalse(FileWatch.objects.filter(path=self.txt_file).exists())
+    
+    def test_report_email(self):
+        """ creates a file to watch, add to watchlist
+        then test filewatch command email sending """
+        # adding a file to watch list
+        watch = FileWatch.add_file(self.txt_file)
+        # updating file
+        f = open(self.txt_file, 'w')
+        f.write('I have been hacked L0z3r')
+        f.close()
+        # launching watch
+        call_command('filewatch', email=True)
+        # at last an email shall be sent
+        self.assertTrue(len(mail.outbox) > 0)
+        
+# end