From 840692805840a9397f5d380bbf6274e33e780ef0 Mon Sep 17 00:00:00 2001 From: Johnny Date: Thu, 30 Aug 2018 00:30:02 +0000 Subject: [PATCH 1/7] Implements user input and server output auditing. --- evennia/contrib/auditing/__init__.py | 0 evennia/contrib/auditing/example.py | 22 +++ evennia/contrib/auditing/server.py | 266 +++++++++++++++++++++++++++ evennia/contrib/auditing/tests.py | 66 +++++++ 4 files changed, 354 insertions(+) create mode 100644 evennia/contrib/auditing/__init__.py create mode 100644 evennia/contrib/auditing/example.py create mode 100644 evennia/contrib/auditing/server.py create mode 100644 evennia/contrib/auditing/tests.py diff --git a/evennia/contrib/auditing/__init__.py b/evennia/contrib/auditing/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/evennia/contrib/auditing/example.py b/evennia/contrib/auditing/example.py new file mode 100644 index 0000000000..d75942489c --- /dev/null +++ b/evennia/contrib/auditing/example.py @@ -0,0 +1,22 @@ +from evennia.utils.logger import * +from twisted.internet.threads import deferToThread +import json + +def output(data, *args, **kwargs): + """ + Writes dictionaries of data generated by an AuditedServerSession to files + in JSON format, bucketed by date. + + Uses Evennia's native logger and writes to the default + log directory (~/yourgame/server/logs/ or settings.LOG_DIR) + + Args: + data (dict): Parsed session transmission data. + + """ + # Bucket logs by day + bucket = data.pop('objects')['time'].strftime('%Y-%m-%d') + + # Write it + log_file(json.dumps(data), filename="auditing_%s.log" % bucket) + \ No newline at end of file diff --git a/evennia/contrib/auditing/server.py b/evennia/contrib/auditing/server.py new file mode 100644 index 0000000000..6bdd06bbbf --- /dev/null +++ b/evennia/contrib/auditing/server.py @@ -0,0 +1,266 @@ +""" +Auditable Server Sessions: +Extension of the stock ServerSession that yields objects representing +all user input and all system output. + +Evennia contribution - Johnny 2017 +""" + +import os +import re +import socket + +from django.utils import timezone +from django.conf import settings as ev_settings +from evennia.utils import logger, mod_import, get_evennia_version +from evennia.server.serversession import ServerSession + +# Attributes governing auditing of commands and where to send log objects +AUDIT_CALLBACK = getattr(ev_settings, 'AUDIT_CALLBACK', None) +AUDIT_IN = getattr(ev_settings, 'AUDIT_IN', False) +AUDIT_OUT = getattr(ev_settings, 'AUDIT_OUT', False) +AUDIT_MASK_IGNORE = set(['@ccreate', '@create'] + getattr(ev_settings, 'AUDIT_IGNORE', [])) +AUDIT_MASK_KEEP_BIGRAM = set(['create', 'connect', '@userpassword'] + getattr(ev_settings, 'AUDIT_MASK_KEEP_BIGRAM', [])) + +if AUDIT_CALLBACK: + try: + AUDIT_CALLBACK = mod_import(AUDIT_CALLBACK).output + logger.log_info("Auditing module online.") + logger.log_info("Recording user input = %s." % AUDIT_IN) + logger.log_info("Recording server output = %s." % AUDIT_OUT) + except Exception as e: + logger.log_err("Failed to activate Auditing module. %s" % e) + +class AuditedServerSession(ServerSession): + """ + This class represents a player's session and is a template for + both portal- and server-side sessions. + + Each connection will see two session instances created: + + 1. A Portal session. This is customized for the respective connection + protocols that Evennia supports, like Telnet, SSH etc. The Portal + session must call init_session() as part of its initialization. The + respective hook methods should be connected to the methods unique + for the respective protocol so that there is a unified interface + to Evennia. + 2. A Server session. This is the same for all connected accounts, + regardless of how they connect. + + The Portal and Server have their own respective sessionhandlers. These + are synced whenever new connections happen or the Server restarts etc, + which means much of the same information must be stored in both places + e.g. the portal can re-sync with the server when the server reboots. + + This particular implementation parses all server inputs and/or outputs and + passes a dict containing the parsed metadata to a callback method of your + creation. This is useful for recording player activity where necessary for + security auditing, usage analysis or post-incident forensic discovery. + + *** WARNING *** + All strings are recorded and stored in plaintext. This includes those strings + which might contain sensitive data (create, connect, @password). These commands + have their arguments masked by default, but you must mask or mask any + custom commands of your own that handle sensitive information. + + Installation: + + Designate this class as the SERVER_SESSION_CLASS in `settings.py`, then set + some additional options concerning what to log and where to send it. + + settings.py: + SERVER_SESSION_CLASS = 'evennia.contrib.auditing.server.AuditedServerSession' + + # Where to send logs? Define the path to a module containing a function + # called 'output()' you've written that accepts a dict object as its sole + # argument. From that function you can store/forward the message received + # as you please. An example file-logger is below: + AUDIT_CALLBACK = 'evennia.contrib.auditing.examples' + + # Log all user input? Be ethical about this; it will log all private and + # public communications between players and/or admins. + AUDIT_IN = True/False + + # Log all server output? This will result in logging of ALL system + # messages and ALL broadcasts to connected players, so on a busy MUD this + # will be very voluminous! + AUDIT_OUT = True/False + + # What commands do you NOT want masked for sensitivity? + AUDIT_MASK_IGNORE = ['@ccreate', '@create'] + + # What commands do you want to keep the first two terms of, masking the rest? + # This only triggers if there are more than two terms in the message. + AUDIT_MASK_KEEP_BIGRAM = ['create', 'connect', '@userpassword'] + """ + def audit(self, **kwargs): + """ + Extracts messages and system data from a Session object upon message + send or receive. + + Kwargs: + src (str): Source of data; 'client' or 'server'. Indicates direction. + text (list): Message sent from client to server. + text (str): Message from server back to client. + + Returns: + log (dict): Dictionary object containing parsed system and user data + related to this message. + + """ + # Get time at start of processing + time_obj = timezone.now() + time_str = str(time_obj) + + # Sanitize user input + session = self + src = kwargs.pop('src', '?') + bytes = 0 + + if src == 'client': + try: + data = str(kwargs['text'][0][0]) + except IndexError: + logger.log_err('Failed to parse client-submitted string!') + return False + + elif src == 'server': + # Server outputs can be unpredictable-- sometimes tuples, sometimes + # plain strings. Try to parse both. + try: + if isinstance(kwargs.get('text', ''), (tuple,list)): + data = kwargs['text'][0] + elif not 'text' in kwargs and len(kwargs.keys()) == 1: + data = kwargs.keys()[0] + else: + data = str(kwargs['text']) + + except: data = str(kwargs) + + bytes = len(data.encode('utf-8')) + + data = data.strip() + + # Do not log empty lines + if not data: return {} + + # Get current session's IP address + client_ip = session.address + + # Capture Account name and dbref together + account = session.get_account() + account_token = '' + if account: + account_token = '%s%s' % (account.key, account.dbref) + + # Capture Character name and dbref together + char = session.get_puppet() + char_token = '' + if char: + char_token = '%s%s' % (char.key, char.dbref) + + # Capture Room name and dbref together + room = None + room_token = '' + if char: + room = char.location + room_token = '%s%s' % (room.key, room.dbref) + + # Mask any PII in message, where possible + data = self.mask(data, **kwargs) + + # Compile the IP, Account, Character, Room, and the message. + log = { + 'time': time_str, + 'hostname': socket.getfqdn(), + 'application': '%s' % ev_settings.SERVERNAME, + 'version': get_evennia_version(), + 'pid': os.getpid(), + 'direction': 'SND' if src == 'server' else 'RCV', + 'protocol': self.protocol_key, + 'ip': client_ip, + 'session': 'session#%s' % self.sessid, + 'account': account_token, + 'character': char_token, + 'room': room_token, + 'msg': '%s' % data, + 'bytes': bytes, + 'objects': { + 'time': time_obj, + 'session': self, + 'account': account, + 'character': char, + 'room': room, + } + } + + return log + + def mask(self, msg, **kwargs): + """ + Masks potentially sensitive user information within messages before + writing to log. Recording cleartext password attempts is bad policy. + + Args: + msg (str): Raw text string sent from client <-> server + + Returns: + msg (str): Text string with sensitive information masked out. + + """ + # Get command based on fuzzy match + command = next((x for x in re.findall('^(?:Command\s\')*[\s]*([create]{5,6}|[connect]{6,7}|[@userpassword]{6,13}).*', msg, flags=re.IGNORECASE)), None) + if not command or command in AUDIT_MASK_IGNORE: + return msg + + # Break msg into terms + terms = [x.strip() for x in re.split('[\s\=]+', msg) if x] + num_terms = len(terms) + + # If the first term was typed correctly, grab the appropriate number + # of subsequent terms and mask the remainder + if command in AUDIT_MASK_KEEP_BIGRAM and num_terms >= 3: + terms = terms[:2] + ['*' * sum([len(x.zfill(8)) for x in terms[2:]])] + else: + # If the first term was not typed correctly, doesn't have the right + # number of terms or is clearly password-related, + # only grab the first term (minimizes chances of capturing passwords + # conjoined with username i.e. 'conect johnnypassword1234!'). + terms = [terms[0]] + ['*' * sum([len(x.zfill(8)) for x in terms[1:]])] + + msg = ' '.join(terms) + return msg + + def data_out(self, **kwargs): + """ + Generic hook for sending data out through the protocol. + + Kwargs: + kwargs (any): Other data to the protocol. + + """ + if AUDIT_CALLBACK and AUDIT_OUT: + try: + log = self.audit(src='server', **kwargs) + if log: AUDIT_CALLBACK(log, **kwargs) + except Exception as e: + logger.log_err(e) + + super(AuditedServerSession, self).data_out(**kwargs) + + def data_in(self, **kwargs): + """ + Hook for protocols to send incoming data to the engine. + + Kwargs: + kwargs (any): Other data from the protocol. + + """ + if AUDIT_CALLBACK and AUDIT_IN: + try: + log = self.audit(src='client', **kwargs) + if log: AUDIT_CALLBACK(log, **kwargs) + except Exception as e: + logger.log_err(e) + + super(AuditedServerSession, self).data_in(**kwargs) diff --git a/evennia/contrib/auditing/tests.py b/evennia/contrib/auditing/tests.py new file mode 100644 index 0000000000..4115cc58a5 --- /dev/null +++ b/evennia/contrib/auditing/tests.py @@ -0,0 +1,66 @@ +""" +Module containing the test cases for the Audit system. +""" + +from django.conf import settings +from evennia.contrib.auditing.server import AuditedServerSession +from evennia.utils.test_resources import EvenniaTest + +class AuditingTest(EvenniaTest): + def setUp(self): + # Configure session auditing settings + settings.AUDIT_CALLBACK = "evennia.contrib.auditing.examples" + settings.AUDIT_IN = True + settings.AUDIT_OUT = True + + # Configure settings to use custom session + settings.SERVER_SESSION_CLASS = "evennia.contrib.auditing.server.AuditedServerSession" + + super(AuditingTest, self).setUp() + + def test_mask(self): + """ + Make sure the 'mask' function is properly masking potentially sensitive + information from strings. + """ + safe_cmds = ( + 'say hello to my little friend', + '@ccreate channel = for channeling', + '@create a pretty shirt : evennia.contrib.clothing.Clothing', + '@charcreate johnnyefhiwuhefwhef', + 'Command "@logout" is not available. Maybe you meant "@color" or "@cboot"?', + ) + + for cmd in safe_cmds: + self.assertEqual(self.session.mask(cmd), cmd) + + unsafe_cmds = ( + ('connect johnny password123', 'connect johnny ***********'), + ('concnct johnny password123', 'concnct *******************'), + ('create johnny password123', 'create johnny ***********'), + ('@userpassword johnny = password234', '@userpassword johnny ***********'), + ('craete johnnypassword123', 'craete *****************'), + ("Command 'conncect teddy teddy' is not available. Maybe you meant \"@encode\"?", 'Command *************************************************************************************') + ) + + for unsafe, safe in unsafe_cmds: + self.assertEqual(self.session.mask(unsafe), safe) + + def test_audit(self): + """ + Make sure the 'audit' function is returning a dictionary based on values + parsed from the Session object. + """ + log = self.session.audit(src='client', text=[['hello']]) + obj = {k:v for k,v in log.iteritems() if k in ('direction', 'protocol', 'application', 'msg')} + self.assertEqual(obj, { + 'direction': 'RCV', + 'protocol': 'telnet', + 'application': 'Evennia', + 'msg': 'hello' + }) + + # Make sure auditor is breaking down responses without actual text + log = self.session.audit(**{'logged_in': {}, 'src': 'server'}) + self.assertEqual(log['msg'], 'logged_in') + \ No newline at end of file From 6fb375ace3c5f053f611f9a9971a3ec8a49087b9 Mon Sep 17 00:00:00 2001 From: Johnny Date: Thu, 30 Aug 2018 23:24:33 +0000 Subject: [PATCH 2/7] Fixes broken coverage. --- evennia/contrib/auditing/example.py | 3 +-- evennia/contrib/auditing/tests.py | 20 +++++++++----------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/evennia/contrib/auditing/example.py b/evennia/contrib/auditing/example.py index d75942489c..f83ad130f6 100644 --- a/evennia/contrib/auditing/example.py +++ b/evennia/contrib/auditing/example.py @@ -1,5 +1,4 @@ -from evennia.utils.logger import * -from twisted.internet.threads import deferToThread +from evennia.utils.logger import log_file import json def output(data, *args, **kwargs): diff --git a/evennia/contrib/auditing/tests.py b/evennia/contrib/auditing/tests.py index 4115cc58a5..2e7e06e70e 100644 --- a/evennia/contrib/auditing/tests.py +++ b/evennia/contrib/auditing/tests.py @@ -6,18 +6,16 @@ from django.conf import settings from evennia.contrib.auditing.server import AuditedServerSession from evennia.utils.test_resources import EvenniaTest +# Configure session auditing settings +settings.AUDIT_CALLBACK = "evennia.contrib.auditing.examples" +settings.AUDIT_IN = True +settings.AUDIT_OUT = True + +# Configure settings to use custom session +settings.SERVER_SESSION_CLASS = "evennia.contrib.auditing.server.AuditedServerSession" + class AuditingTest(EvenniaTest): - def setUp(self): - # Configure session auditing settings - settings.AUDIT_CALLBACK = "evennia.contrib.auditing.examples" - settings.AUDIT_IN = True - settings.AUDIT_OUT = True - - # Configure settings to use custom session - settings.SERVER_SESSION_CLASS = "evennia.contrib.auditing.server.AuditedServerSession" - - super(AuditingTest, self).setUp() - + def test_mask(self): """ Make sure the 'mask' function is properly masking potentially sensitive From ef6494c5ac601a2298ee5b4d4a82d7dd8838c0c9 Mon Sep 17 00:00:00 2001 From: Johnny Date: Tue, 4 Sep 2018 21:48:03 +0000 Subject: [PATCH 3/7] Allows more configurable extensibility and addresses PR change requests. --- evennia/contrib/auditing/example.py | 21 ----- evennia/contrib/auditing/outputs.py | 58 ++++++++++++ evennia/contrib/auditing/server.py | 136 +++++++++++++--------------- evennia/contrib/auditing/tests.py | 42 +++++++-- 4 files changed, 157 insertions(+), 100 deletions(-) delete mode 100644 evennia/contrib/auditing/example.py create mode 100644 evennia/contrib/auditing/outputs.py diff --git a/evennia/contrib/auditing/example.py b/evennia/contrib/auditing/example.py deleted file mode 100644 index f83ad130f6..0000000000 --- a/evennia/contrib/auditing/example.py +++ /dev/null @@ -1,21 +0,0 @@ -from evennia.utils.logger import log_file -import json - -def output(data, *args, **kwargs): - """ - Writes dictionaries of data generated by an AuditedServerSession to files - in JSON format, bucketed by date. - - Uses Evennia's native logger and writes to the default - log directory (~/yourgame/server/logs/ or settings.LOG_DIR) - - Args: - data (dict): Parsed session transmission data. - - """ - # Bucket logs by day - bucket = data.pop('objects')['time'].strftime('%Y-%m-%d') - - # Write it - log_file(json.dumps(data), filename="auditing_%s.log" % bucket) - \ No newline at end of file diff --git a/evennia/contrib/auditing/outputs.py b/evennia/contrib/auditing/outputs.py new file mode 100644 index 0000000000..ec5e84200f --- /dev/null +++ b/evennia/contrib/auditing/outputs.py @@ -0,0 +1,58 @@ +""" +Auditable Server Sessions - Example Outputs +Example methods demonstrating output destinations for logs generated by +audited server sessions. + +This is designed to be a single source of events for developers to customize +and add any additional enhancements before events are written out-- i.e. if you +want to keep a running list of what IPs a user logs in from on account/character +objects, or if you want to perform geoip or ASN lookups on IPs before committing, +or tag certain events with the results of a reputational lookup, this should be +the easiest place to do it. Write a method and invoke it via +`settings.AUDIT_CALLBACK` to have log data objects passed to it. + +Evennia contribution - Johnny 2017 +""" +from evennia.utils.logger import log_file +import json +import syslog + +def to_file(data): + """ + Writes dictionaries of data generated by an AuditedServerSession to files + in JSON format, bucketed by date. + + Uses Evennia's native logger and writes to the default + log directory (~/yourgame/server/logs/ or settings.LOG_DIR) + + Args: + data (dict): Parsed session transmission data. + + """ + # Bucket logs by day and remove objects before serialization + bucket = data.pop('objects')['time'].strftime('%Y-%m-%d') + + # Write it + log_file(json.dumps(data), filename="audit_%s.log" % bucket) + +def to_syslog(data): + """ + Writes dictionaries of data generated by an AuditedServerSession to syslog. + + Takes advantage of your system's native logger and writes to wherever + you have it configured, which is independent of Evennia. + Linux systems tend to write to /var/log/syslog. + + If you're running rsyslog, you can configure it to dump and/or forward logs + to disk and/or an external data warehouse (recommended-- if your server is + compromised or taken down, losing your logs along with it is no help!). + + Args: + data (dict): Parsed session transmission data. + + """ + # Remove objects before serialization + data.pop('objects') + + # Write it out + syslog.syslog(json.dumps(data)) \ No newline at end of file diff --git a/evennia/contrib/auditing/server.py b/evennia/contrib/auditing/server.py index 6bdd06bbbf..d636395e7d 100644 --- a/evennia/contrib/auditing/server.py +++ b/evennia/contrib/auditing/server.py @@ -1,7 +1,7 @@ """ Auditable Server Sessions: Extension of the stock ServerSession that yields objects representing -all user input and all system output. +user inputs and system outputs. Evennia contribution - Johnny 2017 """ @@ -19,39 +19,27 @@ from evennia.server.serversession import ServerSession AUDIT_CALLBACK = getattr(ev_settings, 'AUDIT_CALLBACK', None) AUDIT_IN = getattr(ev_settings, 'AUDIT_IN', False) AUDIT_OUT = getattr(ev_settings, 'AUDIT_OUT', False) -AUDIT_MASK_IGNORE = set(['@ccreate', '@create'] + getattr(ev_settings, 'AUDIT_IGNORE', [])) -AUDIT_MASK_KEEP_BIGRAM = set(['create', 'connect', '@userpassword'] + getattr(ev_settings, 'AUDIT_MASK_KEEP_BIGRAM', [])) +AUDIT_MASKS = [ + {'connect': r"^[@\s]*[connect]{5,8}\s+(\".+?\"|[^\s]+)\s+(?P.+)"}, + {'connect': r"^[@\s]*[connect]{5,8}\s+(?P[\w\\]+)"}, + {'create': r"^[^@]?[create]{5,7}\s+(\w+|\".+?\")\s+(?P[\w\\]+)"}, + {'create': r"^[^@]?[create]{5,7}\s+(?P[\w\\]+)"}, + {'userpassword': r"^[@\s]*[userpassword]{11,14}\s+(\w+|\".+?\")\s+=*\s*(?P[\w\\]+)"}, + {'password': r"^[@\s]*[password]{6,9}\s+(?P.*)"}, +] + getattr(ev_settings, 'AUDIT_MASKS', []) if AUDIT_CALLBACK: try: - AUDIT_CALLBACK = mod_import(AUDIT_CALLBACK).output + AUDIT_CALLBACK = getattr(mod_import('.'.join(AUDIT_CALLBACK.split('.')[:-1])), AUDIT_CALLBACK.split('.')[-1]) logger.log_info("Auditing module online.") - logger.log_info("Recording user input = %s." % AUDIT_IN) - logger.log_info("Recording server output = %s." % AUDIT_OUT) + logger.log_info("Recording user input: %s" % AUDIT_IN) + logger.log_info("Recording server output: %s" % AUDIT_OUT) + logger.log_info("Log destination: %s" % AUDIT_CALLBACK) except Exception as e: logger.log_err("Failed to activate Auditing module. %s" % e) class AuditedServerSession(ServerSession): """ - This class represents a player's session and is a template for - both portal- and server-side sessions. - - Each connection will see two session instances created: - - 1. A Portal session. This is customized for the respective connection - protocols that Evennia supports, like Telnet, SSH etc. The Portal - session must call init_session() as part of its initialization. The - respective hook methods should be connected to the methods unique - for the respective protocol so that there is a unified interface - to Evennia. - 2. A Server session. This is the same for all connected accounts, - regardless of how they connect. - - The Portal and Server have their own respective sessionhandlers. These - are synced whenever new connections happen or the Server restarts etc, - which means much of the same information must be stored in both places - e.g. the portal can re-sync with the server when the server reboots. - This particular implementation parses all server inputs and/or outputs and passes a dict containing the parsed metadata to a callback method of your creation. This is useful for recording player activity where necessary for @@ -75,7 +63,7 @@ class AuditedServerSession(ServerSession): # called 'output()' you've written that accepts a dict object as its sole # argument. From that function you can store/forward the message received # as you please. An example file-logger is below: - AUDIT_CALLBACK = 'evennia.contrib.auditing.examples' + AUDIT_CALLBACK = 'evennia.contrib.auditing.outputs.to_file' # Log all user input? Be ethical about this; it will log all private and # public communications between players and/or admins. @@ -86,12 +74,17 @@ class AuditedServerSession(ServerSession): # will be very voluminous! AUDIT_OUT = True/False - # What commands do you NOT want masked for sensitivity? - AUDIT_MASK_IGNORE = ['@ccreate', '@create'] + # Any custom regexes to detect and mask sensitive information, to be used + # to detect and mask any sensitive custom commands you may develop. + # Takes the form of a list of dictionaries, one k:v pair per dictionary + # where the key name is the canonical name of a command and gets displayed + # at the tail end of the message so you can tell which regex masked it. + # The sensitive data itself must be captured in a named group with a + # label of 'secret'. + AUDIT_MASKS = [ + {'authentication': r"^@auth\s+(?P[\w]+)"}, + ] - # What commands do you want to keep the first two terms of, masking the rest? - # This only triggers if there are more than two terms in the message. - AUDIT_MASK_KEEP_BIGRAM = ['create', 'connect', '@userpassword'] """ def audit(self, **kwargs): """ @@ -100,8 +93,8 @@ class AuditedServerSession(ServerSession): Kwargs: src (str): Source of data; 'client' or 'server'. Indicates direction. - text (list): Message sent from client to server. - text (str): Message from server back to client. + text (str or list): Client sends messages to server in the form of + lists. Server sends messages to client as string. Returns: log (dict): Dictionary object containing parsed system and user data @@ -115,7 +108,7 @@ class AuditedServerSession(ServerSession): # Sanitize user input session = self src = kwargs.pop('src', '?') - bytes = 0 + bytecount = 0 if src == 'client': try: @@ -125,19 +118,9 @@ class AuditedServerSession(ServerSession): return False elif src == 'server': - # Server outputs can be unpredictable-- sometimes tuples, sometimes - # plain strings. Try to parse both. - try: - if isinstance(kwargs.get('text', ''), (tuple,list)): - data = kwargs['text'][0] - elif not 'text' in kwargs and len(kwargs.keys()) == 1: - data = kwargs.keys()[0] - else: - data = str(kwargs['text']) - - except: data = str(kwargs) + data = str(kwargs) - bytes = len(data.encode('utf-8')) + bytecount = len(data.encode('utf-8')) data = data.strip() @@ -167,7 +150,7 @@ class AuditedServerSession(ServerSession): room_token = '%s%s' % (room.key, room.dbref) # Mask any PII in message, where possible - data = self.mask(data, **kwargs) + data = self.mask(data) # Compile the IP, Account, Character, Room, and the message. log = { @@ -184,7 +167,7 @@ class AuditedServerSession(ServerSession): 'character': char_token, 'room': room_token, 'msg': '%s' % data, - 'bytes': bytes, + 'bytes': bytecount, 'objects': { 'time': time_obj, 'session': self, @@ -196,7 +179,7 @@ class AuditedServerSession(ServerSession): return log - def mask(self, msg, **kwargs): + def mask(self, msg): """ Masks potentially sensitive user information within messages before writing to log. Recording cleartext password attempts is bad policy. @@ -208,27 +191,38 @@ class AuditedServerSession(ServerSession): msg (str): Text string with sensitive information masked out. """ - # Get command based on fuzzy match - command = next((x for x in re.findall('^(?:Command\s\')*[\s]*([create]{5,6}|[connect]{6,7}|[@userpassword]{6,13}).*', msg, flags=re.IGNORECASE)), None) - if not command or command in AUDIT_MASK_IGNORE: - return msg - - # Break msg into terms - terms = [x.strip() for x in re.split('[\s\=]+', msg) if x] - num_terms = len(terms) + # Check to see if the command is embedded within server output + _msg = msg + is_embedded = False + match = re.match(".*Command.*'(.+)'.*is not available.*", msg, flags=re.IGNORECASE) + if match: + msg = match.group(1).replace('\\', '') + submsg = msg + is_embedded = True - # If the first term was typed correctly, grab the appropriate number - # of subsequent terms and mask the remainder - if command in AUDIT_MASK_KEEP_BIGRAM and num_terms >= 3: - terms = terms[:2] + ['*' * sum([len(x.zfill(8)) for x in terms[2:]])] - else: - # If the first term was not typed correctly, doesn't have the right - # number of terms or is clearly password-related, - # only grab the first term (minimizes chances of capturing passwords - # conjoined with username i.e. 'conect johnnypassword1234!'). - terms = [terms[0]] + ['*' * sum([len(x.zfill(8)) for x in terms[1:]])] - - msg = ' '.join(terms) + for mask in AUDIT_MASKS: + for command, regex in mask.iteritems(): + try: + match = re.match(regex, msg, flags=re.IGNORECASE) + except Exception as e: + logger.log_err(modified_regex) + logger.log_err(e) + continue + + if match: + term = match.group('secret') + try: + masked = re.sub(term, '*' * len(term.zfill(8)), msg) + except Exception as e: + print(msg, regex, term) + quit() + + if is_embedded: + msg = re.sub(submsg, masked, _msg, flags=re.IGNORECASE) + else: msg = masked + + return '%s ' % (msg, command) + return msg def data_out(self, **kwargs): @@ -242,7 +236,7 @@ class AuditedServerSession(ServerSession): if AUDIT_CALLBACK and AUDIT_OUT: try: log = self.audit(src='server', **kwargs) - if log: AUDIT_CALLBACK(log, **kwargs) + if log: AUDIT_CALLBACK(log) except Exception as e: logger.log_err(e) @@ -259,7 +253,7 @@ class AuditedServerSession(ServerSession): if AUDIT_CALLBACK and AUDIT_IN: try: log = self.audit(src='client', **kwargs) - if log: AUDIT_CALLBACK(log, **kwargs) + if log: AUDIT_CALLBACK(log) except Exception as e: logger.log_err(e) diff --git a/evennia/contrib/auditing/tests.py b/evennia/contrib/auditing/tests.py index 2e7e06e70e..0650469c72 100644 --- a/evennia/contrib/auditing/tests.py +++ b/evennia/contrib/auditing/tests.py @@ -5,9 +5,10 @@ Module containing the test cases for the Audit system. from django.conf import settings from evennia.contrib.auditing.server import AuditedServerSession from evennia.utils.test_resources import EvenniaTest +import re # Configure session auditing settings -settings.AUDIT_CALLBACK = "evennia.contrib.auditing.examples" +settings.AUDIT_CALLBACK = "evennia.contrib.auditing.outputs.to_syslog" settings.AUDIT_IN = True settings.AUDIT_OUT = True @@ -22,11 +23,19 @@ class AuditingTest(EvenniaTest): information from strings. """ safe_cmds = ( - 'say hello to my little friend', + '/say hello to my little friend', '@ccreate channel = for channeling', + '@create/drop some stuff', + '@create rock', '@create a pretty shirt : evennia.contrib.clothing.Clothing', '@charcreate johnnyefhiwuhefwhef', 'Command "@logout" is not available. Maybe you meant "@color" or "@cboot"?', + '/me says, "what is the password?"', + 'say the password is plugh', + # Unfortunately given the syntax, there is no way to discern the + # latter of these as sensitive + '@create pretty sunset' + '@create johnny password123', ) for cmd in safe_cmds: @@ -34,15 +43,32 @@ class AuditingTest(EvenniaTest): unsafe_cmds = ( ('connect johnny password123', 'connect johnny ***********'), - ('concnct johnny password123', 'concnct *******************'), + ('concnct johnny password123', 'concnct johnny ***********'), + ('concnct johnnypassword123', 'concnct *****************'), + ('connect "johnny five" "password 123"', 'connect "johnny five" **************'), + ('connect johnny "password 123"', 'connect johnny **************'), ('create johnny password123', 'create johnny ***********'), - ('@userpassword johnny = password234', '@userpassword johnny ***********'), + ('@password password1234 = password2345', '@password ***************************'), + ('@password password1234 password2345', '@password *************************'), + ('@passwd password1234 = password2345', '@passwd ***************************'), + ('@userpassword johnny = password234', '@userpassword johnny = ***********'), ('craete johnnypassword123', 'craete *****************'), - ("Command 'conncect teddy teddy' is not available. Maybe you meant \"@encode\"?", 'Command *************************************************************************************') + ("Command 'conncect teddy teddy' is not available. Maybe you meant \"@encode\"?", 'Command \'conncect ***** *****\' is not available. Maybe you meant "@encode"?'), + ("{'text': u'Command \\'conncect jsis dfiidf\\' is not available. Type \"help\" for help.'}", "{'text': u'Command \\'conncect jsis ******\\' is not available. Type \"help\" for help.'}") ) - for unsafe, safe in unsafe_cmds: - self.assertEqual(self.session.mask(unsafe), safe) + for index, (unsafe, safe) in enumerate(unsafe_cmds): + self.assertEqual(re.sub('', '', self.session.mask(unsafe)).strip(), safe) + + # Make sure scrubbing is not being abused to evade monitoring + secrets = [ + 'say password password password; ive got a secret that i cant explain', + 'whisper johnny = password let\'s lynch the landlord', + 'say connect johnny password1234 secret life of arabia', + "@password;eval(\"__import__('os').system('clear')\", {'__builtins__':{}})" + ] + for secret in secrets: + self.assertEqual(self.session.mask(secret), secret) def test_audit(self): """ @@ -60,5 +86,5 @@ class AuditingTest(EvenniaTest): # Make sure auditor is breaking down responses without actual text log = self.session.audit(**{'logged_in': {}, 'src': 'server'}) - self.assertEqual(log['msg'], 'logged_in') + self.assertEqual(log['msg'], "{'logged_in': {}}") \ No newline at end of file From 27796c786f8efeb7adee766b2dc14b49c2ee019a Mon Sep 17 00:00:00 2001 From: Johnny Date: Tue, 4 Sep 2018 22:13:56 +0000 Subject: [PATCH 4/7] Fixes failing tests. --- evennia/contrib/auditing/tests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/evennia/contrib/auditing/tests.py b/evennia/contrib/auditing/tests.py index 0650469c72..1db245d7b4 100644 --- a/evennia/contrib/auditing/tests.py +++ b/evennia/contrib/auditing/tests.py @@ -53,8 +53,8 @@ class AuditingTest(EvenniaTest): ('@passwd password1234 = password2345', '@passwd ***************************'), ('@userpassword johnny = password234', '@userpassword johnny = ***********'), ('craete johnnypassword123', 'craete *****************'), - ("Command 'conncect teddy teddy' is not available. Maybe you meant \"@encode\"?", 'Command \'conncect ***** *****\' is not available. Maybe you meant "@encode"?'), - ("{'text': u'Command \\'conncect jsis dfiidf\\' is not available. Type \"help\" for help.'}", "{'text': u'Command \\'conncect jsis ******\\' is not available. Type \"help\" for help.'}") + ("Command 'conncect teddy teddy' is not available. Maybe you meant \"@encode\"?", 'Command \'conncect ******** ********\' is not available. Maybe you meant "@encode"?'), + ("{'text': u'Command \\'conncect jsis dfiidf\\' is not available. Type \"help\" for help.'}", "{'text': u'Command \\'conncect jsis ********\\' is not available. Type \"help\" for help.'}") ) for index, (unsafe, safe) in enumerate(unsafe_cmds): From 8f8ad26e66c9e5eab7dd4bd7074ec4d54412e2ef Mon Sep 17 00:00:00 2001 From: Johnny Date: Wed, 5 Sep 2018 19:53:35 +0000 Subject: [PATCH 5/7] Adds filtering for sparse values, better/recursive parsing of text field, and regex-based command detection. --- evennia/contrib/auditing/server.py | 95 +++++++++++++++++------------- evennia/contrib/auditing/tests.py | 23 ++++---- 2 files changed, 67 insertions(+), 51 deletions(-) diff --git a/evennia/contrib/auditing/server.py b/evennia/contrib/auditing/server.py index d636395e7d..e5a9d67a67 100644 --- a/evennia/contrib/auditing/server.py +++ b/evennia/contrib/auditing/server.py @@ -5,26 +5,26 @@ user inputs and system outputs. Evennia contribution - Johnny 2017 """ - import os import re import socket from django.utils import timezone from django.conf import settings as ev_settings -from evennia.utils import logger, mod_import, get_evennia_version +from evennia.utils import utils, logger, mod_import, get_evennia_version from evennia.server.serversession import ServerSession # Attributes governing auditing of commands and where to send log objects AUDIT_CALLBACK = getattr(ev_settings, 'AUDIT_CALLBACK', None) AUDIT_IN = getattr(ev_settings, 'AUDIT_IN', False) AUDIT_OUT = getattr(ev_settings, 'AUDIT_OUT', False) +AUDIT_ALLOW_SPARSE = getattr(ev_settings, 'AUDIT_ALLOW_SPARSE', False) AUDIT_MASKS = [ {'connect': r"^[@\s]*[connect]{5,8}\s+(\".+?\"|[^\s]+)\s+(?P.+)"}, - {'connect': r"^[@\s]*[connect]{5,8}\s+(?P[\w\\]+)"}, - {'create': r"^[^@]?[create]{5,7}\s+(\w+|\".+?\")\s+(?P[\w\\]+)"}, - {'create': r"^[^@]?[create]{5,7}\s+(?P[\w\\]+)"}, - {'userpassword': r"^[@\s]*[userpassword]{11,14}\s+(\w+|\".+?\")\s+=*\s*(?P[\w\\]+)"}, + {'connect': r"^[@\s]*[connect]{5,8}\s+(?P[\w]+)"}, + {'create': r"^[^@]?[create]{5,6}\s+(\w+|\".+?\")\s+(?P[\w]+)"}, + {'create': r"^[^@]?[create]{5,6}\s+(?P[\w]+)"}, + {'userpassword': r"^[@\s]*[userpassword]{11,14}\s+(\w+|\".+?\")\s+=*\s*(?P[\w]+)"}, {'password': r"^[@\s]*[password]{6,9}\s+(?P.*)"}, ] + getattr(ev_settings, 'AUDIT_MASKS', []) @@ -34,7 +34,8 @@ if AUDIT_CALLBACK: logger.log_info("Auditing module online.") logger.log_info("Recording user input: %s" % AUDIT_IN) logger.log_info("Recording server output: %s" % AUDIT_OUT) - logger.log_info("Log destination: %s" % AUDIT_CALLBACK) + logger.log_info("Recording sparse values: %s" % AUDIT_ALLOW_SPARSE) + logger.log_info("Log callback destination: %s" % AUDIT_CALLBACK) except Exception as e: logger.log_err("Failed to activate Auditing module. %s" % e) @@ -69,16 +70,25 @@ class AuditedServerSession(ServerSession): # public communications between players and/or admins. AUDIT_IN = True/False - # Log all server output? This will result in logging of ALL system + # Log server output? This will result in logging of ALL system # messages and ALL broadcasts to connected players, so on a busy MUD this # will be very voluminous! AUDIT_OUT = True/False + # The default output is a dict. Do you want to allow key:value pairs with + # null/blank values? If you're just writing to disk, disabling this saves + # some disk space, but whether you *want* sparse values or not is more of a + # consideration if you're shipping logs to a NoSQL/schemaless database. + AUDIT_ALLOW_SPARSE = True/False + # Any custom regexes to detect and mask sensitive information, to be used - # to detect and mask any sensitive custom commands you may develop. + # to detect and mask any custom commands you may develop. # Takes the form of a list of dictionaries, one k:v pair per dictionary - # where the key name is the canonical name of a command and gets displayed - # at the tail end of the message so you can tell which regex masked it. + # where the key name is the canonical name of a command which gets displayed + # at the tail end of the message so you can tell which regex masked it-- + # i.e. for a log entry with a typoed `connect` command: + # `conncect johnny *********** ` + # # The sensitive data itself must be captured in a named group with a # label of 'secret'. AUDIT_MASKS = [ @@ -105,28 +115,13 @@ class AuditedServerSession(ServerSession): time_obj = timezone.now() time_str = str(time_obj) - # Sanitize user input session = self src = kwargs.pop('src', '?') bytecount = 0 - if src == 'client': - try: - data = str(kwargs['text'][0][0]) - except IndexError: - logger.log_err('Failed to parse client-submitted string!') - return False - - elif src == 'server': - data = str(kwargs) - - bytecount = len(data.encode('utf-8')) - - data = data.strip() - # Do not log empty lines - if not data: return {} - + if not kwargs: return {} + # Get current session's IP address client_ip = session.address @@ -148,10 +143,25 @@ class AuditedServerSession(ServerSession): if char: room = char.location room_token = '%s%s' % (room.key, room.dbref) - + + # Try to compile an input/output string + def drill(obj, bucket): + if isinstance(obj, dict): return bucket + elif utils.is_iter(obj): + for sub_obj in obj: + bucket.extend(drill(sub_obj, [])) + else: + bucket.append(obj) + return bucket + + text = kwargs.pop('text', '') + if utils.is_iter(text): + text = '|'.join(drill(text, [])) + # Mask any PII in message, where possible - data = self.mask(data) - + bytecount = len(text.encode('utf-8')) + text = self.mask(text) + # Compile the IP, Account, Character, Room, and the message. log = { 'time': time_str, @@ -166,8 +176,9 @@ class AuditedServerSession(ServerSession): 'account': account_token, 'character': char_token, 'room': room_token, - 'msg': '%s' % data, + 'text': text.strip(), 'bytes': bytecount, + 'data': kwargs, 'objects': { 'time': time_obj, 'session': self, @@ -176,6 +187,12 @@ class AuditedServerSession(ServerSession): 'room': room, } } + + # Remove any keys with blank values + if AUDIT_ALLOW_SPARSE == False: + log['data'] = {k:v for k,v in log['data'].iteritems() if v} + log['objects'] = {k:v for k,v in log['objects'].iteritems() if v} + log = {k:v for k,v in log.iteritems() if v} return log @@ -205,25 +222,21 @@ class AuditedServerSession(ServerSession): try: match = re.match(regex, msg, flags=re.IGNORECASE) except Exception as e: - logger.log_err(modified_regex) + logger.log_err(regex) logger.log_err(e) continue if match: term = match.group('secret') - try: - masked = re.sub(term, '*' * len(term.zfill(8)), msg) - except Exception as e: - print(msg, regex, term) - quit() + masked = re.sub(term, '*' * len(term.zfill(8)), msg) if is_embedded: - msg = re.sub(submsg, masked, _msg, flags=re.IGNORECASE) + msg = re.sub(submsg, '%s ' % (masked, command), _msg, flags=re.IGNORECASE) else: msg = masked - return '%s ' % (msg, command) + return msg - return msg + return _msg def data_out(self, **kwargs): """ diff --git a/evennia/contrib/auditing/tests.py b/evennia/contrib/auditing/tests.py index 1db245d7b4..434b4feb87 100644 --- a/evennia/contrib/auditing/tests.py +++ b/evennia/contrib/auditing/tests.py @@ -11,6 +11,7 @@ import re settings.AUDIT_CALLBACK = "evennia.contrib.auditing.outputs.to_syslog" settings.AUDIT_IN = True settings.AUDIT_OUT = True +settings.AUDIT_ALLOW_SPARSE = True # Configure settings to use custom session settings.SERVER_SESSION_CLASS = "evennia.contrib.auditing.server.AuditedServerSession" @@ -36,6 +37,7 @@ class AuditingTest(EvenniaTest): # latter of these as sensitive '@create pretty sunset' '@create johnny password123', + '{"text": "Command \'do stuff\' is not available. Type \"help\" for help."}' ) for cmd in safe_cmds: @@ -58,14 +60,14 @@ class AuditingTest(EvenniaTest): ) for index, (unsafe, safe) in enumerate(unsafe_cmds): - self.assertEqual(re.sub('', '', self.session.mask(unsafe)).strip(), safe) + self.assertEqual(re.sub(' ', '', self.session.mask(unsafe)).strip(), safe) # Make sure scrubbing is not being abused to evade monitoring secrets = [ 'say password password password; ive got a secret that i cant explain', - 'whisper johnny = password let\'s lynch the landlord', - 'say connect johnny password1234 secret life of arabia', - "@password;eval(\"__import__('os').system('clear')\", {'__builtins__':{}})" + 'whisper johnny = password\n let\'s lynch the landlord', + 'say connect johnny password1234|the secret life of arabia', + "@password eval(\"__import__('os').system('clear')\", {'__builtins__':{}})" ] for secret in secrets: self.assertEqual(self.session.mask(secret), secret) @@ -76,15 +78,16 @@ class AuditingTest(EvenniaTest): parsed from the Session object. """ log = self.session.audit(src='client', text=[['hello']]) - obj = {k:v for k,v in log.iteritems() if k in ('direction', 'protocol', 'application', 'msg')} + obj = {k:v for k,v in log.iteritems() if k in ('direction', 'protocol', 'application', 'text')} self.assertEqual(obj, { 'direction': 'RCV', 'protocol': 'telnet', 'application': 'Evennia', - 'msg': 'hello' + 'text': 'hello' }) - # Make sure auditor is breaking down responses without actual text - log = self.session.audit(**{'logged_in': {}, 'src': 'server'}) - self.assertEqual(log['msg'], "{'logged_in': {}}") - \ No newline at end of file + # Make sure OOB data is being recorded + log = self.session.audit(src='client', text="connect johnny password123", prompt="hp=20|st=10|ma=15", pane=2) + self.assertEqual(log['text'], 'connect johnny ***********') + self.assertEqual(log['data']['prompt'], 'hp=20|st=10|ma=15') + self.assertEqual(log['data']['pane'], 2) \ No newline at end of file From 54213ab6146aa2793c7f019a13d8a09999d00f3c Mon Sep 17 00:00:00 2001 From: Johnny Date: Tue, 25 Sep 2018 21:24:54 +0000 Subject: [PATCH 6/7] Moves installation/config instructions to README. --- evennia/contrib/auditing/README.md | 67 ++++++++++++++++++++++++++++++ evennia/contrib/auditing/server.py | 44 +------------------- 2 files changed, 68 insertions(+), 43 deletions(-) create mode 100644 evennia/contrib/auditing/README.md diff --git a/evennia/contrib/auditing/README.md b/evennia/contrib/auditing/README.md new file mode 100644 index 0000000000..ce1eff800b --- /dev/null +++ b/evennia/contrib/auditing/README.md @@ -0,0 +1,67 @@ +# Input/Output Auditing + +Contrib - Johnny 2017 + +This is a tap that optionally intercepts all data sent to/from clients and the +server and passes it to a callback of your choosing. + +It is intended for quality assurance, post-incident investigations and debugging +but obviously can be abused. All data is recorded in cleartext. Please +be ethical, and if you are unwilling to properly deal with the implications of +recording user passwords or private communications, please do not enable +this module. + +Some checks have been implemented to protect the privacy of users. + + +Files included in this module: + + outputs.py - Example callback methods. This module ships with examples of + callbacks that send data as JSON to a file in your game/server/logs + dir or to your native Linux syslog daemon. You can of course write + your own to do other things like post them to Kafka topics. + + server.py - Extends the Evennia ServerSession object to pipe data to the + callback upon receipt. + + tests.py - Unit tests that check to make sure commands with sensitive + arguments are having their PII scrubbed. + + +Installation/Configuration: + +Deployment is completed by configuring a few settings in server.conf. In short, +you must tell Evennia to use this ServerSession instead of its own, specify +which direction(s) you wish to record and where you want the data sent. + + SERVER_SESSION_CLASS = 'evennia.contrib.auditing.server.AuditedServerSession' + + # Where to send logs? Define the path to a module containing your callback + # function. It should take a single dict argument as input. + AUDIT_CALLBACK = 'evennia.contrib.auditing.outputs.to_file' + + # Log user input? Be ethical about this; it will log all private and + # public communications between players and/or admins. + AUDIT_IN = True/False + + # Log server output? This will result in logging of ALL system + # messages and ALL broadcasts to connected players, so on a busy game any + # broadcast to all users will yield a single event for every connected user! + AUDIT_OUT = True/False + + # The default output is a dict. Do you want to allow key:value pairs with + # null/blank values? If you're just writing to disk, disabling this saves + # some disk space, but whether you *want* sparse values or not is more of a + # consideration if you're shipping logs to a NoSQL/schemaless database. + AUDIT_ALLOW_SPARSE = True/False + + # If you write custom commands that handle sensitive data like passwords, + # you must write a regular expression to remove that before writing to log. + # AUDIT_MASKS is a list of dictionaries that define the names of commands + # and the regexes needed to scrub them. + # + # The sensitive data itself must be captured in a named group with a + # label of 'secret'. + AUDIT_MASKS = [ + {'authentication': r"^@auth\s+(?P[\w]+)"}, + ] \ No newline at end of file diff --git a/evennia/contrib/auditing/server.py b/evennia/contrib/auditing/server.py index e5a9d67a67..923d873f0c 100644 --- a/evennia/contrib/auditing/server.py +++ b/evennia/contrib/auditing/server.py @@ -52,49 +52,7 @@ class AuditedServerSession(ServerSession): have their arguments masked by default, but you must mask or mask any custom commands of your own that handle sensitive information. - Installation: - - Designate this class as the SERVER_SESSION_CLASS in `settings.py`, then set - some additional options concerning what to log and where to send it. - - settings.py: - SERVER_SESSION_CLASS = 'evennia.contrib.auditing.server.AuditedServerSession' - - # Where to send logs? Define the path to a module containing a function - # called 'output()' you've written that accepts a dict object as its sole - # argument. From that function you can store/forward the message received - # as you please. An example file-logger is below: - AUDIT_CALLBACK = 'evennia.contrib.auditing.outputs.to_file' - - # Log all user input? Be ethical about this; it will log all private and - # public communications between players and/or admins. - AUDIT_IN = True/False - - # Log server output? This will result in logging of ALL system - # messages and ALL broadcasts to connected players, so on a busy MUD this - # will be very voluminous! - AUDIT_OUT = True/False - - # The default output is a dict. Do you want to allow key:value pairs with - # null/blank values? If you're just writing to disk, disabling this saves - # some disk space, but whether you *want* sparse values or not is more of a - # consideration if you're shipping logs to a NoSQL/schemaless database. - AUDIT_ALLOW_SPARSE = True/False - - # Any custom regexes to detect and mask sensitive information, to be used - # to detect and mask any custom commands you may develop. - # Takes the form of a list of dictionaries, one k:v pair per dictionary - # where the key name is the canonical name of a command which gets displayed - # at the tail end of the message so you can tell which regex masked it-- - # i.e. for a log entry with a typoed `connect` command: - # `conncect johnny *********** ` - # - # The sensitive data itself must be captured in a named group with a - # label of 'secret'. - AUDIT_MASKS = [ - {'authentication': r"^@auth\s+(?P[\w]+)"}, - ] - + See README.md for installation/configuration instructions. """ def audit(self, **kwargs): """ From e99330a44505910cb3b7eeb87eb2612a7e3db266 Mon Sep 17 00:00:00 2001 From: Johnny Date: Tue, 25 Sep 2018 21:37:34 +0000 Subject: [PATCH 7/7] Adds additional CmdNewPassword() checks and tests. --- evennia/contrib/auditing/server.py | 2 ++ evennia/contrib/auditing/tests.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/evennia/contrib/auditing/server.py b/evennia/contrib/auditing/server.py index 923d873f0c..38c97b598e 100644 --- a/evennia/contrib/auditing/server.py +++ b/evennia/contrib/auditing/server.py @@ -25,6 +25,8 @@ AUDIT_MASKS = [ {'create': r"^[^@]?[create]{5,6}\s+(\w+|\".+?\")\s+(?P[\w]+)"}, {'create': r"^[^@]?[create]{5,6}\s+(?P[\w]+)"}, {'userpassword': r"^[@\s]*[userpassword]{11,14}\s+(\w+|\".+?\")\s+=*\s*(?P[\w]+)"}, + {'userpassword': r"^.*new password set to '(?P[^']+)'\."}, + {'userpassword': r"^.* has changed your password to '(?P[^']+)'\."}, {'password': r"^[@\s]*[password]{6,9}\s+(?P.*)"}, ] + getattr(ev_settings, 'AUDIT_MASKS', []) diff --git a/evennia/contrib/auditing/tests.py b/evennia/contrib/auditing/tests.py index 434b4feb87..8d3611a202 100644 --- a/evennia/contrib/auditing/tests.py +++ b/evennia/contrib/auditing/tests.py @@ -44,6 +44,8 @@ class AuditingTest(EvenniaTest): self.assertEqual(self.session.mask(cmd), cmd) unsafe_cmds = ( + ("something - new password set to 'asdfghjk'.", "something - new password set to '********'."), + ("someone has changed your password to 'something'.", "someone has changed your password to '*********'."), ('connect johnny password123', 'connect johnny ***********'), ('concnct johnny password123', 'concnct johnny ***********'), ('concnct johnnypassword123', 'concnct *****************'),