├── .document ├── .github └── workflows │ └── test.yml ├── .gitignore ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── lib ├── safe_shell.rb └── safe_shell │ └── version.rb ├── safe_shell.gemspec ├── spec ├── safe_shell_spec.rb └── spec.opts └── tmp └── .gitkeep /.document: -------------------------------------------------------------------------------- 1 | README.rdoc 2 | lib/**/*.rb 3 | bin/* 4 | features/**/*.feature 5 | LICENSE 6 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Tests 3 | on: [ push, pull_request ] 4 | jobs: 5 | test: 6 | name: Test (Ruby ${{ matrix.ruby }}) 7 | runs-on: ubuntu-latest 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | ruby: 12 | - '2.0' 13 | - '2.1' 14 | - '2.2' 15 | - '2.3' 16 | - '2.4' 17 | - '2.5' 18 | - '2.6' 19 | - '2.7' 20 | - '3.0' 21 | - '3.1' 22 | - '3.2' 23 | - 'jruby' 24 | - 'truffleruby' 25 | steps: 26 | - uses: actions/checkout@v3 27 | - uses: ruby/setup-ruby@v1 28 | with: 29 | ruby-version: ${{ matrix.ruby }} 30 | bundler-cache: true 31 | - run: bundle exec rake 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## MAC OS 2 | .DS_Store 3 | 4 | ## TEXTMATE 5 | *.tmproj 6 | tmtags 7 | 8 | ## EMACS 9 | *~ 10 | \#* 11 | .\#* 12 | 13 | ## VIM 14 | *.swp 15 | 16 | ## PROJECT::GENERAL 17 | coverage 18 | rdoc 19 | pkg/* 20 | *.gem 21 | .bundle 22 | Gemfile.lock 23 | 24 | ## PROJECT::SPECIFIC 25 | tmp/* 26 | 27 | ## RVM 28 | .rvmrc 29 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | # Specify your gem's dependencies in safe_shell.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010 Envato, Ian Leitch, & Pete Yandell. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SafeShell 2 | 3 | SafeShell lets you execute shell commands and get the resulting output, but without the security problems of Ruby's backtick operator. 4 | 5 | ## Usage 6 | 7 | Install gem: 8 | 9 | ```sh 10 | gem install safe_shell 11 | ``` 12 | 13 | Use gem: 14 | 15 | ```ruby 16 | require 'safe_shell' 17 | SafeShell.execute("echo", "Hello, world!") 18 | ``` 19 | 20 | SafeShell sets the $? operator to the process status, in the same manner as the backtick operator. 21 | 22 | ```ruby 23 | # Send stdout and stderr to files: 24 | SafeShell.execute("echo", "Hello, world!", :stdout => "output.txt", :stderr => "error.txt") 25 | 26 | # Send additional environment variables: 27 | SafeShell.execute("echo", "Hello, world!", :env => { 'name' => 'john', 'foo' => 'bar' }) 28 | 29 | # Return true if the command exits with a zero status: 30 | SafeShell.execute?("echo", "Hello, world!") 31 | 32 | # Raise an exception if the command exits with a non-zero status: 33 | SafeShell.execute!("echo", "Hello, world!") 34 | ``` 35 | 36 | ## Why? 37 | 38 | If you use backticks to process a file supplied by a user, a carefully crafted filename could allow execution of an arbitrary command: 39 | 40 | ```ruby 41 | file = ";blah" 42 | `echo #{file}` 43 | sh: blah: command not found 44 | => "\n" 45 | ``` 46 | 47 | SafeShell solves this. 48 | 49 | ```ruby 50 | SafeShell.execute("echo", file) 51 | => ";blah\n" 52 | ``` 53 | 54 | ## Compatibility 55 | 56 | Tested with Ruby 2.0.0 or newer, but it should be happy on pretty much any Ruby version. Maybe not so much on Windows. 57 | 58 | ## Test 59 | 60 | ```sh 61 | bundle exec rake 62 | ```` 63 | 64 | ## Developing 65 | 66 | * Fork the project. 67 | * Make your feature addition or bug fix. 68 | * Add tests for it. This is important so I don't break it in a 69 | future version unintentionally. 70 | * Commit, do not mess with rakefile, version, or history. 71 | (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull) 72 | * Send me a pull request. Bonus points for topic branches. 73 | 74 | ## Status 75 | 76 | In use on at least one big site, so should be pretty solid. There's not much to it, so I'm not expecting there'll be many releases. 77 | 78 | ## Copyright 79 | 80 | Copyright (c) 2010 - 2015 Envato, Ian Leitch, & Pete Yandell. See LICENSE for details. 81 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler' 2 | Bundler::GemHelper.install_tasks 3 | 4 | require 'rspec/core/rake_task' 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | task :default => :spec 9 | 10 | 11 | require 'rdoc/task' 12 | Rake::RDocTask.new do |rdoc| 13 | version = File.exist?('VERSION') ? File.read('VERSION') : "" 14 | 15 | rdoc.rdoc_dir = 'rdoc' 16 | rdoc.title = "safe_shell #{version}" 17 | rdoc.rdoc_files.include('README*') 18 | rdoc.rdoc_files.include('lib/**/*.rb') 19 | end 20 | -------------------------------------------------------------------------------- /lib/safe_shell.rb: -------------------------------------------------------------------------------- 1 | module SafeShell 2 | class CommandFailedException < RuntimeError; end 3 | 4 | def self.execute(command, *args) 5 | opts = args.last.kind_of?(Hash) ? args.pop : {} 6 | read_end, write_end = IO.pipe 7 | new_stdout = opts[:stdout] ? File.open(opts[:stdout], "w+") : write_end 8 | new_stderr = opts[:stderr] ? File.open(opts[:stderr], "w+") : write_end 9 | env = opts[:env] 10 | opts = {:in => read_end, :out => new_stdout, :err => new_stderr} 11 | 12 | pid = if env 13 | spawn(env, command, *(args.map { |a| a.to_s }), opts) 14 | else 15 | spawn(command, *(args.map { |a| a.to_s }), opts) 16 | end 17 | 18 | write_end.close 19 | output = read_end.read 20 | Process.waitpid(pid) 21 | read_end.close 22 | output 23 | end 24 | 25 | def self.execute?(*args) 26 | execute(*args) 27 | $?.success? 28 | end 29 | 30 | def self.execute!(*args) 31 | execute(*args).tap do 32 | raise_command_failed_exception(*args) unless $?.success? 33 | end 34 | end 35 | 36 | private 37 | 38 | def self.raise_command_failed_exception(*args) 39 | raise CommandFailedException.new("Shell command #{args.inspect} failed with status #{$?}") 40 | end 41 | 42 | end 43 | -------------------------------------------------------------------------------- /lib/safe_shell/version.rb: -------------------------------------------------------------------------------- 1 | module SafeShell 2 | VERSION = "1.1.0" 3 | end 4 | -------------------------------------------------------------------------------- /safe_shell.gemspec: -------------------------------------------------------------------------------- 1 | $:.push File.expand_path("../lib", __FILE__) 2 | require "safe_shell/version" 3 | 4 | Gem::Specification.new do |s| 5 | s.name = "safe_shell" 6 | s.version = SafeShell::VERSION 7 | s.platform = Gem::Platform::RUBY 8 | s.authors = ["Envato", "Ian Leitch", "Pete Yandell"] 9 | s.email = ["pete@notahat.com"] 10 | s.homepage = "http://github.com/envato/safe_shell" 11 | s.summary = %q{Safely execute shell commands and get their output.} 12 | s.description = %q{Execute shell commands and get the resulting output, but without the security problems of Ruby’s backtick operator.} 13 | s.license = "MIT" 14 | 15 | s.add_development_dependency "rake" 16 | s.add_development_dependency "rspec" 17 | 18 | s.files = `git ls-files`.split("\n") 19 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 20 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 21 | s.require_paths = ["lib"] 22 | s.extra_rdoc_files = ["LICENSE", "README.md"] 23 | 24 | s.required_ruby_version = ">= 2.0.0" 25 | end 26 | -------------------------------------------------------------------------------- /spec/safe_shell_spec.rb: -------------------------------------------------------------------------------- 1 | require 'safe_shell' 2 | require 'pathname' 3 | 4 | describe "SafeShell" do 5 | 6 | it "should return the output of the command" do 7 | expect(SafeShell.execute("echo", "Hello, world!")).to eql("Hello, world!\n") 8 | end 9 | 10 | it "should safely handle dangerous characters in command arguments" do 11 | expect(SafeShell.execute("echo", ";date")).to eql(";date\n") 12 | end 13 | 14 | it "allows to add new env vars" do 15 | result = SafeShell.execute('env') 16 | expect(result).to_not include("HELLO=world") 17 | expect(result).to_not include("GOOD=world") 18 | 19 | result = SafeShell.execute('env', env: {'HELLO' => 'world', 'GOOD' => 'day'}) 20 | expect(result).to include("HELLO=world") 21 | expect(result).to include("GOOD=day") 22 | end 23 | 24 | it "should set $? to the exit status of the command" do 25 | SafeShell.execute("test", "a", "=", "a") 26 | expect($?.exitstatus).to eql(0) 27 | 28 | SafeShell.execute("test", "a", "=", "b") 29 | expect($?.exitstatus).to eql(1) 30 | end 31 | 32 | it "should handle a Pathname object passed as an argument" do 33 | expect { SafeShell.execute("ls", Pathname.new("/tmp")) }.not_to raise_error 34 | end 35 | 36 | context "output redirection" do 37 | before do 38 | File.delete("tmp/output.txt") if File.exist?("tmp/output.txt") 39 | end 40 | 41 | it "should let you redirect stdout to a file" do 42 | SafeShell.execute("echo", "Hello, world!", :stdout => "tmp/output.txt") 43 | expect(File.exist?("tmp/output.txt")).to eql(true) 44 | expect(File.read("tmp/output.txt")).to eql("Hello, world!\n") 45 | end 46 | 47 | it "should let you redirect stderr to a file" do 48 | SafeShell.execute("cat", "tmp/nonexistent-file", :stderr => "tmp/output.txt") 49 | expect(File.exist?("tmp/output.txt")).to eql(true) 50 | expect(File.read("tmp/output.txt")).to eql("cat: tmp/nonexistent-file: No such file or directory\n") 51 | end 52 | end 53 | 54 | context ".execute!" do 55 | it "returns the output of the command" do 56 | expect(SafeShell.execute!("echo", "Hello, world!")).to eql("Hello, world!\n") 57 | end 58 | 59 | it "raises an exception of the command fails" do 60 | expect { 61 | SafeShell.execute!("test", "a", "=", "b") 62 | }.to raise_error(SafeShell::CommandFailedException) 63 | end 64 | end 65 | 66 | end 67 | -------------------------------------------------------------------------------- /spec/spec.opts: -------------------------------------------------------------------------------- 1 | --color 2 | -------------------------------------------------------------------------------- /tmp/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/envato/safe_shell/2c92699259a5cac45c9aafed9249b4d1116f9a1d/tmp/.gitkeep --------------------------------------------------------------------------------