browserctl 0.7.0 → 0.8.1
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 +15 -0
- data/lib/browserctl/commands/session.rb +176 -8
- data/lib/browserctl/errors.rb +3 -0
- data/lib/browserctl/secret_resolver_registry.rb +39 -0
- data/lib/browserctl/secret_resolvers/base.rb +17 -0
- data/lib/browserctl/secret_resolvers/env.rb +13 -0
- data/lib/browserctl/secret_resolvers/macos_keychain.rb +29 -0
- data/lib/browserctl/secret_resolvers/one_password.rb +22 -0
- data/lib/browserctl/secret_resolvers.rb +14 -0
- data/lib/browserctl/session.rb +134 -12
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow.rb +26 -11
- data/lib/browserctl.rb +1 -0
- metadata +8 -4
- data/examples/smoke/params_file.rb +0 -36
- data/examples/smoke/store_fetch.rb +0 -39
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e85e284d37a585d84857db9f02a93bc7d9aacbf5c026da8ab06559fdff121f16
|
|
4
|
+
data.tar.gz: 76dbe3a44dee51d1c520dc0bd4d282790309efab1ecc5567fcee585b064660f1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e66d36468423be0212176d8d40df94c5e10ba3d5f23663041b502c0d481580f94f12f308deaffac18b25fef1cc0bd644dac110e4d98ce365574ef6aa53dc4784
|
|
7
|
+
data.tar.gz: b147ca301c15e728b9bf6e3682bcf975f7f105adadd77d62db9a6a68fa8a05cd73533562f198b0825abc99f45ee73c04cf50aa2205d840d0181f3b599e9f7093
|
data/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,21 @@ All notable changes to this project will be documented in this file.
|
|
|
10
10
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
11
11
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
12
12
|
|
|
13
|
+
## [0.8.1](https://github.com/patrick204nqh/browserctl/compare/v0.8.0...v0.8.1) (2026-04-29)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Bug Fixes
|
|
17
|
+
|
|
18
|
+
* fill test gaps and tighten DX for v0.8.1 encryption ([#59](https://github.com/patrick204nqh/browserctl/issues/59)) ([80ae8b3](https://github.com/patrick204nqh/browserctl/commit/80ae8b38620f182db02a971516cfb5295cfb4945))
|
|
19
|
+
* session encryption at rest and passphrase-protected export ([#57](https://github.com/patrick204nqh/browserctl/issues/57)) ([e3185d9](https://github.com/patrick204nqh/browserctl/commit/e3185d95dad3dd373659ce3c1e5781d9a06ed7f1))
|
|
20
|
+
|
|
21
|
+
## [0.8.0](https://github.com/patrick204nqh/browserctl/compare/v0.7.0...v0.8.0) (2026-04-29)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### Features
|
|
25
|
+
|
|
26
|
+
* **v0.8:** secret resolver plugin system + load_session fallback ([#54](https://github.com/patrick204nqh/browserctl/issues/54)) ([69737bf](https://github.com/patrick204nqh/browserctl/commit/69737bf10528ad691a31abf916953325637af597))
|
|
27
|
+
|
|
13
28
|
## [0.7.0](https://github.com/patrick204nqh/browserctl/compare/v0.6.0...v0.7.0) (2026-04-28)
|
|
14
29
|
|
|
15
30
|
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "io/console"
|
|
5
|
+
require "json"
|
|
6
|
+
require "open3"
|
|
7
|
+
require "openssl"
|
|
8
|
+
require "securerandom"
|
|
9
|
+
require "tmpdir"
|
|
3
10
|
require_relative "cli_output"
|
|
4
11
|
|
|
5
12
|
module Browserctl
|
|
@@ -9,6 +16,13 @@ module Browserctl
|
|
|
9
16
|
|
|
10
17
|
USAGE = "Usage: browserctl session <save|load|list|delete|export|import> [args]"
|
|
11
18
|
|
|
19
|
+
PBKDF2_ITERATIONS = 100_000
|
|
20
|
+
PBKDF2_KEY_LEN = 32
|
|
21
|
+
SALT_LEN = 16
|
|
22
|
+
NONCE_LEN = 12
|
|
23
|
+
|
|
24
|
+
SENSITIVE_BASENAMES = %w[cookies.json local_storage.json session_storage.json].freeze
|
|
25
|
+
|
|
12
26
|
def self.run(client, args)
|
|
13
27
|
sub = args.shift or abort USAGE
|
|
14
28
|
case sub
|
|
@@ -23,8 +37,9 @@ module Browserctl
|
|
|
23
37
|
end
|
|
24
38
|
|
|
25
39
|
def self.run_save(client, args)
|
|
26
|
-
|
|
27
|
-
|
|
40
|
+
encrypt = args.delete("--encrypt")
|
|
41
|
+
name = args.shift or abort "usage: browserctl session save <name> [--encrypt]"
|
|
42
|
+
print_result(client.session_save(name, encrypt: !!encrypt))
|
|
28
43
|
end
|
|
29
44
|
|
|
30
45
|
def self.run_load(client, args)
|
|
@@ -42,14 +57,23 @@ module Browserctl
|
|
|
42
57
|
end
|
|
43
58
|
|
|
44
59
|
def self.run_export(args)
|
|
45
|
-
|
|
46
|
-
|
|
60
|
+
encrypt = args.delete("--encrypt")
|
|
61
|
+
name = args.shift or abort "usage: browserctl session export <name> <path> [--encrypt]"
|
|
62
|
+
dest = args.shift or abort "usage: browserctl session export <name> <path> [--encrypt]"
|
|
63
|
+
|
|
47
64
|
session_dir = File.join(Browserctl::BROWSERCTL_DIR, "sessions", name)
|
|
48
65
|
abort "session '#{name}' not found" unless Dir.exist?(session_dir)
|
|
49
66
|
|
|
50
67
|
dest = File.expand_path(dest)
|
|
51
|
-
|
|
52
|
-
|
|
68
|
+
|
|
69
|
+
if encrypt
|
|
70
|
+
passphrase = prompt_passphrase(confirm: true)
|
|
71
|
+
encrypt_export(session_dir, name, dest, passphrase)
|
|
72
|
+
else
|
|
73
|
+
pid = Process.spawn("zip", "-r", dest, name, chdir: File.join(Browserctl::BROWSERCTL_DIR, "sessions"))
|
|
74
|
+
Process.wait(pid)
|
|
75
|
+
end
|
|
76
|
+
|
|
53
77
|
puts({ ok: true, path: dest }.to_json)
|
|
54
78
|
end
|
|
55
79
|
|
|
@@ -60,10 +84,154 @@ module Browserctl
|
|
|
60
84
|
|
|
61
85
|
sessions_dir = File.join(Browserctl::BROWSERCTL_DIR, "sessions")
|
|
62
86
|
FileUtils.mkdir_p(sessions_dir)
|
|
63
|
-
|
|
64
|
-
|
|
87
|
+
|
|
88
|
+
if encrypted_zip?(zip_path)
|
|
89
|
+
passphrase = prompt_passphrase
|
|
90
|
+
decrypt_import(zip_path, sessions_dir, passphrase)
|
|
91
|
+
else
|
|
92
|
+
pid = Process.spawn("unzip", "-o", zip_path, "-d", sessions_dir)
|
|
93
|
+
Process.wait(pid)
|
|
94
|
+
end
|
|
95
|
+
|
|
65
96
|
puts({ ok: true }.to_json)
|
|
66
97
|
end
|
|
98
|
+
|
|
99
|
+
# --- private helpers ---
|
|
100
|
+
|
|
101
|
+
def self.prompt_passphrase(confirm: false)
|
|
102
|
+
return ENV["BROWSERCTL_EXPORT_PASSPHRASE"] if ENV["BROWSERCTL_EXPORT_PASSPHRASE"]
|
|
103
|
+
|
|
104
|
+
$stderr.print "Passphrase: "
|
|
105
|
+
pass = $stdin.noecho(&:gets).to_s.chomp
|
|
106
|
+
$stderr.puts
|
|
107
|
+
|
|
108
|
+
if confirm
|
|
109
|
+
$stderr.print "Confirm passphrase: "
|
|
110
|
+
confirm_pass = $stdin.noecho(&:gets).to_s.chomp
|
|
111
|
+
$stderr.puts
|
|
112
|
+
abort "Passphrases do not match." unless pass == confirm_pass
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
pass
|
|
116
|
+
end
|
|
117
|
+
private_class_method :prompt_passphrase
|
|
118
|
+
|
|
119
|
+
def self.derive_key(passphrase, salt)
|
|
120
|
+
OpenSSL::PKCS5.pbkdf2_hmac(
|
|
121
|
+
passphrase,
|
|
122
|
+
salt,
|
|
123
|
+
PBKDF2_ITERATIONS,
|
|
124
|
+
PBKDF2_KEY_LEN,
|
|
125
|
+
OpenSSL::Digest.new("SHA256")
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
private_class_method :derive_key
|
|
129
|
+
|
|
130
|
+
def self.encrypt_blob(plaintext, key)
|
|
131
|
+
nonce = SecureRandom.bytes(NONCE_LEN)
|
|
132
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
133
|
+
cipher.encrypt
|
|
134
|
+
cipher.key = key
|
|
135
|
+
cipher.iv = nonce
|
|
136
|
+
ct = cipher.update(plaintext) + cipher.final
|
|
137
|
+
tag = cipher.auth_tag
|
|
138
|
+
nonce + ct + tag
|
|
139
|
+
end
|
|
140
|
+
private_class_method :encrypt_blob
|
|
141
|
+
|
|
142
|
+
def self.decrypt_blob(blob, key)
|
|
143
|
+
nonce = blob.byteslice(0, NONCE_LEN)
|
|
144
|
+
tag = blob.byteslice(-16, 16)
|
|
145
|
+
ciphertext = blob.byteslice(NONCE_LEN, blob.bytesize - NONCE_LEN - 16)
|
|
146
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
147
|
+
cipher.decrypt
|
|
148
|
+
cipher.key = key
|
|
149
|
+
cipher.iv = nonce
|
|
150
|
+
cipher.auth_tag = tag
|
|
151
|
+
cipher.update(ciphertext) + cipher.final
|
|
152
|
+
rescue OpenSSL::Cipher::CipherError => e
|
|
153
|
+
raise Browserctl::Error, "export decryption failed: #{e.message}"
|
|
154
|
+
end
|
|
155
|
+
private_class_method :decrypt_blob
|
|
156
|
+
|
|
157
|
+
# Build an encrypted zip using the system zip command.
|
|
158
|
+
# Plaintext files are staged in a tmpdir; sensitive ones are encrypted
|
|
159
|
+
# before staging. An _encryption_manifest.json is added at the zip root.
|
|
160
|
+
def self.encrypt_export(session_dir, session_name, dest, passphrase)
|
|
161
|
+
salt = SecureRandom.bytes(SALT_LEN)
|
|
162
|
+
key = derive_key(passphrase, salt)
|
|
163
|
+
manifest = JSON.generate({
|
|
164
|
+
encrypted: true, kdf: "pbkdf2-sha256",
|
|
165
|
+
iterations: PBKDF2_ITERATIONS, salt: salt.unpack1("H*")
|
|
166
|
+
})
|
|
167
|
+
Dir.mktmpdir do |tmpdir|
|
|
168
|
+
File.write(File.join(tmpdir, "_encryption_manifest.json"), manifest)
|
|
169
|
+
stage_session_files(session_dir, session_name, tmpdir, key)
|
|
170
|
+
pid = Process.spawn("zip", "-r", dest, "_encryption_manifest.json", session_name, chdir: tmpdir)
|
|
171
|
+
Process.wait(pid)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
private_class_method :encrypt_export
|
|
175
|
+
|
|
176
|
+
def self.stage_session_files(session_dir, session_name, tmpdir, key)
|
|
177
|
+
staged_session = File.join(tmpdir, session_name)
|
|
178
|
+
FileUtils.mkdir_p(staged_session)
|
|
179
|
+
Dir[File.join(session_dir, "**", "*")].each do |file|
|
|
180
|
+
next if File.directory?(file)
|
|
181
|
+
|
|
182
|
+
relative = file.delete_prefix("#{session_dir}/")
|
|
183
|
+
staged_path = File.join(staged_session, relative)
|
|
184
|
+
FileUtils.mkdir_p(File.dirname(staged_path))
|
|
185
|
+
raw = File.binread(file)
|
|
186
|
+
if SENSITIVE_BASENAMES.include?(File.basename(file))
|
|
187
|
+
File.binwrite("#{staged_path}.enc", encrypt_blob(raw, key))
|
|
188
|
+
else
|
|
189
|
+
File.binwrite(staged_path, raw)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
private_class_method :stage_session_files
|
|
194
|
+
|
|
195
|
+
# Returns true if the zip contains _encryption_manifest.json.
|
|
196
|
+
def self.encrypted_zip?(zip_path)
|
|
197
|
+
out, status = Open3.capture2("unzip", "-l", zip_path)
|
|
198
|
+
status.success? && out.include?("_encryption_manifest.json")
|
|
199
|
+
end
|
|
200
|
+
private_class_method :encrypted_zip?
|
|
201
|
+
|
|
202
|
+
# Extract zip to tmpdir, read manifest, decrypt .enc files, copy to sessions_dir.
|
|
203
|
+
def self.decrypt_import(zip_path, sessions_dir, passphrase)
|
|
204
|
+
Dir.mktmpdir do |tmpdir|
|
|
205
|
+
pid = Process.spawn("unzip", "-o", zip_path, "-d", tmpdir)
|
|
206
|
+
Process.wait(pid)
|
|
207
|
+
manifest_path = File.join(tmpdir, "_encryption_manifest.json")
|
|
208
|
+
raise Browserctl::Error, "missing encryption manifest in zip" unless File.exist?(manifest_path)
|
|
209
|
+
|
|
210
|
+
manifest = JSON.parse(File.read(manifest_path), symbolize_names: true)
|
|
211
|
+
key = derive_key(passphrase, [manifest[:salt]].pack("H*"))
|
|
212
|
+
copy_decrypted_files(tmpdir, sessions_dir, key)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
private_class_method :decrypt_import
|
|
216
|
+
|
|
217
|
+
def self.copy_decrypted_files(tmpdir, sessions_dir, key)
|
|
218
|
+
Dir[File.join(tmpdir, "**", "*")].each do |file|
|
|
219
|
+
next if File.directory?(file)
|
|
220
|
+
next if File.basename(file) == "_encryption_manifest.json"
|
|
221
|
+
|
|
222
|
+
relative = file.delete_prefix("#{tmpdir}/")
|
|
223
|
+
dest_path = File.join(sessions_dir, relative)
|
|
224
|
+
FileUtils.mkdir_p(File.dirname(dest_path))
|
|
225
|
+
if file.end_with?(".enc")
|
|
226
|
+
File.open(dest_path.delete_suffix(".enc"), "wb", 0o600) do |f|
|
|
227
|
+
f.write(decrypt_blob(File.binread(file), key))
|
|
228
|
+
end
|
|
229
|
+
else
|
|
230
|
+
FileUtils.cp(file, dest_path)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
private_class_method :copy_decrypted_files
|
|
67
235
|
end
|
|
68
236
|
end
|
|
69
237
|
end
|
data/lib/browserctl/errors.rb
CHANGED
|
@@ -22,4 +22,7 @@ module Browserctl
|
|
|
22
22
|
class DomainNotAllowed < Error; def self.default_code = "domain_not_allowed" end
|
|
23
23
|
class TimeoutError < Error; def self.default_code = "timeout" end
|
|
24
24
|
class KeyNotFound < Error; def self.default_code = "key_not_found" end
|
|
25
|
+
|
|
26
|
+
class WorkflowError < StandardError; end
|
|
27
|
+
class SecretResolverError < WorkflowError; end
|
|
25
28
|
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
class SecretResolverRegistry
|
|
7
|
+
@mutex = Mutex.new
|
|
8
|
+
@registry = {}
|
|
9
|
+
|
|
10
|
+
def self.register(resolver_class)
|
|
11
|
+
instance = resolver_class.new
|
|
12
|
+
@mutex.synchronize { @registry[resolver_class.scheme] = instance }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.resolve(secret_ref)
|
|
16
|
+
scheme, reference = secret_ref.split("://", 2)
|
|
17
|
+
resolver = @mutex.synchronize { @registry[scheme] }
|
|
18
|
+
raise SecretResolverError, "unknown secret resolver scheme '#{scheme}'" unless resolver
|
|
19
|
+
unless resolver.available?
|
|
20
|
+
raise SecretResolverError,
|
|
21
|
+
"'#{scheme}://' resolver is not available in this environment"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
resolver.resolve(reference)
|
|
25
|
+
rescue SecretResolverError
|
|
26
|
+
raise
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
raise SecretResolverError, "secret resolution failed for #{secret_ref.inspect}: #{e.message}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.registered?(scheme)
|
|
32
|
+
@mutex.synchronize { @registry.key?(scheme) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.reset!
|
|
36
|
+
@mutex.synchronize { @registry.clear }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
module SecretResolvers
|
|
5
|
+
class Base
|
|
6
|
+
def self.scheme
|
|
7
|
+
raise NotImplementedError, "#{name}.scheme not implemented"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def available? = true
|
|
11
|
+
|
|
12
|
+
def resolve(_reference)
|
|
13
|
+
raise NotImplementedError, "#{self.class.name}#resolve not implemented"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
module SecretResolvers
|
|
5
|
+
class Env < Base
|
|
6
|
+
def self.scheme = "env"
|
|
7
|
+
|
|
8
|
+
def resolve(reference)
|
|
9
|
+
ENV.fetch(reference) { raise SecretResolverError, "env var '#{reference}' is not set" }
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
module SecretResolvers
|
|
7
|
+
class MacOSKeychain < Base
|
|
8
|
+
def self.scheme = "keychain"
|
|
9
|
+
|
|
10
|
+
def available?
|
|
11
|
+
RUBY_PLATFORM.include?("darwin") && system("which security > /dev/null 2>&1")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def resolve(reference)
|
|
15
|
+
service, account = reference.split("/", 2)
|
|
16
|
+
if account.nil?
|
|
17
|
+
raise SecretResolverError,
|
|
18
|
+
"keychain reference must be 'service/account', got: #{reference.inspect}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
result, status = Open3.capture2("security", "find-generic-password",
|
|
22
|
+
"-a", account, "-s", service, "-w")
|
|
23
|
+
raise SecretResolverError, "keychain item not found: #{reference}" unless status.success?
|
|
24
|
+
|
|
25
|
+
result.chomp
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
module SecretResolvers
|
|
7
|
+
class OnePassword < Base
|
|
8
|
+
def self.scheme = "op"
|
|
9
|
+
|
|
10
|
+
def available?
|
|
11
|
+
system("which op > /dev/null 2>&1")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def resolve(reference)
|
|
15
|
+
result, status = Open3.capture2("op", "read", "op://#{reference}")
|
|
16
|
+
raise SecretResolverError, "1Password item not found: op://#{reference}" unless status.success?
|
|
17
|
+
|
|
18
|
+
result.chomp
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "secret_resolver_registry"
|
|
4
|
+
require_relative "secret_resolvers/base"
|
|
5
|
+
require_relative "secret_resolvers/env"
|
|
6
|
+
require_relative "secret_resolvers/macos_keychain"
|
|
7
|
+
require_relative "secret_resolvers/one_password"
|
|
8
|
+
|
|
9
|
+
Browserctl::SecretResolverRegistry.register(Browserctl::SecretResolvers::Env)
|
|
10
|
+
Browserctl::SecretResolverRegistry.register(Browserctl::SecretResolvers::MacOSKeychain)
|
|
11
|
+
Browserctl::SecretResolverRegistry.register(Browserctl::SecretResolvers::OnePassword)
|
|
12
|
+
|
|
13
|
+
user_resolvers = File.expand_path("~/.browserctl/resolvers.rb")
|
|
14
|
+
load user_resolvers if File.exist?(user_resolvers)
|
data/lib/browserctl/session.rb
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require "json"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "open3"
|
|
5
8
|
require_relative "constants"
|
|
6
9
|
|
|
7
10
|
module Browserctl
|
|
@@ -10,6 +13,8 @@ module Browserctl
|
|
|
10
13
|
|
|
11
14
|
SAFE_NAME = /\A[a-zA-Z0-9_-]{1,64}\z/
|
|
12
15
|
|
|
16
|
+
SENSITIVE_FILES = %w[cookies.json local_storage.json session_storage.json].freeze
|
|
17
|
+
|
|
13
18
|
def self.path(name) = File.join(BASE_DIR, name)
|
|
14
19
|
def self.exist?(name) = Dir.exist?(path(name))
|
|
15
20
|
|
|
@@ -22,29 +27,65 @@ module Browserctl
|
|
|
22
27
|
Dir[File.join(BASE_DIR, "*/metadata.json")].filter_map { |f| load_meta(f) }
|
|
23
28
|
end
|
|
24
29
|
|
|
25
|
-
def self.save(session_name, metadata:, cookies:, local_storage:, session_storage:)
|
|
30
|
+
def self.save(session_name, metadata:, cookies:, local_storage:, session_storage:, encrypt: false) # rubocop:disable Metrics/ParameterLists
|
|
26
31
|
validate_name!(session_name)
|
|
32
|
+
key, metadata = prepare_encryption(session_name, metadata, encrypt)
|
|
27
33
|
dir = path(session_name)
|
|
28
34
|
FileUtils.mkdir_p(dir)
|
|
29
|
-
write_json(File.join(dir, "metadata.json"),
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
write_json(File.join(dir, "metadata.json"), metadata)
|
|
36
|
+
write_session_files(dir, cookies, local_storage, session_storage, key)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.prepare_encryption(session_name, metadata, encrypt)
|
|
40
|
+
return [nil, metadata] unless encrypt
|
|
33
41
|
|
|
34
|
-
|
|
42
|
+
raise Browserctl::Error, "session encryption requires macOS Keychain (darwin only)" unless keychain_available?
|
|
43
|
+
|
|
44
|
+
key = SecureRandom.bytes(32)
|
|
45
|
+
keychain_store(session_name, key)
|
|
46
|
+
[key, metadata.merge(encrypted: true)]
|
|
47
|
+
end
|
|
48
|
+
private_class_method :prepare_encryption
|
|
49
|
+
|
|
50
|
+
def self.write_session_files(dir, cookies, local_storage, session_storage, key)
|
|
51
|
+
if key
|
|
52
|
+
write_encrypted_secret(File.join(dir, "cookies.json.enc"), cookies, key)
|
|
53
|
+
write_encrypted_secret(File.join(dir, "local_storage.json.enc"), local_storage, key)
|
|
54
|
+
unless session_storage.empty?
|
|
55
|
+
write_encrypted_secret(File.join(dir, "session_storage.json.enc"), session_storage,
|
|
56
|
+
key)
|
|
57
|
+
end
|
|
58
|
+
else
|
|
59
|
+
write_secret(File.join(dir, "cookies.json"), cookies)
|
|
60
|
+
write_secret(File.join(dir, "local_storage.json"), local_storage)
|
|
61
|
+
write_secret(File.join(dir, "session_storage.json"), session_storage) unless session_storage.empty?
|
|
62
|
+
end
|
|
35
63
|
end
|
|
64
|
+
private_class_method :write_session_files
|
|
36
65
|
|
|
37
66
|
def self.load(session_name)
|
|
38
67
|
validate_name!(session_name)
|
|
39
68
|
dir = path(session_name)
|
|
40
69
|
raise "session '#{session_name}' not found" unless Dir.exist?(dir)
|
|
41
70
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
71
|
+
meta = JSON.parse(File.read(File.join(dir, "metadata.json")), symbolize_names: true)
|
|
72
|
+
|
|
73
|
+
if meta[:encrypted]
|
|
74
|
+
key = keychain_fetch(session_name)
|
|
75
|
+
{
|
|
76
|
+
metadata: meta,
|
|
77
|
+
cookies: decrypt_json(File.join(dir, "cookies.json.enc"), key, symbolize_names: true),
|
|
78
|
+
local_storage: decrypt_json(File.join(dir, "local_storage.json.enc"), key, symbolize_names: false),
|
|
79
|
+
session_storage: load_session_storage_encrypted(dir, key)
|
|
80
|
+
}
|
|
81
|
+
else
|
|
82
|
+
{
|
|
83
|
+
metadata: meta,
|
|
84
|
+
cookies: JSON.parse(File.read(File.join(dir, "cookies.json")), symbolize_names: true),
|
|
85
|
+
local_storage: JSON.parse(File.read(File.join(dir, "local_storage.json")), symbolize_names: false),
|
|
86
|
+
session_storage: load_session_storage(dir)
|
|
87
|
+
}
|
|
88
|
+
end
|
|
48
89
|
end
|
|
49
90
|
|
|
50
91
|
def self.load_meta(path)
|
|
@@ -59,12 +100,87 @@ module Browserctl
|
|
|
59
100
|
raise ArgumentError, "invalid session name #{name.inspect} — use letters, digits, _ or - (max 64 chars)"
|
|
60
101
|
end
|
|
61
102
|
|
|
103
|
+
# Encrypt data (JSON) and return binary blob: [12-byte nonce][ciphertext+16-byte tag]
|
|
104
|
+
def self.encrypt_file(data, key)
|
|
105
|
+
nonce = SecureRandom.bytes(12)
|
|
106
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
107
|
+
cipher.encrypt
|
|
108
|
+
cipher.key = key
|
|
109
|
+
cipher.iv = nonce
|
|
110
|
+
ciphertext = cipher.update(data) + cipher.final
|
|
111
|
+
tag = cipher.auth_tag
|
|
112
|
+
nonce + ciphertext + tag
|
|
113
|
+
end
|
|
114
|
+
private_class_method :encrypt_file
|
|
115
|
+
|
|
116
|
+
# Decrypt binary blob produced by encrypt_file; returns original plaintext string.
|
|
117
|
+
def self.decrypt_file(blob, key)
|
|
118
|
+
nonce = blob.byteslice(0, 12)
|
|
119
|
+
tag = blob.byteslice(-16, 16)
|
|
120
|
+
ciphertext = blob.byteslice(12, blob.bytesize - 28)
|
|
121
|
+
|
|
122
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
123
|
+
cipher.decrypt
|
|
124
|
+
cipher.key = key
|
|
125
|
+
cipher.iv = nonce
|
|
126
|
+
cipher.auth_tag = tag
|
|
127
|
+
cipher.update(ciphertext) + cipher.final
|
|
128
|
+
rescue OpenSSL::Cipher::CipherError => e
|
|
129
|
+
raise Browserctl::Error, "decryption failed: #{e.message}"
|
|
130
|
+
end
|
|
131
|
+
private_class_method :decrypt_file
|
|
132
|
+
|
|
133
|
+
def self.keychain_store(name, key)
|
|
134
|
+
hex_key = key.unpack1("H*")
|
|
135
|
+
out, status = Open3.capture2(
|
|
136
|
+
"security", "add-generic-password",
|
|
137
|
+
"-a", name,
|
|
138
|
+
"-s", "browserctl",
|
|
139
|
+
"-w", hex_key,
|
|
140
|
+
"-U"
|
|
141
|
+
)
|
|
142
|
+
raise Browserctl::Error, "keychain store failed: #{out}" unless status.success?
|
|
143
|
+
end
|
|
144
|
+
private_class_method :keychain_store
|
|
145
|
+
|
|
146
|
+
def self.keychain_fetch(name)
|
|
147
|
+
hex_key, status = Open3.capture2(
|
|
148
|
+
"security", "find-generic-password",
|
|
149
|
+
"-a", name,
|
|
150
|
+
"-s", "browserctl",
|
|
151
|
+
"-w"
|
|
152
|
+
)
|
|
153
|
+
raise Browserctl::Error, "keychain fetch failed for session '#{name}'" unless status.success?
|
|
154
|
+
|
|
155
|
+
[hex_key.strip].pack("H*")
|
|
156
|
+
end
|
|
157
|
+
private_class_method :keychain_fetch
|
|
158
|
+
|
|
159
|
+
def self.keychain_available?
|
|
160
|
+
RUBY_PLATFORM.include?("darwin") && system("which security > /dev/null 2>&1")
|
|
161
|
+
end
|
|
162
|
+
private_class_method :keychain_available?
|
|
163
|
+
|
|
62
164
|
def self.load_session_storage(dir)
|
|
63
165
|
ss_path = File.join(dir, "session_storage.json")
|
|
64
166
|
File.exist?(ss_path) ? JSON.parse(File.read(ss_path), symbolize_names: false) : {}
|
|
65
167
|
end
|
|
66
168
|
private_class_method :load_session_storage
|
|
67
169
|
|
|
170
|
+
def self.load_session_storage_encrypted(dir, key)
|
|
171
|
+
ss_path = File.join(dir, "session_storage.json.enc")
|
|
172
|
+
return {} unless File.exist?(ss_path)
|
|
173
|
+
|
|
174
|
+
decrypt_json(ss_path, key, symbolize_names: false)
|
|
175
|
+
end
|
|
176
|
+
private_class_method :load_session_storage_encrypted
|
|
177
|
+
|
|
178
|
+
def self.decrypt_json(path, key, symbolize_names:)
|
|
179
|
+
blob = File.binread(path)
|
|
180
|
+
JSON.parse(decrypt_file(blob, key), symbolize_names: symbolize_names)
|
|
181
|
+
end
|
|
182
|
+
private_class_method :decrypt_json
|
|
183
|
+
|
|
68
184
|
def self.write_json(path, data)
|
|
69
185
|
File.write(path, JSON.generate(data))
|
|
70
186
|
end
|
|
@@ -75,5 +191,11 @@ module Browserctl
|
|
|
75
191
|
File.open(path, "w", 0o600) { |f| f.write(JSON.generate(data)) }
|
|
76
192
|
end
|
|
77
193
|
private_class_method :write_secret
|
|
194
|
+
|
|
195
|
+
def self.write_encrypted_secret(path, data, key)
|
|
196
|
+
blob = encrypt_file(JSON.generate(data), key)
|
|
197
|
+
File.open(path, "wb", 0o600) { |f| f.write(blob) }
|
|
198
|
+
end
|
|
199
|
+
private_class_method :write_encrypted_secret
|
|
78
200
|
end
|
|
79
201
|
end
|
data/lib/browserctl/version.rb
CHANGED
data/lib/browserctl/workflow.rb
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
require "timeout"
|
|
4
4
|
require_relative "client"
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
require_relative "secret_resolvers"
|
|
5
7
|
|
|
6
8
|
module Browserctl
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
ParamDef = Struct.new(:name, :required, :secret, :default, keyword_init: true)
|
|
9
|
+
ParamDef = Struct.new(:name, :required, :secret, :default, :secret_ref, keyword_init: true)
|
|
10
10
|
StepResult = Struct.new(:name, :ok, :error, keyword_init: true)
|
|
11
11
|
StepDef = Struct.new(:label, :block, :retry_count, :timeout, keyword_init: true)
|
|
12
12
|
|
|
@@ -60,18 +60,27 @@ module Browserctl
|
|
|
60
60
|
res
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
-
def save_session(session_name)
|
|
64
|
-
res = @client.session_save(session_name)
|
|
63
|
+
def save_session(session_name, encrypt: false)
|
|
64
|
+
res = @client.session_save(session_name, encrypt: encrypt)
|
|
65
65
|
raise WorkflowError, res[:error] if res[:error]
|
|
66
66
|
|
|
67
67
|
res
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
-
def load_session(session_name)
|
|
70
|
+
def load_session(session_name, fallback: nil)
|
|
71
71
|
res = @client.session_load(session_name)
|
|
72
|
-
|
|
72
|
+
return res unless res[:error]
|
|
73
73
|
|
|
74
|
-
res
|
|
74
|
+
raise WorkflowError, res[:error] unless fallback
|
|
75
|
+
|
|
76
|
+
invoke(fallback.to_s)
|
|
77
|
+
res2 = @client.session_load(session_name)
|
|
78
|
+
if res2[:error]
|
|
79
|
+
raise WorkflowError,
|
|
80
|
+
"session '#{session_name}' still unavailable after running fallback '#{fallback}'"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
res2
|
|
75
84
|
end
|
|
76
85
|
|
|
77
86
|
def list_sessions
|
|
@@ -172,8 +181,10 @@ module Browserctl
|
|
|
172
181
|
@description = text
|
|
173
182
|
end
|
|
174
183
|
|
|
175
|
-
def param(name, required: false, secret: false, default: nil)
|
|
176
|
-
|
|
184
|
+
def param(name, required: false, secret: false, default: nil, secret_ref: nil)
|
|
185
|
+
secret = true if secret_ref
|
|
186
|
+
@param_defs[name] =
|
|
187
|
+
ParamDef.new(name: name, required: required, secret: secret, default: default, secret_ref: secret_ref)
|
|
177
188
|
end
|
|
178
189
|
|
|
179
190
|
def step(label, retry_count: 0, timeout: nil, &block)
|
|
@@ -223,7 +234,11 @@ module Browserctl
|
|
|
223
234
|
|
|
224
235
|
def resolve_params(provided)
|
|
225
236
|
@param_defs.each_with_object({}) do |(name, defn), out|
|
|
226
|
-
val =
|
|
237
|
+
val = if defn.secret_ref
|
|
238
|
+
SecretResolverRegistry.resolve(defn.secret_ref)
|
|
239
|
+
else
|
|
240
|
+
provided[name] || defn.default
|
|
241
|
+
end
|
|
227
242
|
raise WorkflowError, "required param '#{name}' missing" if defn.required && val.nil?
|
|
228
243
|
|
|
229
244
|
out[name] = val
|
data/lib/browserctl.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "browserctl/version"
|
|
4
4
|
require_relative "browserctl/constants"
|
|
5
5
|
require_relative "browserctl/errors"
|
|
6
|
+
require_relative "browserctl/secret_resolvers"
|
|
6
7
|
require_relative "browserctl/workflow"
|
|
7
8
|
require_relative "browserctl/runner"
|
|
8
9
|
require_relative "browserctl/client"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: browserctl
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Patrick
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-04-
|
|
11
|
+
date: 2026-04-29 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: ferrum
|
|
@@ -139,8 +139,6 @@ files:
|
|
|
139
139
|
- bin/browserd
|
|
140
140
|
- bin/setup
|
|
141
141
|
- examples/cloudflare_hitl.rb
|
|
142
|
-
- examples/smoke/params_file.rb
|
|
143
|
-
- examples/smoke/store_fetch.rb
|
|
144
142
|
- examples/test_automation_practices/advanced/ab_testing.rb
|
|
145
143
|
- examples/test_automation_practices/advanced/broken_images.rb
|
|
146
144
|
- examples/test_automation_practices/advanced/file_download.rb
|
|
@@ -191,6 +189,12 @@ files:
|
|
|
191
189
|
- lib/browserctl/policy.rb
|
|
192
190
|
- lib/browserctl/recording.rb
|
|
193
191
|
- lib/browserctl/runner.rb
|
|
192
|
+
- lib/browserctl/secret_resolver_registry.rb
|
|
193
|
+
- lib/browserctl/secret_resolvers.rb
|
|
194
|
+
- lib/browserctl/secret_resolvers/base.rb
|
|
195
|
+
- lib/browserctl/secret_resolvers/env.rb
|
|
196
|
+
- lib/browserctl/secret_resolvers/macos_keychain.rb
|
|
197
|
+
- lib/browserctl/secret_resolvers/one_password.rb
|
|
194
198
|
- lib/browserctl/server.rb
|
|
195
199
|
- lib/browserctl/server/command_dispatcher.rb
|
|
196
200
|
- lib/browserctl/server/handlers/cookies.rb
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
#
|
|
4
|
-
# Smoke test for --params file loading (Task 7.5).
|
|
5
|
-
#
|
|
6
|
-
# Run with:
|
|
7
|
-
# browserctl workflow run examples/smoke/params_file.rb --params examples/smoke/params_file.yml
|
|
8
|
-
#
|
|
9
|
-
# The workflow logs in using credentials from the params file and asserts
|
|
10
|
-
# the secure area is reached — proving the params were loaded and available.
|
|
11
|
-
|
|
12
|
-
Browserctl.workflow "smoke/params_file" do
|
|
13
|
-
desc "Smoke: load credentials from a --params file and use them in a workflow"
|
|
14
|
-
|
|
15
|
-
param :username, required: true
|
|
16
|
-
param :password, required: true, secret: true
|
|
17
|
-
param :base_url, default: "https://the-internet.herokuapp.com"
|
|
18
|
-
|
|
19
|
-
step "open login page" do
|
|
20
|
-
open_page(:main, url: "#{base_url}/login")
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
step "fill credentials from params file" do
|
|
24
|
-
puts " [params] username = #{username.inspect}"
|
|
25
|
-
puts " [params] password = (#{password.length} chars, secret)"
|
|
26
|
-
page(:main).fill("input#username", username)
|
|
27
|
-
page(:main).fill("input#password", password)
|
|
28
|
-
page(:main).click("button[type=submit]")
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
step "assert login succeeded" do
|
|
32
|
-
page(:main).wait(".flash.success", timeout: 10)
|
|
33
|
-
assert page(:main).url.include?("/secure"), "expected redirect to /secure — params may not have loaded"
|
|
34
|
-
puts " [ok] reached secure area — params file loaded correctly"
|
|
35
|
-
end
|
|
36
|
-
end
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
#
|
|
4
|
-
# Smoke test for WorkflowContext#store / #fetch (Task 7.3).
|
|
5
|
-
#
|
|
6
|
-
# Uses the-internet's dynamic loading example: click Start, wait for "Hello World!",
|
|
7
|
-
# capture the text in step 1, assert it is still accessible in step 2 via fetch.
|
|
8
|
-
|
|
9
|
-
Browserctl.workflow "smoke/store_fetch" do
|
|
10
|
-
desc "Smoke: store a value in one step and retrieve it in a later step"
|
|
11
|
-
|
|
12
|
-
param :base_url, default: "https://the-internet.herokuapp.com"
|
|
13
|
-
|
|
14
|
-
step "open dynamic loading page" do
|
|
15
|
-
open_page(:main, url: "#{base_url}/dynamic_loading/1")
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
step "click start and capture loaded text" do
|
|
19
|
-
page(:main).click("div#start button")
|
|
20
|
-
page(:main).wait("div#finish", timeout: 10)
|
|
21
|
-
text = client.evaluate("main", "document.querySelector('div#finish h4')?.innerText?.trim()")[:result]
|
|
22
|
-
assert text && !text.empty?, "expected loaded text, got: #{text.inspect}"
|
|
23
|
-
store(:loaded_text, text)
|
|
24
|
-
puts " [store] loaded_text = #{text.inspect}"
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
step "fetch value from previous step and assert" do
|
|
28
|
-
text = fetch(:loaded_text)
|
|
29
|
-
puts " [fetch] loaded_text = #{text.inspect}"
|
|
30
|
-
assert text == "Hello World!", "expected 'Hello World!', got: #{text.inspect}"
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
step "confirm fetch raises for unknown key" do
|
|
34
|
-
fetch(:nonexistent_key)
|
|
35
|
-
assert false, "expected WorkflowError was not raised"
|
|
36
|
-
rescue Browserctl::WorkflowError => e
|
|
37
|
-
puts " [ok] WorkflowError raised as expected: #{e.message}"
|
|
38
|
-
end
|
|
39
|
-
end
|