module Pluginscan # Responsible for deciding whether usages of a variable in a string are safe class VariableSafetyChecker # Functions which, if they surround the variable, make it safe # because they return a boolean and not the value of the varaible SAFE_FUNCTIONS = [ "isset", "empty", "in_array", "strpos", "strlen", "if", "switch", "is_email", # PHP typechecks - seen in the wild: "is_array", # PHP typechecks - not seen in the wild: "is_bool", "is_callable", "is_double", "is_float", "is_int", "is_integer", "is_long", "is_null", "is_numeric", "is_object", "is_real", "is_resource", "is_scalar", "is_string", "intval", "absint", "wp_verify_nonce", "count", "sizeof", "unset", # Candidates for inclusion - not seen in the wild: # "gettype", # "settype", # "boolval", # "doubleval", # might match eval? # "floatval", ].freeze # Infixes which, if they are used around the variable, make it safe, # because they are checking the value, not returning it SAFE_INFIXES = [ '==', '===', '!=', '!==', '<', '>', '<=', '>=', ].freeze INFIX_CHARS = %w(= < > !).freeze def all_safe?(variable, content) match_count(variable, content) <= safe_count(variable, content) end def match_count(variable, content) content.scan(variable).count # `scan` returns ALL matches end def safe_count(variable, content) safe_function_count(variable, content) + safe_infix_count(variable, content) end # The number of matches which are safe by being wrapped in a function private def safe_function_count(variable, content) SAFE_FUNCTIONS.map { |function| wrapped_in_function_count(function, variable, content) }.inject(:+) end # The number of matches which are safe by being checked in an infix private def safe_infix_count(variable, content) SAFE_INFIXES.map { |infix| used_in_infix_check_count(infix, variable, content) }.inject(:+) end # TODO: the below methods feel private, but are directly tested # That makes me feel like there's an object to be extracted here def used_in_infix_check_count(infix, variable, content) variable = Regexp.escape variable infix = Regexp.escape infix non_infix = "[^#{Regexp.escape INFIX_CHARS.join}]" equals_before_regexp = /#{non_infix}#{infix}\ *#{variable}\ *\[/ equals_after_regexp = /#{variable}\ *\[[^\[]+\]\ *#{infix}#{non_infix}/ content.scan(equals_before_regexp).count + content.scan(equals_after_regexp).count end def wrapped_in_function_count(function_name, variable, content) variable = Regexp.escape variable content.scan(/#{function_name}\ *\(\ *#{variable}[^)]*\)/).count end end end