saiph 0.1.0
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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +114 -0
- data/docs/adr/000-template.md +17 -0
- data/docs/adr/001-require-complete-native-enforcement.md +34 -0
- data/docs/adr/README.md +5 -0
- data/lib/saiph/backend.rb +56 -0
- data/lib/saiph/backends/linux.rb +239 -0
- data/lib/saiph/backends/macos.rb +100 -0
- data/lib/saiph/backends/windows.rb +176 -0
- data/lib/saiph/runner.rb +31 -0
- data/lib/saiph/version.rb +5 -0
- data/lib/saiph.rb +134 -0
- data/sig/saiph.rbs +25 -0
- metadata +71 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 94fa9d2587e5b09e6b37300f5be444effbbed9c97945be0f64e9f5ae89a6cb11
|
|
4
|
+
data.tar.gz: 3a486c04f59ad325635bd3c160b44eb5563d13c9daa1457ada03864368c3537b
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 14e0a18f41563078f57ca175496d1a7c2a63024bcba1437d782ec641092f19ff68e11fcdaf66e38de9fac50cb7c63cc127412767d4e3a2adaf8e737a7bb8ea70
|
|
7
|
+
data.tar.gz: 7ace16f008e4a5b08cba397870ad1d8e46dc8efc6af2b9a960bb7660cd58cf123d31112ec00586dfaa4dfbefa786f92efa0e210ae3282d8c8b60c13772ae7705
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Saiph
|
|
2
|
+
|
|
3
|
+
Saiph applies native operating-system restrictions to child processes. It is a
|
|
4
|
+
small, Pure Ruby boundary for running untrusted plugins without depending on an
|
|
5
|
+
editor or plugin API.
|
|
6
|
+
|
|
7
|
+
Saiph fails closed. `available?` is true only after the native backend passes a
|
|
8
|
+
local capability probe, and a child exits with status 126 if setup fails. Saiph
|
|
9
|
+
never substitutes an ordinary unsandboxed process.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
bundle add saiph
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Saiph supports Ruby 3.1 and later.
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
require "saiph"
|
|
23
|
+
|
|
24
|
+
unless Saiph.available?
|
|
25
|
+
warn "OS sandbox unavailable"
|
|
26
|
+
return
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
policy = Saiph::Policy.new(
|
|
30
|
+
read_paths: [File.expand_path("plugin.rb", __dir__)],
|
|
31
|
+
write_paths: [File.expand_path("tmp", __dir__)],
|
|
32
|
+
network: false,
|
|
33
|
+
exec: false,
|
|
34
|
+
env: ["LANG"]
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
pid = Saiph.spawn(
|
|
38
|
+
[RbConfig.ruby, File.expand_path("plugin.rb", __dir__)],
|
|
39
|
+
policy: policy,
|
|
40
|
+
env: {"LANG" => "en_US.UTF-8"},
|
|
41
|
+
out: $stdout,
|
|
42
|
+
err: $stderr
|
|
43
|
+
)
|
|
44
|
+
status = Process.waitpid2(pid).last
|
|
45
|
+
raise "sandbox setup failed" if status.exitstatus == 126
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`spawn` returns a process ID and accepts normal `Process.spawn` redirections
|
|
49
|
+
and options. Pass the command as an argument array. A string is treated as one
|
|
50
|
+
executable name; Saiph never invokes a shell.
|
|
51
|
+
|
|
52
|
+
`Policy` fields have these meanings:
|
|
53
|
+
|
|
54
|
+
- `read_paths`: existing absolute files or directory trees the child may read.
|
|
55
|
+
- `write_paths`: existing absolute files or directory trees the child may read
|
|
56
|
+
and modify.
|
|
57
|
+
- `network`: whether network access is allowed.
|
|
58
|
+
- `exec`: whether the child may create descendant processes.
|
|
59
|
+
- `env`: names of environment variables visible to the child. Values may be
|
|
60
|
+
overridden with `spawn(..., env: {...})`; undeclared names are rejected.
|
|
61
|
+
|
|
62
|
+
The launcher and system dynamic libraries need a small implicit read-only
|
|
63
|
+
runtime allowance. On Unix this covers the selected executable, the active
|
|
64
|
+
Ruby installation, and system library directories. It does not include the
|
|
65
|
+
home directory or temporary directories.
|
|
66
|
+
|
|
67
|
+
`Saiph.apply!(policy)` restricts the current process and is irreversible. Use
|
|
68
|
+
`spawn` unless the current process was created solely to become a sandbox.
|
|
69
|
+
|
|
70
|
+
## Backends
|
|
71
|
+
|
|
72
|
+
| Platform | `backend` | Enforcement |
|
|
73
|
+
| --- | --- | --- |
|
|
74
|
+
| macOS | `:seatbelt` | `sandbox_init` profile with deny-by-default file, network, and process rules |
|
|
75
|
+
| Linux | `:seccomp` | user/mount/network namespaces, Landlock filesystem rules, and seccomp-BPF |
|
|
76
|
+
| Windows | `:appcontainer` | ephemeral AppContainer plus a Job Object process limit |
|
|
77
|
+
|
|
78
|
+
Linux reports unavailable unless namespaces, Landlock, and seccomp all work in
|
|
79
|
+
the current environment. This is common in restricted CI containers and is an
|
|
80
|
+
expected reason to skip integration tests.
|
|
81
|
+
|
|
82
|
+
Windows 0.1 supports a no-filesystem-grant, no-network policy. Non-empty path
|
|
83
|
+
grants and `network: true` raise `Saiph::Unsupported` because safely changing
|
|
84
|
+
host ACLs requires application-owned provisioning. `apply!` is also unsupported
|
|
85
|
+
on Windows; AppContainer tokens must be selected at process creation.
|
|
86
|
+
|
|
87
|
+
## Security and cleanup
|
|
88
|
+
|
|
89
|
+
- Paths are resolved before launch, so a symlink cannot retarget an allowance.
|
|
90
|
+
- The child environment is rebuilt from its allowlist.
|
|
91
|
+
- Policy transfer uses a private inherited pipe, not a temporary file.
|
|
92
|
+
- Backend probes run in disposable children because Unix restrictions cannot be
|
|
93
|
+
relaxed.
|
|
94
|
+
- Linux blocks signal delivery to host processes in addition to ptrace and
|
|
95
|
+
cross-process memory access.
|
|
96
|
+
- AppContainer profiles, native handles, pipes, and Landlock descriptors are
|
|
97
|
+
closed on both success and failure.
|
|
98
|
+
|
|
99
|
+
Saiph reduces OS capabilities; it does not validate plugin code or replace
|
|
100
|
+
application-level authentication and authorization.
|
|
101
|
+
|
|
102
|
+
## Development
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
bundle install
|
|
106
|
+
bundle exec rake test
|
|
107
|
+
bundle exec rbs -I sig validate
|
|
108
|
+
BUDGET=1 bundle exec rake bench
|
|
109
|
+
gem build --strict saiph.gemspec
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
MIT.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# ADR NNN: Implementation decision title
|
|
2
|
+
|
|
3
|
+
- Status: Proposed
|
|
4
|
+
- Date: YYYY-MM-DD
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Describe the concrete implementation question and its compatibility, runtime,
|
|
9
|
+
or security constraints.
|
|
10
|
+
|
|
11
|
+
## Decision
|
|
12
|
+
|
|
13
|
+
Describe the durable boundary or architecture choice.
|
|
14
|
+
|
|
15
|
+
## Consequences
|
|
16
|
+
|
|
17
|
+
Describe the important trade-offs and when to revisit the decision.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# ADR 001: Require complete native enforcement
|
|
2
|
+
|
|
3
|
+
- Status: Accepted
|
|
4
|
+
- Date: 2026-09-15
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
A platform may expose only part of the mechanisms needed for a process sandbox.
|
|
9
|
+
For example, seccomp reduces Linux system calls but does not restrict pathnames,
|
|
10
|
+
and a namespace may be disabled inside a container. Treating one partial
|
|
11
|
+
mechanism as a sandbox would give callers a false security guarantee.
|
|
12
|
+
|
|
13
|
+
## Decision
|
|
14
|
+
|
|
15
|
+
Saiph reports a backend as available only after its required native mechanisms
|
|
16
|
+
can be activated in a disposable process. Linux requires namespaces, Landlock,
|
|
17
|
+
and seccomp together. macOS requires a compiled deny-by-default Seatbelt
|
|
18
|
+
profile. Windows requires successful creation and launch of an AppContainer.
|
|
19
|
+
|
|
20
|
+
Policy setup happens before user code. Any setup error terminates the launcher
|
|
21
|
+
with status 126. No backend falls back to `Process.spawn` without restrictions.
|
|
22
|
+
The implementation uses Ruby's standard library and native OS APIs through
|
|
23
|
+
Fiddle; it does not add a privileged helper or native extension.
|
|
24
|
+
|
|
25
|
+
## Consequences
|
|
26
|
+
|
|
27
|
+
Saiph can be unavailable in otherwise supported operating systems, especially
|
|
28
|
+
inside CI containers. Callers must surface that state or choose their own
|
|
29
|
+
explicit fallback. Supporting more Linux hosts may later justify an audited
|
|
30
|
+
external helper, but it must preserve the same fail-closed contract.
|
|
31
|
+
|
|
32
|
+
Windows filesystem grants remain unsupported until the embedding application
|
|
33
|
+
can provision and restore ACLs it owns. Saiph rejects those policies instead of
|
|
34
|
+
silently broadening access.
|
data/docs/adr/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Saiph
|
|
4
|
+
module Backends
|
|
5
|
+
module Spawn
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def call(backend, command, policy:, **options)
|
|
9
|
+
overrides = options.delete(:env) || {}
|
|
10
|
+
raise ArgumentError, "env must be a hash" unless overrides.is_a?(Hash)
|
|
11
|
+
|
|
12
|
+
overrides = overrides.to_h { |key, value| [String(key), value.nil? ? nil : String(value)] }
|
|
13
|
+
denied = overrides.keys - policy.env
|
|
14
|
+
raise ArgumentError, "environment variable is not allowed: #{denied.first}" unless denied.empty?
|
|
15
|
+
|
|
16
|
+
environment = policy.env.to_h { |name| [name, overrides.fetch(name, ENV[name])] }
|
|
17
|
+
reader, writer = IO.pipe
|
|
18
|
+
fd = 3
|
|
19
|
+
environment["SAIPH_PAYLOAD_FD"] = fd.to_s
|
|
20
|
+
environment["SAIPH_BACKEND"] = backend.name.to_s
|
|
21
|
+
payload = JSON.generate(
|
|
22
|
+
"command" => command,
|
|
23
|
+
"policy" => {
|
|
24
|
+
"read_paths" => policy.read_paths,
|
|
25
|
+
"write_paths" => policy.write_paths,
|
|
26
|
+
"network" => policy.network,
|
|
27
|
+
"exec" => policy.exec,
|
|
28
|
+
"env" => policy.env
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
redirects = options.merge(fd => reader, close_others: true, unsetenv_others: true)
|
|
32
|
+
pid = Process.spawn(environment, RbConfig.ruby, "-I", File.expand_path("..", __dir__), "-rsaiph/runner", "-e", "Saiph::Runner.run", redirects)
|
|
33
|
+
reader.close
|
|
34
|
+
writer.write(payload)
|
|
35
|
+
writer.close
|
|
36
|
+
pid
|
|
37
|
+
rescue Exception
|
|
38
|
+
if pid
|
|
39
|
+
begin
|
|
40
|
+
Process.kill("TERM", pid)
|
|
41
|
+
rescue Errno::ESRCH
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
begin
|
|
45
|
+
Process.wait(pid)
|
|
46
|
+
rescue Errno::ECHILD
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
reader&.close unless reader&.closed?
|
|
51
|
+
writer&.close unless writer&.closed?
|
|
52
|
+
raise
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fiddle"
|
|
4
|
+
|
|
5
|
+
require_relative "../backend"
|
|
6
|
+
|
|
7
|
+
module Saiph
|
|
8
|
+
module Backends
|
|
9
|
+
module Linux
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
CLONE_NEWNS = 0x00020000
|
|
13
|
+
CLONE_NEWUSER = 0x10000000
|
|
14
|
+
CLONE_NEWNET = 0x40000000
|
|
15
|
+
MS_REC = 0x4000
|
|
16
|
+
MS_PRIVATE = 1 << 18
|
|
17
|
+
PR_SET_SECCOMP = 22
|
|
18
|
+
PR_SET_NO_NEW_PRIVS = 38
|
|
19
|
+
SECCOMP_MODE_FILTER = 2
|
|
20
|
+
LANDLOCK_CREATE_RULESET_VERSION = 1
|
|
21
|
+
LANDLOCK_RULE_PATH_BENEATH = 1
|
|
22
|
+
O_PATH = 0x200000
|
|
23
|
+
O_CLOEXEC = 0x80000
|
|
24
|
+
|
|
25
|
+
FS_EXECUTE = 1 << 0
|
|
26
|
+
FS_WRITE_FILE = 1 << 1
|
|
27
|
+
FS_READ_FILE = 1 << 2
|
|
28
|
+
FS_READ_DIR = 1 << 3
|
|
29
|
+
FS_REMOVE_DIR = 1 << 4
|
|
30
|
+
FS_REMOVE_FILE = 1 << 5
|
|
31
|
+
FS_MAKE_CHAR = 1 << 6
|
|
32
|
+
FS_MAKE_DIR = 1 << 7
|
|
33
|
+
FS_MAKE_REG = 1 << 8
|
|
34
|
+
FS_MAKE_SOCK = 1 << 9
|
|
35
|
+
FS_MAKE_FIFO = 1 << 10
|
|
36
|
+
FS_MAKE_BLOCK = 1 << 11
|
|
37
|
+
FS_MAKE_SYM = 1 << 12
|
|
38
|
+
FS_REFER = 1 << 13
|
|
39
|
+
FS_TRUNCATE = 1 << 14
|
|
40
|
+
FS_IOCTL_DEV = 1 << 15
|
|
41
|
+
FS_BASE = (1 << 13) - 1
|
|
42
|
+
FS_WRITE = FS_WRITE_FILE | FS_REMOVE_DIR | FS_REMOVE_FILE | FS_MAKE_DIR |
|
|
43
|
+
FS_MAKE_REG | FS_MAKE_SOCK | FS_MAKE_FIFO | FS_MAKE_SYM
|
|
44
|
+
|
|
45
|
+
BPF_LD_W_ABS = 0x20
|
|
46
|
+
BPF_JMP_JEQ_K = 0x15
|
|
47
|
+
BPF_JMP_JSET_K = 0x45
|
|
48
|
+
BPF_RET_K = 0x06
|
|
49
|
+
SECCOMP_RET_KILL_PROCESS = 0x80000000
|
|
50
|
+
SECCOMP_RET_ERRNO = 0x00050000
|
|
51
|
+
SECCOMP_RET_ALLOW = 0x7fff0000
|
|
52
|
+
EPERM = 1
|
|
53
|
+
CLONE_THREAD = 0x00010000
|
|
54
|
+
|
|
55
|
+
SYSTEM_READ_PATHS = ["/lib", "/lib64", "/usr/lib", "/etc/ld.so.cache", RbConfig::CONFIG["prefix"]].freeze
|
|
56
|
+
AUDIT_ARCH = {"x86_64" => 0xc000003e, "aarch64" => 0xc00000b7, "arm64" => 0xc00000b7}.freeze
|
|
57
|
+
SYSCALLS = {
|
|
58
|
+
"x86_64" => {
|
|
59
|
+
clone: 56, fork: 57, vfork: 58, clone3: 435, ptrace: 101, bpf: 321, perf_event_open: 298,
|
|
60
|
+
userfaultfd: 323, kexec_load: 246, finit_module: 313, init_module: 175, delete_module: 176,
|
|
61
|
+
reboot: 169, swapon: 167, swapoff: 168, mount: 165, umount2: 166, pivot_root: 155,
|
|
62
|
+
setns: 308, unshare: 272, keyctl: 250, add_key: 248, request_key: 249,
|
|
63
|
+
process_vm_readv: 310, process_vm_writev: 311, open_by_handle_at: 304, kill: 62, tkill: 200,
|
|
64
|
+
tgkill: 234, rt_sigqueueinfo: 129, rt_tgsigqueueinfo: 297, pidfd_send_signal: 424,
|
|
65
|
+
socket: 41, connect: 42, accept: 43, sendto: 44, recvfrom: 45, sendmsg: 46, recvmsg: 47,
|
|
66
|
+
bind: 49, listen: 50, socketpair: 53, accept4: 288, recvmmsg: 299, sendmmsg: 307
|
|
67
|
+
},
|
|
68
|
+
"aarch64" => {
|
|
69
|
+
clone: 220, clone3: 435, ptrace: 117, bpf: 280, perf_event_open: 241, userfaultfd: 282,
|
|
70
|
+
kexec_load: 104, finit_module: 273, init_module: 105, delete_module: 106, reboot: 142,
|
|
71
|
+
swapon: 224, swapoff: 225, mount: 40, umount2: 39, pivot_root: 41, setns: 268,
|
|
72
|
+
unshare: 97, keyctl: 219, add_key: 217, request_key: 218, process_vm_readv: 270,
|
|
73
|
+
process_vm_writev: 271, open_by_handle_at: 265, kill: 129, tkill: 130, tgkill: 131,
|
|
74
|
+
rt_sigqueueinfo: 138, rt_tgsigqueueinfo: 240, pidfd_send_signal: 424, socket: 198,
|
|
75
|
+
socketpair: 199, bind: 200, listen: 201, accept: 202, connect: 203, sendto: 206,
|
|
76
|
+
recvfrom: 207, sendmsg: 211, recvmsg: 212, accept4: 242, recvmmsg: 243, sendmmsg: 269
|
|
77
|
+
}
|
|
78
|
+
}.freeze
|
|
79
|
+
|
|
80
|
+
def name = :seccomp
|
|
81
|
+
|
|
82
|
+
def available?
|
|
83
|
+
return @available if defined?(@available_pid) && @available_pid == Process.pid
|
|
84
|
+
|
|
85
|
+
@available_pid = Process.pid
|
|
86
|
+
@available = supported_architecture? && landlock_abi.positive? && probe
|
|
87
|
+
rescue StandardError, LoadError
|
|
88
|
+
@available = false
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def spawn(command, policy:, **options) = Spawn.call(self, command, policy: policy, **options)
|
|
92
|
+
|
|
93
|
+
def apply!(policy, command: nil)
|
|
94
|
+
raise Unsupported, "Linux sandbox requires x86-64 or AArch64" unless supported_architecture?
|
|
95
|
+
|
|
96
|
+
setup_namespaces(network: policy.network)
|
|
97
|
+
apply_landlock(policy, command)
|
|
98
|
+
apply_seccomp(policy)
|
|
99
|
+
true
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def setup_namespaces(network:)
|
|
103
|
+
flags = CLONE_NEWNS | (network ? 0 : CLONE_NEWNET)
|
|
104
|
+
original_uid, original_gid = Process.uid, Process.gid
|
|
105
|
+
if unshare.call(flags | CLONE_NEWUSER).zero?
|
|
106
|
+
File.write("/proc/self/setgroups", "deny") if File.exist?("/proc/self/setgroups")
|
|
107
|
+
File.write("/proc/self/uid_map", "0 #{original_uid} 1")
|
|
108
|
+
File.write("/proc/self/gid_map", "0 #{original_gid} 1")
|
|
109
|
+
elsif unshare.call(flags) != 0
|
|
110
|
+
system_error("unshare")
|
|
111
|
+
end
|
|
112
|
+
system_error("mount private namespace") unless mount.call(0, "/", 0, MS_REC | MS_PRIVATE, 0).zero?
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def apply_landlock(policy, command)
|
|
116
|
+
abi = landlock_abi
|
|
117
|
+
handled = FS_BASE
|
|
118
|
+
handled |= FS_REFER if abi >= 2
|
|
119
|
+
handled |= FS_TRUNCATE if abi >= 3
|
|
120
|
+
handled |= FS_IOCTL_DEV if abi >= 5
|
|
121
|
+
ruleset = landlock_create(Fiddle::Pointer[[handled].pack("Q<")], 8, 0)
|
|
122
|
+
system_error("landlock_create_ruleset") if ruleset.negative?
|
|
123
|
+
|
|
124
|
+
begin
|
|
125
|
+
rules = {}
|
|
126
|
+
SYSTEM_READ_PATHS.each { |path| merge_rule(rules, path, read_rights(path) | FS_EXECUTE) }
|
|
127
|
+
policy.read_paths.each { |path| merge_rule(rules, path, read_rights(path)) }
|
|
128
|
+
policy.write_paths.each { |path| merge_rule(rules, path, read_rights(path) | write_rights(path, abi)) }
|
|
129
|
+
policy.read_paths.each { |path| merge_rule(rules, path, FS_EXECUTE) } if policy.exec
|
|
130
|
+
merge_rule(rules, command.first, FS_EXECUTE | FS_READ_FILE) if command
|
|
131
|
+
rules.each { |path, rights| add_landlock_rule(ruleset, path, rights & handled) }
|
|
132
|
+
|
|
133
|
+
system_error("prctl(PR_SET_NO_NEW_PRIVS)") unless prctl.call(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0).zero?
|
|
134
|
+
system_error("landlock_restrict_self") unless landlock_restrict(ruleset, 0).zero?
|
|
135
|
+
ensure
|
|
136
|
+
IO.new(ruleset).close
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def merge_rule(rules, path, rights)
|
|
141
|
+
return unless path && File.exist?(path)
|
|
142
|
+
|
|
143
|
+
canonical = File.realpath(path)
|
|
144
|
+
rules[canonical] = rules.fetch(canonical, 0) | rights
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def read_rights(path) = FS_READ_FILE | (File.directory?(path) ? FS_READ_DIR : 0)
|
|
148
|
+
|
|
149
|
+
def write_rights(path, abi)
|
|
150
|
+
rights = FS_WRITE
|
|
151
|
+
rights |= FS_REFER if abi >= 2
|
|
152
|
+
rights |= FS_TRUNCATE if abi >= 3
|
|
153
|
+
File.directory?(path) ? rights : (FS_WRITE_FILE | (abi >= 3 ? FS_TRUNCATE : 0))
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def add_landlock_rule(ruleset, path, rights)
|
|
157
|
+
descriptor = IO.sysopen(path, O_PATH | O_CLOEXEC)
|
|
158
|
+
attribute = [rights, descriptor].pack("Q<l<x4")
|
|
159
|
+
system_error("landlock_add_rule") unless landlock_add(ruleset, LANDLOCK_RULE_PATH_BENEATH, Fiddle::Pointer[attribute], 0).zero?
|
|
160
|
+
ensure
|
|
161
|
+
IO.new(descriptor).close if descriptor
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def apply_seccomp(policy)
|
|
165
|
+
calls = syscall_table
|
|
166
|
+
denied = %i[ptrace bpf perf_event_open userfaultfd kexec_load finit_module init_module delete_module reboot
|
|
167
|
+
swapon swapoff mount umount2 pivot_root setns unshare keyctl add_key request_key process_vm_readv
|
|
168
|
+
process_vm_writev open_by_handle_at kill tkill tgkill rt_sigqueueinfo rt_tgsigqueueinfo pidfd_send_signal]
|
|
169
|
+
denied.concat(%i[socket socketpair bind listen accept accept4 connect sendto recvfrom sendmsg recvmsg recvmmsg sendmmsg]) unless policy.network
|
|
170
|
+
filters = [[BPF_LD_W_ABS, 0, 0, 4], [BPF_JMP_JEQ_K, 1, 0, audit_arch], [BPF_RET_K, 0, 0, SECCOMP_RET_KILL_PROCESS], [BPF_LD_W_ABS, 0, 0, 0]]
|
|
171
|
+
unless policy.exec
|
|
172
|
+
if calls[:clone]
|
|
173
|
+
filters.concat([[BPF_JMP_JEQ_K, 0, 4, calls[:clone]], [BPF_LD_W_ABS, 0, 0, 16],
|
|
174
|
+
[BPF_JMP_JSET_K, 1, 0, CLONE_THREAD], [BPF_RET_K, 0, 0, SECCOMP_RET_ERRNO | EPERM], [BPF_LD_W_ABS, 0, 0, 0]])
|
|
175
|
+
end
|
|
176
|
+
denied.concat(%i[fork vfork clone3])
|
|
177
|
+
end
|
|
178
|
+
denied.filter_map { |name| calls[name] }.uniq.each do |number|
|
|
179
|
+
filters << [BPF_JMP_JEQ_K, 0, 1, number]
|
|
180
|
+
filters << [BPF_RET_K, 0, 0, SECCOMP_RET_ERRNO | EPERM]
|
|
181
|
+
end
|
|
182
|
+
filters << [BPF_RET_K, 0, 0, SECCOMP_RET_ALLOW]
|
|
183
|
+
install_filter(filters)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def install_filter(filters)
|
|
187
|
+
bytes = filters.map { |filter| filter.pack("S<CCL<") }.join
|
|
188
|
+
pointer = Fiddle::Pointer[bytes]
|
|
189
|
+
program = [filters.length].pack("S<") + "\0" * 6 + [pointer.to_i].pack("Q<")
|
|
190
|
+
system_error("prctl(PR_SET_SECCOMP)") unless prctl.call(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, Fiddle::Pointer[program], 0, 0).zero?
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def probe
|
|
194
|
+
pid = Process.fork do
|
|
195
|
+
command = [File.realpath(RbConfig.ruby), "-e", "exit!(0)"]
|
|
196
|
+
apply!(Policy.new([], [], false, false, []), command: command)
|
|
197
|
+
exec({}, *command, unsetenv_others: true)
|
|
198
|
+
rescue Exception
|
|
199
|
+
exit!(1)
|
|
200
|
+
end
|
|
201
|
+
Process.waitpid2(pid).last.success?
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def host_cpu = RbConfig::CONFIG["host_cpu"]
|
|
205
|
+
def supported_architecture? = Fiddle::SIZEOF_VOIDP == 8 && AUDIT_ARCH.key?(host_cpu)
|
|
206
|
+
def audit_arch = AUDIT_ARCH.fetch(host_cpu)
|
|
207
|
+
def syscall_table = SYSCALLS.fetch(host_cpu == "arm64" ? "aarch64" : host_cpu)
|
|
208
|
+
|
|
209
|
+
def landlock_abi
|
|
210
|
+
@landlock_abi ||= landlock_create(0, 0, LANDLOCK_CREATE_RULESET_VERSION)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def function(name, arguments, result = Fiddle::TYPE_INT)
|
|
214
|
+
Fiddle::Function.new(Fiddle::Handle::DEFAULT[name], arguments, result)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def unshare = (@unshare ||= function("unshare", [Fiddle::TYPE_INT]))
|
|
218
|
+
def mount = (@mount ||= function("mount", [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_LONG, Fiddle::TYPE_VOIDP]))
|
|
219
|
+
def prctl = (@prctl ||= function("prctl", [Fiddle::TYPE_INT, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG]))
|
|
220
|
+
|
|
221
|
+
def landlock_create(pointer, size, flags)
|
|
222
|
+
fn = (@landlock_create ||= function("syscall", [Fiddle::TYPE_LONG, Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T, Fiddle::TYPE_INT], Fiddle::TYPE_LONG))
|
|
223
|
+
fn.call(444, pointer, size, flags)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def landlock_add(ruleset, type, pointer, flags)
|
|
227
|
+
fn = (@landlock_add ||= function("syscall", [Fiddle::TYPE_LONG, Fiddle::TYPE_INT, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT], Fiddle::TYPE_LONG))
|
|
228
|
+
fn.call(445, ruleset, type, pointer, flags)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def landlock_restrict(ruleset, flags)
|
|
232
|
+
fn = (@landlock_restrict ||= function("syscall", [Fiddle::TYPE_LONG, Fiddle::TYPE_INT, Fiddle::TYPE_INT], Fiddle::TYPE_LONG))
|
|
233
|
+
fn.call(446, ruleset, flags)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def system_error(operation) = raise(SystemCallError.new(operation, Fiddle.last_error))
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fiddle"
|
|
4
|
+
|
|
5
|
+
require_relative "../backend"
|
|
6
|
+
|
|
7
|
+
module Saiph
|
|
8
|
+
module Backends
|
|
9
|
+
module MacOS
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
LIBRARIES = ["/usr/lib/libsandbox.1.dylib", "/usr/lib/libSystem.B.dylib"].freeze
|
|
13
|
+
BASE_READ_PATHS = ["/System", "/usr/lib", "/private/var/db/dyld", RbConfig::CONFIG["prefix"]].compact.freeze
|
|
14
|
+
|
|
15
|
+
def name = :seatbelt
|
|
16
|
+
|
|
17
|
+
def available?
|
|
18
|
+
return @available if defined?(@available_pid) && @available_pid == Process.pid
|
|
19
|
+
|
|
20
|
+
@available_pid = Process.pid
|
|
21
|
+
@available = probe
|
|
22
|
+
rescue StandardError, LoadError
|
|
23
|
+
@available = false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def spawn(command, policy:, **options) = Spawn.call(self, command, policy: policy, **options)
|
|
27
|
+
|
|
28
|
+
def apply!(policy, command: nil)
|
|
29
|
+
profile = build_profile(policy, command)
|
|
30
|
+
error = [0].pack("J")
|
|
31
|
+
result = sandbox_init.call(Fiddle::Pointer[profile], 0, Fiddle::Pointer[error])
|
|
32
|
+
return true if result.zero?
|
|
33
|
+
|
|
34
|
+
pointer = error.unpack1("J")
|
|
35
|
+
message = pointer.zero? ? "sandbox_init failed" : Fiddle::Pointer.new(pointer).to_s
|
|
36
|
+
sandbox_free_error.call(pointer) unless pointer.zero?
|
|
37
|
+
raise Error, message
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def build_profile(policy, command = nil)
|
|
41
|
+
reads = (BASE_READ_PATHS + policy.read_paths + policy.write_paths + Array(command&.first)).uniq
|
|
42
|
+
lines = ["(version 1)", "(deny default)", "(allow sysctl-read)", "(allow process-info*)"]
|
|
43
|
+
lines << rule("file-read*", reads)
|
|
44
|
+
lines << rule("file-write*", policy.write_paths) unless policy.write_paths.empty?
|
|
45
|
+
lines << "(allow network*)" if policy.network
|
|
46
|
+
if command
|
|
47
|
+
lines << "(allow process-exec (literal #{quote(command.first)}))"
|
|
48
|
+
lines << "(allow process-fork)" if policy.exec
|
|
49
|
+
elsif policy.exec
|
|
50
|
+
lines << rule("process-exec", policy.read_paths)
|
|
51
|
+
lines << "(allow process-fork)"
|
|
52
|
+
end
|
|
53
|
+
lines.join("\n")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def rule(operation, paths)
|
|
57
|
+
filters = paths.filter_map do |path|
|
|
58
|
+
next unless File.exist?(path)
|
|
59
|
+
|
|
60
|
+
"(#{File.directory?(path) ? 'subpath' : 'literal'} #{quote(path)})"
|
|
61
|
+
end
|
|
62
|
+
"(allow #{operation} #{filters.join(' ')})"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def quote(value) = JSON.generate(value)
|
|
66
|
+
|
|
67
|
+
def probe
|
|
68
|
+
pid = Process.fork do
|
|
69
|
+
STDERR.reopen(File::NULL, "w")
|
|
70
|
+
command = [File.realpath(RbConfig.ruby), "-e", "exit!(0)"]
|
|
71
|
+
apply!(Policy.new([], [], false, false, []), command: command)
|
|
72
|
+
exec({}, *command, unsetenv_others: true)
|
|
73
|
+
rescue Exception
|
|
74
|
+
exit!(1)
|
|
75
|
+
end
|
|
76
|
+
Process.waitpid2(pid).last.success?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def library
|
|
80
|
+
@library ||= LIBRARIES.lazy.filter_map do |path|
|
|
81
|
+
Fiddle.dlopen(path)
|
|
82
|
+
rescue Fiddle::DLError
|
|
83
|
+
nil
|
|
84
|
+
end.first or raise LoadError, "sandbox library not found"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def sandbox_init
|
|
88
|
+
@sandbox_init ||= Fiddle::Function.new(
|
|
89
|
+
library["sandbox_init"],
|
|
90
|
+
[Fiddle::TYPE_VOIDP, Fiddle::TYPE_LONG_LONG, Fiddle::TYPE_VOIDP],
|
|
91
|
+
Fiddle::TYPE_INT
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def sandbox_free_error
|
|
96
|
+
@sandbox_free_error ||= Fiddle::Function.new(library["sandbox_free_error"], [Fiddle::TYPE_VOIDP], Fiddle::TYPE_VOID)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fiddle"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
require_relative "../backend"
|
|
7
|
+
|
|
8
|
+
module Saiph
|
|
9
|
+
module Backends
|
|
10
|
+
module Windows
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
P = Fiddle::TYPE_VOIDP
|
|
14
|
+
I = Fiddle::TYPE_INT
|
|
15
|
+
U = Fiddle::TYPE_INT
|
|
16
|
+
N = Fiddle::TYPE_SIZE_T
|
|
17
|
+
L = Fiddle::TYPE_LONG
|
|
18
|
+
V = Fiddle::TYPE_VOID
|
|
19
|
+
PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES = 0x00020009
|
|
20
|
+
EXTENDED_STARTUPINFO_PRESENT = 0x00080000
|
|
21
|
+
CREATE_UNICODE_ENVIRONMENT = 0x00000400
|
|
22
|
+
CREATE_SUSPENDED = 0x00000004
|
|
23
|
+
STARTF_USESTDHANDLES = 0x00000100
|
|
24
|
+
JOB_OBJECT_LIMIT_ACTIVE_PROCESS = 0x00000008
|
|
25
|
+
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000
|
|
26
|
+
JOB_OBJECT_EXTENDED_LIMIT_INFORMATION = 9
|
|
27
|
+
INFINITE = 0xffffffff
|
|
28
|
+
|
|
29
|
+
def name = :appcontainer
|
|
30
|
+
|
|
31
|
+
def available?
|
|
32
|
+
return false unless Fiddle::SIZEOF_VOIDP == 8
|
|
33
|
+
return @available if defined?(@available_pid) && @available_pid == Process.pid
|
|
34
|
+
|
|
35
|
+
@available_pid = Process.pid
|
|
36
|
+
profile, sid = create_profile("Probe")
|
|
37
|
+
@available = create_and_wait([RbConfig.ruby, "-e", "exit!(0)"], sid, allow_children: false).zero?
|
|
38
|
+
rescue StandardError, LoadError, Fiddle::DLError
|
|
39
|
+
@available = false
|
|
40
|
+
ensure
|
|
41
|
+
free_sid.call(sid) if sid && !sid.zero?
|
|
42
|
+
delete_profile.call(wide(profile)) if profile
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def spawn(command, policy:, **options)
|
|
46
|
+
validate_policy!(policy)
|
|
47
|
+
Spawn.call(self, command, policy: policy, **options)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def apply!(_policy, command: nil)
|
|
51
|
+
raise Unsupported, "AppContainer can only be applied while creating a child process"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def launch(command, policy:)
|
|
55
|
+
validate_policy!(policy)
|
|
56
|
+
profile, sid = create_profile("Process")
|
|
57
|
+
create_and_wait(command, sid, allow_children: policy.exec)
|
|
58
|
+
ensure
|
|
59
|
+
free_sid.call(sid) if sid && !sid.zero?
|
|
60
|
+
delete_profile.call(wide(profile)) if profile
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def validate_policy!(policy)
|
|
64
|
+
unless policy.read_paths.empty? && policy.write_paths.empty?
|
|
65
|
+
raise Unsupported, "AppContainer path grants require explicit Windows ACL provisioning"
|
|
66
|
+
end
|
|
67
|
+
raise Unsupported, "AppContainer network capabilities are not available" if policy.network
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def create_and_wait(command, sid, allow_children:)
|
|
71
|
+
attributes = initialize_attributes
|
|
72
|
+
security = [sid, 0, 0, 0].pack("JJL<L<")
|
|
73
|
+
check(update_attributes.call(attributes, 0, PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES,
|
|
74
|
+
Fiddle::Pointer[security], security.bytesize, 0, 0), "UpdateProcThreadAttribute")
|
|
75
|
+
startup = startup_info(attributes)
|
|
76
|
+
information = "\0".b * 24
|
|
77
|
+
line = wide(command.map { |argument| quote_argument(argument) }.join(" "))
|
|
78
|
+
flags = EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT | CREATE_SUSPENDED
|
|
79
|
+
check(create_process.call(wide(command.first), line, 0, 0, 1, flags, 0, 0, Fiddle::Pointer[startup], Fiddle::Pointer[information]), "CreateProcessW")
|
|
80
|
+
process, thread = information.unpack("JJ")
|
|
81
|
+
job = create_job.call(0, 0)
|
|
82
|
+
check(job, "CreateJobObjectW")
|
|
83
|
+
configure_job(job, allow_children: allow_children)
|
|
84
|
+
check(assign_job.call(job, process), "AssignProcessToJobObject")
|
|
85
|
+
resumed = resume_thread.call(thread)
|
|
86
|
+
raise SystemCallError.new("ResumeThread", Fiddle.last_error) if resumed == 0xffffffff
|
|
87
|
+
|
|
88
|
+
check(wait.call(process, INFINITE) != 0xffffffff ? 1 : 0, "WaitForSingleObject")
|
|
89
|
+
status = [0].pack("L<")
|
|
90
|
+
check(exit_code.call(process, Fiddle::Pointer[status]), "GetExitCodeProcess")
|
|
91
|
+
status.unpack1("L<")
|
|
92
|
+
rescue Exception
|
|
93
|
+
terminate.call(process, 126) if process && !process.zero?
|
|
94
|
+
raise
|
|
95
|
+
ensure
|
|
96
|
+
delete_attributes.call(attributes) if attributes
|
|
97
|
+
[thread, process, job].compact.each { |handle| close_handle.call(handle) unless handle.zero? }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def configure_job(job, allow_children:)
|
|
101
|
+
information = "\0".b * 144
|
|
102
|
+
flags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
|
103
|
+
unless allow_children
|
|
104
|
+
flags |= JOB_OBJECT_LIMIT_ACTIVE_PROCESS
|
|
105
|
+
information[40, 4] = [1].pack("L<")
|
|
106
|
+
end
|
|
107
|
+
information[16, 4] = [flags].pack("L<")
|
|
108
|
+
check(set_job.call(job, JOB_OBJECT_EXTENDED_LIMIT_INFORMATION, Fiddle::Pointer[information], information.bytesize),
|
|
109
|
+
"SetInformationJobObject")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def initialize_attributes
|
|
113
|
+
size = [0].pack("J")
|
|
114
|
+
initialize_attribute_list.call(0, 1, 0, Fiddle::Pointer[size])
|
|
115
|
+
attributes = Fiddle::Pointer.malloc(size.unpack1("J"), Fiddle::RUBY_FREE)
|
|
116
|
+
check(initialize_attribute_list.call(attributes, 1, 0, Fiddle::Pointer[size]), "InitializeProcThreadAttributeList")
|
|
117
|
+
attributes
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def startup_info(attributes)
|
|
121
|
+
value = "\0".b * 112
|
|
122
|
+
value[0, 4] = [112].pack("L<")
|
|
123
|
+
value[60, 4] = [STARTF_USESTDHANDLES].pack("L<")
|
|
124
|
+
value[80, 24] = [-10, -11, -12].map { |number| get_std_handle.call(number) }.pack("J3")
|
|
125
|
+
value[104, 8] = [attributes.to_i].pack("J")
|
|
126
|
+
value
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def create_profile(suffix)
|
|
130
|
+
name = "Noxdea.Saiph.#{suffix}.#{Process.pid}.#{SecureRandom.hex(8)}"
|
|
131
|
+
sid = [0].pack("J")
|
|
132
|
+
result = create_profile_fn.call(wide(name), wide("Saiph"), wide("Ephemeral Saiph sandbox"), 0, 0, Fiddle::Pointer[sid])
|
|
133
|
+
raise Error, "CreateAppContainerProfile failed (0x#{(result & 0xffffffff).to_s(16)})" if (result & 0x80000000) != 0
|
|
134
|
+
|
|
135
|
+
[name, sid.unpack1("J")]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def quote_argument(value)
|
|
139
|
+
return value unless value.empty? || value.match?(/[\s"]/)
|
|
140
|
+
|
|
141
|
+
'"' + value.gsub(/(\\*)"/) { Regexp.last_match(1) * 2 + '\\"' }
|
|
142
|
+
.sub(/(\\+)\z/) { Regexp.last_match(1) * 2 } + '"'
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def wide(value) = value.encode("UTF-16LE").b + "\0\0"
|
|
146
|
+
|
|
147
|
+
def check(result, operation)
|
|
148
|
+
raise SystemCallError.new(operation, Fiddle.last_error) if result.to_i.zero?
|
|
149
|
+
|
|
150
|
+
result
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def library(name) = Fiddle.dlopen(name)
|
|
154
|
+
def kernel = (@kernel ||= library("kernel32.dll"))
|
|
155
|
+
def userenv = (@userenv ||= library("userenv.dll"))
|
|
156
|
+
def advapi = (@advapi ||= library("advapi32.dll"))
|
|
157
|
+
def fn(handle, name, arguments, result) = Fiddle::Function.new(handle[name], arguments, result)
|
|
158
|
+
def create_profile_fn = (@create_profile_fn ||= fn(userenv, "CreateAppContainerProfile", [P, P, P, P, U, P], L))
|
|
159
|
+
def delete_profile = (@delete_profile ||= fn(userenv, "DeleteAppContainerProfile", [P], L))
|
|
160
|
+
def free_sid = (@free_sid ||= fn(advapi, "FreeSid", [P], P))
|
|
161
|
+
def initialize_attribute_list = (@initialize_attribute_list ||= fn(kernel, "InitializeProcThreadAttributeList", [P, U, U, P], I))
|
|
162
|
+
def update_attributes = (@update_attributes ||= fn(kernel, "UpdateProcThreadAttribute", [P, U, N, P, N, P, P], I))
|
|
163
|
+
def delete_attributes = (@delete_attributes ||= fn(kernel, "DeleteProcThreadAttributeList", [P], V))
|
|
164
|
+
def create_process = (@create_process ||= fn(kernel, "CreateProcessW", [P, P, P, P, I, U, P, P, P, P], I))
|
|
165
|
+
def get_std_handle = (@get_std_handle ||= fn(kernel, "GetStdHandle", [I], P))
|
|
166
|
+
def create_job = (@create_job ||= fn(kernel, "CreateJobObjectW", [P, P], P))
|
|
167
|
+
def set_job = (@set_job ||= fn(kernel, "SetInformationJobObject", [P, I, P, U], I))
|
|
168
|
+
def assign_job = (@assign_job ||= fn(kernel, "AssignProcessToJobObject", [P, P], I))
|
|
169
|
+
def resume_thread = (@resume_thread ||= fn(kernel, "ResumeThread", [P], U))
|
|
170
|
+
def wait = (@wait ||= fn(kernel, "WaitForSingleObject", [P, U], U))
|
|
171
|
+
def exit_code = (@exit_code ||= fn(kernel, "GetExitCodeProcess", [P, P], I))
|
|
172
|
+
def terminate = (@terminate ||= fn(kernel, "TerminateProcess", [P, U], I))
|
|
173
|
+
def close_handle = (@close_handle ||= fn(kernel, "CloseHandle", [P], I))
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
data/lib/saiph/runner.rb
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../saiph" unless defined?(Saiph::Policy)
|
|
4
|
+
|
|
5
|
+
module Saiph
|
|
6
|
+
module Runner
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def run
|
|
10
|
+
fd = Integer(ENV.delete("SAIPH_PAYLOAD_FD"), 10)
|
|
11
|
+
requested = ENV.delete("SAIPH_BACKEND")
|
|
12
|
+
input = IO.for_fd(fd)
|
|
13
|
+
payload = JSON.parse(input.read)
|
|
14
|
+
input.close
|
|
15
|
+
values = payload.fetch("policy").values_at("read_paths", "write_paths", "network", "exec", "env")
|
|
16
|
+
policy = Policy.new(*values)
|
|
17
|
+
implementation = Saiph.send(:require_backend!)
|
|
18
|
+
raise Unsupported, "sandbox backend changed while starting" unless implementation.name.to_s == requested
|
|
19
|
+
|
|
20
|
+
if implementation.respond_to?(:launch)
|
|
21
|
+
exit!(implementation.launch(payload.fetch("command"), policy: policy))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
implementation.apply!(policy, command: payload.fetch("command"))
|
|
25
|
+
exec(*payload.fetch("command"))
|
|
26
|
+
rescue Exception => error
|
|
27
|
+
warn("saiph: #{error.message}")
|
|
28
|
+
exit!(126)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/saiph.rb
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "rbconfig"
|
|
5
|
+
|
|
6
|
+
require_relative "saiph/version"
|
|
7
|
+
|
|
8
|
+
module Saiph
|
|
9
|
+
class Error < StandardError; end
|
|
10
|
+
class Unsupported < Error; end
|
|
11
|
+
|
|
12
|
+
module Value
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def define(*members)
|
|
16
|
+
return Data.define(*members) if defined?(Data)
|
|
17
|
+
|
|
18
|
+
Struct.new(*members) do
|
|
19
|
+
members.each { |member| undef_method("#{member}=") }
|
|
20
|
+
|
|
21
|
+
def initialize(*values, **keywords)
|
|
22
|
+
unless keywords.empty?
|
|
23
|
+
raise ArgumentError, "cannot mix positional and keyword arguments" unless values.empty?
|
|
24
|
+
|
|
25
|
+
missing = self.class.members - keywords.keys
|
|
26
|
+
unknown = keywords.keys - self.class.members
|
|
27
|
+
raise ArgumentError, "missing keyword: #{missing.first.inspect}" unless missing.empty?
|
|
28
|
+
raise ArgumentError, "unknown keyword: #{unknown.first.inspect}" unless unknown.empty?
|
|
29
|
+
|
|
30
|
+
values = self.class.members.map { |member| keywords.fetch(member) }
|
|
31
|
+
end
|
|
32
|
+
raise ArgumentError, "wrong number of arguments" unless values.length == self.class.members.length
|
|
33
|
+
|
|
34
|
+
super(*values)
|
|
35
|
+
freeze
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
Policy = Value.define(:read_paths, :write_paths, :network, :exec, :env)
|
|
42
|
+
private_constant :Value
|
|
43
|
+
|
|
44
|
+
class << self
|
|
45
|
+
def available? = !!(platform && platform.available?)
|
|
46
|
+
def backend = available? ? platform.name : nil
|
|
47
|
+
|
|
48
|
+
def spawn(command, policy:, **spawn_options)
|
|
49
|
+
normalized = normalize_policy(policy)
|
|
50
|
+
executable = resolve_command(command)
|
|
51
|
+
require_backend!.spawn(executable, policy: normalized, **spawn_options)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def apply!(policy)
|
|
55
|
+
normalized = normalize_policy(policy)
|
|
56
|
+
require_backend!.apply!(normalized)
|
|
57
|
+
ENV.keep_if { |name, _value| normalized.env.include?(name) }
|
|
58
|
+
true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def platform
|
|
64
|
+
@platform ||= case RUBY_PLATFORM
|
|
65
|
+
when /darwin/ then require_relative("saiph/backends/macos") && Backends::MacOS
|
|
66
|
+
when /linux/ then require_relative("saiph/backends/linux") && Backends::Linux
|
|
67
|
+
when /mswin|mingw/ then require_relative("saiph/backends/windows") && Backends::Windows
|
|
68
|
+
else false
|
|
69
|
+
end
|
|
70
|
+
@platform || nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def require_backend!
|
|
74
|
+
candidate = platform
|
|
75
|
+
raise Unsupported, "no supported sandbox backend is available" unless candidate&.available?
|
|
76
|
+
|
|
77
|
+
candidate
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def normalize_policy(policy)
|
|
81
|
+
raise ArgumentError, "policy must be a Saiph::Policy" unless policy.is_a?(Policy)
|
|
82
|
+
|
|
83
|
+
reads = normalize_paths(policy.read_paths, :read_paths)
|
|
84
|
+
writes = normalize_paths(policy.write_paths, :write_paths)
|
|
85
|
+
raise ArgumentError, "network must be true or false" unless [true, false].include?(policy.network)
|
|
86
|
+
raise ArgumentError, "exec must be true or false" unless [true, false].include?(policy.exec)
|
|
87
|
+
|
|
88
|
+
names = Array(policy.env).map do |name|
|
|
89
|
+
value = String(name)
|
|
90
|
+
raise ArgumentError, "invalid environment name" if value.empty? || value.include?("=") || value.include?("\0")
|
|
91
|
+
|
|
92
|
+
value.freeze
|
|
93
|
+
end.uniq.freeze
|
|
94
|
+
Policy.new(reads, writes, policy.network, policy.exec, names)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def normalize_paths(paths, field)
|
|
98
|
+
Array(paths).map do |path|
|
|
99
|
+
value = String(path)
|
|
100
|
+
raise ArgumentError, "#{field} must contain absolute paths" unless File.absolute_path(value) == value
|
|
101
|
+
raise ArgumentError, "#{field} path does not exist: #{value}" unless File.exist?(value)
|
|
102
|
+
|
|
103
|
+
File.realpath(value).freeze
|
|
104
|
+
end.uniq.freeze
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def resolve_command(command)
|
|
108
|
+
parts = (command.is_a?(Array) ? command : [command]).map do |part|
|
|
109
|
+
value = String(part)
|
|
110
|
+
raise ArgumentError, "command contains a null byte" if value.include?("\0")
|
|
111
|
+
|
|
112
|
+
value
|
|
113
|
+
end
|
|
114
|
+
raise ArgumentError, "command must not be empty" if parts.empty? || parts.first.empty?
|
|
115
|
+
|
|
116
|
+
executable = parts.first
|
|
117
|
+
separators = [File::SEPARATOR, File::ALT_SEPARATOR].compact
|
|
118
|
+
unless separators.any? { |separator| executable.include?(separator) }
|
|
119
|
+
executable = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).find do |directory|
|
|
120
|
+
path = File.expand_path(executable, directory)
|
|
121
|
+
break path if File.file?(path) && File.executable?(path)
|
|
122
|
+
end
|
|
123
|
+
else
|
|
124
|
+
executable = File.expand_path(executable)
|
|
125
|
+
end
|
|
126
|
+
raise ArgumentError, "command executable was not found" unless executable && File.file?(executable) && File.executable?(executable)
|
|
127
|
+
|
|
128
|
+
parts[0] = File.realpath(executable)
|
|
129
|
+
parts.freeze
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
require_relative "saiph/runner"
|
data/sig/saiph.rbs
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Saiph
|
|
2
|
+
VERSION: String
|
|
3
|
+
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
class Unsupported < Error
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class Policy
|
|
11
|
+
attr_reader read_paths: Array[String]
|
|
12
|
+
attr_reader write_paths: Array[String]
|
|
13
|
+
attr_reader network: bool
|
|
14
|
+
attr_reader exec: bool
|
|
15
|
+
attr_reader env: Array[String]
|
|
16
|
+
|
|
17
|
+
def self.new: (Array[String] read_paths, Array[String] write_paths, bool network, bool exec, Array[String] env) -> Policy
|
|
18
|
+
| (read_paths: Array[String], write_paths: Array[String], network: bool, exec: bool, env: Array[String]) -> Policy
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.available?: () -> bool
|
|
22
|
+
def self.backend: () -> (:seatbelt | :seccomp | :appcontainer)?
|
|
23
|
+
def self.spawn: (String | Array[String] command, policy: Policy, **untyped options) -> Integer
|
|
24
|
+
def self.apply!: (Policy policy) -> bool
|
|
25
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: saiph
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Yudai Takada
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: fiddle
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '1.1'
|
|
26
|
+
email:
|
|
27
|
+
- t.yudai92@gmail.com
|
|
28
|
+
executables: []
|
|
29
|
+
extensions: []
|
|
30
|
+
extra_rdoc_files: []
|
|
31
|
+
files:
|
|
32
|
+
- CHANGELOG.md
|
|
33
|
+
- LICENSE.txt
|
|
34
|
+
- README.md
|
|
35
|
+
- docs/adr/000-template.md
|
|
36
|
+
- docs/adr/001-require-complete-native-enforcement.md
|
|
37
|
+
- docs/adr/README.md
|
|
38
|
+
- lib/saiph.rb
|
|
39
|
+
- lib/saiph/backend.rb
|
|
40
|
+
- lib/saiph/backends/linux.rb
|
|
41
|
+
- lib/saiph/backends/macos.rb
|
|
42
|
+
- lib/saiph/backends/windows.rb
|
|
43
|
+
- lib/saiph/runner.rb
|
|
44
|
+
- lib/saiph/version.rb
|
|
45
|
+
- sig/saiph.rbs
|
|
46
|
+
homepage: https://github.com/noxdea/saiph
|
|
47
|
+
licenses:
|
|
48
|
+
- MIT
|
|
49
|
+
metadata:
|
|
50
|
+
source_code_uri: https://github.com/noxdea/saiph
|
|
51
|
+
changelog_uri: https://github.com/noxdea/saiph/blob/main/CHANGELOG.md
|
|
52
|
+
allowed_push_host: https://rubygems.org
|
|
53
|
+
rubygems_mfa_required: 'true'
|
|
54
|
+
rdoc_options: []
|
|
55
|
+
require_paths:
|
|
56
|
+
- lib
|
|
57
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - ">="
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '3.1'
|
|
62
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
63
|
+
requirements:
|
|
64
|
+
- - ">="
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: '0'
|
|
67
|
+
requirements: []
|
|
68
|
+
rubygems_version: 4.0.16
|
|
69
|
+
specification_version: 4
|
|
70
|
+
summary: Fail-closed native process sandboxing for Ruby
|
|
71
|
+
test_files: []
|