Skip to content

Commit

Permalink
Mask cmd (#18)
Browse files Browse the repository at this point in the history
* Allow masking part of command in logs. Fix #13
  • Loading branch information
penguinolog committed Apr 12, 2018
1 parent 0f78eff commit 1c34740
Show file tree
Hide file tree
Showing 9 changed files with 666 additions and 307 deletions.
14 changes: 14 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,20 @@ If no STDOUT or STDERR required, it is possible to disable this FIFO pipes via `
The next command level uses lower level and kwargs are forwarded, so expected exit codes are forwarded from `check_stderr`.
Implementation specific flags are always set via kwargs.

If required to mask part of command from logging, `log_mask_re` attribute can be set global over instance or providden with command.
All regex matched groups will be replaced by `'<*masked*>'`.

.. code-block:: python
result = helper.execute(
command="AUTH='top_secret_key'; run command", # type: str
verbose=False, # type: bool
timeout=1 * 60 * 60, # type: typing.Optional[int]
log_mask_re=r"AUTH\s*=\s*'(\w+)'" # type: typing.Optional[str]
)
`result.cmd` will be equal to `AUTH='<*masked*>'; run command`

ExecResult
----------

Expand Down
6 changes: 6 additions & 0 deletions doc/source/SSHClient.rst
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ API: SSHClient and SSHAuth.

.. note:: auth has priority over username/password/private_keys

.. py:attribute:: log_mask_re
``typing.Optional[str]``

regex lookup rule to mask command for logger. all MATCHED groups will be replaced by '<*masked*>'

.. py:attribute:: lock
``threading.RLock``
Expand Down
15 changes: 15 additions & 0 deletions doc/source/Subprocess.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,21 @@ API: Subprocess
.. py:class:: Subprocess()
.. py:method:: __init__(logger, log_mask_re=None)
ExecHelper global API.

:param log_mask_re: regex lookup rule to mask command for logger. all MATCHED groups will be replaced by '<*masked*>'
:type log_mask_re: typing.Optional[str]

.. versionchanged:: 1.2.0 log_mask_re regex rule for masking cmd

.. py:attribute:: log_mask_re
``typing.Optional[str]``

regex lookup rule to mask command for logger. all MATCHED groups will be replaced by '<*masked*>'

.. py:attribute:: lock
``threading.RLock``
Expand Down
246 changes: 246 additions & 0 deletions exec_helpers/_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
# Copyright 2018 Alexey Stepanov aka penguinolog.

#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.

"""ExecHelpers global API.
.. versionadded:: 1.2.0
"""

from __future__ import absolute_import
from __future__ import division
from __future__ import unicode_literals

import re
import threading

from exec_helpers import constants
from exec_helpers import exceptions
from exec_helpers import proc_enums
from exec_helpers import _log_templates


class ExecHelper(object):
"""ExecHelper global API."""

__slots__ = (
'__lock',
'__logger',
'log_mask_re'
)

def __init__(
self,
logger, # type: logging.Logger
log_mask_re=None, # type: typing.Optional[str]
):
"""ExecHelper global API.
:param log_mask_re: regex lookup rule to mask command for logger.
all MATCHED groups will be replaced by '<*masked*>'
:type log_mask_re: typing.Optional[str]
.. versionchanged:: 1.2.0 log_mask_re regex rule for masking cmd
"""
self.__lock = threading.RLock()
self.__logger = logger
self.log_mask_re = log_mask_re

@property
def logger(self): # type: () -> logging.Logger
"""Instance logger access."""
return self.__logger

@property
def lock(self): # type: () -> threading.RLock
"""Lock.
:rtype: threading.RLock
"""
return self.__lock

def __enter__(self):
"""Get context manager.
.. versionchanged:: 1.1.0 lock on enter
"""
self.lock.acquire()
return self

def __exit__(self, exc_type, exc_val, exc_tb): # pragma: no cover
"""Context manager usage."""
self.lock.release()

def _mask_command(
self,
cmd, # type: str
log_mask_re=None, # type: typing.Optional[str]
): # type: (...) -> str
"""Log command with masking and return parsed cmd.
:type cmd: str
:param log_mask_re: regex lookup rule to mask command for logger.
all MATCHED groups will be replaced by '<*masked*>'
:type log_mask_re: typing.Optional[str]
.. versionadded:: 1.2.0
"""
def mask(text, rules): # type: (str, str) -> str
"""Mask part of text using rules."""
indexes = [0] # Start of the line

# places to exclude
for match in re.finditer(rules, text):
for idx, _ in enumerate(match.groups()):
indexes.extend(match.span(idx + 1))
indexes.append(len(text)) # End

masked = ""

# Replace inserts
for idx in range(0, len(indexes) - 2, 2):
start = indexes[idx]
end = indexes[idx + 1]
masked += text[start: end] + '<*masked*>'

masked += text[indexes[-2]: indexes[-1]] # final part
return masked

cmd = cmd.rstrip()

if self.log_mask_re:
cmd = mask(cmd, self.log_mask_re)
if log_mask_re:
cmd = mask(cmd, log_mask_re)

return cmd

def execute(
self,
command, # type: str
verbose=False, # type: bool
timeout=constants.DEFAULT_TIMEOUT, # type: typing.Optional[int]
**kwargs
): # type: (...) -> exec_result.ExecResult
"""Execute command and wait for return code.
Timeout limitation: read tick is 100 ms.
:param command: Command for execution
:type command: str
:param verbose: Produce log.info records for command call and output
:type verbose: bool
:param timeout: Timeout for command execution.
:type timeout: typing.Optional[int]
:rtype: ExecResult
:raises ExecHelperTimeoutError: Timeout exceeded
.. versionchanged:: 1.2.0 default timeout 1 hour
"""
raise NotImplementedError() # pragma: no cover

def check_call(
self,
command, # type: str
verbose=False, # type: bool
timeout=constants.DEFAULT_TIMEOUT, # type: typing.Optional[int]
error_info=None, # type: typing.Optional[str]
expected=None, # type: _type_expected
raise_on_err=True, # type: bool
**kwargs
): # type: (...) -> exec_result.ExecResult
"""Execute command and check for return code.
Timeout limitation: read tick is 100 ms.
:param command: Command for execution
:type command: str
:param verbose: Produce log.info records for command call and output
:type verbose: bool
:param timeout: Timeout for command execution.
:type timeout: typing.Optional[int]
:param error_info: Text for error details, if fail happens
:type error_info: typing.Optional[str]
:param expected: expected return codes (0 by default)
:type expected: typing.Optional[typing.Iterable[int]]
:param raise_on_err: Raise exception on unexpected return code
:type raise_on_err: bool
:rtype: ExecResult
:raises ExecHelperTimeoutError: Timeout exceeded
:raises CalledProcessError: Unexpected exit code
.. versionchanged:: 1.2.0 default timeout 1 hour
"""
expected = proc_enums.exit_codes_to_enums(expected)
ret = self.execute(command, verbose, timeout, **kwargs)
if ret['exit_code'] not in expected:
message = (
_log_templates.CMD_UNEXPECTED_EXIT_CODE.format(
append=error_info + '\n' if error_info else '',
result=ret,
expected=expected
))
self.logger.error(message)
if raise_on_err:
raise exceptions.CalledProcessError(
result=ret,
expected=expected,
)
return ret

def check_stderr(
self,
command, # type: str
verbose=False, # type: bool
timeout=constants.DEFAULT_TIMEOUT, # type: typing.Optional[int]
error_info=None, # type: typing.Optional[str]
raise_on_err=True, # type: bool
**kwargs
): # type: (...) -> exec_result.ExecResult
"""Execute command expecting return code 0 and empty STDERR.
Timeout limitation: read tick is 100 ms.
:param command: Command for execution
:type command: str
:param verbose: Produce log.info records for command call and output
:type verbose: bool
:param timeout: Timeout for command execution.
:type timeout: typing.Optional[int]
:param error_info: Text for error details, if fail happens
:type error_info: typing.Optional[str]
:param raise_on_err: Raise exception on unexpected return code
:type raise_on_err: bool
:rtype: ExecResult
:raises ExecHelperTimeoutError: Timeout exceeded
:raises CalledProcessError: Unexpected exit code or stderr presents
.. versionchanged:: 1.2.0 default timeout 1 hour
"""
ret = self.check_call(
command, verbose, timeout=timeout,
error_info=error_info, raise_on_err=raise_on_err, **kwargs)
if ret['stderr']:
message = (
_log_templates.CMD_UNEXPECTED_STDERR.format(
append=error_info + '\n' if error_info else '',
result=ret,
))
self.logger.error(message)
if raise_on_err:
raise exceptions.CalledProcessError(
result=ret,
expected=kwargs.get('expected'),
)
return ret

0 comments on commit 1c34740

Please sign in to comment.