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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3148046a174ba55ee9edf9d943c9e834391ec89f16684df60bae3b3bcecbfcf4
4
- data.tar.gz: ed337c7d5cf746f9384f9ee9d6397aa53926ecd3e770adf162e43996a820bda4
3
+ metadata.gz: 40a825fdd8eb649cf8455faec21e607cac5bf89f93c1136fdec311990361d1ae
4
+ data.tar.gz: e3eac0d6b59c484f603bf17ebb7ea72a4fc5d1e27916ada148572712b050ae09
5
5
  SHA512:
6
- metadata.gz: 7e8833fbe0ec64716d1ca1bf179b2b7572006e0ea4743d351f1813e10d16a9de61e9ca7d192adffab6e239b610c290b86a247db844b82c18b6cc92f5a0b54611
7
- data.tar.gz: 27df7b94480aaeb6c99742b55a32ad0f212b988fabcea451fbe8d6a1063baad91719060e14efb104c32849a25fa1cf458a42b7a0dd41097232b1b3ed5612eef0
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
- # Straycall
2
-
3
- Straycall reports and blocks unintended network, filesystem, and process access from Ruby builds and test suites. It runs an unfiltered supervisor over Linux `seccomp` user notifications and includes the responsible Ruby backtrace in its report.
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 macOS, Windows, non-MRI Ruby, an unsupported kernel, or with `STRAYCALL=0`, requiring and configuring Straycall is a no-op. The wrapped command runs normally.
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
- - Linux x86_64 or aarch64 with seccomp user notifications and `CONTINUE` (Linux 5.5+)
13
- - MRI Ruby 3.1+
14
- - `seccomp-notify` 0.2.x
35
+ ## Features
15
36
 
16
- Container runtimes may block `seccomp(2)` in their own profile. Use an isolated container with `--security-opt seccomp=unconfined` when needed.
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
- ## CLI (recommended)
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
- straycall --record --policy-path .straycall.yml -- bundle exec rspec
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
- `--warn` and `--record` allow the syscall after reporting it. `--prompt` asks through `/dev/tty`; without a TTY it denies. The default `fail` mode and a denied prompt return `EPERM` and make the CLI exit nonzero.
98
+ ### Violation Modes
36
99
 
37
- ## YAML policy
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: allow
52
- deny: [/usr/bin/curl, /usr/bin/wget]
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
- Hosts are resolved once while loading the configuration. Paths are expanded but symbolic links are deliberately not resolved.
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
- ## Ruby configuration
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
- ## RSpec and Minitest
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
- This in-process mode starts a clean detached supervisor with `Process.spawn`, associates violations with the current test, and checks the supervisor health pipe. Prefer the CLI if tests use broad `Process.wait` calls or fork heavily; filters are inherited and cannot be removed.
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
- ## Reports
193
+ ### Reports
97
194
 
98
- Console output includes the syscall, target, current test, Ruby backtrace (MRI), a `/proc/<tid>/maps` native-library fallback, and an allow-list suggestion. `report_path` writes a versioned JSON document:
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
- {"version":1,"violations":[{"syscall":"connect","tid":123,"target":{"type":"inet","host":"192.0.2.1","port":443},"action":"denied"}]}
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
- ## Known limitations
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
- 1. This is not a security boundary. Allowing a pointer-based syscall with `continue!` is subject to TOCTOU.
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`/`sendmsg`/`sendmmsg` without an explicit destination relies on the earlier monitored `connect`; Straycall cannot recover a peer address from the target file descriptor.
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
- ruby -Ilib -S rspec
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, using the median ratio of five alternating pairs after warmup. Set `STRAYCALL_BENCHMARK_FILES` to scale the workload.
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).
@@ -27,3 +27,4 @@ supervisor = Seccomp::Notify::Supervisor.new(
27
27
  guard.register(supervisor)
28
28
  health_writer.write("S")
29
29
  supervisor.run
30
+ guard.flush_report
data/lib/straycall/cli.rb CHANGED
@@ -17,14 +17,17 @@ module Straycall
17
17
 
18
18
  def run
19
19
  parse!
20
- return exec(*@argv) unless Straycall.available?
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
@@ -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
- raise ConfigurationError, "network allow rule requires exactly one of host or unix" if unix == host
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
- else
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)
@@ -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 = true
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.close
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
@@ -17,7 +17,15 @@ module Straycall
17
17
  module_function
18
18
 
19
19
  def install
20
- return unless Straycall.available?
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 << File.expand_path(path)
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 << File.expand_path(path)
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 = File.expand_path(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
- return @unix.any? { |prefix| address.unix_path.start_with?(prefix) } if address.unix?
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
- Array(ports).each do |port|
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
 
@@ -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
- FILESYSTEM_NOTIFY = %i[openat openat2 unlinkat renameat renameat2 mkdirat].freeze
10
- LEGACY_FILESYSTEM_NOTIFY = %i[open creat unlink rename mkdir].freeze
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 = %i[socket connect bind listen sendto sendmsg sendmmsg]
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
@@ -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
- File.dirname(item.target[:path]) if item.target[:type] == "file" && item.target[:path] != "?"
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" => {"default" => "deny", "allow" => network + unix.map { |path| {"unix" => path} }},
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.write(@path, YAML.dump(policy))
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
@@ -8,7 +8,15 @@ module Straycall
8
8
  module_function
9
9
 
10
10
  def install
11
- return unless Straycall.available?
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"
@@ -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["RUBYOPT"] = ["-r#{reporter}", inherited_rubyopt].compact.join(" ")
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
- write_report
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
- cwd = Policy::Filesystem::AT_FDCWD
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
- return request.continue! if @config.network.allow_all? || ENDPOINT_SOCKET_DOMAINS.include?(domain)
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
- Report::JSON.new(@config.report_path).write(violations) if @config.report_path
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)
@@ -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.start if ENV["STRAYCALL_REPORT_FD"]
43
+ Straycall::Target.start_from_environment
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Straycall
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
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.1.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.2'
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.2'
25
+ version: '0.3'
26
26
  description: A Linux seccomp-notify sandbox that reports network, filesystem, and
27
27
  process violations.
28
28
  email: