# Copyright (c) 2020 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details. # frozen_string_literal: true cs__scoped_require 'set' cs__scoped_require 'base64' cs__scoped_require 'contrast/utils/prevent_serialization' cs__scoped_require 'contrast/utils/tag_util' cs__scoped_require 'contrast/agent/assess/contrast_event' module Contrast module Agent module Assess # Properties associated with a tracked String. If String is monkey # patched this object is lazily generated on affected Strings. # # This class acts as a holder for the Assess information we need in order # to properly convey the events that lead up to the state of the tracked # user input. class Properties include Contrast::Utils::PreventSerialization # CONTRAST-36937 # Creating these on Properties is expensive. We want to delay this for # as long as possible. def properties @_properties ||= {} end def events @_events ||= [] end def tracked? tags? && tags.any? end def tagged? label tags? && tags.key?(label) end def add_properties hash return unless hash properties.merge!(hash) end def add_property name, value return unless name && value properties[name] = value end def any_tags_between? start, finish return false unless tags? range = Contrast::Agent::Assess::Tag.new(start + finish, start) tags.each_value do |tag_array| return true if tag_array.any? { |tag| tag.overlaps?(range) } end false end # Find all of the ranges that span a given index. This is used # in propagation when we need to shift tags about. For instance, in # the append method when we need to see if any tag at the end needs # to be expanded out to the size of the new String. # # Note: Tags do not know their key, so this is only the range covered def tags_at idx return Contrast::Utils::ObjectShare::EMPTY_ARRAY unless tags? at = [] tags.each_value do |tag_array| tag_array.each do |tag| if tag.covers?(idx) at << tag elsif tag.above?(idx) break end end end at end # given a range, select all tags in that range the selected tags are # shifted such that the start index of the new tag (0) aligns with the # given start index in the range # # current tags: 5-15 # range : 5-10 # result : 0-05 def tags_at_range range return Contrast::Utils::ObjectShare::EMPTY_HASH unless tags? at = Hash.new { |h, k| h[k] = [] } length = range.stop - range.start tags.each_pair do |key, value| add = [] value.each do |tag| comparison = tag.compare_range(range.start, range.stop) # BELOW and ABOVE are applicable to this check and are removed. case comparison # part of the tag is being selected when Contrast::Agent::Assess::Tag::LOW_SPAN add << Contrast::Agent::Assess::Tag.new(length) # the tag exists in the requested range, figure out the boundaries when Contrast::Agent::Assess::Tag::WITHIN start = tag.start_idx - range.start finish = length - start add << Contrast::Agent::Assess::Tag.new(finish, start) # the tag spans the requested range. when Contrast::Agent::Assess::Tag::WITHOUT add << Contrast::Agent::Assess::Tag.new(length) # part of the tag is being selected when Contrast::Agent::Assess::Tag::HIGH_SPAN add << Contrast::Agent::Assess::Tag.new(length) end end next if add.empty? at[key] = add end at end # Given a tag name and range object, add a new tag to this # collection. If the given range touches an existing tag, # we'll combine the two, adjusting the existing one and # dropping this new one. def add_tag label, range length = range.stop - range.start tag = Contrast::Agent::Assess::Tag.new(length, range.start) existing = fetch_tag(label) tags[label] = Contrast::Utils::TagUtil.ordered_merge(existing, tag) end def set_tags label, tag_ranges tags[label] = tag_ranges end # Remove all tags with a given label def delete_tags label return unless tags? tags.delete(label) end # Reset the tag hash def clear_tags return unless tags? tags.clear end # Returns a list of all current tag labels, most likely to be used for # a splat operation def tag_keys return Contrast::Utils::ObjectShare::EMPTY_ARRAY unless tags? tags.keys end # Calls merge to combine touching or overlapping tags # Deletes empty tags def cleanup_tags return unless tags? Contrast::Utils::TagUtil.merge_tags(tags) tags.delete_if { |_, value| value.empty? } end # We'll use this as a helper method to retrieve tags from the hash. # Because the hash auto-populates an empty array when we try to access # a tag in it, we cannot use the [] method without side effect. To get # around this, we'll use a fetch work around. def fetch_tag label return unless tags? tags.fetch(label, nil) end # Convert the tags of this object into the TraceTaintRange requried to # be sent to the service def tags_to_dtm return Contrast::Utils::ObjectShare::EMPTY_ARRAY unless tags? ranges = [] tags.each_pair do |key, value| next if value.empty? value.each do |tag| range = Contrast::Api::Dtm::TraceTaintRange.new range.tag = Contrast::Utils::StringUtils.protobuf_safe_string(key) range.range = tag.start_idx.to_s + Contrast::Utils::ObjectShare::COLON + tag.end_idx.to_s ranges << range end end ranges end # Remove all tags within the given ranges. # This does not delete an entire tag if part of that tag is # outside this range, meaning we may reduce sizes of tags # or split them. # # If shift is true, it is assumed the characters at those ranges were # removed. If shift is false, it is assumed those ranges were replaced # by the same number of characters and no shift is needed. # # current tags: 0-15 # range: 5-10 # result: 0-5, 10-15 def delete_tags_at_ranges ranges, shift = true return unless tags? # Stage one - delete the tags w/o changing their # location. ranges.each do |range| remove_tags(range) end return unless shift # the amount we've already removed from the string shift = 0 # Stage two - shift the tags to the left to account # for the sections that were deleted. ranges.each do |range| shift_tags_for_deletion(range, shift) shift += (range.stop - range.start) end # Clean up and merge any touching tags Contrast::Utils::TagUtil.merge_tags(tags) end # Shift all the tags in this object by the given ranges. # This method assumes the ranges are sorted, meaning # the leftmost (lowest) range is first # # current tags: 0-15 # range: 5-10 # result: 0-5, 10-20 def shift_tags ranges return unless tags? ranges.each do |range| shift_tags_for_insertion(range) end end # Add an event to these properties. It will be used to build # a trace if this object ends up in a trigger. def add_event event events << event self end def build_event policy_node, tagged, object, ret, args, invoked = 0, source_type = nil, source_name = nil event = Contrast::Agent::Assess::ContrastEvent.new(policy_node, tagged, object, ret, args, invoked, source_type, source_name) add_event(event) report_sources(tagged, event) end private def report_sources tagged, event return unless tagged && !tagged.to_s.empty? return unless event&.source_type current_request = Contrast::Agent::REQUEST_TRACKER.current return unless current_request return if current_request.observed_route.sources.any? { |source| source.type == event.forced_source_type && source.name == event.forced_source_name } event_source_dtm = event.build_event_source_dtm return unless event_source_dtm current_request.observed_route.sources << event_source_dtm end # Because of the auto-fill thing, we should not allow direct access to # the tags hash. Instead, the methods above should be used to do # operations like add, delete, and fetch. # # CONTRAST-22914 # please do NOT expose this w/ an attr_reader / accessor. there are # helper methods in this class that safely access the hash. the tags # method is private to avoid the side effect of a direct lookup with # `[]` adding an empty array to the hash. def tags @_tags ||= Hash.new { |h, k| h[k] = [] } end # Creating Tags is expensive and we check for Tags all the time on # untracked things. ALWAYS!!! call this method before checking if # an object has tags def tags? instance_variable_defined?(:@_tags) end # Remove the tag ranges covering the given range def remove_tags range return unless tags? full_delete = [] tags.each_pair do |key, value| remove = [] add = [] value.each do |tag| comparison = tag.compare_range(range.start, range.stop) # ABOVE and BELOW are not affected by this check case comparison when Contrast::Agent::Assess::Tag::LOW_SPAN tag.update_end(range.start) when Contrast::Agent::Assess::Tag::WITHIN remove << tag when Contrast::Agent::Assess::Tag::WITHOUT new_tag = tag.clone new_tag.update_start(range.stop) add << new_tag tag.update_end(range.start) when Contrast::Agent::Assess::Tag::HIGH_SPAN tag.update_start(range.stop) end end value.delete_if { |tag| remove.include?(tag) } Contrast::Utils::TagUtil.ordered_merge(value, add) full_delete << key if value.empty? end full_delete.each { |key| tags.delete(key) } end # Shift the tag ranges covering the given range # We assume this is for a deletion, meaning we # have to move tags to the left def shift_tags_for_deletion range, shift return unless tags? tags.each_value do |value| value.each do |tag| comparison = tag.compare_range(range.start - shift, range.stop - shift) length = range.stop - range.start case comparison # this is really the only thing we need to shift when Contrast::Agent::Assess::Tag::ABOVE tag.shift(0 - length) end end end end # Shift the tag ranges covering the given range # We assume this is for a insertion, meaning we # have to move tags to the right def shift_tags_for_insertion range return unless tags? tags.each_value do |value| add = [] value.each do |tag| comparison = tag.compare_range(range.start, range.stop) length = range.stop - range.start # BELOW is not affected by this check case comparison # part of the tag is being inserted on when Contrast::Agent::Assess::Tag::LOW_SPAN new_tag = tag.clone new_tag.update_start(range.start) new_tag.shift(length) add << new_tag tag.update_end(range.start) # the tag exists in the inserted range. it is partially shifted when Contrast::Agent::Assess::Tag::WITHIN tag.shift(length) # the tag spans the range. leave the part below alone when Contrast::Agent::Assess::Tag::WITHOUT new_tag = tag.clone new_tag.update_start(range.start) new_tag.shift(length) add << new_tag tag.update_end(range.start) when Contrast::Agent::Assess::Tag::HIGH_SPAN, Contrast::Agent::Assess::Tag::ABOVE tag.shift(length) end end Contrast::Utils::TagUtil.ordered_merge(value, add) end end end end end end