speakeasy_ruby_sdk 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +201 -0
  3. data/README.md +184 -0
  4. data/lib/speakeasy_ruby_sdk/config.rb +60 -0
  5. data/lib/speakeasy_ruby_sdk/har_builder.rb +242 -0
  6. data/lib/speakeasy_ruby_sdk/http_transaction.rb +122 -0
  7. data/lib/speakeasy_ruby_sdk/masker.rb +166 -0
  8. data/lib/speakeasy_ruby_sdk/time.rb +42 -0
  9. data/lib/speakeasy_ruby_sdk/url_utils.rb +24 -0
  10. data/lib/speakeasy_ruby_sdk/version.rb +3 -0
  11. data/lib/speakeasy_ruby_sdk.rb +112 -0
  12. data/test/bulk_test.rb +176 -0
  13. data/test/http_test.rb +32 -0
  14. data/test/masker_test.rb +124 -0
  15. data/test/testdata/captures_basic_request_and_no_response_body_input.json +15 -0
  16. data/test/testdata/captures_basic_request_and_no_response_body_output.json +61 -0
  17. data/test/testdata/captures_basic_request_and_response_input.json +21 -0
  18. data/test/testdata/captures_basic_request_and_response_output.json +70 -0
  19. data/test/testdata/captures_basic_request_and_response_with_different_content_types_input.json +22 -0
  20. data/test/testdata/captures_basic_request_and_response_with_different_content_types_output.json +74 -0
  21. data/test/testdata/captures_basic_request_and_response_with_no_response_header_set_input.json +21 -0
  22. data/test/testdata/captures_basic_request_and_response_with_no_response_header_set_output.json +70 -0
  23. data/test/testdata/captures_basic_request_with_nano_precision_input.json +23 -0
  24. data/test/testdata/captures_basic_request_with_nano_precision_output.json +70 -0
  25. data/test/testdata/captures_cookies_input.json +35 -0
  26. data/test/testdata/captures_cookies_output.json +165 -0
  27. data/test/testdata/captures_masked_request_response_input.json +73 -0
  28. data/test/testdata/captures_masked_request_response_output.json +156 -0
  29. data/test/testdata/captures_no_response_body_when_not_modified_input.json +17 -0
  30. data/test/testdata/captures_no_response_body_when_not_modified_output.json +60 -0
  31. data/test/testdata/captures_post_request_with_body_input.json +24 -0
  32. data/test/testdata/captures_post_request_with_body_output.json +81 -0
  33. data/test/testdata/captures_query_params_input.json +21 -0
  34. data/test/testdata/captures_query_params_output.json +75 -0
  35. data/test/testdata/captures_redirect_input.json +22 -0
  36. data/test/testdata/captures_redirect_output.json +74 -0
  37. data/test/testdata/drops_request_and_response_bodies_when_request_body_too_large_input.json +24 -0
  38. data/test/testdata/drops_request_and_response_bodies_when_request_body_too_large_output.json +81 -0
  39. data/test/testdata/drops_response_body_when_too_large_input.json +24 -0
  40. data/test/testdata/drops_response_body_when_too_large_output.json +81 -0
  41. metadata +240 -0
@@ -0,0 +1,242 @@
1
+ module SpeakeasyRubySdk
2
+ class HarBuilder
3
+
4
+ def initialize api_config
5
+ @api_config = api_config
6
+ end
7
+
8
+ def construct_creator name, version
9
+ return {
10
+ "name": name,
11
+ "version": version
12
+ }
13
+ end
14
+
15
+ def construct_log version, creator, entries, comment
16
+ return {
17
+ 'log': {
18
+ "version": version,
19
+ "creator": creator,
20
+ "entries": entries,
21
+ "comment": comment
22
+ }
23
+ }
24
+ end
25
+
26
+
27
+ def construct_empty_cache
28
+ return {
29
+ }
30
+ end
31
+ def construct_empty_params
32
+ return []
33
+ end
34
+
35
+ def construct_query_records query_params
36
+ query_params.map {|k, v| {
37
+ "name": k,
38
+ "value": v
39
+ } }.sort_by(&lambda{ |h| h[:name] })
40
+ end
41
+
42
+ def construct_response_cookies cookies
43
+ final_cookies = []
44
+ if ! cookies.nil?
45
+ for cookie in cookies
46
+ new_cookie = {
47
+ 'name': cookie.name,
48
+ 'value': cookie.value,
49
+ }
50
+ if cookie.path && cookie.path != '/'
51
+ new_cookie['path'] = cookie.path
52
+ end
53
+ if cookie.domain
54
+ new_cookie['domain'] = cookie.domain
55
+ end
56
+ if cookie.expires
57
+ new_cookie['expires'] = cookie.expires.strftime("%Y-%m-%dT%H:%M:%S.%NZ")
58
+ end
59
+ if cookie.httponly
60
+ new_cookie['httpOnly'] = cookie.httponly
61
+ end
62
+ if cookie.secure
63
+ new_cookie['secure'] = cookie.secure
64
+ end
65
+ final_cookies << new_cookie
66
+ end
67
+ end
68
+ return final_cookies.sort_by(&lambda{ |c| c[:name] })
69
+ end
70
+
71
+ def construct_request_cookies cookies
72
+ if cookies.nil?
73
+ return []
74
+ else
75
+ return cookies.map{ |cookie|
76
+ {
77
+ 'name': cookie[0],
78
+ 'value': cookie[1]
79
+ }
80
+ }.sort_by(&lambda{ |c| c[:name] })
81
+ end
82
+ end
83
+
84
+ def construct_header_records headers
85
+ final_headers = []
86
+ for k, v in headers
87
+ if v.is_a? Array
88
+ for value in v
89
+ final_headers << {
90
+ "name": k,
91
+ "value": value
92
+ }
93
+ end
94
+ else
95
+ final_headers << {
96
+ "name": k,
97
+ "value": v
98
+ }
99
+ end
100
+ end
101
+ final_headers.sort_by(&lambda{ |h| h[:name] })
102
+ end
103
+
104
+ def construct_post_data request
105
+ content_type = request.headers['content-type']
106
+ if content_type.nil? || content_type.length == 0
107
+ content_type = "application/octet-stream"
108
+ end
109
+ if request.body.empty?
110
+ return nil
111
+ elsif (request.headers.include?('content-length')) && (!request.headers['content-length'].nil?) && (request.headers['content-length'].to_i > @api_config.max_capture_size)
112
+ return {
113
+ "mimeType": content_type,
114
+ "text": "--dropped--"
115
+ }
116
+ else
117
+ return {
118
+ "mimeType": content_type,
119
+ "text": request.body
120
+ }
121
+ end
122
+ end
123
+
124
+ def construct_response_content status, body, headers
125
+ content_type = headers['content-type']
126
+ if content_type.nil? || content_type.length == 0
127
+ content_type = "application/octet-stream"
128
+ end
129
+ if status == 304
130
+ return {
131
+ "mimeType": content_type,
132
+ "size": -1
133
+ }
134
+ elsif (headers.include?('content-length')) && (!headers['content-length'].nil?) && (headers['content-length'].to_i > @api_config.max_capture_size)
135
+ return {
136
+ "mimeType": content_type,
137
+ "text": "--dropped--",
138
+ "size": -1
139
+ }
140
+ elsif ! headers.include?('content-length')
141
+ return {
142
+ "mimeType": content_type,
143
+ "size": -1
144
+ }
145
+ else
146
+ return {
147
+ "mimeType": content_type,
148
+ "text": body,
149
+ "size": headers['content-length'].to_i
150
+ }
151
+ end
152
+ end
153
+
154
+ def calculate_header_size headers
155
+ raw_headers = ''
156
+ for k, v in headers
157
+ if v.is_a? Array
158
+ for value in v
159
+ raw_headers += "#{k}: #{value}\r\n"
160
+ end
161
+ else
162
+ raw_headers += "#{k}: #{v}\r\n"
163
+ end
164
+ end
165
+ raw_headers.bytesize
166
+ end
167
+
168
+ def construct_request request
169
+ req = {
170
+ "method": request.method,
171
+ "url": request.url,
172
+ "httpVersion": request.transaction.protocol,
173
+ "cookies": self.construct_request_cookies(request.cookies),
174
+ "headers": self.construct_header_records(request.headers),
175
+ "queryString": self.construct_query_records(request.query_params),
176
+ "headersSize": self.calculate_header_size(request.headers),
177
+ "bodySize": request.content_length.to_i,
178
+ }
179
+ if ! self.construct_post_data(request).nil?
180
+ req["postData"] = self.construct_post_data(request)
181
+ end
182
+ req
183
+ end
184
+
185
+ def construct_response response
186
+ res = {
187
+ "status": response.status,
188
+ "statusText": Rack::Utils::HTTP_STATUS_CODES[response.status],
189
+ "httpVersion": response.transaction.protocol,
190
+ "cookies": self.construct_response_cookies(response.cookies),
191
+ "headers": self.construct_header_records(response.headers),
192
+ "content": self.construct_response_content(response.status, response.body, response.headers),
193
+ "redirectURL": response.headers.fetch('location', ''),
194
+ "headersSize": self.calculate_header_size(response.headers)
195
+ }
196
+
197
+ if response.status == 304
198
+ res["bodySize"] = 0
199
+ elsif !response.headers.include?('content-length')
200
+ res["bodySize"] = -1
201
+ else
202
+ res["bodySize"] = response.body.bytesize
203
+ end
204
+ res
205
+ end
206
+
207
+ def construct_timings
208
+ return {
209
+ "send": -1,
210
+ "wait": -1,
211
+ "receive": -1,
212
+ }
213
+ end
214
+
215
+ def construct_entries http_transaction
216
+ entry = {
217
+ "startedDateTime": http_transaction.time_utils.start_time.strftime("%Y-%m-%dT%H:%M:%S.%NZ"),
218
+ "time": http_transaction.time_utils.elapsed_time,
219
+ "request": self.construct_request(http_transaction.request),
220
+ "response": self.construct_response(http_transaction.response),
221
+ "serverIPAddress": http_transaction.request.url.hostname,
222
+ "cache": self.construct_empty_cache,
223
+ "timings": self.construct_timings
224
+ }
225
+ if !http_transaction.port.nil? && http_transaction.port != "80"
226
+ entry["connection"] = http_transaction.port
227
+ end
228
+ return [entry]
229
+ end
230
+
231
+ def construct_har http_transaction
232
+ creator = construct_creator SpeakeasyRubySdk.to_s, SpeakeasyRubySdk::VERSION
233
+ version = "1.2"
234
+ comment = "request capture for #{http_transaction.request.url.to_s}"
235
+
236
+ entries = construct_entries http_transaction
237
+
238
+ log = construct_log version, creator, entries, comment
239
+ log.to_json
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,122 @@
1
+ require 'http-cookie'
2
+
3
+ module SpeakeasyRubySdk
4
+ class HttpTransaction
5
+
6
+ attr_reader :time_utils, :status, :env, :request, :response, :protocol, :port
7
+
8
+ def handle_forward_headers request_headers
9
+ if request_headers.include? 'x-forwarded-proto' && request_headers['x-forwarded-proto']
10
+ scheme = request_headers['x-forwarded-proto'].downcase
11
+ elsif request_headers.include? 'x-forwarded-scheme' && request_headers['x-forwarded-scheme']
12
+ scheme = request_headers['x-forwarded-scheme'].downcase
13
+ elsif request_headers.include? 'forwarded' && request_headers['forwarded']
14
+ forwarded = request_headers['forwarded']
15
+ protoRegex = Regexp.new(/(?i)(?:proto=)(https|http)/)
16
+ matches = forwarded.match(protoRegexp)
17
+ if matches.length > 1
18
+ scheme = matches[1].downcase
19
+ end
20
+ end
21
+ scheme
22
+ end
23
+
24
+ def initialize time_utils, env, status, response_headers, response_body, masker
25
+ ## Setup Data
26
+ @time_utils = time_utils
27
+ @status = status
28
+ @env = env
29
+ @protocol = env['SERVER_PROTOCOL']
30
+ @port = env['SERVER_PORT']
31
+
32
+ if ! response_body.nil? && response_body.respond_to?(:body)
33
+ response_body = response_body.body
34
+ elsif !response_body.nil? && response_body.respond_to?(:join)
35
+ response_body = response_body.join
36
+ elsif response_body.nil?
37
+ response_body = ''
38
+ end
39
+ # normalize request headers
40
+ request_headers = Hash[*env.select {|k,v| k.start_with? 'HTTP_'}
41
+ .collect {|k,v| [k.sub(/^HTTP_/, ''), v]}
42
+ .collect {|k,v| [k.split('_').collect(&:downcase).join('-'), v]}
43
+ .sort
44
+ .flatten]
45
+ request_body = env['rack.input'].read
46
+ request_method = env['REQUEST_METHOD']
47
+ rack_request = Rack::Request.new(env)
48
+ response_headers = Hash[response_headers.collect {|k,v| [k.downcase, v] }]
49
+ scheme = self.handle_forward_headers request_headers
50
+ if scheme
51
+ @protocol = scheme
52
+ end
53
+ query_params = Rack::Utils.parse_nested_query(env['QUERY_STRING'])
54
+
55
+ ## Temporary url var for populating cookiejar.
56
+ unmasked_url = UrlUtils.resolve_url env
57
+
58
+ request_cookies = CGI::Cookie.parse(request_headers['cookie'] || '').map { |cookie| [cookie[0], cookie[1][0]] }
59
+ response_cookies = HTTP::CookieJar.new()
60
+ if response_headers.include? 'set-cookie'
61
+ cookies = response_headers['set-cookie']
62
+ cookies.map { |cookie| response_cookies.parse(cookie, unmasked_url) }
63
+ end
64
+
65
+ ## Begin Masking
66
+ masked_query_params = masker.mask_query_params unmasked_url.path, query_params
67
+
68
+ masked_request_headers = masker.mask_request_headers unmasked_url.path, request_headers
69
+ masked_response_headers = masker.mask_response_headers unmasked_url.path, response_headers
70
+
71
+ request_url = UrlUtils.resolve_url env, masked_query_params
72
+
73
+ masked_request_cookies = masker.mask_request_cookies unmasked_url.path, request_cookies
74
+ masked_response_cookies = masker.mask_response_cookies unmasked_url.path, response_cookies
75
+
76
+ masked_request_body = masker.mask_request_body unmasked_url.path, request_body
77
+ masked_response_body = masker.mask_response_body unmasked_url.path, response_body
78
+
79
+
80
+ ## Construct Request Response
81
+ @request = HttpRequest.new self, rack_request, request_url, request_method, masked_request_headers, masked_query_params, masked_request_body, masked_request_cookies
82
+ @response = HttpResponse.new self, status, masked_response_headers, masked_response_body, masked_response_cookies
83
+ end
84
+ end
85
+
86
+ class HttpRequest
87
+ attr_reader :transaction, :method, :url, :query_params, :headers, :body, :cookies
88
+ def initialize transaction, rack_request, request_url, request_method, request_headers, query_params, request_body, request_cookies
89
+ @transaction = transaction
90
+ @rack_request = rack_request
91
+ @url = request_url
92
+ @query_params = query_params
93
+ @method = request_method
94
+ @headers = request_headers
95
+ @body = request_body
96
+ @cookies = request_cookies
97
+ end
98
+
99
+ def content_type
100
+ @rack_request.content_type
101
+ end
102
+
103
+ def content_length
104
+ if @rack_request.content_length == "0"
105
+ -1
106
+ else
107
+ @rack_request.content_length
108
+ end
109
+ end
110
+ end
111
+
112
+ class HttpResponse
113
+ attr_reader :transaction, :status, :headers, :body, :cookies
114
+ def initialize transaction, status, response_headers, response_body, response_cookies
115
+ @transaction = transaction
116
+ @status = (status.nil? || status == -1) ? 200 : status
117
+ @headers = response_headers
118
+ @body = response_body
119
+ @cookies = response_cookies
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,166 @@
1
+ module SpeakeasyRubySdk
2
+
3
+ class MaskConfig
4
+ attr_reader :type, :attributes, :masks, :controller
5
+ def initialize type, attributes, masks=nil, controller=nil
6
+ @type = type
7
+ @contoller = controller
8
+ @attributes = attributes
9
+ @masks = masks
10
+ end
11
+
12
+ def get_mask_for_attribute attribute
13
+ if @masks.nil? || @masks.empty?
14
+ SpeakeasyRubySdk::Masker::SIMPLE_MASK
15
+ elsif @masks.length == 1
16
+ @masks[0]
17
+ else
18
+ i = @attributes.find_index{|att| att == attribute}
19
+ if i > @masks.length
20
+ SpeakeasyRubySdk::Masker::SIMPLE_MASK
21
+ else
22
+ @masks[i]
23
+ end
24
+ end
25
+ end
26
+ end
27
+
28
+ class Masker
29
+
30
+ SIMPLE_MASK = '__masked__'
31
+ SIMPLE_NUMBER_MASK = -12321
32
+
33
+ def initialize config
34
+ @masks = {
35
+ :query_params => [],
36
+ :request_headers => [],
37
+ :response_headers => [],
38
+ :request_cookies => [],
39
+ :response_cookies => [],
40
+ :request_body_string => [],
41
+ :request_body_number => [],
42
+ :response_body_string => [],
43
+ :response_body_number => []
44
+ }
45
+ # todo remove this dependency for other mask control
46
+ @routes = config.routes
47
+
48
+ if config.masking
49
+ config.masking.map {|mask| @masks[mask.type] << mask}
50
+ end
51
+ end
52
+
53
+ def mask_value mask, attribute, path
54
+ if !mask.controller.nil?
55
+ route = @routes.recognize_path path
56
+ if mask.controller === route[:prefix]
57
+ return mask.get_mask_for_attribute attribute
58
+ else
59
+ return value;
60
+ end
61
+ else
62
+ return mask.get_mask_for_attribute attribute
63
+ end
64
+ end
65
+
66
+ def mask_pair masking_key, path, attribute, value
67
+ masked_value = value
68
+ if @masks.include? masking_key
69
+ masks = @masks[masking_key]
70
+ for mask in masks
71
+ if mask.attributes.include? attribute.to_s.downcase
72
+ masked_value = mask_value mask, attribute, path
73
+ end
74
+ end
75
+ end
76
+ return masked_value
77
+ end
78
+
79
+ def mask_dict masking_key, path, hash_map
80
+ masked_dict = {}
81
+ for key, value in hash_map
82
+ masked_dict[key] = mask_pair masking_key, path, key, value
83
+ if value.is_a? Array
84
+ masked_dict[key] = value.map { |v| mask_pair masking_key, path, key, v }
85
+ end
86
+ end
87
+ masked_dict
88
+ end
89
+
90
+ def mask_query_params path, query_params
91
+ mask_dict :query_params, path, query_params
92
+ end
93
+
94
+ def mask_request_headers path, headers
95
+ mask_dict :request_headers, path, headers
96
+ end
97
+
98
+ def mask_response_headers path, headers
99
+ mask_dict :response_headers, path, headers
100
+ end
101
+
102
+ def mask_request_cookies path, cookies
103
+ mask_dict :request_cookies, path, cookies
104
+ end
105
+
106
+ def mask_response_cookies path, cookies
107
+ for cookie in cookies
108
+ key = cookie.name
109
+ value = cookie.value
110
+ masked_value = self.mask_pair :response_cookies, path, key, value
111
+ cookie.value = masked_value
112
+ end
113
+ cookies
114
+ end
115
+
116
+ def mask_body_string masking_key_prefix, path, body
117
+ masking_key = "#{masking_key_prefix}_string".to_sym
118
+
119
+ masked_body = body
120
+ if @masks.include? masking_key
121
+ for mask in @masks[masking_key]
122
+ for attribute in mask.attributes
123
+
124
+ regex_string = Regexp.new "(\"#{attribute}\": *)(\".*?[^\\\\]\")( *[, \\n\\r}]?)"
125
+
126
+ matches = body.match(regex_string)
127
+ if matches
128
+ masked_body = masked_body.gsub(regex_string, "#{matches[1]}\"#{mask.get_mask_for_attribute(attribute)}\"#{matches[3]}")
129
+ end
130
+ end
131
+ end
132
+ end
133
+ masked_body
134
+ end
135
+ def mask_body_number masking_key_prefix, path, body
136
+ masking_key = "#{masking_key_prefix}_number".to_sym
137
+
138
+ masked_body = body
139
+ if @masks.include? masking_key
140
+ for mask in @masks[masking_key]
141
+ for attribute in mask.attributes
142
+ regex_string = Regexp.new "(\"#{attribute}\": *)(-?[0-9]+\\.?[0-9]*)( *[, \\n\\r}]?)"
143
+ matches = body.match(regex_string)
144
+ if matches
145
+ masked_body = masked_body.gsub(regex_string, "#{matches[1]}#{mask.get_mask_for_attribute(attribute)}#{matches[3]}")
146
+ end
147
+ end
148
+ end
149
+ end
150
+ masked_body
151
+ end
152
+
153
+ def mask_request_body path, body
154
+ masked_body = mask_body_string 'request_body', path, body
155
+ mask_body_number 'request_body', path, masked_body
156
+ end
157
+
158
+ def mask_response_body path, body
159
+ masked_body = mask_body_string 'response_body', path, body
160
+ mask_body_number 'response_body', path, masked_body
161
+ end
162
+
163
+ end
164
+ end
165
+
166
+
@@ -0,0 +1,42 @@
1
+ module SpeakeasyRubySdk
2
+ class TimeUtils
3
+ ## Convenience class to optionally override times
4
+ ## In test environment
5
+ def initialize(start_time=nil, elapsed_time=nil)
6
+ @start_time = start_time
7
+ @elapsed_time = elapsed_time
8
+ @end_time = nil
9
+ end
10
+
11
+ def start_time
12
+ now
13
+ end
14
+
15
+ def now
16
+ if @start_time.nil?
17
+ @start_time
18
+ @start_time = Time.now
19
+ return @start_time
20
+ else
21
+ return @start_time
22
+ end
23
+ end
24
+
25
+ def set_end_time
26
+ @end_time = Time.now
27
+ end
28
+
29
+ def elapsed_time
30
+ if !@elapsed_time.nil?
31
+ return @elapsed_time
32
+ else
33
+ @elapsed_time = time_difference(@end_time, @start_time)
34
+ end
35
+ end
36
+
37
+ def time_difference end_time, start_time
38
+ ((end_time - start_time)).to_i
39
+ end
40
+
41
+ end
42
+ end
@@ -0,0 +1,24 @@
1
+ require 'uri'
2
+ module SpeakeasyRubySdk
3
+ module UrlUtils
4
+ def self.resolve_url env, query_params=nil
5
+ if env.include?('SERVER_PORT') && env['SERVER_PORT'] != "80"
6
+ request_uri = "#{env['SERVER_NAME']}:#{env['SERVER_PORT']}#{env['PATH_INFO']}"
7
+ else
8
+ request_uri = "#{env['SERVER_NAME']}#{env['PATH_INFO']}"
9
+ end
10
+
11
+ if request_uri.include? '?'
12
+ request_uri = request_uri.split('?')[0]
13
+ end
14
+ scheme = env['rack.url_scheme']
15
+ host = env['HTTP_HOST']
16
+ if !query_params.nil? && !query_params.empty?
17
+ updated_query_string = URI.encode_www_form query_params
18
+ URI("#{scheme}://#{host}#{request_uri}?#{updated_query_string}")
19
+ else
20
+ URI("#{scheme}://#{host}#{request_uri}")
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,3 @@
1
+ module SpeakeasyRubySdk
2
+ VERSION = '0.0.2'
3
+ end