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,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ # How the server is set up (protocol in sdk/protocols/posture.md): the
4
+ # settings that leave the app open (Rails', when it's a Rails app), whether it
5
+ # runs as root, code anyone on the machine can write, and files in the folders
6
+ # it serves to the web that should never be public. Sent to POST
7
+ # /events/posture a few minutes after start and then once a day (the
8
+ # collector's `every`), so Security -> Security check can say what to change.
9
+ #
10
+ # It only reads: nothing here changes a setting, a permission or a file, and
11
+ # no file's contents are sent -- paths, sizes, times and short values only.
12
+ # The walk is bounded and runs in a background thread, never in a request;
13
+ # with no folder served to the web there is nothing to walk and only the
14
+ # settings are reported.
15
+
16
+ module SiteGoose
17
+ class Posture
18
+ FIRST = 180
19
+ RETRY_EVERY = 60 * 60
20
+ EVERY = 24 * 60 * 60
21
+ MAX_CHECKS = 200
22
+ MAX_EXPOSURE = 50
23
+ MAX_ENTRIES = 20_000
24
+ MAX_DEPTH = 6
25
+ MAX_PERM_CHECKS = 3000
26
+ SKIP_DIRS = %w[.git .svn .hg node_modules vendor cache .cache .history __pycache__].freeze
27
+ VCS_DIRS = %w[.git .svn .hg .bzr].freeze
28
+ CODE_FILE = /\.(py|pyw|js|cjs|mjs|php[0-9]?|phtml|rb|erb|pl|sh)\z/i.freeze
29
+ # Files that shouldn't be downloadable, by name; first match wins.
30
+ EXPOSURE = [
31
+ ["database_dump", /\.sql(\.(gz|bz2|xz|zip))?\z/i],
32
+ ["private_key", /\A(id_rsa|id_dsa|id_ecdsa|id_ed25519)\z|\.(pem|key|p12|pfx|jks)\z/i],
33
+ ["env", /\A\.env(\z|\.)|\Aenv\.(ini|txt)\z/i],
34
+ ["archive", /\.(zip|tar|tgz|tar\.gz|tar\.bz2|rar|7z)\z/i],
35
+ ["backup", /\.(bak|old|orig|save|swp|copy|backup)\z|~\z|\.(py|rb|yml|json|conf|config|ini|ya?ml)\.(bak|old|orig|save|txt)\z/i],
36
+ ["info", /\A(phpinfo|info|adminer)\.php\z/i],
37
+ ["log", /\A(debug|error|access)[-_.]?log\z|\.log\z/i]
38
+ ].freeze
39
+ # What a secret looks like when nobody set one.
40
+ INSECURE_KEYS = %w[changeme secret your-secret-key put-your-secret-key-here].freeze
41
+
42
+ def self.kind_of(name)
43
+ EXPOSURE.each { |kind, pattern| return kind if pattern.match?(name) }
44
+ nil
45
+ end
46
+
47
+ # A folder inside another folder on the list is already covered: without
48
+ # this, every file under it would be reported twice.
49
+ def self.without_nested(roots)
50
+ unique = {}
51
+ roots.each { |root| unique[root["dir"]] ||= root }
52
+ unique.values.reject do |root|
53
+ unique.keys.any? { |other| other != root["dir"] && "#{root['dir']}/".start_with?("#{other}/") }
54
+ end
55
+ end
56
+
57
+ # [{"dir", "prefix"}]: prefix is where the web serves the folder, so the
58
+ # path we report is the one the dashboard can ask the site for. Not told:
59
+ # Rails' public/ (served at /), when it's a Rails app and it's there.
60
+ def self.roots_of(given)
61
+ roots = []
62
+ Array(given).each do |entry|
63
+ folder = entry.is_a?(Hash) ? (entry[:dir] || entry["dir"]) : entry
64
+ next if folder.nil? || folder.to_s.empty?
65
+
66
+ prefix = entry.is_a?(Hash) ? (entry[:prefix] || entry["prefix"]).to_s.gsub(%r{\A/+|/+\z}, "") : ""
67
+ roots << { "dir" => File.expand_path(folder.to_s), "prefix" => prefix }
68
+ end
69
+ return without_nested(roots) unless roots.empty?
70
+
71
+ folder = rails_public
72
+ roots << { "dir" => File.expand_path(folder), "prefix" => "" } if folder && File.directory?(folder)
73
+ without_nested(roots)
74
+ end
75
+
76
+ def self.rails_app
77
+ return nil unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
78
+
79
+ ::Rails.application
80
+ rescue StandardError
81
+ nil
82
+ end
83
+
84
+ def self.rails_public
85
+ return nil unless rails_app
86
+
87
+ ::Rails.public_path.to_s
88
+ rescue StandardError
89
+ nil
90
+ end
91
+
92
+ def self.check(checks, id, failed, value = nil)
93
+ row = { "id" => id, "state" => failed.nil? ? "unknown" : (failed ? "fail" : "pass") }
94
+ row["value"] = value.to_s[0, 200] if value && !value.to_s.empty?
95
+ checks << row
96
+ end
97
+
98
+ # The Rails settings that decide how much a hole in the site is worth.
99
+ def self.rails_checks
100
+ app = rails_app
101
+ return [] unless app
102
+
103
+ config = app.config
104
+ checks = []
105
+ env = ::Rails.env.to_s
106
+ check(checks, "app.rails_env", env != "production", env)
107
+ # Full error pages, with code and backtraces, for every visitor.
108
+ check(checks, "app.rails_debug", config.consider_all_requests_local ? true : false)
109
+ key = begin
110
+ app.secret_key_base.to_s
111
+ rescue StandardError
112
+ nil
113
+ end
114
+ check(checks, "app.secret_key", key.nil? ? nil : (key.size < 32 || INSECURE_KEYS.any? { |mark| key.downcase.include?(mark) }))
115
+ hosts = config.respond_to?(:hosts) ? Array(config.hosts) : nil
116
+ unless hosts.nil?
117
+ # An empty list switches host checking off: any Host header is served.
118
+ shown = hosts.first(10).map { |h| h.respond_to?(:source) ? h.source : h.to_s }
119
+ check(checks, "app.allowed_hosts", hosts.empty? || hosts.any? { |h| h.to_s == "*" }, shown.join(", "))
120
+ end
121
+ session = config.respond_to?(:session_options) ? (config.session_options || {}) : {}
122
+ check(checks, "app.session_cookie_httponly", session[:httponly] == false)
123
+ # Cookie flags only matter once the site is on HTTPS, which these say it is.
124
+ force_ssl = config.respond_to?(:force_ssl) && config.force_ssl ? true : false
125
+ if force_ssl || session[:secure]
126
+ check(checks, "app.session_cookie_secure", !(force_ssl || session[:secure]))
127
+ check(checks, "app.https_redirect", !force_ssl)
128
+ end
129
+ checks
130
+ rescue StandardError
131
+ checks || []
132
+ end
133
+
134
+ def self.app_checks
135
+ checks = rails_checks
136
+ # Running the web app as root turns any hole in it into the whole machine.
137
+ check(checks, "server.runs_as_root", Process.uid.zero?) if Process.respond_to?(:uid)
138
+ checks
139
+ end
140
+
141
+ # One bounded walk per root: [exposure, writable or nil, examples, truncated].
142
+ def self.walk(roots)
143
+ exposure = []
144
+ examples = []
145
+ writable = checked = entries = 0
146
+ roots.each do |root|
147
+ queue = [[root["dir"], 0]]
148
+ until queue.empty?
149
+ folder, depth = queue.shift
150
+ names = begin
151
+ Dir.children(folder)
152
+ rescue SystemCallError
153
+ next
154
+ end
155
+ names.each do |name|
156
+ entries += 1
157
+ return [exposure, checked.positive? ? writable : nil, examples, true] if entries > MAX_ENTRIES
158
+
159
+ path = File.join(folder, name)
160
+ info = begin
161
+ File.lstat(path)
162
+ rescue SystemCallError
163
+ next
164
+ end
165
+ next if info.symlink?
166
+
167
+ relative = [root["prefix"], path.delete_prefix("#{root['dir']}/")].reject(&:empty?).join("/")
168
+ if info.directory?
169
+ if VCS_DIRS.include?(name)
170
+ exposure << { "path" => "#{relative}/config", "kind" => "vcs" } if exposure.size < MAX_EXPOSURE
171
+ next
172
+ end
173
+ queue << [path, depth + 1] if depth + 1 <= MAX_DEPTH && !SKIP_DIRS.include?(name)
174
+ next
175
+ end
176
+ next unless info.file?
177
+
178
+ kind = kind_of(name)
179
+ if kind && exposure.size < MAX_EXPOSURE
180
+ exposure << { "path" => relative, "kind" => kind, "size" => info.size, "mtime" => info.mtime.utc.iso8601(3) }
181
+ end
182
+ next unless checked < MAX_PERM_CHECKS && CODE_FILE.match?(name)
183
+
184
+ checked += 1
185
+ if info.world_writable?
186
+ writable += 1
187
+ examples << relative if examples.size < 3
188
+ end
189
+ end
190
+ end
191
+ end
192
+ [exposure, checked.positive? ? writable : nil, examples, false]
193
+ end
194
+
195
+ # Options: roots (the folders the web serves, as "public" or
196
+ # {dir: ..., prefix: "assets"}; default: Rails' public/), host (the
197
+ # server's name), checks (a callable returning more checks of your own).
198
+ def initialize(core, roots: nil, host: nil, checks: nil, **_)
199
+ @core = core
200
+ @given = roots
201
+ @roots = nil
202
+ @extra = checks
203
+ @host = SiteGoose.text(host.nil? ? SiteGoose.hostname : host).gsub(/[^A-Za-z0-9._-]/, "-")[0, 100]
204
+ @host = "server" if @host.empty?
205
+ @job = core.valid? ? Periodic.new(FIRST) { send_report } : nil
206
+ end
207
+
208
+ # Worked out on first use, once the app (Rails above all) has booted.
209
+ def roots
210
+ @roots ||= Posture.roots_of(@given)
211
+ end
212
+
213
+ # What would be reported now (also used by the job and by tests).
214
+ def report
215
+ exposure, writable, examples, truncated = Posture.walk(roots)
216
+ checks = Posture.app_checks
217
+ unless writable.nil?
218
+ row = { "id" => "server.code_writable", "state" => writable.positive? ? "fail" : "pass" }
219
+ row["value"] = "#{writable} files, e.g. #{examples.join(', ')}" if writable.positive?
220
+ checks << row
221
+ end
222
+ begin
223
+ extra = @extra.respond_to?(:call) ? @extra.call : []
224
+ Array(extra).each do |c|
225
+ next unless c.is_a?(Hash)
226
+
227
+ c = c.to_h { |k, v| [k.to_s, v] }
228
+ next unless c["id"] && %w[pass fail unknown].include?(c["state"].to_s)
229
+
230
+ row = { "id" => c["id"].to_s.downcase[0, 60], "state" => c["state"].to_s }
231
+ row["value"] = c["value"].to_s[0, 200] if c["value"]
232
+ checks << row
233
+ end
234
+ rescue StandardError
235
+ nil
236
+ end
237
+ body = {
238
+ "site_id" => @core.site_id,
239
+ "host" => @host,
240
+ "at" => SiteGoose.iso_now,
241
+ "checks" => checks.first(MAX_CHECKS),
242
+ "facts" => { "agent" => "ruby/#{RUBY_VERSION}" }
243
+ }
244
+ unless roots.empty?
245
+ # Left out when there's no folder to walk: the dashboard then says
246
+ # this wasn't checked, instead of taking an empty walk for a clean one.
247
+ body["exposure"] = exposure.first(MAX_EXPOSURE)
248
+ body["facts"]["webroot"] = roots[0]["dir"][0, 100]
249
+ end
250
+ body["truncated"] = true if truncated
251
+ body
252
+ end
253
+
254
+ def send_report
255
+ # The set-up checks ride the software inventory switch.
256
+ return RETRY_EVERY unless @core.on?("inventory")
257
+
258
+ res = @core.request("posture", "/events/posture", auth: :bearer, body: report)
259
+ return RETRY_EVERY if res.nil? || !res.ok?
260
+
261
+ answer = res.json
262
+ every = answer.is_a?(Hash) && answer["every"].is_a?(Integer) && answer["every"].positive? ? answer["every"] : EVERY
263
+ [RETRY_EVERY, every].max
264
+ end
265
+
266
+ def flush; end
267
+
268
+ def close
269
+ @job&.stop
270
+ end
271
+ end
272
+ end
@@ -0,0 +1,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ # First-party proxy: serving the tracker from the site's own domain, so ad
4
+ # blockers don't hide visitors.
5
+ #
6
+ # Blockers stop analytics by recognising the address a script comes from and
7
+ # what it is called ("track.js", "collect"). This app is already a server on
8
+ # the site's domain, so the package serves both itself:
9
+ #
10
+ # <prefix>/js/<rest> -> <endpoint>/<rest> (tracker files)
11
+ # <prefix>/api/<rest> -> <endpoint>/collectanalytics/<rest> (the collector)
12
+ #
13
+ # The same two rules as the hand-written Nginx/Cloudflare ones, so one install
14
+ # check covers both. The prefix comes from the site secret: not guessable from
15
+ # the page, and the same every time, so a browser's cache and a CDN in front
16
+ # stay valid. It answers before every other module: a visitor's beacon is our
17
+ # own traffic, not a page of the site, so it isn't counted, timed or rate-limited.
18
+
19
+ require "digest"
20
+ require "net/http"
21
+ require "uri"
22
+
23
+ module SiteGoose
24
+ class Proxy
25
+ # The tracker files, by name: anything else is refused rather than forwarded.
26
+ FILES = %w[track.js s.js snapshot.js replay.js survey.js banner.js leadform.js notice.js editor.js picker.js
27
+ feedback.js shield.js widget.js share.js].freeze
28
+ # A pinned version never changes; /v<major>/ follows new releases, so it is
29
+ # held for an hour, the same as the collector serves it with.
30
+ TTL_PINNED_S = 7 * 24 * 60 * 60
31
+ TTL_MAJOR_S = 60 * 60
32
+ MAX_BODY = 6 * 1024 * 1024
33
+ # The collector reads these; everything else, cookies above all, stays here.
34
+ PASS_HEADERS = %w[content-type user-agent accept-language referer origin sec-fetch-site sec-ch-ua
35
+ sec-ch-ua-mobile sec-ch-ua-platform].freeze
36
+ SAFE_PATH = %r{\A[A-Za-z0-9/_.-]{1,120}\z}.freeze
37
+ FILE_PATH = %r{\Av(\d+(?:\.\d+\.\d+)?)/([a-z]+\.js)\z}.freeze
38
+ # These run on the app's own request threads, so they are short, and a
39
+ # collector that stops answering is left alone for a while rather than
40
+ # costing every beacon a timeout: a black-holed collector must not use
41
+ # up Puma's threads.
42
+ CONNECT_TIMEOUT = 1
43
+ READ_TIMEOUT = 2
44
+ BREAKER_FAILURES = 3
45
+ BREAKER_OPEN_S = 30
46
+ # A tracker file that couldn't be fetched isn't asked for again this soon.
47
+ FILE_RETRY_S = 30
48
+
49
+ def self.not_found
50
+ [404, { "content-type" => "text/plain; charset=utf-8" }, "Not found"]
51
+ end
52
+
53
+ def self.prefix_for(secret)
54
+ Digest::SHA256.hexdigest("proxy|#{secret}")[0, 8]
55
+ end
56
+
57
+ # Sent as X-Proxy-Key, so the collector believes the visitor addresses
58
+ # this proxy passes on: anyone can write X-Forwarded-For. Worked out from
59
+ # the secret as the collector does (expresscollect/utils/proxyKey.js).
60
+ def self.key_for(secret)
61
+ Digest::SHA256.hexdigest("proxykey|#{secret}")[0, 32]
62
+ end
63
+
64
+ attr_reader :prefix, :key
65
+
66
+ # prefix: the path it answers on (default: from the secret).
67
+ def initialize(core, prefix: nil, enabled: true, **_)
68
+ @core = core
69
+ @enabled = enabled
70
+ @prefix = (prefix || Proxy.prefix_for(core.secret)).to_s.gsub(/[^A-Za-z0-9_-]/, "")
71
+ @key = core.secret.empty? ? "" : Proxy.key_for(core.secret)
72
+ # One copy of each tracker file, so a repeat visitor costs no upstream
73
+ # request. A handful of small files: it never grows.
74
+ @files = {}
75
+ @failed = {}
76
+ @lock = Mutex.new
77
+ @failures = 0
78
+ @open_until = 0.0
79
+ end
80
+
81
+ # True while the collector is being left alone (after BREAKER_FAILURES
82
+ # failures in a row). When the pause ends one request tries again; the
83
+ # others keep the short answer until it has.
84
+ def resting?
85
+ now = SiteGoose.clock
86
+ @lock.synchronize do
87
+ next false if @failures < BREAKER_FAILURES
88
+ next true if now < @open_until
89
+
90
+ @open_until = now + READ_TIMEOUT + CONNECT_TIMEOUT
91
+ false
92
+ end
93
+ end
94
+
95
+ def worked
96
+ @lock.synchronize { @failures = 0 }
97
+ end
98
+
99
+ def failed
100
+ @lock.synchronize do
101
+ @failures += 1
102
+ @open_until = SiteGoose.clock + BREAKER_OPEN_S if @failures >= BREAKER_FAILURES
103
+ end
104
+ end
105
+
106
+ def on?
107
+ @enabled && @core.valid? && !@prefix.empty? && @core.on?("proxy")
108
+ end
109
+
110
+ # The tracker's address on this site, for the page's script tag.
111
+ def script_url(version = "1")
112
+ "/#{@prefix}/js/v#{version}/s.js"
113
+ end
114
+
115
+ # Where visits are sent, for the tag's data-collect.
116
+ def collect_url
117
+ "/#{@prefix}/api/e"
118
+ end
119
+
120
+ # [kind ("js" | "api" | "bad"), the rest] when the address is ours, else nil.
121
+ def match(path)
122
+ return nil unless on?
123
+
124
+ head = "/#{@prefix}/"
125
+ path = path.to_s
126
+ return nil unless path.start_with?(head)
127
+
128
+ rest = path[head.size..]
129
+ %w[js api].each do |kind|
130
+ next unless rest.start_with?("#{kind}/")
131
+
132
+ tail = rest[(kind.size + 1)..]
133
+ return [kind, tail] if !tail.empty? && !tail.include?("..") && tail.match?(SAFE_PATH)
134
+
135
+ break
136
+ end
137
+ ["bad", ""]
138
+ end
139
+
140
+ # [status, headers, body]. Blocking (up to the timeout).
141
+ def handle(kind, rest, method: "GET", query: "", headers: {}, body: nil, client_ip: "")
142
+ return file(rest) if kind == "js"
143
+ return Proxy.not_found unless kind == "api"
144
+
145
+ collector(rest, method, query, headers || {}, body, client_ip)
146
+ end
147
+
148
+ def fetch(url, method: "GET", headers: {}, body: nil)
149
+ uri = URI.parse(url)
150
+ klass = method == "POST" ? Net::HTTP::Post : Net::HTTP::Get
151
+ req = klass.new(uri.request_uri, headers)
152
+ req.body = body if body && method == "POST"
153
+ Forking.sending do
154
+ http = Net::HTTP.new(uri.host, uri.port)
155
+ http.use_ssl = uri.scheme == "https"
156
+ http.open_timeout = CONNECT_TIMEOUT
157
+ http.read_timeout = READ_TIMEOUT
158
+ http.write_timeout = READ_TIMEOUT if http.respond_to?(:write_timeout=)
159
+ http.start { |h| h.request(req) }
160
+ end
161
+ end
162
+
163
+ # Any version number fits the file pattern, so the list is kept short.
164
+ def remember_failure(rest)
165
+ @lock.synchronize do
166
+ @failed.delete(rest)
167
+ @failed.shift while @failed.size >= 200
168
+ @failed[rest] = SiteGoose.clock
169
+ end
170
+ end
171
+
172
+ def file(rest)
173
+ found = FILE_PATH.match(rest)
174
+ return Proxy.not_found if found.nil? || !FILES.include?(found[2])
175
+
176
+ pinned = found[1].include?(".")
177
+ held = @lock.synchronize { @files[rest] }
178
+ body = nil
179
+ body = held[0] if held && held[1] > Time.now.to_f - (pinned ? TTL_PINNED_S : TTL_MAJOR_S)
180
+ recently_failed = @lock.synchronize { (at = @failed[rest]) && at > SiteGoose.clock - FILE_RETRY_S }
181
+ if body.nil? && !recently_failed && !resting?
182
+ begin
183
+ answer = fetch("#{@core.endpoint}/#{rest}")
184
+ fetched = answer.body.to_s
185
+ answer.code.to_i >= 500 ? failed : worked
186
+ if answer.code.to_i == 200 && fetched.bytesize > 500 && fetched.bytesize <= MAX_BODY
187
+ body = fetched
188
+ @lock.synchronize do
189
+ @files[rest] = [body, Time.now.to_f]
190
+ @failed.delete(rest)
191
+ end
192
+ else
193
+ remember_failure(rest)
194
+ end
195
+ rescue StandardError, Timeout::Error => e
196
+ failed
197
+ remember_failure(rest)
198
+ @core.log("proxy", "could not fetch #{rest}: #{e.message}")
199
+ end
200
+ end
201
+ # Upstream is unreachable: an old copy beats no tracker at all.
202
+ body = held[0] if body.nil? && held
203
+ return [502, {}, ""] if body.nil?
204
+
205
+ [200, {
206
+ "content-type" => "application/javascript; charset=utf-8",
207
+ "cache-control" => pinned ? "public, max-age=31536000, immutable" : "public, max-age=3600",
208
+ "x-content-type-options" => "nosniff"
209
+ }, body]
210
+ end
211
+
212
+ def collector(rest, method, query, headers, body, client_ip)
213
+ method = (method.to_s.empty? ? "GET" : method.to_s).upcase
214
+ if method == "OPTIONS"
215
+ # Same origin as the page, so a preflight shouldn't happen; answer it
216
+ # cheaply rather than passing it on.
217
+ return [204, { "access-control-allow-methods" => "GET, POST, OPTIONS", "access-control-allow-headers" => "Content-Type",
218
+ "access-control-max-age" => "86400" }, ""]
219
+ end
220
+ return Proxy.not_found unless %w[GET POST].include?(method)
221
+ return [413, {}, ""] if body && body.bytesize > MAX_BODY
222
+
223
+ # The visitor's address: the collector needs it for the country, the
224
+ # daily visitor ID and the per-visitor rate limit.
225
+ out = { "X-Forwarded-For" => client_ip.to_s }
226
+ out["X-Proxy-Key"] = @key unless @key.empty?
227
+ PASS_HEADERS.each do |name|
228
+ value = headers[name]
229
+ next if value.nil? || value.to_s.empty?
230
+
231
+ out[name.split("-").map(&:capitalize).join("-")] = value.to_s.delete("\r\n")
232
+ end
233
+ url = "#{@core.endpoint}/collectanalytics/#{rest}#{query.to_s.empty? ? '' : "?#{query}"}"
234
+ # Left alone for now: answered straight away; the tracker treats a
235
+ # failed beacon as lost and carries on.
236
+ return [502, { "retry-after" => BREAKER_OPEN_S.to_s }, ""] if resting?
237
+
238
+ begin
239
+ answer = fetch(url, method: method, headers: out, body: method == "POST" ? body.to_s : nil)
240
+ answer.code.to_i >= 500 ? failed : worked
241
+ passed = {}
242
+ %w[content-type cache-control retry-after].each do |name|
243
+ value = answer[name]
244
+ passed[name] = value if value && !value.empty?
245
+ end
246
+ text = answer.body.to_s
247
+ [answer.code.to_i, passed, text.bytesize > MAX_BODY ? text.byteslice(0, MAX_BODY) : text]
248
+ rescue StandardError, Timeout::Error => e
249
+ # The tracker already treats a failed beacon as lost and carries on.
250
+ failed
251
+ @core.log("proxy", "could not reach the collector: #{e.message}")
252
+ [502, {}, ""]
253
+ end
254
+ end
255
+
256
+ def flush; end
257
+
258
+ def close; end
259
+ end
260
+ end