# Copyright (c) 2021 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details.
# frozen_string_literal: true

module Contrast
  module Agent
    module Assess
      module Rule
        module Provider
          # Determine if there are any cryptographic keys hardcoded into the sourcecode
          # of the application. A constant is a cryptographic key if:
          # 1) the name contains a KEY_FIELD_NAME value
          # 2) the value is a non-empty array of only Fixnums
          class HardcodedKey
            include Contrast::Agent::Assess::Rule::Provider::HardcodedValueRule

            NAME = 'hardcoded-key'
            def rule_id
              NAME
            end

            # These are names, determined by the security team (Matt & Ar), that
            # indicate a field is likely to be a password or secret token of some
            # sort.
            KEY_FIELD_NAMES = %w[KEY AES DES IV SECRET].cs__freeze

            # These are markers whose presence indicates that a field is more
            # likely to be a descriptor or requirement than an actual key.
            # We should ignore fields that contain them.
            NON_KEY_PARTIAL_NAMES = %w[CONTENT_CODES RESPONSE_CODES ERROR_CODES].cs__freeze

            def name_passes? constant_string
              KEY_FIELD_NAMES.any? { |name| constant_string.index(name) } &&
                  NON_KEY_PARTIAL_NAMES.none? { |name| constant_string.index(name) }
            end

            BYTE_HOLDERS = %i[ARRAY LIST].cs__freeze
            # Determine if the given value node violates the hardcode key rule
            # @param value_node [RubyVM::AbstractSyntaxTree::Node] the node to
            #   evaluate
            # @return [Boolean]
            def value_node_passes? value_node
              # If it's a freeze call, then evaluate the entity being frozen
              value_node = value_node.children[0] if freeze_call?(value_node)
              # If it's a String being turned into bytes, then it matches key
              # expectations
              return true if bytes_call?(value_node)

              type = value_node.type
              return false unless BYTE_HOLDERS.include?(type)
              return false unless value_node.children.any?

              # Unless this is an array of literal numerics, we don't match.
              # That array seems to always end in a nil value, so we allow
              # those as well.
              value_node.children.each do |child|
                next unless child

                return false unless child.cs__is_a?(RubyVM::AbstractSyntaxTree::Node) &&
                    child.type == :LIT &&
                    child.children[0]&.cs__is_a?(Integer)
              end

              true
            end

            REDACTED_MARKER = ' = [**REDACTED**]'
            def redacted_marker
              REDACTED_MARKER
            end

            # A node is a bytes_call if it's the Node for String#bytes. We care
            # about this specifically as it's likely to be a common way to
            # generate a key constant, rather than directly declaring an
            # integer array.
            #
            # @param value_node [RubyVM::AbstractSyntaxTree::Node] the node to
            #   evaluate
            # @return [Boolean] is this a node for String#bytes or not
            def bytes_call? value_node
              return false unless value_node.type == :CALL

              children = value_node.children
              return false unless children
              return false unless children.length >= 2

              potential_string_node = children[0]
              return false unless potential_string_node.cs__is_a?(RubyVM::AbstractSyntaxTree::Node) &&
                  potential_string_node.type == :STR

              children[1] == :bytes
            end

            # TODO: RUBY-1014 remove `#value_type_passes?` and `#value_passes?`
            # If the value is a byte array, or at least an array of numbers, it
            # passes for this rule
            def value_type_passes? value
              return false unless value.is_a?(Array) && value.any?

              value.each do |byte|
                return false unless byte.is_a?(Integer)
              end
              true
            end

            # There isn't a filter for the byte value. The check is not evaluated
            # for this rule
            def value_passes? _value
              true
            end
          end
        end
      end
    end
  end
end