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 +4 -4
- data/CHANGELOG.md +34 -0
- data/README.md +69 -9
- data/benchmark/landlock_overhead.rb +212 -0
- data/ext/landlock/landlock.c +41 -11
- data/lib/landlock/version.rb +1 -1
- data/lib/landlock.rb +102 -34
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 60ae145a444fb3dd4c072fe4e5c8bac7de1836325a6d84e10be894d5b2f9ed2c
|
|
4
|
+
data.tar.gz: 983b4da291286f4c903d12cb625508f1a569de918167a2c3f1fab450fcdeb866
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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: [
|
|
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
|
|
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
|
data/ext/landlock/landlock.c
CHANGED
|
@@ -14,7 +14,18 @@
|
|
|
14
14
|
#endif
|
|
15
15
|
|
|
16
16
|
#ifndef SYS_landlock_create_ruleset
|
|
17
|
-
# if defined(
|
|
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
|
|
27
|
-
# define SYS_landlock_add_rule
|
|
28
|
-
# define SYS_landlock_restrict_self
|
|
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(
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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 =
|
|
225
|
+
rule.allowed_access = allowed_access;
|
|
198
226
|
rule.parent_fd = parent_fd;
|
|
199
227
|
|
|
200
|
-
long ret = ll_add_rule(
|
|
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,
|
|
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
|
}
|
data/lib/landlock/version.rb
CHANGED
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
|
|
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
|
-
|
|
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 =
|
|
94
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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,
|
|
114
|
-
argv =
|
|
115
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
read
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|