diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index b1f2816be7..a7d2e1dbcd 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,12 +1,12 @@ # This configuration was generated by # `rubocop --auto-gen-config` -# on 2023-05-18 21:20:20 UTC using RuboCop version 1.51.0. +# on 2023-10-26 22:54:31 UTC using RuboCop version 1.54.0. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. -# Offense count: 30 +# Offense count: 31 # Configuration parameters: EnforcedStyle, AllowedGems, Include. # SupportedStyles: Gemfile, gems.rb, gemspec # Include: **/*.gemspec, **/Gemfile, **/gems.rb @@ -15,15 +15,20 @@ Gemspec/DevelopmentDependencies: - 'infinite_tracing/newrelic-infinite_tracing.gemspec' - 'newrelic_rpm.gemspec' -# Offense count: 416 +# Offense count: 443 # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. Metrics/AbcSize: Max: 40 Exclude: + - 'lib/new_relic/agent/configuration/default_source.rb' - infinite_tracing/test/**/* - lib/new_relic/cli/commands/deployments.rb - test/**/* +Metrics/CollectionLiteralLength: + Exclude: + - 'lib/new_relic/agent/configuration/default_source.rb' + # Offense count: 7 Minitest/AssertRaisesCompoundBody: Exclude: @@ -37,7 +42,7 @@ Minitest/DuplicateTestRun: - 'test/multiverse/suites/rails/error_tracing_test.rb' - 'test/multiverse/suites/sinatra/ignoring_test.rb' -# Offense count: 276 +# Offense count: 284 Minitest/MultipleAssertions: Max: 28 @@ -45,7 +50,7 @@ Minitest/MultipleAssertions: Minitest/TestFileName: Enabled: false -# Offense count: 22 +# Offense count: 20 # This cop supports safe autocorrection (--autocorrect). Minitest/TestMethodName: Enabled: false diff --git a/lib/new_relic/agent/agent_logger.rb b/lib/new_relic/agent/agent_logger.rb index 0bcda55cf6..90f94c3a29 100644 --- a/lib/new_relic/agent/agent_logger.rb +++ b/lib/new_relic/agent/agent_logger.rb @@ -4,6 +4,7 @@ require 'thread' require 'logger' +require 'singleton' require 'new_relic/agent/hostname' require 'new_relic/agent/log_once' require 'new_relic/agent/instrumentation/logger/instrumentation' diff --git a/lib/new_relic/agent/configuration/default_source.rb b/lib/new_relic/agent/configuration/default_source.rb index bc2fa142cc..b104da010c 100644 --- a/lib/new_relic/agent/configuration/default_source.rb +++ b/lib/new_relic/agent/configuration/default_source.rb @@ -72,7 +72,7 @@ def self.transform_for(key) value_from_defaults(key, :transform) end - def self.config_search_paths # rubocop:disable Metrics/AbcSize + def self.config_search_paths proc { yaml = 'newrelic.yml' config_yaml = File.join('config', yaml) @@ -2570,6 +2570,84 @@ def self.enforce_fallback(allowed_values: nil, fallback: nil) :type => Integer, :allowed_from_server => false, :description => 'This value represents the total amount of memory available to the host (not the process), in mebibytes (1024 squared or 1,048,576 bytes).' + }, + # security agent + :'security.agent.enabled' => { + :default => false, + :external => true, + :public => true, + :type => Boolean, + :allowed_from_server => false, + :description => "If `true`, the security agent is loaded (a Ruby 'require' is performed)" + }, + :'security.enabled' => { + :default => false, + :external => true, + :public => true, + :type => Boolean, + :allowed_from_server => false, + :description => 'If `true`, the security agent is started (the agent runs in its event loop)' + }, + :'security.mode' => { + :default => 'IAST', + :external => true, + :public => true, + :type => String, + :allowed_from_server => true, + :allowlist => %w[IAST RASP], + :description => 'Defines the mode for the security agent to operate in. Currently only `IAST` is supported', + :dynamic_name => true + }, + :'security.validator_service_url' => { + :default => 'wss://csec.nr-data.net', + :external => true, + :public => true, + :type => String, + :allowed_from_server => true, + :description => 'Defines the endpoint URL for posting security-related data', + :dynamic_name => true + }, + :'security.detection.rci.enabled' => { + :default => true, + :external => true, + :public => true, + :type => Boolean, + :allowed_from_server => false, + :description => 'If `true`, enables RCI (remote code injection) detection' + }, + :'security.detection.rxss.enabled' => { + :default => true, + :external => true, + :public => true, + :type => Boolean, + :allowed_from_server => false, + :description => 'If `true`, enables RXSS (reflected cross-site scripting) detection' + }, + :'security.detection.deserialization.enabled' => { + :default => true, + :external => true, + :public => true, + :type => Boolean, + :allowed_from_server => false, + :description => 'If `true`, enables deserialization detection' + }, + :'security.application_info.port' => { + :default => nil, + :allow_nil => true, + :public => true, + :type => Integer, + :external => true, + :allowed_from_server => false, + :description => 'The port the application is listening on. This setting is mandatory for Passenger servers. Other servers should be detected by default.' + }, + :'security.request.body_limit' => { + :default => 300, + :allow_nil => true, + :public => true, + :type => Integer, + :external => true, + :allowed_from_server => false, + :description => 'Defines the request body limit to process in security events (in KB). The default value is 300, for 300KB.' } }.freeze # rubocop:enable Metrics/CollectionLiteralLength diff --git a/lib/new_relic/agent/database/obfuscator.rb b/lib/new_relic/agent/database/obfuscator.rb index 0a90dcdb6c..5063fdcfa0 100644 --- a/lib/new_relic/agent/database/obfuscator.rb +++ b/lib/new_relic/agent/database/obfuscator.rb @@ -2,6 +2,7 @@ # See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details. # frozen_string_literal: true +require 'singleton' require 'new_relic/agent/database/obfuscation_helpers' module NewRelic diff --git a/lib/new_relic/agent/instrumentation/rack/instrumentation.rb b/lib/new_relic/agent/instrumentation/rack/instrumentation.rb index 7882b6127c..c39bde8586 100644 --- a/lib/new_relic/agent/instrumentation/rack/instrumentation.rb +++ b/lib/new_relic/agent/instrumentation/rack/instrumentation.rb @@ -13,6 +13,7 @@ class << builder_class attr_accessor :_nr_deferred_detection_ran end builder_class._nr_deferred_detection_ran = false + NewRelic::Control::SecurityInterface.instance.wait = true end def deferred_dependency_check @@ -21,6 +22,8 @@ def deferred_dependency_check NewRelic::Agent.logger.info('Doing deferred dependency-detection before Rack startup') DependencyDetection.detect! self.class._nr_deferred_detection_ran = true + NewRelic::Control::SecurityInterface.instance.wait = false + NewRelic::Control::SecurityInterface.instance.init_agent end def check_for_late_instrumentation(app) diff --git a/lib/new_relic/control.rb b/lib/new_relic/control.rb index fda4a2fac4..66d4c8ab29 100644 --- a/lib/new_relic/control.rb +++ b/lib/new_relic/control.rb @@ -8,7 +8,6 @@ require 'new_relic/language_support' require 'new_relic/helper' -require 'singleton' require 'erb' require 'socket' require 'net/https' @@ -18,6 +17,7 @@ require 'new_relic/control/instrumentation' require 'new_relic/control/class_methods' require 'new_relic/control/instance_methods' +require 'new_relic/control/security_interface' require 'new_relic/agent' require 'new_relic/delayed_job_injection' diff --git a/lib/new_relic/control/instance_methods.rb b/lib/new_relic/control/instance_methods.rb index e0cb566d2b..c64dee0297 100644 --- a/lib/new_relic/control/instance_methods.rb +++ b/lib/new_relic/control/instance_methods.rb @@ -73,6 +73,7 @@ def init_plugin(options = {}) init_config(options) NewRelic::Agent.agent = NewRelic::Agent::Agent.instance init_instrumentation + init_security_agent end def determine_env(options) diff --git a/lib/new_relic/control/private_instance_methods.rb b/lib/new_relic/control/private_instance_methods.rb index bcd14c7a81..2cd45cc827 100644 --- a/lib/new_relic/control/private_instance_methods.rb +++ b/lib/new_relic/control/private_instance_methods.rb @@ -43,6 +43,10 @@ def init_instrumentation DependencyDetection.detect! end end + + def init_security_agent + SecurityInterface.instance.init_agent + end end end end diff --git a/lib/new_relic/control/security_interface.rb b/lib/new_relic/control/security_interface.rb new file mode 100644 index 0000000000..7edbea5c62 --- /dev/null +++ b/lib/new_relic/control/security_interface.rb @@ -0,0 +1,57 @@ +# This file is distributed under New Relic's license terms. +# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details. +# frozen_string_literal: true + +require 'singleton' + +module NewRelic + class Control + class SecurityInterface + include Singleton + + attr_accessor :wait + + SUPPORTABILITY_PREFIX_SECURITY = 'Supportability/Ruby/SecurityAgent/Enabled/' + SUPPORTABILITY_PREFIX_SECURITY_AGENT = 'Supportability/Ruby/SecurityAgent/Agent/Enabled/' + ENABLED = 'enabled' + DISABLED = 'disabled' + + def agent_started? + (@agent_started ||= false) == true + end + + def waiting? + (@wait ||= false) == true + end + + def init_agent + return if agent_started? || waiting? + + record_supportability_metrics + + if Agent.config[:'security.agent.enabled'] && !Agent.config[:high_security] + Agent.logger.info('Invoking New Relic security module') + require 'newrelic_security' + + @agent_started = true + else + Agent.logger.info('New Relic Security is completely disabled by one of the user-provided configurations: `security.agent.enabled` or `high_security`. Not loading security capabilities.') + Agent.logger.info("high_security = #{Agent.config[:high_security]}") + Agent.logger.info("security.agent.enabled = #{Agent.config[:'security.agent.enabled']}") + end + rescue LoadError + Agent.logger.info('New Relic security agent not found - skipping') + rescue StandardError => exception + Agent.logger.error("Exception in New Relic security module loading: #{exception} #{exception.backtrace}") + end + + def record_supportability_metrics + Agent.config[:'security.agent.enabled'] ? security_agent_metric(ENABLED) : security_agent_metric(DISABLED) + end + + def security_agent_metric(setting) + NewRelic::Agent.record_metric_once(SUPPORTABILITY_PREFIX_SECURITY_AGENT + setting) + end + end + end +end diff --git a/newrelic.yml b/newrelic.yml index 3c19b3eb4a..f38f26e660 100644 --- a/newrelic.yml +++ b/newrelic.yml @@ -853,6 +853,55 @@ common: &default_settings # Foundry environment. # utilization.detect_pcf: true + # + # BEGIN security agent + # + # NOTE: At this time, the security agent is intended for use only within + # a dedicated security testing environment with data that can tolerate + # modification or deletion. The security agent is available as a + # separate Ruby gem, newrelic_security. It is recommended that this + # separate gem only be introduced to a security testing environment + # by leveraging Bundler grouping like so: + # + # # Gemfile + # gem 'newrelic_rpm' # New Relic APM observability agent + # gem 'newrelic-infinite_tracing' # New Relic Infinite Tracing + # + # group :security do + # gem 'newrelic_security' # New Relic security agent + # end + # + # NOTE: All "security.*" configuration parameters are related only to the + # security agent, and all other configuration parameters that may + # have "security" in the name some where are related to the APM agent. + # + + # If true, the security agent is loaded (a Ruby 'require' is performed) + # security.agent.enabled: false + + # If true, the security agent is started (the agent runs in its event loop) + # security.enabled: false + + # Defines the mode for the security agent to operate in. Currently only 'IAST' is supported + # security.mode: IAST + + # Defines the endpoint URL for posting security related data + # security.validator_service_url: wss://csec.nr-data.net + + # If `true`, enables RCI(Remote Code Injection) detection + # security.detection.rci.enabled: true + + # If `true`, enables RXSS(Reflected Cross-site Scripting) detection + # security.detection.rxss.enabled: true + + # If `true`, enables deserialization detection + # security.detection.deserialization.enabled: true + + # The port the application is listening on. This setting is mandatory for Passenger servers. Other servers should be detected by default. + # security.application_info.port: nil + + # END security agent + # Environment-specific settings are in this section. # RAILS_ENV or RACK_ENV (as appropriate) is used to determine the environment. # If your application has other named environments, configure them here. diff --git a/test/new_relic/control/security_interface_test.rb b/test/new_relic/control/security_interface_test.rb new file mode 100644 index 0000000000..8eee07c86e --- /dev/null +++ b/test/new_relic/control/security_interface_test.rb @@ -0,0 +1,147 @@ +# This file is distributed under New Relic's license terms. +# See https://github.com/newrelic/newrelic-ruby-agent/blob/main/LICENSE for complete details. +# frozen_string_literal: true + +require_relative '../../test_helper' +require 'new_relic/control/security_interface' + +class NewRelic::Control::SecurityInterfaceTest < Minitest::Test + def setup + reset_supportability_metrics + NewRelic::Agent.config.reset_to_defaults + %i[@agent_started @wait].each do |variable| + instance = NewRelic::Control::SecurityInterface.instance + instance.remove_instance_variable(variable) if instance.instance_variable_defined?(variable) + end + end + + # For testing purposes, clear out the supportability metrics that have already been recorded. + def reset_supportability_metrics + NewRelic::Agent.instance_variable_get(:@metrics_already_recorded)&.clear + end + + def assert_supportability_metrics_enabled + assert_metrics_recorded 'Supportability/Ruby/SecurityAgent/Agent/Enabled/enabled' + end + + def test_initialization_short_circuits_when_the_security_agent_is_disabled + logger = MiniTest::Mock.new + with_config('security.agent.enabled' => false, 'high_security' => false) do + NewRelic::Agent.stub :logger, logger do + logger.expect :info, nil, [/Security is completely disabled/] + logger.expect :info, nil, [/high_security = false/] + logger.expect :info, nil, [/security.agent.enabled = false/] + + NewRelic::Control::SecurityInterface.instance.init_agent + end + + refute_predicate NewRelic::Control::SecurityInterface.instance, :agent_started? + assert_metrics_recorded 'Supportability/Ruby/SecurityAgent/Agent/Enabled/disabled' + end + logger.verify + end + + def test_initialization_short_circuits_when_high_security_mode_is_enabled + logger = MiniTest::Mock.new + with_config('security.agent.enabled' => true, 'high_security' => true) do + NewRelic::Agent.stub :logger, logger do + logger.expect :info, nil, [/Security is completely disabled/] + logger.expect :info, nil, [/high_security = true/] + logger.expect :info, nil, [/security.agent.enabled = true/] + + NewRelic::Control::SecurityInterface.instance.init_agent + end + + refute_predicate NewRelic::Control::SecurityInterface.instance, :agent_started? + assert_supportability_metrics_enabled + end + logger.verify + end + + def test_initialization_short_circuits_if_the_agent_has_already_been_started + reached = false + with_config('security.agent.enabled' => true) do + NewRelic::Agent.stub :config, -> { reached = true } do + NewRelic::Control::SecurityInterface.instance.instance_variable_set(:@agent_started, true) + NewRelic::Control::SecurityInterface.instance.init_agent + end + end + + refute reached, 'Expected init_agent to short circuit but it reached code within the method instead!' + end + + def test_initialization_short_circuits_if_the_agent_has_been_told_to_wait + reached = false + with_config('security.agent.enabled' => true) do + NewRelic::Agent.stub :config, -> { reached = true } do + NewRelic::Control::SecurityInterface.instance.instance_variable_set(:@wait, true) + NewRelic::Control::SecurityInterface.instance.init_agent + end + end + + refute reached, 'Expected init_agent to short circuit but it reached code within the method instead!' + end + + def test_initialization_requires_the_security_agent + skip_unless_minitest5_or_above + + required = false + logger = MiniTest::Mock.new + with_config('security.agent.enabled' => true) do + NewRelic::Agent.stub :logger, logger do + logger.expect :info, nil, [/Invoking New Relic security/] + + NewRelic::Control::SecurityInterface.instance.stub :require, proc { |_gem| required = true }, %w[newrelic_security] do + NewRelic::Control::SecurityInterface.instance.init_agent + end + end + end + logger.verify + + assert required, 'Expected init_agent to perform a require statement' + assert_predicate NewRelic::Control::SecurityInterface.instance, :agent_started? + assert_supportability_metrics_enabled + end + + def test_initialization_anticipates_a_load_error + skip_unless_minitest5_or_above + + logger = MiniTest::Mock.new + with_config('security.agent.enabled' => true) do + NewRelic::Agent.stub :logger, logger do + logger.expect :info, nil, [/Invoking New Relic security/] + logger.expect :info, nil, [/security agent not found/] + + error_proc = proc { |_gem| raise LoadError.new } + NewRelic::Control::SecurityInterface.instance.stub :require, error_proc, %w[newrelic_security] do + NewRelic::Control::SecurityInterface.instance.init_agent + end + end + logger.verify + + refute_predicate NewRelic::Control::SecurityInterface.instance, :agent_started? + assert_supportability_metrics_enabled + end + end + + def test_initialization_handles_errors + skip_unless_minitest5_or_above + + logger = MiniTest::Mock.new + with_config('security.agent.enabled' => true) do + NewRelic::Agent.stub :logger, logger do + logger.expect :info, nil, [/Invoking New Relic security/] + logger.expect :error, nil, [/Exception in New Relic security module loading/] + + error_proc = proc { |_gem| raise StandardError } + NewRelic::Control::SecurityInterface.instance.stub :require, error_proc, %w[newrelic_security] do + NewRelic::Control::SecurityInterface.instance.init_agent + end + end + end + logger.verify + + refute_predicate NewRelic::Control::SecurityInterface.instance, :agent_started? + assert_supportability_metrics_enabled + end +end