# Copyright (c) 2020 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details. # frozen_string_literal: true require 'contrast/agent/assess/tag' require 'contrast/utils/object_share' require 'contrast/utils/string_utils' require 'contrast/utils/tag_util' module Contrast module Agent module Assess module Property # This module serves to hold the functionality required for the # management of our dataflow tags. module Tagged # Is any tag present? # 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 # @return [Boolean] def tracked? instance_variable_defined?(:@_tags) && tags.any? end # Is the given tag present? # Used in testing, so found by `be_tagged`, if you're grepping for it # # @param label [Symbol] the tag to check for # @return [Boolean] def tagged? label tracked? && tags.key?(label) end # Similar to #tracked?, but limited to a given range. # # @param start [Integer] the inclusive start index to check. # @param finish [Integer] the exclusive end index to check. # @return [Boolean] def any_tags_between? start, finish return false unless tracked? tags.each_value do |tag_array| return true if tag_array.any? { |tag| tag.overlaps?(start, finish) } 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 # # @param idx [Integer] the index to check for tags # @return [Array] a set of all the Tags # covering the given index. This is only the ranges, not the names. def tags_at idx return Contrast::Utils::ObjectShare::EMPTY_ARRAY unless tracked? 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 # # @param range [Range] the span to check, inclusive to exclusive # @return [Hash{String => Contrast::Agent::Assess::Tag}] the hash of # key to tags def tags_at_range range return Contrast::Utils::ObjectShare::EMPTY_HASH unless tracked? at = Hash.new { |h, k| h[k] = [] } tags.each_pair do |key, value| add = [] value.each do |tag| comparison = tag.compare_range(range.begin, range.end) # 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(tag.label, range.size) # the tag exists in the requested range, figure out the boundaries when Contrast::Agent::Assess::Tag::WITHIN start = tag.start_idx - range.begin finish = range.size - start add << Contrast::Agent::Assess::Tag.new(tag.label, finish, start) # the tag spans the requested range. when Contrast::Agent::Assess::Tag::WITHOUT add << Contrast::Agent::Assess::Tag.new(tag.label, range.size) # part of the tag is being selected when Contrast::Agent::Assess::Tag::HIGH_SPAN add << Contrast::Agent::Assess::Tag.new(tag.label, range.size) 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. # # @param label [String] the name of the tag # @param range [Range] the Range that the tag covers, inclusive to # exclusive def add_tag label, range length = range.end - range.begin tag = Contrast::Agent::Assess::Tag.new(label, length, range.begin) 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 tags.delete(label) if tracked? end # Reset the tag hash def clear_tags tags.clear if tracked? 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 tracked? tags.keys end # Calls merge to combine touching or overlapping tags # Deletes empty tags def cleanup_tags return unless tracked? 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. # # @param label [Symbol] the label to look up # @return [Array] all the tags with # that label def fetch_tag label tags.fetch(label, nil) if tracked? end # Convert the tags of this object into the TraceTaintRange required # to be sent to the service # # @return [Array] def tags_to_dtm Contrast::Api::Dtm::TraceTaintRange.build_for_event(tags) 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 # # @param ranges [Array] the ranges to delete # @param shift [Boolean] move remaining tags to the left to account # for the deletion def delete_tags_at_ranges ranges, shift = true return unless tracked? # 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.end - range.begin) 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 tracked? ranges.each do |range| shift_tags_for_insertion(range) end 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 # Remove the tag ranges covering the given range def remove_tags range return unless tracked? full_delete = [] tags.each_pair do |key, value| remove = [] add = [] value.each do |tag| comparison = tag.compare_range(range.begin, range.end) # ABOVE and BELOW are not affected by this check case comparison when Contrast::Agent::Assess::Tag::LOW_SPAN tag.update_end(range.begin) when Contrast::Agent::Assess::Tag::WITHIN remove << tag when Contrast::Agent::Assess::Tag::WITHOUT new_tag = tag.clone new_tag.update_start(range.end) add << new_tag tag.update_end(range.begin) when Contrast::Agent::Assess::Tag::HIGH_SPAN tag.update_start(range.end) 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 # @param range [Range] the range to delete # @param shift [Boolean] move remaining tags to the left to account # for the deletion def shift_tags_for_deletion range, shift return unless tracked? tags.each_value do |value| value.each do |tag| comparison = tag.compare_range(range.begin - shift, range.end - shift) # this is really the only thing we need to shift next unless comparison == Contrast::Agent::Assess::Tag::ABOVE length = range.end - range.begin tag.shift(0 - length) 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 tracked? tags.each_value do |value| add = [] value.each do |tag| comparison = tag.compare_range(range.begin, range.end) length = range.end - range.begin # 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.begin) new_tag.shift(length) add << new_tag tag.update_end(range.begin) # 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.begin) new_tag.shift(length) add << new_tag tag.update_end(range.begin) 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 end