# 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 def dictionary @_dictionary ||= update_dictionary end # Mask sensitive data according to the contrast sensitive data rules. # # @param [Contrast::Agent::Reporting::ApplicationActivity] def mask activity return unless activity logger.debug('Masker: masking sensitive data', activity: activity.__id__, request: activity.request&.__id__) return if activity.request.nil? 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.request&.__id__) 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::Agent::Reporting::ApplicationActivity] # @return masked_body [String, nil] def mask_body activity return unless mask_body? body = activity.request.body return if body.nil? || body.empty? activity.request.body = BODY_MASK activity.request.body_binary = BODY_BINARY_MASK end # Mask request params. # # @param activity [Contrast::Agent::Reporting::ApplicationActivity] # @return masked_body [String, nil] def mask_request_params activity params = activity.request.parameters return unless params mask_with_dictionary(activity.attack_results, params) end def mask_request_headers activity headers = activity.request.headers return if headers&.empty? # Used normalized request_headers mask = mask_with_dictionary(activity.attack_results, headers) activity.request.headers = mask if mask end # Mask Cookies. # # @param activity [Contrast::Agent::Reporting::ApplicationActivity] Activity to mask # @return masked_values [Hash, nil] def mask_request_cookies activity cookies = activity.request.cookies return if cookies&.empty? mask_with_dictionary(activity.attack_results, cookies) end # Mask request query string: # exp: password => sensitive to password => contrast-redacted-password # # @param activity [Contrast::Agent::Reporting::ApplicationActivity] # @return masked_query [String] def mask_query_string activity qs = activity.request.query_string return if qs.nil? || qs.empty? mask = mask_raw_query(qs, activity.attack_results) activity.request.query_string = mask if mask 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] # 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| next unless dictionary_match(key) if val.cs__is_a?(Array) mask_values(key, val, hash, results) else # Just assign keys. mask_hash(key, val, hash, results) end end end # Mask the values of key value pair with array of string as input. # If the mask_attack_vector? flag is set then the attack vector won't be # masked. # # @param key [String] # @param hash [Hash] Normalized hash representing the key/val pair. # @param results [Array] # results to match against. # @param val [String, Array] def mask_values key, val, hash, results val.each.with_index do |v, idx| # Mask the attack vector only if the flag is set. hash[key][idx] = MASK + key.downcase if attack_vector?(results, v) && mask_attack_vector? # It is not attack vector and we mask it as normal. hash[key][idx] = MASK + key.downcase unless attack_vector?(results, v) end hash end # Handles the masking of hash # # @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] # results to match against. # @return [Hash] def mask_hash key, val, hash, results # Mask the attack vector only if the flag is set. hash[key] = MASK + key.downcase if attack_vector?(results, val) && mask_attack_vector? # It is not attack vector we mask it. hash[key] = MASK + key.downcase unless attack_vector?(results, val) hash 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] # results to match against. # @param value [String] Input to match. # @return [Boolean] def attack_vector? results, value return false unless value && results results.each do |attacker| attacker.each do |activity| blocked = iterate_attack_samples(activity.blocked, value) return blocked if blocked exploited = iterate_attack_samples(activity.exploited, value) return exploited if exploited ineffective = iterate_attack_samples(activity.ineffective, value) return ineffective if ineffective suspicious = iterate_attack_samples(activity.suspicious, value) return suspicious if suspicious end end false end # Go through activity samples and search for a matching input. # # @param activity [Contrast::Agent::Reporting::ApplicationDefendAttackActivity] # @param value [String] Input to match. # @return [Boolean] def iterate_attack_samples activity, value return false unless activity activity.samples.any? do |sample| match = sample.user_input.value == value.to_s return true if match 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_match 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