sitegoose 0.3.3

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.
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module SiteGoose
6
+ # Rails: the middleware first in the stack, the options from (lowest
7
+ # first) the environment, credentials (sitegoose:), the Rails
8
+ # config (config.sitegoose) and SiteGoose.configure in an
9
+ # initializer. Then, on their own: Rails.error reports (Rails.error.handle,
10
+ # .record, .report: the errors your code handles), Rails.logger with
11
+ # errors: {capture_logging: true}, Devise's failed logins (bot protection)
12
+ # and its logins and logouts (audit log), and the controller, mailer and job
13
+ # steps before an error (its breadcrumbs).
14
+ class Railtie < ::Rails::Railtie
15
+ config.sitegoose = ActiveSupport::OrderedOptions.new
16
+
17
+ initializer "sitegoose.middleware" do |app|
18
+ app.config.middleware.insert_before 0, SiteGoose::Middleware
19
+ end
20
+
21
+ config.after_initialize do |app|
22
+ SiteGoose::RailsSetup.run(app)
23
+ end
24
+ end
25
+
26
+ module RailsSetup
27
+ module_function
28
+
29
+ def run(app)
30
+ credentials = begin
31
+ given = app.credentials.sitegoose
32
+ given.respond_to?(:to_h) ? given.to_h : {}
33
+ rescue StandardError
34
+ {}
35
+ end
36
+ from_config = app.config.sitegoose.respond_to?(:to_h) ? app.config.sitegoose.to_h : {}
37
+ SiteGoose.defaults(**SiteGoose.symbolize(credentials).merge(SiteGoose.symbolize(from_config)))
38
+ subscribe_errors
39
+ SiteGoose.when_made { |made| hook_logger(made) }
40
+ subscribe_steps
41
+ hook_warden
42
+ end
43
+
44
+ def subscribe_errors
45
+ return unless ::Rails.respond_to?(:error) && ::Rails.error.respond_to?(:subscribe)
46
+
47
+ ::Rails.error.subscribe(ErrorSubscriber.new)
48
+ end
49
+
50
+ def hook_logger(made)
51
+ return unless made.errors&.capture_logging && ::Rails.logger
52
+
53
+ made.capture_logger(::Rails.logger)
54
+ end
55
+
56
+ # Public notifications only: which controller action ran, which mail
57
+ # went out, which job was queued. Never their contents.
58
+ def subscribe_steps
59
+ return unless defined?(::ActiveSupport::Notifications)
60
+
61
+ ::ActiveSupport::Notifications.subscribe("start_processing.action_controller") do |*, payload|
62
+ Context.add_breadcrumb("#{payload[:controller]}##{payload[:action]}") if payload.is_a?(Hash)
63
+ end
64
+ ::ActiveSupport::Notifications.subscribe("deliver.action_mailer") do |*, payload|
65
+ Context.add_breadcrumb("Mail sent: #{payload[:mailer]}") if payload.is_a?(Hash) && payload[:mailer]
66
+ end
67
+ ::ActiveSupport::Notifications.subscribe("enqueue.active_job") do |*, payload|
68
+ job = payload.is_a?(Hash) ? payload[:job] : nil
69
+ Context.add_breadcrumb("Job queued: #{job.class.name}") if job
70
+ end
71
+ rescue StandardError
72
+ nil
73
+ end
74
+
75
+ # Devise (Warden): a failed sign-in counts for bot protection, as
76
+ # Django's do in the Python package; sign-ins and sign-outs go in the
77
+ # audit log.
78
+ def hook_warden
79
+ return unless defined?(::Warden::Manager)
80
+
81
+ ::Warden::Manager.before_failure do |env, opts|
82
+ if %i[invalid not_found_in_database].include?(opts.is_a?(Hash) ? opts[:message] : nil)
83
+ SiteGoose.platform&.login_failed(env)
84
+ end
85
+ rescue StandardError
86
+ nil
87
+ end
88
+ ::Warden::Manager.after_authentication do |user, auth, _opts|
89
+ SiteGoose.platform&.audit("user.login", actor: RailsSetup.actor(user), request: auth.env)
90
+ rescue StandardError
91
+ nil
92
+ end
93
+ ::Warden::Manager.before_logout do |user, auth, _opts|
94
+ SiteGoose.platform&.audit("user.logout", actor: RailsSetup.actor(user), request: auth.env) if user
95
+ rescue StandardError
96
+ nil
97
+ end
98
+ end
99
+
100
+ def actor(user)
101
+ %i[email username login name].each do |field|
102
+ value = user.respond_to?(field) ? user.public_send(field) : nil
103
+ return value.to_s unless value.nil? || value.to_s.empty?
104
+ end
105
+ user.respond_to?(:id) ? user.id.to_s : ""
106
+ end
107
+ end
108
+
109
+ # Rails.error's subscriber: what your code handles (Rails.error.handle,
110
+ # .record, .report) is sent. An unhandled error Rails reports from inside a
111
+ # request or job is left to the middleware or the job integration, which
112
+ # know its status and whether it will be retried.
113
+ class ErrorSubscriber
114
+ def report(error, handled: true, severity: :error, context: {}, source: nil, **_rest)
115
+ return if severity.to_s == "info" || !error.is_a?(Exception)
116
+ return if error.instance_variable_defined?(:@__sitegoose_skip)
117
+ return if !handled && Context.request
118
+
119
+ SiteGoose.platform&.capture_exception(error, kind: handled ? "handled" : "error")
120
+ rescue StandardError
121
+ nil
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,390 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Request rules (virtual patching; protocol in sdk/protocols/firewall.md):
4
+ # the firewall has always matched addresses, these match the request -- its
5
+ # method, path, query string and user agent -- so an attack is turned away
6
+ # before the vulnerable code runs. They arrive with the other rules and are
7
+ # compiled once per fetch; matching is plain string work on text we already
8
+ # have, so it costs nothing per request.
9
+ #
10
+ # The matching is the same in every package: the shared cases are in
11
+ # sdk/protocols/request-rules-cases.json, run by test/request_rules_test.rb.
12
+ #
13
+ # Ruby's ^ and $ are line anchors, unlike JavaScript's and Python's, so a
14
+ # pattern's anchors are rewritten to \A and \z before it is compiled: a
15
+ # decoded %0a in a path must not end the "line" a rule anchors to.
16
+
17
+ module SiteGoose
18
+ module RequestRules
19
+ MAX_RULES = 200
20
+ # How much of each part a rule may look at. Patterns come from our rules,
21
+ # but the text comes from whoever is asking: a bounded slice keeps a bad
22
+ # pattern from being a hung request (and Ruby 3.2+ also times it out).
23
+ MAX_TEXT = 2000
24
+ MAX_REGEX = 200
25
+ SECTIONS = %w[path query ua].freeze
26
+ MATCHERS = %w[exact prefix suffix contains].freeze
27
+ REGEX_TIMEOUT = 0.05
28
+
29
+ module_function
30
+
31
+ # A pattern can only come from us, but a careless one would be matched
32
+ # against text an attacker chooses. So the client checks the shape itself
33
+ # instead of trusting what it was handed: a repeated group that holds a
34
+ # quantifier or an alternation ("(a+)+", "(a|ab)*"), backreferences and
35
+ # lookbehind are refused, and so is more than one free repeat. The same
36
+ # check as backend/services/firewall/requestRules.js.
37
+ #
38
+ # Group syntax is a list of what is allowed, not of what isn't: only
39
+ # "(?:", "(?=" and "(?!". Ruby (Onigmo) has more than JavaScript and
40
+ # Python -- subexpression calls (\g<1>, \g'x'), named groups and
41
+ # references written with quotes ((?'x'...), \k'x'), absence operators
42
+ # ((?~...)), conditionals ((?(1)...)), inline options -- and a call
43
+ # repeated against an alternation took 51 seconds in a test.
44
+ GROUPS = [":", "=", "!"].freeze
45
+ # Escapes that refer to a group: \1-\9, \g (call), \k (named reference);
46
+ # and \N, which isn't in the shared subset either.
47
+ REFERENCE = /\\(?:[1-9]|[gkN])/.freeze
48
+
49
+ def safe_pattern?(pattern)
50
+ return false unless pattern.is_a?(String) && !pattern.empty? && pattern.size <= MAX_REGEX
51
+
52
+ open = []
53
+ i = 0
54
+ n = pattern.size
55
+ while i < n
56
+ char = pattern[i]
57
+ if char == "\\"
58
+ return false if pattern[i, 2].match?(REFERENCE)
59
+
60
+ i += 2
61
+ next
62
+ end
63
+ if char == "["
64
+ # A character class: skipped whole, quantifiers inside it are literals.
65
+ i += pattern[i] == "\\" ? 2 : 1 while i < n && pattern[i] != "]"
66
+ i += 1
67
+ next
68
+ end
69
+ if char == "("
70
+ open << false
71
+ # "(?:", "(?=", "(?!": that ? names the kind of group, it repeats nothing.
72
+ # "(*": a PCRE verb ("(*FAIL)"), never in the shared subset.
73
+ return false if pattern[i + 1] == "*"
74
+
75
+ if pattern[i + 1] == "?"
76
+ return false unless GROUPS.include?(pattern[i + 2])
77
+
78
+ i += 2
79
+ end
80
+ elsif "*+?".include?(char) && pattern[i + 1] == "+" && i.positive? && pattern[i - 1] != "("
81
+ return false # possessive: a++, a*+, a?+
82
+ elsif char == "}" && pattern[i + 1] == "+" && pattern[0, i].match?(/\{\d*(,\d*)?\z/)
83
+ return false # possessive: a{2,}+
84
+ elsif char == ")"
85
+ return false if open.empty?
86
+
87
+ ambiguous = open.pop
88
+ following = pattern[i + 1]
89
+ return false if ambiguous && ["*", "+", "{"].include?(following)
90
+
91
+ open[-1] = true if ambiguous && !open.empty?
92
+ elsif !open.empty? && "|*+?{".include?(char)
93
+ open[-1] = true
94
+ end
95
+ i += 1
96
+ end
97
+ open.empty? && free_repeats(pattern) <= 1
98
+ end
99
+
100
+ def literal_at(pattern, i)
101
+ return nil if i >= pattern.size
102
+
103
+ char = pattern[i]
104
+ if char == "\\"
105
+ following = pattern[i + 1]
106
+ return following && !following.match?(/[a-z0-9]/i) ? following : nil
107
+ end
108
+ ".[](){}*+?|^$".include?(char) ? nil : char
109
+ end
110
+
111
+ # Two repeats that can both take the same text multiply each other's work
112
+ # (".*=.*=.*x"). A repeat is pinned when the character straight after it is
113
+ # one it can't match ("[^/]+/"); any other is free, and a pattern may hold
114
+ # one free repeat.
115
+ def free_repeats(pattern)
116
+ free = 0
117
+ i = 0
118
+ n = pattern.size
119
+ while i < n
120
+ start = i
121
+ char = pattern[i]
122
+ if char == "\\"
123
+ i += 2
124
+ elsif char == "["
125
+ i += 1
126
+ i += pattern[i] == "\\" ? 2 : 1 while i < n && pattern[i] != "]"
127
+ i += 1
128
+ elsif char == "("
129
+ i += pattern[i + 1] == "?" ? 3 : 1
130
+ next
131
+ elsif "|^$".include?(char)
132
+ i += 1
133
+ next
134
+ else
135
+ i += 1
136
+ end
137
+ # A group's text can't be tried against one character.
138
+ atom = char == ")" ? nil : pattern[start...i]
139
+ quantifier = pattern[i]
140
+ repeats = ["*", "+"].include?(quantifier)
141
+ if quantifier == "{"
142
+ bounds = /\A\{(\d*)(,(\d*))?\}/.match(pattern[i..])
143
+ next unless bounds
144
+
145
+ # "{3}" is one length, not a choice; "{2,}" and "{2,9}" are choices.
146
+ repeats = !bounds[2].nil? && bounds[3] != bounds[1]
147
+ i += bounds[0].size - 1
148
+ elsif quantifier != "?" && !repeats
149
+ next
150
+ end
151
+ i += 1
152
+ i += 1 if pattern[i] == "?"
153
+ next unless repeats
154
+
155
+ following = literal_at(pattern, i)
156
+ pinned = false
157
+ if atom && following
158
+ begin
159
+ pinned = !Regexp.new("\\A(?:#{atom})\\z", Regexp::IGNORECASE).match?(following)
160
+ rescue RegexpError
161
+ # Not an atom on its own: free.
162
+ pinned = false
163
+ end
164
+ end
165
+ free += 1 unless pinned
166
+ end
167
+ free
168
+ end
169
+
170
+ # ^ and $ outside character classes as \A and \z: what they mean in
171
+ # JavaScript and Python (without a multiline flag).
172
+ def ruby_anchors(pattern)
173
+ out = +""
174
+ i = 0
175
+ in_class = false
176
+ while i < pattern.size
177
+ char = pattern[i]
178
+ if char == "\\"
179
+ out << pattern[i, 2]
180
+ i += 2
181
+ next
182
+ end
183
+ if in_class
184
+ in_class = false if char == "]"
185
+ out << char
186
+ elsif char == "["
187
+ in_class = true
188
+ out << char
189
+ # A "]" straight after "[" or "[^" is a literal.
190
+ if pattern[i + 1] == "^"
191
+ out << "^"
192
+ i += 1
193
+ end
194
+ if pattern[i + 1] == "]"
195
+ out << "]"
196
+ i += 1
197
+ end
198
+ elsif char == "^"
199
+ out << "\\A"
200
+ elsif char == "$"
201
+ out << "\\z"
202
+ else
203
+ out << char
204
+ end
205
+ i += 1
206
+ end
207
+ out
208
+ end
209
+
210
+ # Ruby before 3.2 can't stop a match that runs long, so there a rule's
211
+ # regex is left out (its other parts still match): failing open, said
212
+ # once. Our shape check refuses what we know to be slow, but a timeout
213
+ # is what makes that safe to rely on.
214
+ @regex_allowed = Regexp.respond_to?(:timeout)
215
+ @warned = false
216
+
217
+ class << self
218
+ attr_accessor :regex_allowed
219
+ end
220
+
221
+ def regex_skipped
222
+ return if @warned
223
+
224
+ @warned = true
225
+ warn "[analytics-platform] request rules: Ruby #{RUBY_VERSION} can't time out a regex, so patterns are skipped (their other parts still match); Ruby 3.2+ matches them"
226
+ end
227
+
228
+ def compile_regex(pattern)
229
+ source = ruby_anchors(pattern)
230
+ # Ruby before 3.2 has no per-pattern timeout (and reads the keyword as
231
+ # a third positional argument), so ask which Ruby this is.
232
+ if Regexp.respond_to?(:timeout)
233
+ Regexp.new(source, Regexp::IGNORECASE, timeout: REGEX_TIMEOUT)
234
+ else
235
+ Regexp.new(source, Regexp::IGNORECASE)
236
+ end
237
+ end
238
+
239
+ def section(data)
240
+ return nil unless data.is_a?(Hash)
241
+
242
+ out = {}
243
+ MATCHERS.each do |matcher|
244
+ entries = data[matcher]
245
+ next unless entries.is_a?(Array)
246
+
247
+ values = entries.select { |e| e.is_a?(String) && (matcher == "exact" || !e.empty?) }.map(&:downcase)
248
+ out[matcher] = values unless values.empty?
249
+ end
250
+ pattern = data["regex"]
251
+ if pattern && !RequestRules.regex_allowed
252
+ regex_skipped
253
+ elsif safe_pattern?(pattern)
254
+ begin
255
+ out["regex"] = compile_regex(pattern)
256
+ rescue RegexpError
257
+ # A pattern we can't compile is dropped; the rule's other parts stay.
258
+ nil
259
+ end
260
+ end
261
+ out.empty? ? nil : out
262
+ end
263
+
264
+ # The rules as they will be matched; [] when there are none.
265
+ def compile(data)
266
+ given = data.is_a?(Hash) ? data["rules"] : nil
267
+ rules = []
268
+ (given.is_a?(Array) ? given : []).first(MAX_RULES).each do |rule|
269
+ next unless rule.is_a?(Hash) && rule["id"].is_a?(String) && !rule["id"].empty?
270
+ # We are called before the body is read, so a rule that needs the body
271
+ # is skipped whole rather than matched on the rest of it.
272
+ next if rule.key?("body")
273
+
274
+ methods = rule["methods"]
275
+ ban = rule["ban"]
276
+ compiled = {
277
+ "id" => rule["id"],
278
+ "action" => rule["action"] == "block" ? "block" : "monitor",
279
+ "ban" => ban.is_a?(Numeric) && ban.to_i.positive? ? ban.to_i : 0,
280
+ "methods" => methods.is_a?(Array) ? methods.select { |m| m.is_a?(String) }.map(&:upcase) : []
281
+ }
282
+ count = 0
283
+ SECTIONS.each do |name|
284
+ part = section(rule[name])
285
+ next unless part
286
+
287
+ compiled[name] = part
288
+ count += 1
289
+ end
290
+ # A rule with nothing to match would match everything.
291
+ next if count.zero?
292
+
293
+ rules << compiled
294
+ end
295
+ rules
296
+ end
297
+
298
+ # One pass only, as the protocol says: enough for %2e%2e, and no risk of
299
+ # decoding a visitor's own text into something that looks like an attack.
300
+ # An escape that isn't one stays as it is, and bytes that aren't UTF-8
301
+ # become U+FFFD, as Python's unquote_plus does.
302
+ def decode_once(text)
303
+ return text unless text.include?("%") || text.include?("+")
304
+
305
+ decoded = text.b.tr("+", " ").gsub(/%[0-9a-fA-F]{2}/n) { |m| m[1, 2].to_i(16).chr }
306
+ decoded.force_encoding(Encoding::UTF_8).scrub("�")
307
+ end
308
+
309
+ def normalize_path(path)
310
+ path.downcase.tr("\\", "/").gsub(%r{/{2,}}, "/")
311
+ end
312
+
313
+ # The path as a router reads it: ";params" dropped from each segment
314
+ # (Tomcat, Jetty and some proxies do; "/x;y/../admin" is "/admin" to
315
+ # them), decoded once, "\\" as "/", "//" as "/", "." and ".." resolved
316
+ # (never above the root), lower-cased. The same as every package's.
317
+ def router_path(path)
318
+ text = normalize_path(decode_once(path.to_s.gsub(%r{;[^/]*}, "")))
319
+ parts = []
320
+ segments = text.split("/", -1)
321
+ segments.each_with_index do |segment, index|
322
+ if segment == ".."
323
+ parts.pop if parts.size > 1
324
+ elsif segment != "." && (!segment.empty? || index.zero?)
325
+ parts << segment
326
+ end
327
+ end
328
+ resolved = parts.join("/")
329
+ resolved += "/" if ["", ".", ".."].include?(segments[-1]) && segments.size > 1 && !resolved.end_with?("/")
330
+ resolved = "/#{resolved}" if text.start_with?("/") && !resolved.start_with?("/")
331
+ resolved
332
+ end
333
+
334
+ # A request target's path (no query string) as the router reads it:
335
+ # what the probes, the static-file check and the rules' path match too.
336
+ def normalize_target(target)
337
+ text = SiteGoose.text(target).split(/[?#]/, 2)[0].to_s
338
+ router_path(text[0, MAX_TEXT])[0, MAX_TEXT]
339
+ end
340
+
341
+ def regex_hit?(regex, text)
342
+ regex.match?(text)
343
+ rescue Regexp::TimeoutError
344
+ false
345
+ end
346
+
347
+ def hits?(part, raw, decoded, routed = nil)
348
+ texts = [raw, decoded, routed].compact.uniq
349
+ texts.any? do |text|
350
+ next true if part["exact"]&.include?(text)
351
+ next true if part["prefix"]&.any? { |entry| text.start_with?(entry) }
352
+ next true if part["suffix"]&.any? { |entry| !entry.empty? && text.end_with?(entry) }
353
+ next true if part["contains"]&.any? { |entry| !entry.empty? && text.include?(entry) }
354
+
355
+ part["regex"] ? regex_hit?(part["regex"], text) : false
356
+ end
357
+ end
358
+
359
+ # The first rule that matches, or nil. target is the request target as
360
+ # the server received it, query string and all.
361
+ def match(rules, method = "", target = "", user_agent = "")
362
+ return nil if rules.nil? || rules.empty?
363
+
364
+ target = SiteGoose.text(target)
365
+ mark = target.index("?")
366
+ path_part = mark ? target[0, mark] : target
367
+ query = mark ? target[(mark + 1)..] : ""
368
+ raw_path = normalize_path(path_part[0, MAX_TEXT])
369
+ query = query[0, MAX_TEXT]
370
+ raw_query = query.downcase
371
+ ua = SiteGoose.text(user_agent)[0, MAX_TEXT].downcase
372
+ decoded_path = normalize_path(decode_once(path_part[0, MAX_TEXT])[0, MAX_TEXT])
373
+ # And as the router reads it: "/x;y/../admin" is "/admin" there, so a
374
+ # rule on "/admin" catches it; a hit on any of the three counts.
375
+ routed_path = router_path(path_part[0, MAX_TEXT])[0, MAX_TEXT]
376
+ # Lower-cased after decoding: %55NION decodes to "UNION", which a
377
+ # lower-cased "union select" rule would otherwise never see.
378
+ decoded_query = decode_once(query)[0, MAX_TEXT].downcase
379
+ upper = method.to_s.upcase
380
+ rules.find do |rule|
381
+ next false if !rule["methods"].empty? && !rule["methods"].include?(upper)
382
+ next false if rule["path"] && !hits?(rule["path"], raw_path, decoded_path, routed_path)
383
+ next false if rule["query"] && !hits?(rule["query"], raw_query, decoded_query)
384
+ next false if rule["ua"] && !hits?(rule["ua"], ua, ua)
385
+
386
+ true
387
+ end
388
+ end
389
+ end
390
+ end