# Copyright (c) 2022 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
            REDACTED_MARKER = ' = [**REDACTED**]'
            NAME = 'hardcoded-key'
            # 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
            BYTE_HOLDERS = %i[ARRAY LIST].cs__freeze

            def rule_id
              NAME
            end

            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

            # 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

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

                  return false
                end
              end

              true
            end

            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]
              unless potential_string_node.cs__is_a?(RubyVM::AbstractSyntaxTree::Node) &&
                    potential_string_node.type == :STR

                return false
              end

              children[1] == :bytes
            end
          end
        end
      end
    end
  end
end