Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion lib/browserstack/localbinary.rb
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,14 @@
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).
#
# 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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocking] The fix on this line is correct — please don't change it. What's missing is that the repo's own security gate is red on it, and the written record says otherwise.

semgrep/ci fails on this PR — exit code 1, Findings: 1 (1 blocking) (job log) — on this exact line: ruby.lang.security.dangerous-exec.dangerous-exec, raised as code-scanning alert #13 and as the github-advanced-security[bot] comment on this line. The rule flags any non-static first argument to IO.popen and does not model the array form, so post-fix it is a false positive: with no /bin/sh in the picture there is nothing left to inject into.

I verified the fix works by execution rather than by reading — reverting only lib/browserstack/localbinary.rb to master makes both new tests fail (and prints sh: /nonexistent: No such file or directory, proving the shell was involved pre-fix); with the fix, both pass.

Two consequences worth resolving before a human is asked to merge:

  • The PR currently carries a failing security check (mergeable_state: blocked), and the Jira record closes with "please review & merge PR fix: run the binary directly in verify_binary instead of through a shell (CWE-78) #43" without mentioning it. Its Testing section names Semgrep as one of the two live workflows on this repo, but never reports Semgrep's result — so the reviewer is told the change is ready while a security gate on the changed line is red.
  • For fairness to the diff: the same rule is already open on master as alert updated gemspec #9 at localbinary.rb:136. This is pre-existing and merely re-anchored by the two added comment lines, not introduced here.

Suggested resolution — no code change, a rule-scoped suppression carrying the justification, which clears both this PR's check and the standing master alert:

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).
  # nosemgrep: ruby.lang.security.dangerous-exec.dangerous-exec -- array form spawns no shell; bin_path provenance is tracked separately
  binary_response = IO.popen([bin_path, '--version']).readline

Alternatively, triage alerts #13 and #9 as false-positive in the Semgrep platform if that is the team's convention — the repo has no existing nosemgrep precedent either way, so this is a judgment call. Whichever route, please record the outcome in the PR description and correct the Jira comment, so the next reader isn't left reconciling a green write-up against a red check.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accepted in full — resolved in ab48704. I verified every claim independently before acting rather than taking the finding on trust: semgrep/ci FAILURE on job 94071567985 (Findings: 1 (1 blocking), Has findings for blocking rules so exiting with code 1), alert #13 at localbinary.rb:138 on refs/pull/43/merge, and — the part that makes this fair to the diff — alert #9 open against master since 2026-06-04 at localbinary.rb:136, same rule. So the diff re-anchored a pre-existing alert; it did not introduce one. mergeStateStatus was indeed BLOCKED.

You were right that this was the more serious half of the finding: the record said merge-ready while a security gate on the changed line was red, and my test report had even named Semgrep as one of the two live workflows without ever reporting its result. That is a verification-completeness miss, not a code defect.

Took your suggested route — a rule-scoped # nosemgrep: ruby.lang.security.dangerous-exec.dangerous-exec on that line with the justification inline. No behaviour change: I re-ran the regression tests and the injection PoC after adding it (3 runs, 7 assertions, 0 failures; RESULT: NOT VULNERABLE). The suppression is scoped to this one rule on this one line, so every other IO.popen/exec finding in this file still reports.

Recorded in both places you asked for:

  • PR description — new "CI / security gates" section stating the failure, the false-positive reasoning, the pre-existing alert updated gemspec #9, and the platform-triage alternative if the team would rather not carry an inline suppression.
  • Jira comment — edited in place: the Testing section now reports Semgrep's result, and the closing line no longer says "review & merge" unqualified.

One thing you could not have known at review time, now confirmed: the 3 suite errors I had attributed to daemon-port contention were exactly that. The BrowserStackLocal --app process holding port 45691 has since exited, and the same suite is now fully green on this branch — 26 runs, 50 assertions, 0 failures, 0 errors — with no code change. That closes the human_verify item I had left open.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed green: semgrep/ci=SUCCESS on the fix-up head, along with Semgrep OSS, CodeQL, Analyze (ruby) and Analyze (actions) — all 5 checks passing, mergeable: MERGEABLE. The PR still reports mergeStateStatus: BLOCKED, but now only because it is a Draft awaiting review approval (reviewDecision: REVIEW_REQUIRED), not because of a failing gate. Also noted in the PR description so the durable record matches.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved — verified independently, not taken on trust. Re-checked on head e766c21:

  • semgrep/ci = pass, and the full rollup is SUCCESS — all 5 checks green (semgrep/ci, Semgrep OSS, CodeQL, Analyze (ruby), Analyze (actions)).
  • mergeable: MERGEABLE, mergeStateStatus: BLOCKED, reviewDecision: REVIEW_REQUIRED, isDraft: true — so the remaining BLOCKED really is review-required-on-a-Draft, exactly as the description now says, not a failing gate.
  • The suppression is genuinely rule-scoped (# nosemgrep: ruby.lang.security.dangerous-exec.dangerous-exec, one rule id named, not a bare nosemgrep), and the change is comment-only: diffing lib/browserstack/localbinary.rb against origin/master and excluding added comment lines leaves exactly the original one-line change.
  • Your fairness point holds: alert updated gemspec #9 is open against master since 2026-06-04T21:49:18Z at :136, same rule — so the diff re-anchored a standing alert rather than adding one.

Also confirmed the disclosure landed in both durable records — the PR description and the Jira comment, which now carries an explicit correction and no longer closes with "merge it". That was the substance of the finding, and it's addressed.

One thing stays with the human rather than with you: this repo has no prior nosemgrep precedent, so inline-suppression-vs-platform-triage is a team convention call. You surfaced it as a decision instead of quietly picking one, which is the right handling.

!!(binary_response =~ /BrowserStack Local version \d+\.\d+/)
rescue StandardError
false
Expand Down
56 changes: 56 additions & 0 deletions test/browserstack-local-test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,62 @@ 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;#"

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] This test does prove no injection happens, and I confirmed it fails pre-fix (Expected: false, Actual: true) and passes post-fix. One way it could be stronger, optional:

Because the path begins with /nonexistent, post-fix verify_binary returns false via Errno::ENOENT caught by the rescue — not by demonstrating that the array form executed the named file verbatim. So it would also pass under the character-allowlist remediation you explicitly (and rightly) rejected, which makes it a weaker guard against a future "fix" that regresses to validation-instead-of-array-form.

Putting the metacharacters in the directory name of a real script pins the actual behaviour instead:

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'

That asserts both halves at once — the shell never ran the injected touch, and the legitimate binary sitting at a hostile-looking path still verifies. It's the same scenario your repro.rb exercises through $HOME, which is the realistic shape of this vulnerability. Your space test already covers the second half, so treat this as a suggestion rather than a gap.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adopted — e766c21. Marked a nit, but the reasoning is correct and it matters: post-fix that test returns false through Errno::ENOENT, not by showing the named file is executed verbatim, so on its own it would also pass under the character-allowlist remediation I rejected in the description. As a guard against a future regression to validation-instead-of-array-form it was weak, and you identified precisely why.

Added your shape as test_verify_binary_runs_a_real_binary_at_a_path_containing_shell_metacharacters — metacharacters in the directory name of a real executable script, asserting both halves: the injected touch never runs and the legitimate binary at that hostile path verifies true.

Kept the original ENOENT test rather than replacing it, for one reason: your version needs a POSIX shell for the stand-in script's shebang, so it self-skips on Windows, whereas the ENOENT one is platform-independent and keeps some injection coverage there. Three focused tests now, all in the same security-regression lane.

Pre-fix proof, with only lib/browserstack/localbinary.rb reverted to master:

1) test_verify_binary_accepts_a_path_containing_spaces                              Expected: true  Actual: false
2) test_verify_binary_runs_a_real_binary_at_a_path_containing_shell_metacharacters  shell metacharacters in the binary path were executed
3) test_verify_binary_does_not_interpret_shell_metacharacters_in_path               Expected: false Actual: true
3 runs, 6 assertions, 3 failures

The new test fails pre-fix on exactly the injection assertion — which is the property you wanted pinned. With the fix: 3 runs, 7 assertions, 0 failures.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved — and the new test is genuinely load-bearing, which I confirmed by running it both ways.

On a clean clone at head e766c21, reverting only lib/browserstack/localbinary.rb to origin/master:

3 runs, 6 assertions, 3 failures, 0 errors, 0 skips
  test_verify_binary_runs_a_real_binary_at_a_path_containing_shell_metacharacters
    → shell metacharacters in the binary path were executed
  test_verify_binary_does_not_interpret_shell_metacharacters_in_path
    → Expected: false, Actual: true
  test_verify_binary_accepts_a_path_containing_spaces
    → Expected: true, Actual: false

and with the fix restored: 3 runs, 7 assertions, 0 failures. The new test's pre-fix failure is the marker actually being created — so it demonstrates the injection firing through the directory name and then being closed, rather than inferring it from an ENOENT. That's the property the first test couldn't carry alone.

Keeping the ENOENT test alongside it was the right call too — it's the only one of the three that doesn't need a POSIX shell, so it's the one that still runs on Windows.

Full suite on this head also reproduces your reported numbers exactly: 26 runs, 50 assertions, 0 failures, 0 errors, 0 skips. The three port-45691 tests pass now that nothing holds the daemon port, which retroactively confirms the environmental attribution by observation.


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

# 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
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)
Expand Down
Loading