-#
-# 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.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