landlock 0.2 → 0.3

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,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Landlock
4
+ module ResultBehavior
5
+ attr_reader :stdout, :stderr, :status
6
+
7
+ def success?
8
+ !timed_out? && status&.success?
9
+ end
10
+
11
+ def output_truncated?
12
+ @output_truncated
13
+ end
14
+
15
+ def timed_out?
16
+ @timed_out
17
+ end
18
+
19
+ def to_ary
20
+ [stdout, stderr, status]
21
+ end
22
+
23
+ def to_s
24
+ stdout.to_s
25
+ end
26
+
27
+ def inspect
28
+ "#<#{self.class} status=#{status.inspect} timed_out=#{timed_out?} output_truncated=#{output_truncated?} stdout=#{stdout.inspect} stderr=#{stderr.inspect}>"
29
+ end
30
+ end
31
+
32
+ class CaptureResult
33
+ include ResultBehavior
34
+
35
+ def initialize(stdout:, stderr:, status:, output_truncated: false, timed_out: false)
36
+ @stdout = stdout
37
+ @stderr = stderr
38
+ @status = status
39
+ @output_truncated = output_truncated
40
+ @timed_out = timed_out
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "native"
4
+
5
+ module Landlock
6
+ FS_RIGHTS = {
7
+ execute: ACCESS_FS_EXECUTE,
8
+ write_file: ACCESS_FS_WRITE_FILE,
9
+ read_file: ACCESS_FS_READ_FILE,
10
+ read_dir: ACCESS_FS_READ_DIR,
11
+ remove_dir: ACCESS_FS_REMOVE_DIR,
12
+ remove_file: ACCESS_FS_REMOVE_FILE,
13
+ make_char: ACCESS_FS_MAKE_CHAR,
14
+ make_dir: ACCESS_FS_MAKE_DIR,
15
+ make_reg: ACCESS_FS_MAKE_REG,
16
+ make_sock: ACCESS_FS_MAKE_SOCK,
17
+ make_fifo: ACCESS_FS_MAKE_FIFO,
18
+ make_block: ACCESS_FS_MAKE_BLOCK,
19
+ make_sym: ACCESS_FS_MAKE_SYM,
20
+ refer: ACCESS_FS_REFER,
21
+ truncate: ACCESS_FS_TRUNCATE,
22
+ ioctl_dev: ACCESS_FS_IOCTL_DEV
23
+ }.freeze
24
+
25
+ NET_RIGHTS = { bind_tcp: ACCESS_NET_BIND_TCP, connect_tcp: ACCESS_NET_CONNECT_TCP }.freeze
26
+
27
+ SCOPE_FLAGS = { abstract_unix_socket: SCOPE_ABSTRACT_UNIX_SOCKET, signal: SCOPE_SIGNAL }.freeze
28
+
29
+ READ_RIGHTS = %i[read_file read_dir].freeze
30
+ EXEC_RIGHTS = %i[execute read_file read_dir].freeze
31
+ WRITE_RIGHTS = %i[
32
+ read_file
33
+ read_dir
34
+ write_file
35
+ truncate
36
+ remove_dir
37
+ remove_file
38
+ make_char
39
+ make_dir
40
+ make_reg
41
+ make_sock
42
+ make_fifo
43
+ make_block
44
+ make_sym
45
+ refer
46
+ ].freeze
47
+ FILE_PATH_RIGHTS = %i[execute write_file read_file truncate ioctl_dev].freeze
48
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Landlock
4
+ module Rlimits
5
+ VALID_NAMES = %i[cpu_seconds memory_bytes file_size_bytes open_files processes].freeze
6
+
7
+ module_function
8
+
9
+ def normalize(rlimits)
10
+ Array(rlimits).filter_map do |name, value|
11
+ next if value.nil?
12
+
13
+ key = name.to_sym
14
+ raise ArgumentError, "Unknown rlimit: #{name}" if !VALID_NAMES.include?(key)
15
+
16
+ value = Integer(value)
17
+ raise ArgumentError, "rlimit #{name} must be non-negative" if value.negative?
18
+
19
+ [key, value]
20
+ end
21
+ end
22
+
23
+ def apply!(rlimits)
24
+ rlimits.each do |key, value|
25
+ case key
26
+ when :cpu_seconds
27
+ ::Process.setrlimit(:CPU, value, value)
28
+ when :memory_bytes
29
+ ::Process.setrlimit(:AS, value, value)
30
+ when :file_size_bytes
31
+ ::Process.setrlimit(:FSIZE, value, value)
32
+ when :open_files
33
+ ::Process.setrlimit(:NOFILE, value, value)
34
+ when :processes
35
+ ::Process.setrlimit(:NPROC, value, value)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "../native"
5
+ require_relative "../policy"
6
+ require_relative "../process_io"
7
+ require_relative "../rlimits"
8
+
9
+ module Landlock
10
+ module Runner
11
+ module Fork
12
+ module_function
13
+
14
+ def spawn(
15
+ argv,
16
+ read:,
17
+ write:,
18
+ execute:,
19
+ connect_tcp:,
20
+ bind_tcp:,
21
+ paths:,
22
+ scope:,
23
+ chdir:,
24
+ env:,
25
+ unsetenv_others:,
26
+ close_others:,
27
+ allow_all_known:
28
+ )
29
+ fork do
30
+ begin
31
+ setup_child!(
32
+ argv,
33
+ read:,
34
+ write:,
35
+ execute:,
36
+ connect_tcp:,
37
+ bind_tcp:,
38
+ paths:,
39
+ scope:,
40
+ chdir:,
41
+ env:,
42
+ unsetenv_others:,
43
+ close_others:,
44
+ allow_all_known:,
45
+ rlimits: [],
46
+ seccomp_deny_network: false
47
+ )
48
+ rescue Exception => error
49
+ Runner.exit_child!(error)
50
+ end
51
+ end
52
+ end
53
+
54
+ def call(
55
+ argv,
56
+ read:,
57
+ write:,
58
+ execute:,
59
+ connect_tcp:,
60
+ bind_tcp:,
61
+ paths:,
62
+ scope:,
63
+ chdir:,
64
+ env:,
65
+ unsetenv_others:,
66
+ close_others:,
67
+ allow_all_known:,
68
+ timeout:,
69
+ stdin:,
70
+ rlimits:,
71
+ seccomp_deny_network:,
72
+ max_output_bytes:,
73
+ truncate_output:
74
+ )
75
+ stdout_reader, stdout_writer = IO.pipe
76
+ stderr_reader, stderr_writer = IO.pipe
77
+ stdin_reader, stdin_writer = IO.pipe
78
+
79
+ pid =
80
+ fork do
81
+ begin
82
+ stdout_reader.close
83
+ stderr_reader.close
84
+ stdin_writer.close
85
+ ::Process.setpgrp
86
+ STDIN.reopen(stdin_reader)
87
+ STDOUT.reopen(stdout_writer)
88
+ STDERR.reopen(stderr_writer)
89
+ stdin_reader.close
90
+ stdout_writer.close
91
+ stderr_writer.close
92
+
93
+ setup_child!(
94
+ argv,
95
+ read:,
96
+ write:,
97
+ execute:,
98
+ connect_tcp:,
99
+ bind_tcp:,
100
+ paths:,
101
+ scope:,
102
+ chdir:,
103
+ env:,
104
+ unsetenv_others:,
105
+ close_others:,
106
+ allow_all_known:,
107
+ rlimits:,
108
+ seccomp_deny_network:
109
+ )
110
+ rescue Exception => error
111
+ Runner.exit_child!(error)
112
+ end
113
+ end
114
+
115
+ stdin_reader.close
116
+ stdout_writer.close
117
+ stderr_writer.close
118
+
119
+ ProcessIO.complete_pipe_capture(
120
+ pid,
121
+ stdout_reader,
122
+ stderr_reader,
123
+ stdin_writer,
124
+ stdin,
125
+ timeout,
126
+ max_output_bytes,
127
+ truncate_output
128
+ )
129
+ rescue OutputTooLargeError
130
+ raise
131
+ rescue Exception
132
+ if pid
133
+ ProcessIO.terminate_process(pid)
134
+ ProcessIO.wait_for_pid(pid)
135
+ end
136
+ raise
137
+ ensure
138
+ [stdin_reader, stdin_writer, stdout_reader, stdout_writer, stderr_reader, stderr_writer].each do |io|
139
+ io&.close unless io.closed?
140
+ rescue IOError
141
+ end
142
+ end
143
+
144
+ def setup_child!(
145
+ argv,
146
+ read:,
147
+ write:,
148
+ execute:,
149
+ connect_tcp:,
150
+ bind_tcp:,
151
+ paths:,
152
+ scope:,
153
+ chdir:,
154
+ env:,
155
+ unsetenv_others:,
156
+ close_others:,
157
+ allow_all_known:,
158
+ rlimits:,
159
+ seccomp_deny_network:
160
+ )
161
+ Dir.chdir(chdir) if chdir # rubocop:disable Discourse/NoChdir
162
+ if Policy.requested?(read:, write:, execute:, connect_tcp:, bind_tcp:, paths:, scope:, allow_all_known:)
163
+ Landlock.restrict!(read:, write:, execute:, connect_tcp:, bind_tcp:, paths:, scope:, allow_all_known:)
164
+ end
165
+ Landlock::Native.seccomp_deny_network! if seccomp_deny_network
166
+ Rlimits.apply!(rlimits)
167
+ Kernel.exec(*Runner.kernel_exec_args(argv, env, unsetenv_others:, close_others:))
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+ require_relative "../errors"
5
+ require_relative "../policy"
6
+ require_relative "../process_io"
7
+
8
+ module Landlock
9
+ module Runner
10
+ module Native
11
+ # Starts the native helper with the sandbox policy encoded as argv flags.
12
+ # The helper applies the policy, then execs the target command so the
13
+ # long-lived child is not a forked Ruby process.
14
+ module_function
15
+
16
+ def available?
17
+ File.executable?(helper_path)
18
+ end
19
+
20
+ def helper_path
21
+ candidates = [
22
+ File.expand_path("../landlock-safe-exec", __dir__),
23
+ File.expand_path(
24
+ "../../../tmp/#{RbConfig::CONFIG.fetch("arch")}/landlock/#{RUBY_VERSION}/landlock-safe-exec",
25
+ __dir__
26
+ ),
27
+ File.expand_path("../../../ext/landlock/landlock-safe-exec", __dir__)
28
+ ]
29
+ candidates.find { |path| File.executable?(path) } || candidates.first
30
+ end
31
+
32
+ def spawn(
33
+ argv,
34
+ read:,
35
+ write:,
36
+ execute:,
37
+ connect_tcp:,
38
+ bind_tcp:,
39
+ paths:,
40
+ scope:,
41
+ chdir:,
42
+ env:,
43
+ unsetenv_others:,
44
+ close_others:,
45
+ allow_all_known:
46
+ )
47
+ spawn_helper(
48
+ argv,
49
+ read:,
50
+ write:,
51
+ execute:,
52
+ connect_tcp:,
53
+ bind_tcp:,
54
+ paths:,
55
+ scope:,
56
+ chdir:,
57
+ env:,
58
+ unsetenv_others:,
59
+ close_others:,
60
+ allow_all_known:,
61
+ rlimits: [],
62
+ seccomp_deny_network: false
63
+ )
64
+ end
65
+
66
+ def call(
67
+ argv,
68
+ read:,
69
+ write:,
70
+ execute:,
71
+ connect_tcp:,
72
+ bind_tcp:,
73
+ paths:,
74
+ scope:,
75
+ chdir:,
76
+ env:,
77
+ unsetenv_others:,
78
+ close_others:,
79
+ allow_all_known:,
80
+ timeout:,
81
+ stdin:,
82
+ rlimits:,
83
+ seccomp_deny_network:,
84
+ max_output_bytes:,
85
+ truncate_output:
86
+ )
87
+ stdout_reader, stdout_writer = IO.pipe
88
+ stderr_reader, stderr_writer = IO.pipe
89
+ stdin_reader, stdin_writer = IO.pipe
90
+
91
+ pid =
92
+ spawn_helper(
93
+ argv,
94
+ read:,
95
+ write:,
96
+ execute:,
97
+ connect_tcp:,
98
+ bind_tcp:,
99
+ paths:,
100
+ scope:,
101
+ chdir:,
102
+ env:,
103
+ unsetenv_others:,
104
+ close_others:,
105
+ allow_all_known:,
106
+ rlimits:,
107
+ seccomp_deny_network:,
108
+ stdin_reader:,
109
+ stdout_writer:,
110
+ stderr_writer:,
111
+ pgroup: true
112
+ )
113
+
114
+ stdin_reader.close
115
+ stdout_writer.close
116
+ stderr_writer.close
117
+
118
+ ProcessIO.complete_pipe_capture(
119
+ pid,
120
+ stdout_reader,
121
+ stderr_reader,
122
+ stdin_writer,
123
+ stdin,
124
+ timeout,
125
+ max_output_bytes,
126
+ truncate_output
127
+ )
128
+ rescue OutputTooLargeError
129
+ raise
130
+ rescue Exception
131
+ if pid
132
+ ProcessIO.terminate_process(pid)
133
+ ProcessIO.wait_for_pid(pid)
134
+ end
135
+ raise
136
+ ensure
137
+ [stdin_reader, stdin_writer, stdout_reader, stdout_writer, stderr_reader, stderr_writer].each do |io|
138
+ io&.close unless io.closed?
139
+ rescue IOError
140
+ end
141
+ end
142
+
143
+ def spawn_helper(
144
+ argv,
145
+ read:,
146
+ write:,
147
+ execute:,
148
+ connect_tcp:,
149
+ bind_tcp:,
150
+ paths:,
151
+ scope:,
152
+ chdir:,
153
+ env:,
154
+ unsetenv_others:,
155
+ close_others:,
156
+ allow_all_known:,
157
+ rlimits:,
158
+ seccomp_deny_network:,
159
+ stdin_reader: nil,
160
+ stdout_writer: nil,
161
+ stderr_writer: nil,
162
+ pgroup: false
163
+ )
164
+ spawn_options = { close_others: }
165
+ spawn_options[:unsetenv_others] = true if unsetenv_others
166
+ spawn_options[:chdir] = chdir if chdir
167
+ spawn_options[:in] = stdin_reader if stdin_reader
168
+ spawn_options[:out] = stdout_writer if stdout_writer
169
+ spawn_options[:err] = stderr_writer if stderr_writer
170
+ spawn_options[:pgroup] = true if pgroup
171
+
172
+ spawn_args =
173
+ helper_argv(
174
+ argv,
175
+ read:,
176
+ write:,
177
+ execute:,
178
+ connect_tcp:,
179
+ bind_tcp:,
180
+ paths:,
181
+ scope:,
182
+ allow_all_known:,
183
+ rlimits:,
184
+ seccomp_deny_network:,
185
+ close_others:
186
+ )
187
+ env ? ::Process.spawn(env, *spawn_args, spawn_options) : ::Process.spawn(*spawn_args, spawn_options)
188
+ end
189
+
190
+ def helper_argv(
191
+ argv,
192
+ read:,
193
+ write:,
194
+ execute:,
195
+ connect_tcp:,
196
+ bind_tcp:,
197
+ paths:,
198
+ scope:,
199
+ allow_all_known:,
200
+ rlimits:,
201
+ seccomp_deny_network:,
202
+ close_others:
203
+ )
204
+ args = [helper_path]
205
+ Array(read).each { |path| args << "--read" << path.to_s }
206
+ Array(write).each { |path| args << "--write" << path.to_s }
207
+ Array(execute).each { |path| args << "--execute" << path.to_s }
208
+ Array(paths).each do |rule|
209
+ path, rights = Policy.normalize_path_rule(rule)
210
+ args << "--path" << path.to_s << Array(rights).map(&:to_s).join(",")
211
+ end
212
+ Array(connect_tcp).each { |port| args << "--connect-tcp" << port.to_s }
213
+ Array(bind_tcp).each { |port| args << "--bind-tcp" << port.to_s }
214
+ Array(scope).each { |name| args << "--scope" << name.to_s }
215
+ Array(rlimits).each { |key, value| args << "--rlimit" << "#{key}=#{value}" }
216
+ args << "--allow-all-known" if allow_all_known
217
+ args << "--seccomp-deny-network" if seccomp_deny_network
218
+ args << "--keep-fds" unless close_others
219
+ args << "--"
220
+ args.concat(argv.map(&:to_s))
221
+ args
222
+ end
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Landlock
4
+ module Runner
5
+ module_function
6
+
7
+ def argv_for_exec(argv)
8
+ command = argv.fetch(0)
9
+ [[command, command], *argv.drop(1)]
10
+ end
11
+
12
+ def kernel_exec_args(argv, env, unsetenv_others:, close_others:)
13
+ exec_options = { close_others: }
14
+ exec_options[:unsetenv_others] = true if unsetenv_others
15
+
16
+ env ? [env, *argv_for_exec(argv), exec_options] : [*argv_for_exec(argv), exec_options]
17
+ end
18
+
19
+ def exit_child!(error)
20
+ warn "Landlock child failed before exec: #{error.class}: #{error.message}"
21
+ ensure
22
+ exit! 127
23
+ end
24
+ end
25
+ end
26
+
27
+ require_relative "runner/fork"
28
+ require_relative "runner/native"
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Landlock
4
+ module Validation
5
+ module_function
6
+
7
+ def normalize_argv(argv)
8
+ raise ArgumentError, "argv must be an Array of command arguments" unless argv.is_a?(Array)
9
+ raise ArgumentError, "argv must not be empty" if argv.empty?
10
+
11
+ argv
12
+ end
13
+
14
+ def normalize_ports(ports, name)
15
+ Array(ports).map do |port|
16
+ integer = Integer(port)
17
+ raise ArgumentError, "#{name} port must be between 0 and 65535" if integer.negative? || integer > 65_535
18
+
19
+ integer
20
+ end
21
+ end
22
+
23
+ def validate_existing_paths(paths, name, chdir: nil)
24
+ base = chdir ? File.expand_path(chdir) : Dir.pwd
25
+ Array(paths)
26
+ .map do |path|
27
+ validate_existing_path!(path, name, base)
28
+ path.to_s
29
+ end
30
+ .uniq
31
+ end
32
+
33
+ def validate_existing_path!(path, name, base)
34
+ string = path.to_s
35
+ raise ArgumentError, "#{name} path must not be empty" if string.empty?
36
+
37
+ expanded = File.expand_path(string, base)
38
+ raise ArgumentError, "#{name} path does not exist: #{string}" if !File.exist?(expanded)
39
+ end
40
+
41
+ def validate_output_limit!(max_output_bytes)
42
+ return if max_output_bytes.nil?
43
+
44
+ Integer(max_output_bytes).tap do |value|
45
+ raise ArgumentError, "max_output_bytes must be non-negative" if value.negative?
46
+ end
47
+ end
48
+
49
+ def validate_timeout!(timeout)
50
+ return if timeout.nil?
51
+ raise ArgumentError, "timeout must be numeric" unless timeout.is_a?(Numeric)
52
+
53
+ Float(timeout).tap do |value|
54
+ raise ArgumentError, "timeout must be finite" unless value.finite?
55
+ raise ArgumentError, "timeout must be non-negative" if value.negative?
56
+ end
57
+ end
58
+ end
59
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Landlock
4
- VERSION = "0.2"
4
+ VERSION = "0.3"
5
5
  end