browserctl 0.11.0 → 0.13.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/CHANGELOG.md +45 -0
- data/README.md +4 -3
- data/bin/browserctl +171 -115
- data/bin/browserd +8 -1
- data/lib/browserctl/callable_definition.rb +114 -0
- data/lib/browserctl/client.rb +3 -30
- data/lib/browserctl/commands/cli_output.rb +38 -4
- data/lib/browserctl/commands/daemon.rb +10 -6
- data/lib/browserctl/commands/flow.rb +7 -5
- data/lib/browserctl/commands/init.rb +20 -7
- data/lib/browserctl/commands/migrate.rb +142 -0
- data/lib/browserctl/commands/output_format.rb +144 -0
- data/lib/browserctl/commands/page.rb +9 -5
- data/lib/browserctl/commands/{record.rb → recording.rb} +14 -13
- data/lib/browserctl/commands/resume.rb +1 -1
- data/lib/browserctl/commands/screenshot.rb +2 -2
- data/lib/browserctl/commands/snapshot.rb +8 -3
- data/lib/browserctl/commands/state.rb +3 -2
- data/lib/browserctl/commands/trace.rb +216 -0
- data/lib/browserctl/commands/workflow.rb +9 -7
- data/lib/browserctl/constants.rb +3 -1
- data/lib/browserctl/contextual_persistence.rb +58 -0
- data/lib/browserctl/crash_report.rb +96 -0
- data/lib/browserctl/driver/cdp.rb +2 -3
- data/lib/browserctl/encryption_service.rb +84 -0
- data/lib/browserctl/error/codes.rb +44 -0
- data/lib/browserctl/error/exit_codes.rb +54 -0
- data/lib/browserctl/error/suggested_actions.rb +41 -0
- data/lib/browserctl/errors.rb +44 -14
- data/lib/browserctl/flow.rb +35 -59
- data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +4 -4
- data/lib/browserctl/format_version.rb +37 -0
- data/lib/browserctl/logger.rb +102 -9
- data/lib/browserctl/migrations.rb +216 -0
- data/lib/browserctl/recording/log_writer.rb +82 -0
- data/lib/browserctl/recording/redactor.rb +58 -0
- data/lib/browserctl/recording/state.rb +44 -0
- data/lib/browserctl/recording/workflow_renderer.rb +214 -0
- data/lib/browserctl/recording.rb +39 -268
- data/lib/browserctl/redactor.rb +58 -0
- data/lib/browserctl/rubocop/cops/typed_error.rb +69 -0
- data/lib/browserctl/runner.rb +12 -6
- data/lib/browserctl/secret_resolver_registry.rb +23 -4
- data/lib/browserctl/server/command_dispatcher.rb +28 -16
- data/lib/browserctl/server/handlers/daemon_control.rb +5 -1
- data/lib/browserctl/server/handlers/error_payload.rb +27 -0
- data/lib/browserctl/server/handlers/interaction.rb +21 -3
- data/lib/browserctl/server/handlers/navigation.rb +19 -3
- data/lib/browserctl/server/handlers/state.rb +7 -5
- data/lib/browserctl/server.rb +2 -1
- data/lib/browserctl/state/bundle.rb +63 -49
- data/lib/browserctl/state.rb +46 -9
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow/flow_wrapper.rb +1 -1
- data/lib/browserctl/workflow/recovery_manager.rb +87 -0
- data/lib/browserctl/workflow.rb +117 -238
- metadata +25 -14
- data/examples/session_reuse.rb +0 -75
- data/lib/browserctl/commands/session.rb +0 -243
- data/lib/browserctl/driver/base.rb +0 -13
- data/lib/browserctl/driver.rb +0 -5
- data/lib/browserctl/server/handlers/session.rb +0 -94
- data/lib/browserctl/session.rb +0 -206
data/lib/browserctl/recording.rb
CHANGED
|
@@ -1,65 +1,46 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "json"
|
|
4
|
-
require "date"
|
|
5
|
-
require "time"
|
|
6
|
-
require "fileutils"
|
|
7
3
|
require "tmpdir"
|
|
8
|
-
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
require_relative "error/codes"
|
|
9
6
|
|
|
10
7
|
module Browserctl
|
|
11
|
-
|
|
8
|
+
# Captures a sequence of daemon commands as a JSONL log and renders it
|
|
9
|
+
# back out as a Ruby workflow file. This class is the public facade;
|
|
10
|
+
# the focused responsibilities live under `Browserctl::Recording::*`:
|
|
11
|
+
#
|
|
12
|
+
# - `Recording::State` — singleton over the on-disk marker.
|
|
13
|
+
# - `Recording::Redactor` — secret-aware redaction.
|
|
14
|
+
# - `Recording::LogWriter` — log file I/O and JSONL formatting.
|
|
15
|
+
# - `Recording::WorkflowRenderer` — log-to-Ruby workflow translation.
|
|
16
|
+
class Recording
|
|
12
17
|
RECORDINGS_DIR = File.join(Dir.tmpdir, "browserctl-recordings")
|
|
13
18
|
STATE_FILE = File.expand_path("~/.browserctl/active_recording")
|
|
14
19
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
# The captured group (or matched substring) is used as the inferred field
|
|
21
|
-
# name; that name later drives the generated `secret_ref:` placeholder.
|
|
22
|
-
SECRET_FIELD_PATTERN = /\b(password|passwd|api[_-]?key|token|secret|otp|pin|client[_-]?secret|access[_-]?token)\b/i
|
|
23
|
-
|
|
24
|
-
# Conservative thresholds for inferring an explicit wait between recorded
|
|
25
|
-
# steps. Gaps shorter than the threshold come from natural input cadence;
|
|
26
|
-
# gaps above it usually mean the page actually had work to do.
|
|
27
|
-
WAIT_THRESHOLD_SECONDS = 1.5
|
|
28
|
-
WAIT_PADDING_SECONDS = 5
|
|
29
|
-
WAIT_FLOOR_SECONDS = 5
|
|
20
|
+
# Recording-log format version, written into the `_meta` header and
|
|
21
|
+
# validated when generate_workflow loads a recording. See
|
|
22
|
+
# docs/reference/format-versions.md.
|
|
23
|
+
RECORDING_FORMAT_VERSION = 1
|
|
24
|
+
SUPPORTED_FORMAT_VERSIONS = [RECORDING_FORMAT_VERSION].freeze
|
|
30
25
|
|
|
31
26
|
# Bumped when the recording log shape changes in a way that older
|
|
32
27
|
# tooling (workflow generate, replay) cannot read.
|
|
33
28
|
LOG_FORMAT = "v0.11"
|
|
34
29
|
|
|
30
|
+
RECORDABLE = %w[page_open navigate fill click screenshot evaluate].freeze
|
|
31
|
+
|
|
35
32
|
def self.start(name)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
File.write(STATE_FILE, name)
|
|
39
|
-
FileUtils.rm_f(log_path(name))
|
|
40
|
-
FileUtils.touch(log_path(name))
|
|
41
|
-
File.chmod(0o600, log_path(name))
|
|
42
|
-
File.open(log_path(name), "a") do |f|
|
|
43
|
-
f.puts JSON.generate(
|
|
44
|
-
cmd: "_meta",
|
|
45
|
-
log_format: LOG_FORMAT,
|
|
46
|
-
recording: name,
|
|
47
|
-
started_at: Time.now.utc.iso8601
|
|
48
|
-
)
|
|
49
|
-
end
|
|
33
|
+
LogWriter.init_log(name)
|
|
34
|
+
State.write(name)
|
|
50
35
|
name
|
|
51
36
|
end
|
|
52
37
|
|
|
53
38
|
def self.stop
|
|
54
|
-
|
|
55
|
-
raise "no active recording — run: browserctl record start <name>" unless name
|
|
56
|
-
|
|
57
|
-
File.unlink(STATE_FILE)
|
|
58
|
-
name
|
|
39
|
+
State.clear!
|
|
59
40
|
end
|
|
60
41
|
|
|
61
42
|
def self.active
|
|
62
|
-
|
|
43
|
+
State.active
|
|
63
44
|
end
|
|
64
45
|
|
|
65
46
|
def self.append(cmd, response: nil, **attrs)
|
|
@@ -76,23 +57,22 @@ module Browserctl
|
|
|
76
57
|
entry = { cmd: cmd.to_s, ts: now }.merge(attrs.transform_keys(&:to_s))
|
|
77
58
|
entry.merge!(replay_metadata(response)) if response
|
|
78
59
|
|
|
79
|
-
|
|
80
|
-
f.puts JSON.generate(entry)
|
|
81
|
-
end
|
|
60
|
+
LogWriter.append_entry(name, entry)
|
|
82
61
|
end
|
|
83
62
|
|
|
84
63
|
def self.generate_workflow(name, output_path: nil, keep_log: false)
|
|
85
|
-
log = log_path(name)
|
|
86
|
-
raise "no recording found for '#{name}'" unless File.exist?(log)
|
|
64
|
+
log = LogWriter.log_path(name)
|
|
65
|
+
raise Browserctl::Error, "no recording found for '#{name}'" unless File.exist?(log)
|
|
87
66
|
|
|
88
|
-
raw =
|
|
67
|
+
raw = LogWriter.read_entries(name)
|
|
68
|
+
LogWriter.verify_format_version!(raw, path: log)
|
|
89
69
|
lines = raw.reject { |l| l[:cmd] == "_meta" }
|
|
90
|
-
ruby =
|
|
70
|
+
ruby = WorkflowRenderer.render(name, lines)
|
|
91
71
|
File.write(output_path, ruby) if output_path
|
|
92
72
|
warn_about_ref_interactions(lines)
|
|
93
73
|
ruby
|
|
94
74
|
ensure
|
|
95
|
-
|
|
75
|
+
LogWriter.delete_log(name) unless keep_log
|
|
96
76
|
end
|
|
97
77
|
|
|
98
78
|
def self.warn_about_ref_interactions(lines)
|
|
@@ -106,25 +86,19 @@ module Browserctl
|
|
|
106
86
|
class << self
|
|
107
87
|
private
|
|
108
88
|
|
|
109
|
-
def
|
|
110
|
-
|
|
89
|
+
def now
|
|
90
|
+
Time.now.utc.to_f
|
|
111
91
|
end
|
|
112
92
|
|
|
113
93
|
def record_ref_interaction(recording_name, cmd, attrs, response)
|
|
114
94
|
entry = { cmd: "_ref_interaction", ts: now, action: cmd, ref: attrs[:ref], name: attrs[:name] }
|
|
115
95
|
entry.merge!(replay_metadata(response)) if response
|
|
116
|
-
|
|
117
|
-
f.puts JSON.generate(entry)
|
|
118
|
-
end
|
|
96
|
+
LogWriter.append_entry(recording_name, entry)
|
|
119
97
|
end
|
|
120
98
|
|
|
121
99
|
# Pulls the replay-relevant fields out of a daemon response. Each
|
|
122
100
|
# is optional — older daemons or non-resolving commands may omit
|
|
123
101
|
# any of them.
|
|
124
|
-
def now
|
|
125
|
-
Time.now.utc.to_f
|
|
126
|
-
end
|
|
127
|
-
|
|
128
102
|
def replay_metadata(response)
|
|
129
103
|
meta = {}
|
|
130
104
|
meta[:ref] = response[:ref] if response[:ref]
|
|
@@ -135,227 +109,24 @@ module Browserctl
|
|
|
135
109
|
meta.transform_keys(&:to_s)
|
|
136
110
|
end
|
|
137
111
|
|
|
138
|
-
def build_workflow_ruby(name, commands)
|
|
139
|
-
steps = annotated_steps(commands).join("\n\n")
|
|
140
|
-
secrets = commands.map { |c| c[:secret_field] }.compact.uniq
|
|
141
|
-
header = secret_header(secrets)
|
|
142
|
-
<<~RUBY
|
|
143
|
-
# frozen_string_literal: true
|
|
144
|
-
#{header}
|
|
145
|
-
Browserctl.workflow #{name.inspect} do
|
|
146
|
-
desc "Recorded on #{Date.today}"
|
|
147
|
-
#{secrets.map { |f| " param :secret_#{f}, secret: true" }.join("\n")}
|
|
148
|
-
#{steps.gsub(/^/, ' ')}
|
|
149
|
-
end
|
|
150
|
-
RUBY
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
# Walks the recorded events and emits the rendered step strings,
|
|
154
|
-
# interleaving inferred waits before selector-driven actions whose
|
|
155
|
-
# preceding gap exceeds WAIT_THRESHOLD_SECONDS, and inferred URL
|
|
156
|
-
# postconditions after click/fill steps that triggered navigation.
|
|
157
|
-
def annotated_steps(commands)
|
|
158
|
-
last_url = {}
|
|
159
|
-
commands.each_with_index.flat_map do |cmd, i|
|
|
160
|
-
rendered = []
|
|
161
|
-
if i.positive? && (wait = inferred_wait_step(commands[i - 1], cmd))
|
|
162
|
-
rendered << wait
|
|
163
|
-
end
|
|
164
|
-
rendered << build_step(cmd)
|
|
165
|
-
if (post = url_postcondition_step(cmd, last_url))
|
|
166
|
-
rendered << post
|
|
167
|
-
end
|
|
168
|
-
if (snap = snapshot_postcondition_step(cmd))
|
|
169
|
-
rendered << snap
|
|
170
|
-
end
|
|
171
|
-
update_last_url!(cmd, last_url)
|
|
172
|
-
rendered
|
|
173
|
-
end
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
# Emits a postcondition assertion when a click/fill resulted in a URL
|
|
177
|
-
# change. Compares the canonical (scheme+host+path) form so query
|
|
178
|
-
# strings and fragments don't make every replay flaky.
|
|
179
|
-
def url_postcondition_step(cmd, last_url)
|
|
180
|
-
return nil unless %w[click fill].include?(cmd[:cmd])
|
|
181
|
-
return nil unless cmd[:postcondition_hint] && cmd[:postcondition_hint][:url]
|
|
182
|
-
|
|
183
|
-
page = cmd[:name]
|
|
184
|
-
observed = cmd[:postcondition_hint][:url]
|
|
185
|
-
prior = last_url[page]
|
|
186
|
-
return nil if canonical_url(observed) == canonical_url(prior)
|
|
187
|
-
|
|
188
|
-
prefix = canonical_url(observed)
|
|
189
|
-
return nil unless prefix
|
|
190
|
-
|
|
191
|
-
<<~RUBY.chomp
|
|
192
|
-
step "assert url after #{cmd[:cmd]} on #{page}" do
|
|
193
|
-
current = page(:#{page}).url
|
|
194
|
-
assert current.start_with?(#{prefix.inspect}), "expected URL to start with #{prefix}, got \#{current}"
|
|
195
|
-
end
|
|
196
|
-
RUBY
|
|
197
|
-
end
|
|
198
|
-
|
|
199
|
-
# Emits an assert_snapshot_stable step when the recording captured a
|
|
200
|
-
# post-step DOM digest. Under workflow run --check the helper records
|
|
201
|
-
# drift on mismatch instead of raising, so a wiggly page surfaces in
|
|
202
|
-
# the report rather than failing the run outright.
|
|
203
|
-
def snapshot_postcondition_step(cmd)
|
|
204
|
-
return nil unless %w[click fill].include?(cmd[:cmd])
|
|
205
|
-
return nil unless cmd[:post_snapshot_digest]
|
|
206
|
-
|
|
207
|
-
page = cmd[:name]
|
|
208
|
-
digest = cmd[:post_snapshot_digest]
|
|
209
|
-
<<~RUBY.chomp
|
|
210
|
-
step "assert post-snapshot stable on #{page}" do
|
|
211
|
-
assert_snapshot_stable(:#{page}, expected_digest: #{digest.inspect})
|
|
212
|
-
end
|
|
213
|
-
RUBY
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
def update_last_url!(cmd, last_url)
|
|
217
|
-
case cmd[:cmd]
|
|
218
|
-
when "navigate", "page_open"
|
|
219
|
-
last_url[cmd[:name]] = cmd[:url] if cmd[:url]
|
|
220
|
-
when "click", "fill"
|
|
221
|
-
observed = cmd[:postcondition_hint] && cmd[:postcondition_hint][:url]
|
|
222
|
-
last_url[cmd[:name]] = observed if observed
|
|
223
|
-
end
|
|
224
|
-
end
|
|
225
|
-
|
|
226
|
-
def canonical_url(url)
|
|
227
|
-
return nil if url.nil? || url.empty?
|
|
228
|
-
|
|
229
|
-
uri = URI.parse(url)
|
|
230
|
-
path = uri.path.to_s
|
|
231
|
-
path = "/" if path.empty?
|
|
232
|
-
"#{uri.scheme}://#{uri.host}#{path}"
|
|
233
|
-
rescue URI::InvalidURIError
|
|
234
|
-
nil
|
|
235
|
-
end
|
|
236
|
-
|
|
237
|
-
def inferred_wait_step(prev, current)
|
|
238
|
-
return nil unless %w[fill click].include?(current[:cmd])
|
|
239
|
-
return nil unless current[:selector]
|
|
240
|
-
|
|
241
|
-
delta = elapsed(prev, current)
|
|
242
|
-
return nil unless delta && delta >= WAIT_THRESHOLD_SECONDS
|
|
243
|
-
|
|
244
|
-
timeout = [WAIT_FLOOR_SECONDS, delta.ceil + WAIT_PADDING_SECONDS].max
|
|
245
|
-
page = current[:name]
|
|
246
|
-
sel = current[:selector]
|
|
247
|
-
<<~RUBY.chomp
|
|
248
|
-
# inferred wait: prior step took ~#{format('%.1f', delta)}s
|
|
249
|
-
step "wait for #{sel} on #{page}" do
|
|
250
|
-
page(:#{page}).wait(#{sel.inspect}, timeout: #{timeout})
|
|
251
|
-
end
|
|
252
|
-
RUBY
|
|
253
|
-
end
|
|
254
|
-
|
|
255
|
-
def elapsed(prev, current)
|
|
256
|
-
return nil unless prev && current && prev[:ts] && current[:ts]
|
|
257
|
-
|
|
258
|
-
current[:ts] - prev[:ts]
|
|
259
|
-
end
|
|
260
|
-
|
|
261
|
-
def secret_header(secrets)
|
|
262
|
-
return "" if secrets.empty?
|
|
263
|
-
|
|
264
|
-
lines = ["# TODO: review the following secret-shaped fields detected during recording.",
|
|
265
|
-
"# Configure a secret_ref: source for each before running:"]
|
|
266
|
-
secrets.each { |f| lines << "# - secret_#{f}" }
|
|
267
|
-
"\n#{lines.join("\n")}\n"
|
|
268
|
-
end
|
|
269
|
-
|
|
270
|
-
def build_step(cmd)
|
|
271
|
-
label, body = step_parts(cmd)
|
|
272
|
-
|
|
273
|
-
if body.nil?
|
|
274
|
-
page_sym = cmd[:name].to_s.gsub(/[^a-zA-Z0-9_]/, "_")
|
|
275
|
-
action = cmd[:action].to_s.gsub(/[^a-z_]/, "")
|
|
276
|
-
return "# TODO: ref-based #{action} on #{cmd[:name].inspect} (ref: #{cmd[:ref].inspect}) — " \
|
|
277
|
-
"replace with a stable CSS selector\n" \
|
|
278
|
-
"# step #{label.inspect} do\n" \
|
|
279
|
-
"# page(:#{page_sym}).#{action}(\"YOUR_SELECTOR_HERE\")\n" \
|
|
280
|
-
"# end"
|
|
281
|
-
end
|
|
282
|
-
|
|
283
|
-
prefix = []
|
|
284
|
-
prefix << "# NOTE: sensitive query params were redacted during recording" \
|
|
285
|
-
if cmd[:url].to_s.include?("[REDACTED]")
|
|
286
|
-
prefix << "# fingerprint fallback: #{cmd[:fingerprint].to_json}" if cmd[:fingerprint]
|
|
287
|
-
|
|
288
|
-
head = prefix.empty? ? "" : "#{prefix.join("\n")}\n"
|
|
289
|
-
"#{head}step #{label.inspect} do\n #{body}\nend"
|
|
290
|
-
end
|
|
291
|
-
|
|
292
|
-
def step_parts(cmd)
|
|
293
|
-
return ref_interaction_parts(cmd) if cmd[:cmd] == "_ref_interaction"
|
|
294
|
-
return selector_parts(cmd) if %w[fill click].include?(cmd[:cmd])
|
|
295
|
-
|
|
296
|
-
page = cmd[:name]
|
|
297
|
-
case cmd[:cmd]
|
|
298
|
-
when "page_open" then ["open #{page}", "page(:#{page}).navigate(#{cmd[:url].inspect})"]
|
|
299
|
-
when "navigate" then ["navigate #{page}", "page(:#{page}).navigate(#{cmd[:url].inspect})"]
|
|
300
|
-
when "screenshot" then ["screenshot #{page}", "page(:#{page}).screenshot"]
|
|
301
|
-
when "evaluate" then ["eval on #{page}", "page(:#{page}).evaluate(#{cmd[:expression].inspect})"]
|
|
302
|
-
else ["#{cmd[:cmd]} on #{page}", "# unrecognised command: #{cmd.inspect}"]
|
|
303
|
-
end
|
|
304
|
-
end
|
|
305
|
-
|
|
306
|
-
def ref_interaction_parts(cmd)
|
|
307
|
-
["TODO: ref-based #{cmd[:action]} on #{cmd[:name]} (ref: #{cmd[:ref]})", nil]
|
|
308
|
-
end
|
|
309
|
-
|
|
310
|
-
def selector_parts(cmd)
|
|
311
|
-
page = cmd[:name]
|
|
312
|
-
case cmd[:cmd]
|
|
313
|
-
when "fill"
|
|
314
|
-
value_arg = cmd[:secret_field] ? "params[:secret_#{cmd[:secret_field]}]" : "params[:fill_value]"
|
|
315
|
-
["fill #{cmd[:selector]} on #{page}",
|
|
316
|
-
"page(:#{page}).fill(#{cmd[:selector].inspect}, #{value_arg})"]
|
|
317
|
-
when "click"
|
|
318
|
-
["click #{cmd[:selector]} on #{page}",
|
|
319
|
-
"page(:#{page}).click(#{cmd[:selector].inspect})"]
|
|
320
|
-
end
|
|
321
|
-
end
|
|
322
|
-
|
|
323
112
|
def prepare_attrs(cmd, attrs)
|
|
324
113
|
attrs = attrs.except(:capture_post_snapshot)
|
|
325
114
|
if cmd == "fill"
|
|
326
115
|
attrs = attrs.except(:value)
|
|
327
|
-
field = infer_secret_field(attrs[:selector])
|
|
116
|
+
field = Redactor.infer_secret_field(attrs[:selector])
|
|
328
117
|
if field
|
|
329
118
|
attrs[:secret_hint] = true
|
|
330
119
|
attrs[:secret_field] = field
|
|
331
120
|
end
|
|
332
121
|
end
|
|
333
|
-
attrs[:url] = redact_url(attrs[:url]) if %w[navigate page_open].include?(cmd) && attrs[:url]
|
|
122
|
+
attrs[:url] = Redactor.redact_url(attrs[:url]) if %w[navigate page_open].include?(cmd) && attrs[:url]
|
|
334
123
|
attrs
|
|
335
124
|
end
|
|
336
|
-
|
|
337
|
-
def infer_secret_field(selector)
|
|
338
|
-
return nil unless selector
|
|
339
|
-
|
|
340
|
-
match = selector.match(SECRET_FIELD_PATTERN)
|
|
341
|
-
return nil unless match
|
|
342
|
-
|
|
343
|
-
match[1].downcase.gsub(/[^a-z0-9]/, "_")
|
|
344
|
-
end
|
|
345
|
-
|
|
346
|
-
def redact_url(url)
|
|
347
|
-
uri = URI.parse(url)
|
|
348
|
-
return url if uri.query.nil?
|
|
349
|
-
|
|
350
|
-
uri.query = uri.query.gsub(/([^&=]+)=([^&]*)/) do |full_match|
|
|
351
|
-
raw_key = ::Regexp.last_match(1)
|
|
352
|
-
key = URI.decode_www_form_component(raw_key)
|
|
353
|
-
key =~ SENSITIVE_PARAM_PATTERN ? "#{raw_key}=[REDACTED]" : full_match
|
|
354
|
-
end
|
|
355
|
-
uri.to_s
|
|
356
|
-
rescue URI::InvalidURIError
|
|
357
|
-
url
|
|
358
|
-
end
|
|
359
125
|
end
|
|
360
126
|
end
|
|
361
127
|
end
|
|
128
|
+
|
|
129
|
+
require_relative "recording/state"
|
|
130
|
+
require_relative "recording/redactor"
|
|
131
|
+
require_relative "recording/log_writer"
|
|
132
|
+
require_relative "recording/workflow_renderer"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
# Redacts known secret values from arbitrary strings.
|
|
5
|
+
#
|
|
6
|
+
# Used by `browserctl trace` to ensure traces are safe to attach to issues
|
|
7
|
+
# by default. Secret values are sourced from two places:
|
|
8
|
+
# 1. ENV variables whose names match well-known secret patterns
|
|
9
|
+
# (`*_TOKEN`, `*_KEY`, `*_SECRET`, `*_PASSWORD`).
|
|
10
|
+
# 2. Values captured at runtime by `SecretResolverRegistry` (in-memory
|
|
11
|
+
# only — never persisted).
|
|
12
|
+
#
|
|
13
|
+
# Replacement marker is the literal `[REDACTED]`. We considered including
|
|
14
|
+
# a sha256 prefix to distinguish values, but that doubles the surface area
|
|
15
|
+
# for accidental leakage (a determined attacker could brute-force short
|
|
16
|
+
# secrets from the prefix), and the timeline is more scannable with a
|
|
17
|
+
# uniform marker.
|
|
18
|
+
class Redactor
|
|
19
|
+
MARKER = "[REDACTED]"
|
|
20
|
+
MIN_LENGTH = 4
|
|
21
|
+
ENV_PATTERNS = [/_TOKEN\z/, /_KEY\z/, /_SECRET\z/, /_PASSWORD\z/].freeze
|
|
22
|
+
|
|
23
|
+
# @param secrets [Array<String>] secret values to redact.
|
|
24
|
+
def initialize(secrets: [])
|
|
25
|
+
# Filter empty / too-short values; longest-first to avoid partial
|
|
26
|
+
# overlaps (e.g. redacting "abcd" before "abcdef" would leave "ef").
|
|
27
|
+
@secrets = secrets
|
|
28
|
+
.compact
|
|
29
|
+
.map(&:to_s)
|
|
30
|
+
.reject { |s| s.length < MIN_LENGTH }
|
|
31
|
+
.uniq
|
|
32
|
+
.sort_by { |s| -s.length }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param string [String, nil]
|
|
36
|
+
# @return [String, nil]
|
|
37
|
+
def redact(string)
|
|
38
|
+
return string if string.nil?
|
|
39
|
+
|
|
40
|
+
result = string.to_s
|
|
41
|
+
@secrets.each { |secret| result = result.gsub(secret, MARKER) }
|
|
42
|
+
result
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def empty?
|
|
46
|
+
@secrets.empty?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Build a Redactor from the current ENV using well-known patterns.
|
|
50
|
+
# Optionally merges in additional values (e.g. from runtime instrumentation).
|
|
51
|
+
def self.from_env(env: ENV, extra: [])
|
|
52
|
+
values = env.each_with_object([]) do |(name, value), acc|
|
|
53
|
+
acc << value if ENV_PATTERNS.any? { |re| name =~ re }
|
|
54
|
+
end
|
|
55
|
+
new(secrets: values + Array(extra))
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubocop"
|
|
4
|
+
|
|
5
|
+
module RuboCop
|
|
6
|
+
module Cop
|
|
7
|
+
module Browserctl
|
|
8
|
+
# Enforces that any explicit `code:` keyword passed to a `raise` of a
|
|
9
|
+
# `Browserctl::*` error refers to a constant from
|
|
10
|
+
# `Browserctl::Error::Codes` rather than a free-form string literal.
|
|
11
|
+
#
|
|
12
|
+
# Subclasses with their own `default_code` are trusted (the cop does not
|
|
13
|
+
# try to statically resolve `default_code` across files); the contract
|
|
14
|
+
# is enforced by the unit test suite. This cop's job is to catch the
|
|
15
|
+
# specific failure mode of inlining a stale snake_case code at the
|
|
16
|
+
# raise site and bypassing the canonical enum.
|
|
17
|
+
#
|
|
18
|
+
# @example
|
|
19
|
+
# # bad — string literal that isn't a Codes constant
|
|
20
|
+
# raise Browserctl::Error, "state expired", code: "state_expired"
|
|
21
|
+
#
|
|
22
|
+
# # good — Codes constant reference
|
|
23
|
+
# raise Browserctl::Error, "state expired",
|
|
24
|
+
# code: Browserctl::Error::Codes::STATE_EXPIRED
|
|
25
|
+
#
|
|
26
|
+
# # good — typed subclass relies on its own default_code
|
|
27
|
+
# raise Browserctl::SelectorNotFound, "no such selector"
|
|
28
|
+
#
|
|
29
|
+
# The pattern is intentionally narrow. The full default_code-vs-Codes
|
|
30
|
+
# reconciliation lives in `lib/browserctl/errors.rb` and is covered by
|
|
31
|
+
# `spec/unit/errors_spec.rb`.
|
|
32
|
+
class TypedError < RuboCop::Cop::Base
|
|
33
|
+
MSG = "Browserctl raise: `code:` must reference Browserctl::Error::Codes::* — " \
|
|
34
|
+
"got string literal %<value>p. See lib/browserctl/error/codes.rb."
|
|
35
|
+
|
|
36
|
+
VALID_CODES = %w[
|
|
37
|
+
AUTH_REQUIRED
|
|
38
|
+
SELECTOR_NOT_FOUND
|
|
39
|
+
STATE_EXPIRED
|
|
40
|
+
SECRET_RESOLUTION_FAILED
|
|
41
|
+
DAEMON_UNREACHABLE
|
|
42
|
+
PROTOCOL_MISMATCH
|
|
43
|
+
].freeze
|
|
44
|
+
|
|
45
|
+
# Matches `raise Browserctl::Foo, ..., code: <value>` and yields the
|
|
46
|
+
# `code:` value node.
|
|
47
|
+
def_node_matcher :browserctl_raise_with_code, <<~PATTERN
|
|
48
|
+
(send nil? :raise
|
|
49
|
+
(const (const nil? :Browserctl) _)
|
|
50
|
+
...
|
|
51
|
+
(hash <(pair (sym :code) $_) ...>))
|
|
52
|
+
PATTERN
|
|
53
|
+
|
|
54
|
+
def on_send(node)
|
|
55
|
+
return unless node.method?(:raise)
|
|
56
|
+
|
|
57
|
+
browserctl_raise_with_code(node) do |code_value|
|
|
58
|
+
next unless code_value.str_type?
|
|
59
|
+
|
|
60
|
+
value = code_value.value
|
|
61
|
+
next if VALID_CODES.include?(value)
|
|
62
|
+
|
|
63
|
+
add_offense(code_value, message: format(MSG, value: value))
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
data/lib/browserctl/runner.rb
CHANGED
|
@@ -57,7 +57,7 @@ module Browserctl
|
|
|
57
57
|
SAFE_WORKFLOW_NAME = /\A[a-zA-Z0-9_-]+\z/
|
|
58
58
|
|
|
59
59
|
def self.load_params_file(path)
|
|
60
|
-
raise "params file not found: #{path}" unless File.exist?(path)
|
|
60
|
+
raise Browserctl::WorkflowError, "params file not found: #{path}" unless File.exist?(path)
|
|
61
61
|
|
|
62
62
|
case File.extname(path).downcase
|
|
63
63
|
when ".yml", ".yaml"
|
|
@@ -66,12 +66,12 @@ module Browserctl
|
|
|
66
66
|
when ".json"
|
|
67
67
|
JSON.parse(File.read(path), symbolize_names: true)
|
|
68
68
|
else
|
|
69
|
-
raise "unsupported params file format: #{path} (use .yml, .yaml, or .json)"
|
|
69
|
+
raise Browserctl::WorkflowError, "unsupported params file format: #{path} (use .yml, .yaml, or .json)"
|
|
70
70
|
end
|
|
71
71
|
rescue Psych::SyntaxError => e
|
|
72
|
-
raise "invalid YAML in #{path}: #{e.message}"
|
|
72
|
+
raise Browserctl::WorkflowError, "invalid YAML in #{path}: #{e.message}"
|
|
73
73
|
rescue JSON::ParserError => e
|
|
74
|
-
raise "invalid JSON in #{path}: #{e.message}"
|
|
74
|
+
raise Browserctl::WorkflowError, "invalid JSON in #{path}: #{e.message}"
|
|
75
75
|
end
|
|
76
76
|
|
|
77
77
|
private
|
|
@@ -95,7 +95,10 @@ module Browserctl
|
|
|
95
95
|
return if Browserctl.lookup_workflow(name.to_s)
|
|
96
96
|
|
|
97
97
|
path = workflow_path(name)
|
|
98
|
-
|
|
98
|
+
return unless path
|
|
99
|
+
|
|
100
|
+
Browserctl.verify_workflow_format_version!(path)
|
|
101
|
+
load path
|
|
99
102
|
end
|
|
100
103
|
|
|
101
104
|
def workflow_path(name)
|
|
@@ -111,7 +114,10 @@ module Browserctl
|
|
|
111
114
|
|
|
112
115
|
def load_from_dir(dir)
|
|
113
116
|
Dir.glob("#{dir}/*.rb").each do |f|
|
|
114
|
-
|
|
117
|
+
next if $LOADED_FEATURES.include?(f)
|
|
118
|
+
|
|
119
|
+
Browserctl.verify_workflow_format_version!(f)
|
|
120
|
+
load f
|
|
115
121
|
end
|
|
116
122
|
end
|
|
117
123
|
|
|
@@ -4,8 +4,9 @@ require_relative "errors"
|
|
|
4
4
|
|
|
5
5
|
module Browserctl
|
|
6
6
|
class SecretResolverRegistry
|
|
7
|
-
@mutex
|
|
8
|
-
@registry
|
|
7
|
+
@mutex = Mutex.new
|
|
8
|
+
@registry = {}
|
|
9
|
+
@resolved_values = []
|
|
9
10
|
|
|
10
11
|
def self.register(resolver_class)
|
|
11
12
|
instance = resolver_class.new
|
|
@@ -25,7 +26,9 @@ module Browserctl
|
|
|
25
26
|
raise SecretResolverError, msg
|
|
26
27
|
end
|
|
27
28
|
|
|
28
|
-
resolver.resolve(reference)
|
|
29
|
+
value = resolver.resolve(reference)
|
|
30
|
+
record_resolved_value(value)
|
|
31
|
+
value
|
|
29
32
|
rescue SecretResolverError
|
|
30
33
|
raise
|
|
31
34
|
rescue StandardError => e
|
|
@@ -36,8 +39,24 @@ module Browserctl
|
|
|
36
39
|
@mutex.synchronize { @registry.key?(scheme) }
|
|
37
40
|
end
|
|
38
41
|
|
|
42
|
+
# In-memory record of values resolved during this process. Used by the
|
|
43
|
+
# Redactor so trace output never leaks values that flowed through the
|
|
44
|
+
# registry. Never persisted.
|
|
45
|
+
def self.resolved_values
|
|
46
|
+
@mutex.synchronize { @resolved_values.dup }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.record_resolved_value(value)
|
|
50
|
+
return unless value.is_a?(String) && !value.empty?
|
|
51
|
+
|
|
52
|
+
@mutex.synchronize { @resolved_values << value unless @resolved_values.include?(value) }
|
|
53
|
+
end
|
|
54
|
+
|
|
39
55
|
def self.reset!
|
|
40
|
-
@mutex.synchronize
|
|
56
|
+
@mutex.synchronize do
|
|
57
|
+
@registry.clear
|
|
58
|
+
@resolved_values.clear
|
|
59
|
+
end
|
|
41
60
|
end
|
|
42
61
|
end
|
|
43
62
|
end
|