# Copyright (c) 2021 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details. # frozen_string_literal: true require 'contrast/agent/assess/policy/trigger_method' require 'contrast/components/logger' require 'contrast/extension/module' module Contrast module Agent module Assess module Rule module Provider # Hardcoded rules detect if any secret value has been written # directly into the sourcecode of the application. To use this base # class, a provider must implement three methods: # 1) name_passes? : does the constant name match a given value set # 2) value_node_passes? : does the value of the constant match a # given value set # 3) redacted_marker : the value to plug in for the obfuscated value module HardcodedValueRule include Contrast::Components::Logger::InstanceMethods def disabled? !::Contrast::ASSESS.enabled? || ::Contrast::ASSESS.rule_disabled?(rule_id) end # TODO: RUBY-1014 - remove `#analyze` COMMON_CONSTANTS = %i[CONTRAST_ASSESS_POLICY_STATUS VERSION].cs__freeze def analyze clazz return if disabled? # we only want the constants explicitly defined in this class, not # those of its ancestor(s) constants = clazz.cs__constants(false) # if there are no constants, let's just leave return unless constants&.any? constants.each do |constant| next if COMMON_CONSTANTS.include?(constant) # if this class autoloads its constant, get the hell away from it # I mean it! Don't even think about it. # # Autoload means this constant (usually [always?] a class or # module) won't be required until something in the application # tries to load it. We CANNOT be that thing. We'll just have to # wait until it's loaded, at which point we'll be handed it # again. next if clazz.cs__autoload?(constant) # constant comes to us as a symbol. that sucks. we need to do # some string methods on it, so stringify it. constant_string = constant.to_s # if this is another class or a module, move on next unless constant_name?(constant_string) next unless name_passes?(constant_string) value = clazz.cs__const_get(constant, false) # if the constant isn't holding a string, skip it next unless value_type_passes?(value) # if it looks like a placeholder / pointer to a config, skip it next unless value_passes?(value) build_finding(clazz, constant_string) end end # Parse the file pertaining to the given TracePoint to walk its AST # to determine if a Constant is hardcoded. For our purposes, this # hard coding means directly set rather than as an interpolated # String or through a method call. # # Note: This is a top layer check, we make no assertions about what # the methods or interpolations do. Their presence, even if only # calling a hardcoded thing, causes this check to not report. # # @param trace_point [TracePoint] the TracePoint event created on # the :end of a Module being loaded # @param ast [RubyVM::AbstractSyntaxTree::Node] the abstract syntax # tree of the Module defined in the TracePoint end event def parse trace_point, ast return if disabled? parse_ast(trace_point.self, ast) rescue StandardError => e logger.error('Unable to parse AST for hardcoded keys', e, module: trace_point.self) end # Constants can be variable or classes defined in the given # class. We ONLY want the variables, which should be defined in # the MACRO_CASE (upper case & underscore format) CONSTANT_NAME_PATTERN = /^[A-Z_]+$/.cs__freeze def constant_name? constant constant.match?(CONSTANT_NAME_PATTERN) end # The name of the field CONSTANT_NAME_KEY = 'name' # The code line, recreated, with the password obfuscated CODE_SOURCE_KEY = 'codeSource' # The constant name SOURCE_KEY = 'source' private # @param mod [Module] the module to which this AST pertains # @param ast [RubyVM::AbstractSyntaxTree::Node, Object] a node # within the AST, which may be a leaf, so any Object def parse_ast mod, ast return unless ast.cs__is_a?(RubyVM::AbstractSyntaxTree::Node) return unless ast.cs__respond_to?(:children) children = ast.children return unless children.any? ast.children.each do |child| parse_ast(mod, child) end # https://www.rubydoc.info/gems/ruby-internal/Node/CDECL return unless ast.type == :CDECL # The CDECL Node has two children, the first being the Constant # name as a symbol, the second as the value to assign to that # constant children = ast.children name = children[0].to_s # If that constant name doesn't pass our checks, move on. return unless name_passes?(name) value = children[1] # The assignment node could be a direct value or a call of some # sort. We leave it to each rule to properly handle these nodes. return unless value_node_passes?(value) build_finding(mod, name) end # Constants can be set as frozen directly. We need to account for # this change as it means the Node given to the :CDECL call will be # a :CALL, not a constant. # # @param value_node [RubyVM::AbstractSyntaxTree::Node] the node to # evaluate # @return [Boolean] is this a freeze call or not def freeze_call? value_node return false unless value_node.type == :CALL children = value_node.children return false unless children return false unless children.length >= 2 children[1] == :freeze end def build_finding clazz, constant_string class_name = clazz.cs__name finding = Contrast::Api::Dtm::Finding.new finding.rule_id = Contrast::Utils::StringUtils.protobuf_safe_string(rule_id) finding.version = Contrast::Agent::Assess::Policy::TriggerMethod::CURRENT_FINDING_VERSION finding.properties[SOURCE_KEY] = Contrast::Utils::StringUtils.protobuf_safe_string(class_name) finding.properties[CONSTANT_NAME_KEY] = Contrast::Utils::StringUtils.protobuf_safe_string(constant_string) finding.properties[CODE_SOURCE_KEY] = Contrast::Utils::StringUtils.protobuf_safe_string(constant_string + redacted_marker) hash = Contrast::Utils::HashDigest.generate_class_scanning_hash(finding) finding.hash_code = Contrast::Utils::StringUtils.protobuf_safe_string(hash) finding.preflight = Contrast::Utils::PreflightUtil.create_preflight(finding) Contrast::Agent::Assess::Policy::TriggerMethod.report_finding(finding) rescue StandardError => e logger.error('Unable to build a finding for Hardcoded Rule', e) nil end end end end end end end