# 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' require 'contrast/utils/assess/property/tagged_utils' 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 include Contrast::Utils::Assess::TaggedUtils # 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 # 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