rapid7/metasploit-framework

View on GitHub
lib/rex/ui/text/dispatcher_shell.rb

Summary

Maintainability
F
3 days
Test Coverage
# -*- coding: binary -*-
require 'pp'
require 'rex/text/table'
require 'erb'

module Rex
module Ui
module Text

###
#
# The dispatcher shell class is designed to provide a generic means
# of processing various shell commands that may be located in
# different modules or chunks of codes.  These chunks are referred
# to as command dispatchers.  The only requirement for command dispatchers is
# that they prefix every method that they wish to be mirrored as a command
# with the cmd_ prefix.
#
###
module DispatcherShell

  include Resource

  ###
  #
  # Empty template base class for command dispatchers.
  #
  ###
  module CommandDispatcher

    module ClassMethods
      #
      # Check whether or not the command dispatcher is capable of handling the
      # specified command. The command may still be disabled through some means
      # at runtime.
      #
      # @param [String] name The name of the command to check.
      # @return [Boolean] true if the dispatcher can handle the command.
      def has_command?(name)
        self.method_defined?("cmd_#{name}")
      end

      def included(base)
        # Propagate the included hook
        CommandDispatcher.included(base)
      end
    end

    def self.included(base)
      # Install class methods so they are inheritable
      base.extend(ClassMethods)
    end

    #
    # Initializes the command dispatcher mixin.
    #
    def initialize(shell)
      self.shell = shell
      self.tab_complete_items = []
    end

    #
    # Returns nil for an empty set of commands.
    #
    # This method should be overridden to return a Hash with command
    # names for keys and brief help text for values.
    #
    def commands
    end

    #
    # Returns an empty set of commands.
    #
    # This method should be overridden if the dispatcher has commands that
    # should be treated as deprecated. Deprecated commands will not show up in
    # help and will not tab-complete, but will still be callable.
    #
    def deprecated_commands
      []
    end

    #
    # Wraps shell.print_error
    #
    def print_error(msg = '')
      shell.print_error(msg)
    end

    alias_method :print_bad, :print_error

    #
    # Wraps shell.print_status
    #
    def print_status(msg = '')
      shell.print_status(msg)
    end

    #
    # Wraps shell.print_line
    #
    def print_line(msg = '')
      shell.print_line(msg)
    end

    #
    # Wraps shell.print_good
    #
    def print_good(msg = '')
      shell.print_good(msg)
    end

    #
    # Wraps shell.print_warning
    #
    def print_warning(msg = '')
      shell.print_warning(msg)
    end

    #
    # Wraps shell.print
    #
    def print(msg = '')
      shell.print(msg)
    end

    #
    # Print a warning that the called command is deprecated and optionally
    # forward to the replacement +method+ (useful for when commands are
    # renamed).
    #
    def deprecated_cmd(method=nil, *args)
      cmd = caller[0].match(/`cmd_(.*)'/)[1]
      print_error "The #{cmd} command is DEPRECATED"
      if cmd == "db_autopwn"
        print_error "See http://r-7.co/xY65Zr instead"
      elsif method and self.respond_to?("cmd_#{method}", true)
        print_error "Use #{method} instead"
        self.send("cmd_#{method}", *args)
      end
    end

    def deprecated_help(method=nil)
      cmd = caller[0].match(/`cmd_(.*)_help'/)[1]
      print_error "The #{cmd} command is DEPRECATED"
      if cmd == "db_autopwn"
        print_error "See http://r-7.co/xY65Zr instead"
      elsif method and self.respond_to?("cmd_#{method}_help", true)
        print_error "Use 'help #{method}' instead"
        self.send("cmd_#{method}_help")
      end
    end

    #
    # Wraps shell.update_prompt
    #
    def update_prompt(*args)
      shell.update_prompt(*args)
    end

    def cmd_help_help
      print_line "There's only so much I can do"
    end

    #
    # Displays the help banner.  With no arguments, this is just a list of
    # all commands grouped by dispatcher.  Otherwise, tries to use a method
    # named cmd_#{+cmd+}_help for the first dispatcher that has a command
    # named +cmd+.  If no such method exists, uses +cmd+ as a regex to
    # compare against each enstacked dispatcher's name and dumps commands
    # of any that match.
    #
    def cmd_help(cmd=nil, *ignored)
      if cmd
        help_found = false
        cmd_found = false
        shell.dispatcher_stack.each do |dispatcher|
          next unless dispatcher.respond_to?(:commands)
          next if (dispatcher.commands.nil?)
          next if (dispatcher.commands.length == 0)

          if dispatcher.respond_to?("cmd_#{cmd}", true)
            cmd_found = true
            break unless dispatcher.respond_to?("cmd_#{cmd}_help", true)
            dispatcher.send("cmd_#{cmd}_help")
            help_found = true
            break
          end
        end

        unless cmd_found
          # We didn't find a cmd, try it as a dispatcher name
          shell.dispatcher_stack.each do |dispatcher|
            if dispatcher.name =~ /#{cmd}/i
              print_line(dispatcher.help_to_s)
              cmd_found = help_found = true
            end
          end
        end

        if docs_dir && File.exist?(File.join(docs_dir, cmd + '.md'))
          print_line
          print(File.read(File.join(docs_dir, cmd + '.md')))
        end
        print_error("No help for #{cmd}, try -h") if cmd_found and not help_found
        print_error("No such command") if not cmd_found
      else
        print(shell.help_to_s)
        if docs_dir && File.exist?(File.join(docs_dir + '.md'))
          print_line
          print(File.read(File.join(docs_dir + '.md')))
        end
      end
    end

    #
    # Tab completion for the help command
    #
    # By default just returns a list of all commands in all dispatchers.
    #
    def cmd_help_tabs(str, words)
      return [] if words.length > 1

      tabs = []
      shell.dispatcher_stack.each { |dispatcher|
        tabs += dispatcher.commands.keys
      }
      return tabs
    end

    alias cmd_? cmd_help

    #
    # Return a pretty, user-readable table of commands provided by this
    # dispatcher.
    # The command column width can be modified by passing in :command_width.
    #
    def help_to_s(opts={})
      # If this dispatcher has no commands, we can't do anything useful.
      return "" if commands.nil? or commands.length == 0

      # Display the commands
      tbl = Rex::Text::Table.new(
        'Header'  => "#{self.name} Commands",
        'Indent'  => opts['Indent'] || 4,
        'Columns' =>
          [
            'Command',
            'Description'
          ],
        'ColProps' =>
          {
            'Command' =>
              {
                'Width' => opts[:command_width]
              }
          })

      commands.sort.each { |c|
        tbl << c
      }

      return "\n" + tbl.to_s + "\n"
    end

    #
    # Return the subdir of the `documentation/` directory that should be used
    # to find usage documentation
    #
    # TODO: get this value from somewhere that doesn't invert a bunch of
    # dependencies
    #
    def docs_dir
      File.expand_path(File.join(__FILE__, '..', '..', '..', '..', '..', 'documentation', 'cli'))
    end

    #
    # No tab completion items by default
    #
    attr_accessor :shell, :tab_complete_items

    #
    # Provide a generic tab completion for file names.
    #
    # If the only completion is a directory, this descends into that directory
    # and continues completions with filenames contained within.
    #
    def tab_complete_filenames(str, words)
      matches = ::Readline::FILENAME_COMPLETION_PROC.call(str)
      if matches and matches.length == 1 and File.directory?(matches[0])
        dir = matches[0]
        dir += File::SEPARATOR if dir[-1,1] != File::SEPARATOR
        matches = ::Readline::FILENAME_COMPLETION_PROC.call(dir)
      end
      matches.nil? ? [] : matches
    end

    #
    # Return a list of possible directory for tab completion.
    #
    def tab_complete_directory(str, words)
      directory = str[-1] == File::SEPARATOR ? str : File.dirname(str)
      filename = str[-1] == File::SEPARATOR ? '' : File.basename(str)
      entries = Dir.entries(directory).select { |fp| fp.start_with?(filename) }
      dirs = entries - ['.', '..']
      dirs = dirs.map { |fp| File.join(directory, fp).gsub(/\A\.\//, '') }
      dirs = dirs.select { |x| File.directory?(x) }
      dirs = dirs.map { |x| x + File::SEPARATOR }
      if dirs.length == 1 && dirs[0] != str && dirs[0].end_with?(File::SEPARATOR)
        # If Readline receives a single value from this function, it will assume we're done with the tab
        # completing, and add an extra space at the end.
        # This is annoying if we're recursively tab-traversing our way through subdirectories -
        # we may want to continue traversing, but MSF will add a space, requiring us to back up to continue
        # tab-completing our way through successive subdirectories.
        ::Readline.completion_append_character = nil
      end

      if dirs.length == 0 && File.directory?(str)
        # we've hit the end of the road
        dirs = [str]
      end

      dirs
    end

    #
    # Provide a generic tab completion function based on the specification
    # pass as fmt. The fmt argument in a hash where values are an array
    # defining how the command should be completed. The first element of the
    # array can be one of:
    #   nil      - This argument is a flag and takes no option.
    #   true     - This argument takes an option with no suggestions.
    #   :address - This option is a source address.
    #   :bool    - This option is a boolean.
    #   :file    - This option is a file path.
    #   Array    - This option is an array of possible values.
    #
    def tab_complete_generic(fmt, str, words)
      last_word = words[-1]
      fmt = fmt.select { |key, value| last_word == key || !words.include?(key) }

      val = fmt[last_word]
      return fmt.keys if !val  # the last word does not look like a fmtspec
      arg = val[0]
      return fmt.keys if !arg  # the last word is a fmtspec that takes no argument

      tabs = []
      if arg.to_s.to_sym == :address
        tabs = tab_complete_source_address
      elsif arg.to_s.to_sym == :bool
        tabs = ['true', 'false']
      elsif arg.to_s.to_sym == :file
        tabs = tab_complete_filenames(str, words)
      elsif arg.kind_of?(Array)
        tabs = arg.map {|a| a.to_s}
      end
      tabs
    end

    #
    # Return a list of possible source addresses for tab completion.
    #
    def tab_complete_source_address
      addresses = [Rex::Socket.source_address]
      # getifaddrs was introduced in 2.1.2
      if ::Socket.respond_to?(:getifaddrs)
        ifaddrs = ::Socket.getifaddrs.select do |ifaddr|
          ifaddr.addr && ifaddr.addr.ip?
        end
        addresses += ifaddrs.map { |ifaddr| ifaddr.addr.ip_address }
      end
      addresses
    end

    #
    # A callback that can be used to handle unknown commands. This can for example, allow a dispatcher to mark a command
    # as being disabled.
    #
    # @return [Symbol, nil] Returns a symbol specifying the action that was taken by the handler or `nil` if no action
    #   was taken. The only supported action at this time is `:handled`, signifying that the unknown command was handled
    #   by this dispatcher and no additional dispatchers should receive it.
    def unknown_command(method, line)
      nil
    end
  end

  #
  # DispatcherShell derives from shell.
  #
  include Shell

  #
  # Initialize the dispatcher shell.
  #
  def initialize(prompt, prompt_char = '>', histfile = nil, framework = nil, name = nil)
    super

    # Initialize the dispatcher array
    self.dispatcher_stack = []

    # Initialize the tab completion array
    self.on_command_proc = nil
  end

  #
  # This method accepts the entire line of text from the Readline
  # routine, stores all completed words, and passes the partial
  # word to the real tab completion function. This works around
  # a design problem in the Readline module and depends on the
  # Readline.basic_word_break_characters variable being set to \x00
  #
  def tab_complete(str)
    ::Readline.completion_append_character = ' '
    ::Readline.completion_case_fold = false

    # Check trailing whitespace so we can tell 'x' from 'x '
    str_match = str.match(/[^\\]([\\]{2})*\s+$/)
    str_trail = (str_match.nil?) ? '' : str_match[0]

    # Split the line up by whitespace into words
    split_str = shellsplitex(str)

    # Append an empty token if we had trailing whitespace
    split_str[:tokens] << { begin: str.length, value: '' } if str_trail.length > 0

    # Pop the last word and pass it to the real method
    result = tab_complete_stub(str, split_str)
    if result
      result.uniq
    else
      result
    end
  end

  # Performs tab completion of a command, if supported
  #
  def tab_complete_stub(original_str, split_str)
    *preceding_tokens, current_token = split_str[:tokens]
    return nil unless current_token

    items = []
    current_word = current_token[:value]
    tab_words = preceding_tokens.map { |word| word[:value] }

    # Next, try to match internal command or value completion
    # Enumerate each entry in the dispatcher stack
    dispatcher_stack.each do |dispatcher|

      # If no command is set and it supports commands, add them all
      if tab_words.empty? and dispatcher.respond_to?('commands')
        items.concat(dispatcher.commands.keys)
      end

      # If the dispatcher exports a tab completion function, use it
      if dispatcher.respond_to?('tab_complete_helper')
        res = dispatcher.tab_complete_helper(current_word, tab_words)
      else
        res = tab_complete_helper(dispatcher, current_word, tab_words)
      end

      if res.nil?
        # A nil response indicates no optional arguments
        return [''] if items.empty?
      else
        if res.second == :override_completions
          return res.first
        else
          # Otherwise we add the completion items to the list
          items.concat(res)
        end
      end
    end

    # Match based on the partial word
    matches = items.select do |word|
      word.downcase.start_with?(current_word.downcase)
    end

    # Prepend the preceding string of the command (or it all gets replaced!)
    preceding_str = original_str[0...current_token[:begin]]
    quote = current_token[:quote]
    matches_with_preceding_words_appended = matches.map do |word|
      word = quote.nil? ? word.gsub('\\') { '\\\\' }.gsub(' ', '\\ ') : "#{quote}#{word}#{quote}"
      preceding_str + word
    end

    matches_with_preceding_words_appended
  end

  #
  # Provide command-specific tab completion
  #
  def tab_complete_helper(dispatcher, str, words)
    tabs_meth = "cmd_#{words[0]}_tabs"
    # Is the user trying to tab complete one of our commands?
    if dispatcher.commands.include?(words[0]) and dispatcher.respond_to?(tabs_meth)
      res = dispatcher.send(tabs_meth, str, words)
      return [] if res.nil?
      return res
    end

    # Avoid the default completion list for unknown commands
    []
  end

  #
  # Run a single command line.
  #
  # @param [String] line The command string that should be executed.
  # @param [Boolean] propagate_errors Whether or not to raise exceptions that are caught while executing the command.
  #
  # @return [Boolean] A boolean value signifying whether or not the command was handled. Value is `true` when the
  #   command line was handled.
  def run_single(line, propagate_errors: false)
    arguments  = parse_line(line)
    method     = arguments.shift
    cmd_status = nil  # currently either nil or :handled, more statuses can be added in the future
    error      = false

    # If output is disabled output will be nil
    output.reset_color if (output)

    if (method)
      entries = dispatcher_stack.length

      dispatcher_stack.each { |dispatcher|
        next if not dispatcher.respond_to?('commands')

        begin
          if (dispatcher.commands.has_key?(method) or dispatcher.deprecated_commands.include?(method))
            self.on_command_proc.call(line.strip) if self.on_command_proc
            run_command(dispatcher, method, arguments)
            cmd_status = :handled
          elsif cmd_status.nil?
            cmd_status = dispatcher.unknown_command(method, line)
          end
        rescue ::Interrupt
          cmd_status = :handled
          print_error("#{method}: Interrupted")
          raise if propagate_errors
        rescue OptionParser::ParseError => e
          print_error("#{method}: #{e.message}")
          raise if propagate_errors
        rescue
          error = $!

          print_error(
            "Error while running command #{method}: #{$!}" +
            "\n\nCall stack:\n#{$@.join("\n")}")

          raise if propagate_errors
        rescue ::Exception => e
          error = $!

          print_error(
            "Error while running command #{method}: #{$!}")

          raise if propagate_errors
        end

        # If the dispatcher stack changed as a result of this command,
        # break out
        break if (dispatcher_stack.length != entries)
      }

      if (cmd_status.nil? && error == false)
        unknown_command(method, line)
      end
    end

    return cmd_status == :handled
  end

  #
  # Runs the supplied command on the given dispatcher.
  #
  def run_command(dispatcher, method, arguments)
    self.busy = true

    if(blocked_command?(method))
      print_error("The #{method} command has been disabled.")
    else
      dispatcher.send('cmd_' + method, *arguments)
    end
  ensure
    self.busy = false
  end

  #
  # If the command is unknown...
  #
  def unknown_command(method, line)
    # Map each dispatchers commands to valid_commands
    valid_commands = dispatcher_stack.flat_map { |dispatcher| dispatcher.commands.keys }

    message = "Unknown command: #{method}."
    suggestion = DidYouMean::SpellChecker.new(dictionary: valid_commands).correct(method).first
    message << " Did you mean %grn#{suggestion}%clr?" if suggestion
    message << ' Run the %grnhelp%clr command for more details.'

    print_error(message)
  end

  #
  # Push a dispatcher to the front of the stack.
  #
  def enstack_dispatcher(dispatcher)
    self.dispatcher_stack.unshift(inst = dispatcher.new(self))

    inst
  end

  #
  # Pop a dispatcher from the front of the stacker.
  #
  def destack_dispatcher
    self.dispatcher_stack.shift
  end

  #
  # Adds the supplied dispatcher to the end of the dispatcher stack so that
  # it doesn't affect any enstack'd dispatchers.
  #
  def append_dispatcher(dispatcher)
    inst = dispatcher.new(self)
    self.dispatcher_stack.each { |disp|
      if (disp.name == inst.name)
        raise "Attempting to load already loaded dispatcher #{disp.name}"
      end
    }
    self.dispatcher_stack.push(inst)

    inst
  end

  #
  # Removes the supplied dispatcher instance.
  #
  def remove_dispatcher(name)
    self.dispatcher_stack.delete_if { |inst|
      (inst.name == name)
    }
  end

  #
  # Returns the current active dispatcher
  #
  def current_dispatcher
    self.dispatcher_stack[0]
  end

  #
  # Return a readable version of a help banner for all of the enstacked
  # dispatchers.
  #
  # See +CommandDispatcher#help_to_s+
  #
  def help_to_s(opts = {})
    str = ''

    max_command_length = dispatcher_stack.flat_map { |dispatcher| dispatcher.commands.to_a }.map { |(name, _description)| name.length }.max

    dispatcher_stack.reverse.each { |dispatcher|
      str << dispatcher.help_to_s(opts.merge({ command_width: [max_command_length, 12].max }))
    }

    return str << "For more info on a specific command, use %grn<command> -h%clr or %grnhelp <command>%clr.\n\n"
  end


  #
  # Returns nil for an empty set of blocked commands.
  #
  def blocked_command?(cmd)
    return false if not self.blocked
    self.blocked.has_key?(cmd)
  end

  #
  # Block a specific command
  #
  def block_command(cmd)
    self.blocked ||= {}
    self.blocked[cmd] = true
  end

  #
  # Unblock a specific command
  #
  def unblock_command(cmd)
    self.blocked || return
    self.blocked.delete(cmd)
  end

  #
  # Split a line as Shellwords.split would however instead of raising an
  # ArgumentError on unbalanced quotes return the remainder of the string as if
  # the last character were the closing quote.
  #
  # This code was originally taken from https://github.com/ruby/ruby/blob/93420d34aaf8c30f11a66dd08eb186da922c831d/lib/shellwords.rb#L88
  #
  def shellsplitex(line)
    tokens = []
    field_value = String.new
    field_begin = nil

    line.scan(/\G(\s*)(?>([^\s\\\'\"]+)|'([^\']*)'|"((?:[^\"\\]|\\.)*)"|(\\.?)|(\S))(\s|\z)?/m) do |preceding_whitespace, word, sq, dq, esc, garbage, sep|
      field_begin ||= Regexp.last_match.begin(0) + preceding_whitespace.length
      if garbage
        quote_start_begin = Regexp.last_match.begin(0) + preceding_whitespace.length
        field_quote = garbage
        field_value << line[quote_start_begin + 1..-1].gsub('\\\\', '\\')

        tokens << { begin: field_begin, value: field_value, quote: field_quote }
        break
      end

      field_value << (word || sq || (dq && dq.gsub(/\\([$`"\\\n])/, '\\1')) || esc.gsub(/\\(.)/, '\\1'))
      if sep
        tokens << { begin: field_begin, value: field_value, quote: ((sq && "'") || (dq && '"') || nil) }
        field_value = String.new
        field_begin = nil
      end
    end

    { tokens: tokens }
  end

  attr_accessor :dispatcher_stack # :nodoc:
  attr_accessor :busy # :nodoc:
  attr_accessor :blocked # :nodoc:

end

end
end
end