trusted-sandbox 0.0.2.pre

Sign up to get free protection for your applications and to get access to all the features.
@@ -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