trusted-sandbox 0.0.2.pre

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.
@@ -0,0 +1,33 @@
1
+ module TrustedSandbox
2
+ class Defaults < Config
3
+
4
+ def initialize
5
+ self.docker_options = {}
6
+ self.docker_image_name = 'vaharoni/trusted_sandbox:2.1.2.v1'
7
+ self.memory_limit = 50 * 1024 * 1024
8
+ self.memory_swap_limit = 50 * 1024 * 1024
9
+ self.cpu_shares = 1
10
+ self.execution_timeout = 15
11
+ self.network_access = false
12
+ self.enable_swap_limit = false
13
+ self.enable_quotas = false
14
+ self.host_code_root_path = 'tmp/code_dirs'
15
+ self.host_uid_pool_lock_path = 'tmp/uid_pool_lock'
16
+
17
+ self.docker_url = ENV['DOCKER_HOST']
18
+ self.docker_cert_path = ENV['DOCKER_CERT_PATH']
19
+
20
+ # Note, changing these may require changing Dockerfile and run.rb and rebuilding the docker image
21
+ self.container_code_path = '/home/sandbox/src'
22
+ self.container_input_filename = 'input'
23
+ self.container_output_filename = 'output'
24
+
25
+ # Note, changing these requires running `rake trusted_sandbox:set_quotas`
26
+ self.pool_min_uid = 20000
27
+ self.pool_size = 5000
28
+
29
+ self.keep_code_folders = false
30
+ end
31
+
32
+ end
33
+ end
@@ -0,0 +1,8 @@
1
+ module TrustedSandbox
2
+ class InvocationError < StandardError ; end
3
+ class PoolTimeoutError < StandardError; end
4
+ class ContainerError < StandardError; end
5
+ class UserCodeError < StandardError; end
6
+ class InternalError < StandardError; end
7
+ class ExecutionTimeoutError < StandardError; end
8
+ end
@@ -0,0 +1,53 @@
1
+ module TrustedSandbox
2
+ class RequestSerializer
3
+
4
+ attr_reader :host_code_dir_path, :input_file_name
5
+
6
+ # @param host_code_dir_path [String] path to the folder where the argument value needs to be stored
7
+ # @param input_file_name [String] name of input file inside the host_code_dir_path
8
+ def initialize(host_code_dir_path, input_file_name)
9
+ @host_code_dir_path = host_code_dir_path
10
+ @input_file_name = input_file_name
11
+ end
12
+
13
+ # @param klass [Class] class name to be serialized
14
+ # @param args [Array] the array of argument values
15
+ # @return [String] full path of the argument that was stored
16
+ def serialize(klass, *args)
17
+ self.klass = klass
18
+ copy_code_file
19
+
20
+ data = Marshal.dump([klass.name, dest_file_name, args])
21
+ File.binwrite input_file_path, data
22
+ end
23
+
24
+ private
25
+
26
+ def input_file_path
27
+ File.join host_code_dir_path, input_file_name
28
+ end
29
+
30
+ # = Methods depending on @klass
31
+
32
+ attr_accessor :klass
33
+
34
+ def source_file_path
35
+ file, _line = klass.instance_method(:initialize).source_location
36
+ raise InvocationError.new("Cannot find location of class #{klass.name}") unless File.exist?(file.to_s)
37
+ file
38
+ end
39
+
40
+ def dest_file_name
41
+ File.basename(source_file_path)
42
+ end
43
+
44
+ def dest_file_path
45
+ File.join host_code_dir_path, dest_file_name
46
+ end
47
+
48
+ def copy_code_file
49
+ FileUtils.cp source_file_path, dest_file_path
50
+ end
51
+
52
+ end
53
+ end
@@ -0,0 +1,63 @@
1
+ module TrustedSandbox
2
+ class Response
3
+
4
+ attr_reader :host_code_dir_path, :output_file_name, :stdout, :stderr,
5
+ :raw_response, :status, :error, :error_to_raise, :output
6
+
7
+ # @param host_code_dir_path [String] path to the folder where the argument value needs to be stored
8
+ # @param output_file_name [String] name of output file inside the host_code_dir_path
9
+ def initialize(host_code_dir_path, output_file_name, stdout, stderr)
10
+ @host_code_dir_path = host_code_dir_path
11
+ @output_file_name = output_file_name
12
+ @stdout = stdout
13
+ @stderr = stderr
14
+ parse_output_file
15
+ end
16
+
17
+ def valid?
18
+ status == 'success'
19
+ end
20
+
21
+ def output!
22
+ propagate_errors!
23
+ output
24
+ end
25
+
26
+ private
27
+
28
+ def output_file_path
29
+ File.join(host_code_dir_path, output_file_name)
30
+ end
31
+
32
+ def parse_output_file
33
+ begin
34
+ data = File.binread output_file_path
35
+ @raw_response = Marshal.load(data)
36
+ rescue => e
37
+ @status = 'error'
38
+ @error = e
39
+ @error_to_raise = ContainerError.new(e)
40
+ return
41
+ end
42
+
43
+ unless ['success', 'error'].include? @raw_response[:status]
44
+ @status = 'error'
45
+ @error = ContainerError.new('Output file has invalid format')
46
+ @error_to_raise = @error
47
+ return
48
+ end
49
+
50
+ @status = @raw_response[:status]
51
+ @output = @raw_response[:output]
52
+ @error = @raw_response[:error]
53
+ @error_to_raise = UserCodeError.new(@error) if @error
54
+ end
55
+
56
+ def propagate_errors!
57
+ return if valid?
58
+ raise InternalError.new 'Response object is invalid but no errors were recorded.' unless error
59
+ raise error_to_raise
60
+ end
61
+
62
+ end
63
+ end
@@ -0,0 +1,129 @@
1
+ module TrustedSandbox
2
+ class Runner
3
+
4
+ attr_reader :uid_pool, :config
5
+
6
+ # @param config [Config]
7
+ # @param uid_pool [UidPool]
8
+ # @param config_override [Hash] allows overriding configurations for a specific invocation
9
+ def initialize(config, uid_pool, config_override={})
10
+ @config = config.override(config_override)
11
+ @uid_pool = uid_pool
12
+ end
13
+
14
+ # @param klass [Class] the class object that should be run
15
+ # @param *args [Array] arguments to send to klass#initialize
16
+ # @return [Response]
17
+ def run(klass, *args)
18
+ create_code_dir
19
+ serialize_request(klass, *args)
20
+ create_container
21
+ start_container
22
+ ensure
23
+ release_uid
24
+ remove_code_dir unless config.keep_code_folders
25
+ remove_container
26
+ end
27
+
28
+ # @param klass [Class] the class object that should be run
29
+ # @param *args [Array] arguments to send to klass#initialize
30
+ # @return [Response]
31
+ # @raise [InternalError, UserCodeError, ContainerError]
32
+ def run!(klass, *args)
33
+ run(klass, *args).output!
34
+ end
35
+
36
+ private
37
+
38
+ def obtain_uid
39
+ @uid ||= uid_pool.lock
40
+ end
41
+
42
+ def release_uid
43
+ uid_pool.release(@uid) if @uid
44
+ end
45
+
46
+ def code_dir_path
47
+ @code_dir_path ||= File.join config.host_code_root_path, obtain_uid.to_s
48
+ end
49
+
50
+ def remove_code_dir
51
+ FileUtils.rm_rf code_dir_path
52
+ end
53
+
54
+ def create_code_dir
55
+ FileUtils.mkdir_p code_dir_path
56
+ end
57
+
58
+ def serialize_request(klass, *args)
59
+ serializer = RequestSerializer.new(code_dir_path, config.container_input_filename)
60
+ serializer.serialize(klass, *args)
61
+ end
62
+
63
+ def create_container
64
+ @container = Docker::Container.create create_container_request
65
+ end
66
+
67
+ def start_container
68
+ @container.start start_container_request
69
+ stdout, stderr = nil, nil
70
+ Timeout.timeout(config.execution_timeout) do
71
+ stdout, stderr = @container.attach(stream: true, stdin: nil, stdout: true, stderr: true, logs: true, tty: false)
72
+ end
73
+ TrustedSandbox::Response.new code_dir_path, config.container_output_filename, stdout, stderr
74
+ rescue Timeout::Error => e
75
+ raise TrustedSandbox::ExecutionTimeoutError.new(e)
76
+ end
77
+
78
+ def remove_container
79
+ return unless @container
80
+ @container.delete force: true
81
+ end
82
+
83
+ def create_container_request
84
+ basic_request = {
85
+ # 'Hostname' => '',
86
+ # 'Domainname' => '',
87
+ # 'User' => '',
88
+ 'CpuShares' => config.cpu_shares,
89
+ 'Memory' => config.memory_limit,
90
+ # 'Cpuset' => '0,1',
91
+ 'AttachStdin' => false,
92
+ 'AttachStdout' => true,
93
+ 'AttachStderr' => true,
94
+ # 'PortSpecs' => null,
95
+ 'Tty' => false,
96
+ 'OpenStdin' => false,
97
+ 'StdinOnce' => false,
98
+ 'Cmd' => [@uid.to_s],
99
+ 'Image' => config.docker_image_name,
100
+ 'Volumes' => {
101
+ config.container_code_path => {}
102
+ },
103
+ # 'WorkingDir' => '',
104
+ 'NetworkDisabled' => !config.network_access,
105
+ # 'ExposedPorts' => {
106
+ # '22/tcp' => {}
107
+ # }
108
+ }
109
+ basic_request.merge!('MemorySwap' => config.memory_swap_limit) if config.enable_swap_limit
110
+ basic_request.merge!('Env' => ['USE_QUOTAS=1']) if config.enable_quotas
111
+ basic_request
112
+ end
113
+
114
+ def start_container_request
115
+ {
116
+ 'Binds' => ["#{code_dir_path}:#{config.container_code_path}"],
117
+ # 'Links' => ['redis3:redis'],
118
+ # 'LxcConf' => {'lxc.utsname' => 'docker'},
119
+ # 'PortBindings' => {'22/tcp' => [{'HostPort' => '11022'}]},
120
+ # 'PublishAllPorts' => false,
121
+ # 'Privileged' => false,
122
+ # 'Dns' => ['8.8.8.8'],
123
+ # 'VolumesFrom' => ['parent', 'other:ro'],
124
+ # 'CapAdd' => ['NET_ADMIN'],
125
+ # 'CapDrop' => ['MKNOD']
126
+ }
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,23 @@
1
+ FROM ubuntu:14.04
2
+ MAINTAINER Amit Aharoni <amit.sites@gmail.com>
3
+
4
+ RUN apt-get -y update
5
+ RUN apt-get -y install build-essential zlib1g-dev libssl-dev libreadline6-dev libyaml-dev wget
6
+ RUN cd /tmp && wget http://ftp.ruby-lang.org/pub/ruby/2.1/ruby-2.1.2.tar.gz && tar -xvzf ruby-2.1.2.tar.gz
7
+ RUN cd /tmp/ruby-2.1.2/ && ./configure --prefix=/usr/local && make && make install
8
+
9
+ RUN groupadd app && useradd -m -G app -d /home/sandbox sandbox
10
+
11
+ RUN gem install bundler
12
+ ADD Gemfile /home/sandbox/Gemfile
13
+ ADD bundle_config /home/sandbox/.bundle/config
14
+ RUN chown sandbox /home/sandbox/Gemfile && \
15
+ chown sandbox /home/sandbox/.bundle && \
16
+ chown sandbox /home/sandbox/.bundle/config && \
17
+ sudo -u sandbox -i bundle install
18
+
19
+ ADD entrypoint.sh entrypoint.sh
20
+ ADD run.rb /home/sandbox/run.rb
21
+ RUN chown sandbox /home/sandbox/run.rb
22
+
23
+ ENTRYPOINT ["/bin/bash", "entrypoint.sh"]
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gem 'activesupport'
@@ -0,0 +1,3 @@
1
+ ---
2
+ BUNDLE_PATH: .bundle
3
+ BUNDLE_DISABLE_SHARED_GEMS: "1"
@@ -0,0 +1,15 @@
1
+ uid=$1
2
+
3
+ if [ -z $uid ]; then
4
+ echo "you must provide a uid"
5
+ exit 1
6
+ fi
7
+
8
+ echo "127.0.0.1 $(hostname)" >> /etc/hosts
9
+
10
+ if [ -n "$USE_QUOTAS" -a "$USE_QUOTAS" != "0" -a "$USE_QUOTAS" != "false" ]; then
11
+ usermod -u $uid sandbox
12
+ chown sandbox /home/sandbox/src
13
+ fi
14
+
15
+ sudo -u sandbox -i bundle exec ruby run.rb
@@ -0,0 +1,18 @@
1
+ begin
2
+ require 'active_support'
3
+
4
+ input_file_path = '/home/sandbox/src/input'
5
+ output_file_path = '/home/sandbox/src/output'
6
+
7
+ data = File.binread(input_file_path)
8
+ klass_name, file_name, args = Marshal.load(data)
9
+ require File.join('/home/sandbox/src', file_name)
10
+ klass = ActiveSupport::Inflector.constantize klass_name
11
+
12
+ obj = klass.new(*args)
13
+ output = obj.run
14
+
15
+ File.binwrite output_file_path, Marshal.dump(status: 'success', output: output)
16
+ rescue => e
17
+ File.binwrite output_file_path, Marshal.dump(status: 'error', error: e)
18
+ end
@@ -0,0 +1,153 @@
1
+ module TrustedSandbox
2
+
3
+ # Offers intra-server inter-process pool of Uids. In other words:
4
+ # - Every server has its own pool. Since Docker containers live within a server, this is what we want.
5
+ # - Processes within the same server share the pool.
6
+ #
7
+ # Usage:
8
+ # The following will behave the same when different processes try to perform #lock and #release.
9
+ #
10
+ # pool = UidPool.new 100, 101
11
+ # pool.lock
12
+ # # => 100
13
+ #
14
+ # pool.lock
15
+ # # => 101
16
+ #
17
+ # pool.lock
18
+ # # => RuntimeError: No available UIDs in the pool. Please try again later.
19
+ #
20
+ # pool.release(100)
21
+ # # => 100
22
+ #
23
+ # pool.lock
24
+ # # => 100
25
+ #
26
+ # pool.release_all
27
+ #
28
+ class UidPool
29
+
30
+ attr_reader :lock_dir, :master_lock_file, :lower, :upper, :timeout, :retries, :delay
31
+
32
+ # @param lower [Integer] lower bound of the pool
33
+ # @param upper [Integer] upper bound of the pool
34
+ # @option timeout [Integer] number of seconds to wait for the lock
35
+ # @option retries [Integer] number of attempts to retry to acquire a uid
36
+ # @option delay [Float] delay between retries
37
+ def initialize(lock_dir, lower, upper, timeout: nil, retries: nil, delay: nil)
38
+ @lock_dir = lock_dir
39
+ FileUtils.mkdir_p(lock_dir)
40
+
41
+ @master_lock_file = lock_file_path_for('master')
42
+ @lower = lower
43
+ @upper = upper
44
+ @timeout = timeout || 3
45
+ @retries = retries || 5
46
+ @delay = delay || 0.5
47
+ end
48
+
49
+ def inspect
50
+ "#<TrustedSandbox::UidPool used: #{used}, available: #{available}, used_uids: #{used_uids}>"
51
+ end
52
+
53
+ # @return [Integer]
54
+ def lock
55
+ retries.times do
56
+ atomically(timeout) do
57
+ uid = available_uid
58
+ if uid
59
+ lock_uid uid
60
+ return uid.to_i
61
+ end
62
+ end
63
+ sleep(delay)
64
+ end
65
+ raise PoolTimeoutError.new('No available UIDs in the pool. Please try again later.')
66
+ end
67
+
68
+ # Releases all UIDs
69
+ # @return [UidPool] self
70
+ def release_all
71
+ all_uids.each do |uid|
72
+ release uid
73
+ end
74
+ self
75
+ end
76
+
77
+ # @param uid [Integer]
78
+ def release(uid)
79
+ atomically(timeout) do
80
+ release_uid uid
81
+ end
82
+ end
83
+
84
+ # @return [Integer] number of used UIDs
85
+ def used
86
+ used_uids.length
87
+ end
88
+
89
+ # @return [Integer] number of availabld UIDs
90
+ def available
91
+ available_uids.length
92
+ end
93
+
94
+ # @return [Array<Integer>] all taken uids
95
+ def used_uids
96
+ uids = Dir.entries(lock_dir) - %w(. .. master)
97
+ uids.map(&:to_i)
98
+ end
99
+
100
+ # @return [Array<Integer>] all non taken uids
101
+ def available_uids
102
+ all_uids - used_uids
103
+ end
104
+
105
+ private
106
+
107
+ # @return [Array<Integer>] all uids in range
108
+ def all_uids
109
+ [*lower..upper]
110
+ end
111
+
112
+ # @param uid [Integer]
113
+ # @return [String] full path for the UID lock file
114
+ def lock_file_path_for(uid)
115
+ File.join lock_dir, uid.to_s
116
+ end
117
+
118
+ # Creates a UID lock file in the lock_dir
119
+ #
120
+ # @param uid [Integer]
121
+ # @return [Integer] the UID locked
122
+ def lock_uid(uid)
123
+ File.open lock_file_path_for(uid), 'w'
124
+ uid
125
+ end
126
+
127
+ # Removes a UID lock file from the lock_dir
128
+ #
129
+ # @param uid [Integer]
130
+ # @return [Integer] the UID removed
131
+ def release_uid(uid)
132
+ FileUtils.rm lock_file_path_for(uid), force: true
133
+ uid
134
+ end
135
+
136
+ # @param timeout [Integer]
137
+ # @return yield return value
138
+ def atomically(timeout)
139
+ Timeout.timeout(timeout) do
140
+ File.open(master_lock_file, File::RDWR|File::CREAT, 0644) do |f|
141
+ f.flock File::LOCK_EX
142
+ yield
143
+ end
144
+ end
145
+ end
146
+
147
+ # @return [Integer, nil] one available uid or nil if none is available
148
+ def available_uid
149
+ available_uids.first
150
+ end
151
+
152
+ end
153
+ end
@@ -0,0 +1,3 @@
1
+ module TrustedSandbox
2
+ VERSION = '0.0.2.pre'
3
+ end
@@ -0,0 +1,59 @@
1
+ module TrustedSandbox
2
+
3
+ require 'yaml'
4
+ require 'docker'
5
+ require 'trusted_sandbox/config'
6
+ require 'trusted_sandbox/defaults'
7
+ require 'trusted_sandbox/errors'
8
+ require 'trusted_sandbox/request_serializer'
9
+ require 'trusted_sandbox/response'
10
+ require 'trusted_sandbox/runner'
11
+ require 'trusted_sandbox/uid_pool'
12
+ require 'trusted_sandbox/version'
13
+
14
+ # Usage:
15
+ # TrustedSandbox.config do |c|
16
+ # c.pool_size = 10
17
+ # # ...
18
+ # end
19
+ def self.config
20
+ @config ||= Defaults.send(:new).override config_overrides_from_file
21
+ yield @config if block_given?
22
+ @config.finished_configuring
23
+ end
24
+
25
+ def self.config_overrides_from_file(env = nil)
26
+ yaml_path = %w(trusted_sandbox.yml config/trusted_sandbox.yml).find {|x| File.exist?(x)}
27
+ return {} unless yaml_path
28
+
29
+ env ||= ENV['TRUSTED_SANDBOX_ENV'] || ENV['RAILS_ENV'] || 'development'
30
+ YAML.load_file(yaml_path)[env]
31
+ end
32
+
33
+ def self.uid_pool
34
+ @uid_pool ||= UidPool.new config.host_uid_pool_lock_path, config.pool_min_uid, config.pool_max_uid,
35
+ timeout: config.pool_timeout, retries: config.pool_retries, delay: config.pool_delay
36
+ end
37
+
38
+ # @param config_override [Hash] allows overriding configurations for a specific invocation
39
+ def self.with_options(config_override={})
40
+ yield new_runner(config_override)
41
+ end
42
+
43
+ # @param klass [Class] the class to be instantiated in the safe sandbox
44
+ # @param *args [Array] arguments to send to klass#new
45
+ def self.run(klass, *args)
46
+ new_runner.run(klass, *args)
47
+ end
48
+
49
+ def self.run!(klass, *args)
50
+ new_runner.run!(klass, *args)
51
+ end
52
+
53
+ def self.new_runner(config_override = {})
54
+ Runner.new(config, uid_pool, config_override)
55
+ end
56
+ end
57
+
58
+ # Run the configuration block
59
+ TrustedSandbox.config
@@ -0,0 +1,26 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'trusted_sandbox/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'trusted-sandbox'
8
+ spec.version = TrustedSandbox::VERSION
9
+ spec.authors = ['Amit Aharoni']
10
+ spec.email = ['amit.sites@gmail.com']
11
+ spec.description = %q{Trusted Sandbox makes it simple to execute Ruby classes that eval untrusted code in a resource-controlled docker container}
12
+ spec.summary = %q{Run untrusted Ruby code in a contained sandbox using Docker}
13
+ spec.homepage = 'https://github.com/vaharoni/trusted-sandbox'
14
+ spec.license = 'MIT'
15
+
16
+ spec.files = `git ls-files`.split($/)
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ['lib']
20
+
21
+ spec.add_development_dependency 'bundler', '~> 1.3'
22
+ spec.add_development_dependency 'rake'
23
+
24
+ spec.add_runtime_dependency 'docker-api', '~> 1.13'
25
+ spec.add_runtime_dependency 'thor', '~> 0.19'
26
+ end