require 'brakeman/checks/base_check'

#Checks for string interpolation and parameters in calls to
#Kernel#system, Kernel#exec, Kernel#syscall, and inside backticks.
#
#Examples of command injection vulnerabilities:
#
# system("rf -rf #{params[:file]}")
# exec(params[:command])
# `unlink #{params[:something}`
class Brakeman::CheckExecute < Brakeman::BaseCheck
  Brakeman::Checks.add self

  @description = "Finds instances of possible command injection"

  SAFE_VALUES = [s(:const, :RAILS_ROOT),
                  s(:call, s(:const, :Rails), :root),
                  s(:call, s(:const, :Rails), :env),
                  s(:call, s(:const, :Process), :pid)]

  SHELL_ESCAPE_MODULE_METHODS = Set[:escape, :join, :shellescape, :shelljoin]
  SHELL_ESCAPE_MIXIN_METHODS = Set[:shellescape, :shelljoin]

  # These are common shells that are known to allow the execution of commands
  # via a -c flag. See dash_c_shell_command? for more info.
  KNOWN_SHELL_COMMANDS = Set["sh", "bash", "ksh", "csh", "tcsh", "zsh"]

  SHELLWORDS = s(:const, :Shellwords)

  #Check models, controllers, and views for command injection.
  def run_check
    Brakeman.debug "Finding system calls using ``"
    check_for_backticks tracker

    check_open_calls

    Brakeman.debug "Finding other system calls"
    calls = tracker.find_call :targets => [:IO, :Open3, :Kernel, :'POSIX::Spawn', :Process, nil],
      :methods => [:capture2, :capture2e, :capture3, :exec, :pipeline, :pipeline_r,
        :pipeline_rw, :pipeline_start, :pipeline_w, :popen, :popen2, :popen2e,
        :popen3, :spawn, :syscall, :system], :nested => true

    Brakeman.debug "Processing system calls"
    calls.each do |result|
      process_result result
    end
  end

  private

  #Processes results from Tracker#find_call.
  def process_result result
    call = result[:call]
    args = call.arglist
    first_arg = call.first_arg
    failure = nil

    case call.method
    when :popen
      # Normally, if we're in a `popen` call, we only are worried about shell
      # injection when the argument is not an array, because array elements
      # are always escaped by Ruby. However, an exception is when the array
      # contains two values are something like "bash -c" because then the third
      # element is effectively the command being run and might be a malicious
      # executable if it comes (partially or fully) from user input.
      if !array?(first_arg)
        failure = include_user_input?(first_arg) ||
                  dangerous_interp?(first_arg) ||
                  dangerous_string_building?(first_arg)
      elsif dash_c_shell_command?(first_arg[1], first_arg[2])
        failure = include_user_input?(first_arg[3]) ||
                  dangerous_interp?(first_arg[3]) ||
                  dangerous_string_building?(first_arg[3])
      end
    when :pipeline, :pipline_r, :pipeline_rw, :pipeline_w, :pipeline_start
      # Since these pipeline commands pipe together several commands,
      # need to check each argument. If it's an array, check first argument
      # (the command) and also check for `bash -c`. Otherwise check the argument
      # as a unit.

      args.each do |arg|
        next unless sexp? arg

        if array?(arg)
          # Check first element of array
          failure = include_user_input?(arg[1]) ||
            dangerous_interp?(arg[1]) ||
            dangerous_string_building?(arg[1])

          # Check for ['bash', '-c', user_input]
          if dash_c_shell_command?(arg[1], arg[2])
            failure = include_user_input?(arg[3]) ||
              dangerous_interp?(arg[3]) ||
              dangerous_string_building?(arg[3])
          end
        else
          failure = include_user_input?(arg)
        end

        break if failure
      end
    when :system, :exec
      # Normally, if we're in a `system` or `exec` call, we only are worried
      # about shell injection when there's a single argument, because comma-
      # separated arguments are always escaped by Ruby. However, an exception is
      # when the first two arguments are something like "bash -c" because then
      # the third argument is effectively the command being run and might be
      # a malicious executable if it comes (partially or fully) from user input.
      if dash_c_shell_command?(first_arg, call.second_arg)
        failure = include_user_input?(args[3]) ||
                  dangerous_interp?(args[3]) ||
                  dangerous_string_building?(args[3])
      else
        failure = include_user_input?(first_arg) ||
                  dangerous_interp?(first_arg) ||
                  dangerous_string_building?(first_arg)
      end
    when :capture2, :capture2e, :capture3
      # Open3 capture methods can take a :stdin_data argument which is used as the
      # the input to the called command so it is not succeptable to command injection.
      # As such if the last argument is a hash (and therefore execution options) it
      # should be ignored

      args.pop if hash?(args.last) && args.length > 2
      failure = include_user_input?(args) ||
                dangerous_interp?(args) ||
                dangerous_string_building?(args)
    else
      failure = include_user_input?(args) ||
                dangerous_interp?(args) ||
                dangerous_string_building?(args)
    end

    if failure and original? result

      if failure.type == :interp #Not from user input
        confidence = :medium
      else
        confidence = :high
      end

      warn :result => result,
        :warning_type => "Command Injection",
        :warning_code => :command_injection,
        :message => "Possible command injection",
        :code => call,
        :user_input => failure,
        :confidence => confidence,
        :cwe_id => [77]
    end
  end

  # @return [Boolean] true iff the command given by `first_arg`, `second_arg`
  #   invokes a new shell process via `<shell_command> -c` (like `bash -c`)
  def dash_c_shell_command?(first_arg, second_arg)
    string?(first_arg) &&
    KNOWN_SHELL_COMMANDS.include?(first_arg.value) &&
    string?(second_arg) &&
    second_arg.value == "-c"
  end

  def check_open_calls
    tracker.find_call(:targets => [nil, :Kernel], :method => :open).each do |result|
      if match = dangerous_open_arg?(result[:call].first_arg)
        warn :result => result,
          :warning_type => "Command Injection",
          :warning_code => :command_injection,
          :message => msg("Possible command injection in ", msg_code("open")),
          :user_input => match,
          :confidence => :high,
          :cwe_id => [77]
      end
    end
  end

  def include_user_input? exp
    if node_type? exp, :arglist, :dstr, :evstr, :dxstr
      exp.each_sexp do |e|
        if res = include_user_input?(e)
          return res
        end
      end

      false
    else
      if shell_escape? exp
        false
      else
        super exp
      end
    end
  end

  def dangerous_open_arg? exp
    if string_interp? exp
      # Check for input at start of string
      exp[1] == "" and
        node_type? exp[2], :evstr and
        has_immediate_user_input? exp[2]
    else
      has_immediate_user_input? exp
    end
  end

  #Looks for calls using backticks such as
  #
  # `rm -rf #{params[:file]}`
  def check_for_backticks tracker
    tracker.find_call(:target => nil, :method => :`).each do |result|
      process_backticks result
    end
  end

  #Processes backticks.
  def process_backticks result
    return unless original? result

    exp = result[:call]

    if input = include_user_input?(exp)
      confidence = :high
    elsif input = dangerous?(exp)
      confidence = :medium
    else
      return
    end

    warn :result => result,
      :warning_type => "Command Injection",
      :warning_code => :command_injection,
      :message => "Possible command injection",
      :code => exp,
      :user_input => input,
      :confidence => confidence,
      :cwe_id => [77]
  end

  # This method expects a :dstr or :evstr node
  def dangerous? exp
    exp.each_sexp do |e|
      if call? e and e.method == :to_s
        e = e.target
      end

      next if node_type? e, :lit, :str
      next if SAFE_VALUES.include? e
      next if shell_escape? e
      next if temp_file_path? e

      if node_type? e, :if
        # If we're in a conditional, evaluate the `then` and `else` clauses to
        # see if they're dangerous.
        if res = dangerous?(e.sexp_body.sexp_body)
          return res
        end
      elsif node_type? e, :or, :evstr, :dstr
        if res = dangerous?(e)
          return res
        end
      else
        return e
      end
    end

    false
  end

  def dangerous_interp? exp
    match = include_interp? exp
    return unless match
    interp = match.match

    interp.each_sexp do |e|
      if res = dangerous?(e)
        return Match.new(:interp, res)
      end
    end

    false
  end

  #Checks if an expression contains string interpolation.
  #
  #Returns Match with :interp type if found.
  def include_interp? exp
    @string_interp = false
    process exp
    @string_interp
  end

  def dangerous_string_building? exp
    if string_building?(exp) && res = dangerous?(exp)
      return Match.new(:interp, res)
    end

    false
  end

  def shell_escape? exp
    return false unless call? exp

    if exp.target == SHELLWORDS and SHELL_ESCAPE_MODULE_METHODS.include? exp.method
      true
    elsif SHELL_ESCAPE_MIXIN_METHODS.include?(exp.method)
      true
    else
      false
    end
  end
end
