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,557 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SiteGoose
4
+ MODULES = %w[proxy firewall errors analytics performance crawlers forms headers audit flags maintenance inventory
5
+ posture resources].freeze
6
+
7
+ # The site, its secret and the modules that are on. Each module is
8
+ # separate: one failing to start never stops the others, and the firewall
9
+ # never waits on reporting. A loaded module can also be switched off in the
10
+ # dashboard (Security -> Server package).
11
+ class Platform
12
+ attr_reader :core
13
+
14
+ # site_id, secret (site settings; keep it on the server), endpoint (the
15
+ # collector's URL); trust_proxy (how to find the visitor's address: nil
16
+ # uses the connection's, a number of proxies in X-Forwarded-For,
17
+ # "cloudflare"); debug (print send problems); server_name (this
18
+ # service's name); queries (false: Server speed without the database
19
+ # queries); per module true / false / {options}.
20
+ def initialize(site_id: nil, secret: nil, endpoint: nil, trust_proxy: nil, debug: false, server_name: nil,
21
+ logger: nil, queries: true, **modules)
22
+ unknown = modules.keys.map(&:to_s) - MODULES
23
+ warn "[analytics-platform] unknown options ignored: #{unknown.join(', ')}" unless unknown.empty?
24
+ @core = Core.new(site_id, secret, endpoint, debug: debug, server_name: server_name, logger: logger)
25
+ unless @core.valid?
26
+ warn "[analytics-platform] site_id, secret and endpoint are required; nothing is sent and every request passes"
27
+ end
28
+ @modules = {}
29
+ MODULES.each do |name|
30
+ setting = modules.key?(name.to_sym) ? modules[name.to_sym] : true
31
+ next if setting == false || setting.nil?
32
+
33
+ opts = setting.is_a?(Hash) ? SiteGoose.symbolize(setting) : {}
34
+ opts[:trust_proxy] = trust_proxy if name == "firewall" && !opts.key?(:trust_proxy)
35
+ # server_name names this service for every module that sends one, so
36
+ # its errors, timings and resources can be told apart.
37
+ if server_name && %w[errors performance crawlers resources].include?(name)
38
+ opts[:server_name] = server_name unless opts.key?(:server_name)
39
+ end
40
+ # Inventory and posture call it host, and are stored one per site and
41
+ # host: without it two services on one machine overwrite each other's.
42
+ opts[:host] = server_name if server_name && %w[inventory posture].include?(name) && !opts.key?(:host)
43
+ begin
44
+ @modules[name] = load(name, opts)
45
+ rescue StandardError, ScriptError => e
46
+ warn "[analytics-platform] the #{name} module could not start and is off: #{e.message}"
47
+ end
48
+ end
49
+ @queries = nil
50
+ performance = @modules["performance"]
51
+ if performance && queries != false
52
+ begin
53
+ @queries = Queries.new(@core, server_name: performance.server_name)
54
+ rescue StandardError, ScriptError => e
55
+ warn "[analytics-platform] database queries could not start and are off: #{e.message}"
56
+ end
57
+ end
58
+ @core.start(@modules.keys)
59
+ @closed = false
60
+ SiteGoose.created(self)
61
+ end
62
+
63
+ def load(name, opts)
64
+ case name
65
+ when "firewall" then Firewall.new(@core, **opts)
66
+ when "errors" then Errors.new(@core, **opts)
67
+ when "analytics" then Analytics.new(@core, **opts)
68
+ when "performance" then Performance.new(@core, **opts)
69
+ when "crawlers" then Crawlers.new(@core, **opts)
70
+ when "forms" then Forms.new(@core, **opts)
71
+ when "headers" then Headers.new
72
+ when "audit" then Audit.new(@core, **opts)
73
+ when "flags" then Flags.new(@core, **opts)
74
+ when "maintenance" then Maintenance.new(@core, **opts)
75
+ when "inventory" then Inventory.new(@core, **opts)
76
+ when "posture" then Posture.new(@core, **opts)
77
+ when "resources" then Resources.new(@core, **opts)
78
+ when "proxy" then Proxy.new(@core, **opts)
79
+ else raise ArgumentError, name
80
+ end
81
+ end
82
+ private :load
83
+
84
+ # ---- Modules ----
85
+
86
+ # The modules that are on.
87
+ def modules
88
+ @modules.keys
89
+ end
90
+
91
+ def module(name)
92
+ @modules[name.to_s]
93
+ end
94
+
95
+ def firewall
96
+ @modules["firewall"]
97
+ end
98
+
99
+ def errors
100
+ @modules["errors"]
101
+ end
102
+
103
+ # The database queries Server speed records (nil with queries: false or
104
+ # performance off).
105
+ attr_reader :queries
106
+
107
+ # The first-party proxy: proxy.script_url and proxy.collect_url are the
108
+ # two addresses the page's script tag needs, on this site's own domain.
109
+ def proxy
110
+ @modules["proxy"]
111
+ end
112
+
113
+ # [status, headers, body] when the address belongs to the proxy, else nil.
114
+ # Called first: a visitor's beacon is our traffic, not a page of the site.
115
+ def proxy_response(path, method: "GET", query: "", headers: {}, body: nil, remote: "")
116
+ proxy = @modules["proxy"]
117
+ return nil unless proxy
118
+
119
+ found = proxy.match(path)
120
+ return nil unless found
121
+
122
+ ip = firewall ? firewall.client_ip(remote, headers) : remote
123
+ # A denied or banned address gets nothing from us either; the rate
124
+ # limit and the browser check aren't for beacons.
125
+ if firewall
126
+ decision = firewall.check_address(ip, method, path, SiteGoose.header(headers, "user-agent"))
127
+ return [403, { "content-type" => "text/plain; charset=utf-8", "cache-control" => "no-store" }, "Forbidden"] if decision[:action] == "block"
128
+ end
129
+ body = body.call if body.respond_to?(:call)
130
+ proxy.handle(found[0], found[1], method: method, query: query, headers: headers, body: body, client_ip: ip)
131
+ rescue StandardError => e
132
+ @core.log("proxy", "failed:", e.message)
133
+ [502, {}, ""]
134
+ end
135
+
136
+ def capture_exception(error, request: nil, status: 0, kind: "handled")
137
+ errors&.capture_exception(error, request: request, status: status, kind: kind)
138
+ nil
139
+ end
140
+
141
+ # A step of your own in the trail shown before an error in this request or job.
142
+ def add_breadcrumb(text)
143
+ Context.add_breadcrumb(text) if errors
144
+ end
145
+
146
+ # A background job (a queue consumer, a cron task): its errors are sent
147
+ # with the job's name as their route, and each run is timed (Health ->
148
+ # Server speed -> Jobs); a run that raises counts as failed, and the
149
+ # exception is raised on.
150
+ #
151
+ # platform.job("nightly-import") { ... }
152
+ #
153
+ # report: ->(error) { true } when this error should be sent (Sidekiq: only
154
+ # the last attempt); default every error.
155
+ def job(name, report: nil)
156
+ route = "/(job) #{SiteGoose.text(name.to_s.empty? ? 'job' : name, 180)}"
157
+ fields = { "method" => "JOB", "path" => route, "route" => route, "session_id" => "" }
158
+ started = SiteGoose.clock
159
+ status = nil
160
+ Context.with(fields) do
161
+ result = yield
162
+ status = 200
163
+ result
164
+ rescue SignalException, SystemExit
165
+ # Interrupted or stopped: not a run that failed, and not timed.
166
+ status = nil
167
+ raise
168
+ rescue Exception => e # rubocop:disable Lint/RescueException -- raised on
169
+ status = 500
170
+ begin
171
+ capture_exception(e, status: 500, kind: "error", request: fields) if report.nil? || report.call(e)
172
+ rescue StandardError
173
+ nil
174
+ end
175
+ raise
176
+ ensure
177
+ time_request("JOB", route, status, started, route) if status
178
+ end
179
+ end
180
+
181
+ # A failed login (bot protection): the address, or a Rack request.
182
+ def login_failed(request_or_ip)
183
+ return unless firewall
184
+
185
+ ip = request_or_ip.is_a?(String) ? request_or_ip : request_ip(request_or_ip)
186
+ firewall.login_failed(ip) unless ip.to_s.empty?
187
+ end
188
+
189
+ # The visitor's address for a Rack (or Rails) request or env.
190
+ def request_ip(request)
191
+ env = SiteGoose.env_of(request)
192
+ return "" unless env
193
+
194
+ remote = env["REMOTE_ADDR"].to_s
195
+ firewall ? firewall.client_ip(remote, SiteGoose.headers_of(env)) : remote
196
+ end
197
+
198
+ # A feature flag from the dashboard (user: for rollouts and user lists;
199
+ # default: until the flag is known, or with the module off).
200
+ def enabled?(key, user: nil, default: false)
201
+ flags = @modules["flags"]
202
+ flags ? flags.enabled?(key, user, default) : default
203
+ end
204
+ alias is_enabled enabled?
205
+
206
+ # Maintenance mode: nil to let the request through, [:pass, headers] to let
207
+ # it through and remember the preview link, or [status, headers, body].
208
+ def maintenance_response(remote, headers, path, query, secure: false)
209
+ maintenance = @modules["maintenance"]
210
+ return nil unless maintenance
211
+
212
+ ip = firewall ? firewall.client_ip(remote, headers) : remote
213
+ maintenance.decide(ip, path, query, SiteGoose.header(headers, "cookie"), secure: secure)
214
+ rescue StandardError => e
215
+ @core.log("maintenance", "check failed, request passed:", e.message)
216
+ nil
217
+ end
218
+
219
+ # Records who did what (Security -> Audit log). action: e.g.
220
+ # "user.role_changed"; request adds the actor's address and visit.
221
+ def audit(action, actor: "", target: "", details: nil, request: nil)
222
+ audit = @modules["audit"]
223
+ return unless audit
224
+
225
+ ip = request ? request_ip(request) : ""
226
+ audit.record(action, actor, target, details, ip, request ? session_id(request) : "")
227
+ rescue StandardError => e
228
+ @core.log("audit", "could not record:", e.message)
229
+ end
230
+
231
+ # Form protection: checks a form before you accept it. The form is the
232
+ # request's POST parameters (Rack, Rails), else pass fields.
233
+ # -> {ok:, spam:, reasons:, checked:}; spam goes to Leads -> Spam.
234
+ def check_form(request = nil, fields: nil, email: nil, name: nil, form: nil)
235
+ forms = @modules["forms"]
236
+ return { ok: true, spam: false, reasons: [], checked: false } unless forms
237
+
238
+ if fields.nil? && request
239
+ fields = if request.respond_to?(:request_parameters) then request.request_parameters
240
+ elsif request.respond_to?(:POST) then request.POST
241
+ end
242
+ fields = fields.to_unsafe_h if fields.respond_to?(:to_unsafe_h)
243
+ end
244
+ forms.check(fields || {}, request ? request_ip(request) : "", email, name, form)
245
+ rescue StandardError => e
246
+ @core.log("forms", "could not check a form:", e.message)
247
+ { ok: true, spam: false, reasons: ["unchecked"], checked: false }
248
+ end
249
+
250
+ def track(name, fields = nil, session_id: "")
251
+ analytics = @modules["analytics"]
252
+ analytics ? analytics.track(name, fields, session_id) : ""
253
+ end
254
+
255
+ def identify(user_id, session_id: "")
256
+ analytics = @modules["analytics"]
257
+ analytics ? analytics.identify(user_id, session_id) : false
258
+ end
259
+
260
+ def lead(fields, session_id: "")
261
+ analytics = @modules["analytics"]
262
+ analytics&.lead(fields, session_id)
263
+ end
264
+
265
+ # The dashboard's security headers the response doesn't set itself
266
+ # (present: the header names it has).
267
+ def security_headers(present = [])
268
+ return [] unless @modules.key?("headers")
269
+
270
+ have = present.map { |name| name.to_s.downcase }
271
+ @core.security_headers.reject { |name, _| have.include?(name.downcase) }
272
+ end
273
+
274
+ # Records a finished request for Server speed (started: SiteGoose.clock).
275
+ # The request's database queries go under the same method and route.
276
+ def time_request(method, route, status, started, path = "")
277
+ performance = @modules["performance"]
278
+ recorded = performance&.record(method, route, status, (SiteGoose.clock - started) * 1000, path)
279
+ @queries&.finish(*recorded) if recorded
280
+ rescue StandardError => e
281
+ @core.log("performance", "could not time a request:", e.message)
282
+ end
283
+
284
+ # Records a finished request when it came from a crawler or AI bot
285
+ # (Health -> Crawlers). started: SiteGoose.clock when it arrived.
286
+ def record_crawler(user_agent, path, status, started = nil, size = 0, ip = "")
287
+ crawlers = @modules["crawlers"]
288
+ return unless crawlers
289
+
290
+ ms = started ? (SiteGoose.clock - started) * 1000 : 0
291
+ crawlers.middleware(user_agent, path, status, ms, size, ip)
292
+ rescue StandardError => e
293
+ @core.log("crawlers", "could not count a crawler request:", e.message)
294
+ end
295
+
296
+ # The browser's visit, from a Rack or Rails request (the X-Analytics-Session header).
297
+ def session_id(request)
298
+ env = SiteGoose.env_of(request)
299
+ value = env && env["HTTP_X_ANALYTICS_SESSION"]
300
+ value.is_a?(String) ? value : ""
301
+ end
302
+
303
+ # Exceptions a Logger is given at ERROR or above are sent too (kind
304
+ # "logged"); the logger still writes them. Rails.logger is hooked on its
305
+ # own with errors: {capture_logging: true}.
306
+ def capture_logger(logger)
307
+ return logger unless errors && logger
308
+
309
+ targets = logger.respond_to?(:broadcasts) ? [logger, *logger.broadcasts] : [logger]
310
+ targets.uniq.each do |target|
311
+ next unless target.respond_to?(:add)
312
+
313
+ target.singleton_class.prepend(LoggerCapture) unless target.singleton_class.include?(LoggerCapture)
314
+ target.instance_variable_set(:@__sitegoose_capture, lambda { |error|
315
+ name = target.respond_to?(:progname) ? target.progname.to_s : ""
316
+ capture_exception(error, kind: "logged") unless name.start_with?("analytics")
317
+ })
318
+ end
319
+ logger
320
+ end
321
+
322
+ # Sends everything waiting (serverless, a job runner: before returning).
323
+ def flush
324
+ [*@modules.values, @queries].compact.each do |mod|
325
+ mod.flush
326
+ rescue StandardError
327
+ nil
328
+ end
329
+ nil
330
+ end
331
+
332
+ # Stops background work and sends what's waiting.
333
+ def close
334
+ return if @closed
335
+
336
+ @closed = true
337
+ @core.stop
338
+ [*@modules.values, @queries].compact.each do |mod|
339
+ mod.close
340
+ rescue StandardError
341
+ nil
342
+ end
343
+ nil
344
+ end
345
+
346
+ def closed?
347
+ @closed
348
+ end
349
+
350
+ # ---- Requests ----
351
+
352
+ # [status, headers, body] when the firewall turns the request away, else
353
+ # nil. headers: a hash with lowercase names. target: the request target
354
+ # as sent, query string and all (SiteGoose.request_target).
355
+ def blocked(remote, headers, method, target)
356
+ fw = firewall
357
+ return nil unless fw
358
+
359
+ begin
360
+ ip = fw.client_ip(remote, headers)
361
+ decision = fw.check(ip, method, target, SiteGoose.header(headers, "user-agent"),
362
+ SiteGoose.header(headers, "accept"), SiteGoose.header(headers, "cookie"))
363
+ return fw.challenge_page(ip, method) if decision[:action] == "block" && decision[:rule] == "challenge"
364
+ rescue StandardError => e
365
+ @core.log("firewall", "check failed, request passed:", e.message)
366
+ return nil
367
+ end
368
+ return nil unless decision[:action] == "block"
369
+
370
+ extra = { "content-type" => "text/plain; charset=utf-8", "cache-control" => "no-store" }
371
+ if decision[:status] == 429
372
+ extra["retry-after"] = fw.retry_after
373
+ return [429, extra, "Too Many Requests"]
374
+ end
375
+ [403, extra, "Forbidden"]
376
+ end
377
+
378
+ # The Rack integration: everything a request goes through, in order. The
379
+ # block runs the app. Used by SiteGoose::Middleware.
380
+ def rack_call(env)
381
+ Forking.check
382
+ started = SiteGoose.clock
383
+ headers = SiteGoose.headers_of(env)
384
+ method = env["REQUEST_METHOD"].to_s
385
+ remote = env["REMOTE_ADDR"].to_s
386
+ raw_path = "#{env['SCRIPT_NAME']}#{env['PATH_INFO']}"
387
+ path = SiteGoose.decode_path(raw_path)
388
+ query = env["QUERY_STRING"].to_s
389
+ # The tracker's own addresses, answered before anything else.
390
+ answered = proxy_response(raw_path, method: method, query: query, headers: headers, remote: remote,
391
+ body: -> { SiteGoose.read_body(env) })
392
+ return SiteGoose.rack_response(answered, method) if answered
393
+
394
+ ip = nil
395
+ refused = blocked(remote, headers, method, SiteGoose.request_target(env))
396
+ if refused
397
+ ip = firewall ? firewall.client_ip(remote, headers) : remote
398
+ # A crawler the firewall turned away is still a crawler, and counted
399
+ # with the status it got.
400
+ record_crawler(headers["user-agent"], path, refused[0], started, 0, ip)
401
+ return SiteGoose.rack_response(refused, method)
402
+ end
403
+ closed = maintenance_response(remote, headers, path, query, secure: SiteGoose.https?(env))
404
+ return SiteGoose.rack_response(closed, method) if closed && closed[0] != :pass
405
+
406
+ remember = closed ? closed[1] : {}
407
+ fields = -> { SiteGoose.request_fields(env, method, path) }
408
+ status = 500
409
+ response = nil
410
+ Context.with(fields) do
411
+ response = yield
412
+ status = response[0].to_i
413
+ # Rails and Sinatra turn an exception into an error page themselves,
414
+ # so it never reaches here raised; they leave it in the env.
415
+ error = env["action_dispatch.exception"] || env["sinatra.error"]
416
+ capture_exception(error, status: status, kind: "error", request: fields.call) if error.is_a?(Exception) && status >= 500
417
+ response
418
+ rescue Exception => e # rubocop:disable Lint/RescueException -- raised on
419
+ unless e.is_a?(SignalException) || e.is_a?(SystemExit)
420
+ capture_exception(e, status: 500, kind: "error", request: fields.call)
421
+ end
422
+ raise
423
+ ensure
424
+ begin
425
+ route = SiteGoose.route_of(env, path)
426
+ time_request(method, route, status, started, path)
427
+ size = response ? SiteGoose.content_length(response[1]) : 0
428
+ record_crawler(headers["user-agent"], path, status, started, size, ip || (firewall ? firewall.client_ip(remote, headers) : remote))
429
+ rescue StandardError
430
+ nil
431
+ end
432
+ end
433
+ SiteGoose.add_headers(response, security_headers(SiteGoose.header_names(response[1])), remember)
434
+ end
435
+ end
436
+
437
+ # ---- Rack helpers ----
438
+
439
+ class << self
440
+ # Whether the visitor's request came over HTTPS (a TLS-ending proxy says
441
+ # so in X-Forwarded-Proto, as Rack reads it).
442
+ def https?(env)
443
+ return true if env["HTTPS"].to_s.downcase == "on" || env["rack.url_scheme"] == "https"
444
+
445
+ env["HTTP_X_FORWARDED_PROTO"].to_s.split(",").first.to_s.strip.downcase == "https"
446
+ end
447
+
448
+ # The env of a Rack::Request, an ActionDispatch::Request, or an env itself.
449
+ def env_of(request)
450
+ return request if request.is_a?(Hash)
451
+ return request.env if request.respond_to?(:env)
452
+
453
+ nil
454
+ end
455
+
456
+ # The request's headers as a hash with lowercase names.
457
+ def headers_of(env)
458
+ headers = {}
459
+ env.each do |key, value|
460
+ next unless key.is_a?(String) && value.is_a?(String)
461
+
462
+ if key.start_with?("HTTP_")
463
+ headers[key[5..].tr("_", "-").downcase] = value
464
+ elsif key == "CONTENT_TYPE" || key == "CONTENT_LENGTH"
465
+ headers[key.tr("_", "-").downcase] = value
466
+ end
467
+ end
468
+ headers
469
+ end
470
+
471
+ def read_body(env)
472
+ input = env["rack.input"]
473
+ return "" unless input
474
+
475
+ body = input.read(Proxy::MAX_BODY + 1).to_s
476
+ input.rewind if input.respond_to?(:rewind)
477
+ body
478
+ rescue StandardError
479
+ ""
480
+ end
481
+
482
+ # The route as the framework names it: Rails 7.1+ keeps the matched
483
+ # pattern (/users/:id), Sinatra its route ("GET /users/:id"); an older
484
+ # Rails gives the path parameters, put back into the path; else "" (the
485
+ # path is normalized).
486
+ def route_of(env, path)
487
+ pattern = env["action_dispatch.route_uri_pattern"]
488
+ return pattern.sub(/\(\.:format\)\z/, "")[0, 200] if pattern.is_a?(String) && !pattern.empty?
489
+
490
+ route = env["sinatra.route"]
491
+ return route.split(" ", 2)[1].to_s[0, 200] if route.is_a?(String) && !route.empty?
492
+
493
+ params = env["action_dispatch.request.path_parameters"]
494
+ return "" unless params.is_a?(Hash) && params.key?(:controller)
495
+
496
+ format = params[:format].to_s
497
+ parts = path.split("/", -1)
498
+ parts[-1] = parts[-1].delete_suffix(".#{format}") if !format.empty? && parts[-1]
499
+ params.each do |key, value|
500
+ next if %i[controller action format].include?(key) || !value.is_a?(String) || value.empty?
501
+
502
+ index = parts.index(value)
503
+ parts[index] = ":#{key}" if index
504
+ end
505
+ route = parts.join("/")
506
+ route.empty? ? "/" : route[0, 200]
507
+ rescue StandardError
508
+ ""
509
+ end
510
+
511
+ def request_fields(env, method, path)
512
+ session = env["HTTP_X_ANALYTICS_SESSION"]
513
+ { "method" => method, "path" => path, "route" => route_of(env, path), "session_id" => session.is_a?(String) ? session : "" }
514
+ end
515
+
516
+ def header_names(headers)
517
+ headers.respond_to?(:keys) ? headers.keys.map { |k| k.to_s.downcase } : []
518
+ end
519
+
520
+ def content_length(headers)
521
+ return 0 unless headers.respond_to?(:each)
522
+
523
+ headers.each { |k, v| return v.to_s.to_i if k.to_s.casecmp?("content-length") }
524
+ 0
525
+ end
526
+
527
+ # Our own answer (the check page, a refusal, the proxy) as a Rack response.
528
+ def rack_response(answer, method)
529
+ status, headers, body = answer
530
+ body = "" if method.to_s.upcase == "HEAD"
531
+ [status, headers.to_h { |k, v| [k.to_s.downcase, v.to_s] }, [body.to_s]]
532
+ end
533
+
534
+ # The dashboard's security headers and the maintenance cookie, onto the
535
+ # app's own response.
536
+ def add_headers(response, extra, remember)
537
+ return response if extra.empty? && remember.empty?
538
+
539
+ status, headers, body = response
540
+ headers = headers.dup if headers.frozen?
541
+ extra.each { |name, value| headers[name.downcase] = value }
542
+ remember.each do |name, value|
543
+ existing = headers[name]
544
+ headers[name] = if existing.nil? || existing.empty? then value
545
+ elsif existing.is_a?(Array) then existing + [value]
546
+ elsif rack3? then [existing, value]
547
+ else "#{existing}\n#{value}"
548
+ end
549
+ end
550
+ [status, headers, body]
551
+ end
552
+
553
+ def rack3?
554
+ defined?(::Rack::RELEASE) && ::Rack::RELEASE.to_s >= "3"
555
+ end
556
+ end
557
+ end