straycall 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +158 -28
- data/exe/straycall-supervisor +1 -0
- data/lib/straycall/cli.rb +7 -3
- data/lib/straycall/config.rb +24 -5
- data/lib/straycall/handlers/filesystem.rb +23 -1
- data/lib/straycall/in_process.rb +3 -2
- data/lib/straycall/minitest.rb +13 -1
- data/lib/straycall/policy/exec.rb +20 -4
- data/lib/straycall/policy/filesystem.rb +1 -1
- data/lib/straycall/policy/network.rb +31 -2
- data/lib/straycall/policy.rb +11 -12
- data/lib/straycall/recorder.rb +14 -4
- data/lib/straycall/rspec.rb +9 -1
- data/lib/straycall/supervisor.rb +47 -7
- data/lib/straycall/target.rb +13 -1
- data/lib/straycall/version.rb +1 -1
- data/lib/straycall.rb +8 -0
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 40a825fdd8eb649cf8455faec21e607cac5bf89f93c1136fdec311990361d1ae
|
|
4
|
+
data.tar.gz: e3eac0d6b59c484f603bf17ebb7ea72a4fc5d1e27916ada148572712b050ae09
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a23da98781ebfbe39409b9fc601ae3e5bbef513ec8d4fd482a0f2efb15153b275f6af23359cb6d9b11f562db2d39e562b450014649af681b959316103a6344fa
|
|
7
|
+
data.tar.gz: b4b5533a953cd033fd9a0f1d6db5f838b38e855450bf07e3eb22c21ec6ad15d1b3233dba451ec15dec9ea9e2b0bcddee0b4e7dade5f5cc12c813896e9ceb6ca4
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Straycall are documented here.
|
|
4
4
|
|
|
5
|
+
## v0.2.0 - 2026-08-24
|
|
6
|
+
|
|
7
|
+
- Reuse CLI supervision from RSpec and Minitest integrations and prevent descendant Ruby processes from consuming another process's reporter messages.
|
|
8
|
+
- Keep listener-transfer descriptors reserved and warn when in-process installation cannot cover existing threads.
|
|
9
|
+
- Cover additional filesystem mutation syscalls, writable shared mappings, listeners, and local netlink sockets.
|
|
10
|
+
- Make recorded policies non-destructive, exact, and replayable for raw socket families and unbound listeners.
|
|
11
|
+
- Resolve executable aliases and support directory-scoped executable allow rules.
|
|
12
|
+
- Reduce notification and report-writing overhead, handle IPv4-mapped loopback addresses, and warn when supervision is unavailable.
|
|
13
|
+
- Require seccomp-notify 0.3 for thread-safe termination, x32 rejection, and listener-transfer hardening.
|
|
14
|
+
|
|
5
15
|
## v0.1.0 - 2026-08-23
|
|
6
16
|
|
|
7
17
|
- Initial release.
|
data/README.md
CHANGED
|
@@ -1,40 +1,116 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
<h1 align="center">Straycall</h1>
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<strong>Report and block unintended syscalls in Ruby builds and tests</strong>
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="https://rubygems.org/gems/straycall"><img src="https://img.shields.io/gem/v/straycall.svg?colorB=38bdf8" alt="Gem Version"></a>
|
|
9
|
+
<a href="https://rubygems.org/gems/straycall"><img src="https://img.shields.io/gem/dt/straycall.svg" alt="Downloads"></a>
|
|
10
|
+
<img src="https://img.shields.io/badge/ruby-%3E%3D%203.1-ruby.svg" alt="Ruby Version">
|
|
11
|
+
<a href="https://github.com/ydah/straycall/actions/workflows/main.yml"><img src="https://github.com/ydah/straycall/actions/workflows/main.yml/badge.svg" alt="Ruby CI"></a>
|
|
12
|
+
<img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License">
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
<p align="center">
|
|
16
|
+
<a href="https://ydah.github.io/straycall/">Website</a> ·
|
|
17
|
+
<a href="#features">Features</a> ·
|
|
18
|
+
<a href="#installation">Installation</a> ·
|
|
19
|
+
<a href="#quick-start">Quick Start</a> ·
|
|
20
|
+
<a href="#configuration">Configuration</a> ·
|
|
21
|
+
<a href="#integrations">Integrations</a> ·
|
|
22
|
+
<a href="#how-it-works">How It Works</a> ·
|
|
23
|
+
<a href="#known-limitations">Limitations</a>
|
|
24
|
+
</p>
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
Straycall runs an unfiltered supervisor over Linux [seccomp user notifications](https://www.kernel.org/doc/html/latest/userspace-api/seccomp_filter.html) to report and block unintended network, filesystem, and process access. Each report includes the responsible Ruby backtrace when available.
|
|
4
29
|
|
|
5
30
|
> [!WARNING]
|
|
6
31
|
> Straycall detects accidents; it is not a security boundary for hostile code. Pointer-based policy checks, symbolic links, existing file descriptors, and kernel interfaces outside seccomp's reach leave intentional escape routes.
|
|
7
32
|
|
|
8
|
-
On
|
|
9
|
-
|
|
10
|
-
## Requirements
|
|
33
|
+
On unsupported platforms, non-MRI Ruby, or with `STRAYCALL=0`, Straycall is a no-op and the wrapped command runs normally.
|
|
11
34
|
|
|
12
|
-
|
|
13
|
-
- MRI Ruby 3.1+
|
|
14
|
-
- `seccomp-notify` 0.2.x
|
|
35
|
+
## Features
|
|
15
36
|
|
|
16
|
-
|
|
37
|
+
- Network policies for connections, Unix sockets, binds, and listeners
|
|
38
|
+
- Filesystem policies for writes and sensitive reads
|
|
39
|
+
- Process policies for allowed and denied executables
|
|
40
|
+
- Ruby backtraces with native-library fallback
|
|
41
|
+
- `fail`, `warn`, `record`, and interactive `prompt` modes
|
|
42
|
+
- CLI, YAML, and Ruby configuration
|
|
43
|
+
- RSpec and Minitest integrations
|
|
44
|
+
- Console and versioned JSON reports
|
|
45
|
+
- Automatic no-op behavior outside supported Linux environments
|
|
17
46
|
|
|
18
47
|
## Installation
|
|
19
48
|
|
|
49
|
+
Add Straycall to your Gemfile:
|
|
50
|
+
|
|
20
51
|
```ruby
|
|
21
52
|
gem "straycall"
|
|
22
53
|
```
|
|
23
54
|
|
|
24
|
-
|
|
55
|
+
Then install:
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
bundle install
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Requirements
|
|
62
|
+
|
|
63
|
+
- Linux x86_64 or aarch64
|
|
64
|
+
- Linux 5.5+ with seccomp user notifications and `CONTINUE`
|
|
65
|
+
- MRI Ruby 3.1+
|
|
66
|
+
- `seccomp-notify` 0.2.x
|
|
67
|
+
|
|
68
|
+
Container runtimes may block `seccomp(2)` in their own profile. Use an isolated container with `--security-opt seccomp=unconfined` when needed.
|
|
69
|
+
|
|
70
|
+
## Quick Start
|
|
25
71
|
|
|
26
72
|
The CLI keeps the supervisor outside the filtered command, so it does not interfere with the command's `Process.wait` calls.
|
|
27
73
|
|
|
74
|
+
Run a test suite with a YAML policy:
|
|
75
|
+
|
|
28
76
|
```sh
|
|
29
77
|
straycall --config .straycall.yml -- bundle exec rspec
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Write a JSON report:
|
|
81
|
+
|
|
82
|
+
```sh
|
|
30
83
|
straycall --report-path tmp/straycall.json -- ruby script.rb
|
|
31
|
-
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Record observed access as a reviewable policy:
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
straycall --record -- bundle exec rspec
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Prompt while installing dependencies:
|
|
93
|
+
|
|
94
|
+
```sh
|
|
32
95
|
straycall --preset=bundle-install -- bundle install
|
|
33
96
|
```
|
|
34
97
|
|
|
35
|
-
|
|
98
|
+
### Violation Modes
|
|
36
99
|
|
|
37
|
-
|
|
100
|
+
| Mode | Behavior |
|
|
101
|
+
|------|----------|
|
|
102
|
+
| `fail` | Deny the syscall with `EPERM` and exit nonzero |
|
|
103
|
+
| `warn` | Allow the syscall and report it |
|
|
104
|
+
| `record` | Allow the syscall and write a reviewable policy |
|
|
105
|
+
| `prompt` | Ask through `/dev/tty`; deny when no TTY is available |
|
|
106
|
+
|
|
107
|
+
The default mode is `fail`.
|
|
108
|
+
|
|
109
|
+
Record mode writes `.straycall.recorded.yml` and refuses to replace an existing file. Review it before moving it to `.straycall.yml`; pass `--force` only when replacing a previous recording intentionally.
|
|
110
|
+
|
|
111
|
+
## Configuration
|
|
112
|
+
|
|
113
|
+
Create `.straycall.yml` in your project root:
|
|
38
114
|
|
|
39
115
|
```yaml
|
|
40
116
|
network:
|
|
@@ -43,21 +119,36 @@ network:
|
|
|
43
119
|
- host: 127.0.0.1
|
|
44
120
|
ports: [5432, 6379]
|
|
45
121
|
- unix: /var/run/postgresql/.s.PGSQL.5432
|
|
122
|
+
- domain: 17 # platform socket-family number
|
|
123
|
+
allow_unbound_listen: false
|
|
124
|
+
|
|
46
125
|
write:
|
|
47
126
|
default: deny
|
|
48
127
|
allow: [tmp, log, coverage, /tmp]
|
|
49
128
|
deny_read: [/etc/shadow, ~/.ssh, ~/.aws, ~/.gem/credentials]
|
|
129
|
+
|
|
50
130
|
exec:
|
|
51
|
-
default:
|
|
52
|
-
|
|
131
|
+
default: deny
|
|
132
|
+
allow_under: [/usr/bin]
|
|
133
|
+
|
|
53
134
|
on_violation: fail
|
|
54
135
|
report_backtrace: true
|
|
55
136
|
report_path: tmp/straycall.json
|
|
56
137
|
```
|
|
57
138
|
|
|
58
|
-
|
|
139
|
+
| Option | Default | Description |
|
|
140
|
+
|--------|---------|-------------|
|
|
141
|
+
| `network.default` | `deny` | Allow or deny network endpoints not listed in `network.allow` |
|
|
142
|
+
| `network.allow_unbound_listen` | `false` | Permit listeners whose bound endpoint cannot be recovered |
|
|
143
|
+
| `write.default` | `allow` | Allow or deny writes outside `write.allow` |
|
|
144
|
+
| `exec.default` | `allow` | Allow or deny executables outside `exec.allow` |
|
|
145
|
+
| `on_violation` | `fail` | Select `fail`, `warn`, `record`, or `prompt` |
|
|
146
|
+
| `report_backtrace` | `true` | Include Ruby backtraces in reports |
|
|
147
|
+
| `report_path` | unset | Write a versioned JSON report to this path |
|
|
148
|
+
|
|
149
|
+
Hosts are resolved once while loading the configuration. Filesystem paths are expanded; executable paths are resolved through symbolic links when possible.
|
|
59
150
|
|
|
60
|
-
|
|
151
|
+
### Ruby Configuration
|
|
61
152
|
|
|
62
153
|
```ruby
|
|
63
154
|
require "straycall"
|
|
@@ -67,19 +158,25 @@ Straycall.configure do |config|
|
|
|
67
158
|
config.allow_loopback ports: [5432, 6379]
|
|
68
159
|
config.allow_host "registry.internal.example", ports: [443]
|
|
69
160
|
config.allow_unix "/var/run/postgresql/.s.PGSQL.5432"
|
|
161
|
+
config.allow_socket_domain Socket::AF_PACKET
|
|
162
|
+
config.allow_unbound_listen!
|
|
70
163
|
|
|
71
164
|
config.allow_write_under "tmp", "log", "coverage", Dir.tmpdir
|
|
72
165
|
config.deny_write_elsewhere!
|
|
73
166
|
config.deny_read "/etc/shadow", "~/.ssh"
|
|
74
167
|
|
|
75
168
|
config.allow_exec "/usr/bin/git", "/usr/bin/make"
|
|
169
|
+
config.allow_exec_under "/opt/project/bin"
|
|
76
170
|
config.deny_exec_elsewhere!
|
|
77
171
|
# config.allow_io_uring! # disables complete syscall coverage
|
|
172
|
+
|
|
78
173
|
config.report_path = "tmp/straycall.json"
|
|
79
174
|
end
|
|
80
175
|
```
|
|
81
176
|
|
|
82
|
-
##
|
|
177
|
+
## Integrations
|
|
178
|
+
|
|
179
|
+
### RSpec and Minitest
|
|
83
180
|
|
|
84
181
|
Load the integration at the top of the test helper:
|
|
85
182
|
|
|
@@ -91,35 +188,68 @@ require "straycall/rspec"
|
|
|
91
188
|
require "straycall/minitest"
|
|
92
189
|
```
|
|
93
190
|
|
|
94
|
-
|
|
191
|
+
In-process mode starts a clean detached supervisor with `Process.spawn`, associates violations with the current test, and checks the supervisor health pipe. When it runs below the `straycall` CLI, it reuses the CLI supervisor instead of installing a second seccomp listener. Prefer the CLI if tests use broad `Process.wait` calls or fork heavily; filters are inherited and cannot be removed.
|
|
95
192
|
|
|
96
|
-
|
|
193
|
+
### Reports
|
|
97
194
|
|
|
98
|
-
Console
|
|
195
|
+
Console reports include the syscall, target, current test, Ruby backtrace, native-library fallback, and an allow-list suggestion. Set `report_path` to write a versioned JSON document:
|
|
99
196
|
|
|
100
197
|
```json
|
|
101
|
-
{
|
|
198
|
+
{
|
|
199
|
+
"version": 1,
|
|
200
|
+
"violations": [
|
|
201
|
+
{
|
|
202
|
+
"syscall": "connect",
|
|
203
|
+
"tid": 123,
|
|
204
|
+
"target": {
|
|
205
|
+
"type": "inet",
|
|
206
|
+
"host": "192.0.2.1",
|
|
207
|
+
"port": 443
|
|
208
|
+
},
|
|
209
|
+
"action": "denied"
|
|
210
|
+
}
|
|
211
|
+
]
|
|
212
|
+
}
|
|
102
213
|
```
|
|
103
214
|
|
|
104
|
-
##
|
|
215
|
+
## How It Works
|
|
216
|
+
|
|
217
|
+
1. Straycall starts an unfiltered supervisor before the target command.
|
|
218
|
+
2. The target installs a seccomp filter for the configured syscall classes.
|
|
219
|
+
3. Linux pauses matching syscalls and sends user notifications to the supervisor.
|
|
220
|
+
4. The supervisor reads the target, evaluates the policy, and allows or denies the call.
|
|
221
|
+
5. Straycall joins violations with the current test and Ruby backtrace, then writes the configured reports.
|
|
105
222
|
|
|
106
|
-
|
|
223
|
+
## Known Limitations
|
|
224
|
+
|
|
225
|
+
1. Straycall is not a security boundary. Allowing a pointer-based syscall with `continue!` is subject to TOCTOU.
|
|
107
226
|
2. vDSO calls such as normal clock reads cannot be intercepted.
|
|
108
227
|
3. File descriptors opened before filter installation are outside the policy.
|
|
109
228
|
4. `io_uring_setup` is denied so io_uring cannot bypass monitored syscalls. `allow_io_uring!` opts out, but network and filesystem coverage is then incomplete.
|
|
110
229
|
5. Ruby backtraces require MRI 3.1+; native calls fall back to `/proc/<tid>/maps`.
|
|
111
230
|
6. Filesystem checks use the unresolved path string. A symlink can point outside an allowed root.
|
|
112
231
|
7. If the supervisor dies, the kernel returns `ENOSYS`; integrations detect the health-pipe closure as soon as they regain control.
|
|
113
|
-
8. A connected `sendto
|
|
232
|
+
8. A connected `sendto`, `sendmsg`, or `sendmmsg` without an explicit destination relies on the earlier monitored `connect`; Straycall cannot recover a peer address from the target file descriptor.
|
|
114
233
|
9. Listener tracking observes an allowed `bind` before the kernel returns. It prevents accidental auto-bind listeners, but it is not a hostile-code boundary.
|
|
234
|
+
10. In-process integrations only filter the thread that installs seccomp. Load them before starting application threads; Straycall warns when threads already exist.
|
|
235
|
+
11. `ptrace` is denied inside supervised processes, so attach profilers and debuggers from outside the target.
|
|
115
236
|
|
|
116
237
|
## Development
|
|
117
238
|
|
|
118
239
|
```sh
|
|
119
|
-
|
|
240
|
+
bundle install
|
|
241
|
+
bundle exec rake
|
|
120
242
|
gem build straycall.gemspec
|
|
121
243
|
```
|
|
122
244
|
|
|
123
245
|
Real seccomp integration examples skip automatically outside supported Linux environments.
|
|
124
246
|
|
|
125
|
-
On Linux, `ruby benchmark/require_overhead.rb` checks that read-only `openat` overhead stays below 10% across 100,000 generated require files
|
|
247
|
+
On Linux, `ruby benchmark/require_overhead.rb` checks that read-only `openat` overhead stays below 10% across 100,000 generated require files. Set `STRAYCALL_BENCHMARK_FILES` to scale the workload.
|
|
248
|
+
|
|
249
|
+
## Contributing
|
|
250
|
+
|
|
251
|
+
Bug reports and pull requests are welcome at https://github.com/ydah/straycall.
|
|
252
|
+
|
|
253
|
+
## License
|
|
254
|
+
|
|
255
|
+
Released under the [MIT License](LICENSE.txt).
|
data/exe/straycall-supervisor
CHANGED
data/lib/straycall/cli.rb
CHANGED
|
@@ -17,14 +17,17 @@ module Straycall
|
|
|
17
17
|
|
|
18
18
|
def run
|
|
19
19
|
parse!
|
|
20
|
-
|
|
20
|
+
unless Straycall.available?
|
|
21
|
+
Straycall.warn_unavailable!
|
|
22
|
+
return exec(*@argv)
|
|
23
|
+
end
|
|
21
24
|
|
|
22
25
|
require "seccomp/notify"
|
|
23
26
|
require_relative "supervisor"
|
|
24
27
|
status, violations = Supervisor.new(Straycall.config).run(@argv)
|
|
25
28
|
if Straycall.config.on_violation == :record
|
|
26
29
|
require_relative "recorder"
|
|
27
|
-
Recorder.new(@policy_path).write(violations)
|
|
30
|
+
Recorder.new(@policy_path, force: @force).write(violations)
|
|
28
31
|
end
|
|
29
32
|
return 1 if violations.any? { |violation| violation.action == :denied }
|
|
30
33
|
|
|
@@ -50,6 +53,7 @@ module Straycall
|
|
|
50
53
|
options.on("--report-path PATH") { |value| @config_actions << ->(config) { config.report_path = value } }
|
|
51
54
|
options.on("--record") { @config_actions << ->(config) { config.on_violation = :record } }
|
|
52
55
|
options.on("--policy-path PATH") { |value| @policy_path = value }
|
|
56
|
+
options.on("--force") { @force = true }
|
|
53
57
|
options.on("--warn") { @config_actions << ->(config) { config.on_violation = :warn } }
|
|
54
58
|
options.on("--prompt") { @config_actions << ->(config) { config.on_violation = :prompt } }
|
|
55
59
|
options.on("--preset NAME") { |value| @config_actions << ->(config) { apply_preset(config, value) } }
|
|
@@ -57,7 +61,7 @@ module Straycall
|
|
|
57
61
|
parser.order!(@argv)
|
|
58
62
|
@argv.shift if @argv.first == "--"
|
|
59
63
|
raise OptionParser::MissingArgument, "command" if @argv.empty?
|
|
60
|
-
@policy_path ||= ".straycall.yml"
|
|
64
|
+
@policy_path ||= ".straycall.recorded.yml"
|
|
61
65
|
Straycall.config = @config_path ? Config.load(@config_path) : Straycall.config
|
|
62
66
|
@config_actions.each { |action| action.call(Straycall.config) }
|
|
63
67
|
end
|
data/lib/straycall/config.rb
CHANGED
|
@@ -53,6 +53,14 @@ module Straycall
|
|
|
53
53
|
network.allow_host(host, ports:)
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
+
def allow_socket_domain(domain)
|
|
57
|
+
network.allow_domain(domain)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def allow_unbound_listen!
|
|
61
|
+
network.allow_unbound_listen!
|
|
62
|
+
end
|
|
63
|
+
|
|
56
64
|
def allow_write_under(*paths)
|
|
57
65
|
paths.each { |path| filesystem.allow_write_under(path) }
|
|
58
66
|
end
|
|
@@ -69,6 +77,10 @@ module Straycall
|
|
|
69
77
|
paths.each { |path| exec.allow(path) }
|
|
70
78
|
end
|
|
71
79
|
|
|
80
|
+
def allow_exec_under(*paths)
|
|
81
|
+
paths.each { |path| exec.allow_under(path) }
|
|
82
|
+
end
|
|
83
|
+
|
|
72
84
|
def deny_exec_elsewhere!
|
|
73
85
|
exec.deny_elsewhere!
|
|
74
86
|
end
|
|
@@ -127,29 +139,35 @@ module Straycall
|
|
|
127
139
|
private
|
|
128
140
|
|
|
129
141
|
def apply_network(data)
|
|
130
|
-
validate_keys!(data, %w[default allow], "network")
|
|
142
|
+
validate_keys!(data, %w[default allow allow_unbound_listen], "network")
|
|
131
143
|
default = key?(data, "default") ? fetch(data, "default") : "deny"
|
|
132
144
|
raise ConfigurationError, "network.default must be allow or deny" unless %w[allow deny].include?(default)
|
|
133
145
|
|
|
134
146
|
default == "allow" ? network.allow! : network.deny!
|
|
135
147
|
array(data, "allow").each do |rule|
|
|
136
148
|
raise ConfigurationError, "network allow rules must be mappings" unless rule.is_a?(Hash)
|
|
137
|
-
validate_keys!(rule, %w[host ports unix], "network allow rule")
|
|
149
|
+
validate_keys!(rule, %w[host ports unix domain], "network allow rule")
|
|
138
150
|
|
|
139
151
|
unix = key?(rule, "unix")
|
|
140
152
|
host = key?(rule, "host")
|
|
141
|
-
|
|
153
|
+
domain = key?(rule, "domain")
|
|
154
|
+
raise ConfigurationError, "network allow rule requires exactly one of host, unix, or domain" unless [unix, host, domain].count(true) == 1
|
|
142
155
|
|
|
143
156
|
if unix
|
|
144
157
|
raise ConfigurationError, "unix network allow rules cannot specify ports" if key?(rule, "ports")
|
|
145
158
|
|
|
146
159
|
path = fetch(rule, "unix")
|
|
147
160
|
allow_unix(string(path, "unix"))
|
|
148
|
-
|
|
161
|
+
elsif host
|
|
149
162
|
address = fetch(rule, "host")
|
|
150
163
|
allow_host(string(address, "host"), ports: fetch(rule, "ports"))
|
|
164
|
+
else
|
|
165
|
+
raise ConfigurationError, "socket domain rules cannot specify ports" if key?(rule, "ports")
|
|
166
|
+
|
|
167
|
+
allow_socket_domain(fetch(rule, "domain"))
|
|
151
168
|
end
|
|
152
169
|
end
|
|
170
|
+
allow_unbound_listen! if key?(data, "allow_unbound_listen") && boolean(data, "allow_unbound_listen")
|
|
153
171
|
end
|
|
154
172
|
|
|
155
173
|
def apply_write(data)
|
|
@@ -163,11 +181,12 @@ module Straycall
|
|
|
163
181
|
end
|
|
164
182
|
|
|
165
183
|
def apply_exec(data)
|
|
166
|
-
validate_keys!(data, %w[default allow deny], "exec")
|
|
184
|
+
validate_keys!(data, %w[default allow allow_under deny], "exec")
|
|
167
185
|
default = key?(data, "default") ? fetch(data, "default") : "allow"
|
|
168
186
|
raise ConfigurationError, "exec.default must be allow or deny" unless %w[allow deny].include?(default)
|
|
169
187
|
|
|
170
188
|
array(data, "allow").each { |path| allow_exec(string(path, "exec.allow")) }
|
|
189
|
+
array(data, "allow_under").each { |path| allow_exec_under(string(path, "exec.allow_under")) }
|
|
171
190
|
array(data, "deny").each { |path| deny_exec(string(path, "exec.deny")) }
|
|
172
191
|
deny_exec_elsewhere! if default == "deny"
|
|
173
192
|
end
|
|
@@ -38,6 +38,28 @@ module Straycall
|
|
|
38
38
|
record_violation(request, {type: "file", path: "?"}, nil)
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
+
def handle_fd_mutation(request, fd)
|
|
42
|
+
path = File.readlink("/proc/#{request.tid}/fd/#{fd}").delete_suffix(" (deleted)")
|
|
43
|
+
return request.continue! unless path.start_with?(File::SEPARATOR)
|
|
44
|
+
|
|
45
|
+
target = {type: "file", path:, operation: request.syscall.to_s}
|
|
46
|
+
if !@config.filesystem.allowed_write?(path) || @config.on_violation == :record
|
|
47
|
+
record_violation(request, target, %(allow_write_under #{File.dirname(path).inspect}))
|
|
48
|
+
else
|
|
49
|
+
request.continue!(unsafe: true)
|
|
50
|
+
end
|
|
51
|
+
rescue SystemCallError
|
|
52
|
+
record_violation(request, {type: "file", path: "?", operation: request.syscall.to_s}, nil)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def handle_mmap(request)
|
|
56
|
+
prot = request.args[2]
|
|
57
|
+
shared = (request.args[3] & 1).positive?
|
|
58
|
+
return request.continue! unless shared && (prot & 2).positive?
|
|
59
|
+
|
|
60
|
+
handle_fd_mutation(request, request.args[4])
|
|
61
|
+
end
|
|
62
|
+
|
|
41
63
|
private
|
|
42
64
|
|
|
43
65
|
def handle_open(request, dirfd, path_address, flags)
|
|
@@ -45,7 +67,7 @@ module Straycall
|
|
|
45
67
|
filesystem = @config.filesystem
|
|
46
68
|
write = filesystem.write?(flags)
|
|
47
69
|
denied = write ? !filesystem.allowed_write?(path) : filesystem.denied_read?(path)
|
|
48
|
-
target = {type: "file", path:, flags:}
|
|
70
|
+
target = {type: "file", path:, flags:, operation: write ? "write" : "read"}
|
|
49
71
|
if denied || (@config.on_violation == :record && write)
|
|
50
72
|
suggestion = write ? %(allow_write_under #{File.dirname(path).inspect}) : nil
|
|
51
73
|
record_violation(request, target, suggestion)
|
data/lib/straycall/in_process.rb
CHANGED
|
@@ -9,6 +9,7 @@ module Straycall
|
|
|
9
9
|
module_function
|
|
10
10
|
|
|
11
11
|
def install(config, report_socket:, inherited_report_socket:)
|
|
12
|
+
warn "straycall: existing threads cannot be covered by an in-process seccomp filter" if Dir.children("/proc/self/task").length > 1
|
|
12
13
|
transfer_socket, supervisor_socket = UNIXSocket.pair
|
|
13
14
|
health_reader, health_writer = IO.pipe
|
|
14
15
|
policy = Policy.seccomp(config)
|
|
@@ -18,7 +19,7 @@ module Straycall
|
|
|
18
19
|
|
|
19
20
|
[supervisor_socket, report_socket, health_writer].each { |io| io.close_on_exec = false }
|
|
20
21
|
inherited_report_socket.close_on_exec = true
|
|
21
|
-
transfer_socket.close_on_exec =
|
|
22
|
+
transfer_socket.close_on_exec = false
|
|
22
23
|
encoded_config = Marshal.dump(config).unpack1("H*")
|
|
23
24
|
clean_environment = ENV.keys.grep(/\A(?:BUNDLE|BUNDLER|RUBY(?:OPT|LIB))/).to_h { |name| [name, nil] }
|
|
24
25
|
load_path = [File.expand_path("..", __dir__), File.join(Gem.loaded_specs.fetch("seccomp-notify").full_gem_path, "lib")].join(File::PATH_SEPARATOR)
|
|
@@ -53,7 +54,7 @@ module Straycall
|
|
|
53
54
|
listener = Seccomp::Notify::Filter.install!(program, flags:)
|
|
54
55
|
Seccomp::Notify::FdPassing.send_fd(transfer_socket, listener)
|
|
55
56
|
listener.close
|
|
56
|
-
transfer_socket
|
|
57
|
+
@transfer_socket = transfer_socket
|
|
57
58
|
unless IO.select([health_reader], nil, nil, 5) && health_reader.read(1) == "S"
|
|
58
59
|
raise Error, "straycall supervisor failed to initialize"
|
|
59
60
|
end
|
data/lib/straycall/minitest.rb
CHANGED
|
@@ -17,7 +17,15 @@ module Straycall
|
|
|
17
17
|
module_function
|
|
18
18
|
|
|
19
19
|
def install
|
|
20
|
-
|
|
20
|
+
if RUBY_PLATFORM.include?("linux") && RUBY_ENGINE == "ruby"
|
|
21
|
+
require "seccomp/notify"
|
|
22
|
+
require_relative "target"
|
|
23
|
+
return configure_hooks if Seccomp::Notify.supervisor_alive?
|
|
24
|
+
end
|
|
25
|
+
unless Straycall.available?
|
|
26
|
+
Straycall.warn_unavailable!
|
|
27
|
+
return
|
|
28
|
+
end
|
|
21
29
|
|
|
22
30
|
require "seccomp/notify"
|
|
23
31
|
require_relative "in_process"
|
|
@@ -25,6 +33,10 @@ module Straycall
|
|
|
25
33
|
supervisor_socket, target_socket = UNIXSocket.pair
|
|
26
34
|
InProcess.install(Straycall.config, report_socket: supervisor_socket, inherited_report_socket: target_socket)
|
|
27
35
|
Target.start(target_socket)
|
|
36
|
+
configure_hooks
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def configure_hooks
|
|
28
40
|
::Minitest::Test.prepend(Hooks)
|
|
29
41
|
::Minitest.after_run do
|
|
30
42
|
begin
|
|
@@ -6,21 +6,27 @@ module Straycall
|
|
|
6
6
|
def initialize
|
|
7
7
|
@default = :allow
|
|
8
8
|
@allowed = []
|
|
9
|
+
@allowed_roots = []
|
|
9
10
|
@denied = []
|
|
10
11
|
end
|
|
11
12
|
|
|
12
13
|
def allow(path)
|
|
13
14
|
validate_path!(path)
|
|
14
|
-
@allowed <<
|
|
15
|
+
@allowed << normalize(path)
|
|
15
16
|
end
|
|
16
17
|
|
|
17
18
|
def deny_elsewhere!
|
|
18
19
|
@default = :deny
|
|
19
20
|
end
|
|
20
21
|
|
|
22
|
+
def allow_under(path)
|
|
23
|
+
validate_path!(path)
|
|
24
|
+
@allowed_roots << normalize(path)
|
|
25
|
+
end
|
|
26
|
+
|
|
21
27
|
def deny(path)
|
|
22
28
|
validate_path!(path)
|
|
23
|
-
@denied <<
|
|
29
|
+
@denied << normalize(path)
|
|
24
30
|
end
|
|
25
31
|
|
|
26
32
|
def active?
|
|
@@ -28,8 +34,8 @@ module Straycall
|
|
|
28
34
|
end
|
|
29
35
|
|
|
30
36
|
def allowed?(path)
|
|
31
|
-
path =
|
|
32
|
-
@default == :allow ? !@denied.include?(path) : @allowed.include?(path)
|
|
37
|
+
path = normalize(path)
|
|
38
|
+
@default == :allow ? !@denied.include?(path) : @allowed.include?(path) || @allowed_roots.any? { |root| under?(path, root) }
|
|
33
39
|
end
|
|
34
40
|
|
|
35
41
|
private
|
|
@@ -39,6 +45,16 @@ module Straycall
|
|
|
39
45
|
|
|
40
46
|
raise ConfigurationError, "executable paths must be non-empty strings without NUL bytes"
|
|
41
47
|
end
|
|
48
|
+
|
|
49
|
+
def normalize(path)
|
|
50
|
+
File.realpath(path)
|
|
51
|
+
rescue SystemCallError
|
|
52
|
+
File.expand_path(path)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def under?(path, root)
|
|
56
|
+
path == root || path.start_with?("#{root}#{File::SEPARATOR}")
|
|
57
|
+
end
|
|
42
58
|
end
|
|
43
59
|
end
|
|
44
60
|
end
|
|
@@ -63,7 +63,7 @@ module Straycall
|
|
|
63
63
|
File.readlink("/proc/#{tid}/cwd")
|
|
64
64
|
else
|
|
65
65
|
File.readlink("/proc/#{tid}/fd/#{signed(dirfd)}")
|
|
66
|
-
end
|
|
66
|
+
end.delete_suffix(" (deleted)")
|
|
67
67
|
File.expand_path(path, base)
|
|
68
68
|
rescue SystemCallError => error
|
|
69
69
|
raise PathResolutionError, "cannot resolve target path through /proc: #{error.message}"
|
|
@@ -12,6 +12,8 @@ module Straycall
|
|
|
12
12
|
@default = :deny
|
|
13
13
|
@inet = []
|
|
14
14
|
@unix = []
|
|
15
|
+
@domains = []
|
|
16
|
+
@allow_unbound_listen = false
|
|
15
17
|
end
|
|
16
18
|
|
|
17
19
|
def deny!
|
|
@@ -47,17 +49,43 @@ module Straycall
|
|
|
47
49
|
raise ConfigurationError, "cannot resolve host #{host.inspect}: #{error.message}"
|
|
48
50
|
end
|
|
49
51
|
|
|
52
|
+
def allow_domain(domain)
|
|
53
|
+
raise ConfigurationError, "socket domains must be non-negative integers" unless domain.is_a?(Integer) && domain >= 0
|
|
54
|
+
|
|
55
|
+
@domains << domain
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def allow_unbound_listen!
|
|
59
|
+
@allow_unbound_listen = true
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def allowed_domain?(domain)
|
|
63
|
+
@default == :allow || @domains.include?(domain)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def unbound_listen_allowed?
|
|
67
|
+
@default == :allow || @allow_unbound_listen
|
|
68
|
+
end
|
|
69
|
+
|
|
50
70
|
def allowed?(address)
|
|
51
71
|
return true if @default == :allow
|
|
52
|
-
|
|
72
|
+
if address.unix?
|
|
73
|
+
path = address.unix_path
|
|
74
|
+
return @unix.any? { |prefix| path == prefix || path.start_with?(File.join(prefix, "")) }
|
|
75
|
+
end
|
|
53
76
|
return false unless address.ip?
|
|
54
77
|
|
|
55
78
|
ip = IPAddr.new(address.ip_address.split("%", 2).first)
|
|
79
|
+
ip = ip.native if ip.ipv4_mapped?
|
|
56
80
|
@inet.any? do |rule|
|
|
57
81
|
rule.address.include?(ip) && (rule.ports.nil? || rule.ports.any? { |port| port === address.ip_port })
|
|
58
82
|
end
|
|
59
83
|
end
|
|
60
84
|
|
|
85
|
+
def endpoint?(address)
|
|
86
|
+
address.unix? || address.ip?
|
|
87
|
+
end
|
|
88
|
+
|
|
61
89
|
def allow_all?
|
|
62
90
|
@default == :allow
|
|
63
91
|
end
|
|
@@ -71,7 +99,8 @@ module Straycall
|
|
|
71
99
|
def normalize_ports(ports)
|
|
72
100
|
return if ports.nil?
|
|
73
101
|
|
|
74
|
-
|
|
102
|
+
ports = [ports] unless ports.is_a?(Array)
|
|
103
|
+
ports.each do |port|
|
|
75
104
|
next if port.is_a?(Range) && port.begin.is_a?(Integer) && port.end.is_a?(Integer) && port.begin.between?(1, port.end) && port.end <= 65_535
|
|
76
105
|
next if port.is_a?(Integer) && (1..65_535).cover?(port)
|
|
77
106
|
|
data/lib/straycall/policy.rb
CHANGED
|
@@ -2,31 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
module Straycall
|
|
4
4
|
module Policy
|
|
5
|
-
FORBIDDEN_NOTIFY = %i[read write recvfrom ppoll poll select futex clock_nanosleep rt_sigreturn].freeze
|
|
6
|
-
|
|
7
5
|
module_function
|
|
8
6
|
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
NETWORK_NOTIFY = %i[socket connect bind listen sendto sendmsg sendmmsg].freeze
|
|
8
|
+
FILESYSTEM_NOTIFY = %i[
|
|
9
|
+
openat openat2 unlinkat renameat renameat2 mkdirat linkat symlinkat
|
|
10
|
+
truncate ftruncate fchmod fchmodat fchmodat2 fchown fchownat utimensat copy_file_range mmap
|
|
11
|
+
].freeze
|
|
12
|
+
LEGACY_FILESYSTEM_NOTIFY = %i[
|
|
13
|
+
open creat unlink rename mkdir rmdir link symlink chmod chown lchown
|
|
14
|
+
].freeze
|
|
11
15
|
EXEC_NOTIFY = %i[execve execveat].freeze
|
|
12
16
|
|
|
13
17
|
def seccomp(config)
|
|
14
18
|
notified = notification_names(config)
|
|
15
|
-
validate_notified!(*notified)
|
|
16
19
|
|
|
17
20
|
conditional = []
|
|
18
21
|
conditional << :openat if notified.include?(:openat) && !config.filesystem.read_denials?
|
|
19
22
|
conditional << :open if notified.include?(:open) && !config.filesystem.read_denials?
|
|
23
|
+
conditional << :mmap if notified.include?(:mmap)
|
|
20
24
|
Seccomp::Notify::Policy.new(deny_io_uring: !config.io_uring_allowed?) do
|
|
21
25
|
notify(*(notified - conditional))
|
|
22
26
|
notify_if(:openat, argument: 2, mask: Policy::Filesystem::WRITE_FLAGS) if conditional.include?(:openat)
|
|
23
27
|
notify_if(:open, argument: 1, mask: Policy::Filesystem::WRITE_FLAGS) if conditional.include?(:open)
|
|
28
|
+
notify_if(:mmap, argument: 3, mask: 1) if conditional.include?(:mmap)
|
|
24
29
|
deny :ptrace
|
|
25
30
|
end
|
|
26
31
|
end
|
|
27
32
|
|
|
28
33
|
def notification_names(config)
|
|
29
|
-
names =
|
|
34
|
+
names = config.network.allow_all? && config.on_violation != :record ? [] : NETWORK_NOTIFY.dup
|
|
30
35
|
if config.filesystem.active? || config.on_violation == :record
|
|
31
36
|
names.concat(FILESYSTEM_NOTIFY)
|
|
32
37
|
names.concat(LEGACY_FILESYSTEM_NOTIFY) if RUBY_PLATFORM.include?("linux") && RUBY_PLATFORM.include?("x86_64")
|
|
@@ -35,11 +40,5 @@ module Straycall
|
|
|
35
40
|
names
|
|
36
41
|
end
|
|
37
42
|
|
|
38
|
-
def validate_notified!(*names)
|
|
39
|
-
invalid = names.map(&:to_sym) & FORBIDDEN_NOTIFY
|
|
40
|
-
raise ConfigurationError, "cannot notify reporter syscall(s): #{invalid.join(", ")}" unless invalid.empty?
|
|
41
|
-
|
|
42
|
-
true
|
|
43
|
-
end
|
|
44
43
|
end
|
|
45
44
|
end
|
data/lib/straycall/recorder.rb
CHANGED
|
@@ -5,8 +5,9 @@ require "fileutils"
|
|
|
5
5
|
|
|
6
6
|
module Straycall
|
|
7
7
|
class Recorder
|
|
8
|
-
def initialize(path = ".straycall.yml")
|
|
8
|
+
def initialize(path = ".straycall.recorded.yml", force: false)
|
|
9
9
|
@path = path
|
|
10
|
+
@force = force
|
|
10
11
|
end
|
|
11
12
|
|
|
12
13
|
def write(violations)
|
|
@@ -18,21 +19,30 @@ module Straycall
|
|
|
18
19
|
end
|
|
19
20
|
end.uniq
|
|
20
21
|
unix = violations.filter_map { |item| item.target[:path] if item.target[:type] == "unix" && item.target[:path] != "?" }.uniq
|
|
22
|
+
domains = violations.filter_map { |item| item.target[:domain] if item.target[:type] == "socket" && item.target[:domain] }.uniq
|
|
23
|
+
unbound_listen = violations.any? { |item| item.target[:type] == "socket" && item.target[:operation] == "listen" }
|
|
21
24
|
writes = violations.filter_map do |item|
|
|
22
|
-
|
|
25
|
+
item.target[:path] if item.target[:type] == "file" && item.target[:path] != "?" && item.target[:operation] != "read"
|
|
23
26
|
end.uniq
|
|
24
27
|
executables = violations.filter_map do |item|
|
|
25
28
|
item.target[:path] if item.target[:type] == "exec" && item.target[:path] != "?"
|
|
26
29
|
end.uniq
|
|
27
30
|
policy = {
|
|
28
|
-
"network" => {
|
|
31
|
+
"network" => {
|
|
32
|
+
"default" => "deny",
|
|
33
|
+
"allow" => network + unix.map { |path| {"unix" => path} } + domains.map { |domain| {"domain" => domain} },
|
|
34
|
+
"allow_unbound_listen" => unbound_listen
|
|
35
|
+
},
|
|
29
36
|
"write" => {"default" => "deny", "allow" => writes},
|
|
30
37
|
"exec" => {"default" => "deny", "allow" => executables},
|
|
31
38
|
"on_violation" => "fail"
|
|
32
39
|
}
|
|
33
40
|
FileUtils.mkdir_p(File.dirname(@path))
|
|
34
|
-
File
|
|
41
|
+
flags = File::WRONLY | File::CREAT | (@force ? File::TRUNC : File::EXCL)
|
|
42
|
+
File.open(@path, flags, 0o644) { |file| file.write(YAML.dump(policy)) }
|
|
35
43
|
@path
|
|
44
|
+
rescue Errno::EEXIST
|
|
45
|
+
raise ConfigurationError, "refusing to overwrite #{@path}; pass --force to replace it"
|
|
36
46
|
end
|
|
37
47
|
end
|
|
38
48
|
end
|
data/lib/straycall/rspec.rb
CHANGED
|
@@ -8,7 +8,15 @@ module Straycall
|
|
|
8
8
|
module_function
|
|
9
9
|
|
|
10
10
|
def install
|
|
11
|
-
|
|
11
|
+
if RUBY_PLATFORM.include?("linux") && RUBY_ENGINE == "ruby"
|
|
12
|
+
require "seccomp/notify"
|
|
13
|
+
require_relative "target"
|
|
14
|
+
return configure_hooks if Seccomp::Notify.supervisor_alive?
|
|
15
|
+
end
|
|
16
|
+
unless Straycall.available?
|
|
17
|
+
Straycall.warn_unavailable!
|
|
18
|
+
return
|
|
19
|
+
end
|
|
12
20
|
|
|
13
21
|
require "seccomp/notify"
|
|
14
22
|
require_relative "in_process"
|
data/lib/straycall/supervisor.rb
CHANGED
|
@@ -8,6 +8,7 @@ require_relative "reporter/symbolizer"
|
|
|
8
8
|
require_relative "prompt"
|
|
9
9
|
require_relative "handlers/filesystem"
|
|
10
10
|
require_relative "handlers/exec"
|
|
11
|
+
require "shellwords"
|
|
11
12
|
|
|
12
13
|
module Straycall
|
|
13
14
|
class Supervisor
|
|
@@ -15,8 +16,10 @@ module Straycall
|
|
|
15
16
|
include Handlers::Exec
|
|
16
17
|
|
|
17
18
|
ENDPOINT_SOCKET_DOMAINS = [Socket::AF_UNIX, Socket::AF_INET, Socket::AF_INET6].freeze
|
|
19
|
+
LOCAL_SOCKET_DOMAINS = Socket.const_defined?(:AF_NETLINK, false) ? [Socket.const_get(:AF_NETLINK, false)].freeze : [].freeze
|
|
18
20
|
MMSGHDR_SIZE = 64
|
|
19
21
|
MAX_MMSG = 1024
|
|
22
|
+
REPORT_INTERVAL = 0.1
|
|
20
23
|
|
|
21
24
|
attr_reader :violations
|
|
22
25
|
|
|
@@ -40,14 +43,15 @@ module Straycall
|
|
|
40
43
|
reporter = File.expand_path("target", __dir__)
|
|
41
44
|
inherited_rubyopt = environment.key?("RUBYOPT") ? environment["RUBYOPT"] : ENV["RUBYOPT"]
|
|
42
45
|
environment["STRAYCALL_REPORT_FD"] = target_socket.fileno.to_s
|
|
43
|
-
environment["
|
|
46
|
+
environment["STRAYCALL_REPORT_PID"] = Process.pid.to_s
|
|
47
|
+
environment["RUBYOPT"] = [Shellwords.escape("-r#{reporter}"), inherited_rubyopt].compact.join(" ")
|
|
44
48
|
exec(environment, *argv)
|
|
45
49
|
end
|
|
46
50
|
target_socket.close
|
|
47
51
|
@reporter = Reporter::Client.new(supervisor_socket)
|
|
48
52
|
register(supervisor)
|
|
49
53
|
status = supervisor.run
|
|
50
|
-
|
|
54
|
+
flush_report
|
|
51
55
|
[status, violations]
|
|
52
56
|
ensure
|
|
53
57
|
target_socket&.close unless target_socket&.closed?
|
|
@@ -63,13 +67,23 @@ module Straycall
|
|
|
63
67
|
supervisor.on(:sendmsg) { |request| handle_sendmsg(request) }
|
|
64
68
|
supervisor.on(:sendmmsg) { |request| handle_sendmmsg(request) }
|
|
65
69
|
names = Policy.notification_names(@config)
|
|
70
|
+
cwd = Policy::Filesystem::AT_FDCWD
|
|
66
71
|
supervisor.on(:openat) { |request| handle_openat(request) } if names.include?(:openat)
|
|
67
72
|
supervisor.on(:openat2) { |request| handle_openat2(request) } if names.include?(:openat2)
|
|
68
73
|
supervisor.on(:unlinkat) { |request| handle_path_mutation(request, request.args[0], request.args[1]) } if names.include?(:unlinkat)
|
|
69
74
|
supervisor.on(:mkdirat) { |request| handle_path_mutation(request, request.args[0], request.args[1]) } if names.include?(:mkdirat)
|
|
75
|
+
supervisor.on(:symlinkat) { |request| handle_path_mutation(request, request.args[1], request.args[2]) } if names.include?(:symlinkat)
|
|
70
76
|
supervisor.on(:renameat) { |request| handle_rename(request, *request.args.values_at(0, 1, 2, 3)) } if names.include?(:renameat)
|
|
71
77
|
supervisor.on(:renameat2) { |request| handle_rename(request, *request.args.values_at(0, 1, 2, 3)) } if names.include?(:renameat2)
|
|
72
|
-
|
|
78
|
+
supervisor.on(:linkat) { |request| handle_rename(request, *request.args.values_at(0, 1, 2, 3)) } if names.include?(:linkat)
|
|
79
|
+
supervisor.on(:truncate) { |request| handle_path_mutation(request, cwd, request.args[0]) } if names.include?(:truncate)
|
|
80
|
+
supervisor.on(:ftruncate) { |request| handle_fd_mutation(request, request.args[0]) } if names.include?(:ftruncate)
|
|
81
|
+
supervisor.on(:fchmodat) { |request| handle_path_mutation(request, request.args[0], request.args[1]) } if names.include?(:fchmodat)
|
|
82
|
+
supervisor.on(:fchmodat2) { |request| handle_path_mutation(request, request.args[0], request.args[1]) } if names.include?(:fchmodat2)
|
|
83
|
+
supervisor.on(:fchownat) { |request| handle_path_mutation(request, request.args[0], request.args[1]) } if names.include?(:fchownat)
|
|
84
|
+
supervisor.on(:utimensat) { |request| handle_path_mutation(request, request.args[0], request.args[1]) } if names.include?(:utimensat)
|
|
85
|
+
supervisor.on(:copy_file_range) { |request| handle_fd_mutation(request, request.args[2]) } if names.include?(:copy_file_range)
|
|
86
|
+
supervisor.on(:mmap) { |request| handle_mmap(request) } if names.include?(:mmap)
|
|
73
87
|
supervisor.on(:open) { |request| handle_open(request, cwd, request.args[0], request.args[1]) } if names.include?(:open)
|
|
74
88
|
if names.include?(:creat)
|
|
75
89
|
supervisor.on(:creat) do |request|
|
|
@@ -79,17 +93,32 @@ module Straycall
|
|
|
79
93
|
end
|
|
80
94
|
supervisor.on(:unlink) { |request| handle_path_mutation(request, cwd, request.args[0]) } if names.include?(:unlink)
|
|
81
95
|
supervisor.on(:mkdir) { |request| handle_path_mutation(request, cwd, request.args[0]) } if names.include?(:mkdir)
|
|
96
|
+
supervisor.on(:rmdir) { |request| handle_path_mutation(request, cwd, request.args[0]) } if names.include?(:rmdir)
|
|
97
|
+
supervisor.on(:symlink) { |request| handle_path_mutation(request, cwd, request.args[1]) } if names.include?(:symlink)
|
|
98
|
+
%i[chmod chown lchown].each do |name|
|
|
99
|
+
supervisor.on(name) { |request| handle_path_mutation(request, cwd, request.args[0]) } if names.include?(name)
|
|
100
|
+
end
|
|
101
|
+
%i[fchmod fchown].each do |name|
|
|
102
|
+
supervisor.on(name) { |request| handle_fd_mutation(request, request.args[0]) } if names.include?(name)
|
|
103
|
+
end
|
|
82
104
|
supervisor.on(:rename) { |request| handle_rename(request, cwd, request.args[0], cwd, request.args[1]) } if names.include?(:rename)
|
|
105
|
+
supervisor.on(:link) { |request| handle_rename(request, cwd, request.args[0], cwd, request.args[1]) } if names.include?(:link)
|
|
83
106
|
supervisor.on(:execve) { |request| handle_exec(request, nil, 0) } if names.include?(:execve)
|
|
84
107
|
supervisor.on(:execveat) { |request| handle_exec(request, 0, 1) } if names.include?(:execveat)
|
|
85
108
|
supervisor
|
|
86
109
|
end
|
|
87
110
|
|
|
111
|
+
def flush_report
|
|
112
|
+
write_report(force: true)
|
|
113
|
+
end
|
|
114
|
+
|
|
88
115
|
private
|
|
89
116
|
|
|
90
117
|
def handle_socket(request)
|
|
91
118
|
domain = request.args[0]
|
|
92
|
-
|
|
119
|
+
allowed = ENDPOINT_SOCKET_DOMAINS.include?(domain) || LOCAL_SOCKET_DOMAINS.include?(domain)
|
|
120
|
+
allowed ||= @config.network.allowed_domain?(domain) && @config.on_violation != :record
|
|
121
|
+
return request.continue! if allowed
|
|
93
122
|
|
|
94
123
|
record_violation(request, {type: "socket", domain:, operation: "socket"}, nil)
|
|
95
124
|
end
|
|
@@ -99,6 +128,7 @@ module Straycall
|
|
|
99
128
|
|
|
100
129
|
address = request.read_sockaddr(request.args[1], request.args[2])
|
|
101
130
|
return request.error!(Errno::EPERM) unless request.valid?
|
|
131
|
+
return request.continue!(unsafe: true) unless @config.network.endpoint?(address)
|
|
102
132
|
if @config.network.allowed?(address) && @config.on_violation != :record
|
|
103
133
|
remember_bound_socket(request, address)
|
|
104
134
|
return request.continue!(unsafe: true)
|
|
@@ -111,10 +141,11 @@ module Straycall
|
|
|
111
141
|
end
|
|
112
142
|
|
|
113
143
|
def handle_listen(request)
|
|
114
|
-
return request.continue! if @config.network.allow_all?
|
|
144
|
+
return request.continue! if @config.network.allow_all? && @config.on_violation != :record
|
|
115
145
|
|
|
116
146
|
identity = socket_identity(request.tid, request.args[0])
|
|
117
147
|
address = @mutex.synchronize { @bound_sockets[identity] }
|
|
148
|
+
return request.continue! if !address && @config.network.unbound_listen_allowed? && @config.on_violation != :record
|
|
118
149
|
if address && @config.network.allowed?(address) && @config.on_violation != :record
|
|
119
150
|
return request.continue!
|
|
120
151
|
end
|
|
@@ -134,6 +165,7 @@ module Straycall
|
|
|
134
165
|
|
|
135
166
|
address = request.read_sockaddr(request.args[address_arg], request.args[length_arg])
|
|
136
167
|
return request.error!(Errno::EPERM) unless request.valid?
|
|
168
|
+
return request.continue!(unsafe: true) unless @config.network.endpoint?(address)
|
|
137
169
|
if @config.network.allowed?(address) && @config.on_violation != :record
|
|
138
170
|
return request.continue!(unsafe: true)
|
|
139
171
|
end
|
|
@@ -151,6 +183,7 @@ module Straycall
|
|
|
151
183
|
|
|
152
184
|
address = request.read_sockaddr(name, length)
|
|
153
185
|
return request.error!(Errno::EPERM) unless request.valid?
|
|
186
|
+
return request.continue!(unsafe: true) unless @config.network.endpoint?(address)
|
|
154
187
|
if @config.network.allowed?(address) && @config.on_violation != :record
|
|
155
188
|
return request.continue!(unsafe: true)
|
|
156
189
|
end
|
|
@@ -171,6 +204,7 @@ module Straycall
|
|
|
171
204
|
request.read_sockaddr(name, length) unless name.zero? || length.zero?
|
|
172
205
|
end
|
|
173
206
|
return request.error!(Errno::EPERM) unless request.valid?
|
|
207
|
+
destinations.select! { |address| @config.network.endpoint?(address) }
|
|
174
208
|
observed = if @config.on_violation == :record
|
|
175
209
|
destinations
|
|
176
210
|
else
|
|
@@ -219,8 +253,14 @@ module Straycall
|
|
|
219
253
|
%(allow_host #{address.ip_address.inspect}, ports: [#{address.ip_port}])
|
|
220
254
|
end
|
|
221
255
|
|
|
222
|
-
def write_report
|
|
223
|
-
|
|
256
|
+
def write_report(force: false)
|
|
257
|
+
return unless @config.report_path
|
|
258
|
+
|
|
259
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
260
|
+
return if !force && @last_report_at && now - @last_report_at < REPORT_INTERVAL
|
|
261
|
+
|
|
262
|
+
Report::JSON.new(@config.report_path).write(violations)
|
|
263
|
+
@last_report_at = now
|
|
224
264
|
end
|
|
225
265
|
|
|
226
266
|
def report_target(target)
|
data/lib/straycall/target.rb
CHANGED
|
@@ -25,7 +25,19 @@ module Straycall
|
|
|
25
25
|
@reporter&.stop
|
|
26
26
|
@reporter = nil
|
|
27
27
|
end
|
|
28
|
+
|
|
29
|
+
def start_from_environment
|
|
30
|
+
return unless ENV["STRAYCALL_REPORT_FD"]
|
|
31
|
+
if ENV["STRAYCALL_REPORT_PID"] && Integer(ENV["STRAYCALL_REPORT_PID"]) != Process.pid
|
|
32
|
+
IO.for_fd(Integer(ENV["STRAYCALL_REPORT_FD"]), autoclose: true).close
|
|
33
|
+
return
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
start
|
|
37
|
+
rescue ArgumentError, SystemCallError
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
28
40
|
end
|
|
29
41
|
end
|
|
30
42
|
|
|
31
|
-
Straycall::Target.
|
|
43
|
+
Straycall::Target.start_from_environment
|
data/lib/straycall/version.rb
CHANGED
data/lib/straycall.rb
CHANGED
|
@@ -34,6 +34,14 @@ module Straycall
|
|
|
34
34
|
|
|
35
35
|
def reset!
|
|
36
36
|
@config = Config.new
|
|
37
|
+
@warned_unavailable = false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def warn_unavailable!
|
|
41
|
+
return if ENV["STRAYCALL"] == "0" || !RUBY_PLATFORM.include?("linux") || @warned_unavailable
|
|
42
|
+
|
|
43
|
+
@warned_unavailable = true
|
|
44
|
+
warn "straycall: seccomp user notifications are unavailable; running unmonitored"
|
|
37
45
|
end
|
|
38
46
|
end
|
|
39
47
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: straycall
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yudai Takada
|
|
@@ -15,14 +15,14 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - "~>"
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '0.
|
|
18
|
+
version: '0.3'
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: '0.
|
|
25
|
+
version: '0.3'
|
|
26
26
|
description: A Linux seccomp-notify sandbox that reports network, filesystem, and
|
|
27
27
|
process violations.
|
|
28
28
|
email:
|