# Copyright (c) 2022 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'
require 'contrast/agent/reporting/report'

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

            # @return [Boolean]
            def disabled?
              !::Contrast::ASSESS.enabled? || ::Contrast::ASSESS.rule_disabled?(rule_id)
            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

            # 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_and_report(mod, name)
            rescue StandardError => e
              logger.error('Unable to parse AST for Hardcoded Rule analysis.', e)
            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

            # @param mod [Module] the module to which this AST pertains
            # @param name [String] the name of the hardcoded constant
            def build_and_report mod, name
              finding = build_finding(mod, name)
              return unless finding

              preflight = Contrast::Agent::Reporting::BuildPreflight.generate(finding)
              return unless preflight

              Contrast::Agent::Reporting::ReportingStorage[preflight.messages[0].data] = finding
              Contrast::Agent.reporter&.send_event(preflight)
            end

            # @param clazz [Class] the Class or Module containing the constant
            # @param constant_string [String]
            # @return [Contrast::Agent::Reporting::Finding]
            def build_finding clazz, constant_string
              finding = Contrast::Agent::Reporting::Finding.new(rule_id)
              finding.properties[SOURCE_KEY] = clazz.cs__name
              finding.properties[CONSTANT_NAME_KEY] = constant_string
              finding.properties[CODE_SOURCE_KEY] = constant_string + redacted_marker
              finding.hash_code = Contrast::Utils::HashDigest.generate_class_scanning_hash(finding)
              finding
            end
          end
        end
      end
    end
  end
end