# 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/interface'
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::Interface
            access_component :analysis, :app_context, :logging

            def disabled?
              !ASSESS.enabled? || 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