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,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