#!/usr/bin/env python # -*- coding: utf-8 -*- # # king_phisher/startup.py # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of the project nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ################################################################################ # # CLEAN ROOM MODULE # # This module is classified as a "Clean Room" module and is subject to # restrictions on what it may import. # # See: https://king-phisher.readthedocs.io/en/latest/development/modules.html#clean-room-modules # ################################################################################ import collections import gc import io import logging import os import select import shlex import shutil import subprocess import sys from king_phisher import its from king_phisher import version ProcessResults = collections.namedtuple('ProcessResults', ('stdout', 'stderr', 'status')) """ A named tuple for holding the results of an executed external process. .. py:attribute:: stdout A string containing the data the process wrote to stdout. .. py:attribute:: stderr A string containing the data the process wrote to stderr. .. py:attribute:: status An integer representing the process's exit code. """ def _multistream(input, *outputs, size=None): transfered = 0 if select.select([input], [], [], 0)[0]: chunk = input.read(size) for output in outputs: output.write(chunk) output.flush() transfered += len(chunk) return transfered def _run_pipenv(args, **kwargs): """ Execute Pipenv with the supplied arguments and return the :py:class:`~.ProcessResults`. If the exit status is non-zero, then the stdout buffer from the Pipenv execution will be written to stderr. :param tuple args: The arguments for the Pipenv. :param str cwd: An optional current working directory to use for the process. :return: The results of the execution. :rtype: :py:class:`~.ProcessResults` """ path = which('pipenv') if path is None: return RuntimeError('pipenv could not be found') args = (path,) + tuple(args) results = run_process(args, **kwargs) if results.status: sys.stderr.write('pipenv encountered the following error:\n') sys.stderr.write(results.stdout) sys.stderr.flush() return results def pipenv_entry(parser, entry_point): """ Run through startup logic for a Pipenv script (see Pipenv: `Custom Script Shortcuts`_ for more information). This sets up a basic stream logging configuration, establishes the Pipenv environment and finally calls the actual entry point using :py:func:`os.execve`. .. note:: Due to the use of :py:func:`os.execve`, this function does not return. .. note:: Due to the use of :py:func:`os.execve` and ``os.EX_*`` exit codes, this function is not available on Windows. :param parser: The argument parser to use. Arguments are added to it and extracted before passing the remainder to the entry point. :param str entry_point: The name of the entry point using Pipenv. .. _Custom Script Shortcuts: https://pipenv.readthedocs.io/en/latest/advanced/#custom-script-shortcuts """ if its.on_windows: # this is because of the os.exec call and os.EX_* status codes raise RuntimeError('pipenv_entry is incompatible with windows') env_group = parser.add_argument_group('environment wrapper options') env_action = env_group.add_mutually_exclusive_group() env_action.add_argument('--env-install', dest='pipenv_install', default=False, action='store_true', help='install pipenv environment and exit') env_action.add_argument('--env-update', dest='pipenv_update', default=False, action='store_true', help='update pipenv requirements and exit') if its.on_windows: env_group.set_defaults(pipenv_verbose=False) else: env_group.add_argument('--env-verbose', dest='pipenv_verbose', default=False, action='store_true', help='display pipenv output') argp_add_default_args(parser) arguments, _ = parser.parse_known_args() sys_argv = sys.argv sys_argv.pop(0) if sys.version_info < (3, 4): print('[-] the Python version is too old (minimum required is 3.4)') return os.EX_SOFTWARE # initialize basic stream logging logger = logging.getLogger('KingPhisher.wrapper') logger.setLevel(arguments.loglvl if arguments.loglvl else 'WARNING') console_log_handler = logging.StreamHandler() console_log_handler.setLevel(arguments.loglvl if arguments.loglvl else 'WARNING') console_log_handler.setFormatter(logging.Formatter('%(levelname)-8s %(message)s')) logger.addHandler(console_log_handler) target_directory = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) logger.debug("target directory: {}".format(target_directory)) os.environ['PIPENV_VENV_IN_PROJECT'] = os.environ.get('PIPENV_VENV_IN_PROJECT', 'True') os.environ['PIPENV_PIPFILE'] = os.environ.get('PIPENV_PIPFILE', os.path.join(target_directory, 'Pipfile')) python_path = os.environ.get('PYTHONPATH') python_path = [] if python_path is None else python_path.split(os.pathsep) python_path.append(target_directory) os.environ['PYTHONPATH'] = os.pathsep.join(python_path) logger.info('checking for the pipenv environment') if which('pipenv') is None: logger.exception('pipenv not found, run tools/install.sh --update') return os.EX_UNAVAILABLE pipenv_path = which('pipenv') logger.debug("pipenv path: {0!r}".format(pipenv_path)) pipenv_args = ['--site-packages', '--three'] if arguments.pipenv_verbose and logger.isEnabledFor(logging.DEBUG): pipenv_args.append('--verbose') if arguments.pipenv_install or not os.path.isdir(os.path.join(target_directory, '.venv')): if arguments.pipenv_install: logger.info('installing the pipenv environment') else: logger.warning('no pre-existing pipenv environment was found, installing it now') results = _run_pipenv(pipenv_args + ['install'], cwd=target_directory, tee=arguments.pipenv_verbose) if results.status: logger.error('failed to install the pipenv environment') logger.info('removing the incomplete .venv directory') try: shutil.rmtree(os.path.join(target_directory, '.venv')) except OSError: logger.error('failed to remove the incomplete .venv directory', exc_info=True) return results.status if arguments.pipenv_install: return os.EX_OK if arguments.pipenv_update: logger.info('updating the pipenv environment') results = _run_pipenv(pipenv_args + ['update'], cwd=target_directory, tee=arguments.pipenv_verbose) if results.status: logger.error('failed to update the pipenv environment') return results.status logger.info('the pipenv environment has been updated') return os.EX_OK logger.debug('pipenv Pipfile: {}'.format(os.environ['PIPENV_PIPFILE'])) # the blank arg being passed is required for pipenv passing_argv = [' ', 'run', entry_point] + sys_argv os.execve(pipenv_path, passing_argv, os.environ) def run_process(process_args, cwd=None, tee=False, encoding='utf-8'): """ Run a subprocess, wait for it to complete and return a :py:class:`~.ProcessResults` object. This function differs from :py:func:`.start_process` in the type it returns and the fact that it always waits for the subprocess to finish before returning. .. versionchanged:: 1.15.0 Added the *tee* parameter. :param tuple process_args: The arguments for the processes including the binary. :param bool cwd: An optional current working directory to use for the process. :param bool tee: Whether or not to display the console output while the process is running. :param str encoding: The encoding to use for strings. :return: The results of the process including the status code and any text printed to stdout or stderr. :rtype: :py:class:`~.ProcessResults` """ process_handle = start_process(process_args, wait=False, cwd=cwd) if tee: if its.on_windows: # this is because select() does not support file descriptors raise RuntimeError('tee mode is not supported on Windows') stdout = io.BytesIO() stderr = io.BytesIO() while process_handle.poll() is None: _multistream(process_handle.stdout, stdout, sys.stdout.buffer, size=1) _multistream(process_handle.stderr, stderr, sys.stderr.buffer, size=1) _multistream(process_handle.stdout, stdout, sys.stdout.buffer) _multistream(process_handle.stderr, stderr, sys.stderr.buffer) stdout = stdout.getvalue() stderr = stderr.getvalue() else: process_handle.wait() stdout = process_handle.stdout.read() stderr = process_handle.stderr.read() results = ProcessResults( stdout.decode(encoding), stderr.decode(encoding), process_handle.returncode ) return results def start_process(process_args, wait=True, cwd=None): """ Start a subprocess and optionally wait for it to finish. If not **wait**, a handle to the subprocess is returned instead of ``True`` when it exits successfully. This function differs from :py:func:`.run_process` in that it optionally waits for the subprocess to finish, and can return a handle to it. :param tuple process_args: The arguments for the processes including the binary. :param bool wait: Whether or not to wait for the subprocess to finish before returning. :param str cwd: The optional current working directory. :return: If **wait** is set to True, then a boolean indication success is returned, else a handle to the subprocess is returened. """ cwd = cwd or os.getcwd() if isinstance(process_args, str): process_args = shlex.split(process_args) close_fds = True startupinfo = None preexec_fn = None if wait else getattr(os, 'setsid', None) if sys.platform.startswith('win'): close_fds = False startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW startupinfo.wShowWindow = subprocess.SW_HIDE logger = logging.getLogger('KingPhisher.ExternalProcess') logger.debug('starting external process: ' + ' '.join(process_args)) proc_h = subprocess.Popen( process_args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, preexec_fn=preexec_fn, close_fds=close_fds, cwd=cwd, startupinfo=startupinfo ) if not wait: return proc_h return proc_h.wait() == 0 def which(program): """ Examine the ``PATH`` environment variable to determine the location for the specified program. If it can not be found None is returned. This is fundamentally similar to the Unix utility of the same name. :param str program: The name of the program to search for. :return: The absolute path to the program if found. :rtype: str """ is_exe = lambda fpath: (os.path.isfile(fpath) and os.access(fpath, os.X_OK)) for path in os.environ['PATH'].split(os.pathsep): path = path.strip('"') exe_file = os.path.join(path, program) if is_exe(exe_file): return exe_file if is_exe(program): return os.path.abspath(program) return None def argp_add_default_args(parser, default_root=''): """ Add standard arguments to a new :py:class:`argparse.ArgumentParser` instance. Used to add the utilities argparse options to the wrapper for display. :param parser: The parser to add arguments to. :type parser: :py:class:`argparse.ArgumentParser` :param str default_root: The default root logger to specify. """ parser.add_argument('-v', '--version', action='version', version=parser.prog + ' Version: ' + version.version) log_group = parser.add_argument_group('logging options') log_group.add_argument('-L', '--log', dest='loglvl', type=str.upper, choices=('DEBUG', 'INFO', 'WARNING', 'ERROR', 'FATAL'), help='set the logging level') log_group.add_argument('--logger', default=default_root, help='specify the root logger') gc_group = parser.add_argument_group('garbage collector options') gc_group.add_argument('--gc-debug-leak', action='store_const', const=gc.DEBUG_LEAK, default=0, help='set the DEBUG_LEAK flag') gc_group.add_argument('--gc-debug-stats', action='store_const', const=gc.DEBUG_STATS, default=0, help='set the DEBUG_STATS flag') return parser def argp_add_client(parser): """ Add client-specific arguments to a new :py:class:`argparse.ArgumentParser` instance. :param parser: The parser to add arguments to. :type parser: :py:class:`argparse.ArgumentParser` """ kpc_group = parser.add_argument_group('client specific options') kpc_group.add_argument('-c', '--config', dest='config_file', required=False, help='specify a configuration file to use') kpc_group.add_argument('--no-plugins', dest='use_plugins', default=True, action='store_false', help='disable all plugins') kpc_group.add_argument('--no-style', dest='use_style', default=True, action='store_false', help='disable interface styling') return parser def argp_add_server(parser): """ Add server-specific arguments to a new :py:class:`argparse.ArgumentParser` instance. :param parser: The parser to add arguments to. :type parser: :py:class:`argparse.ArgumentParser` """ kps_group = parser.add_argument_group('server specific options') kps_group.add_argument('-f', '--foreground', dest='foreground', action='store_true', default=False, help='run in the foreground (do not fork)') kps_group.add_argument('--verify-config', dest='verify_config', action='store_true', default=False, help='verify the configuration and exit') kps_group.add_argument('config_file', action='store', help='configuration file to use') return parser