Path: blob/master/lib/rubocop/cop/lint/check_code_missing_reason.rb
74553 views
# frozen_string_literal: true12module RuboCop3module Cop4module Lint5# Detects CheckCode usages inside `check` methods that are missing a6# human-readable reason string.7#8# Every CheckCode *returned* from a `check` method should include a reason9# so that users understand why the target was assessed that way. The cop10# only fires inside `def check` bodies, which avoids false positives from11# the many legitimate non-return uses of CheckCode constants elsewhere12# (comparisons, case/when branches, array membership checks, etc.).13#14# Flagged patterns (inside `def check` only):15# - Bare constants with no call: `CheckCode::Safe`16# - Empty calls: `CheckCode::Safe()`17# - Kwargs-only calls: `CheckCode::Safe(details: {...})`18#19# @example20# # bad - bare constant, no reason21# def check22# CheckCode::Safe23# Exploit::CheckCode::Vulnerable24# end25#26# # bad - called with no reason string27# def check28# CheckCode::Safe()29# Exploit::CheckCode::Unknown()30# end31#32# # bad - only keyword args, no reason string33# def check34# CheckCode::Vulnerable(details: { version: '1.0' })35# end36#37# # good - reason string provided38# def check39# CheckCode::Safe('The target is not running the vulnerable service')40# Exploit::CheckCode::Appears("Version #{version} appears vulnerable")41# CheckCode::Vulnerable('Confirmed RCE', details: { version: version })42# end43#44# # fine - comparisons and case/when outside check are not flagged45# def exploit46# fail_with(...) unless check == CheckCode::Vulnerable47# case checkcode48# when Exploit::CheckCode::Vulnerable, Exploit::CheckCode::Appears49# print_good(checkcode.message)50# end51# end52#53class CheckCodeMissingReason < Base54MSG = 'Provide a human-readable reason string when returning a CheckCode, ' \55"e.g. `%<check_code>s('The target is not vulnerable because ...')`"5657CHECK_CODE_METHODS = %i[58Unknown59Safe60Detected61Appears62Vulnerable63Unsupported64].to_set.freeze6566# Matches the receiver of a CheckCode call or constant — the `CheckCode`67# portion of `CheckCode::Safe`, `Exploit::CheckCode::Safe`, or68# `Msf::Exploit::CheckCode::Safe`.69def_node_matcher :check_code_receiver?, <<~PATTERN70{71(const nil? :CheckCode)72(const (const nil? :Exploit) :CheckCode)73(const (const (const nil? :Msf) :Exploit) :CheckCode)74}75PATTERN7677# Matches a bare CheckCode constant with no call, e.g. `CheckCode::Safe`78# or `Exploit::CheckCode::Appears`.79def_node_matcher :bare_check_code_const?, <<~PATTERN80(const #check_code_receiver? CHECK_CODE_METHODS)81PATTERN8283# Matches a CheckCode method call, e.g. `CheckCode::Safe(...)` or84# `Exploit::CheckCode::Appears('msg')`.85def_node_matcher :check_code_call?, <<~PATTERN86(send #check_code_receiver? CHECK_CODE_METHODS ...)87PATTERN8889def on_const(node)90return unless bare_check_code_const?(node)91return unless inside_check_method?(node)92# Skip if this const is the receiver of a send node — the on_send93# handler will cover that case and we don't want a double offense.94return if node.parent&.send_type? && node.parent.receiver == node95# Skip when used as a comparator: `checkcode == CheckCode::Safe`,96# `check.eql? CheckCode::Vulnerable`, case/when branches, arrays, etc.97# These are consumers of a CheckCode value, not return values.98return if used_as_comparator?(node)99100add_offense(node, message: format(MSG, check_code: node.source))101end102103def on_send(node)104return unless check_code_call?(node)105return unless inside_check_method?(node)106return if reason?(node)107108add_offense(node, message: format(MSG, check_code: "#{node.receiver.source}::#{node.method_name}"))109end110111private112113# Returns true if the node is inside a `def check` method body.114def inside_check_method?(node)115node.each_ancestor(:def).any? { |def_node| def_node.method_name == :check }116end117118COMPARISON_METHODS = %i[== != === =~ !~ eql? equal?].to_set.freeze119120# Returns true when the CheckCode constant is being used as a comparator121# rather than as a return value — e.g. the RHS of `==`/`eql?`, a122# case/when branch, or an array element.123def used_as_comparator?(node)124parent = node.parent125return false unless parent126127# `when CheckCode::Safe` or `when CheckCode::Safe, CheckCode::Appears`128return true if parent.when_type?129130# `[CheckCode::Vulnerable, CheckCode::Appears]`131return true if parent.array_type?132133# `result == CheckCode::Safe`, `check.eql? CheckCode::Vulnerable`, etc.134# The node must be an argument (not the receiver) of the comparison send.135return true if parent.send_type? &&136parent.receiver != node &&137COMPARISON_METHODS.include?(parent.method_name)138139false140end141142# Returns true if the call has a non-hash first positional argument —143# i.e. any value used as the reason (string, interpolated string,144# variable, exception object, method call result, etc.).145# A hash as the sole first arg means only keyword args were passed.146def reason?(node)147first_arg = node.arguments.first148return false if first_arg.nil?149return false if first_arg.hash_type?150151true152end153end154end155end156end157158159