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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +342 -0
- data/lib/sitegoose/analytics.rb +113 -0
- data/lib/sitegoose/challenge.rb +177 -0
- data/lib/sitegoose/core.rb +523 -0
- data/lib/sitegoose/crawlers.rb +195 -0
- data/lib/sitegoose/errors.rb +456 -0
- data/lib/sitegoose/firewall.rb +417 -0
- data/lib/sitegoose/inventory.rb +131 -0
- data/lib/sitegoose/ip.rb +159 -0
- data/lib/sitegoose/middleware.rb +52 -0
- data/lib/sitegoose/performance.rb +114 -0
- data/lib/sitegoose/platform.rb +557 -0
- data/lib/sitegoose/posture.rb +272 -0
- data/lib/sitegoose/proxy.rb +260 -0
- data/lib/sitegoose/queries.rb +399 -0
- data/lib/sitegoose/query_text.rb +215 -0
- data/lib/sitegoose/railtie.rb +124 -0
- data/lib/sitegoose/request_rules.rb +390 -0
- data/lib/sitegoose/resources.rb +449 -0
- data/lib/sitegoose/sidekiq.rb +95 -0
- data/lib/sitegoose/small_modules.rb +250 -0
- data/lib/sitegoose/version.rb +5 -0
- data/lib/sitegoose.rb +261 -0
- metadata +92 -0
|
@@ -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 = { "&" => "&", "<" => "<", ">" => ">", '"' => """, "'" => "'" }.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
|
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: []
|