From: Andrew Lorimer Date: Fri, 27 Sep 2019 11:01:24 +0000 (+1000) Subject: rename parsers, better journald integration X-Git-Url: https://git.lorimer.id.au/logparse.git/diff_plain/refs/heads/master?ds=inline rename parsers, better journald integration --- diff --git a/doc/source/index.rst b/doc/source/index.rst index f824ae9..19d92d3 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -43,9 +43,18 @@ Parsers The program is based on a model of independent **parsers** (consisting of Python modules) which analyse logs from a particular service. Logparse comes with a range of these built in, but additional parsers can be written in Python and placed in `/usr/share/logparse/parsers`. At the moment, the built-in parsers are: -- cron (DEPRECATED) - number of commands, list commands (root user only) -- cron-journald - number of commands, list commands, list commands per user (requires libsystemd) -- httpd - list requests, clients, user agents, bytes transferred, no. of errors +#### +cron +#### + +.. automodule:: logparse.parsers.cron + +#### +httpd +#### + +.. automodule:: logparse.parsers.httpd + - mem - get installed/usable/free memory - postfix - list recipients and bytes sent - smbd - number of logins, list users & clients @@ -54,7 +63,7 @@ The program is based on a model of independent **parsers** (consisting of Python - sudo (DEPRECATED)- number of sessions, list users and commands - sudo-journald - number of sessions, list users and commands (requires libsystemd) - sysinfo - hostname, OS, OS version, platform, processors -- systemctl - system status, running/failed units (requires libsystemd) +- systemd - system status, running/failed units (requires libsystemd) - temperature - instantaneous temperatures of motherboard, CPU, cores, disks - ufw - blocked packets, port and IP data (requires libsystemd) - zfs - zpool scrub reports, disk usage @@ -97,6 +106,8 @@ parsers Space-separated list of parsers to enable. If empty, all the included parsers are run except for deprecated ones. Analogous to the command line option -l|--logs. Default: empty ignore-parsers Space-separated list of parsers to ignore (i.e. not execute). If empty, no parsers are ignored. Analogous to the command line option -nl|--ignore-logs. Default: empty +datetime-format + String representing the format for dates and times in the logfiles (when using plain logfiles, not journald parsers). This should be a standard Python strptime format (see ). Supported parsers allow the datetime-format to be set in their individual sections, which overrides this global value. Default: %b %d %H:%M:%S ############################################## HTML specific configuration (`[html]` section) @@ -168,27 +179,38 @@ Default parser options Each parser has its own set of options in a section with the name of the parser. In the case of multiple versions of the same parser (e.g. sshd and sshd-journald), the configuration section goes by the base name (e.g. sshd). Options defined in individual parser sections override those defined in the global configuration. -#### -cron -#### +###################### +cron and cron_journald +###################### +commands + Regular expression string for which commands to include when parsing logs. If `truncate-commands` is set to true, then the truncated command will be compared against the regex pattern, not the full command. Default: `.*` +datetime-format + String representing the format for dates and times in the logfiles (when using cron, not cron_journald). This should be a standard Python strptime format (see ). Reverts to the global config if empty. Default: empty +list-users + Display a list of the most common commands run by each user (this may be combined with the `summary` option below). Default: true period - Maximum age of logs to analyse. Overrides global config. Only used in cron-journald at the moment. See :ref:`period` for more information. Default: empty + Maximum age of logs to analyse. Overrides global config. See :ref:`period` for more information. Default: empty +summary + Show a summary of cron sessions. This consists of the total number of sessions, total number of users, and a list of the most popular commands and who executed them. Default: false +truncate-commands + Whether to remove absolute directory paths in commands. When set to true, a command such as `/usr/bin/cat` will become `cat`. Default: true +users + Regular expression string for which usernames to include when parsing logs. This could be used to exclude cron sessions from trusted users. Default: `.*` -.. _period: -#### -sshd -#### +###################### +sshd and sshd_journald +###################### period Maximum age of logs to analyse. Overrides global config. Only used in sshd-journald at the moment. See :ref:`period` for more information. Default: empty sshd-resolve-domains DNS lookup configuration for sshd parsers only (overrides global config). Accepted values are `ip`, `fqdn`, `fqdn-implicit`, and `host-only`. See the global setting `resolve-domains` for more information. Default: empty -#### -smbd -#### +###################### +smbd and smbd_journald +###################### shares Regular expression string for which Samba shares to include when parsing logs. To consider all shares, set this to `.*`. To exclude a certain share, use negative lookaround. Default: `^((?!IPC\$).)*$` @@ -217,16 +239,22 @@ ufw-resolve-domains period Maximum age of logs to analyse. Overrides global config. See :ref:`period` for more information. Default: empty -#### -sudo -#### +###################### +sudo and sudo_journald +###################### +list-users + Display a list of the most common commands initiated by each user (this may be combined with the `summary` option below). Default: true period Maximum age of logs to analyse. Overrides global config. See :ref:`period` for more information. Default: empty +summary + Show a summary of sudo sessions and most popular commands. Default: false +truncate-commands + Whether to remove absolute directory paths in commands. When set to true, a command such as `/usr/bin/cat` will become `cat`. Default: true -######### -systemctl -######### +####### +systemd +####### period Maximum age of logs to analyse. Overrides global config. See :ref:`period` for more information. Default: empty @@ -234,6 +262,7 @@ show-all Whether to include services which are running but okay in the output. Default: true +.. _period: ======================== Log period configuration diff --git a/header.html b/header.html index b5da1fe..0b2c0c6 100755 --- a/header.html +++ b/header.html @@ -8,7 +8,7 @@ - +

$title

$title

Parsing logs since $period

$hostname
$date $time
diff --git a/logparse/__init__.py b/logparse/__init__.py index 41c6100..50cc98d 100644 --- a/logparse/__init__.py +++ b/logparse/__init__.py @@ -35,7 +35,7 @@ COLORS = { } # Template for formatting log messages -FORMAT = ("{bold}%(name)-15s{reset} %(levelname)-18s %(message)s " +FORMAT = ("{bold}%(module)-15s{reset} %(levelname)-18s %(message)s " "({bold}%(filename)s{reset}:%(lineno)d)") diff --git a/logparse/config.py b/logparse/config.py index bcdc8b2..00143fd 100644 --- a/logparse/config.py +++ b/logparse/config.py @@ -35,7 +35,9 @@ defaults = { 'hostname-path': '/etc/hostname', 'parsers': '', 'ignore-parsers': '', - 'period': '1 minute' + 'period': '1 week', + 'datetime-format': "%%b %%d %%H:%%M:%%S", + 'journald': True }, 'html': { 'header': '/etc/logparse/header.html', @@ -62,7 +64,13 @@ defaults = { 'httpd-error': '/var/log/apache2/error.log' }, 'cron': { - 'period': '' + 'summary': False, + 'list-users': True, + 'period': '', + 'datetime-format': '', + 'truncate-commands': True, + 'users': '.*', + 'commands': '.*' }, 'mail': { 'to': '', @@ -91,7 +99,12 @@ defaults = { }, 'httpd': { 'httpd-resolve-domains': '', - 'period': '' + 'datetime-format': "%%d/%%b/%%Y:%%H:%%M:%%S %%z", + 'period': '', + 'clients': '.*', + 'files': '.*', + 'referrers': '.*', + 'access-format': "%%h %%l %%u %%t \"%%r\" %%>s %%O \"%%{Referer}i\" \"%%{User-Agent}i\"" }, 'du': { 'paths': ['/', '/etc', '/home'], @@ -102,12 +115,19 @@ defaults = { 'period': '' }, 'sudo': { + 'journald': '', + 'datetime-format': '', 'period': '', 'list-users': True, 'summary': True, - 'truncate-commands': True + 'truncate-commands': True, + 'init-users': '.*', + 'superusers': '.*', + 'commands': '.*', + 'directories': '.*' + }, - 'systemctl': { + 'systemd': { 'period': '', 'show-all': True } @@ -126,7 +146,8 @@ def loadconf(configpaths): prefs.read_dict(defaults) try: success = prefs.read(configpaths) - logger.debug("Loaded {0} config file(s): {1}".format(str(len(success)), str(success))) + logger.debug("Loaded {0} config file(s): {1}".format( + str(len(success)), str(success))) except Exception as e: logger.warning("Error processing config: " + str(e)) return prefs diff --git a/logparse/formatting.py b/logparse/formatting.py index 624d66c..5b53671 100644 --- a/logparse/formatting.py +++ b/logparse/formatting.py @@ -14,6 +14,7 @@ import locale from string import Template from math import floor, ceil from tabulate import tabulate +import textwrap import logparse from logparse import interface, util, mail, config @@ -38,6 +39,7 @@ JXNCHARS_DOUBLE = ['╠', '╣', '╦', '╩', '╬'] JXNCHARS_SINGLE = ['├', '┤', '┬', '┴', '┼'] BULLET = "• " INDENT = " " +SPLIT_CHARS = ['.', '(', ')', '[', ']', '&', r"/", "\\", ',', '-', '_'] global VARSUBST @@ -67,7 +69,9 @@ def init_var(): "hostname": util.hostname(config.prefs.get( "logparse", "hostname-path")), "version": logparse.__version__, - "css": css_path + "css": css_path, + "period": util.LogPeriod("logparse").startdate.strftime( + TIMEFMT + " " + DATEFMT) } @@ -133,17 +137,17 @@ class PlaintextOutput(Output): Print details with some primitive formatting """ box = PlaintextBox(content= - Template("$title $version on $hostname\n\n$time $date") + Template("$title $version on $hostname\n\n$time $date" + "\nParsing logs since $period") .safe_substitute(VARSUBST), vpadding=2, hpadding="\t\t", linewidth=self.linewidth) - line = PlaintextLine(self.linewidth) - self.append(box.draw() + line.draw()) + self.append(box.draw() + "\n"*2) def append_footer(self): """ Append a horizontal line and some details """ - self.append(PlaintextLine(self.linewidth, vpadding=1).draw()) + self.append(PlaintextLine(self.linewidth).draw()) self.append(Template("$hostname $time $date").safe_substitute(VARSUBST)) def append_section(self, section): @@ -181,35 +185,40 @@ class PlaintextOutput(Output): logger.warning("No subtitle provided.. skipping section") return + logger.debug("Processing data {}".format(subtitle)) + if (data == None or len(data) == 0): - logger.debug("No data provided.. just printing subtitle") - return subtitle + '\n' + # If no list items are provided, just print the subtitle + return subtitle + "\n" + elif (len(data) == 1): + # If only one item is provided, print it inline with subtitle + return self._wrap_datum("{}: {}".format(subtitle, data[0]), + bullet=False, indent=False) + "\n" else: - logger.debug("Received data " + str(data)) - subtitle += ':' - if (len(data) == 1): - return subtitle + ' ' + data[0] + '\n' - else: - itemoutput = subtitle + '\n' - for datum in data: - datum = BULLET + datum - if len(datum) > self.linewidth - 3: - words = datum.split() - if max(map(len, words)) > self.linewidth - len(INDENT): - continue - res, part, others = [], words[0], words[1:] - for word in others: - if 1 + len(word) > self.linewidth - len(part): - res.append(part) - part = word - else: - part += ' ' + word - if part: - res.append(part) - datum = ('\n ').join(res) - datum = INDENT + datum - itemoutput += datum + '\n' - return itemoutput + # If many items are provided, print them all as a bulleted list + itemoutput = subtitle + ":\n" + for datum in data: + itemoutput += self._wrap_datum(datum) + "\n" + return itemoutput + + def _wrap_datum(self, text, bullet=True, indent=True): + """ + Use cpython's textwrap module to limit line width to the value + specified in self.linewidth. This is much easier than doing it all + from scratch (which I tried to do originally). Note that line + continuations are automatically indented even if they don't have a + bullet. This is to make it clear which lines are continuations. + """ + + wrapper = textwrap.TextWrapper( + initial_indent=(INDENT if indent else "") \ + + (BULLET if bullet else ""), + subsequent_indent=INDENT + (' '*len(BULLET) if bullet else ""), + width=self.linewidth, + replace_whitespace=True) + + return wrapper.fill(text) + class HtmlOutput(Output): """ @@ -322,7 +331,7 @@ class HtmlOutput(Output): logger.debug("No data provided.. just printing subtitle") return tag('p', False, subtitle, cl="severity-" + str(severity)) else: - logger.debug("Received data " + str(data)) + logger.debug("Received data {}: {}".format(subtitle, data)) subtitle += ':' if (len(data) == 1): return tag('p', False, subtitle + ' ' + data[0], @@ -407,9 +416,11 @@ class Data: def truncl(self, limit): # truncate list """ Truncate self.items to a specified value and state how many items are - hidden. + hidden. Set limit to -1 to avoid truncating any items. """ + if limit == -1: + return self if (len(self.items) > limit): more = len(self.items) - limit if more == 1: @@ -543,8 +554,8 @@ class Row(object): class Column(object): """ - Object representing a single table cell. This is somewhat of a misnomer - - one column object exists for each cell in the table. Columns are children + Object representing a single table cell. "Column" is somewhat of a misnomer + - one column object exists for each cell in the table. Columns are children of rows. """ @@ -563,7 +574,7 @@ class PlaintextLine: Draw a horizontal line for plain text format, with optional padding/styling. """ - def __init__(self, linewidth=80, double=True, vpadding=1, hpadding=""): + def __init__(self, linewidth=80, double=True, vpadding=0, hpadding=""): """ Initialise variables """ @@ -581,7 +592,7 @@ class PlaintextLine: line = (LINECHARS_DOUBLE[1] if self.double else LINECHARS_SINGLE[1]) return "\n" * self.vpadding + self.hpadding \ + line * (self.linewidth - 2 * len(self.hpadding)) \ - + self.hpadding + "\n" * self.vpadding + + self.hpadding + "\n" * (self.vpadding + 1) class PlaintextBox: @@ -623,7 +634,7 @@ class PlaintextBox: contentwidth = int((self.linewidth if self.linewidth > 0 else 80) if self.content.splitlines() else len(max(contentlines, key=len))) - logger.debug("Contentwidth is {0}".format(str(contentwidth))) + logger.debug("Content width is {0}".format(str(contentwidth))) logger.debug("Longest line is {0}".format( len(max(contentlines, key=len)))) contentwidth += -2*(len(self.hpadding)+1) diff --git a/logparse/interface.py b/logparse/interface.py index bee8d49..46c5aea 100644 --- a/logparse/interface.py +++ b/logparse/interface.py @@ -17,7 +17,7 @@ from copy import copy import logging import logging.handlers import os -from sys import stdin, version +from sys import exit, stdin, version from subprocess import check_output from datetime import datetime @@ -94,6 +94,12 @@ def main(): # Set up parsers loader = load_parsers.ParserLoader() + + try: + loader.check_systemd() + except Exception as e: + logger.error("Failed to check systemd dependencies: ".format(e)) + if parser_names: for parser_name in parser_names: if parser_name not in ignore_logs: @@ -105,8 +111,23 @@ def main(): # Execute parsers + executed_parsers = [] + for parser in loader.parsers: - output.append_section(parser.parse_log()) + if (argparser.parse_args().verbose + or config.prefs.getboolean("logparse", "verbose")): + output.append_section(parser.parse_log()) + + else: + try: + output.append_section(parser.parse_log()) + except Exception as e: + logger.error("Uncaught error executing logger {0}: {1}".format( + parser.name, e)) + executed_parsers.append(parser.name) + + if len(executed_parsers) == 0: + exit() # Write footer output.append_footer() @@ -213,6 +234,7 @@ def main(): return + def get_argparser(): """ Initialise arguments (in a separate function for documentation purposes) diff --git a/logparse/load_parsers.py b/logparse/load_parsers.py index c0ed524..341cb61 100644 --- a/logparse/load_parsers.py +++ b/logparse/load_parsers.py @@ -12,10 +12,13 @@ Classes in this module: """ import importlib +from importlib import util from os.path import dirname from pkgutil import iter_modules import inspect from pathlib import Path +import subprocess +from subprocess import Popen, PIPE from typing import get_type_hints import logging @@ -68,6 +71,35 @@ class Parser(): """ raise NotImplementedError("Failed to find an entry point for parser") + def check_dependencies(self) -> tuple: + """ + Parsers should check their own requirements here and return a boolean + value reflecting whether the parser can run successfully. Typically + this method should check for the program whose logs are being parsed, + as well as any external dependencies like libsystemd. This method + should return a tuple containing a boolean representing whether or not + the dependencies are satisfied and list containing the names of any + dependencies that are unsatisfied. + """ + return (True, None) + + def _check_dependency_command(self, cmdline) -> tuple: + """ + Runs a shell command (typically something --version) and returns the + output and return code as a tuple. The command to run is passed as a + string, optionally including arguments, in the `cmdline` argument. + """ + logger.debug("Checking output of command " + cmdline) + cmd = subprocess.getstatusoutput(cmdline) + if cmd[0] != 0: + logger.warning("{0} is not available on this system (`{1}` " + "returned code {2}: \"{3}\")".format( + cmdline.split()[0], cmdline, *cmd)) + return cmd + else: + logger.debug("Command {0} succeeded".format(cmdline)) + return cmd + class ParserLoader: @@ -88,24 +120,66 @@ class ParserLoader: self.pkg = pkg self.path = path self.parsers = [] + self.has_systemd = False def search(self, pattern): """ - Basic wrapper for the two search functions below. + Find a parser and determine its journald attribute. When a user + requests a parser of the form .*_journald, this function will use + that parser if it exists, but if not it will revert to using the + base parser (without the _journald) if it has a journald attribute. + If it does not have this error (and the parser as requested does not + exist), then no parser is loaded.. """ + # Separate into underscore words + split_name = pattern.split("_") + + # Check if parser exists with exact name requested by user + result = self._search_both(pattern) + + if result == None and split_name[-1] == "journald": + # No match for exact name but .*_journald was requested... + if self.has_systemd: + # Look for base parser with journald attribute + result = self._search_both("".join(split_name[:-1])) + if result == None: + logger.error("Couldn't find a matching parser module " + "for {0}".format(pattern)) + if not hasattr(result, "journald"): + logger.error("Found parser {} but it does not support " + "journald".format("".join(split_name[:-1]))) + result = None + else: + result.journald = True + else: + logger.error("A parser that requires systemd was requested " + "but the dependencies are not installed.") + return None + if not result.deps_ok: + return None + + if result == None: + # Still can't find a matching parser + logger.error("Couldn't find a matching parser module " + "for {0}".format(pattern)) + else: + self.parsers.append(result) + + return result + + def _search_both(self, pattern): + """ + Basic wrapper for the two search functions below. + """ default_parser = self._search_default(pattern) if default_parser != None: - self.parsers.append(default_parser) return default_parser else: user_parser = self._search_user(pattern) if user_parser != None: - self.parsers.append(user_parser) return user_parser else: - logger.warning("Couldn't find a matching parser module " - "for search term {0}".format(pattern)) return None def _search_user(self, pattern): @@ -144,11 +218,13 @@ class ParserLoader: 4. Must provide the parse_log() method 5. Must not return None 6. Must not match an already-loaded class + 7. Dependencies must exist """ logger.debug("Checking validity of module {0} at {1}".format( parser_module.__name__, parser_module.__file__)) available_parsers = [] + missing_dependencies = [] clsmembers = inspect.getmembers(parser_module, inspect.isclass) # Check individual classes @@ -156,16 +232,16 @@ class ParserLoader: if not (issubclass(c, Parser) & (c is not Parser)): continue if c in self.parsers: - logger.warning("Parser class {0} has already been loaded " + logger.error("Parser class {0} has already been loaded " "from another source, ignoring it".format( c.__class__.__name__, c.__file__)) if not inspect.isroutine(c.parse_log): - logger.warning("Parser class {0} in {1} does not contain a " + logger.error("Parser class {0} in {1} does not contain a " "parse_log() method".format( c.__class__.__name__, c.__file__)) continue if None in get_type_hints(c): - logger.warning("Parser class {0} in {1} contains a " + logger.error("Parser class {0} in {1} contains a " "null-returning parse_log() method".format( c.__class__.__name__, c.__file__)) continue @@ -174,24 +250,89 @@ class ParserLoader: logger.warning("Parser {0} is deprecated - " "use {1} instead".format( parser_obj.name, parser_obj.successor)) + # Check dependencies + deps = parser_obj.check_dependencies() + if deps[0]: + parser_obj.deps_ok = True + else: + logger.error("The following dependencies are missing for " + "parser {0}: {1}".format(parser_obj.name, + ", ".join(deps[1]))) + missing_dependencies.append(parser_obj) + parser_obj.deps_ok = False + logger.debug("Found parser {0}.{1}".format( c.__module__, c.__class__.__name__)) available_parsers.append(parser_obj) # Check module structure + if len(available_parsers) > 1: + logger.error("Found multiple valid parser classes in {0} at {1} " + "- ignoring this module" + .format(parser_module.__name__, parser_module.__file__)) + return None + elif len(available_parsers) == 0: + if len(missing_dependencies) > 0: + return None + logger.error("No valid classes in {0} at {1}". + format(parser_module.__name__, parser_module.__file__)) + return None if len(available_parsers) == 1: logger.debug("Parser module {0} at {1} passed validity checks" .format(parser_module.__name__, parser_module.__file__)) return available_parsers[0] - elif len(available_parsers) == 0: - logger.warning("No valid classes in {0} at {1}". - format(parser_module.__name__, parser_module.__file__)) - return None - elif len(available_parsers) > 1: - logger.warning("Found multiple valid parser classes in {0} at {1} " - "- ignoring this module" - .format(parser_module.__name__, parser_module.__file__)) - return None + + def check_systemd(self): + """ + Check if the appropriate dependencies are installed for parsing + systemd logs. + + Output codes: + 0. systemd + libsystemd + systemd-python are installed + 1. systemd + libsystemd are installed + 2. systemd is installed + 3. systemd is not installed, no support required + """ + # Test if systemctl works + systemctl_cmd = Popen(["systemctl", "--version"], stdout=PIPE) + systemctl_cmd.communicate() + + if systemctl_cmd.returncode == 0: + logger.debug("Passed systemctl test") + + # Test if libsystemd exists + libsystemd_cmd = Popen(["locate", "libsystemd.so.0"], stdout=PIPE) + libsystemd_cmd.communicate() + + if libsystemd_cmd.returncode == 0: + logger.debug("Passed libsystemd test") + + # Test if systemd-python exists + if util.find_spec("systemd") is not None: + logger.debug("Passed systemd-python test") + self.has_systemd = True + logger.debug("Passed all systemd dependency checks") + return 0 + else: + logger.warning("Systemd is running on this system but the " + "package systemd-python is not installed. Parsers " + "that use journald will not work. For more " + "features, install systemd-python from " + " or " + "`pip install systemd-python`.") + return 1 + else: + logger.warning("Systemd is running on this system but " + "libsystemd headers are missing. This package is " + "required to make use of the journald parsers. " + "Libsystemd should be available with your package " + "manager of choice.") + return 2 + else: + logger.debug("Systemd not installed.. parsers that use journald " + "will not work.") + return 3 + def load_pkg(self): """ diff --git a/logparse/parsers/cron.py b/logparse/parsers/cron.py index d984a57..4cb12db 100644 --- a/logparse/parsers/cron.py +++ b/logparse/parsers/cron.py @@ -1,53 +1,203 @@ -# -# 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