diff --git a/README.md b/README.md index b54f927..9479ea7 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ end client = XenditApi::Client.new('secret_key') # when you need to filter logs due to PII or security -client = XenditAPi::Client.new('secret_key', filtered_logs: [:card_cvv, :expected_amount]) +client = XenditAPi::Client.new('secret_key', filtered_logs: [:card_cvv, :expected_amount], mask_params: [:email, :full_name]) ``` When you need to filter logs, also make sure you already inject the logger object first, because we don't provide any default logger object. If you writing in Rails, you could use `Rails.logger`. diff --git a/lib/xendit_api/client.rb b/lib/xendit_api/client.rb index b33b789..52b2dba 100644 --- a/lib/xendit_api/client.rb +++ b/lib/xendit_api/client.rb @@ -1,5 +1,6 @@ require 'faraday_middleware' require 'xendit_api/middleware/handle_response_exception' +require 'xendit_api/middleware/faraday_log_formatter' require 'xendit_api/api/virtual_account' require 'xendit_api/api/ewallet' require 'xendit_api/api/credit_card' @@ -16,7 +17,6 @@ require 'logger' module XenditApi - # rubocop:disable Metrics/ClassLength class Client BASE_URL = 'https://api.xendit.co'.freeze @@ -30,17 +30,10 @@ def initialize(authorization = nil, options = {}) logger = find_logger(options[:logger]) if logger - connection.response :logger, logger, { headers: false, bodies: true, errors: true } do |log| - filtered_logs = options[:filtered_logs] - if filtered_logs.respond_to?(:each) - filtered_logs.each do |filter| - log.filter(%r{(#{filter}=)([\w+-.?@:/]+)}, '\1[FILTERED]') - log.filter(/(#{filter}":\s*")(.*?)(")/i, '\1[FILTERED]\3') - log.filter(/(#{filter}":\s*)(\d+(?:\.\d+)?|true|false)/i, '\1[FILTERED]') - log.filter(/(#{filter}":\s*)(\[.*?\])/i, '\1[FILTERED]') - end - end - end + connection.response :logger, logger, + full_hide_params: options[:filtered_logs] || [], + mask_params: options[:mask_params] || [], + formatter: XenditApi::Middleware::FaradayLogFormatter end connection.use XenditApi::Middleware::HandleResponseException, logger connection.adapter Faraday.default_adapter @@ -142,5 +135,4 @@ def find_logger(logger_option) logger_option || XenditApi.configuration&.logger end end - # rubocop:enable Metrics/ClassLength end diff --git a/lib/xendit_api/json_masker.rb b/lib/xendit_api/json_masker.rb new file mode 100644 index 0000000..424f4ab --- /dev/null +++ b/lib/xendit_api/json_masker.rb @@ -0,0 +1,80 @@ +module XenditApi + class JsonMasker + def self.mask(json, options = {}) + return json unless json.is_a?(String) + return json if json.empty? + + output = JSON.parse(json) + XenditApi::JsonMasker.new(output, options).to_masked + rescue JSON::ParserError + json + end + + def initialize(data, options = {}) + @data = data + @options = options + @mask_params = options[:mask_params] || [] + @full_hide_params = options[:full_hide_params] || [] + end + + def to_masked + return @data if @mask_params.empty? && @full_hide_params.empty? + + case @data + when Array + @data.map do |item| + if item.is_a?(Hash) + XenditApi::JsonMasker.new(item, @options).to_hash + else + item + end + end + when Hash + filter(@data) + else + @data + end + end + + def to_hash + filter(@data) + end + + private + + # rubocop:disable Style/CaseLikeIf + def filter(output) + output.each do |key, value| + output[key] = if value.is_a?(Hash) + XenditApi::JsonMasker.new(value, @options).to_hash + elsif value.is_a?(Array) + value.map do |item| + if item.is_a?(Hash) + XenditApi::JsonMasker.new(item, @options).to_hash + else + item + end + end + else + mask_value(key, value) + end + end + end + # rubocop:enable Style/CaseLikeIf + + def mask_value(key, value) + full_hide_params_to_s = @full_hide_params.map(&:to_s) + return '*****' if full_hide_params_to_s.include?(key.to_s) + + mask_params_to_s = @mask_params.map(&:to_s) + return value if mask_params_to_s.include?(key.to_s) == false + + value = value.to_s + return '*****' if value.length <= 5 + + unmasked = value[0..2] + masked = value[3..-1].gsub(/./, '*') + "#{unmasked}#{masked}" + end + end +end diff --git a/lib/xendit_api/middleware/faraday_log_formatter.rb b/lib/xendit_api/middleware/faraday_log_formatter.rb new file mode 100644 index 0000000..38eb2e1 --- /dev/null +++ b/lib/xendit_api/middleware/faraday_log_formatter.rb @@ -0,0 +1,42 @@ +require 'faraday/logging/formatter' +require 'xendit_api/json_masker' +require 'xendit_api/url_masker' + +module XenditApi + module Middleware + class FaradayLogFormatter < Faraday::Logging::Formatter + MAX_LOG_SIZE = 10_000 + + def initialize(env = {}) + @logger = env[:logger] + @options = env[:options] + super(logger: env[:logger], options: env[:options]) + end + + def request(env) + masked_url = XenditApi::UrlMasker.mask(env[:url].to_s, @options) + @logger.info "#{env[:method].upcase} #{masked_url}" + return if env[:request_body].to_s.empty? + return if env[:request_body].to_s.size > MAX_LOG_SIZE + + message = { + body: XenditApi::JsonMasker.mask(env[:request_body], @options) + } + @logger.info({ request: message }.to_json) + end + + def response(env) + return if env[:response_body].to_s.empty? + return if env[:request_body].to_s.size > MAX_LOG_SIZE + + message = { + status: env[:status], + body: XenditApi::JsonMasker.mask(env[:response_body], @options) + } + @logger.info({ response: message }.to_json) + end + + def exception(exc); end + end + end +end diff --git a/lib/xendit_api/url_masker.rb b/lib/xendit_api/url_masker.rb new file mode 100644 index 0000000..d88f115 --- /dev/null +++ b/lib/xendit_api/url_masker.rb @@ -0,0 +1,55 @@ +module XenditApi + class UrlMasker + def self.mask(url, options = {}) + return url unless url.is_a?(String) + return url if url.empty? + + url = URI.parse(url) + XenditApi::UrlMasker.new(url, options).to_s + rescue URI::Error + url + end + + def initialize(url, options = {}) + @url = url + @mask_params = options[:mask_params] || [] + @full_hide_params = options[:full_hide_params] || [] + end + + def to_s + filter(@url) + end + + private + + # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + def filter(url) + query_params = URI.decode_www_form(url.query || '').to_h + return url.to_s if query_params.empty? + + query_params.each do |key, value| + full_hide_params_to_s = @full_hide_params.map(&:to_s) + mask_params_to_s = @mask_params.map(&:to_s) + if full_hide_params_to_s.include?(key) + query_params[key] = '*****' + elsif mask_params_to_s.include?(key) + value = value.to_s + if value.length <= 5 + query_params[key] = '*****' + next + end + + unmasked = value[0..2] + masked = value[3..-1].gsub(/./, '*') + query_params[key] = "#{unmasked}#{masked}" + end + end + # Rebuild the URL with the masked query parameters + masked_query = URI.encode_www_form(query_params) + masked_url = url.dup + masked_url.query = masked_query + masked_url.to_s + end + # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + end +end diff --git a/spec/xendit_api/json_masker_spec.rb b/spec/xendit_api/json_masker_spec.rb new file mode 100644 index 0000000..0b2032c --- /dev/null +++ b/spec/xendit_api/json_masker_spec.rb @@ -0,0 +1,155 @@ +require 'spec_helper' +require 'xendit_api/json_masker' + +RSpec.describe XenditApi::JsonMasker do + describe '.mask' do + it 'returns nil when input is nil' do + expect(described_class.mask(nil, mask_params: %w[card_number expiration_date cvv name email], full_hide_params: %w[amount account_number])).to be_nil + end + + it 'returns empty string when input is empty string' do + expect(described_class.mask('', mask_params: %w[card_number expiration_date cvv name email], full_hide_params: %w[amount account_number])).to eq('') + end + + it 'returns array when input is array' do + expect(described_class.mask([], mask_params: %w[card_number expiration_date cvv name email], full_hide_params: %w[amount account_number])).to eq([]) + end + + it 'returns expected when data is an array' do + parsed = [ + { + card_number: '123456789012', + cvv: '123', + address: 'Jakarta', + email: 'hello@email.com' + }, + { + card_number: '123456789012', + cvv: '123', + address: 'Jakarta', + email: 'hello@email.com' + } + ] + + masked = [ + { + 'card_number' => '*****', + 'cvv' => '*****', + 'address' => 'Jakarta', + 'email' => 'hel************' + }, + { + 'card_number' => '*****', + 'cvv' => '*****', + 'address' => 'Jakarta', + 'email' => 'hel************' + } + ] + + output = described_class.mask(parsed.to_json, mask_params: %w[email], full_hide_params: %w[card_number cvv]) + + expect(output).to eq(masked) + end + + it 'returns expected when data is an array and attribute symbol' do + parsed = [ + { + card_number: '123456789012', + cvv: '123', + address: 'Jakarta', + email: 'hello@email.com' + }, + { + card_number: '123456789012', + cvv: '123', + address: 'Jakarta', + email: 'hello@email.com' + } + ] + + masked = [ + { + 'card_number' => '*****', + 'cvv' => '*****', + 'address' => 'Jakarta', + 'email' => 'hel************' + }, + { + 'card_number' => '*****', + 'cvv' => '*****', + 'address' => 'Jakarta', + 'email' => 'hel************' + } + ] + + output = described_class.mask(parsed.to_json, mask_params: %i[email], full_hide_params: %i[card_number cvv]) + + expect(output).to eq(masked) + end + + it 'returns expected with valid JSON' do + parsed = { + card_number: '1234567890123456', + expiration_date: '12/23', + cvv: '***', + name: 'John Doe', + address: 'Jakarta', + external_id: '12398123123', + information: { + email: 'bill@john.com', + account_number: '1092830182309123' + }, + items: [ + { + quantity: 89_821_823, + amount: 15_000, + email: 'john@bill.com', + more_info: { + email: 'hello@gmail.com', + booking_id: '1234567890', + page: 1, + limit: 2 + } + } + ] + } + + masked = { + 'card_number' => '123*************', + 'expiration_date' => '*****', + 'cvv' => '*****', + 'name' => 'Joh*****', + 'address' => 'Jakarta', + 'external_id' => '12398123123', + 'information' => { + 'email' => 'bil**********', + 'account_number' => '*****' + }, + 'items' => [ + { + 'quantity' => 89_821_823, + 'amount' => '*****', + 'email' => 'joh**********', + 'more_info' => { + 'email' => 'hel************', + 'booking_id' => '1234567890', + 'page' => 1, + 'limit' => 2 + } + } + ] + } + + output = described_class.mask(parsed.to_json, mask_params: %w[card_number expiration_date cvv name email], full_hide_params: %w[amount account_number]) + + expect(output).to eq(masked) + end + + it 'returns expected with invalid JSON' do + data = 'this is invalid json' + output = described_class.mask(data, mask_params: %w[card_number expiration_date cvv name email], full_hide_params: %w[amount account_number]) + + expect(output).to eq(data) + end + end +end diff --git a/spec/xendit_api/url_masker_spec.rb b/spec/xendit_api/url_masker_spec.rb new file mode 100644 index 0000000..0030a87 --- /dev/null +++ b/spec/xendit_api/url_masker_spec.rb @@ -0,0 +1,46 @@ +require 'spec_helper' +require 'xendit_api/url_masker' + +RSpec.describe XenditApi::UrlMasker do + describe '.mask' do + it 'returns expected with valid URL' do + url = 'https://example.com?token=1234567890123456&cvv=123&other_param=value&account_number=123456789&bank=bca' + expected = 'https://example.com?token=*****&cvv=*****&other_param=value&account_number=123******&bank=*****' + options = { + full_hide_params: %w[token cvv], + mask_params: %w[account_number bank] + } + expect(described_class.mask(url, options)).to eq(expected) + end + + it 'returns expected with valid URL with symbol params' do + url = 'https://example.com?token=1234567890123456&cvv=123&other_param=value&account_number=123456789&bank=bca' + expected = 'https://example.com?token=*****&cvv=*****&other_param=value&account_number=123******&bank=*****' + options = { + full_hide_params: %i[token cvv], + mask_params: %i[account_number bank] + } + expect(described_class.mask(url, options)).to eq(expected) + end + + it 'returns expected with empty URL' do + url = '' + expected = '' + options = { + full_hide_params: %w[token cvv], + mask_params: %w[account_number name] + } + expect(described_class.mask(url, options)).to eq(expected) + end + + it 'returns expected when there is no query string' do + url = 'https://example.com' + expected = 'https://example.com' + options = { + full_hide_params: %w[token cvv], + mask_params: %w[account_number name] + } + expect(described_class.mask(url, options)).to eq(expected) + end + end +end