# Copyright (c) 2022 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details. # frozen_string_literal: true require 'contrast/components/logger' require 'contrast/agent/reporting/reporter' require 'contrast/utils/object_share' require 'contrast/agent/reporting/attack_result/response_type' require 'contrast/agent/reporting/masker/masker_utils' module Contrast module Agent module Reporting # This module duty is to mask any activity containing sensitive information - PII masking. module Masker MASK = 'contrast-redacted-' BODY_MASK = 'contrast-redacted-body' BODY_BINARY_MASK = BODY_MASK.bytes.to_s.cs__freeze class << self include Contrast::Components::Logger::InstanceMethods include Contrast::Utils::ObjectShare include Contrast::Agent::Reporting::MaskerUtils # Keyword dictionary, extracted from the TS response. # # @return Array<Contrast::Agent::Reporting::Settings::SensitiveDataMaskingRule> def dictionary @_dictionary ||= update_dictionary end # Mask sensitive data according to the contrast sensitive data rules. # # @param [Contrast::Api::Dtm::Activity] def mask activity return unless Contrast::Agent::Reporter.enabled? return unless activity logger.debug('Searching for sensitive data', activity: activity.__id__, request: activity.http_request&.uuid) mask_body(activity) mask_query_string(activity) mask_request_params(activity) mask_request_cookies(activity) mask_request_headers(activity) rescue StandardError => _e logger.debug('Could not mask activity!', activity: activity.__id__, request: activity.http_request&.uuid) end private # When called this will overwrite existing rules with those # in settings. Ideally we need to call this on init and when # the Agent receives new rules from TS response.This is called # from Contrast::Components::Settings. # # To make it save this is private method because if new # settings arrive and they might be empty as edge case, # this will produce dictionary with empty rules and this # is not desirable. def update_dictionary @_dictionary = Contrast::SETTINGS.sensitive_data_masking.rules end # Mask request body: # # @param activity [Contrast::Api::Dtm::Activity] # @return masked_body [String, nil] def mask_body activity return unless mask_body? body = activity.http_request.request_body return if body.nil? || body.empty? activity.http_request.request_body = BODY_MASK activity.http_request.request_body_binary = BODY_BINARY_MASK end # Mask request params. # # @param activity [Contrast::Api::Dtm::Activity] # @return masked_body [String, nil] def mask_request_params activity params = activity.http_request.normalized_request_params return unless params mask_with_dictionary(activity.results, params) end def mask_request_headers activity if activity.http_request.parsed_request_headers # Used normalized request_headers mask_with_dictionary(activity.results, activity.http_request.normalized_request_headers) else headers = activity.http_request.request_headers mask_field_hash(headers, activity.results) end end # Mask Cookies. # # @param activity [Contrast::Api::Dtm::Activity] Activity to mask # @return masked_values [Hash, nil] def mask_request_cookies activity cookies = activity.http_request.normalized_cookies return unless cookies mask_with_dictionary(activity.results, cookies) end # Mask request query string: # exp: password => sensitive to password => contrast-redacted-password # # @param activity [Contrast::Api::Dtm::Activity] # @return masked_query [String] def mask_query_string activity qs = activity.http_request.query_string return if qs.nil? || qs.empty? mask_field_hash(qs, activity.results) unless qs.cs__is_a?(String) mask_raw_query(qs, activity.results) end # Mask if the value in the passed hash are matched against dictionary # keyword. If the mask_attack_vector flag is set, this will also mask # any attack. # # @param results [Array<Contrast::Api::Dtm::AttackResults>] # results to match against. # @param hash [Hash] Normalized hash representing the key/val pair from # the activity's http request parameters. # @return nil | Protobuf::Field::FieldHash def mask_with_dictionary results, hash return if hash.nil? || hash.empty? hash.each do |key, val| match = dictionary_matcher(key) next unless match # The normalized values are paired. # key => Contrast::Api::Dtm::Pair (key, val<Values>). # try one level down if val.cs__respond_to?(:values) mask_values(key, val, results) else # Just assign keys. mask_hash(key, val, hash, results) end end hash end # Mask the values of DTM pair with attack vector condition check. # if the attack vector flag is set then mask the attack value. # # @param key [String] current iterable key from Protobuf::Field::FieldHash # pointing to Contrast::Api::Dtm::Pair<key, val>(holding the value to mask) # @param results [Array<Contrast::Api::Dtm::AttackResults>] # results to match against. # @param val [Contrast::Api::Dtm::Pair<Value>] def mask_values key, val, results val.values.each.with_index do |v, idx| # Mask the attack vector only if the flag is set. val.values[idx] = MASK + key.downcase if attack_vector?(results, v) && mask_attack_vector? # It is not attack vector and we mask it as normal. val.values[idx] = MASK + key.downcase unless attack_vector?(results, v) end val end # Handles the masking of Field hash with string values. # this case is used when called from #mask_field_hash # and #mask_raw_query helper methods. Since they dont # return values containing sub-values (key, val<Values>). # # @param key [String] current iterable key from Protobuf::Field::FieldHash # @param val [String] normalized value to be matched against the results # and masked. # @param hash [Hash] Normalized hash representing the key/val pair. # @param results [Array<Contrast::Api::Dtm::AttackResults>] # results to match against. def mask_hash key, val, hash, results hash[key] = MASK + key.downcase if attack_vector?(results, val) && mask_attack_vector? hash[key] = MASK + key.downcase unless attack_vector?(results, val) end # Match to see if values matches input from AttackResults array. # If match is found and the attack result's response is any of # [BAP(Block At Perimeter), BLOCKED, PROBED] the return is true. # # @param results [Array<Contrast::Api::Dtm::AttackResults>] # results to match against. # @param value [String] Input to match. # @return true | false def attack_vector? results, value return false unless value && results results.each do |result| # Check samples Contrast::Api::Dtm::RaspRuleSample # is the value in sample and the response is valid? result.samples.any? do |sample| # Check user input Contrast::Api::Dtm::UserInput. match = sample.user_input.value == value.to_s && result.response&.name != Contrast::Agent::Reporting::ResponseType::NO_ACTION return match if match end end false end # Consult with our current settings state. # # @return true | false def mask_attack_vector? Contrast::SETTINGS.sensitive_data_masking.mask_attack_vector? end # Consult with our current settings state. # # @return true | false def mask_body? Contrast::SETTINGS.sensitive_data_masking.mask_http_body? end # Check to see if value is matched in the dictionary. # # @param value [String] Value to check. # @return match [String, nil] from the Dictionary, or nil. def dictionary_matcher value return unless @_dictionary @_dictionary.each do |rule| idx = rule.keywords.find_index(value.downcase) return rule.keywords[idx] if idx end nil end end end end end end