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,417 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Firewall module: rules from the collector (GET /events/firewall/rules,
|
|
4
|
+
# protocol in sdk/protocols/firewall.md) every minute or so; every request is
|
|
5
|
+
# checked against them in memory, so the app never waits on the collector.
|
|
6
|
+
# What was blocked (or, in monitor mode, would have been) goes back in batches.
|
|
7
|
+
#
|
|
8
|
+
# Fails open: until rules arrive, and whenever the collector can't be reached,
|
|
9
|
+
# requests pass (with the last rules that arrived, if any). The rate limit
|
|
10
|
+
# counts in this process: with several workers, pass `counter` (e.g. Redis)
|
|
11
|
+
# for one count across them.
|
|
12
|
+
|
|
13
|
+
require "json"
|
|
14
|
+
|
|
15
|
+
module SiteGoose
|
|
16
|
+
class Firewall
|
|
17
|
+
PROTOCOL = 1
|
|
18
|
+
FLUSH_EVERY = 5
|
|
19
|
+
BATCH = 200
|
|
20
|
+
QUEUE_MAX = 2000
|
|
21
|
+
COUNTER_MAX = 100_000
|
|
22
|
+
# The layers this server runs (X-Firewall-Layers): the address firewall and the browser check.
|
|
23
|
+
LAYERS = "app check"
|
|
24
|
+
# Files a page load asks for by the dozen: not counted against `rate`
|
|
25
|
+
# (the agent's nginx limit leaves the same extensions out).
|
|
26
|
+
STATIC = /\.(?:css|js|mjs|map|png|jpe?g|gif|webp|avif|svg|ico|bmp|woff2?|ttf|otf|eot|mp4|webm|ogg|mp3|wav|pdf|wasm)\z/i.freeze
|
|
27
|
+
STATIC_PREFIXES = %w[/assets/ /packs/ /vite/ /static/ /_next/static/].freeze
|
|
28
|
+
|
|
29
|
+
attr_reader :pass_key, :queue, :bans
|
|
30
|
+
attr_accessor :rules, :etag
|
|
31
|
+
|
|
32
|
+
# The rules' probes (addresses of files no visitor asks for: .env,
|
|
33
|
+
# .git/...) -> {exact:, prefix:, ban:}, or nil.
|
|
34
|
+
def self.compile_probes(probes)
|
|
35
|
+
return nil unless probes.is_a?(Hash)
|
|
36
|
+
|
|
37
|
+
paths = ->(key) { Array(probes[key]).select { |p| p.is_a?(String) && !p.empty? }.map(&:downcase) }
|
|
38
|
+
exact = paths.call("exact").to_h { |p| [p, true] }
|
|
39
|
+
prefix = paths.call("prefix")
|
|
40
|
+
return nil if exact.empty? && prefix.empty?
|
|
41
|
+
|
|
42
|
+
ban = probes["ban"].is_a?(Numeric) ? [0, probes["ban"].to_i].max : 0
|
|
43
|
+
{ exact: exact, prefix: prefix, ban: ban }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Read from the path the router sees, so "/admin;x.css" isn't one.
|
|
47
|
+
def self.static?(target)
|
|
48
|
+
path = RequestRules.normalize_target(target)
|
|
49
|
+
STATIC_PREFIXES.any? { |prefix| path.start_with?(prefix) } || path.match?(STATIC)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Who the rate limit, the login count and the bans here are about: an
|
|
53
|
+
# IPv4 address (::ffff:a.b.c.d as a.b.c.d), or an IPv6 address's /64,
|
|
54
|
+
# which one machine usually holds whole -- as the browser check binds.
|
|
55
|
+
#
|
|
56
|
+
# Written the same way by every package, so a site whose servers run
|
|
57
|
+
# different languages on one Redis counts a visitor once: "198.51.100.7",
|
|
58
|
+
# or the /64 as its network, RFC 5952 (lower-case, compressed):
|
|
59
|
+
# "2001:db8:1:2::/64". The browser check's pass binds its own way
|
|
60
|
+
# (Challenge.who), which this doesn't change.
|
|
61
|
+
def self.who(ip)
|
|
62
|
+
key = IP.key(ip)
|
|
63
|
+
return ip.to_s if key.nil?
|
|
64
|
+
return IPAddr.new(key & 0xFFFF_FFFF, Socket::AF_INET).to_s if (key >> 32) == 0xFFFF
|
|
65
|
+
|
|
66
|
+
"#{IPAddr.new((key >> 64) << 64, Socket::AF_INET6)}/64"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# True when the path (no query, lower-cased, "//" as "/") is a probe, as
|
|
70
|
+
# sent or as the router reads it (";params" dropped, "." and ".."
|
|
71
|
+
# resolved, decoded once: "/x/..;/.env" is "/.env").
|
|
72
|
+
def self.probe?(path, probes)
|
|
73
|
+
return false unless probes
|
|
74
|
+
|
|
75
|
+
clean = SiteGoose.text(path).split(/[?#]/, 2)[0].to_s.downcase.gsub(%r{/{2,}}, "/")
|
|
76
|
+
[clean, RequestRules.normalize_target(path)].uniq.any? do |text|
|
|
77
|
+
probes[:exact].key?(text) || probes[:prefix].any? { |p| text.start_with?(p) }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The rules' browser check -> {skip:, exempt:}, or nil when the site isn't to be checked.
|
|
82
|
+
def self.compile_challenge(data)
|
|
83
|
+
return nil unless data.is_a?(Hash)
|
|
84
|
+
|
|
85
|
+
{ skip: Challenge.compile_skip(data["skip"]), exempt: IpSet.new(Array(data["exempt"])) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# This machine's host name for X-Server-Name: plain ASCII, 100 characters at most.
|
|
89
|
+
def self.server_name
|
|
90
|
+
SiteGoose.hostname.gsub(/[^\x21-\x7e]/, "")[0, 100]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.int(value, fallback = 0)
|
|
94
|
+
value.is_a?(Numeric) ? value.to_i : (value.is_a?(String) && value.match?(/\A\d+\z/) ? value.to_i : fallback)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Options: trust_proxy, mode ("monitor"/"block": overrides the dashboard),
|
|
98
|
+
# counter (callable(key, ttl) -> hits, shared between workers), cache_file
|
|
99
|
+
# (rules kept there between restarts).
|
|
100
|
+
def initialize(core, trust_proxy: nil, mode: nil, counter: nil, cache_file: nil, **_)
|
|
101
|
+
@core = core
|
|
102
|
+
@trust_proxy = trust_proxy
|
|
103
|
+
@mode = %w[monitor block].include?(mode.to_s) ? mode.to_s : nil
|
|
104
|
+
@counter = counter
|
|
105
|
+
@cache_file = cache_file
|
|
106
|
+
@rules = nil
|
|
107
|
+
@etag = ""
|
|
108
|
+
@lock = Mutex.new
|
|
109
|
+
@counts = {}
|
|
110
|
+
@queue = {}
|
|
111
|
+
# Bot protection here: address -> when its ban ends; address -> [window start, failed logins].
|
|
112
|
+
@bans = {}
|
|
113
|
+
@logins = {}
|
|
114
|
+
# The browser check's passes are signed with the site secret, so any of
|
|
115
|
+
# the site's servers accepts one another handed out.
|
|
116
|
+
@pass_key = Challenge.pass_key(core.secret)
|
|
117
|
+
load_cache
|
|
118
|
+
@jobs = []
|
|
119
|
+
return unless core.valid?
|
|
120
|
+
|
|
121
|
+
@jobs = [Periodic.new { sync }, Periodic.new(FLUSH_EVERY) { flush; FLUSH_EVERY }]
|
|
122
|
+
Forking.register(self)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def load_cache
|
|
126
|
+
return unless @cache_file && File.exist?(@cache_file)
|
|
127
|
+
|
|
128
|
+
cached = JSON.parse(File.read(@cache_file))
|
|
129
|
+
@rules = compile(cached["rules"])
|
|
130
|
+
@etag = cached["etag"].to_s
|
|
131
|
+
rescue StandardError
|
|
132
|
+
nil
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def after_fork_in_child
|
|
136
|
+
@lock = Mutex.new
|
|
137
|
+
@queue = {}
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def compile(data)
|
|
141
|
+
raise ArgumentError, "unknown rules version" unless data.is_a?(Hash) && data["v"] == PROTOCOL
|
|
142
|
+
|
|
143
|
+
rate = data["rate"].is_a?(Hash) ? data["rate"] : nil
|
|
144
|
+
rate = if rate && Firewall.int(rate["limit"]).positive?
|
|
145
|
+
{ limit: Firewall.int(rate["limit"]), window: [1, Firewall.int(rate["window"], 60).nonzero? || 60].max }
|
|
146
|
+
end
|
|
147
|
+
login = data["login"].is_a?(Hash) && Firewall.int(data["login"]["limit"]).positive? ? data["login"] : nil
|
|
148
|
+
{
|
|
149
|
+
enabled: data["enabled"] == true,
|
|
150
|
+
mode: data["mode"] == "block" ? "block" : "monitor",
|
|
151
|
+
sync: [MIN_SYNC, Firewall.int(data["sync"], RETRY).nonzero? || RETRY].max,
|
|
152
|
+
allow: IpSet.new(Array(data["allow"])),
|
|
153
|
+
# Crawlers the rate limit must not catch (absent in older rules).
|
|
154
|
+
goodbots: IpSet.new(Array(data["goodbots"])),
|
|
155
|
+
deny: IpSet.new(Array(data["deny"])),
|
|
156
|
+
list: IpSet.new(Array(data["lists"])),
|
|
157
|
+
shared: IpSet.new(Array(data["shared"])),
|
|
158
|
+
rate: rate,
|
|
159
|
+
banned: IpSet.new(Array(data["banned"])),
|
|
160
|
+
ban: data["ban"].is_a?(Hash) ? Firewall.int(data["ban"]["seconds"]) : 0,
|
|
161
|
+
login: login,
|
|
162
|
+
probes: Firewall.compile_probes(data["probes"]),
|
|
163
|
+
# Virtual patching: rules that match the request, not the address.
|
|
164
|
+
requests: RequestRules.compile(data["requests"]),
|
|
165
|
+
# The browser check: present only when the site is to be checked.
|
|
166
|
+
challenge: Firewall.compile_challenge(data["challenge"])
|
|
167
|
+
}
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def sync
|
|
171
|
+
headers = { "X-Firewall-Layers" => LAYERS }
|
|
172
|
+
name = Firewall.server_name
|
|
173
|
+
headers["X-Server-Name"] = name unless name.empty?
|
|
174
|
+
headers["If-None-Match"] = @etag unless @etag.empty?
|
|
175
|
+
res = @core.request("firewall", "/events/firewall/rules?site_id=#{@core.site_id}", method: "GET", auth: :bearer, headers: headers)
|
|
176
|
+
if res && res.status == 200
|
|
177
|
+
data = res.json
|
|
178
|
+
begin
|
|
179
|
+
@rules = compile(data)
|
|
180
|
+
@etag = res.headers["etag"].to_s
|
|
181
|
+
write_cache(data) if @cache_file
|
|
182
|
+
rescue StandardError => e
|
|
183
|
+
@core.log("firewall", "rules not used:", e.message)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
@rules ? @rules[:sync] : RETRY
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Written beside the file and renamed over it, so a crash or a second
|
|
190
|
+
# worker writing at once never leaves half a file to load.
|
|
191
|
+
def write_cache(data)
|
|
192
|
+
temp = "#{@cache_file}.#{Process.pid}.#{Thread.current.object_id}.tmp"
|
|
193
|
+
File.write(temp, JSON.generate({ "etag" => @etag, "rules" => data }))
|
|
194
|
+
File.rename(temp, @cache_file)
|
|
195
|
+
rescue StandardError => e
|
|
196
|
+
File.delete(temp) rescue nil # rubocop:disable Style/RescueModifier
|
|
197
|
+
@core.log("firewall", "rules not kept in the cache file:", e.message)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def over_rate?(ip, rate)
|
|
201
|
+
who = Firewall.who(ip)
|
|
202
|
+
window = (Time.now.to_f / rate[:window]).floor
|
|
203
|
+
if @counter
|
|
204
|
+
return @counter.call("afw:#{@core.site_id}:#{who}:#{window}", rate[:window] * 2).to_i > rate[:limit]
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
@lock.synchronize do
|
|
208
|
+
entry = @counts[who]
|
|
209
|
+
if entry.nil? || entry[0] != window
|
|
210
|
+
# Taken out and put back, so the hash runs oldest window first and
|
|
211
|
+
# a full table loses the addresses that went quiet longest ago.
|
|
212
|
+
@counts.delete(who)
|
|
213
|
+
Firewall.make_room(@counts)
|
|
214
|
+
entry = [window, 0]
|
|
215
|
+
@counts[who] = entry
|
|
216
|
+
end
|
|
217
|
+
entry[1] += 1
|
|
218
|
+
entry[1] > rate[:limit]
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# A table of addresses at COUNTER_MAX loses its oldest entries (insertion
|
|
223
|
+
# order), not everyone's count at once.
|
|
224
|
+
def self.make_room(table)
|
|
225
|
+
table.shift while table.size >= COUNTER_MAX
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def banned_here?(ip)
|
|
229
|
+
who = Firewall.who(ip)
|
|
230
|
+
ends = @bans[who]
|
|
231
|
+
return false unless ends
|
|
232
|
+
return true if ends > Time.now.to_f
|
|
233
|
+
|
|
234
|
+
@lock.synchronize { @bans.delete(who) }
|
|
235
|
+
false
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def ban_here(ip, seconds)
|
|
239
|
+
return unless seconds.positive?
|
|
240
|
+
|
|
241
|
+
who = Firewall.who(ip)
|
|
242
|
+
@lock.synchronize do
|
|
243
|
+
ends = [@bans.delete(who) || 0, Time.now.to_f + seconds].max
|
|
244
|
+
Firewall.make_room(@bans)
|
|
245
|
+
@bans[who] = ends
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# A failed login on your site: counted here (past the site's limit the
|
|
250
|
+
# address is banned straight away) and reported, so it counts on the
|
|
251
|
+
# Security page and gets banned on the other servers too.
|
|
252
|
+
def login_failed(ip)
|
|
253
|
+
key = IP.key(ip)
|
|
254
|
+
rules = @rules
|
|
255
|
+
return if key.nil? || (rules && rules[:allow].has?(key))
|
|
256
|
+
|
|
257
|
+
login = rules && rules[:login]
|
|
258
|
+
if login
|
|
259
|
+
now = Time.now.to_f
|
|
260
|
+
window = Firewall.int(login["window"], 3600).nonzero? || 3600
|
|
261
|
+
who = Firewall.who(ip)
|
|
262
|
+
reached = @lock.synchronize do
|
|
263
|
+
entry = @logins[who]
|
|
264
|
+
if entry.nil? || now - entry[0] >= window
|
|
265
|
+
@logins.delete(who)
|
|
266
|
+
Firewall.make_room(@logins)
|
|
267
|
+
entry = @logins[who] = [now, 0]
|
|
268
|
+
end
|
|
269
|
+
entry[1] += 1
|
|
270
|
+
hit = entry[1] >= Firewall.int(login["limit"])
|
|
271
|
+
@logins.delete(who) if hit
|
|
272
|
+
hit
|
|
273
|
+
end
|
|
274
|
+
ban_here(ip.to_s, Firewall.int(login["ban"], 3600).nonzero? || 3600) if reached
|
|
275
|
+
end
|
|
276
|
+
report({ "ip" => ip.to_s, "rule" => "login", "action" => "monitor", "method" => "", "path" => "", "user_agent" => "" })
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def report(event)
|
|
280
|
+
key = event.values_at("ip", "rule", "action", "method", "path").join("|")
|
|
281
|
+
@lock.synchronize do
|
|
282
|
+
row = @queue[key]
|
|
283
|
+
if row
|
|
284
|
+
row["hits"] += 1
|
|
285
|
+
elsif @queue.size < QUEUE_MAX
|
|
286
|
+
@queue[key] = event.merge("at" => SiteGoose.iso_now, "hits" => 1)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def flush
|
|
292
|
+
rows = @lock.synchronize do
|
|
293
|
+
taken = @queue.values
|
|
294
|
+
@queue = {}
|
|
295
|
+
taken
|
|
296
|
+
end
|
|
297
|
+
rows.each_slice(BATCH) do |batch|
|
|
298
|
+
@core.request("firewall", "/events/firewall/events", auth: :bearer, body: { "site_id" => @core.site_id, "events" => batch })
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# -> {action: "allow" | "block" | "monitor", rule:, status:}. Allowed
|
|
303
|
+
# addresses skip every other rule. accept and cookie (the request's
|
|
304
|
+
# headers) are for the browser check: rule "challenge", status 503,
|
|
305
|
+
# answered with challenge_page().
|
|
306
|
+
def check(ip, method = "", path = "", user_agent = "", accept = "", cookie = "", now = nil)
|
|
307
|
+
allow = { action: "allow", rule: "", status: 200 }
|
|
308
|
+
rules = @rules
|
|
309
|
+
return allow if rules.nil? || !rules[:enabled]
|
|
310
|
+
|
|
311
|
+
key = IP.key(ip)
|
|
312
|
+
return allow if key.nil? || rules[:allow].has?(key)
|
|
313
|
+
|
|
314
|
+
ip = ip.to_s
|
|
315
|
+
action = (@mode || rules[:mode]) == "block" ? "block" : "monitor"
|
|
316
|
+
rule = address_rule(rules, key, ip)
|
|
317
|
+
if rule.empty? && Firewall.probe?(path, rules[:probes])
|
|
318
|
+
rule = "probe"
|
|
319
|
+
# Asking for a secret file: banned here in block mode; the report bans it on the other servers.
|
|
320
|
+
ban_here(ip, rules[:probes][:ban]) if action == "block"
|
|
321
|
+
end
|
|
322
|
+
if rule.empty?
|
|
323
|
+
matched = RequestRules.match(rules[:requests], method, path, user_agent)
|
|
324
|
+
if matched
|
|
325
|
+
rule = "request:#{matched['id']}"
|
|
326
|
+
# A rule the dashboard only watches passes even in block mode; one
|
|
327
|
+
# that blocks also bans the address here, as a flood does.
|
|
328
|
+
action = action == "block" && matched["action"] == "block" ? "block" : "monitor"
|
|
329
|
+
ban_here(ip, matched["ban"]) if action == "block" && matched["ban"].positive?
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
if rule.empty? && rules[:rate] && !rules[:goodbots].has?(key) && !Firewall.static?(path) && over_rate?(ip, rules[:rate])
|
|
333
|
+
rule = "rate"
|
|
334
|
+
# Banned here straight away in block mode, as a probe is; the report bans it on the other servers.
|
|
335
|
+
ban_here(ip, rules[:ban]) if action == "block"
|
|
336
|
+
end
|
|
337
|
+
event = lambda do |name, act|
|
|
338
|
+
{ "ip" => ip, "rule" => name, "action" => act, "method" => SiteGoose.text(method, 10),
|
|
339
|
+
"path" => SiteGoose.text(path).split("?", 2)[0].to_s[0, 300], "user_agent" => SiteGoose.text(user_agent, 300) }
|
|
340
|
+
end
|
|
341
|
+
# Not a mode matter: the collector only sends `challenge` when the site
|
|
342
|
+
# is to be checked, and a match only watched (monitor) gets it too
|
|
343
|
+
# (firewall.md, step 7).
|
|
344
|
+
if (rule.empty? || action != "block") && needs_check?(rules, key, ip, method, path, accept, cookie, now)
|
|
345
|
+
report(event.call(rule, action)) unless rule.empty?
|
|
346
|
+
rule = "challenge"
|
|
347
|
+
action = "block"
|
|
348
|
+
end
|
|
349
|
+
return allow if rule.empty?
|
|
350
|
+
|
|
351
|
+
report(event.call(rule, action))
|
|
352
|
+
{ action: action, rule: rule, status: rule == "rate" ? 429 : (rule == "challenge" ? 503 : 403) }
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# The rules about the address alone (deny, bans, lists, shared): "" when none.
|
|
356
|
+
def address_rule(rules, key, ip)
|
|
357
|
+
if rules[:deny].has?(key) then "deny"
|
|
358
|
+
elsif rules[:banned].has?(key) || banned_here?(ip) then "ban"
|
|
359
|
+
elsif rules[:list].has?(key) then "list"
|
|
360
|
+
elsif rules[:shared].has?(key) then "shared"
|
|
361
|
+
else ""
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# The address rules only, for the tracker's own addresses (the proxy):
|
|
366
|
+
# a denied or banned address gets nothing from us either, but beacons
|
|
367
|
+
# are never rate-limited, checked or matched against request rules.
|
|
368
|
+
# -> the same shape as check().
|
|
369
|
+
def check_address(ip, method = "", path = "", user_agent = "")
|
|
370
|
+
allow = { action: "allow", rule: "", status: 200 }
|
|
371
|
+
rules = @rules
|
|
372
|
+
return allow if rules.nil? || !rules[:enabled]
|
|
373
|
+
|
|
374
|
+
key = IP.key(ip)
|
|
375
|
+
return allow if key.nil? || rules[:allow].has?(key)
|
|
376
|
+
|
|
377
|
+
rule = address_rule(rules, key, ip.to_s)
|
|
378
|
+
return allow if rule.empty?
|
|
379
|
+
|
|
380
|
+
action = (@mode || rules[:mode]) == "block" ? "block" : "monitor"
|
|
381
|
+
report({ "ip" => ip.to_s, "rule" => rule, "action" => action, "method" => SiteGoose.text(method, 10),
|
|
382
|
+
"path" => SiteGoose.text(path).split("?", 2)[0].to_s[0, 300], "user_agent" => SiteGoose.text(user_agent, 300) })
|
|
383
|
+
{ action: action, rule: rule, status: 403 }
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Step 7: the browser check is on, the request asks for a page outside
|
|
387
|
+
# skip, the address is in none of allow (already passed), goodbots and
|
|
388
|
+
# exempt, and it has no valid pass.
|
|
389
|
+
def needs_check?(rules, key, ip, method, path, accept, cookie, now)
|
|
390
|
+
check = rules[:challenge]
|
|
391
|
+
return false if check.nil? || !Challenge.asks_for_page?(method, path, accept, check[:skip])
|
|
392
|
+
return false if rules[:goodbots].has?(key) || check[:exempt].has?(key)
|
|
393
|
+
|
|
394
|
+
!Challenge.pass?(@pass_key, ip, cookie, now)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# [503, headers, body] for a request check() gave rule "challenge".
|
|
398
|
+
def challenge_page(ip, method)
|
|
399
|
+
Challenge.page(@pass_key, ip, method)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def retry_after
|
|
403
|
+
rules = @rules
|
|
404
|
+
rules && rules[:rate] ? rules[:rate][:window].to_s : "60"
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def client_ip(remote, headers)
|
|
408
|
+
SiteGoose.client_ip(remote, headers, @trust_proxy)
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def close
|
|
412
|
+
@jobs.each(&:stop)
|
|
413
|
+
Forking.unregister(self)
|
|
414
|
+
flush
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Software inventory module (protocol in sdk/protocols/inventory.md): the
|
|
4
|
+
# gems the app runs and its Ruby version, sent to POST /events/inventory a
|
|
5
|
+
# minute after start and then once a day (the collector's `every`), so the
|
|
6
|
+
# Security check can match them against known security holes (OSV's
|
|
7
|
+
# "RubyGems" ecosystem). Runs in a background thread, never in a request;
|
|
8
|
+
# names and versions only.
|
|
9
|
+
#
|
|
10
|
+
# Where the list comes from: the gems loaded in this process (Bundler's, so
|
|
11
|
+
# what Gemfile.lock pins and the groups the app runs with), each marked
|
|
12
|
+
# `direct` when the Gemfile asks for it by name. With no Bundler (a plain
|
|
13
|
+
# `ruby app.rb`), Gemfile.lock next to the app is read instead.
|
|
14
|
+
|
|
15
|
+
module SiteGoose
|
|
16
|
+
class Inventory
|
|
17
|
+
FIRST = 60
|
|
18
|
+
RETRY_EVERY = 60 * 60
|
|
19
|
+
EVERY = 24 * 60 * 60
|
|
20
|
+
MAX_PACKAGES = 5000
|
|
21
|
+
ECOSYSTEM = "RubyGems"
|
|
22
|
+
|
|
23
|
+
# " rack (3.1.8)" and " nokogiri (1.16.7-x86_64-linux)" under a
|
|
24
|
+
# GEM, GIT or PATH section's specs, as [name, version].
|
|
25
|
+
def self.lockfile_specs(text)
|
|
26
|
+
found = {}
|
|
27
|
+
in_specs = false
|
|
28
|
+
text.to_s.each_line do |line|
|
|
29
|
+
if line.match?(/\A\S/)
|
|
30
|
+
in_specs = false
|
|
31
|
+
elsif line.match?(/\A specs:\s*\z/)
|
|
32
|
+
in_specs = true
|
|
33
|
+
elsif in_specs && (m = /\A {4}([^\s(]+) \(([^)]+)\)\s*\z/.match(line))
|
|
34
|
+
version = m[2].split("-", 2)[0]
|
|
35
|
+
found[m[1]] ||= version
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
found
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The Gemfile's own dependencies, by name, from a Gemfile.lock's DEPENDENCIES.
|
|
42
|
+
def self.lockfile_direct(text)
|
|
43
|
+
names = {}
|
|
44
|
+
in_deps = false
|
|
45
|
+
text.to_s.each_line do |line|
|
|
46
|
+
if line.match?(/\A\S/)
|
|
47
|
+
in_deps = line.start_with?("DEPENDENCIES")
|
|
48
|
+
elsif in_deps && (m = /\A {2}([^\s!(]+)/.match(line))
|
|
49
|
+
names[m[1]] = true
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
names
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.bundler_direct
|
|
56
|
+
return {} unless defined?(::Bundler) && ::Bundler.respond_to?(:definition)
|
|
57
|
+
|
|
58
|
+
::Bundler.definition.dependencies.to_h { |dep| [dep.name, true] }
|
|
59
|
+
rescue StandardError, ScriptError
|
|
60
|
+
{}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# [{"ecosystem", "name", "version", "direct"?}], one per gem, sorted by name.
|
|
64
|
+
def self.installed_packages(root = nil)
|
|
65
|
+
specs = {}
|
|
66
|
+
direct = {}
|
|
67
|
+
if defined?(::Gem) && ::Gem.respond_to?(:loaded_specs)
|
|
68
|
+
::Gem.loaded_specs.each_value do |spec|
|
|
69
|
+
name = spec.name.to_s
|
|
70
|
+
version = spec.version.to_s
|
|
71
|
+
specs[name] ||= version unless name.empty? || version.empty?
|
|
72
|
+
end
|
|
73
|
+
direct = bundler_direct
|
|
74
|
+
end
|
|
75
|
+
if specs.size <= 1
|
|
76
|
+
lock = File.join((root || Dir.pwd).to_s, "Gemfile.lock")
|
|
77
|
+
if File.file?(lock)
|
|
78
|
+
text = File.read(lock, encoding: "UTF-8")
|
|
79
|
+
specs = lockfile_specs(text).merge(specs)
|
|
80
|
+
direct = lockfile_direct(text) if direct.empty?
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
specs.sort_by { |name, _| name.downcase }.map do |name, version|
|
|
84
|
+
row = { "ecosystem" => ECOSYSTEM, "name" => name, "version" => version.delete_prefix("v") }
|
|
85
|
+
row["direct"] = true if direct[name]
|
|
86
|
+
row
|
|
87
|
+
end
|
|
88
|
+
rescue StandardError
|
|
89
|
+
[]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Options: host (the server's name; default the host name), root (where
|
|
93
|
+
# Gemfile.lock is when Bundler isn't loaded; default the working directory).
|
|
94
|
+
def initialize(core, host: nil, root: nil, **_)
|
|
95
|
+
@core = core
|
|
96
|
+
@root = root
|
|
97
|
+
@host = SiteGoose.text(host.nil? ? SiteGoose.hostname : host).gsub(/[^A-Za-z0-9._-]/, "-")[0, 100]
|
|
98
|
+
@host = "server" if @host.empty?
|
|
99
|
+
@job = core.valid? ? Periodic.new(FIRST) { report } : nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def body
|
|
103
|
+
packages = Inventory.installed_packages(@root)
|
|
104
|
+
{
|
|
105
|
+
"site_id" => @core.site_id,
|
|
106
|
+
"host" => @host,
|
|
107
|
+
"at" => SiteGoose.iso_now,
|
|
108
|
+
"runtime" => { "ruby" => RUBY_VERSION },
|
|
109
|
+
"packages" => packages.first(MAX_PACKAGES),
|
|
110
|
+
"truncated" => packages.size > MAX_PACKAGES
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def report
|
|
115
|
+
return RETRY_EVERY unless @core.on?("inventory")
|
|
116
|
+
|
|
117
|
+
res = @core.request("inventory", "/events/inventory", auth: :bearer, body: body)
|
|
118
|
+
return RETRY_EVERY if res.nil? || !res.ok?
|
|
119
|
+
|
|
120
|
+
answer = res.json
|
|
121
|
+
every = answer.is_a?(Hash) ? answer["every"] : nil
|
|
122
|
+
every.is_a?(Integer) && every.positive? ? [RETRY_EVERY, every].max : EVERY
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def flush; end
|
|
126
|
+
|
|
127
|
+
def close
|
|
128
|
+
@job&.stop
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|