Skip to content

Commit b1dc87c

Browse files
Merge pull request #55 from mekari-engineering/KRED-2061-v3
KRED-2061: Add feature to masking logs
2 parents 8a93421 + de73fc4 commit b1dc87c

7 files changed

Lines changed: 384 additions & 14 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ end
3535
client = XenditApi::Client.new('secret_key')
3636

3737
# when you need to filter logs due to PII or security
38-
client = XenditAPi::Client.new('secret_key', filtered_logs: [:card_cvv, :expected_amount])
38+
client = XenditAPi::Client.new('secret_key', filtered_logs: [:card_cvv, :expected_amount], mask_params: [:email, :full_name])
3939
```
4040

4141
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`.

lib/xendit_api/client.rb

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
require 'faraday_middleware'
22
require 'xendit_api/middleware/handle_response_exception'
3+
require 'xendit_api/middleware/faraday_log_formatter'
34
require 'xendit_api/api/virtual_account'
45
require 'xendit_api/api/ewallet'
56
require 'xendit_api/api/credit_card'
@@ -16,7 +17,6 @@
1617
require 'logger'
1718

1819
module XenditApi
19-
# rubocop:disable Metrics/ClassLength
2020
class Client
2121
BASE_URL = 'https://api.xendit.co'.freeze
2222

@@ -30,17 +30,10 @@ def initialize(authorization = nil, options = {})
3030

3131
logger = find_logger(options[:logger])
3232
if logger
33-
connection.response :logger, logger, { headers: false, bodies: true, errors: true } do |log|
34-
filtered_logs = options[:filtered_logs]
35-
if filtered_logs.respond_to?(:each)
36-
filtered_logs.each do |filter|
37-
log.filter(%r{(#{filter}=)([\w+-.?@:/]+)}, '\1[FILTERED]')
38-
log.filter(/(#{filter}":\s*")(.*?)(")/i, '\1[FILTERED]\3')
39-
log.filter(/(#{filter}":\s*)(\d+(?:\.\d+)?|true|false)/i, '\1[FILTERED]')
40-
log.filter(/(#{filter}":\s*)(\[.*?\])/i, '\1[FILTERED]')
41-
end
42-
end
43-
end
33+
connection.response :logger, logger,
34+
full_hide_params: options[:filtered_logs] || [],
35+
mask_params: options[:mask_params] || [],
36+
formatter: XenditApi::Middleware::FaradayLogFormatter
4437
end
4538
connection.use XenditApi::Middleware::HandleResponseException, logger
4639
connection.adapter Faraday.default_adapter
@@ -142,5 +135,4 @@ def find_logger(logger_option)
142135
logger_option || XenditApi.configuration&.logger
143136
end
144137
end
145-
# rubocop:enable Metrics/ClassLength
146138
end

lib/xendit_api/json_masker.rb

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
module XenditApi
2+
class JsonMasker
3+
def self.mask(json, options = {})
4+
return json unless json.is_a?(String)
5+
return json if json.empty?
6+
7+
output = JSON.parse(json)
8+
XenditApi::JsonMasker.new(output, options).to_masked
9+
rescue JSON::ParserError
10+
json
11+
end
12+
13+
def initialize(data, options = {})
14+
@data = data
15+
@options = options
16+
@mask_params = options[:mask_params] || []
17+
@full_hide_params = options[:full_hide_params] || []
18+
end
19+
20+
def to_masked
21+
return @data if @mask_params.empty? && @full_hide_params.empty?
22+
23+
case @data
24+
when Array
25+
@data.map do |item|
26+
if item.is_a?(Hash)
27+
XenditApi::JsonMasker.new(item, @options).to_hash
28+
else
29+
item
30+
end
31+
end
32+
when Hash
33+
filter(@data)
34+
else
35+
@data
36+
end
37+
end
38+
39+
def to_hash
40+
filter(@data)
41+
end
42+
43+
private
44+
45+
# rubocop:disable Style/CaseLikeIf
46+
def filter(output)
47+
output.each do |key, value|
48+
output[key] = if value.is_a?(Hash)
49+
XenditApi::JsonMasker.new(value, @options).to_hash
50+
elsif value.is_a?(Array)
51+
value.map do |item|
52+
if item.is_a?(Hash)
53+
XenditApi::JsonMasker.new(item, @options).to_hash
54+
else
55+
item
56+
end
57+
end
58+
else
59+
mask_value(key, value)
60+
end
61+
end
62+
end
63+
# rubocop:enable Style/CaseLikeIf
64+
65+
def mask_value(key, value)
66+
full_hide_params_to_s = @full_hide_params.map(&:to_s)
67+
return '*****' if full_hide_params_to_s.include?(key.to_s)
68+
69+
mask_params_to_s = @mask_params.map(&:to_s)
70+
return value if mask_params_to_s.include?(key.to_s) == false
71+
72+
value = value.to_s
73+
return '*****' if value.length <= 5
74+
75+
unmasked = value[0..2]
76+
masked = value[3..-1].gsub(/./, '*')
77+
"#{unmasked}#{masked}"
78+
end
79+
end
80+
end
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
require 'faraday/logging/formatter'
2+
require 'xendit_api/json_masker'
3+
require 'xendit_api/url_masker'
4+
5+
module XenditApi
6+
module Middleware
7+
class FaradayLogFormatter < Faraday::Logging::Formatter
8+
MAX_LOG_SIZE = 10_000
9+
10+
def initialize(env = {})
11+
@logger = env[:logger]
12+
@options = env[:options]
13+
super(logger: env[:logger], options: env[:options])
14+
end
15+
16+
def request(env)
17+
masked_url = XenditApi::UrlMasker.mask(env[:url].to_s, @options)
18+
@logger.info "#{env[:method].upcase} #{masked_url}"
19+
return if env[:request_body].to_s.empty?
20+
return if env[:request_body].to_s.size > MAX_LOG_SIZE
21+
22+
message = {
23+
body: XenditApi::JsonMasker.mask(env[:request_body], @options)
24+
}
25+
@logger.info({ request: message }.to_json)
26+
end
27+
28+
def response(env)
29+
return if env[:response_body].to_s.empty?
30+
return if env[:request_body].to_s.size > MAX_LOG_SIZE
31+
32+
message = {
33+
status: env[:status],
34+
body: XenditApi::JsonMasker.mask(env[:response_body], @options)
35+
}
36+
@logger.info({ response: message }.to_json)
37+
end
38+
39+
def exception(exc); end
40+
end
41+
end
42+
end

lib/xendit_api/url_masker.rb

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
module XenditApi
2+
class UrlMasker
3+
def self.mask(url, options = {})
4+
return url unless url.is_a?(String)
5+
return url if url.empty?
6+
7+
url = URI.parse(url)
8+
XenditApi::UrlMasker.new(url, options).to_s
9+
rescue URI::Error
10+
url
11+
end
12+
13+
def initialize(url, options = {})
14+
@url = url
15+
@mask_params = options[:mask_params] || []
16+
@full_hide_params = options[:full_hide_params] || []
17+
end
18+
19+
def to_s
20+
filter(@url)
21+
end
22+
23+
private
24+
25+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
26+
def filter(url)
27+
query_params = URI.decode_www_form(url.query || '').to_h
28+
return url.to_s if query_params.empty?
29+
30+
query_params.each do |key, value|
31+
full_hide_params_to_s = @full_hide_params.map(&:to_s)
32+
mask_params_to_s = @mask_params.map(&:to_s)
33+
if full_hide_params_to_s.include?(key)
34+
query_params[key] = '*****'
35+
elsif mask_params_to_s.include?(key)
36+
value = value.to_s
37+
if value.length <= 5
38+
query_params[key] = '*****'
39+
next
40+
end
41+
42+
unmasked = value[0..2]
43+
masked = value[3..-1].gsub(/./, '*')
44+
query_params[key] = "#{unmasked}#{masked}"
45+
end
46+
end
47+
# Rebuild the URL with the masked query parameters
48+
masked_query = URI.encode_www_form(query_params)
49+
masked_url = url.dup
50+
masked_url.query = masked_query
51+
masked_url.to_s
52+
end
53+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
54+
end
55+
end
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
require 'spec_helper'
2+
require 'xendit_api/json_masker'
3+
4+
RSpec.describe XenditApi::JsonMasker do
5+
describe '.mask' do
6+
it 'returns nil when input is nil' do
7+
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
8+
end
9+
10+
it 'returns empty string when input is empty string' do
11+
expect(described_class.mask('', mask_params: %w[card_number expiration_date cvv name email], full_hide_params: %w[amount account_number])).to eq('')
12+
end
13+
14+
it 'returns array when input is array' do
15+
expect(described_class.mask([], mask_params: %w[card_number expiration_date cvv name email], full_hide_params: %w[amount account_number])).to eq([])
16+
end
17+
18+
it 'returns expected when data is an array' do
19+
parsed = [
20+
{
21+
card_number: '123456789012',
22+
cvv: '123',
23+
address: 'Jakarta',
24+
email: 'hello@email.com'
25+
},
26+
{
27+
card_number: '123456789012',
28+
cvv: '123',
29+
address: 'Jakarta',
30+
email: 'hello@email.com'
31+
}
32+
]
33+
34+
masked = [
35+
{
36+
'card_number' => '*****',
37+
'cvv' => '*****',
38+
'address' => 'Jakarta',
39+
'email' => 'hel************'
40+
},
41+
{
42+
'card_number' => '*****',
43+
'cvv' => '*****',
44+
'address' => 'Jakarta',
45+
'email' => 'hel************'
46+
}
47+
]
48+
49+
output = described_class.mask(parsed.to_json, mask_params: %w[email], full_hide_params: %w[card_number cvv])
50+
51+
expect(output).to eq(masked)
52+
end
53+
54+
it 'returns expected when data is an array and attribute symbol' do
55+
parsed = [
56+
{
57+
card_number: '123456789012',
58+
cvv: '123',
59+
address: 'Jakarta',
60+
email: 'hello@email.com'
61+
},
62+
{
63+
card_number: '123456789012',
64+
cvv: '123',
65+
address: 'Jakarta',
66+
email: 'hello@email.com'
67+
}
68+
]
69+
70+
masked = [
71+
{
72+
'card_number' => '*****',
73+
'cvv' => '*****',
74+
'address' => 'Jakarta',
75+
'email' => 'hel************'
76+
},
77+
{
78+
'card_number' => '*****',
79+
'cvv' => '*****',
80+
'address' => 'Jakarta',
81+
'email' => 'hel************'
82+
}
83+
]
84+
85+
output = described_class.mask(parsed.to_json, mask_params: %i[email], full_hide_params: %i[card_number cvv])
86+
87+
expect(output).to eq(masked)
88+
end
89+
90+
it 'returns expected with valid JSON' do
91+
parsed = {
92+
card_number: '1234567890123456',
93+
expiration_date: '12/23',
94+
cvv: '***',
95+
name: 'John Doe',
96+
address: 'Jakarta',
97+
external_id: '12398123123',
98+
information: {
99+
email: 'bill@john.com',
100+
account_number: '1092830182309123'
101+
},
102+
items: [
103+
{
104+
quantity: 89_821_823,
105+
amount: 15_000,
106+
email: 'john@bill.com',
107+
more_info: {
108+
email: 'hello@gmail.com',
109+
booking_id: '1234567890',
110+
page: 1,
111+
limit: 2
112+
}
113+
}
114+
]
115+
}
116+
117+
masked = {
118+
'card_number' => '123*************',
119+
'expiration_date' => '*****',
120+
'cvv' => '*****',
121+
'name' => 'Joh*****',
122+
'address' => 'Jakarta',
123+
'external_id' => '12398123123',
124+
'information' => {
125+
'email' => 'bil**********',
126+
'account_number' => '*****'
127+
},
128+
'items' => [
129+
{
130+
'quantity' => 89_821_823,
131+
'amount' => '*****',
132+
'email' => 'joh**********',
133+
'more_info' => {
134+
'email' => 'hel************',
135+
'booking_id' => '1234567890',
136+
'page' => 1,
137+
'limit' => 2
138+
}
139+
}
140+
]
141+
}
142+
143+
output = described_class.mask(parsed.to_json, mask_params: %w[card_number expiration_date cvv name email], full_hide_params: %w[amount account_number])
144+
145+
expect(output).to eq(masked)
146+
end
147+
148+
it 'returns expected with invalid JSON' do
149+
data = 'this is invalid json'
150+
output = described_class.mask(data, mask_params: %w[card_number expiration_date cvv name email], full_hide_params: %w[amount account_number])
151+
152+
expect(output).to eq(data)
153+
end
154+
end
155+
end

0 commit comments

Comments
 (0)