# 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/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 # # Note that we disable Lint/DuplicateBranch in this branch in order # list out all tag range cases in the proper order to make this # easier to understand # # @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 = nil value.each do |tag| within_range = resize_to_range(tag, range) if within_range add ||= [] add << within_range end end next unless add&.any? 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 # Returns a list of all current tags. # # @return [Hash] def get_tags # rubocop:disable Naming/AccessorMethodName return Contrast::Utils::ObjectShare::EMPTY_HASH unless tracked? tags 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 get_tags.fetch(label, nil) if tracked? 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 # 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 # 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 to account for new # data being added. We assume this is for a insertion, meaning we # have to move tags out of the range and to the right. For example, # given current tags: 0-15 # when we insert a range: 5-10 # then the result is: 0-5, 10-20 # # Note that we disable Lint/DuplicateBranch in this branch in order # list out all tag range cases in the proper order to make this # easier to understand # # @param range [Range] the range of new information that's been # inserted 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 # rubocop:disable Lint/DuplicateBranch 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 # rubocop:disable Lint/DuplicateBranch tag.shift(length) end end Contrast::Utils::TagUtil.ordered_merge(value, add) end end private # 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 # Given a tag, compare it to a given range and, if any part of that tag is within the range, return a new tag # covering the union of the original tag and the range. This new tag will start at the # max(tag.start, range.start) and end at min(tag.end, range.end) # # @param tag [Contrast::Agent::Assess::Tag] the Tag that may be in this range # @param range [Range] the span to check, inclusive to exclusive # @return [Contrast::Agent::Assess::Tag, nil] a new tag, truncated to only span within the given range or nil # if no overlap exists def resize_to_range tag, range comparison = tag.compare_range(range.begin, range.end) # BELOW and ABOVE are not applicable to this check and result in nil case comparison # part of the tag is being selected when Contrast::Agent::Assess::Tag::LOW_SPAN 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 Contrast::Agent::Assess::Tag.new(tag.label, finish, start) # the tag spans the requested range. when Contrast::Agent::Assess::Tag::WITHOUT # rubocop:disable Lint/DuplicateBranch Contrast::Agent::Assess::Tag.new(tag.label, range.size) # part of the tag is being selected when Contrast::Agent::Assess::Tag::HIGH_SPAN # rubocop:disable Lint/DuplicateBranch Contrast::Agent::Assess::Tag.new(tag.label, range.size) end end end end end end end