devbench 0.5.0 → 0.7.0
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 +4 -4
- data/README.md +107 -20
- data/lib/devbench/config.rb +109 -27
- data/lib/devbench/direct.rb +172 -23
- data/lib/devbench/egress_policy.rb +101 -0
- data/lib/devbench/logs.rb +413 -0
- data/lib/devbench/railtie.rb +21 -0
- data/lib/devbench/scrub.rb +46 -8
- data/lib/devbench/sidekiq_hooks.rb +4 -0
- data/lib/devbench/transport.rb +65 -10
- data/lib/devbench/version.rb +1 -1
- data/lib/devbench/view_helper.rb +62 -0
- data/lib/devbench.rb +7 -2
- data/lib/generators/devbench/devbench_generator.rb +210 -0
- metadata +9 -3
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'erb'
|
|
4
|
+
require_relative 'version'
|
|
5
|
+
|
|
6
|
+
module Devbench
|
|
7
|
+
# The browser sensor's script tag, for the layout's <head>:
|
|
8
|
+
#
|
|
9
|
+
# <%= devbench_script_tag %>
|
|
10
|
+
#
|
|
11
|
+
# renders
|
|
12
|
+
#
|
|
13
|
+
# <script src="https://unpkg.com/devbench@0.7.0/dist/devbench.min.js"
|
|
14
|
+
# data-dsn="https://<public>@<host>" data-release="<release>" defer></script>
|
|
15
|
+
#
|
|
16
|
+
# The DSN is the public part of DEVBENCH_DSN only (Devbench.browser_dsn),
|
|
17
|
+
# so the page needs no setting of its own and the secret never reaches
|
|
18
|
+
# HTML. No public part (no DSN, a 0.5 single-key DSN, reporting disabled)
|
|
19
|
+
# renders nothing at all. The src is pinned to this gem's version, so a
|
|
20
|
+
# gem upgrade upgrades the browser sensor with it. Where the app uses a
|
|
21
|
+
# CSP nonce (content_security_policy_nonce_generator), the tag carries it,
|
|
22
|
+
# as Rails' own `javascript_include_tag nonce: true` does.
|
|
23
|
+
#
|
|
24
|
+
# Included into ActionView by the Railtie. Never raises.
|
|
25
|
+
module ViewHelper
|
|
26
|
+
CDN = 'https://unpkg.com'
|
|
27
|
+
|
|
28
|
+
def self.src
|
|
29
|
+
"#{CDN}/devbench@#{Devbench::VERSION}/dist/devbench.min.js"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def devbench_script_tag
|
|
33
|
+
dsn = Devbench.browser_dsn
|
|
34
|
+
return devbench_safe('') if dsn.nil?
|
|
35
|
+
|
|
36
|
+
attrs = [['src', ViewHelper.src], ['data-dsn', dsn], ['data-release', Devbench.config.resolved_release]]
|
|
37
|
+
nonce = devbench_nonce
|
|
38
|
+
attrs << ['nonce', nonce] if nonce
|
|
39
|
+
html = attrs.map { |name, value| %(#{name}="#{ERB::Util.html_escape(value)}") }.join(' ')
|
|
40
|
+
devbench_safe("<script #{html} defer></script>")
|
|
41
|
+
rescue StandardError, SystemStackError
|
|
42
|
+
devbench_safe('')
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Rails' content_security_policy_nonce: nil unless the app configured a
|
|
48
|
+
# nonce generator.
|
|
49
|
+
def devbench_nonce
|
|
50
|
+
return nil unless respond_to?(:content_security_policy_nonce, true)
|
|
51
|
+
|
|
52
|
+
nonce = content_security_policy_nonce
|
|
53
|
+
nonce.nil? || nonce.to_s.empty? ? nil : nonce.to_s
|
|
54
|
+
rescue StandardError, SystemStackError
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def devbench_safe(html)
|
|
59
|
+
html.respond_to?(:html_safe) ? html.html_safe : html
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
data/lib/devbench.rb
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
# Dev Bench server SDK for Ruby/Rails (formerly "ADT"; DECISIONS #160).
|
|
4
4
|
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
5
|
+
# bundle add devbench
|
|
6
|
+
# bin/rails generate devbench
|
|
7
|
+
# DEVBENCH_DSN=https://<public>:<secret>@<ingest host> (from `adt dsn create`)
|
|
7
8
|
#
|
|
8
9
|
# What it does:
|
|
9
10
|
# * accepts and forwards the correlation id (Devbench::Middleware, Devbench::HTTP)
|
|
@@ -12,6 +13,8 @@
|
|
|
12
13
|
# * reports unhandled exceptions (Devbench::Middleware, Devbench::Railtie,
|
|
13
14
|
# Devbench::SidekiqHooks,
|
|
14
15
|
# Devbench.capture_exception)
|
|
16
|
+
# * keeps recent log lines per trace (Devbench::Logs; direct mode)
|
|
17
|
+
# * renders the browser sensor's tag (devbench_script_tag)
|
|
15
18
|
#
|
|
16
19
|
# The old name is kept: `require 'adt'` and every `ADT.` / `ADT::` call in an
|
|
17
20
|
# existing install resolve to this module.
|
|
@@ -27,6 +30,8 @@ require_relative 'devbench/session'
|
|
|
27
30
|
require_relative 'devbench/session_middleware'
|
|
28
31
|
require_relative 'devbench/rails_hooks'
|
|
29
32
|
require_relative 'devbench/sidekiq_hooks'
|
|
33
|
+
require_relative 'devbench/logs'
|
|
34
|
+
require_relative 'devbench/view_helper'
|
|
30
35
|
|
|
31
36
|
# The pre-0.5 name. The same module object, not a copy: ADT::Middleware *is*
|
|
32
37
|
# Devbench::Middleware, so a stack holding either holds the one class.
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators'
|
|
4
|
+
require 'devbench'
|
|
5
|
+
|
|
6
|
+
module Devbench
|
|
7
|
+
# bin/rails generate devbench
|
|
8
|
+
#
|
|
9
|
+
# Step 2 of the install (DECISIONS #161):
|
|
10
|
+
#
|
|
11
|
+
# 1. bundle add devbench
|
|
12
|
+
# 2. bin/rails generate devbench
|
|
13
|
+
# 3. set DEVBENCH_DSN
|
|
14
|
+
#
|
|
15
|
+
# Does two things, and touches nothing else:
|
|
16
|
+
#
|
|
17
|
+
# * puts <%= devbench_script_tag %> in the application layout's <head>
|
|
18
|
+
# (.erb, .haml or .slim), or prints exactly what to add where;
|
|
19
|
+
# * if config/initializers/content_security_policy.rb defines a policy,
|
|
20
|
+
# allows the sensor's script (https://unpkg.com) in script_src, and
|
|
21
|
+
# the ingest host plus evidence storage (https://*.storage.supabase.co)
|
|
22
|
+
# in connect_src.
|
|
23
|
+
#
|
|
24
|
+
# Idempotent: a second run changes nothing. Prints what it changed.
|
|
25
|
+
class DevbenchGenerator < ::Rails::Generators::Base
|
|
26
|
+
namespace 'devbench'
|
|
27
|
+
desc 'Adds the Dev Bench browser tag to your layout and allows it in your CSP.'
|
|
28
|
+
|
|
29
|
+
TAG = 'devbench_script_tag'
|
|
30
|
+
SCRIPT_HOST = 'https://unpkg.com'
|
|
31
|
+
STORAGE_HOST = 'https://*.storage.supabase.co'
|
|
32
|
+
# Ruby code, not a host: evaluated when the CSP initializer runs, so the
|
|
33
|
+
# policy follows DEVBENCH_DSN (Devbench.csp_connect_sources).
|
|
34
|
+
RUNTIME_CONNECT = '*Devbench.csp_connect_sources'
|
|
35
|
+
LAYOUTS = %w[erb haml slim].map { |ext| "app/views/layouts/application.html.#{ext}" }.freeze
|
|
36
|
+
CSP = 'config/initializers/content_security_policy.rb'
|
|
37
|
+
|
|
38
|
+
def add_script_tag
|
|
39
|
+
path = LAYOUTS.find { |p| File.exist?(File.join(destination_root, p)) }
|
|
40
|
+
return manual_tag('no app/views/layouts/application.html.{erb,haml,slim} found') if path.nil?
|
|
41
|
+
|
|
42
|
+
full = File.join(destination_root, path)
|
|
43
|
+
text = File.read(full)
|
|
44
|
+
if text.include?(TAG)
|
|
45
|
+
say_status :identical, "#{path} (already has #{TAG})", :blue
|
|
46
|
+
return
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
updated = path.end_with?('.erb') ? insert_erb(text) : insert_indented(text, path.end_with?('.haml') ? /%head\b/ : /head\b/)
|
|
50
|
+
return manual_tag("could not find the <head> of #{path}") if updated.nil?
|
|
51
|
+
|
|
52
|
+
File.write(full, updated)
|
|
53
|
+
say_status :insert, "#{path}: <%= #{TAG} %> in <head>", :green
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def allow_in_content_security_policy
|
|
57
|
+
full = File.join(destination_root, CSP)
|
|
58
|
+
unless File.exist?(full)
|
|
59
|
+
say_status :skip, "#{CSP} not found: no CSP to change", :blue
|
|
60
|
+
return
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
text = File.read(full)
|
|
64
|
+
var = text[/^[ \t]*(?:Rails\.application\.)?config\.content_security_policy[ \t]+do[ \t]*\|[ \t]*(\w+)[ \t]*\|/, 1]
|
|
65
|
+
if var.nil?
|
|
66
|
+
say_status :skip, "#{CSP} defines no policy (all commented out): nothing to change", :blue
|
|
67
|
+
return
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
changes = []
|
|
71
|
+
text = allow(text, var, 'script_src', [SCRIPT_HOST], changes)
|
|
72
|
+
text = allow(text, var, 'connect_src', [RUNTIME_CONNECT], changes)
|
|
73
|
+
if changes.empty?
|
|
74
|
+
say_status :identical, "#{CSP} (already allows Dev Bench)", :blue
|
|
75
|
+
return
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
File.write(full, text)
|
|
79
|
+
changes.each { |c| say_status :csp, "#{CSP}: #{c}", :green }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def print_next_steps
|
|
83
|
+
say ''
|
|
84
|
+
say 'Next:'
|
|
85
|
+
say ' 1. Set DEVBENCH_DSN in this app\'s environment (the value `adt dsn create` printed).'
|
|
86
|
+
say ' 2. Check it: bin/rails devbench:test'
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def manual_tag(reason)
|
|
92
|
+
say_status :manual, reason, :yellow
|
|
93
|
+
say " Add this to your layout, just before </head>:\n\n <%= #{TAG} %>\n\n" \
|
|
94
|
+
" (Haml or Slim: `= #{TAG}` as the last line inside head.)"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Before </head>, indented one step deeper than it when it is on a line
|
|
98
|
+
# of its own.
|
|
99
|
+
def insert_erb(text)
|
|
100
|
+
at = text =~ %r{</head\s*>}i
|
|
101
|
+
return nil if at.nil?
|
|
102
|
+
|
|
103
|
+
line_start = text.rindex("\n", at - 1)
|
|
104
|
+
line_start = line_start.nil? ? 0 : line_start + 1
|
|
105
|
+
before = text[line_start...at]
|
|
106
|
+
if before.strip.empty?
|
|
107
|
+
"#{text[0...line_start]}#{before} <%= #{TAG} %>\n#{text[line_start..]}"
|
|
108
|
+
else
|
|
109
|
+
"#{text[0...at]}<%= #{TAG} %>#{text[at..]}"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Haml / Slim: `= devbench_script_tag` as the last child of the head
|
|
114
|
+
# element, at its children's indentation.
|
|
115
|
+
def insert_indented(text, head)
|
|
116
|
+
lines = text.lines
|
|
117
|
+
index = lines.index { |l| l =~ /\A([ \t]*)#{head}/ }
|
|
118
|
+
return nil if index.nil?
|
|
119
|
+
|
|
120
|
+
head_indent = lines[index][/\A[ \t]*/]
|
|
121
|
+
last = index
|
|
122
|
+
child_indent = nil
|
|
123
|
+
(index + 1...lines.length).each do |i|
|
|
124
|
+
line = lines[i]
|
|
125
|
+
next if line.strip.empty?
|
|
126
|
+
|
|
127
|
+
indent = line[/\A[ \t]*/]
|
|
128
|
+
break if indent.length <= head_indent.length
|
|
129
|
+
|
|
130
|
+
child_indent ||= indent
|
|
131
|
+
last = i
|
|
132
|
+
end
|
|
133
|
+
child_indent ||= "#{head_indent} "
|
|
134
|
+
lines[last] = "#{lines[last]}\n" unless lines[last].end_with?("\n")
|
|
135
|
+
lines.insert(last + 1, "#{child_indent}= #{TAG}\n")
|
|
136
|
+
lines.join
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Adds each host to `var.directive`, or — when the directive is absent
|
|
140
|
+
# but default_src is set — adds the directive as default_src's sources
|
|
141
|
+
# plus the hosts, which is what the browser was falling back to. Leaves
|
|
142
|
+
# a policy that restricts neither alone.
|
|
143
|
+
def allow(text, var, directive, hosts, changes)
|
|
144
|
+
lines = text.lines
|
|
145
|
+
range = statement(lines, var, directive)
|
|
146
|
+
if range
|
|
147
|
+
hosts.each do |host|
|
|
148
|
+
stmt = lines[range].join
|
|
149
|
+
next if stmt.include?(host)
|
|
150
|
+
|
|
151
|
+
lines[range.last] = append_source(lines[range.last], host)
|
|
152
|
+
changes << "added #{host} to #{directive}"
|
|
153
|
+
end
|
|
154
|
+
return lines.join
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Neither: the policy does not restrict this kind of request at all.
|
|
158
|
+
default = statement(lines, var, 'default_src')
|
|
159
|
+
return text if default.nil?
|
|
160
|
+
|
|
161
|
+
first = lines[default.first]
|
|
162
|
+
indent = first[/\A[ \t]*/]
|
|
163
|
+
sources = lines[default].map { |l| strip_comment(l.chomp) }.join(' ')
|
|
164
|
+
.sub(/\A[ \t]*#{var}\.default_src/, '').gsub(/\s+/, ' ').strip
|
|
165
|
+
new_line = "#{indent}#{var}.#{directive} #{[sources, *hosts.map { |h| source_code(h) }].reject(&:empty?).join(', ')}\n"
|
|
166
|
+
lines.insert(default.last + 1, new_line)
|
|
167
|
+
changes << "added #{directive} (default_src's sources + #{hosts.join(', ')})"
|
|
168
|
+
lines.join
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# The line range of an uncommented `var.directive ...` statement,
|
|
172
|
+
# following continuation lines (a trailing comma or backslash).
|
|
173
|
+
def statement(lines, var, directive)
|
|
174
|
+
start = lines.index { |l| l =~ /\A[ \t]*#{Regexp.escape(var)}\.#{directive}\b/ }
|
|
175
|
+
return nil if start.nil?
|
|
176
|
+
|
|
177
|
+
last = start
|
|
178
|
+
last += 1 while last + 1 < lines.length && strip_comment(lines[last]).rstrip.end_with?(',', '\\')
|
|
179
|
+
start..last
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def append_source(line, host)
|
|
183
|
+
body = line.chomp
|
|
184
|
+
newline = line.end_with?("\n") ? "\n" : ''
|
|
185
|
+
code = strip_comment(body).rstrip
|
|
186
|
+
comment = body[code.length..]
|
|
187
|
+
"#{code}, #{source_code(host)}#{comment}#{newline}"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# A trailing `# comment`, not counting '#' inside a string or #{}.
|
|
191
|
+
def strip_comment(line)
|
|
192
|
+
quote = nil
|
|
193
|
+
line.each_char.with_index do |ch, i|
|
|
194
|
+
if quote
|
|
195
|
+
quote = nil if ch == quote && line[i - 1] != '\\'
|
|
196
|
+
elsif ch == '"' || ch == "'"
|
|
197
|
+
quote = ch
|
|
198
|
+
elsif ch == '#' && line[i + 1] != '{'
|
|
199
|
+
return line[0...i]
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
line
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# A host is written as a string literal; a runtime splat as code.
|
|
206
|
+
def source_code(source)
|
|
207
|
+
source.start_with?('*') ? source : source.inspect
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
metadata
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: devbench
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dev Bench
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-10 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: |
|
|
14
14
|
Reports unhandled exceptions, Rails.error reports, failed ActiveJob and
|
|
15
15
|
Sidekiq jobs, and failures the application handled, with who was
|
|
16
16
|
affected, to Dev Bench. Fingerprints and counts in-process and sends one
|
|
17
17
|
small request a minute; detail is uploaded only when Dev Bench asks for
|
|
18
|
-
it, scrubbed first.
|
|
18
|
+
it, scrubbed first. Keeps recent server log lines per user action for
|
|
19
|
+
triage, renders the browser sensor's tag, and hooks itself into Rails:
|
|
20
|
+
bundle add devbench, bin/rails generate devbench, set DEVBENCH_DSN.
|
|
19
21
|
|
|
20
22
|
Adds no dependencies beyond the standard library, never alters a response
|
|
21
23
|
body, and never raises: a diagnostics gem that can fail a request is worse
|
|
@@ -32,9 +34,11 @@ files:
|
|
|
32
34
|
- lib/devbench/config.rb
|
|
33
35
|
- lib/devbench/current.rb
|
|
34
36
|
- lib/devbench/direct.rb
|
|
37
|
+
- lib/devbench/egress_policy.rb
|
|
35
38
|
- lib/devbench/fingerprint.rb
|
|
36
39
|
- lib/devbench/http.rb
|
|
37
40
|
- lib/devbench/identity.rb
|
|
41
|
+
- lib/devbench/logs.rb
|
|
38
42
|
- lib/devbench/middleware.rb
|
|
39
43
|
- lib/devbench/rails_hooks.rb
|
|
40
44
|
- lib/devbench/railtie.rb
|
|
@@ -47,6 +51,8 @@ files:
|
|
|
47
51
|
- lib/devbench/trace.rb
|
|
48
52
|
- lib/devbench/transport.rb
|
|
49
53
|
- lib/devbench/version.rb
|
|
54
|
+
- lib/devbench/view_helper.rb
|
|
55
|
+
- lib/generators/devbench/devbench_generator.rb
|
|
50
56
|
homepage: https://github.com/pasperry/devbench-sdk
|
|
51
57
|
licenses:
|
|
52
58
|
- LicenseRef-Proprietary
|