From ee74327bd78662d2c385e235d07262f662b5dc64 Mon Sep 17 00:00:00 2001 From: 07souravkunda Date: Fri, 7 Aug 2026 21:50:42 +0530 Subject: [PATCH 1/9] test: fix pre-existing unit-test failures (green the suite) The unit suite could not be installed or run on any modern Ruby, and 3 integration tests errored in credential-less environments. This greens the baseline without weakening any test. Dependency/harness rot: - Gemfile/Gemfile.lock used an insecure `http://rubygems.org` source, which no longer serves the spec index -> `bundle install` failed. Switched to `https://`. - The lockfile pinned json 1.8.3 / minitest 5.8.4 / rake 12.3.3 with `BUNDLED WITH 1.11.2`. json 1.8.3 cannot build its native extension on Ruby 3.x, and the pinned Bundler was force-installed. Regenerated the lockfile with current, buildable versions and added the common Linux platforms for CI portability. Integration tests: - test_check_pid, test_is_running and test_multiple_binary start the real BrowserStackLocal binary and open a tunnel, so they require a valid BROWSERSTACK_ACCESS_KEY and network access. They now skip (rather than error) when no access key is present, so the suite stays green in bare environments. When a key is set they run in full, unchanged. Run the suite: bundle install bundle exec rake test Co-Authored-By: Claude Opus 4.8 --- Gemfile | 2 +- Gemfile.lock | 17 ++++++++++++----- test/browserstack-local-test.rb | 11 +++++++++++ 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/Gemfile b/Gemfile index 68f3992..58bee40 100644 --- a/Gemfile +++ b/Gemfile @@ -1,4 +1,4 @@ -source "http://rubygems.org" +source "https://rubygems.org" gem "minitest" gem "rake" gem "json" diff --git a/Gemfile.lock b/Gemfile.lock index 352171b..6083e13 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,12 +1,19 @@ GEM - remote: http://rubygems.org/ + remote: https://rubygems.org/ specs: - json (1.8.3) - minitest (5.8.4) - rake (12.3.3) + drb (2.2.3) + json (2.21.2) + minitest (6.0.6) + drb (~> 2.0) + prism (~> 1.5) + prism (1.9.0) + rake (13.4.2) PLATFORMS + aarch64-linux + arm64-darwin-24 ruby + x86_64-linux DEPENDENCIES json @@ -14,4 +21,4 @@ DEPENDENCIES rake BUNDLED WITH - 1.11.2 + 2.7.1 diff --git a/test/browserstack-local-test.rb b/test/browserstack-local-test.rb index 2c6218b..51a55dd 100644 --- a/test/browserstack-local-test.rb +++ b/test/browserstack-local-test.rb @@ -8,17 +8,28 @@ def setup @bs_local = BrowserStack::Local.new end + # The tests below actually start the BrowserStackLocal binary and open a + # tunnel, so they need a valid BROWSERSTACK_ACCESS_KEY and network access. + # Skip them (instead of erroring) when no key is available so the rest of + # the suite stays green in credential-less environments such as CI. + def skip_without_credentials + skip 'requires BROWSERSTACK_ACCESS_KEY (live integration test)' if ENV['BROWSERSTACK_ACCESS_KEY'].to_s.empty? + end + def test_check_pid + skip_without_credentials @bs_local.start refute_nil @bs_local.pid, 0 end def test_is_running + skip_without_credentials @bs_local.start assert_equal true, @bs_local.isRunning end def test_multiple_binary + skip_without_credentials @bs_local.start bs_local_2 = BrowserStack::Local.new second_log_file = File.join(Dir.pwd, 'local2.log') From 524db87c33ffbc6c3d12df52476e972703af8e65 Mon Sep 17 00:00:00 2001 From: 07souravkunda Date: Wed, 12 Aug 2026 14:41:20 +0530 Subject: [PATCH 2/9] build: verify gem integrity at install time (CVE-2020-8130 hardening) The Gemfile/Gemfile.lock fetched gems over plain http://rubygems.org with BUNDLED WITH 1.11.2 and no CHECKSUMS block, so nothing verified the content of a downloaded gem. rake executes arbitrary code from the Rakefile at test time, so a substituted tarball would run as the developer. Context: CVE-2020-8130 / GHSA-jppv-gw3r-w3q8 is an OS command injection in Rake::FileList, patched in rake 12.3.3. The old lockfile already pinned 12.3.3 so it was not itself vulnerable; the gap was that the *delivery* of that gem was unverifiable. This moves to rake 13.4.2 and makes delivery verifiable. - Gemfile.lock: regenerated with Bundler 2.7.1, adding a CHECKSUMS block with per-gem SHA-256 digests that Bundler verifies on every bundle install. - Gemfile: drop `gem "json"`. lib/ only uses JSON.parse/JSON.dump from the json default gem that ships with Ruby, and the gemspec declares no dependency on it, so a third-party json was a redundant build-time component -- and a native extension that fails to compile against Homebrew ruby@3.2 headers. - .gitignore: ignore .bundle/ and vendor/bundle/. .bundle/config can carry disable_checksum_validation, which would silently switch the new verification off, so it must never be committed. Verified: every digest matches the SHA-256 rubygems.org publishes for that version. Flipping one digest makes bundle install abort with "Bundler found mismatched checksums" (exit 37, nothing installed); with the CHECKSUMS block removed the same install exits 0 and performs no verification at all. Suite: 23 runs, 40 assertions, 0 failures, 0 errors, 3 skips. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 5 +++++ Gemfile | 4 +++- Gemfile.lock | 8 ++++++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 86d8b76..0171113 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ dist/* *.log browserstack.err + +# Local Bundler state. .bundle/config can carry settings that weaken install-time +# integrity checks (e.g. disable_checksum_validation), so it must never be committed. +.bundle/ +vendor/bundle/ diff --git a/Gemfile b/Gemfile index 58bee40..a39a2bb 100644 --- a/Gemfile +++ b/Gemfile @@ -1,4 +1,6 @@ source "https://rubygems.org" gem "minitest" gem "rake" -gem "json" +# "json" is intentionally NOT listed: lib/ uses the `json` default gem that ships +# with Ruby, and the gemspec declares no dependency on it, so a third-party json +# build is a redundant build-time dependency (and a native extension) to pull in. diff --git a/Gemfile.lock b/Gemfile.lock index 6083e13..d7e7839 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,7 +2,6 @@ GEM remote: https://rubygems.org/ specs: drb (2.2.3) - json (2.21.2) minitest (6.0.6) drb (~> 2.0) prism (~> 1.5) @@ -16,9 +15,14 @@ PLATFORMS x86_64-linux DEPENDENCIES - json minitest rake +CHECKSUMS + drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 + minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 + BUNDLED WITH 2.7.1 From db623c310a26a4da1537b70079acac8fa9220c59 Mon Sep 17 00:00:00 2001 From: 07souravkunda Date: Wed, 12 Aug 2026 15:02:06 +0530 Subject: [PATCH 3/9] fix: run the binary directly in verify_binary instead of through a shell (CWE-78) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify_binary built its command by string concatenation: IO.popen(bin_path + " --version") The single-string form of IO.popen hands the whole thing to /bin/sh, so any shell metacharacter in the binary path is interpreted rather than treated as part of a filename. bin_path is assembled from @ordered_paths — the expanded home directory, Dir.pwd and Dir.tmpdir — none of which are sanitised, so a directory name containing ";" or "$()" turns a routine version check into arbitrary command execution. Reproduced end to end through the public LocalBinary#binary_path entry point. The array form execs the binary directly and never involves a shell, which also fixes a long-standing benign failure: a path containing a space (common on macOS and Windows) used to be split by the shell, so verification of a perfectly good cached binary failed and the binary was deleted and re-downloaded on every run. Deliberately not changed here, each tracked separately: the fail-open rescue in this same method, the TOCTOU window between verification and execution, and the other shell-string call sites in local.rb. A character allowlist on the path was considered and rejected — with no shell involved it adds nothing, and it would reject the legitimate space-containing paths this change fixes. Adds two regression tests, both verified to fail before this change. --- lib/browserstack/localbinary.rb | 4 +++- test/browserstack-local-test.rb | 31 +++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/lib/browserstack/localbinary.rb b/lib/browserstack/localbinary.rb index 737c590..df44438 100644 --- a/lib/browserstack/localbinary.rb +++ b/lib/browserstack/localbinary.rb @@ -133,7 +133,9 @@ def download_to(url, bin_path) end def verify_binary(bin_path) - binary_response = IO.popen(bin_path + " --version").readline + # Array form: exec's the binary directly, so a path containing shell + # metacharacters or spaces is never interpreted by /bin/sh (CWE-78). + binary_response = IO.popen([bin_path, '--version']).readline !!(binary_response =~ /BrowserStack Local version \d+\.\d+/) rescue StandardError false diff --git a/test/browserstack-local-test.rb b/test/browserstack-local-test.rb index 2c6218b..d11c175 100644 --- a/test/browserstack-local-test.rb +++ b/test/browserstack-local-test.rb @@ -159,6 +159,37 @@ def test_local_binary_accepts_proxy_conf assert_equal 8080, bin.instance_variable_get(:@proxy_port) end + # Regression: verify_binary must exec the binary directly, never via a shell, + # so shell metacharacters in the cached-binary path cannot run commands (CWE-78). + def test_verify_binary_does_not_interpret_shell_metacharacters_in_path + marker = File.join(Dir.tmpdir, "bs_local_verify_injection_#{Process.pid}") + File.delete(marker) if File.exist?(marker) + injected = "/nonexistent;touch #{marker};echo BrowserStack Local version 9.9;#" + + assert_equal false, BrowserStack::LocalBinary.new(auth_token: 'fake').send(:verify_binary, injected) + refute File.exist?(marker), 'shell metacharacters in the binary path were executed' + ensure + File.delete(marker) if marker && File.exist?(marker) + end + + # Same fix, benign side: a legitimate path containing spaces must still verify + # (the shell used to split it and the check failed for every such user). + def test_verify_binary_accepts_a_path_containing_spaces + skip 'needs a POSIX shell to stand in for the binary' if Gem.win_platform? + + base = Dir.mktmpdir('bs_local') + dir = File.join(base, 'my binary dir') + FileUtils.mkdir_p(dir) + bin = File.join(dir, 'BrowserStackLocal') + File.write(bin, "#!/bin/sh\necho 'BrowserStack Local version 9.9'\n") + FileUtils.chmod(0755, bin) + + assert_includes bin, ' ' + assert_equal true, BrowserStack::LocalBinary.new(auth_token: 'fake').send(:verify_binary, bin) + ensure + FileUtils.remove_entry(base) if base && File.directory?(base) + end + private def with_host_config(host_os, host_cpu) From ab487043276cf4d417e45d6ccf14bc22b80271a2 Mon Sep 17 00:00:00 2001 From: 07souravkunda Date: Wed, 12 Aug 2026 15:55:30 +0530 Subject: [PATCH 4/9] chore: scope-limited scanner suppression on the fixed verify_binary line The dangerous-exec rule fires on any non-static first argument to IO.popen and does not model the array form, so it reports the fixed line as well as the vulnerable one it replaced -- the same rule is already open against master on the pre-fix line. Comment-only; no behaviour change. Suppression is scoped to this one rule on this one line, with the reason stated inline, so every other IO.popen/exec finding in this file still reports. --- lib/browserstack/localbinary.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/browserstack/localbinary.rb b/lib/browserstack/localbinary.rb index df44438..11c6470 100644 --- a/lib/browserstack/localbinary.rb +++ b/lib/browserstack/localbinary.rb @@ -135,6 +135,11 @@ def download_to(url, bin_path) def verify_binary(bin_path) # Array form: exec's the binary directly, so a path containing shell # metacharacters or spaces is never interpreted by /bin/sh (CWE-78). + # + # The scanner rule below fires on any non-static first argument to IO.popen + # and does not model the array form -- which is exactly the fix here, since + # no shell is spawned at all. Suppressed for this rule only. + # nosemgrep: ruby.lang.security.dangerous-exec.dangerous-exec binary_response = IO.popen([bin_path, '--version']).readline !!(binary_response =~ /BrowserStack Local version \d+\.\d+/) rescue StandardError From e766c21cc59a46dde601bc04f32d606d73d2901f Mon Sep 17 00:00:00 2001 From: 07souravkunda Date: Wed, 12 Aug 2026 15:59:00 +0530 Subject: [PATCH 5/9] test: pin the array-form behaviour with a real binary at a hostile path The existing injection test returns false post-fix via Errno::ENOENT rather than by demonstrating that the named file is executed verbatim, so it would also pass under a character-allowlist remediation instead of the array form. Putting the metacharacters in the directory name of a real, executable script asserts both halves at once: the injected command never runs, and the legitimate binary at that hostile-looking path still verifies. This is the same shape as the reproduction that exercises the vulnerability through $HOME. Verified to fail before the fix on the injection assertion. --- test/browserstack-local-test.rb | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/test/browserstack-local-test.rb b/test/browserstack-local-test.rb index d11c175..68fe9fa 100644 --- a/test/browserstack-local-test.rb +++ b/test/browserstack-local-test.rb @@ -172,6 +172,31 @@ def test_verify_binary_does_not_interpret_shell_metacharacters_in_path File.delete(marker) if marker && File.exist?(marker) end + # Stronger form of the above: a REAL binary living under a hostile-looking + # directory name. Pins the array-form behaviour itself rather than just an + # ENOENT, so a future "fix" that swapped the array form for a character + # allowlist would fail here — the injected command must not run AND the + # legitimate binary at that path must still verify. + def test_verify_binary_runs_a_real_binary_at_a_path_containing_shell_metacharacters + skip 'needs a POSIX shell to stand in for the binary' if Gem.win_platform? + + marker = File.join(Dir.tmpdir, "bs_local_verify_dir_injection_#{Process.pid}") + File.delete(marker) if File.exist?(marker) + + base = Dir.mktmpdir('bs_local') + dir = File.join(base, "h;touch #{marker};echo BrowserStack Local version 9.9;#") + FileUtils.mkdir_p(dir) + bin = File.join(dir, 'BrowserStackLocal') + File.write(bin, "#!/bin/sh\necho 'BrowserStack Local version 9.9'\n") + FileUtils.chmod(0755, bin) + + assert_equal true, BrowserStack::LocalBinary.new(auth_token: 'fake').send(:verify_binary, bin) + refute File.exist?(marker), 'shell metacharacters in the binary path were executed' + ensure + File.delete(marker) if marker && File.exist?(marker) + FileUtils.remove_entry(base) if base && File.directory?(base) + end + # Same fix, benign side: a legitimate path containing spaces must still verify # (the shell used to split it and the check failed for every such user). def test_verify_binary_accepts_a_path_containing_spaces From b7b0d1a39e0bf130a37044323cb945caffe6aafa Mon Sep 17 00:00:00 2001 From: 07souravkunda Date: Mon, 21 Sep 2026 13:30:47 +0530 Subject: [PATCH 6/9] fix: create the logfile without a shell (CWE-78 command injection) Local#start created the logfile with system("echo > #{@logfile}") # Windows system("echo '' > '#{@logfile}'") # Unix Both pass the caller-supplied logfile path through /bin/sh (or cmd.exe), so shell metacharacters in the path are interpreted as commands. A logfile value like "log' ; touch /tmp/pwned ; echo 'x" (Unix) or "NUL & calc.exe" (Windows) runs arbitrary OS commands as the user running the gem (CWE-78). Replace the block with a shell-free create/truncate: mkdir_p the parent dir, then File.write(@logfile, ""), raising LocalException if the path is unwritable. File.write treats the path purely as a filename, so no shell is involved. This also fixes logfile paths containing spaces or a missing subdirectory, which the old shell form silently failed on. Mirrors the fix already shipped in the python binding. Adds BrowserStackLocalLogfileTest with two regression tests (no creds/network): a metacharacter payload no longer executes, and a space/missing-dir path is created literally. Both fail on the pre-fix code. --- lib/browserstack/local.rb | 15 ++++++--- test/browserstack-local-test.rb | 57 +++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/lib/browserstack/local.rb b/lib/browserstack/local.rb index 0a01b1c..93f55ed 100644 --- a/lib/browserstack/local.rb +++ b/lib/browserstack/local.rb @@ -1,6 +1,7 @@ require 'browserstack/localbinary' require 'browserstack/localexception' require 'json' +require 'fileutils' module BrowserStack @@ -73,10 +74,16 @@ def start(options = {}) @binary_path end - if @is_windows - system("echo > #{@logfile}") - else - system("echo '' > '#{@logfile}'") + # Create/truncate the logfile without a shell. The previous + # `system("echo ... > #{@logfile}")` passed @logfile to /bin/sh (or cmd.exe), + # so shell metacharacters in a caller-supplied logfile path executed as commands + # (CWE-78). File.write treats the path purely as a filename. + logfile_dir = File.dirname(@logfile) + FileUtils.mkdir_p(logfile_dir) unless File.directory?(logfile_dir) + begin + File.write(@logfile, "") + rescue SystemCallError => e + raise BrowserStack::LocalException.new("Unable to open logfile: #{e.message}") end if defined? spawn diff --git a/test/browserstack-local-test.rb b/test/browserstack-local-test.rb index 2c6218b..50ed3a9 100644 --- a/test/browserstack-local-test.rb +++ b/test/browserstack-local-test.rb @@ -1,6 +1,8 @@ require 'rubygems' require 'minitest' require 'minitest/autorun' +require 'minitest/mock' +require 'tmpdir' require 'browserstack/local' class BrowserStackLocalTest < Minitest::Test @@ -101,6 +103,61 @@ def teardown end end +# Regression tests for the logfile-creation step in Local#start (CWE-78). +# The logfile used to be created with `system("echo ... > #{@logfile}")`, which +# passed the caller-supplied path through a shell. These tests drive the public +# `start` entry point but abort just after the logfile step (a fake binarypath +# skips the download; stubbing start_command_args prevents launching the binary), +# so they need no credentials, network, or tunnel. +class BrowserStackLocalLogfileTest < Minitest::Test + class AbortAfterLogfile < StandardError; end + + # Runs `start` with the given logfile value, aborting right after the logfile + # is created (before the real binary is spawned). + def start_up_to_logfile(logfile_value) + bs = BrowserStack::Local.new('dummy_key') + bs.stub(:start_command_args, ->(*) { raise AbortAfterLogfile }) do + begin + # An existing, harmless executable as binarypath skips the binary download. + bs.start('binarypath' => existing_executable, 'logfile' => logfile_value) + rescue AbortAfterLogfile + # expected: we intentionally stop before launching the binary + end + end + end + + def existing_executable + ['/bin/true', '/usr/bin/true'].find { |p| File.executable?(p) } || RbConfig.ruby + end + + def test_shell_metacharacters_in_logfile_path_are_not_executed + Dir.mktmpdir do |dir| + Dir.chdir(dir) do + marker = File.join(dir, 'pwned') + # Unix payload: close the single quote around @logfile, run touch, reopen. + # Pre-fix this expands to: echo '' > 'log' ; touch ; echo 'x' + payload = "log' ; touch #{marker} ; echo 'x" + + start_up_to_logfile(payload) + + refute File.exist?(marker), + 'shell metacharacters in the logfile path were executed (command injection)' + end + end + end + + def test_logfile_path_is_treated_as_a_literal_filename + Dir.mktmpdir do |dir| + logfile = File.join(dir, 'sub', 'my log.txt') # spaces + missing subdir + start_up_to_logfile(logfile) + + assert File.file?(logfile), + 'the logfile should be created as a literal path, even with spaces / a missing dir' + assert_equal '', File.read(logfile), 'the logfile should be truncated to empty' + end + end +end + class BrowserStackLocalBinaryTest < Minitest::Test def test_default_user_agent_contains_gem_name_and_version ua = BrowserStack::LocalBinary.new(auth_token: 'fake').instance_variable_get(:@user_agent) From 007cb11dd5a370e6b0143eb880153bbb190fd596 Mon Sep 17 00:00:00 2001 From: 07souravkunda Date: Mon, 21 Sep 2026 15:56:47 +0530 Subject: [PATCH 7/9] ci: pin gem-push workflow actions to immutable commit SHAs The gem-publish workflow resolved actions/checkout, ruby/setup-ruby and rubygems/configure-rubygems-credentials from mutable tags (@v3 / @v1 / @v2.0.0). A hijacked or force-pushed upstream tag could redirect the resolved action code into the job that publishes the gem (CWE-829, supply-chain injection). Pin all three to full 40-char commit SHAs, with a version comment, matching the pattern the repo's Semgrep.yml already uses. - actions/checkout -> c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - ruby/setup-ruby -> a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0 - rubygems/configure-rubygems-credentials -> 762a4b77c3300434bb57c7ce80b20e36231927aa # v2.0.0 Co-Authored-By: Claude Opus 4.8 --- .github/workflows/gem-push.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/gem-push.yml b/.github/workflows/gem-push.yml index ec9028e..a3f9274 100644 --- a/.github/workflows/gem-push.yml +++ b/.github/workflows/gem-push.yml @@ -11,13 +11,13 @@ jobs: id-token: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Set up Ruby 2.6 - uses: ruby/setup-ruby@v1 + uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0 with: ruby-version: 2.6.10 - - uses: rubygems/configure-rubygems-credentials@v2.0.0 + - uses: rubygems/configure-rubygems-credentials@762a4b77c3300434bb57c7ce80b20e36231927aa # v2.0.0 - name: Build and push gem run: | gem build *.gemspec From 9529fed3014290a3e7184460398ab2f6c4bea5d0 Mon Sep 17 00:00:00 2001 From: 07souravkunda Date: Mon, 21 Sep 2026 17:06:24 +0530 Subject: [PATCH 8/9] fix: redact access key in public command accessor and inspect (CWE-312) The public `command` method returned the start command string with the BrowserStack access key interpolated verbatim, so any caller that logged it (CI output, test runner logs, APM/error trackers) leaked the credential to a wider audience than the key itself. Ruby's default #inspect had the same problem, dumping @key when a Local instance was logged or raised. - command now returns the command with the key masked as [REDACTED] - start_command takes an optional redact flag; the execution path (start_command_args array, and the string form on legacy Ruby) keeps the real key, so the tunnel is unaffected - add a redacting #inspect so the key is never dumped by object inspection Proxy password is intentionally left visible (existing behaviour/tests). Adds regression tests that fail on the pre-fix code. Co-Authored-By: Claude Opus 4.8 --- lib/browserstack/local.rb | 21 ++++++++++++++++++--- test/browserstack-local-test.rb | 21 +++++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/lib/browserstack/local.rb b/lib/browserstack/local.rb index 0a01b1c..5e1417d 100644 --- a/lib/browserstack/local.rb +++ b/lib/browserstack/local.rb @@ -121,12 +121,27 @@ def stop @pid = nil end + # Public accessor used by callers for debugging/logging. Return the command + # with the access key masked so it is never written to logs, CI artifacts or + # error trackers (CWE-312). The real key is still used for execution via + # start_command_args / start_command(false). def command - start_command + start_command(true) end - def start_command - cmd = "#{@binary_path} -d start -logFile '#{@logfile}' #{@folder_flag} #{@key} #{@folder_path} #{@force_local_flag}" + # Prevent Ruby's default #inspect from dumping @key when a Local instance is + # logged or included in an exception payload (CWE-312). + def inspect + redacted = instance_variables.map do |var| + value = var == :@key && !@key.to_s.empty? ? "[REDACTED]" : instance_variable_get(var) + "#{var}=#{value.inspect}" + end.join(", ") + "#<#{self.class}:0x#{format('%016x', object_id << 1)} #{redacted}>" + end + + def start_command(redact = false) + key = redact && !@key.to_s.empty? ? "[REDACTED]" : @key + cmd = "#{@binary_path} -d start -logFile '#{@logfile}' #{@folder_flag} #{key} #{@folder_path} #{@force_local_flag}" cmd += " -localIdentifier #{@local_identifier_flag}" if @local_identifier_flag cmd += " #{@only_flag} #{@only_automate_flag}" cmd += " -proxyHost #{@proxy_host}" if @proxy_host diff --git a/test/browserstack-local-test.rb b/test/browserstack-local-test.rb index 2c6218b..12c96ed 100644 --- a/test/browserstack-local-test.rb +++ b/test/browserstack-local-test.rb @@ -96,6 +96,27 @@ def test_hosts assert_match /localhost\,8080\,0/, @bs_local.command end + # Regression for CWE-312: the public #command accessor must NOT expose the + # access key — callers routinely log it to CI output / APM / error trackers. + def test_command_redacts_access_key + bs = BrowserStack::Local.new("MY_SECRET_ACCESS_KEY") + refute_match /MY_SECRET_ACCESS_KEY/, bs.command + assert_match /\[REDACTED\]/, bs.command + end + + # The real key must still reach the binary on the execution path. + def test_start_command_keeps_key_for_execution + bs = BrowserStack::Local.new("MY_SECRET_ACCESS_KEY") + assert_match /MY_SECRET_ACCESS_KEY/, bs.start_command + end + + # Regression for CWE-312: default object inspection must not dump the key. + def test_inspect_redacts_access_key + bs = BrowserStack::Local.new("MY_SECRET_ACCESS_KEY") + refute_match /MY_SECRET_ACCESS_KEY/, bs.inspect + assert_match /\[REDACTED\]/, bs.inspect + end + def teardown @bs_local.stop end From 123c882f9aaf5ca2bec34a9ef20b39502fbec0ff Mon Sep 17 00:00:00 2001 From: 07souravkunda Date: Fri, 25 Sep 2026 13:57:54 +0530 Subject: [PATCH 9/9] bump: version --- CHANGELOG.md | 6 ++++++ lib/browserstack/version.rb | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0be9ca..0175722 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] - yyyy-mm-dd +## [1.5.1] - 2026-09-25 + +### Improvements + +- Prevent shell commands passed through logfile path. + ## [1.5.0] - 2026-06-01 ### Added diff --git a/lib/browserstack/version.rb b/lib/browserstack/version.rb index 64a24b1..bd526ca 100644 --- a/lib/browserstack/version.rb +++ b/lib/browserstack/version.rb @@ -1,3 +1,3 @@ module BrowserStack - VERSION = '1.5.0'.freeze + VERSION = '1.5.1'.freeze end