rename parsers, better journald integration
[logparse.git] / logparse / parsers / cron.py
index d984a572c14c3047aad19fdec2ae79debb2598a5..4cb12db2d529f782cdb7b5ef32b9d437ddd440aa 100644 (file)
-#
-#   cron.py
-#
-#   List the logged (executed) cron jobs and their commands (uses syslog file)
-#
-#   NOTE: This file is now deprecated in favour of the newer journald mechanism
-#   used in cron-journald.py. This parser is still functional but is slower and
-#   has less features. Please switch over if possible.
-#
+"""
+Get information about executed cron commands - number of commands, list
+of commands, and list of commands per user. Uses either journald or plain
+logfiles (path specified in config).
 
+NOTE: This parser supports reading from both journald and plain syslog files. 
+By default the plain logfiles will be used, but the journald option is 
+preferred for newer systems which support it. To use the journald mode, 
+specify the parser as `cron_journald` instead of `cron`.
+
+NOTE: If using journald, the log level for cron.service should be at least 2 
+(default is 1). This can be changed with `sudo systemctl edit cron --full`, 
+and ammend `-L 2` to the ExecStart command.
+
+TODO: also output a list of scheduled (future) jobs
+"""
+
+import datetime
 import re
 
 from logparse.formatting import *
 from logparse.util import readlog
 from logparse import config
 from logparse.load_parsers import Parser
-from logparse.load_parsers import Parser
+
+class CronCommand:
+    """
+    Class representing a single cron session. Assigns its own variables of 
+    date, user and cmd when given a `systemd.journal.Record` object or a plain 
+    log message string on initialisation. NOTE: This class is used in both
+    `cron.py` and `cron_journald.py`.
+    """
+
+    def __init__(self, record, datefmt=""):
+        """
+        Parse the date, user and command from the logfile string or record
+        """
+        if isinstance(record, str):
+            if not datefmt:
+                logger.error("Date format not provided - cannot parse this "
+                        "log message")
+            # Parse from a raw logfile string
+            self.date, self.user, self.cmd = re.search(
+                    r"^(?P<time>.+)\s\w+\sCRON"
+            "\[\d+\]:\s\((?P<user>\S*)\)\sCMD\s\(+(\[\d+\]\s)?(?P<cmd>.*)\)+",
+                    record).groupdict().values()
+            self.date = datetime.datetime.strptime(self.date, datefmt)
+            if not "Y" in datefmt:
+                self.date = self.date.replace(year=datetime.datetime.now().year)
+        elif isinstance(record, dict):
+            self.date = record["_SOURCE_REALTIME_TIMESTAMP"]
+            self.user, self.cmd = re.search(r"\((?P<user>\S+)\) "
+                    "CMD \((\[\d+\] )?(?P<cmd>.*)\)", record["MESSAGE"]) \
+                    .groupdict().values()
+            self.cmd = " ".join(self.cmd.split())
+        else:
+            raise TypeError("record should be str or dict")
+
+    def truncate(self):
+        """
+        Hide the full directory path for any scripts or explicit binary
+        references in the command. e.g. `/usr/bin/cat` → `cat`
+        """
+        self.cmd = re.sub(r"(\s|^)/\S*/(\S+)", r"\1\2", self.cmd)
+        return self.cmd
+
+    def match_user(self, pattern):
+        """
+        Check if the user of this object matches against a regex string and 
+        return a boolean result of this comparison.
+        """
+        user_match = False
+        for p in pattern:
+            user_match = re.fullmatch(p, self.user) \
+                or user_match
+        return user_match
+
+    def match_cmd(self, pattern):
+        """
+        Check if the command of this object matches against a regex string and 
+        return a boolean result of this comparison.
+        """
+        cmd_match = False
+        for p in pattern:
+            cmd_match = re.fullmatch(p, self.user) \
+                or cmd_match
+        return cmd_match 
+
 
 class Cron(Parser):
 
     def __init__(self):
         super().__init__()
         self.name = "cron"
-        self.info = "List the logged (executed) cron jobs and their commands (uses static syslog file)"
-        self.deprecated = True
-        self.successor = "cron_journald"
+        self.info = "List the logged (executed) cron jobs and their commands"
+        self.journald = False
 
-    def parse_log(self):
+    def _get_journald(self, startdate):
+        from systemd import journal
+        j = journal.Reader()
+        j.this_machine()
+        j.log_level(journal.LOG_INFO)
+        j.add_match(_COMM="cron")
+        j.seek_realtime(startdate)
+        return [entry for entry in j if "MESSAGE" in entry 
+                and " CMD " in entry["MESSAGE"]]
+
+    def _get_logfile(self, path):
+        from logparse.util import readlog
+        return [x for x in readlog(path).splitlines() if " CMD " in x] 
 
-        logger.warning("NOTE: This cron parser is now deprecated. Please use cron-journald if possible.")
+    def parse_log(self):
 
         logger.debug("Starting cron section")
         section = Section("cron")
 
-        matches = re.findall('.*CMD\s*\(\s*(?!.*cd)(.*)\)', readlog(config.prefs.get("logs", "cron")))
-        num = len(matches)
-        commands = []
-        for match in matches:
-            commands.append(str(match))
-        logger.info("Found " + str(num) + " cron jobs")
-        jobs_data = Data(str(num) + " cron jobs run")
-        section.append_data(jobs_data)
-
-        if (num > 0):
-            logger.debug("Analysing cron commands")
-            cmd_data = Data("Top cron commands")
-            cmd_data.items = ("`{0}`".format(x) for x in commands)
-            cmd_data.orderbyfreq()
-            cmd_data.truncl(config.prefs.getint("logparse", "maxcmd"))
-            section.append_data(cmd_data)
+        if not (config.prefs.getboolean("cron", "summary") 
+                or config.prefs.getboolean("cron", "list-users")):
+            logger.warning("Both summary and list-users configuration options "
+                "are set to false, so no output will be generated. "
+                "Skipping this parser.")
+            return None
+
+        datefmt = config.prefs.get("cron", "datetime-format")
+        if not datefmt:
+            datefmt = config.prefs.get("logparse", "datetime-format")
+        if not datefmt:
+            logger.error("Invalid datetime-format configuration parameter")
+            return None
+
+        command_objects = []
+        users = {}
+        oldlog_buffer = 0
+
+        if self.journald:
+            logger.debug("Searching for cron commands in journald")
+            messages = self._get_journald(section.period.startdate)
+        else:
+            logger.debug("Searching for matches in {0}".format(
+                config.prefs.get("logs", "cron")))
+            messages = self._get_logfile(config.prefs.get("logs", "cron"))
+
+        if len(messages) < 1:
+            logger.error("Couldn't find any cron log messages")
+            return
+
+        for msg in messages:
+
+            try:
+                cmd_obj = CronCommand(msg, datefmt)
+            except Exception as e:
+                logger.warning("Malformed cron session log: {0}. "
+                    "Error message: {1}".format(msg, str(e)))
+                continue
+            else:
+                if cmd_obj.date < section.period.startdate:
+                    continue
+                if not (cmd_obj.match_user(config.prefs.get("cron", "users")
+                    .split()) and cmd_obj.match_cmd(config.prefs.get(
+                        "cron", "commands").split())):
+                    logger.debug("Ignoring cron session by {0} with command "
+                        "{1} due to config".format(cmd_obj.user, cmd_obj.cmd))
+                    continue
+
+            if config.prefs.getboolean("cron", "truncate-commands"):
+                cmd_obj.truncate()
+
+            command_objects.append(cmd_obj)
+            if not cmd_obj.user in users:
+                users[cmd_obj.user] = []
+            users[cmd_obj.user].append(cmd_obj.cmd)
+
+        if len(command_objects) == 0:
+            logger.error("No valid cron commands found")
+            return
+
+        logger.info("Found {0} cron jobs".format(len(command_objects)))
+
+        if config.prefs.getboolean("cron", "summary"):
+            summary_data = Data()
+            summary_data.subtitle = "Total of " + plural("cron session",
+                    len(command_objects)) + " for " + plural("user",
+                            len(users))
+            summary_data.items = ["{}: `{}`".format(c.user, c.cmd)
+                    for c in command_objects]
+            summary_data.orderbyfreq()
+            summary_data.truncl(config.prefs.getint("logparse", "maxcmd"))
+            section.append_data(summary_data)
+
+        if config.prefs.getboolean("cron", "list-users"):
+            for user, cmdlist in users.items():
+                user_data = Data()
+                user_data.subtitle = plural("session", len(cmdlist)) \
+                        + " for " + user + (" (" + plural("unique command",
+                            len(set(cmdlist))) + ")" if len(set(cmdlist)) > 1
+                            else "")
+                user_data.items = ["`{}`".format(cmd) for cmd in cmdlist]
+                user_data.orderbyfreq()
+                user_data.truncl(config.prefs.getint("logparse", "maxcmd"))
+                section.append_data(user_data)
 
         logger.info("Finished cron section")
         return section