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.
@@ -1,522 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "open3"
4
- require "rbconfig"
5
- require "timeout"
6
- require_relative "landlock"
7
-
8
- module Landlock
9
- class SafeExec
10
- Error = Class.new(StandardError)
11
- OutputTooLargeError = Class.new(Error)
12
-
13
- class CommandError < Error
14
- attr_reader :stdout, :stderr, :status, :result
15
-
16
- def initialize(message, stdout: "", stderr: "", status: nil, result: nil)
17
- @stdout = stdout
18
- @stderr = stderr
19
- @status = status
20
- @result = result
21
- super(message)
22
- end
23
- end
24
-
25
- class Result
26
- attr_reader :stdout, :stderr, :status
27
-
28
- def initialize(stdout:, stderr:, status:, output_truncated: false, timed_out: false)
29
- @stdout = stdout
30
- @stderr = stderr
31
- @status = status
32
- @output_truncated = output_truncated
33
- @timed_out = timed_out
34
- end
35
-
36
- def success?
37
- !timed_out? && status&.success?
38
- end
39
-
40
- def output_truncated?
41
- @output_truncated
42
- end
43
-
44
- def timed_out?
45
- @timed_out
46
- end
47
-
48
- def to_ary
49
- [stdout, stderr, status]
50
- end
51
-
52
- def to_s
53
- stdout.to_s
54
- end
55
-
56
- def inspect
57
- "#<#{self.class} status=#{status.inspect} timed_out=#{timed_out?} output_truncated=#{output_truncated?} stdout=#{stdout.inspect} stderr=#{stderr.inspect}>"
58
- end
59
- end
60
-
61
- DEFAULT_READ_PATHS = %w[/bin /etc /lib /lib64 /usr].freeze
62
- DEFAULT_EXECUTE_PATHS = %w[/bin /lib /lib64 /usr].freeze
63
- READ_CHUNK_BYTES = 16 * 1024
64
-
65
- class << self
66
- def capture(*command, **options)
67
- perform_capture(*command, raise_on_failure: false, **options)
68
- end
69
-
70
- def capture!(*command, **options)
71
- perform_capture(*command, raise_on_failure: true, **options)
72
- end
73
-
74
- def perform_capture(
75
- *command,
76
- read: [],
77
- write: [],
78
- execute: [],
79
- timeout: nil,
80
- failure_message: "",
81
- success_status_codes: [0],
82
- env: {},
83
- inherit_env: false,
84
- chdir: nil,
85
- stdin: nil,
86
- connect_tcp: nil,
87
- bind_tcp: [],
88
- rlimits: {},
89
- seccomp_deny_network: false,
90
- max_output_bytes: nil,
91
- truncate_output: false,
92
- allow_all_known: true,
93
- raise_on_failure:
94
- )
95
- validate_sandbox_option_values!(connect_tcp: connect_tcp, bind_tcp: bind_tcp)
96
-
97
- unsupported_options = unsupported_sandbox_options(
98
- read: read,
99
- write: write,
100
- execute: execute,
101
- connect_tcp: connect_tcp,
102
- bind_tcp: bind_tcp,
103
- seccomp_deny_network: seccomp_deny_network
104
- )
105
- use_helper = helper_available?
106
- warn_unsupported_platform_once(unsupported_options) if !use_helper && unsupported_options.any?
107
-
108
- stdout, stderr, status, output_truncated, timed_out = if use_helper
109
- max_output_bytes = validate_output_limit!(max_output_bytes)
110
- capture_process(
111
- command,
112
- read: read,
113
- write: write,
114
- execute: execute,
115
- timeout: timeout,
116
- env: env,
117
- inherit_env: inherit_env,
118
- chdir: chdir,
119
- stdin: stdin,
120
- connect_tcp: connect_tcp,
121
- bind_tcp: bind_tcp,
122
- rlimits: rlimits,
123
- seccomp_deny_network: seccomp_deny_network,
124
- max_output_bytes: max_output_bytes,
125
- truncate_output: truncate_output,
126
- allow_all_known: allow_all_known
127
- )
128
- else
129
- max_output_bytes = validate_output_limit!(max_output_bytes)
130
- capture_process_without_helper(
131
- command,
132
- timeout: timeout,
133
- env: env,
134
- inherit_env: inherit_env,
135
- chdir: chdir,
136
- stdin: stdin,
137
- rlimits: rlimits,
138
- max_output_bytes: max_output_bytes,
139
- truncate_output: truncate_output
140
- )
141
- end
142
-
143
- result = Result.new(stdout: stdout, stderr: stderr, status: status, output_truncated: output_truncated, timed_out: timed_out)
144
-
145
- if raise_on_failure && (!status.exited? || !success_status_codes.include?(status.exitstatus))
146
- message = [command.join(" "), failure_message, stderr].filter { |part| part.to_s != "" }.join("\n")
147
- raise CommandError.new(message, stdout: stdout, stderr: stderr, status: status, result: result)
148
- end
149
-
150
- result
151
- rescue OutputTooLargeError => e
152
- message = [command.join(" "), failure_message, e.message].filter { |part| part.to_s != "" }.join("\n")
153
- raise CommandError.new(message)
154
- end
155
- private :perform_capture
156
-
157
- def supported?
158
- helper_available? && Landlock.supported?
159
- end
160
-
161
- def sandboxing?
162
- supported?
163
- end
164
-
165
- def helper_path
166
- candidates = [
167
- File.expand_path("landlock-safe-exec", __dir__),
168
- File.expand_path("../../tmp/#{RbConfig::CONFIG.fetch("arch")}/landlock/#{RUBY_VERSION}/landlock-safe-exec", __dir__),
169
- File.expand_path("../../ext/landlock/landlock-safe-exec", __dir__)
170
- ]
171
- candidates.find { |path| File.executable?(path) } || candidates.first
172
- end
173
-
174
- def default_read_paths
175
- existing_paths(DEFAULT_READ_PATHS)
176
- end
177
-
178
- def default_execute_paths
179
- existing_paths(DEFAULT_EXECUTE_PATHS)
180
- end
181
-
182
- def existing_paths(paths)
183
- Array(paths).filter { |path| path.to_s != "" && File.exist?(path) }.uniq
184
- end
185
-
186
- private
187
-
188
- def helper_available?
189
- RUBY_PLATFORM.include?("linux") && File.executable?(helper_path)
190
- end
191
-
192
- def validate_sandbox_option_values!(connect_tcp:, bind_tcp:)
193
- normalized_ports(connect_tcp, :connect_tcp) if !connect_tcp.nil?
194
- normalized_ports(bind_tcp, :bind_tcp)
195
- end
196
-
197
- def unsupported_sandbox_options(read:, write:, execute:, connect_tcp:, bind_tcp:, seccomp_deny_network:)
198
- options = []
199
- options << :read if Array(read).any?
200
- options << :write if Array(write).any?
201
- options << :execute if Array(execute).any?
202
- options << :connect_tcp if !connect_tcp.nil?
203
- options << :bind_tcp if Array(bind_tcp).any?
204
- options << :seccomp_deny_network if seccomp_deny_network
205
- options
206
- end
207
-
208
- def warn_unsupported_platform_once(options)
209
- return if @warned_unsupported_sandbox
210
-
211
- @warned_unsupported_sandbox = true
212
- warn(
213
- "Landlock::SafeExec sandbox options #{options.join(", ")} are unavailable without the Linux " \
214
- "landlock-safe-exec helper; running command as a pass-through with those restrictions ignored"
215
- )
216
- end
217
-
218
- def validate_output_limit!(max_output_bytes)
219
- return if max_output_bytes.nil?
220
-
221
- Integer(max_output_bytes).tap do |value|
222
- raise ArgumentError, "max_output_bytes must be non-negative" if value.negative?
223
- end
224
- end
225
-
226
- def capture_process_without_helper(
227
- command,
228
- timeout:,
229
- env:,
230
- inherit_env:,
231
- chdir:,
232
- stdin:,
233
- rlimits:,
234
- max_output_bytes:,
235
- truncate_output:
236
- )
237
- argv = normalize_command(command)
238
- spawn_options = fallback_spawn_options(
239
- inherit_env: inherit_env,
240
- chdir: chdir,
241
- rlimits: rlimits
242
- )
243
- popen_args = [env || {}, *argv, spawn_options]
244
-
245
- output_state = { bytes: 0, truncated: false }
246
- output_mutex = Mutex.new
247
- stdout = stderr = status = nil
248
- timed_out = false
249
-
250
- Open3.popen3(*popen_args) do |stdin_io, stdout_io, stderr_io, wait_thread|
251
- stdin_thread = write_process_input(stdin_io, stdin)
252
- stdout_thread = Thread.new do
253
- Thread.current.report_on_exception = false
254
- read_process_output(stdout_io, max_output_bytes, truncate_output, output_state, output_mutex, wait_thread.pid)
255
- end
256
- stderr_thread = Thread.new do
257
- Thread.current.report_on_exception = false
258
- read_process_output(stderr_io, max_output_bytes, truncate_output, output_state, output_mutex, wait_thread.pid)
259
- end
260
-
261
- status, timed_out = wait_for_process(wait_thread, timeout)
262
- stdin_thread&.value
263
- stdout = stdout_thread.value
264
- stderr = stderr_thread.value
265
- end
266
-
267
- [stdout, stderr, status, output_state[:truncated], timed_out]
268
- end
269
-
270
- def fallback_spawn_options(inherit_env:, chdir:, rlimits:)
271
- options = { close_others: true, pgroup: true }
272
- options[:unsetenv_others] = true if !inherit_env
273
- options[:chdir] = chdir if chdir
274
- options.merge!(rlimit_spawn_options(rlimits))
275
- options
276
- end
277
-
278
- def rlimit_spawn_options(rlimits)
279
- normalized_rlimits(rlimits).to_h do |key, value|
280
- [rlimit_spawn_key(key), [value, value]]
281
- end
282
- end
283
-
284
- def rlimit_spawn_key(name)
285
- case name
286
- when :cpu_seconds
287
- :rlimit_cpu
288
- when :memory_bytes
289
- :rlimit_as
290
- when :file_size_bytes
291
- :rlimit_fsize
292
- when :open_files
293
- :rlimit_nofile
294
- when :processes
295
- :rlimit_nproc
296
- end
297
- end
298
-
299
- def normalize_command(command)
300
- raise ArgumentError, "command must not be empty" if command.empty?
301
-
302
- command.map(&:to_s)
303
- end
304
-
305
- def capture_process(
306
- command,
307
- read:,
308
- write:,
309
- execute:,
310
- timeout:,
311
- env:,
312
- inherit_env:,
313
- chdir:,
314
- stdin:,
315
- connect_tcp:,
316
- bind_tcp:,
317
- rlimits:,
318
- seccomp_deny_network:,
319
- max_output_bytes:,
320
- truncate_output:,
321
- allow_all_known:
322
- )
323
- argv = helper_argv(
324
- command,
325
- read: read,
326
- write: write,
327
- execute: execute,
328
- env: env,
329
- inherit_env: inherit_env,
330
- chdir: chdir,
331
- connect_tcp: connect_tcp,
332
- bind_tcp: bind_tcp,
333
- rlimits: rlimits,
334
- seccomp_deny_network: seccomp_deny_network,
335
- allow_all_known: allow_all_known
336
- )
337
-
338
- output_state = { bytes: 0, truncated: false }
339
- output_mutex = Mutex.new
340
- stdout = stderr = status = nil
341
- timed_out = false
342
-
343
- Open3.popen3(*argv, pgroup: true) do |stdin_io, stdout_io, stderr_io, wait_thread|
344
- stdin_thread = write_process_input(stdin_io, stdin)
345
- stdout_thread = Thread.new do
346
- Thread.current.report_on_exception = false
347
- read_process_output(stdout_io, max_output_bytes, truncate_output, output_state, output_mutex, wait_thread.pid)
348
- end
349
- stderr_thread = Thread.new do
350
- Thread.current.report_on_exception = false
351
- read_process_output(stderr_io, max_output_bytes, truncate_output, output_state, output_mutex, wait_thread.pid)
352
- end
353
-
354
- status, timed_out = wait_for_process(wait_thread, timeout)
355
- stdin_thread&.value
356
- stdout = stdout_thread.value
357
- stderr = stderr_thread.value
358
- end
359
-
360
- [stdout, stderr, status, output_state[:truncated], timed_out]
361
- end
362
-
363
- def helper_argv(
364
- command,
365
- read:,
366
- write:,
367
- execute:,
368
- env:,
369
- inherit_env:,
370
- chdir:,
371
- connect_tcp:,
372
- bind_tcp:,
373
- rlimits:,
374
- seccomp_deny_network:,
375
- allow_all_known:
376
- )
377
- normalize_command(command)
378
- read_paths = validate_existing_paths(read, :read)
379
- write_paths = validate_existing_paths(write, :write)
380
- execute_paths = validate_existing_paths(execute, :execute)
381
- filesystem_policy_requested = read_paths.any? || write_paths.any? || execute_paths.any?
382
-
383
- argv = [helper_path]
384
- read_paths.each { |path| argv << "--read" << path }
385
- write_paths.each { |path| argv << "--write" << path }
386
- execute_paths.each { |path| argv << "--execute" << path }
387
- sandbox_connect_tcp_ports(connect_tcp).each { |port| argv << "--connect-tcp" << port.to_s }
388
- normalized_ports(bind_tcp, :bind_tcp).each { |port| argv << "--bind-tcp" << port.to_s }
389
- argv << "--chdir" << chdir if chdir
390
- Array(env).each { |key, value| argv << "--env" << "#{key}=#{value}" }
391
- argv << "--unsetenv-others" if !inherit_env
392
- normalized_rlimits(rlimits).each { |key, value| argv << "--rlimit" << "#{key}=#{value}" }
393
- argv << "--seccomp-deny-network" if seccomp_deny_network
394
- argv << "--allow-all-known" if allow_all_known && filesystem_policy_requested
395
- argv << "--"
396
- argv.concat(command.map(&:to_s))
397
- argv
398
- end
399
-
400
- def sandbox_connect_tcp_ports(connect_tcp)
401
- return normalized_ports(connect_tcp, :connect_tcp) if !connect_tcp.nil?
402
- return [] if !Landlock.supported? || Landlock.abi_version < 4
403
-
404
- [0]
405
- end
406
-
407
- def normalized_ports(ports, name)
408
- Array(ports).map do |port|
409
- integer = Integer(port)
410
- raise ArgumentError, "#{name} port must be between 0 and 65535" if integer.negative? || integer > 65_535
411
-
412
- integer
413
- end
414
- end
415
-
416
- def validate_existing_paths(paths, name)
417
- Array(paths).map do |path|
418
- string = path.to_s
419
- raise ArgumentError, "#{name} path must not be empty" if string.empty?
420
- raise ArgumentError, "#{name} path does not exist: #{string}" if !File.exist?(string)
421
-
422
- string
423
- end.uniq
424
- end
425
-
426
- def normalized_rlimits(rlimits)
427
- Array(rlimits).filter_map do |name, value|
428
- next if value.nil?
429
-
430
- key = name.to_sym
431
- unless %i[cpu_seconds memory_bytes file_size_bytes open_files processes].include?(key)
432
- raise ArgumentError, "Unknown rlimit: #{name}"
433
- end
434
-
435
- value = Integer(value)
436
- raise ArgumentError, "rlimit #{name} must be non-negative" if value.negative?
437
-
438
- [key, value]
439
- end
440
- end
441
-
442
- def wait_for_process(wait_thread, timeout)
443
- if timeout
444
- [Timeout.timeout(timeout) { wait_thread.value }, false]
445
- else
446
- [wait_thread.value, false]
447
- end
448
- rescue Timeout::Error
449
- terminate_process(wait_thread.pid)
450
- [wait_thread.value, true]
451
- end
452
-
453
- def write_process_input(io, input)
454
- return io.close if input.nil?
455
-
456
- Thread.new do
457
- Thread.current.report_on_exception = false
458
- begin
459
- if input.respond_to?(:read)
460
- while (chunk = input.read(READ_CHUNK_BYTES))
461
- io.write(chunk)
462
- end
463
- else
464
- io.write(input.to_s)
465
- end
466
- rescue Errno::EPIPE, IOError
467
- ensure
468
- io.close unless io.closed?
469
- end
470
- end
471
- end
472
-
473
- def read_process_output(io, max_output_bytes, truncate_output, output_state, output_mutex, pid)
474
- return io.read if max_output_bytes.nil?
475
-
476
- output = +""
477
- while (chunk = io.read(READ_CHUNK_BYTES))
478
- chunk_to_append = chunk
479
- over_limit = false
480
-
481
- output_mutex.synchronize do
482
- remaining_bytes = max_output_bytes - output_state[:bytes]
483
- if remaining_bytes <= 0
484
- chunk_to_append = ""
485
- over_limit = true
486
- elsif chunk.bytesize > remaining_bytes
487
- chunk_to_append = chunk.byteslice(0, remaining_bytes)
488
- over_limit = true
489
- end
490
-
491
- output_state[:bytes] += chunk.bytesize
492
- output_state[:truncated] = true if over_limit
493
- end
494
-
495
- output << chunk_to_append
496
- if over_limit
497
- terminate_process(pid)
498
- raise OutputTooLargeError, "Process output exceeded #{max_output_bytes} bytes" if !truncate_output
499
-
500
- break
501
- end
502
- end
503
- output
504
- end
505
-
506
- def terminate_process(pid)
507
- signal_process("TERM", pid)
508
- sleep 0.5
509
- signal_process("KILL", pid)
510
- end
511
-
512
- def signal_process(signal, pid)
513
- Process.kill(signal, -pid)
514
- rescue Errno::ESRCH, Errno::EPERM
515
- begin
516
- Process.kill(signal, pid)
517
- rescue Errno::ESRCH, Errno::EPERM
518
- end
519
- end
520
- end
521
- end
522
- end