api_hammer 0.8.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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