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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +33 -1
- data/README.md +42 -43
- data/benchmark/landlock_overhead.rb +9 -30
- data/ext/landlock/bin/safe_exec_helper.c +432 -199
- data/ext/landlock/extconf.rb +4 -1
- data/ext/landlock/landlock.c +34 -10
- data/ext/landlock/landlock_native.h +30 -29
- data/ext/landlock/seccomp_deny_network.h +176 -0
- data/lib/landlock/env.rb +31 -0
- data/lib/landlock/errors.rb +32 -0
- data/lib/landlock/execution.rb +238 -0
- data/lib/landlock/native.rb +38 -0
- data/lib/landlock/policy.rb +161 -0
- data/lib/landlock/process_io.rb +249 -0
- data/lib/landlock/result.rb +43 -0
- data/lib/landlock/rights.rb +48 -0
- data/lib/landlock/rlimits.rb +40 -0
- data/lib/landlock/runner/fork.rb +171 -0
- data/lib/landlock/runner/native.rb +225 -0
- data/lib/landlock/runner.rb +28 -0
- data/lib/landlock/validation.rb +59 -0
- data/lib/landlock/version.rb +1 -1
- data/lib/landlock.rb +25 -246
- metadata +51 -10
- data/lib/landlock/safe_exec.rb +0 -522
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0d4dc223f9ec0de53ff3ac2db1547a2bb21cee7fe670106795384c2d508395fd
|
|
4
|
+
data.tar.gz: 81db633dd067c82e68746b5bffb7db1b4a46a85984aededc03607a49f1ec5bd1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bcf9b3f9a3b369aca990a9ead22bdee7e5414baee0c73762504a10eb15fa87e56d75253f58802885621000b8e84f70d7ec5ee81b835ac1209b41476727079b81
|
|
7
|
+
data.tar.gz: 24f8ee88a25a18f5049b957878969cdb890a12f93e900bdda1402cbd1bf0ea33ec8841c96a3f6391bc216cea0730d361d4be24dac7ae19caf572f372c8c818c2
|
data/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,39 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## [0.3] - 2026-06-17
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Add top-level `Landlock.capture` and `Landlock.capture!`, direct Landlock/`exec` capture APIs with stdout/stderr capture, stdin, wall-clock timeout with process-group cleanup, output byte limits, `rlimits:`, controlled environments, `chdir:`, TCP/scoped Landlock rules, `allow_all_known:`, and optional `seccomp_deny_network:`.
|
|
12
|
+
- Expose `Landlock.seccomp_deny_network!` from the native extension so capture children can install the same deny-network seccomp filter used by the helper binary.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- Remove the legacy `Landlock::SafeExec` Ruby facade. Use `Landlock.capture`/`capture!` with argv arrays for captured subprocesses and `Landlock.exec`/`spawn` for non-capturing subprocesses.
|
|
17
|
+
- `Landlock.exec`, `Landlock.spawn`, and `Landlock.capture` now use the packaged native `landlock-safe-exec` helper when available, passing sandbox policy as helper arguments to avoid forking a large Ruby process for child setup and falling back to the Ruby fork runner if the helper argv exceeds `ARG_MAX`.
|
|
18
|
+
- Split subprocess running internals into `Landlock::Runner::Native` and `Landlock::Runner::Fork` backends, with shared validation, process I/O, rlimit, environment, and policy helpers.
|
|
19
|
+
- Normalize subprocess `env:` keys and values in Ruby before spawning and keep environment values out of native-helper argv.
|
|
20
|
+
- Require non-empty `Landlock.exec`/`spawn` policies instead of launching an unsandboxed command when no Landlock rules are provided.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Treat timeouts as failures for `capture!` even when a command handles termination and exits with an otherwise successful status.
|
|
25
|
+
- Bound post-timeout pipe draining so escaped descendants that keep stdout/stderr open cannot hang capture past the requested timeout.
|
|
26
|
+
- Harden `landlock-safe-exec` by closing inherited file descriptors, applying rlimits after sandbox setup, matching Ruby `write:` rights, tightening CLI parsing, and making the shared seccomp network-deny filter reject x32 syscall-number bypasses.
|
|
27
|
+
- Validate `Landlock.capture` filesystem policy paths before forking so missing paths raise `ArgumentError`.
|
|
28
|
+
- Reject empty `Landlock.capture` policies unless another restriction such as seccomp or rlimits is provided.
|
|
29
|
+
- Filter directory-only custom path-rule rights for file paths in `Landlock.restrict!`, matching helper behavior.
|
|
30
|
+
|
|
31
|
+
### Documentation
|
|
32
|
+
|
|
33
|
+
- Document `Landlock.capture`, its result/error types, capture options, and subprocess sandboxing guidance.
|
|
34
|
+
|
|
35
|
+
### [0.2.1] - 2026-06-16
|
|
36
|
+
|
|
37
|
+
- Build `landlock-safe-exec` without Ruby extension `$(LIBS)` to avoid unnecessary runtime library dependencies and improve SafeExec helper startup time.
|
|
38
|
+
|
|
39
|
+
## [0.2] - 2026-04-30
|
|
8
40
|
|
|
9
41
|
- Add `Landlock::SafeExec.capture`, backed by a compiled `landlock-safe-exec` helper, for subprocess capture with Landlock, optional seccomp network denial, resource limits, exact environment handling, stdin, timeout handling, process-group cleanup, result metadata, and output limits.
|
|
10
42
|
- Share native Landlock syscall/constant definitions between the Ruby extension and helper binary.
|
data/README.md
CHANGED
|
@@ -19,7 +19,7 @@ See [CHANGELOG.md](CHANGELOG.md) for release notes.
|
|
|
19
19
|
|
|
20
20
|
## Safe subprocess execution
|
|
21
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:`
|
|
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 require a non-empty Landlock policy, accept `env:`/`unsetenv_others:` for controlled environments, and use the packaged native helper when available so the long-lived child is not a forked Ruby process.
|
|
23
23
|
|
|
24
24
|
Allow Ruby to execute and read its runtime, but only allow outbound TCP connections to port 443:
|
|
25
25
|
|
|
@@ -66,23 +66,17 @@ Landlock.exec(
|
|
|
66
66
|
)
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
-
##
|
|
69
|
+
## Capturing subprocess output
|
|
70
70
|
|
|
71
|
-
`Landlock
|
|
72
|
-
|
|
73
|
-
For example, inspect an uploaded video with `ffprobe` while only allowing reads from the upload and system runtime paths, denying network access, and bounding CPU/output:
|
|
71
|
+
`Landlock.capture` is the stdout/stderr-capturing sibling of `Landlock.exec`: it launches a child process, applies Landlock rules, resource limits, and the optional seccomp network-deny filter before the target command starts, then execs that command directly. When the packaged `landlock-safe-exec` helper is available, `exec`, `spawn`, and `capture` all spawn that small native helper with policy arguments so the parent does not need to fork a bloated Ruby process; they fall back to the Ruby fork path when the helper cannot be used or when an unusually large helper argv would exceed the platform `ARG_MAX`. Environment changes are applied by Ruby when spawning the helper rather than encoded in helper argv. Use `capture!` when unsuccessful exit statuses should raise.
|
|
74
72
|
|
|
75
73
|
```ruby
|
|
76
|
-
result = Landlock
|
|
77
|
-
"ffprobe",
|
|
78
|
-
"
|
|
79
|
-
"
|
|
80
|
-
"-show_streams",
|
|
81
|
-
"-of", "json",
|
|
82
|
-
upload_path,
|
|
83
|
-
read: [upload_path, *Landlock::SafeExec.default_read_paths],
|
|
84
|
-
execute: Landlock::SafeExec.default_execute_paths,
|
|
74
|
+
result = Landlock.capture(
|
|
75
|
+
["ffprobe", "-v", "error", "-show_format", "-of", "json", upload_path],
|
|
76
|
+
read: [upload_path, "/usr", "/lib", "/lib64", "/etc"].select { |path| File.exist?(path) },
|
|
77
|
+
execute: ["/usr", "/lib", "/lib64"].select { |path| File.exist?(path) },
|
|
85
78
|
env: { "PATH" => ENV.fetch("PATH", "") },
|
|
79
|
+
unsetenv_others: true,
|
|
86
80
|
rlimits: {
|
|
87
81
|
cpu_seconds: 5,
|
|
88
82
|
memory_bytes: 512 * 1024 * 1024,
|
|
@@ -98,45 +92,46 @@ result = Landlock::SafeExec.capture(
|
|
|
98
92
|
metadata = JSON.parse(result.stdout) if result.success?
|
|
99
93
|
```
|
|
100
94
|
|
|
101
|
-
|
|
95
|
+
`Landlock.capture` takes the command as a single argv array, like `Landlock.exec`. It returns a `Landlock::CaptureResult` with `stdout`, `stderr`, `status`, `success?`, `timed_out?`, and `output_truncated?`, including for unsuccessful exit statuses. It also supports array destructuring:
|
|
102
96
|
|
|
103
97
|
```ruby
|
|
104
|
-
stdout, stderr, status = Landlock
|
|
105
|
-
"
|
|
106
|
-
|
|
107
|
-
|
|
98
|
+
stdout, stderr, status = Landlock.capture(
|
|
99
|
+
["tool", "arg"],
|
|
100
|
+
read: ["/usr", "/lib", "/lib64", "/etc"].select { |path| File.exist?(path) },
|
|
101
|
+
execute: ["/usr", "/lib", "/lib64"].select { |path| File.exist?(path) }
|
|
108
102
|
)
|
|
109
103
|
```
|
|
110
104
|
|
|
111
|
-
`capture
|
|
105
|
+
`Landlock.capture!` has the same return shape for successful commands, but raises `Landlock::CommandError` for unsuccessful statuses. The error also exposes `stdout`, `stderr`, `status`, and `result`.
|
|
106
|
+
|
|
107
|
+
`Landlock.capture` requires an actual restriction: provide Landlock rules, `seccomp_deny_network: true`, or `rlimits:`. This avoids accidentally running a command completely unsandboxed when a dynamically built policy is empty. It also requires Linux Landlock support and raises `Landlock::UnsupportedError` when unavailable; it does not fall back to running the command unsandboxed.
|
|
108
|
+
|
|
109
|
+
Pass `stdin:` when a tool should read from standard input instead of a file:
|
|
112
110
|
|
|
113
111
|
```ruby
|
|
114
|
-
stdout, stderr, status = Landlock
|
|
112
|
+
stdout, stderr, status = Landlock.capture(
|
|
113
|
+
["tr", "a-z", "A-Z"],
|
|
114
|
+
stdin: "hello",
|
|
115
|
+
rlimits: { open_files: 64 }
|
|
116
|
+
)
|
|
115
117
|
```
|
|
116
118
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
SafeExec options:
|
|
119
|
+
Capture options:
|
|
120
120
|
|
|
121
121
|
- `read:`, `write:`, `execute:` — filesystem allowlists. Explicit paths must exist; missing paths raise `ArgumentError` instead of being silently ignored.
|
|
122
|
-
- `
|
|
123
|
-
- `bind_tcp:` — allowed TCP
|
|
122
|
+
- `paths:` — exact path rules with explicit Landlock rights, e.g. `{ path:, rights: %i[read_file] }`.
|
|
123
|
+
- `connect_tcp:` and `bind_tcp:` — allowed TCP ports. TCP access is unrestricted unless a network rule is provided.
|
|
124
|
+
- `scope:` — Landlock ABI v6+ scopes such as `:signal` and `:abstract_unix_socket`.
|
|
124
125
|
- `seccomp_deny_network:` — additionally deny common Linux network syscalls with seccomp. This is Linux-specific and intended as defense in depth.
|
|
125
126
|
- `rlimits:` — resource limits. Supported keys are `:cpu_seconds`, `:memory_bytes`, `:file_size_bytes`, `:open_files`, and `:processes`. Values must be non-negative integers.
|
|
126
|
-
- `timeout:` — wall-clock timeout in seconds. On timeout
|
|
127
|
-
- `max_output_bytes:` — combined stdout+stderr byte limit. With `truncate_output: false`, exceeding the limit raises. With `truncate_output: true`, output is truncated and `result.output_truncated?` is true.
|
|
127
|
+
- `timeout:` — wall-clock timeout in seconds. On timeout capture terminates the process group and returns/raises with `result.timed_out?` true.
|
|
128
|
+
- `max_output_bytes:` — combined stdout+stderr byte limit. With `truncate_output: false`, exceeding the limit raises `Landlock::CommandError` with the partial output captured before termination. With `truncate_output: true`, output is truncated and `result.output_truncated?` is true.
|
|
128
129
|
- `stdin:` — string or IO-like object to write to the child process stdin.
|
|
129
130
|
- `chdir:` — working directory for the child.
|
|
130
|
-
- `env:` —
|
|
131
|
-
- `
|
|
132
|
-
- `success_status_codes:`
|
|
133
|
-
- `allow_all_known:` — when filesystem rules are present, handle all Landlock filesystem rights known to the running ABI so unlisted filesystem access is denied.
|
|
134
|
-
|
|
135
|
-
SafeExec uses an exact environment by default: `env:` is the full environment passed to the child, not additions to the parent environment. Use `inherit_env: true` when a command really needs the parent environment plus the supplied `env:` overrides.
|
|
136
|
-
|
|
137
|
-
Use `Landlock::SafeExec.supported?` (or `sandboxing?`) to check whether the Linux helper and Landlock are available. When this is false, SafeExec still runs commands in pass-through mode but does not enforce Landlock/seccomp sandbox options.
|
|
138
|
-
|
|
139
|
-
On non-Linux platforms, or when the compiled helper is unavailable, SafeExec runs as a pass-through compatibility wrapper. Process-management features such as capture, timeout, environment handling, `chdir:`, output limits, `stdin:`, and supported `rlimits:` still apply, but Landlock and seccomp options (`read:`, `write:`, `execute:`, `connect_tcp:`, `bind_tcp:`, `seccomp_deny_network:`) are ignored and a warning is emitted. This makes cross-platform integration easier while keeping the security guarantees explicit: sandboxing is Linux-only.
|
|
131
|
+
- `env:` — environment entries for the child.
|
|
132
|
+
- `unsetenv_others:` — clear the parent environment before applying `env:`.
|
|
133
|
+
- `success_status_codes:` and `failure_message:` — `capture!` failure handling options.
|
|
134
|
+
- `allow_all_known:` — when filesystem rules are present, handle all Landlock filesystem rights known to the running ABI so unlisted filesystem access is denied.
|
|
140
135
|
|
|
141
136
|
## Restrict current process
|
|
142
137
|
|
|
@@ -202,12 +197,16 @@ Treat small positive or negative deltas as noise and benchmark on the kernel, fi
|
|
|
202
197
|
|
|
203
198
|
## Caveats
|
|
204
199
|
|
|
205
|
-
Landlock is not a complete container. It does not
|
|
200
|
+
Landlock is not a complete container. It restricts selected kernel-mediated actions for the current thread and its future descendants, but it does not create namespaces, hide process IDs, virtualize the filesystem, or isolate the process from every kernel interface. For serious untrusted execution, combine Landlock with a controlled environment, resource limits, seccomp, and process isolation appropriate to your threat model.
|
|
201
|
+
|
|
202
|
+
`Landlock.restrict!` only installs a Landlock ruleset. It does not close already-open file descriptors, impose resource limits, clean the environment, or kill subprocess trees. The subprocess helpers add practical hardening around this: `exec`/`spawn` add controlled environments and `close_others`, while `capture` also adds optional `rlimits:`, optional `seccomp_deny_network:`, output limits, timeout handling, and process-group termination. This is still not a VM/container boundary. By default, subprocess helpers close inherited file descriptors numbered 3 and higher before installing the sandbox; pass `close_others: false` only when the child intentionally needs inherited descriptors. Direct `landlock-safe-exec` use also closes inherited descriptors by default.
|
|
203
|
+
|
|
204
|
+
When the native helper is used, sandbox policy details such as allowed paths, TCP ports, scopes, rights, and rlimits are passed as helper argv. They may be visible to same-user processes through tools such as `ps` or `/proc/<pid>/cmdline` until the helper execs the target command. Environment values passed with `env:` are not encoded in helper argv, but do not put secrets in policy path names or other policy arguments.
|
|
206
205
|
|
|
207
|
-
If
|
|
206
|
+
If `Landlock.exec`, `Landlock.spawn`, or `Landlock.capture` child setup fails before `exec`, the child prints a diagnostic and exits 127. `landlock-safe-exec` setup/argument failures exit 126. These codes can collide with commands that legitimately exit with the same status, so inspect stderr when debugging failures.
|
|
208
207
|
|
|
209
|
-
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.
|
|
208
|
+
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. Capture APIs validate explicit `read:`, `write:`, and `execute:` paths before launching so typos fail closed instead of silently weakening a policy.
|
|
210
209
|
|
|
211
|
-
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.
|
|
210
|
+
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. Landlock TCP rules do not cover UDP or pathname Unix-domain sockets; ABI v6+ scopes can restrict signals and abstract Unix-domain sockets. `seccomp_deny_network:` is Linux-specific defense in depth for common network syscalls, not a general-purpose seccomp policy language.
|
|
212
211
|
|
|
213
|
-
`Landlock.restrict!` applies to the calling thread and its future children; already-running sibling threads are not retroactively sandboxed. Prefer `Landlock.exec
|
|
212
|
+
`Landlock.restrict!` applies to the calling thread and its future children; already-running sibling threads are not retroactively sandboxed. Prefer `Landlock.capture`, `Landlock.exec`, or `Landlock.spawn` for subprocess sandboxing from a larger Ruby application.
|
|
@@ -71,9 +71,7 @@ module LandlockBench
|
|
|
71
71
|
|
|
72
72
|
def run_child(payload)
|
|
73
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
|
|
74
|
+
abort "bench child failed (#{status.exitstatus})\nSTDOUT:\n#{stdout}\nSTDERR:\n#{stderr}" unless status.success?
|
|
77
75
|
|
|
78
76
|
JSON.parse(stdout)
|
|
79
77
|
end
|
|
@@ -91,8 +89,8 @@ module LandlockBench
|
|
|
91
89
|
mode: "workloads",
|
|
92
90
|
iterations: DEFAULT_ITERATIONS,
|
|
93
91
|
dir_iterations: DIR_ITERATIONS,
|
|
94
|
-
read_paths
|
|
95
|
-
workspace:
|
|
92
|
+
read_paths:,
|
|
93
|
+
workspace:
|
|
96
94
|
}
|
|
97
95
|
|
|
98
96
|
baseline = collect_samples(DEFAULT_SAMPLES) { run_child(common.merge(sandbox: false)) }
|
|
@@ -104,9 +102,7 @@ module LandlockBench
|
|
|
104
102
|
end
|
|
105
103
|
|
|
106
104
|
sandbox = collect_samples(DEFAULT_SAMPLES) { run_child(common.merge(sandbox: true)) }
|
|
107
|
-
setup = collect_samples(SETUP_SAMPLES)
|
|
108
|
-
run_child(mode: "setup", read_paths: read_paths).fetch("setup_ns")
|
|
109
|
-
end
|
|
105
|
+
setup = collect_samples(SETUP_SAMPLES) { run_child(mode: "setup", read_paths:).fetch("setup_ns") }
|
|
110
106
|
|
|
111
107
|
print_workload_table(baseline, sandbox)
|
|
112
108
|
puts
|
|
@@ -132,14 +128,7 @@ module LandlockBench
|
|
|
132
128
|
locked = median(sandbox.map { |sample| sample.fetch(name) })
|
|
133
129
|
delta = locked - base
|
|
134
130
|
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
|
-
)
|
|
131
|
+
puts format("%-12s %14s %14s %12s %9.2f%%", name, format_ms(base), format_ms(locked), format_ms(delta), pct)
|
|
143
132
|
else
|
|
144
133
|
puts format("%-12s %14s %14s %12s %10s", name, format_ms(base), "n/a", "n/a", "n/a")
|
|
145
134
|
end
|
|
@@ -185,20 +174,10 @@ module LandlockBench
|
|
|
185
174
|
|
|
186
175
|
GC.disable
|
|
187
176
|
{
|
|
188
|
-
"cpu_loop" => measure
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
"
|
|
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
|
|
177
|
+
"cpu_loop" => measure { iterations.times { |index| sink ^= ((index * 31) & 0xffff) } },
|
|
178
|
+
"file_stat" => measure { iterations.times { sink ^= File.stat(file).size } },
|
|
179
|
+
"file_read" => measure { iterations.times { sink ^= File.binread(file).bytesize } },
|
|
180
|
+
"dir_scan" => measure { dir_iterations.times { Dir.foreach(entries) { |entry| sink ^= entry.bytesize } } }
|
|
202
181
|
}.merge("sink" => sink)
|
|
203
182
|
ensure
|
|
204
183
|
GC.enable
|