Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial support for ActiveRecord enums (and other things, too) #1618

Merged
merged 12 commits into from Jul 16, 2021
35 changes: 33 additions & 2 deletions lib/brakeman/processors/alias_processor.rb
Expand Up @@ -208,6 +208,15 @@ def process_call exp
return Sexp.new(:false).line(exp.line)
end

# For the simplest case of `Foo.thing`
if node_type? target, :const and first_arg.nil?
if tracker and (klass = tracker.find_class(class_name(target.value)))
if return_value = klass.get_simple_method_return_value(:class, method)
return return_value.deep_clone(exp.line)
end
end
end

#See if it is possible to simplify some basic cases
#of addition/concatenation.
case method
Expand Down Expand Up @@ -794,6 +803,18 @@ def process_cdecl exp
exp
end

def hash_or_array_include_all_literals? exp
return unless call? exp and sexp? exp.target
target = exp.target

case target.node_type
when :hash
hash_include_all_literals? exp
else
array_include_all_literals? exp
end
end

# Check if exp is a call to Array#include? on an array literal
# that contains all literal values. For example:
#
Expand All @@ -812,6 +833,16 @@ def array_detect_all_literals? exp
(all_literals? exp.target or dir_glob? exp.target)
end

# Check if exp is a call to Hash#include? on a hash literal
# that contains all literal values. For example:
#
# {x: 1}.include? x
def hash_include_all_literals? exp
call? exp and
exp.method == :include? and
all_literals? exp.target, :hash
end

#Sets @inside_if = true
def process_if exp
if @ignore_ifs.nil?
Expand Down Expand Up @@ -852,15 +883,15 @@ def process_if exp
scope do
@branch_env = env.current
branch_index = 2 + i # s(:if, condition, then_branch, else_branch)
if i == 0 and array_include_all_literals? condition
if i == 0 and hash_or_array_include_all_literals? condition
# If the condition is ["a", "b"].include? x
# set x to "a" inside the true branch
var = condition.first_arg
previous_value = env.current[var]
env.current[var] = safe_literal(var.line)
exp[branch_index] = process_if_branch branch
env.current[var] = previous_value
elsif i == 1 and array_include_all_literals? condition and early_return? branch
elsif i == 1 and hash_or_array_include_all_literals? condition and early_return? branch
var = condition.first_arg
env.current[var] = safe_literal(var.line)
exp[branch_index] = process_if_branch branch
Expand Down
6 changes: 0 additions & 6 deletions lib/brakeman/processors/lib/call_conversion_helper.rb
@@ -1,11 +1,5 @@
module Brakeman
module CallConversionHelper
def all_literals? exp, expected_type = :array
node_type? exp, expected_type and
exp.length > 1 and
exp.all? { |e| e.is_a? Symbol or node_type? e, :lit, :str }
end

# Join two array literals into one.
def join_arrays lhs, rhs, original_exp = nil
if array? lhs and array? rhs
Expand Down
31 changes: 31 additions & 0 deletions lib/brakeman/processors/model_processor.rb
Expand Up @@ -73,6 +73,8 @@ def process_call exp
@current_class.set_attr_accessible exp
when :attr_protected
@current_class.set_attr_protected exp
when :enum
add_enum_method exp
else
if @current_class
@current_class.add_option method, exp
Expand All @@ -87,4 +89,33 @@ def process_call exp
call
end
end

def add_enum_method call
arg = call.first_arg
return unless hash? arg

enum_name = arg[1].value # first key

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line raises WrongSexpError: Sexp#value called on multi-item Sexp if arg[1] is string interpolation like the followings. Unfortunately our project contains this rare case 😢

%i[a b].each do |prefix|
  enum "#{prefix}_column" => [...]
end

enums = arg[2] # first value
enums_name = pluralize(enum_name.to_s).to_sym

call_line = call.line

if hash? enums
enum_values = enums
elsif array? enums
# Build hash for enum values like Rails does
enum_values = s(:hash).line(call_line)

enums.each_sexp.with_index do |v, index|
enum_values << v
enum_values << s(:lit, index).line(call_line)
end
end

enum_method = s(:defn, enum_name, s(:args), safe_literal(call_line))
enums_method = s(:defs, s(:self), enums_name, s(:args), enum_values)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I may be wrong but I think that enum_values have to take into consideration _prefix/_suffix options.
https://api.rubyonrails.org/v5.2.3/classes/ActiveRecord/Enum.html

enum(item_type: %i[digital]) #=> define digital
enum(item_type: %i[digital], _prefix: true) #=> define item_type_digital


@current_class.add_method :public, enum_name, enum_method, @current_file
@current_class.add_method :public, enums_name, enums_method, @current_file
end
end
28 changes: 28 additions & 0 deletions lib/brakeman/tracker/collection.rb
Expand Up @@ -15,6 +15,7 @@ def initialize name, parent, file_name, src, tracker
@includes = []
@methods = { :public => {}, :private => {}, :protected => {} }
@class_methods = {}
@simple_methods = { :class => {}, instance: {} }
@options = {}
@tracker = tracker

Expand Down Expand Up @@ -49,6 +50,7 @@ def add_option name, exp

def add_method visibility, name, src, file_name
meth_info = Brakeman::MethodInfo.new(name, src, self, file_name)
add_simple_method_maybe meth_info

if src.node_type == :defs
@class_methods[name] = meth_info
Expand Down Expand Up @@ -112,5 +114,31 @@ def top_line
def methods_public
@methods[:public]
end

def get_simple_method_return_value type, name
@simple_methods[type][name]
end

private

def add_simple_method_maybe meth_info
if meth_info.very_simple_method?
add_simple_method meth_info
end
end

def add_simple_method meth_info
name = meth_info.name
value = meth_info.return_value

case meth_info.src.node_type
when :defn
@simple_methods[:instance][name] = value
when :defs
@simple_methods[:class][name] = value
else
raise "Expected sexp type: #{src.node_type}"
end
end
end
end
41 changes: 41 additions & 0 deletions lib/brakeman/tracker/method_info.rb
Expand Up @@ -19,11 +19,52 @@ def initialize name, src, owner, file
else
raise "Expected sexp type: #{src.node_type}"
end

@simple_method = nil
end

# To support legacy code that expected a Hash
def [] attr
self.send(attr)
end

def very_simple_method?
return @simple_method == :very unless @simple_method.nil?

# Very simple methods have one (simple) expression in the body and
# no arguments
if src.formal_args.length == 1 # no args
if src.method_length == 1 # Single expression in body
value = first_body # First expression in body

if simple_literal? value or
(array? value and all_literals? value) or
(hash? value and all_literals? value, :hash)

@return_value = value
@simple_method = :very
end
end
end

@simple_method ||= false
end

def return_value env = nil
if very_simple_method?
return @return_value
else
nil
end
end

def first_body
case @type
when :class
src[4]
when :instance
src[3]
end
end
end
end
20 changes: 18 additions & 2 deletions lib/brakeman/util.rb
Expand Up @@ -50,7 +50,11 @@ def underscore camel_cased_word

# stupid simple, used to delegate to ActiveSupport
def pluralize word
word + "s"
if word.end_with? 's'
word + 'es'
else
word + 's'
end
end

#Returns a class name as a Symbol.
Expand Down Expand Up @@ -293,12 +297,24 @@ def node_type? exp, *types
exp.is_a? Sexp and types.include? exp.node_type
end

LITERALS = [:lit, :false, :str, :true, :array, :hash]
SIMPLE_LITERALS = [:lit, :false, :str, :true]

def simple_literal? exp
exp.is_a? Sexp and SIMPLE_LITERALS.include? exp.node_type
end

LITERALS = [*SIMPLE_LITERALS, :array, :hash]

def literal? exp
exp.is_a? Sexp and LITERALS.include? exp.node_type
end

def all_literals? exp, expected_type = :array
node_type? exp, expected_type and
exp.length > 1 and
exp.all? { |e| e.is_a? Symbol or node_type? e, :lit, :str }
end

DIR_CONST = s(:const, :Dir)

# Dir.glob(...).whatever
Expand Down
14 changes: 14 additions & 0 deletions lib/ruby_parser/bm_sexp.rb
Expand Up @@ -543,6 +543,20 @@ def body_list
self.body.unshift :rlist
end

# Number of "statements" in a method.
# This is more effecient than `Sexp#body.length`
# because `Sexp#body` creates a new Sexp.
def method_length
expect :defn, :defs

case self.node_type
when :defn
self.length - 3
when :defs
self.length - 4
end
end

def render_type
expect :render
self[1]
Expand Down
11 changes: 11 additions & 0 deletions test/apps/rails6/app/controllers/groups_controller.rb
Expand Up @@ -69,4 +69,15 @@ def test_rails6_sqli
User.order("name #{params[:direction]}") # Warn in 6.0, not in 6.1
User.order(:name).reorder(params[:column]) # Warn in 6.0, not in 6.1
end

# From https://github.com/presidentbeef/brakeman/issues/1492
def enum_include_check
status = "#{params[:status]}"
if Group.statuses.include? status
@status = status.to_sym
@countries = Group.send(@status) # Should not warn
else
redirect_to root_path, notice: 'Invalid status'
end
end
end
16 changes: 16 additions & 0 deletions test/apps/rails6/app/models/group.rb
Expand Up @@ -18,4 +18,20 @@ def fetch_constant_hash_value(role_name)
role = roles.fetch(role_name)
Arel.sql("role = '#{role}'")
end

def use_simple_method
# No warning
self.where("thing = #{Group.simple_method}")
end

def self.simple_method
"Hello"
end

enum status: { start: 0, stop: 2, in_process: 3 }

def use_enum
# No warning
self.where("thing IN #{Group.statuses.values_at(*[:start, :stop]).join(',')}")
end
end
6 changes: 6 additions & 0 deletions test/apps/rails6/app/models/user.rb
Expand Up @@ -24,4 +24,10 @@ def self.more_heredocs
def recent_stuff
where("date > #{Date.today - 1}")
end

enum state: ["pending", "active", "archived"]

def check_enum
where("state = #{User.states["pending"]}")
end
end
26 changes: 26 additions & 0 deletions test/tests/rails6.rb
Expand Up @@ -254,6 +254,32 @@ def test_sql_injection_reorder
:user_input => s(:call, s(:params), :[], s(:lit, :column))
end

def test_sql_injection_enum
assert_no_warning :type => :warning,
:warning_code => 0,
:fingerprint => "b2071137eba7ef6ecbcc1c6381a428e5c576a5fadf73dc04b2e155c41043e1d2",
:warning_type => "SQL Injection",
:line => 31,
:message => /^Possible\ SQL\ injection/,
:confidence => 0,
:relative_path => "app/models/user.rb",
:code => s(:call, nil, :where, s(:dstr, "state = ", s(:evstr, s(:call, s(:call, s(:const, :User), :states), :[], s(:str, "pending"))))),
:user_input => s(:call, s(:call, s(:const, :User), :states), :[], s(:str, "pending"))
end

def test_dangerous_send_enum
assert_no_warning :type => :warning,
:warning_code => 23,
:fingerprint => "483fa36e41f5791e86f345a19b517a61859886d685ce40ef852871bb7a935f2d",
:warning_type => "Dangerous Send",
:line => 78,
:message => /^User\ controlled\ method\ execution/,
:confidence => 0,
:relative_path => "app/controllers/groups_controller.rb",
:code => s(:call, s(:const, :Group), :send, s(:call, s(:dstr, "", s(:evstr, s(:call, s(:params), :[], s(:lit, :status)))), :to_sym)),
:user_input => s(:call, s(:params), :[], s(:lit, :status))
end

def test_cross_site_scripting_sanity
assert_warning :type => :template,
:warning_code => 2,
Expand Down