landlock 0.1.0 → 0.1.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6cf65089e4af85184c67c7a2e6c679932dd21c492882ccf9cf4612d5603cb7a4
4
- data.tar.gz: a987aaefb3e9ec52f69087c533fed5e384157aa4e1caebbc56226030c84e1112
3
+ metadata.gz: 60ae145a444fb3dd4c072fe4e5c8bac7de1836325a6d84e10be894d5b2f9ed2c
4
+ data.tar.gz: 983b4da291286f4c903d12cb625508f1a569de918167a2c3f1fab450fcdeb866
5
5
  SHA512:
6
- metadata.gz: 6de7d6d837c0365fcf6d18e4cf3d41d291d6d043b71a3b05d1501e3c805faf7acd082865e67bad699814f0bdd342859f7790e80c3d58e510b83f8121e3445955
7
- data.tar.gz: f87863bd3de8f8a8d5ce6ffd56382bafcf6cefc7ec74dca9ea9a7e6df7c5f759c556f51a2e687668d0fd30b809cf07a8136b73e26467dd069d021977a5abd4e2
6
+ metadata.gz: ff7628efa5a0f464788e1adfe215d457e77eef7d8c94d1c6ee17a61f651732e3012afe0a1ae04792709b7255e5e8196576f4b5b19a0f7351a719cdfa6e72b49b
7
+ data.tar.gz: 596b360c7dbcc2bd5079ea597df5b39d9d57c240ed6298d30ddeace1b955294e34bffe7f58353d3bcf01ae897bf2d738d5527c3cc25af1e485506de5f359d91e
data/CHANGELOG.md ADDED
@@ -0,0 +1,34 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [0.1.1] - 2026-04-30
6
+
7
+ ### Security
8
+
9
+ - Require `Landlock.exec` and `Landlock.spawn` commands to be passed as argument arrays. This avoids Ruby's implicit shell execution path for string commands.
10
+ - Execute subprocesses with an explicit `argv[0]` tuple (`[command, command]`) so array commands keep their no-shell behavior.
11
+ - Use `exit! 127` for child setup failures before `exec`, preventing inherited `at_exit` handlers from running in the forked child.
12
+ - Honor `unsetenv_others: true` by passing Ruby's `unsetenv_others` exec option instead of only constructing a reduced environment hash.
13
+ - Add ABI v6 Landlock scoping support via `scope: [:signal, :abstract_unix_socket]` to restrict signalling and abstract Unix-domain socket access outside the sandbox domain.
14
+ - Expose `allow_all_known:` on `Landlock.exec` and `Landlock.spawn` so subprocess sandboxes can deny unlisted filesystem actions without needing dummy allow rules.
15
+
16
+ ### Fixed
17
+
18
+ - Allow high-level `read`, `write`, and `execute` helpers to target individual files by filtering directory-only rights before adding file path rules.
19
+ - Fix fallback Landlock syscall numbers on i386, handle x32, and prefer platform `__NR_*` constants when available.
20
+ - Convert path rule arguments before opening path file descriptors in the native extension to avoid leaking descriptors on argument conversion errors.
21
+
22
+ ### Documentation
23
+
24
+ - Document important sandbox caveats: only handled rights are restricted, TCP rules do not cover UDP/pathname Unix sockets, already-open descriptors remain usable, and `restrict!` applies to the calling thread and future children.
25
+
26
+ ### Tests
27
+
28
+ - Add coverage for no-shell argv validation, child setup failure behavior, `unsetenv_others`, strict filesystem subprocess policies, file-specific path rules, and ABI v6 signal scoping.
29
+
30
+ ## [0.1.0] - 2026-04-30
31
+
32
+ ### Added
33
+
34
+ - Initial Ruby bindings for Linux Landlock rulesets, filesystem path rules, TCP port rules, and safe subprocess helpers.
data/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # landlock
2
2
 
3
- Ruby bindings for Linux [Landlock](https://docs.kernel.org/userspace-api/landlock.html): unprivileged, kernel-enforced sandboxing for the current process and its descendants.
3
+ Ruby bindings for Linux [Landlock](https://docs.kernel.org/userspace-api/landlock.html): unprivileged, kernel-enforced sandboxing for the calling thread and its future descendants.
4
4
 
5
5
  This gem includes a small native extension around the three Landlock syscalls and a Ruby API for safe subprocess execution.
6
6
 
7
7
  ## Status
8
8
 
9
- Experimental. Filesystem support requires Landlock ABI v1+. TCP network rules require ABI v4+.
9
+ Experimental. Filesystem support requires Landlock ABI v1+. TCP network rules require ABI v4+. Signal and abstract Unix-domain socket scopes require ABI v6+.
10
10
 
11
11
  ```ruby
12
12
  require "landlock"
@@ -15,8 +15,12 @@ puts Landlock.abi_version
15
15
  puts Landlock.supported?
16
16
  ```
17
17
 
18
+ See [CHANGELOG.md](CHANGELOG.md) for release notes.
19
+
18
20
  ## Safe subprocess execution
19
21
 
22
+ Pass commands as an argument array. `Landlock.exec` and `Landlock.spawn` do not invoke a shell implicitly; use an explicit shell in the array if that is really required. Both helpers accept `env:` and `unsetenv_others:` and pass them through to `Kernel.exec` so subprocesses can run with a controlled environment.
23
+
20
24
  Allow Ruby to execute and read its runtime, but only allow outbound TCP connections to port 443:
21
25
 
22
26
  ```ruby
@@ -24,7 +28,8 @@ status = Landlock.exec(
24
28
  [RbConfig.ruby, "script.rb"],
25
29
  read: ["/usr", "/lib", "/lib64", "/etc/ssl"],
26
30
  execute: ["/usr", "/lib", "/lib64"],
27
- connect_tcp: [443]
31
+ connect_tcp: [443],
32
+ allow_all_known: true
28
33
  )
29
34
 
30
35
  abort "failed" unless status.success?
@@ -35,12 +40,20 @@ Deny all outbound TCP except the listed ports:
35
40
  ```ruby
36
41
  Landlock.exec(
37
42
  ["curl", "https://example.com"],
38
- read: ["/usr", "/lib", "/lib64", "/etc/ssl", "/etc/resolv.conf", "/etc/hosts"],
43
+ read: [
44
+ "/usr", "/lib", "/lib64",
45
+ "/etc/ssl", "/etc/resolv.conf", "/etc/hosts",
46
+ "/etc/nsswitch.conf", "/etc/gai.conf", "/etc/host.conf",
47
+ "/run/systemd/resolve", "/var/lib/sss"
48
+ ].select { |path| File.exist?(path) },
39
49
  execute: ["/usr", "/lib", "/lib64"],
40
- connect_tcp: [443]
50
+ connect_tcp: [443],
51
+ allow_all_known: true
41
52
  )
42
53
  ```
43
54
 
55
+ TLS and name-resolution dependencies vary by distribution and NSS configuration; add any local CA, DNS, NSS, or resolver paths your system needs.
56
+
44
57
  Allow binding a local TCP port:
45
58
 
46
59
  ```ruby
@@ -48,22 +61,27 @@ Landlock.exec(
48
61
  [RbConfig.ruby, "server.rb"],
49
62
  read: ["/usr", "/lib", "/lib64", Dir.pwd],
50
63
  execute: ["/usr", "/lib", "/lib64"],
51
- bind_tcp: [9292]
64
+ bind_tcp: [9292],
65
+ allow_all_known: true
52
66
  )
53
67
  ```
54
68
 
55
69
  ## Restrict current process
56
70
 
57
- This is irreversible for the current thread/process. Use `Landlock.exec` or `Landlock.spawn` unless you really mean it.
71
+ This is irreversible for the current thread and its future children. Use `Landlock.exec` or `Landlock.spawn` unless you really mean it.
58
72
 
59
73
  ```ruby
60
74
  Landlock.restrict!(
61
75
  read: ["/usr", "/app"],
62
76
  write: ["/tmp/my-output"],
63
- connect_tcp: [443]
77
+ connect_tcp: [443],
78
+ scope: [:signal, :abstract_unix_socket],
79
+ allow_all_known: true
64
80
  )
65
81
  ```
66
82
 
83
+ `write:` grants the filesystem rights needed for practical writes under the listed paths, including directory traversal and reads (`read_file`/`read_dir`). If you need exact rights, use `paths:` with an explicit `rights:` list.
84
+
67
85
  ## Lower-level path rules
68
86
 
69
87
  ```ruby
@@ -76,6 +94,48 @@ Landlock.restrict!(
76
94
  )
77
95
  ```
78
96
 
97
+ ## Performance
98
+
99
+ Landlock enforcement is done by the kernel after a ruleset is installed. In normal use the practical cost should be dominated by the one-time sandbox setup and by the work your process already performs, not by Ruby-side wrappers.
100
+
101
+ This repository includes a small benchmark suite that compares common workloads before and after applying a read-only Landlock policy:
102
+
103
+ ```sh
104
+ bundle exec rake bench
105
+ # or
106
+ bundle exec ruby benchmark/landlock_overhead.rb
107
+ ```
108
+
109
+ The suite reports median timings for CPU-only work, file metadata reads, small file reads, directory scans, and the one-time ruleset setup cost. You can tune the run length with environment variables:
110
+
111
+ ```sh
112
+ SAMPLES=15 ITERATIONS=100000 DIR_ITERATIONS=5000 bundle exec rake bench
113
+ ```
114
+
115
+ Sample output looks like:
116
+
117
+ ```text
118
+ workload baseline landlocked delta delta %
119
+ --------------------------------------------------------------------
120
+ cpu_loop 0.650 ms 0.648 ms -0.002 ms -0.31%
121
+ file_stat 42.100 ms 42.300 ms 0.200 ms 0.48%
122
+ file_read 120.500 ms 120.900 ms 0.400 ms 0.33%
123
+ dir_scan 88.000 ms 88.200 ms 0.200 ms 0.23%
124
+
125
+ Setup cost (create ruleset, add read rules, restrict current process):
126
+ median 0.080 ms (25 samples)
127
+ ```
128
+
129
+ Treat small positive or negative deltas as noise and benchmark on the kernel, filesystem, and hardware you deploy on. The expected result is no practical steady-state overhead for typical application work, with a small one-time cost when installing the sandbox.
130
+
79
131
  ## Caveats
80
132
 
81
- Landlock is not a complete container. It does not impose CPU/memory limits, hide already-open file descriptors, or replace seccomp/namespaces/cgroups. For serious untrusted execution, combine it with controlled environment, `close_others`, resource limits, and preferably process isolation.
133
+ Landlock is not a complete container. It does not impose CPU/memory limits, hide already-open file descriptors, or replace seccomp/namespaces/cgroups. For serious untrusted execution, combine it with a controlled environment, `close_others`, resource limits, and preferably process isolation.
134
+
135
+ If a child fails during sandbox setup or `exec`, the helpers print a diagnostic and the child exits 127. That code can collide with a command that legitimately exits 127; unsupported kernels are checked before forking so they raise `Landlock::UnsupportedError` synchronously instead.
136
+
137
+ Path rules follow the kernel's normal path resolution when the rule is installed. Because paths are opened without `O_NOFOLLOW`, a symlink rule applies to the symlink target's inode, not to the symlink path itself.
138
+
139
+ Landlock only restricts access rights included in a ruleset's handled set: omitted categories remain allowed. Use `allow_all_known: true` when you want unlisted filesystem actions denied. The high-level helpers handle the categories you pass (`read`, `write`, `execute`, `connect_tcp`, `bind_tcp`, `scope`). Landlock's TCP rules do not cover UDP or pathname Unix-domain sockets; ABI v6+ scopes can restrict signals and abstract Unix-domain sockets.
140
+
141
+ `Landlock.restrict!` applies to the calling thread and its future children; already-running sibling threads are not retroactively sandboxed. Prefer `Landlock.exec` or `Landlock.spawn` for subprocess sandboxing from a larger Ruby application.
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "rbconfig"
5
+ require "tmpdir"
6
+ require "fileutils"
7
+ require "open3"
8
+
9
+ root = File.expand_path("..", __dir__)
10
+ lib_dir = File.join(root, "lib")
11
+ ext_lib_dir = File.join(lib_dir, "landlock")
12
+
13
+ $LOAD_PATH.unshift(lib_dir)
14
+ $LOAD_PATH.unshift(ext_lib_dir)
15
+
16
+ require "landlock"
17
+
18
+ module LandlockBench
19
+ ROOT = File.expand_path("..", __dir__)
20
+ DEFAULT_SAMPLES = Integer(ENV.fetch("SAMPLES", 7))
21
+ DEFAULT_ITERATIONS = Integer(ENV.fetch("ITERATIONS", 25_000))
22
+ DIR_ITERATIONS = Integer(ENV.fetch("DIR_ITERATIONS", 2_000))
23
+ SETUP_SAMPLES = Integer(ENV.fetch("SETUP_SAMPLES", 25))
24
+
25
+ WORKLOADS = [
26
+ ["cpu_loop", "integer arithmetic loop"],
27
+ ["file_stat", "File.stat on an allowed file"],
28
+ ["file_read", "File.binread of a small allowed file"],
29
+ ["dir_scan", "Dir.foreach over an allowed directory"]
30
+ ].freeze
31
+
32
+ module_function
33
+
34
+ def monotonic_ns
35
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
36
+ end
37
+
38
+ def measure
39
+ started = monotonic_ns
40
+ yield
41
+ monotonic_ns - started
42
+ end
43
+
44
+ def runtime_paths
45
+ [
46
+ File.dirname(RbConfig.ruby),
47
+ RbConfig::CONFIG["libdir"],
48
+ RbConfig::CONFIG["archlibdir"],
49
+ "/usr",
50
+ "/lib",
51
+ "/lib64",
52
+ "/etc"
53
+ ].compact.uniq.select { |path| File.exist?(path) }
54
+ end
55
+
56
+ def prepare_workspace
57
+ Dir.mktmpdir("landlock-bench") do |dir|
58
+ file = File.join(dir, "small.txt")
59
+ entries = File.join(dir, "entries")
60
+ Dir.mkdir(entries)
61
+ File.binwrite(file, "x" * 1024)
62
+ 100.times { |index| File.binwrite(File.join(entries, "entry-#{index}.txt"), "x") }
63
+
64
+ yield({ "dir" => dir, "file" => file, "entries" => entries })
65
+ end
66
+ end
67
+
68
+ def child_command(payload)
69
+ [RbConfig.ruby, __FILE__, "--child", JSON.generate(payload)]
70
+ end
71
+
72
+ def run_child(payload)
73
+ stdout, stderr, status = Open3.capture3(*child_command(payload), chdir: ROOT)
74
+ unless status.success?
75
+ abort "bench child failed (#{status.exitstatus})\nSTDOUT:\n#{stdout}\nSTDERR:\n#{stderr}"
76
+ end
77
+
78
+ JSON.parse(stdout)
79
+ end
80
+
81
+ def run_parent
82
+ puts "Landlock performance benchmark"
83
+ puts "Ruby: #{RUBY_DESCRIPTION}"
84
+ puts "Landlock ABI: #{Landlock.abi_version}"
85
+ puts "Samples: #{DEFAULT_SAMPLES}, iterations: #{DEFAULT_ITERATIONS}"
86
+ puts
87
+
88
+ prepare_workspace do |workspace|
89
+ read_paths = (runtime_paths + [workspace.fetch("dir")]).uniq
90
+ common = {
91
+ mode: "workloads",
92
+ iterations: DEFAULT_ITERATIONS,
93
+ dir_iterations: DIR_ITERATIONS,
94
+ read_paths: read_paths,
95
+ workspace: workspace
96
+ }
97
+
98
+ baseline = collect_samples(DEFAULT_SAMPLES) { run_child(common.merge(sandbox: false)) }
99
+
100
+ unless Landlock.supported?
101
+ puts "Landlock is not supported on this host; only baseline timings were collected."
102
+ print_workload_table(baseline, nil)
103
+ return
104
+ end
105
+
106
+ sandbox = collect_samples(DEFAULT_SAMPLES) { run_child(common.merge(sandbox: true)) }
107
+ setup = collect_samples(SETUP_SAMPLES) do
108
+ run_child(mode: "setup", read_paths: read_paths).fetch("setup_ns")
109
+ end
110
+
111
+ print_workload_table(baseline, sandbox)
112
+ puts
113
+ puts "Setup cost (create ruleset, add read rules, restrict current process):"
114
+ puts " median #{format_ms(median(setup))} (#{SETUP_SAMPLES} samples)"
115
+ puts
116
+ puts "Lower is better. Negative deltas mean the sandboxed sample was faster in this run."
117
+ puts "Expect small differences to be noise; compare medians across repeated runs."
118
+ end
119
+ end
120
+
121
+ def collect_samples(count)
122
+ Array.new(count) { yield }
123
+ end
124
+
125
+ def print_workload_table(baseline, sandbox)
126
+ puts format("%-12s %14s %14s %12s %10s", "workload", "baseline", "landlocked", "delta", "delta %")
127
+ puts "-" * 68
128
+
129
+ WORKLOADS.each do |name, description|
130
+ base = median(baseline.map { |sample| sample.fetch(name) })
131
+ if sandbox
132
+ locked = median(sandbox.map { |sample| sample.fetch(name) })
133
+ delta = locked - base
134
+ pct = base.positive? ? (delta.to_f / base * 100.0) : 0.0
135
+ puts format(
136
+ "%-12s %14s %14s %12s %9.2f%%",
137
+ name,
138
+ format_ms(base),
139
+ format_ms(locked),
140
+ format_ms(delta),
141
+ pct
142
+ )
143
+ else
144
+ puts format("%-12s %14s %14s %12s %10s", name, format_ms(base), "n/a", "n/a", "n/a")
145
+ end
146
+ puts " #{description}"
147
+ end
148
+ end
149
+
150
+ def median(values)
151
+ sorted = values.sort
152
+ midpoint = sorted.length / 2
153
+ return sorted.fetch(midpoint) if sorted.length.odd?
154
+
155
+ (sorted.fetch(midpoint - 1) + sorted.fetch(midpoint)) / 2.0
156
+ end
157
+
158
+ def format_ms(ns)
159
+ format("%.3f ms", ns.to_f / 1_000_000.0)
160
+ end
161
+
162
+ def run_child_mode(payload)
163
+ case payload.fetch("mode")
164
+ when "setup"
165
+ puts JSON.generate("setup_ns" => measure { restrict_for(payload) })
166
+ when "workloads"
167
+ restrict_for(payload) if payload.fetch("sandbox")
168
+ puts JSON.generate(run_workloads(payload))
169
+ else
170
+ raise ArgumentError, "unknown child mode: #{payload.fetch("mode").inspect}"
171
+ end
172
+ end
173
+
174
+ def restrict_for(payload)
175
+ Landlock.restrict!(read: payload.fetch("read_paths"))
176
+ end
177
+
178
+ def run_workloads(payload)
179
+ iterations = Integer(payload.fetch("iterations"))
180
+ dir_iterations = Integer(payload.fetch("dir_iterations"))
181
+ workspace = payload.fetch("workspace")
182
+ file = workspace.fetch("file")
183
+ entries = workspace.fetch("entries")
184
+ sink = 0
185
+
186
+ GC.disable
187
+ {
188
+ "cpu_loop" => measure do
189
+ iterations.times { |index| sink ^= ((index * 31) & 0xffff) }
190
+ end,
191
+ "file_stat" => measure do
192
+ iterations.times { sink ^= File.stat(file).size }
193
+ end,
194
+ "file_read" => measure do
195
+ iterations.times { sink ^= File.binread(file).bytesize }
196
+ end,
197
+ "dir_scan" => measure do
198
+ dir_iterations.times do
199
+ Dir.foreach(entries) { |entry| sink ^= entry.bytesize }
200
+ end
201
+ end
202
+ }.merge("sink" => sink)
203
+ ensure
204
+ GC.enable
205
+ end
206
+ end
207
+
208
+ if ARGV.first == "--child"
209
+ LandlockBench.run_child_mode(JSON.parse(ARGV.fetch(1)))
210
+ else
211
+ LandlockBench.run_parent
212
+ end
@@ -14,7 +14,18 @@
14
14
  #endif
15
15
 
16
16
  #ifndef SYS_landlock_create_ruleset
17
- # if defined(__x86_64__)
17
+ # if defined(__NR_landlock_create_ruleset) && defined(__NR_landlock_add_rule) && defined(__NR_landlock_restrict_self)
18
+ # define SYS_landlock_create_ruleset __NR_landlock_create_ruleset
19
+ # define SYS_landlock_add_rule __NR_landlock_add_rule
20
+ # define SYS_landlock_restrict_self __NR_landlock_restrict_self
21
+ # elif defined(__x86_64__) && defined(__ILP32__)
22
+ # ifndef __X32_SYSCALL_BIT
23
+ # define __X32_SYSCALL_BIT 0x40000000
24
+ # endif
25
+ # define SYS_landlock_create_ruleset (__X32_SYSCALL_BIT + 444)
26
+ # define SYS_landlock_add_rule (__X32_SYSCALL_BIT + 445)
27
+ # define SYS_landlock_restrict_self (__X32_SYSCALL_BIT + 446)
28
+ # elif defined(__x86_64__)
18
29
  # define SYS_landlock_create_ruleset 444
19
30
  # define SYS_landlock_add_rule 445
20
31
  # define SYS_landlock_restrict_self 446
@@ -23,9 +34,9 @@
23
34
  # define SYS_landlock_add_rule 445
24
35
  # define SYS_landlock_restrict_self 446
25
36
  # elif defined(__i386__)
26
- # define SYS_landlock_create_ruleset 451
27
- # define SYS_landlock_add_rule 452
28
- # define SYS_landlock_restrict_self 453
37
+ # define SYS_landlock_create_ruleset 444
38
+ # define SYS_landlock_add_rule 445
39
+ # define SYS_landlock_restrict_self 446
29
40
  # endif
30
41
  #endif
31
42
 
@@ -97,6 +108,13 @@
97
108
  #define LANDLOCK_ACCESS_NET_CONNECT_TCP (1ULL << 1)
98
109
  #endif
99
110
 
111
+ #ifndef LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET
112
+ #define LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET (1ULL << 0)
113
+ #endif
114
+ #ifndef LANDLOCK_SCOPE_SIGNAL
115
+ #define LANDLOCK_SCOPE_SIGNAL (1ULL << 1)
116
+ #endif
117
+
100
118
  #ifndef O_PATH
101
119
  #define O_PATH 010000000
102
120
  #endif
@@ -170,16 +188,24 @@ static VALUE rb_ll_abi_version(VALUE self) {
170
188
  return LONG2NUM(abi);
171
189
  }
172
190
 
173
- static VALUE rb_ll_create_ruleset(VALUE self, VALUE fs_bits, VALUE net_bits) {
191
+ static VALUE rb_ll_create_ruleset(int argc, VALUE *argv, VALUE self) {
192
+ VALUE fs_bits, net_bits, scoped_bits;
193
+ rb_scan_args(argc, argv, "21", &fs_bits, &net_bits, &scoped_bits);
194
+
174
195
  struct rb_landlock_ruleset_attr attr;
175
196
  uint64_t handled_access_net = NUM2ULL(net_bits);
176
- size_t attr_size = handled_access_net == 0 ?
177
- offsetof(struct rb_landlock_ruleset_attr, handled_access_net) :
178
- offsetof(struct rb_landlock_ruleset_attr, scoped);
197
+ uint64_t scoped = NIL_P(scoped_bits) ? 0 : NUM2ULL(scoped_bits);
198
+ size_t attr_size = offsetof(struct rb_landlock_ruleset_attr, handled_access_net);
199
+ if (scoped != 0) {
200
+ attr_size = sizeof(struct rb_landlock_ruleset_attr);
201
+ } else if (handled_access_net != 0) {
202
+ attr_size = offsetof(struct rb_landlock_ruleset_attr, scoped);
203
+ }
179
204
 
180
205
  memset(&attr, 0, sizeof(attr));
181
206
  attr.handled_access_fs = NUM2ULL(fs_bits);
182
207
  attr.handled_access_net = handled_access_net;
208
+ attr.scoped = scoped;
183
209
 
184
210
  long fd = ll_create_ruleset(&attr, attr_size, 0);
185
211
  if (fd < 0) raise_syscall_error("landlock_create_ruleset");
@@ -187,6 +213,8 @@ static VALUE rb_ll_create_ruleset(VALUE self, VALUE fs_bits, VALUE net_bits) {
187
213
  }
188
214
 
189
215
  static VALUE rb_ll_add_path_rule(VALUE self, VALUE ruleset_fd, VALUE path, VALUE access_bits) {
216
+ int ruleset = NUM2INT(ruleset_fd);
217
+ uint64_t allowed_access = NUM2ULL(access_bits);
190
218
  Check_Type(path, T_STRING);
191
219
  const char *cpath = StringValueCStr(path);
192
220
  int parent_fd = open(cpath, O_PATH | O_CLOEXEC);
@@ -194,10 +222,10 @@ static VALUE rb_ll_add_path_rule(VALUE self, VALUE ruleset_fd, VALUE path, VALUE
194
222
 
195
223
  struct rb_landlock_path_beneath_attr rule;
196
224
  memset(&rule, 0, sizeof(rule));
197
- rule.allowed_access = NUM2ULL(access_bits);
225
+ rule.allowed_access = allowed_access;
198
226
  rule.parent_fd = parent_fd;
199
227
 
200
- long ret = ll_add_rule(NUM2INT(ruleset_fd), LANDLOCK_RULE_PATH_BENEATH, &rule, 0);
228
+ long ret = ll_add_rule(ruleset, LANDLOCK_RULE_PATH_BENEATH, &rule, 0);
201
229
  int saved_errno = errno;
202
230
  close(parent_fd);
203
231
  if (ret < 0) {
@@ -253,7 +281,7 @@ void Init_landlock(void) {
253
281
  }
254
282
 
255
283
  rb_define_singleton_method(mLandlock, "abi_version", rb_ll_abi_version, 0);
256
- rb_define_singleton_method(mLandlock, "_create_ruleset", rb_ll_create_ruleset, 2);
284
+ rb_define_singleton_method(mLandlock, "_create_ruleset", rb_ll_create_ruleset, -1);
257
285
  rb_define_singleton_method(mLandlock, "_add_path_rule", rb_ll_add_path_rule, 3);
258
286
  rb_define_singleton_method(mLandlock, "_add_net_rule", rb_ll_add_net_rule, 3);
259
287
  rb_define_singleton_method(mLandlock, "_restrict_self", rb_ll_restrict_self, 1);
@@ -277,4 +305,6 @@ void Init_landlock(void) {
277
305
  rb_define_const(mLandlock, "ACCESS_FS_IOCTL_DEV", ULL2NUM(LANDLOCK_ACCESS_FS_IOCTL_DEV));
278
306
  rb_define_const(mLandlock, "ACCESS_NET_BIND_TCP", ULL2NUM(LANDLOCK_ACCESS_NET_BIND_TCP));
279
307
  rb_define_const(mLandlock, "ACCESS_NET_CONNECT_TCP", ULL2NUM(LANDLOCK_ACCESS_NET_CONNECT_TCP));
308
+ rb_define_const(mLandlock, "SCOPE_ABSTRACT_UNIX_SOCKET", ULL2NUM(LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET));
309
+ rb_define_const(mLandlock, "SCOPE_SIGNAL", ULL2NUM(LANDLOCK_SCOPE_SIGNAL));
280
310
  }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Landlock
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
data/lib/landlock.rb CHANGED
@@ -41,12 +41,18 @@ module Landlock
41
41
  connect_tcp: ACCESS_NET_CONNECT_TCP
42
42
  }.freeze
43
43
 
44
+ SCOPE_FLAGS = {
45
+ abstract_unix_socket: SCOPE_ABSTRACT_UNIX_SOCKET,
46
+ signal: SCOPE_SIGNAL
47
+ }.freeze
48
+
44
49
  READ_RIGHTS = %i[read_file read_dir].freeze
45
50
  EXEC_RIGHTS = %i[execute read_file read_dir].freeze
46
51
  WRITE_RIGHTS = %i[
47
52
  read_file read_dir write_file truncate remove_dir remove_file make_char
48
53
  make_dir make_reg make_sock make_fifo make_block make_sym refer
49
54
  ].freeze
55
+ FILE_PATH_RIGHTS = %i[execute write_file read_file truncate ioctl_dev].freeze
50
56
 
51
57
  module_function
52
58
 
@@ -56,18 +62,19 @@ module Landlock
56
62
  false
57
63
  end
58
64
 
59
- def restrict!(read: [], write: [], execute: [], connect_tcp: [], bind_tcp: [], paths: [], allow_all_known: false)
65
+ def restrict!(read: [], write: [], execute: [], connect_tcp: [], bind_tcp: [], paths: [], scope: [], allow_all_known: false)
60
66
  abi = abi_version
61
67
  raise UnsupportedError, "Linux Landlock is unavailable" unless abi.positive?
62
68
 
63
69
  fs_handled = allow_all_known ? _fs_rights_for_abi(abi) : _handled_fs_for(read:, write:, execute:, paths:, abi:)
64
70
  net_handled = _handled_net_for(connect_tcp:, bind_tcp:, abi:)
71
+ scoped = _scope_for(scope:, abi:)
65
72
 
66
- if fs_handled.zero? && net_handled.zero?
67
- raise ArgumentError, "empty Landlock policy: provide filesystem paths or TCP ports"
73
+ if fs_handled.zero? && net_handled.zero? && scoped.zero?
74
+ raise ArgumentError, "empty Landlock policy: provide filesystem paths, TCP ports, or scopes"
68
75
  end
69
76
 
70
- fd = _create_ruleset(fs_handled, net_handled)
77
+ fd = _create_ruleset(fs_handled, net_handled, scoped)
71
78
  begin
72
79
  add_path_rules(fd, read, READ_RIGHTS, abi)
73
80
  add_path_rules(fd, execute, EXEC_RIGHTS, abi)
@@ -75,7 +82,10 @@ module Landlock
75
82
 
76
83
  paths.each do |rule|
77
84
  path, rights = normalize_path_rule(rule)
78
- _add_path_rule(fd, File.expand_path(path), mask(rights, FS_RIGHTS, abi))
85
+ access_mask = mask(rights, FS_RIGHTS, abi)
86
+ next if access_mask.zero?
87
+
88
+ _add_path_rule(fd, File.expand_path(path), access_mask)
79
89
  end
80
90
 
81
91
  add_net_rules(fd, connect_tcp, [:connect_tcp], abi)
@@ -89,20 +99,19 @@ module Landlock
89
99
  true
90
100
  end
91
101
 
92
- def exec(argv, read: [], write: [], execute: [], connect_tcp: [], bind_tcp: [], paths: [], chdir: nil, env: nil, unsetenv_others: false, close_others: true)
93
- argv = Array(argv)
94
- raise ArgumentError, "argv must not be empty" if argv.empty?
102
+ def exec(argv, read: [], write: [], execute: [], connect_tcp: [], bind_tcp: [], paths: [], scope: [], chdir: nil, env: nil, unsetenv_others: false, close_others: true, allow_all_known: false)
103
+ argv = normalize_argv(argv)
104
+ ensure_landlock_supported!
95
105
 
96
106
  pid = fork do
97
- # Safe after fork: this runs only in the child process before exec.
98
- Dir.chdir(chdir) if chdir # rubocop:disable Discourse/NoChdir
99
- restrict!(read:, write:, execute:, connect_tcp:, bind_tcp:, paths:)
100
-
101
- if env
102
- exec_env = unsetenv_others ? env : ENV.to_h.merge(env)
103
- Kernel.exec(exec_env, *argv, close_others: close_others)
104
- else
105
- Kernel.exec(*argv, close_others: close_others)
107
+ begin
108
+ # Safe after fork: this runs only in the child process before exec.
109
+ Dir.chdir(chdir) if chdir # rubocop:disable Discourse/NoChdir
110
+ restrict!(read:, write:, execute:, connect_tcp:, bind_tcp:, paths:, scope:, allow_all_known:)
111
+
112
+ Kernel.exec(*kernel_exec_args(argv, env, unsetenv_others:, close_others:))
113
+ rescue Exception => error
114
+ exit_child!(error)
106
115
  end
107
116
  end
108
117
 
@@ -110,35 +119,85 @@ module Landlock
110
119
  status
111
120
  end
112
121
 
113
- def spawn(argv, **opts)
114
- argv = Array(argv)
115
- raise ArgumentError, "argv must not be empty" if argv.empty?
122
+ def spawn(argv, read: [], write: [], execute: [], connect_tcp: [], bind_tcp: [], paths: [], scope: [], chdir: nil, env: nil, unsetenv_others: false, close_others: true, allow_all_known: false)
123
+ argv = normalize_argv(argv)
124
+ ensure_landlock_supported!
116
125
 
117
126
  fork do
118
- # Safe after fork: this runs only in the child process before exec.
119
- Dir.chdir(opts[:chdir]) if opts[:chdir] # rubocop:disable Discourse/NoChdir
120
- restrict!(
121
- read: opts.fetch(:read, []),
122
- write: opts.fetch(:write, []),
123
- execute: opts.fetch(:execute, []),
124
- connect_tcp: opts.fetch(:connect_tcp, []),
125
- bind_tcp: opts.fetch(:bind_tcp, []),
126
- paths: opts.fetch(:paths, [])
127
- )
128
- Kernel.exec(*argv, close_others: opts.fetch(:close_others, true))
127
+ begin
128
+ # Safe after fork: this runs only in the child process before exec.
129
+ Dir.chdir(chdir) if chdir # rubocop:disable Discourse/NoChdir
130
+ restrict!(read:, write:, execute:, connect_tcp:, bind_tcp:, paths:, scope:, allow_all_known:)
131
+ Kernel.exec(*kernel_exec_args(argv, env, unsetenv_others:, close_others:))
132
+ rescue Exception => error
133
+ exit_child!(error)
134
+ end
135
+ end
136
+ end
137
+
138
+ def normalize_argv(argv)
139
+ raise ArgumentError, "argv must be an Array of command arguments" unless argv.is_a?(Array)
140
+ raise ArgumentError, "argv must not be empty" if argv.empty?
141
+
142
+ argv
143
+ end
144
+ private_class_method :normalize_argv
145
+
146
+ def argv_for_exec(argv)
147
+ command = argv.fetch(0)
148
+ [[command, command], *argv.drop(1)]
149
+ end
150
+ private_class_method :argv_for_exec
151
+
152
+ def kernel_exec_args(argv, env, unsetenv_others:, close_others:)
153
+ exec_options = { close_others: close_others }
154
+ exec_options[:unsetenv_others] = true if unsetenv_others
155
+
156
+ if env
157
+ [env, *argv_for_exec(argv), exec_options]
158
+ else
159
+ [*argv_for_exec(argv), exec_options]
129
160
  end
130
161
  end
162
+ private_class_method :kernel_exec_args
163
+
164
+ def ensure_landlock_supported!
165
+ raise UnsupportedError, "Linux Landlock is unavailable" unless abi_version.positive?
166
+ end
167
+ private_class_method :ensure_landlock_supported!
168
+
169
+ def exit_child!(error)
170
+ warn "Landlock child failed before exec: #{error.class}: #{error.message}"
171
+ ensure
172
+ exit! 127
173
+ end
174
+ private_class_method :exit_child!
175
+
176
+ def path_rights(path, rights)
177
+ File.directory?(path) ? rights : Array(rights) & FILE_PATH_RIGHTS
178
+ end
179
+ private_class_method :path_rights
131
180
 
132
181
  def add_path_rules(fd, paths, rights, abi)
133
- Array(paths).each { |path| _add_path_rule(fd, File.expand_path(path), mask(rights, FS_RIGHTS, abi)) }
182
+ Array(paths).each do |path|
183
+ expanded_path = File.expand_path(path)
184
+ access_mask = mask(path_rights(expanded_path, rights), FS_RIGHTS, abi)
185
+ next if access_mask.zero?
186
+
187
+ _add_path_rule(fd, expanded_path, access_mask)
188
+ end
134
189
  end
135
190
  private_class_method :add_path_rules
136
191
 
137
192
  def add_net_rules(fd, ports, rights, abi)
138
- return if Array(ports).empty?
193
+ ports = Array(ports)
194
+ return if ports.empty?
139
195
  raise UnsupportedError, "Landlock network rules require ABI v4+; running ABI v#{abi}" if abi < 4
140
196
 
141
- Array(ports).each { |port| _add_net_rule(fd, Integer(port), mask(rights, NET_RIGHTS, abi)) }
197
+ access_mask = mask(rights, NET_RIGHTS, abi)
198
+ return if access_mask.zero?
199
+
200
+ ports.each { |port| _add_net_rule(fd, Integer(port), access_mask) }
142
201
  end
143
202
  private_class_method :add_net_rules
144
203
 
@@ -192,4 +251,13 @@ module Landlock
192
251
  bits
193
252
  end
194
253
  private_class_method :_handled_net_for
254
+
255
+ def _scope_for(scope:, abi:)
256
+ bits = mask(scope, SCOPE_FLAGS, abi)
257
+ return 0 if bits.zero?
258
+ raise UnsupportedError, "Landlock scopes require ABI v6+; running ABI v#{abi}" if abi < 6
259
+
260
+ bits
261
+ end
262
+ private_class_method :_scope_for
195
263
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: landlock
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sam Saffron
@@ -74,8 +74,10 @@ extensions:
74
74
  - ext/landlock/extconf.rb
75
75
  extra_rdoc_files: []
76
76
  files:
77
+ - CHANGELOG.md
77
78
  - LICENSE.txt
78
79
  - README.md
80
+ - benchmark/landlock_overhead.rb
79
81
  - ext/landlock/extconf.rb
80
82
  - ext/landlock/landlock.c
81
83
  - lib/landlock.rb