# Copyright (c) 2021 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details. # frozen_string_literal: true require 'contrast/agent/assess/events/event_factory' require 'contrast/agent/assess/policy/trigger_validation/trigger_validation' require 'contrast/components/logger' require 'contrast/utils/object_share' require 'contrast/utils/sha256_builder' module Contrast module Agent module Assess module Policy # A trigger method is one which can perform a dangerous action, as described by the # Contrast::Agent::Assess::Policy::TriggerNode class. Each such method will call to this module just after # invocation in order to determine if the call was done safely. In those cases where it was not, a Finding # report is issued to the Service. module TriggerMethod extend Contrast::Components::Logger::InstanceMethods # The level of TeamServer compliance our traces meet when in the abnormal condition of being dataflow rules # without routes. MINIMUM_FINDING_VERSION = 3 # The level of TeamServer compliance our traces meet. CURRENT_FINDING_VERSION = 4 class << self # This is called from within our woven proc. It will be called as if it were inline in the Rack # application. # # @param trigger_node [Contrast::Agent::Assess::Policy::TriggerNode] the node that applies to the method # being called # @param object [Object] the Object on which the method was invoked # @param ret [Object] the Return of the invoked method # @param args [Array] the Arguments with which the method was invoked def apply_trigger_rule trigger_node, object, ret, args return if trigger_node.nil? if trigger_node.sources&.any? trigger_node.sources.each do |marker| source = determine_source(marker, object, ret, args) apply_trigger(trigger_node, source, object, ret, *args) end else apply_trigger(trigger_node, nil, object, ret, *args) end end def apply_eval_trigger trigger_node, source, object, ret, *args apply_trigger(trigger_node, source, object, ret, *args) end # This converts the source of the finding, and the events leading up to it into a Finding # # @param trigger_node [Contrast::Agent::Assess::Policy::TriggerNode] the node to direct applying this # trigger event # @param source [Object] the source of the Trigger Event # @param object [Object] the Object on which the method was invoked # @param ret [Object] the Return of the invoked method # @param args [Array] the Arguments with which the method was invoked # @return [Contrast::Api::Dtm::Finding, nil] the Contrast::Api::Dtm::Finding to send to TeamServer or # nil if conditions were not met def build_finding trigger_node, source, object, ret, *args return unless Contrast::Agent::Assess::Policy::TriggerValidation.valid?(trigger_node, object, ret, args) request = find_request(source) return unless reportable?(request&.env) finding = Contrast::Api::Dtm::Finding.new finding.rule_id = Contrast::Utils::StringUtils.protobuf_safe_string(trigger_node.rule_id) append_events(finding, trigger_node, source, object, ret, args) append_route(finding, request) append_hash(finding, source, request) finding.version = determine_compliance_version(finding) logger.trace('Finding created', node_id: trigger_node.id, source_id: source.__id__, rule: trigger_node.rule_id) report_finding(finding, request) rescue StandardError => e logger.error('Unable to build a finding', e, rule: trigger_node.rule_id, node_id: trigger_node.id) end # Given a finding, append it to an activity message and send it to the Service for processing. If an # activity message does not exist, b/c we're invoked outside of a request context, build an activity and # immediately report it with the finding. # # @param finding [Contrast::Api::Dtm::Finding] the Finding to report. # @param request [Contrast::Agent::Request] our wrapper around the Rack::Request. def report_finding finding, request = nil context = Contrast::Agent::REQUEST_TRACKER.current if context context.activity.findings << finding return end activity = Contrast::Api::Dtm::Activity.new activity.findings << finding if request activity.http_request = request.dtm else logger.debug('Attempted to report finding without request', finding: finding) end # If we're out of request context, then we need to report this finding ourselves, so we'll send it in the # one-off activity we created. Contrast::Agent.messaging_queue.send_event_eventually(activity) end private # A request is reportable if it is not from ActionController::Live # # @param env [Hash] the env of the Request # @return [Boolean] def reportable? env !(defined?(ActionController::Live) && env && env['action_controller.instance'].cs__class.included_modules.include?(ActionController::Live)) end # Find the request for this finding. This assumes, for now, that if there is an active request, then that # is the request to report. Otherwise, we'll use the first request found in the events of the # source_object. # # @param source [Object,nil] some Object used as the source of a trigger event # @return [Contrast::Agent::Request,nil] the request from which the dataflow on the request originated. def find_request source return Contrast::Agent::REQUEST_TRACKER.current.request if Contrast::Agent::REQUEST_TRACKER.current return unless (properties = Contrast::Agent::Assess::Tracker.properties(source)) properties.events.each do |event| next unless event.cs__is_a?(Contrast::Agent::Assess::Events::SourceEvent) return event.request if event.request end nil end def settings Contrast::Agent::FeatureState.instance end # This is our method that actually checks the taint on the object our trigger_node targets. # # @param trigger_node [Contrast::Agent::Assess::Policy::TriggerNode] the node to direct applying this # trigger event # @param source [Object] the source of the Trigger Event # @param object [Object] the Object on which the method was invoked # @param ret [Object] the Return of the invoked method # @param args [Array] the Arguments with which the method was invoked def apply_trigger trigger_node, source, object, ret, *args return unless trigger_node return if trigger_node.rule_disabled? return if trigger_node.dataflow? && source.nil? if trigger_node.regexp_rule? apply_regexp_rule(trigger_node, source, object, ret, *args) elsif trigger_node.custom_trigger? trigger_node.apply_custom_trigger(trigger_node, source, object, ret, *args) elsif trigger_node.dataflow? apply_dataflow_rule(trigger_node, source, object, ret, *args) else # trigger rule - just calling the method is dangerous build_finding(trigger_node, source, object, ret, *args) end rescue StandardError => e logger.warn('Unable to apply trigger', e, node_id: trigger_node.id) end # Given the marker from the trigger_node (the pointer indicating the entity from which the taint # originated), return the entity on which this trigger needs to operate. # # In an effort to speed up this lookup, we've changed the marker for parameters to be implicit - if it is # not a return or an object, it must be a parameter, which we can reference by index. # # @param marker [String] the source marker that indicates which Object # @param object [Object] the Object on which the method was invoked # @param ret [Object] the Return of the invoked method # @param args [Array] the Arguments with which the method was invoked # @return [Object] the literal object that this Trigger Event verifies def determine_source marker, object, ret, args case marker when Contrast::Utils::ObjectShare::RETURN_KEY ret when Contrast::Utils::ObjectShare::OBJECT_KEY object else # 'P' if marker.is_a?(Integer) args[marker] else arg = nil args.each do |search| next unless search.is_a?(Hash) arg = search[marker] break if arg end arg end end end # This is our method that actually checks the taint on the object our trigger_node targets for our Regexp # based rules. # # @param trigger_node [Contrast::Agent::Assess::Policy::TriggerNode] the node to direct applying this # trigger event # @param source [Object] the source of the Trigger Event # @param object [Object] the Object on which the method was invoked # @param ret [Object] the Return of the invoked method # @param args [Array] the Arguments with which the method was invoked def apply_regexp_rule trigger_node, source, object, ret, *args return unless source.is_a?(String) return if trigger_node.good_value && source.match?(trigger_node.good_value) return if trigger_node.bad_value && source !~ trigger_node.bad_value build_finding(trigger_node, source, object, ret, *args) end # This is our method that actually checks the taint on the object our trigger_node targets for our Dataflow # based rules. # # @param trigger_node [Contrast::Agent::Assess::Policy::TriggerNode] the node to direct applying this # trigger event # @param source [Object] the source of the Trigger Event # @param object [Object] the Object on which the method was invoked # @param ret [Object] the Return of the invoked method # @param args [Array] the Arguments with which the method was invoked def apply_dataflow_rule trigger_node, source, object, ret, *args return unless source if Contrast::Agent::Assess::Tracker.trackable?(source) return unless Contrast::Agent::Assess::Tracker.tracked?(source) return unless trigger_node.violated?(source) build_finding(trigger_node, source, object, ret, *args) elsif Contrast::Utils::DuckUtils.iterable_hash?(source) source.each_pair do |key, value| apply_dataflow_rule(trigger_node, key, object, ret, *args) apply_dataflow_rule(trigger_node, value, object, ret, *args) end elsif Contrast::Utils::DuckUtils.iterable_enumerable?(source) source.each do |value| apply_dataflow_rule(trigger_node, value, object, ret, *args) end else logger.debug('Trigger source is untrackable. Unable to inspect.', node_id: trigger_node.id, source_id: source.__id__, source_type: source.cs__class.cs__name, frozen: source.cs__frozen?) end end def append_events finding, trigger_node, source, object, ret, args append_from_source(finding, source) finding.events << Contrast::Agent::Assess::Events::EventFactory.build(trigger_node, source, object, ret, args).to_dtm_event end def append_from_source finding, source return unless source return unless Contrast::Agent::Assess::Tracker.trackable?(source) properties = Contrast::Agent::Assess::Tracker.properties(source) return unless properties build_events finding, properties.event if properties.event # Google::Protobuf::Map doesn't support merge!, so we have to do this long form source_props = properties.properties return unless source_props source_props.each_pair do |key, value| key = Contrast::Utils::StringUtils.force_utf8(key) finding.properties[key] = Contrast::Utils::StringUtils.force_utf8(value) end end def build_events finding, event return unless event event.parent_events&.each do |parent_event| build_events(finding, parent_event) end # events could technically be nil, but we would have failed the rule check before getting here. not # worth the nil check finding.events << event.to_dtm_event end def append_route finding, request context = Contrast::Agent::REQUEST_TRACKER.current if context finding.routes << context.route if context.route elsif request&.route finding.routes << request.route end end def append_hash finding, source, request hash_code = Contrast::Utils::HashDigest.generate_event_hash(finding, source, request) finding.hash_code = Contrast::Utils::StringUtils.force_utf8(hash_code) finding.preflight = Contrast::Utils::PreflightUtil.create_preflight(finding) end # Because our APIs are not versioned, TeamServer relies on a field, version, to tell it what, if any, # validation it can preform on the findings we send it. Examine the finding and determine the level to # which it conforms. # # @param finding [Contrast::Api::Dtm::Finding] # @return [int] the version of compliance def determine_compliance_version finding return MINIMUM_FINDING_VERSION unless finding # as routes are the only variable between findings, in the case where we couldn't determine one, any # finding with a route is at maximum compliance return CURRENT_FINDING_VERSION if finding.routes.any? # any finding without events is not of a dataflow type and therefore at maximum compliance return CURRENT_FINDING_VERSION unless finding.events.any? MINIMUM_FINDING_VERSION end end end end end end end