api_hammer 0.8.1 → 0.9.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: b1fe246f792bb80940376f41dddb4f18d7578d0e
4
- data.tar.gz: 22c1fd36b3196a1ec938fa0fc82c8f22daa2dece
3
+ metadata.gz: 25094fc3ff153feb9e2ac93c7c25a0e46d3143eb
4
+ data.tar.gz: 42cbb9d5226111853163fd94043cd33ee16eee70
5
5
  SHA512:
6
- metadata.gz: 797705bc23ad885527c573a69e5b81d51f391e4d418aa1ce804f55e060660ba59c6e7824123fb6fa07d448c80720432e25346ae8d1410b4bb5047a7033025f48
7
- data.tar.gz: 074ee7a9d90d371f8e6ed3edac7bd3fdc58c2cbd5a4c428e2f0a60ae5c7c48b385f786e5fec6422ce809937f82b10ab00be6385c98f643dec9627bef26cfd3c2
6
+ metadata.gz: f2b197a3c690c2e77a5cbebfcca7b6f98cbc1c4e5d7883a2cc1af545934be7433e58c5910eeffb77110e1da308a8753b993d5d2c683999192e18b178b4f26701
7
+ data.tar.gz: 351a18b0ac2e23726b7614743f7097402a17330d80a30a81fa2cb418791bd8a598d682eba94947ce9529d92d1bcdc02b5f4f25d99bc43a60ab2b769c177211d7
data/CHANGELOG.md CHANGED
@@ -1,3 +1,14 @@
1
+ # v0.9.2
2
+ - bugfix form encoded filtering
3
+
4
+ # v0.9.1
5
+ - recognize `app.config.api_hammer_request_logging_options` for request logger options
6
+
7
+ # v0.9.0
8
+ - rack request logger logs ids in arrays of hashes when logging ids
9
+ - filtered logging of sensitive keys in bodies of requests (json and form encoded)
10
+ - logstash filter for oauth headers and oauthenticator log entries
11
+
1
12
  # v0.8.1
2
13
  - request log format tweaks
3
14
 
@@ -0,0 +1,67 @@
1
+ module ApiHammer
2
+ # parses attributes out of content type header
3
+ class ContentTypeAttrs
4
+ def initialize(content_type)
5
+ @media_type = content_type.split(/\s*[;]\s*/, 2).first if content_type
6
+ @media_type.strip! if @media_type
7
+ @content_type = content_type
8
+ @parsed = false
9
+ @attributes = Hash.new { |h,k| h[k] = [] }
10
+ catch(:unparseable) do
11
+ throw(:unparseable) unless content_type
12
+ uri_parser = URI.const_defined?(:Parser) ? URI::Parser.new : URI
13
+ scanner = StringScanner.new(content_type)
14
+ scanner.scan(/.*;\s*/) || throw(:unparseable)
15
+ while match = scanner.scan(/(\w+)=("?)([^"]*)("?)\s*(,?)\s*/)
16
+ key = scanner[1]
17
+ quote1 = scanner[2]
18
+ value = scanner[3]
19
+ quote2 = scanner[4]
20
+ comma_follows = !scanner[5].empty?
21
+ throw(:unparseable) unless quote1 == quote2
22
+ throw(:unparseable) if !comma_follows && !scanner.eos?
23
+ @attributes[uri_parser.unescape(key)] << uri_parser.unescape(value)
24
+ end
25
+ throw(:unparseable) unless scanner.eos?
26
+ @parsed = true
27
+ end
28
+ end
29
+
30
+ attr_reader :media_type
31
+
32
+ def parsed?
33
+ @parsed
34
+ end
35
+
36
+ def [](key)
37
+ @attributes[key]
38
+ end
39
+
40
+ def text?
41
+ # ordered hash by priority mapping types to binary or text
42
+ # regexps will have \A and \z added
43
+ types = {
44
+ %r(image/.*) => :binary,
45
+ %r(audio/.*) => :binary,
46
+ %r(video/.*) => :binary,
47
+ %r(model/.*) => :binary,
48
+ %r(text/.*) => :text,
49
+ %r(message/.*) => :text,
50
+ 'application/octet-stream' => :binary,
51
+ 'application/ogg' => :binary,
52
+ 'application/pdf' => :binary,
53
+ 'application/postscript' => :binary,
54
+ 'application/zip' => :binary,
55
+ 'application/gzip' => :binary,
56
+ }
57
+ types.each do |match, type|
58
+ matched = match.is_a?(Regexp) ? media_type =~ %r(\A#{match.source}\z) : media_type == match
59
+ if matched
60
+ return type == :text
61
+ end
62
+ end
63
+ # fallback (unknown or not given) assume text
64
+ return true
65
+ end
66
+ end
67
+ end
@@ -1,57 +1,86 @@
1
1
  require 'faraday'
2
- require 'rack'
3
2
  require 'term/ansicolor'
4
3
  require 'json'
5
4
  require 'strscan'
6
5
 
7
6
  module ApiHammer
8
- # parses attributes out of content type header
9
- class ContentTypeAttrs
10
- def initialize(content_type)
11
- @media_type = content_type.split(/\s*[;]\s*/, 2).first if content_type
12
- @media_type.strip! if @media_type
13
- @content_type = content_type
14
- @parsed = false
15
- @attributes = Hash.new { |h,k| h[k] = [] }
16
- catch(:unparseable) do
17
- throw(:unparseable) unless content_type
18
- uri_parser = URI.const_defined?(:Parser) ? URI::Parser.new : URI
19
- scanner = StringScanner.new(content_type)
20
- scanner.scan(/.*;\s*/) || throw(:unparseable)
21
- while match = scanner.scan(/(\w+)=("?)([^"]*)("?)\s*(,?)\s*/)
22
- key = scanner[1]
23
- quote1 = scanner[2]
24
- value = scanner[3]
25
- quote2 = scanner[4]
26
- comma_follows = !scanner[5].empty?
27
- throw(:unparseable) unless quote1 == quote2
28
- throw(:unparseable) if !comma_follows && !scanner.eos?
29
- @attributes[uri_parser.unescape(key)] << uri_parser.unescape(value)
30
- end
31
- throw(:unparseable) unless scanner.eos?
32
- @parsed = true
7
+ module Faraday
8
+ class Request
9
+ def initialize(request_env, response_env)
10
+ @request_env = request_env
11
+ @response_env = response_env
33
12
  end
34
- end
35
13
 
36
- attr_reader :media_type
14
+ attr_reader :request_env
15
+ attr_reader :response_env
37
16
 
38
- def parsed?
39
- @parsed
40
- end
17
+ # deal with the vagaries of getting the response body in a form which JSON
18
+ # gem will not cry about generating
19
+ def response_body
20
+ instance_variable_defined?(:@response_body) ? @response_body : @response_body = catch(:response_body) do
21
+ unless response_env.body.is_a?(String)
22
+ begin
23
+ # if the response body is not a string, but JSON doesn't complain
24
+ # about dumping whatever it is, go ahead and use it
25
+ JSON.generate([response_env.body])
26
+ throw :response_body, response_env.body
27
+ rescue
28
+ # otherwise return nil - don't know what to do with whatever this object is
29
+ throw :response_body, nil
30
+ end
31
+ end
32
+
33
+ # first try to change the string's encoding per the Content-Type header
34
+ content_type = response_env.response_headers['Content-Type']
35
+ response_body = response_env.body.dup
36
+ unless response_body.valid_encoding?
37
+ # I think this always comes in as ASCII-8BIT anyway so may never get here. hopefully.
38
+ response_body.force_encoding('ASCII-8BIT')
39
+ end
41
40
 
42
- def [](key)
43
- @attributes[key]
41
+ content_type_attrs = ContentTypeAttrs.new(content_type)
42
+ if content_type_attrs.parsed?
43
+ charset = content_type_attrs['charset'].first
44
+ if charset && Encoding.list.any? { |enc| enc.to_s.downcase == charset.downcase }
45
+ if response_body.dup.force_encoding(charset).valid_encoding?
46
+ response_body.force_encoding(charset)
47
+ else
48
+ # I guess just ignore the specified encoding if the result is not valid. fall back to
49
+ # something else below.
50
+ end
51
+ end
52
+ end
53
+ begin
54
+ JSON.generate([response_body])
55
+ rescue Encoding::UndefinedConversionError
56
+ # if updating by content-type didn't do it, try UTF8 since JSON wants that - but only
57
+ # if it seems to be valid utf8.
58
+ # don't try utf8 if the response content-type indicated something else.
59
+ try_utf8 = !(content_type_attrs && content_type_attrs.parsed? && content_type_attrs['charset'].any?)
60
+ if try_utf8 && response_body.dup.force_encoding('UTF-8').valid_encoding?
61
+ response_body.force_encoding('UTF-8')
62
+ else
63
+ # I'm not sure if there is a way in this situation to get JSON gem to generate the
64
+ # string correctly. fall back to an array of codepoints I guess? this is a weird
65
+ # solution but the best I've got for now.
66
+ response_body = response_body.codepoints.to_a
67
+ end
68
+ end
69
+ response_body
70
+ end
71
+ end
44
72
  end
45
- end
46
73
 
47
- module Faraday
48
74
  # Faraday middleware for logging.
49
75
  #
50
- # two lines:
76
+ # logs two lines:
51
77
  #
52
78
  # - an info line, colored prettily to show a brief summary of the request and response
53
79
  # - a debug line of json to record all relevant info. this is a lot of stuff jammed into one line, not
54
80
  # pretty, but informative.
81
+ #
82
+ # options:
83
+ # - :filter_keys defines keys whose values will be filtered out of the logging
55
84
  class RequestLogger < ::Faraday::Middleware
56
85
  include Term::ANSIColor
57
86
 
@@ -61,89 +90,6 @@ module ApiHammer
61
90
  @options = options
62
91
  end
63
92
 
64
- # deal with the vagaries of getting the response body in a form which JSON
65
- # gem will not cry about dumping
66
- def response_body(response_env)
67
- unless response_env.body.is_a?(String)
68
- begin
69
- # if the response body is not a string, but JSON doesn't complain
70
- # about dumping whatever it is, go ahead and use it
71
- JSON.dump([response_env.body])
72
- return response_env.body
73
- rescue
74
- # otherwise return nil - don't know what to do with whatever this object is
75
- return nil
76
- end
77
- end
78
-
79
- # first try to change the string's encoding per the Content-Type header
80
- content_type = response_env.response_headers['Content-Type']
81
- response_body = response_env.body.dup
82
- unless response_body.valid_encoding?
83
- # I think this always comes in as ASCII-8BIT anyway so may never get here. hopefully.
84
- response_body.force_encoding('ASCII-8BIT')
85
- end
86
-
87
- content_type_attrs = ContentTypeAttrs.new(content_type)
88
- if content_type_attrs.parsed?
89
- charset = content_type_attrs['charset'].first
90
- if charset && Encoding.list.any? { |enc| enc.to_s.downcase == charset.downcase }
91
- if response_body.dup.force_encoding(charset).valid_encoding?
92
- response_body.force_encoding(charset)
93
- else
94
- # I guess just ignore the specified encoding if the result is not valid. fall back to
95
- # something else below.
96
- end
97
- end
98
- end
99
- begin
100
- JSON.dump([response_body])
101
- rescue Encoding::UndefinedConversionError
102
- # if updating by content-type didn't do it, try UTF8 since JSON wants that - but only
103
- # if it seems to be valid utf8.
104
- # don't try utf8 if the response content-type indicated something else.
105
- try_utf8 = !(content_type_attrs && content_type_attrs.parsed? && content_type_attrs['charset'].any?)
106
- if try_utf8 && response_body.dup.force_encoding('UTF-8').valid_encoding?
107
- response_body.force_encoding('UTF-8')
108
- else
109
- # I'm not sure if there is a way in this situation to get JSON gem to dump the
110
- # string correctly. fall back to an array of codepoints I guess? this is a weird
111
- # solution but the best I've got for now.
112
- response_body = response_body.codepoints.to_a
113
- end
114
- end
115
- response_body
116
- end
117
-
118
- def text?(content_type)
119
- content_type_attrs = ContentTypeAttrs.new(content_type)
120
- media_type = content_type_attrs.media_type
121
- # ordered hash by priority mapping types to binary or text
122
- # regexps will have \A and \z added
123
- types = {
124
- %r(image/.*) => :binary,
125
- %r(audio/.*) => :binary,
126
- %r(video/.*) => :binary,
127
- %r(model/.*) => :binary,
128
- %r(text/.*) => :text,
129
- %r(message/.*) => :text,
130
- 'application/octet-stream' => :binary,
131
- 'application/ogg' => :binary,
132
- 'application/pdf' => :binary,
133
- 'application/postscript' => :binary,
134
- 'application/zip' => :binary,
135
- 'application/gzip' => :binary,
136
- }
137
- types.each do |match, type|
138
- matched = match.is_a?(Regexp) ? media_type =~ %r(\A#{match.source}\z) : media_type == match
139
- if matched
140
- return type == :text
141
- end
142
- end
143
- # fallback (unknown or not given) assume text
144
- return true
145
- end
146
-
147
93
  def call(request_env)
148
94
  began_at = Time.now
149
95
 
@@ -155,6 +101,8 @@ module ApiHammer
155
101
  @app.call(request_env).on_complete do |response_env|
156
102
  now = Time.now
157
103
 
104
+ request = ApiHammer::Faraday::Request.new(request_env, response_env)
105
+
158
106
  status_color = case response_env.status.to_i
159
107
  when 200..299
160
108
  :intense_green
@@ -166,18 +114,32 @@ module ApiHammer
166
114
  :white
167
115
  end
168
116
  status_s = bold(send(status_color, response_env.status.to_s))
117
+
118
+ filtered_request_body = request_body.dup if request_body
119
+ filtered_response_body = request.response_body.dup if request.response_body
120
+
121
+ if @options[:filter_keys]
122
+ body_info = [['request', filtered_request_body, request_env.request_headers], ['response', filtered_response_body, response_env.response_headers]]
123
+ body_info.map do |(role, body, headers)|
124
+ if body
125
+ parsed_body = ApiHammer::ParsedBody.new(body, headers['Content-Type'])
126
+ body.replace(parsed_body.filtered_body(@options.slice(:filter_keys)))
127
+ end
128
+ end
129
+ end
130
+
169
131
  data = {
170
132
  'request_role' => 'client',
171
133
  'request' => {
172
134
  'method' => request_env[:method],
173
135
  'uri' => request_env[:url].normalize.to_s,
174
136
  'headers' => request_env.request_headers,
175
- 'body' => (request_body if text?(request_env.request_headers['Content-Type'])),
137
+ 'body' => (filtered_request_body if ContentTypeAttrs.new(request_env.request_headers['Content-Type']).text?),
176
138
  }.reject{|k,v| v.nil? },
177
139
  'response' => {
178
140
  'status' => response_env.status.to_s,
179
141
  'headers' => response_env.response_headers,
180
- 'body' => (response_body(response_env) if text?(response_env.response_headers['Content-Type'])),
142
+ 'body' => (filtered_response_body if ContentTypeAttrs.new(response_env.response_headers['Content-Type']).text?),
181
143
  }.reject{|k,v| v.nil? },
182
144
  'processing' => {
183
145
  'began_at' => began_at.utc.to_f,
@@ -186,7 +148,7 @@ module ApiHammer
186
148
  }.reject{|k,v| v.nil? },
187
149
  }
188
150
 
189
- json_data = JSON.dump(data)
151
+ json_data = JSON.generate(data)
190
152
  dolog = proc do
191
153
  now_s = now.strftime('%Y-%m-%d %H:%M:%S %Z')
192
154
  @logger.info "#{bold(intense_magenta('>'))} #{status_s} : #{bold(intense_magenta(request_env[:method].to_s.upcase))} #{intense_magenta(request_env[:url].normalize.to_s)} @ #{intense_magenta(now_s)}"
@@ -0,0 +1,33 @@
1
+ require 'strscan'
2
+
3
+ module ApiHammer
4
+ module Filtration
5
+ class FormEncoded
6
+ def initialize(string, options = {})
7
+ @string = string
8
+ @options = options
9
+ end
10
+
11
+ def filter
12
+ ss = StringScanner.new(@string)
13
+ filtered = ''
14
+ while !ss.eos?
15
+ if ss.scan(/[&;]/)
16
+ filtered << ss[0]
17
+ end
18
+ if ss.scan(/[^&;]+/)
19
+ kv = ss[0]
20
+ key, value = kv.split('=', 2)
21
+ parsed_key = CGI::unescape(key)
22
+ if [*@options[:filter_keys]].any? { |fk| parsed_key =~ /(\A|[\[\]])#{Regexp.escape(fk)}(\z|[\[\]])/ }
23
+ filtered << [key, '[FILTERED]'].join('=')
24
+ else
25
+ filtered << ss[0]
26
+ end
27
+ end
28
+ end
29
+ filtered
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,137 @@
1
+ require 'json'
2
+ require 'json/pure/parser'
3
+
4
+ module ApiHammer
5
+ module Filtration
6
+ class ParserError < JSON::ParserError; end
7
+
8
+ # This class implements the JSON filterer that is used to filter a JSON string
9
+ class Json < JSON::Pure::Parser
10
+ # Creates a new instance for the string _source_.
11
+ def initialize(source, opts = {})
12
+ super source
13
+ @options = opts
14
+ end
15
+
16
+ def filter
17
+ reset
18
+ obj = ''
19
+ while !eos? && scan_result(obj, IGNORE)
20
+ end
21
+ if eos?
22
+ raise ParserError, "source did not contain any JSON!"
23
+ else
24
+ value = filter_value
25
+ if value == UNPARSED
26
+ raise ParserError, "source did not contain any JSON!"
27
+ else
28
+ obj << value
29
+ end
30
+ end
31
+ obj
32
+ end
33
+
34
+ private
35
+
36
+ def filter_string
37
+ if scan(STRING)
38
+ self[0]
39
+ else
40
+ UNPARSED
41
+ end
42
+ end
43
+
44
+ def filter_value
45
+ simple = [FLOAT, INTEGER, TRUE, FALSE, NULL] + (@allow_nan ? [NAN, INFINITY, MINUS_INFINITY] : [])
46
+ if simple.any? { |type| scan(type) }
47
+ self[0]
48
+ elsif (string = filter_string) != UNPARSED
49
+ string
50
+ elsif scan(ARRAY_OPEN)
51
+ self[0] + filter_array
52
+ elsif scan(OBJECT_OPEN)
53
+ self[0] + filter_object
54
+ else
55
+ UNPARSED
56
+ end
57
+ end
58
+
59
+ def filter_array
60
+ result = ''
61
+ delim = false
62
+ until eos?
63
+ if (value = filter_value) != UNPARSED
64
+ delim = false
65
+ result << value
66
+ scan_result(result, IGNORE)
67
+ if scan_result(result, COLLECTION_DELIMITER)
68
+ delim = true
69
+ elsif !match?(ARRAY_CLOSE)
70
+ raise ParserError, "expected ',' or ']' in array at '#{peek(20)}'!"
71
+ end
72
+ elsif scan_result(result, ARRAY_CLOSE)
73
+ if delim
74
+ raise ParserError, "expected next element in array at '#{peek(20)}'!"
75
+ end
76
+ break
77
+ elsif scan_result(result, IGNORE)
78
+ #
79
+ else
80
+ raise ParserError, "unexpected token in array at '#{peek(20)}'!"
81
+ end
82
+ end
83
+ result
84
+ end
85
+
86
+ FILTERED_JSON = JSON.generate("[FILTERED]", :quirks_mode => true)
87
+
88
+ def filter_object
89
+ result = ''
90
+ delim = false
91
+ until eos?
92
+ if (string = filter_string) != UNPARSED
93
+ parsed_key = JSON.parse(string, :quirks_mode => true)
94
+ result << string
95
+ scan_result(result, IGNORE)
96
+ unless scan_result(result, PAIR_DELIMITER)
97
+ raise ParserError, "expected ':' in object at '#{peek(20)}'!"
98
+ end
99
+ scan_result(result, IGNORE)
100
+ unless (value = filter_value).equal? UNPARSED
101
+ if [*@options[:filter_keys]].include?(parsed_key)
102
+ result << FILTERED_JSON
103
+ else
104
+ result << value
105
+ end
106
+ delim = false
107
+ scan_result(result, IGNORE)
108
+ if scan_result(result, COLLECTION_DELIMITER)
109
+ delim = true
110
+ elsif !match?(OBJECT_CLOSE)
111
+ raise ParserError, "expected ',' or '}' in object at '#{peek(20)}'!"
112
+ end
113
+ else
114
+ raise ParserError, "expected value in object at '#{peek(20)}'!"
115
+ end
116
+ elsif scan_result(result, OBJECT_CLOSE)
117
+ if delim
118
+ raise ParserError, "expected next name, value pair in object at '#{peek(20)}'!"
119
+ end
120
+ break
121
+ elsif scan_result(result, IGNORE)
122
+ #
123
+ else
124
+ raise ParserError, "unexpected token in object at '#{peek(20)}'!"
125
+ end
126
+ end
127
+ result
128
+ end
129
+
130
+ def scan_result(result, match)
131
+ if scan(match)
132
+ result << self[0]
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,39 @@
1
+ require 'rack'
2
+
3
+ module ApiHammer
4
+ class ParsedBody
5
+ attr_reader :body, :content_type, :media_type
6
+
7
+ def initialize(body, content_type)
8
+ @body = body
9
+ @content_type = content_type
10
+ @media_type = ::Rack::Request.new({'CONTENT_TYPE' => content_type}).media_type
11
+ end
12
+
13
+ def object
14
+ instance_variable_defined?(:@object) ? @object : @object = begin
15
+ if media_type == 'application/json'
16
+ JSON.parse(body) rescue nil
17
+ elsif media_type == 'application/x-www-form-urlencoded'
18
+ CGI.parse(body).map { |k, vs| {k => vs.last} }.inject({}, &:update)
19
+ end
20
+ end
21
+ end
22
+
23
+ def filtered_body(options)
24
+ @filtered_body ||= begin
25
+ if media_type == 'application/json'
26
+ begin
27
+ ApiHammer::Filtration::Json.new(body, options).filter
28
+ rescue JSON::ParserError
29
+ body
30
+ end
31
+ elsif media_type == 'application/x-www-form-urlencoded'
32
+ ApiHammer::Filtration::FormEncoded.new(body, options).filter
33
+ else
34
+ body
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -51,7 +51,8 @@ module ApiHammer
51
51
 
52
52
  ApiHammer::RequestLogSubscriber.attach_to :action_controller
53
53
 
54
- app.config.middleware.insert_after(::Rails::Rack::Logger, ApiHammer::RequestLogger, ::Rails.logger)
54
+ options = app.config.respond_to?(:api_hammer_request_logging_options) ? app.config.api_hammer_request_logging_options : {}
55
+ app.config.middleware.insert_after(::Rails::Rack::Logger, ApiHammer::RequestLogger, ::Rails.logger, options)
55
56
 
56
57
  ActionController::Base.send(:include, AddRequestToPayload)
57
58
  end
@@ -17,6 +17,14 @@ module ApiHammer
17
17
 
18
18
  LARGE_BODY_SIZE = 4096
19
19
 
20
+ # options
21
+ # - :logger
22
+ # - :filter_keys
23
+ def initialize(app, logger, options={})
24
+ @options = options
25
+ super(app, logger)
26
+ end
27
+
20
28
  def call(env)
21
29
  began_at = Time.now
22
30
 
@@ -102,22 +110,21 @@ module ApiHammer
102
110
  response_body_string = response_body.to_enum.to_a.join('')
103
111
  body_info = [['request', request_body, request.content_type], ['response', response_body_string, response.content_type]]
104
112
  body_info.map do |(role, body, content_type)|
113
+ parsed_body = ApiHammer::ParsedBody.new(body, content_type)
105
114
  if (400..599).include?(status.to_i) || body.size < LARGE_BODY_SIZE
106
115
  # log bodies if they are not large, or if there was an error (either client or server)
107
- data[role]['body'] = body
116
+ data[role]['body'] = parsed_body.filtered_body(@options.reject { |k,v| ![:filter_keys].include?(k) })
108
117
  else
109
118
  # otherwise, log id and uuid fields
110
- media_type = ::Rack::Request.new({'CONTENT_TYPE' => content_type}).media_type
111
- body_object = begin
112
- if media_type == 'application/json'
113
- JSON.parse(body) rescue nil
114
- elsif media_type == 'application/x-www-form-urlencoded'
115
- CGI.parse(body).map { |k, vs| {k => vs.last} }.inject({}, &:update)
116
- end
119
+ body_object = parsed_body.object
120
+ sep = /(?:\b|\W|_)/
121
+ hash_ids = proc do |hash|
122
+ hash.reject { |key, value| !(key =~ /#{sep}([ug]u)?id#{sep}/ && value.is_a?(String)) }
117
123
  end
118
124
  if body_object.is_a?(Hash)
119
- sep = /(?:\b|\W|_)/
120
- body_ids = body_object.reject { |key, value| !(key =~ /#{sep}([ug]u)?id#{sep}/ && value.is_a?(String)) }
125
+ body_ids = hash_ids.call(body_object)
126
+ elsif body_object.is_a?(Array) && body_object.all? { |e| e.is_a?(Hash) }
127
+ body_ids = body_object.map(&hash_ids)
121
128
  end
122
129
 
123
130
  data[role]['body_ids'] = body_ids if body_ids && body_ids.any?
@@ -1,3 +1,3 @@
1
1
  module ApiHammer
2
- VERSION = "0.8.1"
2
+ VERSION = "0.9.2"
3
3
  end
data/lib/api_hammer.rb CHANGED
@@ -10,9 +10,15 @@ module ApiHammer
10
10
  autoload :RailsOrSidekiqLogger, 'api_hammer/rails_or_sidekiq_logger'
11
11
  autoload :FaradayOutputter, 'api_hammer/faraday/outputter'
12
12
  autoload :FaradayCurlVOutputter, 'api_hammer/faraday/outputter'
13
+ autoload :ParsedBody, 'api_hammer/parsed_body'
14
+ autoload :ContentTypeAttrs, 'api_hammer/content_type_attrs'
13
15
  module Faraday
14
16
  autoload :RequestLogger, 'api_hammer/faraday/request_logger'
15
17
  end
18
+ module Filtration
19
+ autoload :Json, 'api_hammer/filtration/json'
20
+ autoload :FormEncoded, 'api_hammer/filtration/form_encoded'
21
+ end
16
22
  end
17
23
 
18
24
  require 'faraday'
@@ -16,6 +16,7 @@ class LogStash::Filters::ApiHammerRequest < LogStash::Filters::Base
16
16
  def filter(event)
17
17
  # discard the request status line for humans - always followed by json which we'll parse
18
18
  col = /[\e\[\dm]*/.source
19
+ # begin direction status method path time end
19
20
  human_request = [/\A/, /[<>]/, /\s/, /\d+/, / : /, /\w+/, / /, /[^\e]+/, / @ /, /[^\e]+/, /\z/].map(&:source).join(col)
20
21
  event.cancel if event[@source] =~ /#{human_request}/
21
22
 
@@ -0,0 +1,45 @@
1
+ require "logstash/filters/base"
2
+ require "logstash/namespace"
3
+ require 'oauthenticator'
4
+
5
+ class LogStash::Filters::OAuthenticator < LogStash::Filters::Base
6
+ config_name "oauthenticator"
7
+ milestone 1
8
+
9
+ config :consume, :validate => :boolean, :default => true
10
+ config :source, :validate => :string, :default => 'message'
11
+
12
+ public
13
+ def register
14
+ end
15
+
16
+ public
17
+ def filter(event)
18
+ #OAuthenticator authenticated an authentic request with Authorization: OAuth realm="", oauth_consumer_key="ios-production-lFo4Zqgs", oauth_token="aE7wU1VPPa7G2l2VLtVRalgIOM4zKJUu7BMnQZoH", oauth_signature_method="HMAC-SHA1", oauth_version="1.0", oauth_nonce="34DA75CB-7653-4AF5-A3F8-B0989AABFCDF", oauth_timestamp="1411935761", oauth_signature="H%2F0kt3aSPqdo2qgfRrbPPirR%2F4g%3D"
19
+ match = event[@source].match(/\A(OAuthenticator authenticated an authentic request) with Authorization: /)
20
+ if match
21
+ authorization = match.post_match
22
+
23
+ begin
24
+ event['oauth'] = OAuthenticator.parse_authorization(authorization)
25
+ rescue OAuthenticator::Error => parse_exception
26
+ nil
27
+ end
28
+
29
+ event[@source] = match[1] if @consume
30
+ end
31
+
32
+ # parse the authorization of a request filtered by LogStash::Filters::ApiHammerRequest
33
+ if event['request'].is_a?(Hash) && event['request']['headers'].is_a?(Hash)
34
+ authorization = event['request']['headers'].inject(nil) { |a, (k,v)| k.is_a?(String) && k.downcase == 'authorization' ? v : a }
35
+ if authorization.is_a?(String)
36
+ begin
37
+ event['request']['oauth'] = OAuthenticator.parse_authorization(authorization)
38
+ rescue OAuthenticator::Error => parse_exception
39
+ # if it is not oauth or badly formed oauth we don't care
40
+ nil
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -3,6 +3,8 @@ require "logstash/namespace"
3
3
  require 'rack'
4
4
  require 'cgi'
5
5
  require 'json'
6
+ require 'api_hammer/parsed_body'
7
+
6
8
  class LogStash::Filters::RequestBodiesParsed < LogStash::Filters::Base
7
9
  config_name "request_bodies_parsed"
8
10
  milestone 1
@@ -19,15 +21,8 @@ class LogStash::Filters::RequestBodiesParsed < LogStash::Filters::Base
19
21
  if event[re]['headers'].is_a?(Hash) && !content_type
20
22
  _, content_type = event[re]['headers'].detect { |(k,_)| k =~ /\Acontent.type\z/i }
21
23
  end
22
- media_type = ::Rack::Request.new({'CONTENT_TYPE' => content_type}).media_type
23
- body_parsed = begin
24
- if media_type == 'application/json'
25
- JSON.parse(event[re]['body']) rescue nil
26
- elsif media_type == 'application/x-www-form-urlencoded'
27
- CGI.parse(event[re]['body']).map { |k, vs| {k => vs.last} }.inject({}, &:update)
28
- end
29
- end
30
- event[re]['body_parsed'] = body_parsed if body_parsed
24
+ parsed_body = ApiHammer::ParsedBody.new(event[re]['body'], content_type)
25
+ event[re]['body_parsed'] = parsed_body.object if parsed_body.object
31
26
  end
32
27
  end
33
28
  end
@@ -103,7 +103,8 @@ describe ApiHammer::RequestLogger do
103
103
  conn.get '/'
104
104
  assert_match('[120,120,195]', logio.string)
105
105
  end
106
-
106
+ end
107
+ describe 'logging body by content-type' do
107
108
  {
108
109
  'application/octet-stream' => false,
109
110
  'image/png' => false,
@@ -124,4 +125,90 @@ describe ApiHammer::RequestLogger do
124
125
  end
125
126
  end
126
127
  end
128
+
129
+ describe 'filtering' do
130
+ describe 'json response' do
131
+ it 'filters' do
132
+ app = proc { |env| [200, {'Content-Type' => 'application/json'}, ['{"pin": "foobar", "bar": "baz"}']] }
133
+ conn = Faraday.new do |f|
134
+ f.request :api_hammer_request_logger, logger, :filter_keys => 'pin'
135
+ f.use Faraday::Adapter::Rack, app
136
+ end
137
+ conn.get '/'
138
+ assert_includes(logio.string, %q("body":"{\"pin\": \"[FILTERED]\", \"bar\": \"baz\"}"))
139
+ end
140
+ it 'filters nested' do
141
+ app = proc { |env| [200, {'Content-Type' => 'application/json'}, ['{"object": {"pin": "foobar"}, "bar": "baz"}']] }
142
+ conn = Faraday.new do |f|
143
+ f.request :api_hammer_request_logger, logger, :filter_keys => 'pin'
144
+ f.use Faraday::Adapter::Rack, app
145
+ end
146
+ conn.get '/'
147
+ assert_includes(logio.string, %q("body":"{\"object\": {\"pin\": \"[FILTERED]\"}, \"bar\": \"baz\"}"))
148
+ end
149
+ it 'filters in array' do
150
+ app = proc { |env| [200, {'Content-Type' => 'application/json'}, ['[{"object": [{"pin": ["foobar"]}], "bar": "baz"}]']] }
151
+ conn = Faraday.new do |f|
152
+ f.request :api_hammer_request_logger, logger, :filter_keys => 'pin'
153
+ f.use Faraday::Adapter::Rack, app
154
+ end
155
+ conn.get '/'
156
+ assert_includes(logio.string, %q("body":"[{\"object\": [{\"pin\": \"[FILTERED]\"}], \"bar\": \"baz\"}]"))
157
+ end
158
+ end
159
+
160
+ describe 'json request' do
161
+ it 'filters a json request' do
162
+ app = proc { |env| [200, {}, []] }
163
+ conn = Faraday.new do |f|
164
+ f.request :api_hammer_request_logger, logger, :filter_keys => 'pin'
165
+ f.use Faraday::Adapter::Rack, app
166
+ end
167
+ conn.post '/', '[{"object": [{"pin": ["foobar"]}], "bar": "baz"}]', {'Content-Type' => 'application/json'}
168
+ assert_includes(logio.string, %q("body":"[{\"object\": [{\"pin\": \"[FILTERED]\"}], \"bar\": \"baz\"}]"))
169
+ end
170
+ end
171
+
172
+ describe 'form encoded response' do
173
+ it 'filters' do
174
+ app = proc { |env| [200, {'Content-Type' => 'application/x-www-form-urlencoded'}, ['pin=foobar&bar=baz']] }
175
+ conn = Faraday.new do |f|
176
+ f.request :api_hammer_request_logger, logger, :filter_keys => 'pin'
177
+ f.use Faraday::Adapter::Rack, app
178
+ end
179
+ conn.get '/'
180
+ assert_includes(logio.string, %q("body":"pin=[FILTERED]&bar=baz"))
181
+ end
182
+ it 'filters nested' do
183
+ app = proc { |env| [200, {'Content-Type' => 'application/x-www-form-urlencoded'}, ['object[pin]=foobar&bar=baz']] }
184
+ conn = Faraday.new do |f|
185
+ f.request :api_hammer_request_logger, logger, :filter_keys => 'pin'
186
+ f.use Faraday::Adapter::Rack, app
187
+ end
188
+ conn.get '/'
189
+ assert_includes(logio.string, %q("body":"object[pin]=[FILTERED]&bar=baz"))
190
+ end
191
+ it 'filters in array' do
192
+ app = proc { |env| [200, {'Content-Type' => 'application/x-www-form-urlencoded'}, ['object[][pin][]=foobar&bar=baz']] }
193
+ conn = Faraday.new do |f|
194
+ f.request :api_hammer_request_logger, logger, :filter_keys => 'pin'
195
+ f.use Faraday::Adapter::Rack, app
196
+ end
197
+ conn.get '/'
198
+ assert_includes(logio.string, %q("body":"object[][pin][]=[FILTERED]&bar=baz"))
199
+ end
200
+ end
201
+
202
+ describe 'form encoded request' do
203
+ it 'filters a json request' do
204
+ app = proc { |env| [200, {}, []] }
205
+ conn = Faraday.new do |f|
206
+ f.request :api_hammer_request_logger, logger, :filter_keys => 'pin'
207
+ f.use Faraday::Adapter::Rack, app
208
+ end
209
+ conn.post '/', 'object[pin]=foobar&bar=baz', {'Content-Type' => 'application/x-www-form-urlencoded'}
210
+ assert_includes(logio.string, %q(object[pin]=[FILTERED]&bar=baz))
211
+ end
212
+ end
213
+ end
127
214
  end
@@ -28,6 +28,13 @@ describe ApiHammer::RequestLogger do
28
28
  assert_match(%q("body_ids":{"uuid":"theuuid","foo_uuid":"thefoouuid","id":"theid","id_for_x":"theidforx","bar.id":"thebarid","baz-guid":"bazzz"}), logio.string)
29
29
  end
30
30
 
31
+ it 'logs id and uuid (json array)' do
32
+ body = %Q([{"uuid": "theuuid", "foo_uuid": "thefoouuid"}, {"id": "theid", "id_for_x": "theidforx"}, {"bar.id": "thebarid", "baz-guid": "bazzz", "bigthing": "#{' ' * 4096}"}])
33
+ app = ApiHammer::RequestLogger.new(proc { |env| [200, {"Content-Type" => 'application/json; charset=UTF8'}, [body]] }, logger)
34
+ app.call(Rack::MockRequest.env_for('/')).last.close
35
+ assert_match(%q("body_ids":[{"uuid":"theuuid","foo_uuid":"thefoouuid"},{"id":"theid","id_for_x":"theidforx"},{"bar.id":"thebarid","baz-guid":"bazzz"}]), logio.string)
36
+ end
37
+
31
38
  it 'logs id and uuid (form encoded)' do
32
39
  body = %Q(uuid=theuuid&foo_uuid=thefoouuid&id=theid&id_for_x=theidforx&bar.id=thebarid&baz-guid=bazzz&bigthing=#{' ' * 4096})
33
40
  app = ApiHammer::RequestLogger.new(proc { |env| [200, {"Content-Type" => 'application/x-www-form-urlencoded; charset=UTF8'}, [body]] }, logger)
@@ -48,4 +55,71 @@ describe ApiHammer::RequestLogger do
48
55
  assert_match(/"request":\{.*"body":"the_request_body/, logio.string)
49
56
  assert_match(/"response":\{.*"body":"the_response_body/, logio.string)
50
57
  end
58
+
59
+ describe 'filtering' do
60
+ describe 'json response' do
61
+ it 'filters' do
62
+ body = %Q({"pin": "foobar"})
63
+ app = proc { |env| [200, {"Content-Type" => 'application/json; charset=UTF8'}, [body]] }
64
+ app = ApiHammer::RequestLogger.new(app, logger, :filter_keys => 'pin')
65
+ app.call(Rack::MockRequest.env_for('/')).last.close
66
+
67
+ assert_includes(logio.string, %q("body":"{\"pin\": \"[FILTERED]\"}"))
68
+ end
69
+ it 'filters nested' do
70
+ body = %Q({"object": {"pin": "foobar"}})
71
+ app = proc { |env| [200, {"Content-Type" => 'application/json; charset=UTF8'}, [body]] }
72
+ app = ApiHammer::RequestLogger.new(app, logger, :filter_keys => 'pin')
73
+ app.call(Rack::MockRequest.env_for('/')).last.close
74
+
75
+ assert_includes(logio.string, %q("body":"{\"object\": {\"pin\": \"[FILTERED]\"}}"))
76
+ end
77
+ it 'filters in array' do
78
+ body = %Q([{"object": [{"pin": ["foobar"]}]}])
79
+ app = proc { |env| [200, {"Content-Type" => 'application/json; charset=UTF8'}, [body]] }
80
+ app = ApiHammer::RequestLogger.new(app, logger, :filter_keys => 'pin')
81
+ app.call(Rack::MockRequest.env_for('/')).last.close
82
+
83
+ assert_includes(logio.string, %q("body":"[{\"object\": [{\"pin\": \"[FILTERED]\"}]}]"))
84
+ end
85
+ end
86
+
87
+ describe 'json request' do
88
+ it 'filters a json request' do
89
+ app = ApiHammer::RequestLogger.new(proc { |env| [200, {}, []] }, logger, :filter_keys => 'pin')
90
+ app.call(Rack::MockRequest.env_for('/', :input => '[{"object": [{"pin": ["foobar"]}]}]', 'CONTENT_TYPE' => 'application/json')).last.close
91
+ assert_includes(logio.string, %q("body":"[{\"object\": [{\"pin\": \"[FILTERED]\"}]}]"))
92
+ end
93
+ end
94
+
95
+ describe('form encoded response') do
96
+ it 'filters' do
97
+ app = proc { |env| [200, {"Content-Type" => 'application/x-www-form-urlencoded'}, ['pin=foobar']] }
98
+ app = ApiHammer::RequestLogger.new(app, logger, :filter_keys => 'pin')
99
+ app.call(Rack::MockRequest.env_for('/')).last.close
100
+ assert_includes(logio.string, %q("body":"pin=[FILTERED]"))
101
+ end
102
+ it 'filters nested' do
103
+ app = proc { |env| [200, {'Content-Type' => 'application/x-www-form-urlencoded'}, ['object[pin]=foobar']] }
104
+ app = ApiHammer::RequestLogger.new(app, logger, :filter_keys => 'pin')
105
+ app.call(Rack::MockRequest.env_for('/')).last.close
106
+ assert_includes(logio.string, %q("body":"object[pin]=[FILTERED]"))
107
+ end
108
+ it 'filters in array' do
109
+ app = proc { |env| [200, {'Content-Type' => 'application/x-www-form-urlencoded'}, ['object[][pin][]=foobar']] }
110
+ app = ApiHammer::RequestLogger.new(app, logger, :filter_keys => 'pin')
111
+ app.call(Rack::MockRequest.env_for('/')).last.close
112
+ assert_includes(logio.string, %q("body":"object[][pin][]=[FILTERED]"))
113
+ end
114
+ end
115
+
116
+ describe 'form encoded request' do
117
+ it 'filters a json request' do
118
+ app = proc { |env| [200, {}, []] }
119
+ app = ApiHammer::RequestLogger.new(app, logger, :filter_keys => 'pin')
120
+ app.call(Rack::MockRequest.env_for('/', :input => 'object[pin]=foobar', 'CONTENT_TYPE' => 'application/x-www-form-urlencoded')).last.close
121
+ assert_includes(logio.string, %q(object[pin]=[FILTERED]))
122
+ end
123
+ end
124
+ end
51
125
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: api_hammer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.1
4
+ version: 0.9.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ethan
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2014-12-19 00:00:00.000000000 Z
11
+ date: 2015-01-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack
@@ -239,9 +239,13 @@ files:
239
239
  - lib/api_hammer.rb
240
240
  - lib/api_hammer/active_record_cache_find_by.rb
241
241
  - lib/api_hammer/check_required_params.rb
242
+ - lib/api_hammer/content_type_attrs.rb
242
243
  - lib/api_hammer/faraday/outputter.rb
243
244
  - lib/api_hammer/faraday/request_logger.rb
245
+ - lib/api_hammer/filtration/form_encoded.rb
246
+ - lib/api_hammer/filtration/json.rb
244
247
  - lib/api_hammer/halt.rb
248
+ - lib/api_hammer/parsed_body.rb
245
249
  - lib/api_hammer/public_instance_exec.rb
246
250
  - lib/api_hammer/rails.rb
247
251
  - lib/api_hammer/rails_or_sidekiq_logger.rb
@@ -257,6 +261,7 @@ files:
257
261
  - lib/api_hammer/weblink.rb
258
262
  - lib/logstash/filters/active_support_tags.rb
259
263
  - lib/logstash/filters/api_hammer_request.rb
264
+ - lib/logstash/filters/oauthenticator.rb
260
265
  - lib/logstash/filters/request_bodies_parsed.rb
261
266
  - lib/logstash/filters/ruby_logger.rb
262
267
  - lib/logstash/filters/sidekiq.rb