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

cs__scoped_require 'contrast/agent/settings_state'
cs__scoped_require 'contrast/extensions/ruby_core/module'
cs__scoped_require 'contrast/utils/boolean_util'

module Contrast
  module Agent
    # This class functions as a way to query the Agent for its current feature
    # set without having to expose other sections of code to the decision tree
    # needed to make that determination.
    class FeatureState < SettingsState
      include Singleton

      # FeatureState methods are grouped in modules,
      # and these modules are organized roughly in ascending order
      # according to their complexity - where more complex
      # methods call simpler ones.

      module ConfigWrappers
        # Wrapper to provide convenience for boolean values, allowing for the
        # handling of nil defaults that behave like true and other strange
        # cases.
        module Booleans
          include Contrast::Components::Interface
          access_component :config

          def false? config
            Contrast::Utils::BooleanUtil.false?(config)
          end

          def true? config
            Contrast::Utils::BooleanUtil.true?(config)
          end

          def inventory_enabled?
            !false?(CONFIG.root.inventory.enable)
          end

          def protect_forcibly_enabled?
            true?(CONFIG.root.protect.enable)
          end

          def protect_forcibly_disabled?
            false?(CONFIG.root.protect.enable)
          end

          def assess_forcibly_enabled?
            true?(CONFIG.root.assess.enable)
          end

          def assess_forcibly_disabled?
            false?(CONFIG.root.assess.enable)
          end

          def assess_track_frozen_sources?
            !false?(CONFIG.root.agent.ruby.track_frozen_sources)
          end

          def scan_response?
            !false?(CONFIG.root.assess.enable_scan_response)
          end

          def omit_body?
            true?(CONFIG.root.agent.omit_body)
          end

          def require_scanning_enabled?
            !false?(CONFIG.root.agent.ruby.require_scan)
          end

          def heap_dump_enabled?
            true?(CONFIG.root.agent.heap_dump.enable)
          end

          def service_forcibly_disabled?
            false?(CONFIG.root.agent.start_bundled_service)
          end

          def report_any_command_execution?
            ctrl = protect_rule_config[Contrast::Agent::Protect::Rule::CmdInjection::NAME]
            true?(ctrl.disable_system_commands)
          end

          def report_custom_code_sysfile_access?
            ctrl = protect_rule_config[Contrast::Agent::Protect::Rule::PathTraversal::NAME]
            true?(ctrl.detect_custom_code_accessing_system_files)
          end

          # Determines if the Process we're currently in matches that of the
          # Process in which the FeatureState instance was created.
          # If it doesn't, that indicates the running context is in a new
          # Process.
          # @return [Boolean] if we're in the original Process in which the
          #   FeaturesState instance was initialized.
          def in_new_process?
            current_pid = Process.pid
            original_pid = pid
            current_pid != original_pid
          end
        end

        # Wrapper for configurations regarding assess & protect rules
        module Parameters
          include Contrast::Components::Interface
          access_component :settings, :config

          def protect_rule_config
            CONFIG.root.protect.rules || []
          end

          def assess_rule_disabled? name
            assess_disabled_rules.include? name
          end

          def protect_rule_enabled? rule_id
            protect_rule_mode(rule_id).to_sym != :NO_ACTION
          end

          def assess_tags
            CONFIG.root.assess&.tags
          end

          def assess_disabled_rules
            CONFIG.root.assess&.rules&.disabled_rules || SETTINGS.disabled_assess_rules || []
          end

          def current_session_id
            Contrast::Utils::StringUtils.force_utf8(SETTINGS.session_id)
          end

          def disabled_agent_rake_tasks
            CONFIG.root.agent.ruby.disabled_agent_rake_tasks
          end

          def exclusions
            SETTINGS.exclusion_matchers
          end

          def url_exclusions
            exclusions.select(&:url?)
          end

          def input_exclusions
            exclusions.select(&:input?)
          end

          def code_exclusions
            exclusions.select(&:code?)
          end
        end
      end

      # Wrapper for the Assess & Protect states
      module SettingWrappers
        include Contrast::Components::Interface
        access_component :settings, :config

        def protect_setting_enabled?
          SETTINGS.protect_state[:enabled]
        end

        def assess_setting_enabled?
          SETTINGS.assess_state[:enabled]
        end
      end

      # whether it's connected to the Contrast Service,
      # whether config succeeded,
      # whether writable paths are actually writeable, etc.
      module AgentState
        # Indicates the status of the Agent - initialized & ready - as well as
        # the mode(s) in which it is functioning
        module Operation
          include Contrast::Components::Interface
          access_component :agent

          # Return true if the agent is ready to protect the application
          def agent_ready?
            AGENT.enabled? && connection_established? && update_received?
          end

          def protect_enabled?
            return false unless AGENT.enabled?

            # config overrides if forcibly set
            return false if protect_forcibly_disabled?
            return true  if protect_forcibly_enabled?

            !!protect_setting_enabled?
          end

          def assess_enabled?
            return false unless AGENT.enabled?

            # config overrides if forcibly set
            return false if assess_forcibly_disabled?
            return true  if assess_forcibly_enabled?

            !!assess_setting_enabled?
          end
        end

        # Wrapper for the state of our Service settings and a way to access the
        # Contrast::Agent::SocketClient responsible for interactions between
        # the Agent & Service.
        module Service
          include Contrast::Components::Interface
          access_component :agent

          def service_enabled?
            AGENT.enabled? && !service_forcibly_disabled?
          end

          def client
            @_client ||= Contrast::Agent::SocketClient.new
          end

          # Return true if the agent has connected with the service
          def connection_established?
            client.connection_established?
          end

          # Return true if the agent has processed a response from the service
          def update_received?
            !@last_update.nil?
          end
        end
      end

      # 'Controls' synthesize all of the above into parameter sets.
      # These can have more complex logic.
      module Controls
        include Contrast::Components::Interface
        access_component :config

        def heap_dump_control
          {
              path: File.absolute_path(CONFIG.root.agent.heap_dump.path),
              count: CONFIG.root.agent.heap_dump.count.to_i,
              window: CONFIG.root.agent.heap_dump.window_ms.to_f / 1000,
              delay: CONFIG.root.agent.heap_dump.delay_ms.to_f / 1000,
              clean: true?(CONFIG.root.agent.heap_dump.clean)
          }
        end

        DEFAULT_SAMPLING_ENABLED            = false
        DEFAULT_SAMPLING_BASELINE           = 5
        DEFAULT_SAMPLING_REQUEST_FREQUENCY  = 5
        DEFAULT_SAMPLING_RESPONSE_FREQUENCY = 25
        DEFAULT_SAMPLING_WINDOW_MS          = 180_000 # 3 minutes, arbitrary value from Java agent
        def sampling_control settings = @sampling_features
          cas = CONFIG.root.assess&.sampling

          {
              enabled: true?([cas&.enable, settings&.enabled, DEFAULT_SAMPLING_ENABLED] .reject(&:nil?).first),
              baseline: [cas&.baseline, settings&.baseline, DEFAULT_SAMPLING_BASELINE] .map(&:to_i).find(&:positive?),
              request_frequency: [cas&.request_frequency, settings&.request_frequency, DEFAULT_SAMPLING_REQUEST_FREQUENCY] .map(&:to_i).find(&:positive?),
              response_frequency: [cas&.response_frequency, settings&.response_frequency, DEFAULT_SAMPLING_RESPONSE_FREQUENCY].map(&:to_i).find(&:positive?),
              window: [cas&.window_ms, settings&.window_ms, DEFAULT_SAMPLING_WINDOW_MS] .map(&:to_i).find(&:positive?)
          }
        end

        DEFAULT_LOG_FILENAME = 'contrast_service.log'
        def service_control
          {
              host: CONFIG.root.agent.service.host || Contrast::Configuration::DEFAULT_HOST,
              port: CONFIG.root.agent.service.port || Contrast::Configuration::DEFAULT_PORT,
              socket_path: CONFIG.root.agent.service.socket,
              logger_path: CONFIG.root.agent.service.logger.path || DEFAULT_LOG_FILENAME
          }
        end

        def exception_control
          {
              enable: true?(CONFIG.root.agent.ruby.exceptions.capture),
              status: CONFIG.root.agent.ruby.exceptions.override_status || 403,
              message: CONFIG.root.agent.ruby.exceptions.override_message || Contrast::Utils::ObjectShare::OVERRIDE_MESSAGE
          }
        end
      end

      # Wrapper for the state of our diagnostics settings
      module Diagnostic
        include Contrast::Components::Interface
        access_component :config

        def uninstrument_namespaces
          tmp = CONFIG.root.agent.ruby.uninstrument_namespace.map(&:to_sym)
          tmp.select! { |sym| Object.cs__const_defined?(sym) }
          tmp.map!    { |sym| Object.cs__const_get(sym) }
          tmp
        end
      end

      # Wrapper for the state of our logging settings and holder for utility
      # methods for logging state.
      module Logging
        include Contrast::Components::Interface
        access_component :config

        FALLBACK = STDOUT
        LOG_INFO_RULE_SETTINGS = 'Current rule settings:'
        # Utility method to log the current mode of all the rules
        def log_rule_modes
          return unless logger&.info?

          logger.info(LOG_INFO_RULE_SETTINGS)
          PROTECT.rules.each { |k, v| logger.info(".. protect:\t#{ k } (#{ v.mode })") }
          ASSESS.rules.each  { |k, v| logger.info(".. assess: \t#{ k } (#{ v.enabled? })") }
        end

        ENV_KEYS = %w[HOME PWD RACK_ENV RAILS_ENV RUBY_VERSION GEM_HOME GEM_PATH].cs__freeze
        # Utility method to log some current ruby and rails information from environment
        def log_environment
          return unless logger.info?

          logger.info "AGENT_VERSION=#{ Contrast::Agent::VERSION }"
          ENV.each do |env_key, env_value|
            env_key = env_key.to_s
            next unless ENV_KEYS.include?(env_key) ||
                (env_key.start_with?(Contrast::Components::Config::CONTRAST_ENV_MARKER) &&
                    !env_key.start_with?(Contrast::Components::Config::CONTRAST_ENV_MARKER + 'API'))

            logger.info "#{ env_key }=#{ env_value }"
          end
          logger.info "PARENT_PROCESS_ID=#{ Process.ppid }"
          logger.info "PROCESS_ID=#{ Process.pid }"
        end

        def log_configuration
          return unless logger.info?

          loggable = CONFIG.raw.send(:load_config)
          loggable.delete('api')
          logger.info "LOCAL CONFIGURATION: #{ JSON.pretty_generate(loggable) }"
        end

        FRAMEWORKS = %w[rails sinatra grape].cs__freeze
        WEB_SERVERS = %w[agoo falcon hoof iodine mongrel mongrel2 passenger puma rack skinny thin trinidad unicorn webrick yarn].cs__freeze
        LIBRARIES = %w[excon json mongo moped mysql nokogiri oga ox pg psych sqlite3 typhoeus yaml].cs__freeze
        def log_specific_libraries
          FRAMEWORKS.each(&cs__method(:log_gem_data))
          WEB_SERVERS.each(&cs__method(:log_gem_data))
          LIBRARIES.each(&cs__method(:log_gem_data))
        end

        def log_all_libraries
          return unless logger.debug?

          Gem.loaded_specs.each_pair do |_name, gem_spec|
            logger.debug { "GEM LOADED: #{ gem_spec.name }=#{ gem_spec.version }" }
          end
        end

        def log_gem_data gem_name
          gem_spec = Gem.loaded_specs[gem_name]
          return unless gem_spec

          logger.info { "GEM LOADED: #{ gem_spec.name }=#{ gem_spec.version }" }
        end
      end

      # Methods that query the config.
      include ConfigWrappers::Booleans    # These check boolean config options.
      include ConfigWrappers::Parameters  # These check against the config and return parameters.
      include SettingWrappers             # Methods that query settings from TeamServer.
      include AgentState::Operation       # Enable/disable the agent, ask if the agent can run.
      include AgentState::Service         # Agent's client that talks to the service.
      include Controls                    # Syntheses of the above.
      include Diagnostic                  # Misc.
      include Logging                     # Logging.
    end
  end
end