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,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The modules with little of their own: forms, audit, flags, maintenance and
4
+ # the security headers.
5
+
6
+ require "securerandom"
7
+
8
+ module SiteGoose
9
+ # Form protection module: checks a form your server received before you
10
+ # accept it (POST /collectanalytics/shield/verify, the same check as spam
11
+ # protection's). It reads the hidden "analytics-shield" answer the tracker
12
+ # puts in protected forms, and checks the email, name and text for fake
13
+ # sign-ups. Spam lands in the site's spam folder (Leads -> Spam). Passwords,
14
+ # card numbers and tokens are never sent. Fails open: when the check can't
15
+ # be made, the form is accepted (checked false).
16
+ class Forms
17
+ PRIVATE_FIELD = /pass|card|cvc|cvv|secret|token|ssn|iban|csrf|analytics-shield/i.freeze
18
+ MAX_FIELDS = 50
19
+ ANSWER = "analytics-shield"
20
+
21
+ # Rails and Rack nest "user[email]" as {"user" => {"email" => ...}}: put
22
+ # back as the form named them, so a label is what the visitor saw.
23
+ def self.flat(fields, prefix = nil, out = {}, depth = 0)
24
+ (fields || {}).each do |label, value|
25
+ name = prefix ? "#{prefix}[#{label}]" : label.to_s
26
+ if value.is_a?(Hash)
27
+ flat(value, name, out, depth + 1) if depth < 3
28
+ else
29
+ out[name] = value
30
+ end
31
+ break if out.size >= MAX_FIELDS * 2
32
+ end
33
+ out
34
+ end
35
+
36
+ def self.plain_fields(fields)
37
+ out = {}
38
+ flat(fields).first(MAX_FIELDS).each do |label, value|
39
+ next if label.to_s.match?(PRIVATE_FIELD)
40
+
41
+ value = value.select { |v| v.is_a?(String) }.join(", ") if value.is_a?(Array)
42
+ out[SiteGoose.text(label, 100)] = SiteGoose.text(value, 2000) if value.is_a?(String)
43
+ end
44
+ out
45
+ end
46
+
47
+ def initialize(core, **_)
48
+ @core = core
49
+ end
50
+
51
+ # fields: the form as a hash. -> {ok:, spam:, reasons:, checked:}.
52
+ def check(fields, ip = "", email = nil, name = nil, form = nil)
53
+ return { ok: true, spam: false, reasons: [], checked: false } unless @core.on?("forms")
54
+
55
+ unchecked = { ok: true, spam: false, reasons: ["unchecked"], checked: false }
56
+ fields = (fields || {}).to_h { |k, v| [k.to_s, v] }
57
+ token = fields[ANSWER]
58
+ token = token.first if token.is_a?(Array)
59
+ body = { "token" => token.is_a?(String) ? token : "", "fields" => Forms.plain_fields(fields), "ip" => ip.to_s }
60
+ { "email" => email, "name" => name, "form" => form }.each { |k, v| body[k] = v if v.is_a?(String) }
61
+ res = @core.request("forms", "/collectanalytics/shield/verify", body: body)
62
+ if res.nil? || res.status >= 500 || [403, 429].include?(res.status)
63
+ @core.log("forms", "the site secret was refused; forms aren't checked") if res&.status == 403
64
+ return unchecked
65
+ end
66
+ data = res.json
67
+ data = {} unless data.is_a?(Hash)
68
+ spam = data["success"] == false
69
+ { ok: !spam, spam: spam, reasons: Array(data["reasons"]), checked: true }
70
+ end
71
+
72
+ def flush; end
73
+
74
+ def close; end
75
+ end
76
+
77
+ # Audit log module: who did what on your site, sent to POST /events/audit
78
+ # in small batches in the background (Security -> Audit log). What goes in
79
+ # (names, emails) is up to you. Devise's logins and logouts are recorded on
80
+ # their own.
81
+ class Audit
82
+ BATCH = 100
83
+ QUEUE_MAX = 1000
84
+ FLUSH_EVERY = 5
85
+
86
+ attr_reader :queue
87
+
88
+ def initialize(core, **_)
89
+ @core = core
90
+ @queue = []
91
+ @lock = Mutex.new
92
+ @job = core.valid? ? Periodic.new(FLUSH_EVERY) { flush; FLUSH_EVERY } : nil
93
+ Forking.register(self)
94
+ end
95
+
96
+ def after_fork_in_child
97
+ @lock = Mutex.new
98
+ @queue = []
99
+ end
100
+
101
+ def record(action, actor = "", target = "", details = nil, actor_ip = "", session_id = "")
102
+ return if !@core.on?("audit") || !action.is_a?(String) || action.empty?
103
+
104
+ entry = {
105
+ "at" => SiteGoose.iso_now, "action" => action[0, 64], "actor" => SiteGoose.text(actor, 200),
106
+ "target" => SiteGoose.text(target, 300), "details" => details, "actor_ip" => actor_ip.to_s,
107
+ "session_id" => session_id.to_s
108
+ }
109
+ full = @lock.synchronize do
110
+ return if @queue.size >= QUEUE_MAX
111
+
112
+ @queue << entry
113
+ @queue.size >= BATCH
114
+ end
115
+ @job&.run_now if full
116
+ end
117
+
118
+ def flush
119
+ waiting = @lock.synchronize do
120
+ taken = @queue
121
+ @queue = []
122
+ taken
123
+ end
124
+ waiting.each_slice(BATCH) { |batch| @core.request("audit", "/events/audit", body: { "entries" => batch }) }
125
+ end
126
+
127
+ def close
128
+ @job&.stop
129
+ Forking.unregister(self)
130
+ flush
131
+ end
132
+ end
133
+
134
+ # Feature flags module: turn features of your app on or off from the
135
+ # dashboard (Site settings -> Server), for everyone, a share of users, or
136
+ # listed user IDs. enabled?() answers from memory; the flags come with the
137
+ # module switches (core), about once a minute.
138
+ class Flags
139
+ # FNV-1a over "key:user", 0-99: the same as the Node, PHP and Python packages.
140
+ def self.bucket(key, user)
141
+ h = 0x811C9DC5
142
+ "#{key}:#{user}".each_byte do |byte|
143
+ h ^= byte
144
+ h = (h * 0x01000193) & 0xFFFFFFFF
145
+ end
146
+ h % 100
147
+ end
148
+
149
+ def self.evaluate(flag, key, user)
150
+ return false unless flag["on"]
151
+
152
+ uid = user.nil? ? "" : user.to_s
153
+ return true if !uid.empty? && Array(flag["users"]).map(&:to_s).include?(uid)
154
+
155
+ rollout = flag.key?("rollout") ? flag["rollout"].to_i : 100
156
+ return true if rollout >= 100
157
+ return false if rollout <= 0 || uid.empty?
158
+
159
+ bucket(key, uid) < rollout
160
+ end
161
+
162
+ def initialize(core, **_)
163
+ @core = core
164
+ end
165
+
166
+ def enabled?(key, user = nil, default = false)
167
+ flag = @core.on?("flags") ? @core.flags[key.to_s] : nil
168
+ flag.is_a?(Hash) ? Flags.evaluate(flag, key.to_s, user) : default
169
+ rescue StandardError
170
+ default
171
+ end
172
+
173
+ def flush; end
174
+
175
+ def close; end
176
+ end
177
+
178
+ # Maintenance mode module: while it's on (Site settings -> Server), visitors
179
+ # get a "back soon" page (503, so search engines know it's temporary),
180
+ # except allowed addresses and whoever opened the preview link
181
+ # (?site_preview=<code>, remembered for a day in a cookie).
182
+ class Maintenance
183
+ COOKIE = "site_preview"
184
+ ESCAPES = { "&" => "&amp;", "<" => "&lt;", ">" => "&gt;", '"' => "&quot;", "'" => "&#x27;" }.freeze
185
+
186
+ def self.escape(text)
187
+ SiteGoose.text(text).gsub(/[&<>"']/, ESCAPES)
188
+ end
189
+
190
+ def self.page(message)
191
+ text = escape(message.to_s.empty? ? "We're doing some maintenance. Please check back in a few minutes." : message)
192
+ "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">" \
193
+ "<title>Back soon</title><style>body{margin:0;min-height:100vh;display:grid;place-items:center;font:16px/1.5 system-ui,sans-serif;" \
194
+ "background:#f8fafc;color:#0f172a}main{max-width:32rem;padding:2rem;text-align:center}h1{font-size:1.6rem;margin:0 0 .5rem}" \
195
+ "p{color:#475569;margin:0}@media(prefers-color-scheme:dark){body{background:#0f172a;color:#e2e8f0}p{color:#94a3b8}}</style></head>" \
196
+ "<body><main><h1>Back soon</h1><p>#{text}</p></main></body></html>"
197
+ end
198
+
199
+ def self.query_value(query, name)
200
+ query.to_s.split("&").each do |pair|
201
+ k, v = pair.split("=", 2)
202
+ return RequestRules.decode_once(v.to_s) if RequestRules.decode_once(k.to_s) == name
203
+ end
204
+ ""
205
+ end
206
+
207
+ # Options: skip (->(path) { true } lets a request through: health checks).
208
+ def initialize(core, skip: nil, **_)
209
+ @core = core
210
+ @skip = skip
211
+ @allow_for = nil
212
+ @allow = IpSet.new([])
213
+ end
214
+
215
+ # nil to let the request through, [:pass, headers] to let it through and
216
+ # remember the preview link, else [status, headers, body].
217
+ # secure: the request came over HTTPS (the cookie is then Secure).
218
+ def decide(ip, path, query, cookie_header, secure: false)
219
+ m = @core.on?("maintenance") ? @core.maintenance : nil
220
+ return nil if !m.is_a?(Hash) || !m["on"] || (@skip && @skip.call(path))
221
+
222
+ unless @allow_for.equal?(m["allow"])
223
+ @allow_for = m["allow"]
224
+ @allow = IpSet.new(Array(m["allow"]))
225
+ end
226
+ key = IP.key(ip)
227
+ return nil if key && @allow.has?(key)
228
+
229
+ code = m["bypass"].to_s
230
+ unless code.empty?
231
+ if Challenge.secure_compare(Maintenance.query_value(query, COOKIE), code)
232
+ return [:pass, { "set-cookie" => "#{COOKIE}=#{code}; Path=/; Max-Age=86400; HttpOnly; SameSite=Lax#{secure ? '; Secure' : ''}" }]
233
+ end
234
+ return nil if Challenge.cookie_values(cookie_header, COOKIE).any? { |value| Challenge.secure_compare(value, code) }
235
+ end
236
+ [503, { "content-type" => "text/html; charset=utf-8", "retry-after" => "600", "cache-control" => "no-store" }, Maintenance.page(m["message"])]
237
+ end
238
+
239
+ def flush; end
240
+
241
+ def close; end
242
+ end
243
+
244
+ # The security headers module: the headers come with the module switches (core).
245
+ class Headers
246
+ def flush; end
247
+
248
+ def close; end
249
+ end
250
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SiteGoose
4
+ VERSION = "0.3.3"
5
+ end
data/lib/sitegoose.rb ADDED
@@ -0,0 +1,261 @@
1
+ # frozen_string_literal: true
2
+
3
+ # One package for a site's Ruby server, run from the analytics dashboard:
4
+ # firewall, error monitoring and server analytics. Each module is separate:
5
+ # one failing never stops the others, and the firewall never waits on
6
+ # reporting. A loaded module can also be switched off in the dashboard
7
+ # (Security -> Server package).
8
+ #
9
+ # # Rails: the Railtie puts the middleware first; config in an initializer.
10
+ # SiteGoose.configure(site_id: "...", secret: ENV["ANALYTICS_SITE_SECRET"], endpoint: "https://...")
11
+ #
12
+ # # Sinatra, Hanami, Roda, any Rack app:
13
+ # use SiteGoose::Middleware, site_id: "...", secret: ENV["ANALYTICS_SITE_SECRET"], endpoint: "https://..."
14
+ #
15
+ # A module is on with true (or its options as a hash) and off with false.
16
+ # Standard library only, and rack; Ruby 3.0+.
17
+
18
+ require_relative "sitegoose/version"
19
+ require_relative "sitegoose/core"
20
+ require_relative "sitegoose/ip"
21
+ require_relative "sitegoose/challenge"
22
+ require_relative "sitegoose/request_rules"
23
+ require_relative "sitegoose/firewall"
24
+ require_relative "sitegoose/performance"
25
+ require_relative "sitegoose/errors"
26
+ require_relative "sitegoose/query_text"
27
+ require_relative "sitegoose/queries"
28
+ require_relative "sitegoose/crawlers"
29
+ require_relative "sitegoose/analytics"
30
+ require_relative "sitegoose/small_modules"
31
+ require_relative "sitegoose/inventory"
32
+ require_relative "sitegoose/posture"
33
+ require_relative "sitegoose/resources"
34
+ require_relative "sitegoose/proxy"
35
+ require_relative "sitegoose/platform"
36
+ require_relative "sitegoose/middleware"
37
+
38
+ module SiteGoose
39
+ @lock = Mutex.new
40
+ @options = nil
41
+ @defaults = {}
42
+ @platform = nil
43
+ @created = []
44
+ @created_lock = Mutex.new
45
+ @when_made = []
46
+
47
+ # The options a block sets: SiteGoose.configure { |c| c.site_id = "..." }.
48
+ class Config
49
+ def initialize(values = {})
50
+ @values = SiteGoose.symbolize(values)
51
+ end
52
+
53
+ def to_h
54
+ @values.dup
55
+ end
56
+
57
+ def [](key)
58
+ @values[key.to_sym]
59
+ end
60
+
61
+ def []=(key, value)
62
+ @values[key.to_sym] = value
63
+ end
64
+
65
+ def method_missing(name, *args)
66
+ key = name.to_s
67
+ if key.end_with?("=") && args.size == 1
68
+ @values[key.chomp("=").to_sym] = args[0]
69
+ elsif args.empty?
70
+ @values[name]
71
+ else
72
+ super
73
+ end
74
+ end
75
+
76
+ def respond_to_missing?(_name, _private = false)
77
+ true
78
+ end
79
+ end
80
+
81
+ class << self
82
+ # Sets the options the platform is made from; it is made on first use
83
+ # (the first request, job or call), so a Puma or Unicorn master that only
84
+ # forks workers starts no threads of its own. Options already given (the
85
+ # Rails config, credentials, the environment) are kept unless named again.
86
+ def configure(**options)
87
+ config = Config.new(options)
88
+ yield config if block_given?
89
+ @lock.synchronize do
90
+ @options = (@options || {}).merge(config.to_h)
91
+ if @platform
92
+ warn "[analytics-platform] configure after the platform was made: the new options apply after SiteGoose.reset"
93
+ end
94
+ end
95
+ nil
96
+ end
97
+
98
+ # Options under what configure() gives (the Railtie's, from the Rails
99
+ # config and credentials), over the environment's.
100
+ def defaults(**options)
101
+ @lock.synchronize { @defaults = @defaults.merge(SiteGoose.symbolize(options)) }
102
+ nil
103
+ end
104
+
105
+ # Runs the block with the platform once it is made (now, if it is).
106
+ def when_made(&block)
107
+ made = @lock.synchronize do
108
+ @when_made << block
109
+ @platform
110
+ end
111
+ block.call(made) if made
112
+ nil
113
+ end
114
+
115
+ # Options from the environment, under what configure() gives.
116
+ def env_options
117
+ out = {}
118
+ out[:site_id] = ENV["ANALYTICS_SITE_ID"] if ENV["ANALYTICS_SITE_ID"]
119
+ out[:secret] = ENV["ANALYTICS_SITE_SECRET"] if ENV["ANALYTICS_SITE_SECRET"]
120
+ endpoint = ENV["ANALYTICS_ENDPOINT"] || ENV["ANALYTICS_FIREWALL_ENDPOINT"]
121
+ out[:endpoint] = endpoint if endpoint
122
+ out[:server_name] = ENV["ANALYTICS_SERVER_NAME"] if ENV["ANALYTICS_SERVER_NAME"]
123
+ trust = ENV["ANALYTICS_TRUST_PROXY"]
124
+ out[:trust_proxy] = trust.match?(/\A\d+\z/) ? trust.to_i : trust if trust && !trust.empty?
125
+ out
126
+ end
127
+
128
+ def options
129
+ env_options.merge(@defaults).merge(@options || {})
130
+ end
131
+
132
+ def configured?
133
+ !@options.nil? || !@defaults.empty? || !env_options.empty?
134
+ end
135
+
136
+ # The platform, made on first use from configure(), the Rails config and
137
+ # credentials, and the environment; nil when nothing configures it, or
138
+ # with enabled: false (the middleware then passes every request).
139
+ def platform
140
+ return @platform if @platform
141
+
142
+ hooks = nil
143
+ made = @lock.synchronize do
144
+ return @platform if @platform
145
+ return nil unless configured?
146
+
147
+ given = options
148
+ return nil if given.delete(:enabled) == false
149
+
150
+ hooks = @when_made.dup
151
+ @platform = Platform.new(**given)
152
+ end
153
+ hooks&.each do |hook|
154
+ hook.call(made)
155
+ rescue StandardError => e
156
+ warn "[analytics-platform] #{e.class}: #{e.message}"
157
+ end
158
+ made
159
+ end
160
+
161
+ # Uses this platform as the one everything else reaches (tests, or a
162
+ # platform you made yourself).
163
+ def platform=(value)
164
+ @lock.synchronize { @platform = value }
165
+ end
166
+
167
+ # Closes the platform and forgets it and its options.
168
+ def reset
169
+ made = @lock.synchronize do
170
+ taken = @platform
171
+ @platform = nil
172
+ @options = nil
173
+ @defaults = {}
174
+ taken
175
+ end
176
+ made&.close
177
+ nil
178
+ end
179
+
180
+ def created(platform)
181
+ @created_lock.synchronize { @created << platform }
182
+ end
183
+
184
+ # At exit: the exception that is ending the program is sent, then every
185
+ # platform sends what's waiting and stops its threads.
186
+ # All of it within EXIT_DEADLINE seconds: a container's grace period is
187
+ # longer than that, and what wasn't sent by then waits in the errors file.
188
+ def at_exit_hook(error, seconds = EXIT_DEADLINE)
189
+ platforms = @created_lock.synchronize { @created.dup }
190
+ within(seconds) do
191
+ platforms.each do |made|
192
+ next if made.closed?
193
+
194
+ made.errors&.capture_exit(error)
195
+ made.close
196
+ rescue StandardError
197
+ nil
198
+ end
199
+ end
200
+ end
201
+
202
+ # ---- The platform's API, on the module ----
203
+
204
+ def track(name, fields = nil, session_id: "")
205
+ platform&.track(name, fields, session_id: session_id) || ""
206
+ end
207
+
208
+ def identify(user_id, session_id: "")
209
+ platform ? platform.identify(user_id, session_id: session_id) : false
210
+ end
211
+
212
+ def lead(fields, session_id: "")
213
+ platform&.lead(fields, session_id: session_id)
214
+ end
215
+
216
+ def capture_exception(error, request: nil, status: 0, kind: "handled")
217
+ platform&.capture_exception(error, request: request, status: status, kind: kind)
218
+ end
219
+
220
+ def add_breadcrumb(text)
221
+ platform&.add_breadcrumb(text)
222
+ end
223
+
224
+ def audit(action, actor: "", target: "", details: nil, request: nil)
225
+ platform&.audit(action, actor: actor, target: target, details: details, request: request)
226
+ end
227
+
228
+ def enabled?(key, user: nil, default: false)
229
+ made = platform
230
+ made ? made.enabled?(key, user: user, default: default) : default
231
+ end
232
+
233
+ def check_form(request = nil, **options)
234
+ made = platform
235
+ made ? made.check_form(request, **options) : { ok: true, spam: false, reasons: [], checked: false }
236
+ end
237
+
238
+ def login_failed(request_or_ip)
239
+ platform&.login_failed(request_or_ip)
240
+ end
241
+
242
+ def session_id(request)
243
+ made = platform
244
+ made ? made.session_id(request) : ""
245
+ end
246
+
247
+ def job(name, report: nil, &block)
248
+ made = platform
249
+ made ? made.job(name, report: report, &block) : yield
250
+ end
251
+
252
+ def flush
253
+ @created_lock.synchronize { @created.dup }.each(&:flush)
254
+ nil
255
+ end
256
+ end
257
+
258
+ at_exit { SiteGoose.at_exit_hook($!) }
259
+ end
260
+
261
+ require_relative "sitegoose/railtie" if defined?(::Rails::Railtie)
metadata ADDED
@@ -0,0 +1,92 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sitegoose
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.3
5
+ platform: ruby
6
+ authors:
7
+ - steviec87
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-11-04 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rack
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '4'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '2.0'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '4'
33
+ description: 'Rack middleware and a Railtie: a firewall with rate limits, shared blocks,
34
+ attack patterns and a browser check; error monitoring (Rails.error, Sidekiq); server
35
+ speed per route; crawler counts; server-side events, leads and audit log; feature
36
+ flags and maintenance mode; and the gems, settings and machine figures the Security
37
+ check needs. Standard library and rack only.'
38
+ email:
39
+ executables: []
40
+ extensions: []
41
+ extra_rdoc_files: []
42
+ files:
43
+ - LICENSE
44
+ - README.md
45
+ - lib/sitegoose.rb
46
+ - lib/sitegoose/analytics.rb
47
+ - lib/sitegoose/challenge.rb
48
+ - lib/sitegoose/core.rb
49
+ - lib/sitegoose/crawlers.rb
50
+ - lib/sitegoose/errors.rb
51
+ - lib/sitegoose/firewall.rb
52
+ - lib/sitegoose/inventory.rb
53
+ - lib/sitegoose/ip.rb
54
+ - lib/sitegoose/middleware.rb
55
+ - lib/sitegoose/performance.rb
56
+ - lib/sitegoose/platform.rb
57
+ - lib/sitegoose/posture.rb
58
+ - lib/sitegoose/proxy.rb
59
+ - lib/sitegoose/queries.rb
60
+ - lib/sitegoose/query_text.rb
61
+ - lib/sitegoose/railtie.rb
62
+ - lib/sitegoose/request_rules.rb
63
+ - lib/sitegoose/resources.rb
64
+ - lib/sitegoose/sidekiq.rb
65
+ - lib/sitegoose/small_modules.rb
66
+ - lib/sitegoose/version.rb
67
+ homepage: https://sitegoose.com
68
+ licenses:
69
+ - MIT
70
+ metadata:
71
+ rubygems_mfa_required: 'true'
72
+ post_install_message:
73
+ rdoc_options: []
74
+ require_paths:
75
+ - lib
76
+ required_ruby_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '3.0'
81
+ required_rubygems_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
86
+ requirements: []
87
+ rubygems_version: 3.5.22
88
+ signing_key:
89
+ specification_version: 4
90
+ summary: Firewall, error monitoring and server analytics for your Ruby server, run
91
+ from your SiteGoose dashboard.
92
+ test_files: []