landlock 0.1.0 → 0.2
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +42 -0
- data/README.md +141 -9
- data/benchmark/landlock_overhead.rb +212 -0
- data/ext/landlock/bin/safe_exec_helper.c +369 -0
- data/ext/landlock/extconf.rb +30 -0
- data/ext/landlock/landlock.c +25 -153
- data/ext/landlock/landlock_native.h +167 -0
- data/lib/landlock/safe_exec.rb +522 -0
- data/lib/landlock/version.rb +1 -1
- data/lib/landlock.rb +103 -34
- metadata +6 -1
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#ifndef RB_LANDLOCK_NATIVE_H
|
|
2
|
+
#define RB_LANDLOCK_NATIVE_H
|
|
3
|
+
|
|
4
|
+
#include <errno.h>
|
|
5
|
+
#include <fcntl.h>
|
|
6
|
+
#include <stddef.h>
|
|
7
|
+
#include <stdint.h>
|
|
8
|
+
#include <unistd.h>
|
|
9
|
+
|
|
10
|
+
#ifdef __linux__
|
|
11
|
+
#include <sys/prctl.h>
|
|
12
|
+
#include <sys/syscall.h>
|
|
13
|
+
#ifdef HAVE_LINUX_LANDLOCK_H
|
|
14
|
+
#include <linux/landlock.h>
|
|
15
|
+
#endif
|
|
16
|
+
#endif
|
|
17
|
+
|
|
18
|
+
#ifndef SYS_landlock_create_ruleset
|
|
19
|
+
# if defined(__linux__) && defined(__NR_landlock_create_ruleset) && defined(__NR_landlock_add_rule) && defined(__NR_landlock_restrict_self)
|
|
20
|
+
# define SYS_landlock_create_ruleset __NR_landlock_create_ruleset
|
|
21
|
+
# define SYS_landlock_add_rule __NR_landlock_add_rule
|
|
22
|
+
# define SYS_landlock_restrict_self __NR_landlock_restrict_self
|
|
23
|
+
# elif defined(__linux__) && defined(__x86_64__) && defined(__ILP32__)
|
|
24
|
+
# ifndef __X32_SYSCALL_BIT
|
|
25
|
+
# define __X32_SYSCALL_BIT 0x40000000
|
|
26
|
+
# endif
|
|
27
|
+
# define SYS_landlock_create_ruleset (__X32_SYSCALL_BIT + 444)
|
|
28
|
+
# define SYS_landlock_add_rule (__X32_SYSCALL_BIT + 445)
|
|
29
|
+
# define SYS_landlock_restrict_self (__X32_SYSCALL_BIT + 446)
|
|
30
|
+
# elif defined(__linux__) && (defined(__x86_64__) || defined(__aarch64__) || defined(__i386__))
|
|
31
|
+
# define SYS_landlock_create_ruleset 444
|
|
32
|
+
# define SYS_landlock_add_rule 445
|
|
33
|
+
# define SYS_landlock_restrict_self 446
|
|
34
|
+
# endif
|
|
35
|
+
#endif
|
|
36
|
+
|
|
37
|
+
#ifndef LANDLOCK_CREATE_RULESET_VERSION
|
|
38
|
+
#define LANDLOCK_CREATE_RULESET_VERSION (1U << 0)
|
|
39
|
+
#endif
|
|
40
|
+
|
|
41
|
+
#ifndef LANDLOCK_RULE_PATH_BENEATH
|
|
42
|
+
#define LANDLOCK_RULE_PATH_BENEATH 1
|
|
43
|
+
#endif
|
|
44
|
+
|
|
45
|
+
#ifndef LANDLOCK_RULE_NET_PORT
|
|
46
|
+
#define LANDLOCK_RULE_NET_PORT 2
|
|
47
|
+
#endif
|
|
48
|
+
|
|
49
|
+
#ifndef LANDLOCK_ACCESS_FS_EXECUTE
|
|
50
|
+
#define LANDLOCK_ACCESS_FS_EXECUTE (1ULL << 0)
|
|
51
|
+
#endif
|
|
52
|
+
#ifndef LANDLOCK_ACCESS_FS_WRITE_FILE
|
|
53
|
+
#define LANDLOCK_ACCESS_FS_WRITE_FILE (1ULL << 1)
|
|
54
|
+
#endif
|
|
55
|
+
#ifndef LANDLOCK_ACCESS_FS_READ_FILE
|
|
56
|
+
#define LANDLOCK_ACCESS_FS_READ_FILE (1ULL << 2)
|
|
57
|
+
#endif
|
|
58
|
+
#ifndef LANDLOCK_ACCESS_FS_READ_DIR
|
|
59
|
+
#define LANDLOCK_ACCESS_FS_READ_DIR (1ULL << 3)
|
|
60
|
+
#endif
|
|
61
|
+
#ifndef LANDLOCK_ACCESS_FS_REMOVE_DIR
|
|
62
|
+
#define LANDLOCK_ACCESS_FS_REMOVE_DIR (1ULL << 4)
|
|
63
|
+
#endif
|
|
64
|
+
#ifndef LANDLOCK_ACCESS_FS_REMOVE_FILE
|
|
65
|
+
#define LANDLOCK_ACCESS_FS_REMOVE_FILE (1ULL << 5)
|
|
66
|
+
#endif
|
|
67
|
+
#ifndef LANDLOCK_ACCESS_FS_MAKE_CHAR
|
|
68
|
+
#define LANDLOCK_ACCESS_FS_MAKE_CHAR (1ULL << 6)
|
|
69
|
+
#endif
|
|
70
|
+
#ifndef LANDLOCK_ACCESS_FS_MAKE_DIR
|
|
71
|
+
#define LANDLOCK_ACCESS_FS_MAKE_DIR (1ULL << 7)
|
|
72
|
+
#endif
|
|
73
|
+
#ifndef LANDLOCK_ACCESS_FS_MAKE_REG
|
|
74
|
+
#define LANDLOCK_ACCESS_FS_MAKE_REG (1ULL << 8)
|
|
75
|
+
#endif
|
|
76
|
+
#ifndef LANDLOCK_ACCESS_FS_MAKE_SOCK
|
|
77
|
+
#define LANDLOCK_ACCESS_FS_MAKE_SOCK (1ULL << 9)
|
|
78
|
+
#endif
|
|
79
|
+
#ifndef LANDLOCK_ACCESS_FS_MAKE_FIFO
|
|
80
|
+
#define LANDLOCK_ACCESS_FS_MAKE_FIFO (1ULL << 10)
|
|
81
|
+
#endif
|
|
82
|
+
#ifndef LANDLOCK_ACCESS_FS_MAKE_BLOCK
|
|
83
|
+
#define LANDLOCK_ACCESS_FS_MAKE_BLOCK (1ULL << 11)
|
|
84
|
+
#endif
|
|
85
|
+
#ifndef LANDLOCK_ACCESS_FS_MAKE_SYM
|
|
86
|
+
#define LANDLOCK_ACCESS_FS_MAKE_SYM (1ULL << 12)
|
|
87
|
+
#endif
|
|
88
|
+
#ifndef LANDLOCK_ACCESS_FS_REFER
|
|
89
|
+
#define LANDLOCK_ACCESS_FS_REFER (1ULL << 13)
|
|
90
|
+
#endif
|
|
91
|
+
#ifndef LANDLOCK_ACCESS_FS_TRUNCATE
|
|
92
|
+
#define LANDLOCK_ACCESS_FS_TRUNCATE (1ULL << 14)
|
|
93
|
+
#endif
|
|
94
|
+
#ifndef LANDLOCK_ACCESS_FS_IOCTL_DEV
|
|
95
|
+
#define LANDLOCK_ACCESS_FS_IOCTL_DEV (1ULL << 15)
|
|
96
|
+
#endif
|
|
97
|
+
|
|
98
|
+
#ifndef LANDLOCK_ACCESS_NET_BIND_TCP
|
|
99
|
+
#define LANDLOCK_ACCESS_NET_BIND_TCP (1ULL << 0)
|
|
100
|
+
#endif
|
|
101
|
+
#ifndef LANDLOCK_ACCESS_NET_CONNECT_TCP
|
|
102
|
+
#define LANDLOCK_ACCESS_NET_CONNECT_TCP (1ULL << 1)
|
|
103
|
+
#endif
|
|
104
|
+
|
|
105
|
+
#ifndef LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET
|
|
106
|
+
#define LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET (1ULL << 0)
|
|
107
|
+
#endif
|
|
108
|
+
#ifndef LANDLOCK_SCOPE_SIGNAL
|
|
109
|
+
#define LANDLOCK_SCOPE_SIGNAL (1ULL << 1)
|
|
110
|
+
#endif
|
|
111
|
+
|
|
112
|
+
#ifndef O_PATH
|
|
113
|
+
#define O_PATH 010000000
|
|
114
|
+
#endif
|
|
115
|
+
|
|
116
|
+
#ifndef O_CLOEXEC
|
|
117
|
+
#define O_CLOEXEC 02000000
|
|
118
|
+
#endif
|
|
119
|
+
|
|
120
|
+
#ifndef PR_SET_NO_NEW_PRIVS
|
|
121
|
+
#define PR_SET_NO_NEW_PRIVS 38
|
|
122
|
+
#endif
|
|
123
|
+
|
|
124
|
+
struct rb_landlock_ruleset_attr {
|
|
125
|
+
uint64_t handled_access_fs;
|
|
126
|
+
uint64_t handled_access_net;
|
|
127
|
+
uint64_t scoped;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
struct rb_landlock_path_beneath_attr {
|
|
131
|
+
uint64_t allowed_access;
|
|
132
|
+
int32_t parent_fd;
|
|
133
|
+
} __attribute__((packed));
|
|
134
|
+
|
|
135
|
+
struct rb_landlock_net_port_attr {
|
|
136
|
+
uint64_t allowed_access;
|
|
137
|
+
uint64_t port;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
static long ll_create_ruleset(const void *attr, size_t size, uint32_t flags) {
|
|
141
|
+
#ifdef SYS_landlock_create_ruleset
|
|
142
|
+
return syscall(SYS_landlock_create_ruleset, attr, size, flags);
|
|
143
|
+
#else
|
|
144
|
+
errno = ENOSYS;
|
|
145
|
+
return -1;
|
|
146
|
+
#endif
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
static long ll_add_rule(int ruleset_fd, int rule_type, const void *rule_attr, uint32_t flags) {
|
|
150
|
+
#ifdef SYS_landlock_add_rule
|
|
151
|
+
return syscall(SYS_landlock_add_rule, ruleset_fd, rule_type, rule_attr, flags);
|
|
152
|
+
#else
|
|
153
|
+
errno = ENOSYS;
|
|
154
|
+
return -1;
|
|
155
|
+
#endif
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
static long ll_restrict_self(int ruleset_fd, uint32_t flags) {
|
|
159
|
+
#ifdef SYS_landlock_restrict_self
|
|
160
|
+
return syscall(SYS_landlock_restrict_self, ruleset_fd, flags);
|
|
161
|
+
#else
|
|
162
|
+
errno = ENOSYS;
|
|
163
|
+
return -1;
|
|
164
|
+
#endif
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
#endif
|
|
@@ -0,0 +1,522 @@
|
|
|
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
|
data/lib/landlock/version.rb
CHANGED