seccomp-notify 0.2.0 → 0.3.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: ace169c2d9c0677fc63afda27695ec1afcf8dbfa2ee0b7e935690ee1b48c9c13
4
- data.tar.gz: 159c4ea512b51d37cef181ba13cfc95b176b7ad5044ab1131cdc626ac281f419
3
+ metadata.gz: 1338763ff6ac458c1224576b7763524b57034de1b29423acc2dde48268012dd1
4
+ data.tar.gz: 03dc39b44de12c7bb1401edc303097eb02b389c511e040589c2293cb840d2c41
5
5
  SHA512:
6
- metadata.gz: 6875777a81e6507e7ea582ecfe3078b193c9dda9a896e424112e1976c3ee6bd9e943f41294bed8dd49e50cb2a8aa91f9560c3093ad49700402b154c4c1af627b
7
- data.tar.gz: 0cb483dd0df1793bf30df385c73bd14daf2c30ed81dabcbe714e8ecdbc56c84308386b9c277145c206bc2543465370ec080b11e8ebefdc39d048aab8c56e4133
6
+ metadata.gz: 9c99782a7d1c9bf43d3069aa1f742f1c3f5e33b40a47014c6e5a3d4803062b8d8239e2df8a915583d056a4441ed438deebde1ad1cc8af9e1a1bdd8b5f95e333f
7
+ data.tar.gz: dd0f9b82f5749bdeb933e6e56c616619f150fadbb9742582f03ef32ff8d8401e7732dff4070b7a0860abcd6bb42ed537047cda119fa314891c99ab51af0bbd4e
data/CHANGELOG.md CHANGED
@@ -5,6 +5,15 @@ All notable changes to seccomp-notify are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and versions follow [Semantic Versioning](https://semver.org/).
7
7
 
8
+ ## v0.3.0 - 2026-08-24
9
+
10
+ - Kill the issuing thread group without treating a thread ID as a process ID.
11
+ - Keep the listener transfer descriptor reserved so later `sendmsg` calls cannot bypass policy.
12
+ - Reject x32 syscalls, conflicting policy actions, and invalid emulated return values.
13
+ - Preserve the protocol-neutral type of decoded socket addresses.
14
+ - Marshal ioctl and seccomp buffers through native memory for compacting-GC safety.
15
+ - Reduce runtime feature detection from four isolated child probes to two and synchronize supervisor shutdown state.
16
+
8
17
  ## v0.2.0 - 2026-08-22
9
18
 
10
19
  - Stop supervising a non-child target once it becomes a zombie.
data/README.md CHANGED
@@ -102,7 +102,8 @@ syscall, not the process ID.
102
102
  `supervise_self(policy, supervisor: :fork)` accepts a block that configures the child supervisor.
103
103
  The safer `:spawn` form starts a clean Ruby VM and supports the default pass-through handler only.
104
104
  Both forms detach the supervisor child, so broad `Process.wait` calls in the target can still
105
- observe related lifecycle effects.
105
+ observe related lifecycle effects. The default pass-through handler requires `continue!` and Linux
106
+ 5.5 or newer; handler-based supervision works on Linux 5.0 or newer.
106
107
 
107
108
  ## Responses
108
109
 
@@ -137,8 +138,9 @@ If the target calls `exec`, the pipe descriptor is inherited and published as
137
138
  - I/O submitted through an existing `io_uring` does not pass through the filter;
138
139
  `io_uring_setup` is denied by default.
139
140
  - If the supervisor dies, blocked notification syscalls return `ENOSYS`.
140
- - `fcntl` and `sendmsg` must remain allowed because Ruby uses them to wrap and transfer the listener
141
- descriptor. Policies that notify or deny them are rejected.
141
+ - `fcntl` must remain allowed while Ruby wraps the listener descriptor, so policies that notify or
142
+ deny it are rejected. `sendmsg` may be filtered; only listener transfer on its reserved descriptor
143
+ is allowed, and that descriptor remains open across `exec` to prevent fd-number reuse.
142
144
 
143
145
  ## Examples
144
146
 
@@ -32,12 +32,17 @@ module Seccomp
32
32
  private
33
33
 
34
34
  def prologue
35
- [
35
+ instructions = [
36
36
  ins(Constants::BPF_LD_W_ABS, 0, 0, Constants::OFF_ARCH),
37
37
  ins(Constants::BPF_JMP_JEQ_K, 1, 0, AUDIT_ARCHES.fetch(@arch)),
38
38
  ins(Constants::BPF_RET_K, 0, 0, Constants::SECCOMP_RET_KILL_PROCESS),
39
39
  ins(Constants::BPF_LD_W_ABS, 0, 0, Constants::OFF_NR)
40
40
  ]
41
+ if @arch == :x86_64
42
+ instructions << ins(Constants::BPF_JMP_JGE_K, 0, 1, Constants::X32_SYSCALL_BIT)
43
+ instructions << ins(Constants::BPF_RET_K, 0, 0, Constants::SECCOMP_RET_KILL_PROCESS)
44
+ end
45
+ instructions
41
46
  end
42
47
 
43
48
  def linear(decisions)
@@ -32,7 +32,9 @@ module Seccomp
32
32
  AUDIT_ARCH_X86_64 = 0xc000_003e
33
33
  AUDIT_ARCH_AARCH64 = 0xc000_00b7
34
34
  AUDIT_ARCH_I386 = 0x4000_0003
35
+ X32_SYSCALL_BIT = 0x4000_0000
35
36
  SYS_SECCOMP = {x86_64: 317, aarch64: 277}.freeze
37
+ SYS_TGKILL = {x86_64: 234, aarch64: 131}.freeze
36
38
 
37
39
  PR_SET_NO_NEW_PRIVS = 38
38
40
  PR_SET_PTRACER = 0x5961_6d61
@@ -12,9 +12,7 @@ module Seccomp
12
12
  result = {user_notif: supported?, continue: false, addfd: false, addfd_send: false, wait_killable_recv: false}
13
13
  return result.freeze unless result[:user_notif]
14
14
 
15
- result[:continue] = probe_response(Constants::SECCOMP_USER_NOTIF_FLAG_CONTINUE)
16
- result[:addfd] = probe_addfd(0)
17
- result[:addfd_send] = probe_addfd(Constants::SECCOMP_ADDFD_FLAG_SEND)
15
+ result.update(probe_responses)
18
16
  result[:wait_killable_recv] = probe_install(Constants::SECCOMP_FILTER_FLAG_WAIT_KILLABLE_RECV)
19
17
  disabled.each { |name| result[name.to_sym] = false if result.key?(name.to_sym) }
20
18
  result[:addfd_send] = false unless result[:addfd]
@@ -28,21 +26,36 @@ module Seccomp
28
26
  false
29
27
  end
30
28
 
31
- def probe_response(flags)
32
- probe do |listener, request|
33
- response = [request.unpack1("Q<"), 0, 0, flags].pack(Structs::RESPONSE_FORMAT)
34
- Ioctl.call(listener, Ioctl::NOTIF_SEND, response)
35
- end
36
- end
37
-
38
- def probe_addfd(flags)
29
+ def probe_responses
30
+ parent, child = UNIXSocket.pair
31
+ listener = nil
32
+ pid = nil
39
33
  File.open("/dev/null") do |source|
40
- probe do |listener, request|
41
- id = request.unpack1("Q<")
42
- injected = Ioctl.call(listener, Ioctl::NOTIF_ADDFD, [id, flags, source.fileno, 0, 0].pack(Structs::ADDFD_FORMAT))
43
- Ioctl.call(listener, Ioctl::NOTIF_SEND, [id, injected, 0, 0].pack(Structs::RESPONSE_FORMAT)) if flags.zero?
34
+ pid = fork do
35
+ parent.close
36
+ listener = Filter.install!(BPF::Builder.new(Policy.new { notify :getpid }).build)
37
+ FdPassing.send_fd(child, listener)
38
+ 3.times { Libc::SYSCALL.call(Syscalls.number(:getpid), 0, 0, 0) }
39
+ exit! 0
40
+ rescue StandardError
41
+ exit! 1
44
42
  end
43
+ child.close
44
+ listener = FdPassing.recv_fd(parent, timeout: 2)
45
+ result = {
46
+ continue: probe_response(listener, Constants::SECCOMP_USER_NOTIF_FLAG_CONTINUE),
47
+ addfd: probe_addfd(listener, source, 0),
48
+ addfd_send: probe_addfd(listener, source, Constants::SECCOMP_ADDFD_FLAG_SEND)
49
+ }
50
+ return result if wait_for_probe(pid).success?
45
51
  end
52
+ {continue: false, addfd: false, addfd_send: false}
53
+ rescue SystemCallError, EOFError, Timeout::Error
54
+ terminate_probe(pid)
55
+ {continue: false, addfd: false, addfd_send: false}
56
+ ensure
57
+ listener&.close unless listener&.closed?
58
+ parent&.close unless parent&.closed?
46
59
  end
47
60
 
48
61
  def probe_install(flags)
@@ -67,30 +80,31 @@ module Seccomp
67
80
  parent&.close unless parent&.closed?
68
81
  end
69
82
 
70
- def probe
71
- parent, child = UNIXSocket.pair
72
- pid = fork do
73
- parent.close
74
- listener = Filter.install!(BPF::Builder.new(Policy.new { notify :getpid }).build)
75
- FdPassing.send_fd(child, listener)
76
- Libc::SYSCALL.call(Syscalls.number(:getpid), 0, 0, 0)
77
- exit! 0
78
- rescue StandardError
79
- exit! 1
80
- end
81
- child.close
82
- listener = FdPassing.recv_fd(parent, timeout: 2)
83
+ def probe_response(listener, flags)
83
84
  request = "\0" * Structs::NOTIF_SIZE
84
85
  Ioctl.call(listener, Ioctl::NOTIF_RECV, request)
85
- yield listener, request
86
- status = wait_for_probe(pid)
87
- status.success?
88
- rescue SystemCallError, EOFError, Timeout::Error
89
- terminate_probe(pid)
86
+ id = request.unpack1("Q<")
87
+ Ioctl.call(listener, Ioctl::NOTIF_SEND, [id, 0, 0, flags].pack(Structs::RESPONSE_FORMAT))
88
+ true
89
+ rescue SystemCallError
90
+ respond_to_probe(listener, id)
90
91
  false
91
- ensure
92
- listener&.close unless listener&.closed?
93
- parent&.close unless parent&.closed?
92
+ end
93
+
94
+ def probe_addfd(listener, source, flags)
95
+ request = "\0" * Structs::NOTIF_SIZE
96
+ Ioctl.call(listener, Ioctl::NOTIF_RECV, request)
97
+ id = request.unpack1("Q<")
98
+ injected = Ioctl.call(listener, Ioctl::NOTIF_ADDFD, [id, flags, source.fileno, 0, 0].pack(Structs::ADDFD_FORMAT))
99
+ respond_to_probe(listener, id, injected) if flags.zero?
100
+ true
101
+ rescue SystemCallError
102
+ respond_to_probe(listener, id)
103
+ false
104
+ end
105
+
106
+ def respond_to_probe(listener, id, value = 0)
107
+ Ioctl.call(listener, Ioctl::NOTIF_SEND, [id, value, 0, 0].pack(Structs::RESPONSE_FORMAT)) if id
94
108
  end
95
109
 
96
110
  def wait_for_probe(pid)
@@ -46,17 +46,25 @@ module Seccomp
46
46
  end
47
47
 
48
48
  def seccomp(operation, flags, buffer = 0)
49
- pointer = buffer.is_a?(String) ? Fiddle::Pointer[buffer] : buffer
50
- SYSCALL.call(Constants::SYS_SECCOMP.fetch(architecture), operation, flags, pointer.to_i)
49
+ with_pointer(buffer) do |pointer|
50
+ SYSCALL.call(Constants::SYS_SECCOMP.fetch(architecture), operation, flags, pointer.to_i)
51
+ end
52
+ end
53
+
54
+ def tgkill(tgid, tid, signal)
55
+ result = SYSCALL.call(Constants::SYS_TGKILL.fetch(architecture), tgid, tid, signal)
56
+ raise SystemCallError.new("tgkill", Fiddle.last_error) if result.negative?
57
+
58
+ result
51
59
  end
52
60
 
53
61
  def ioctl(fd, request, buffer)
54
- IOCTL.call(fd, request, Fiddle::Pointer[buffer])
62
+ with_pointer(buffer) { |pointer| IOCTL.call(fd, request, pointer) }
55
63
  end
56
64
 
57
65
  def poll_hup?(fd)
58
66
  buffer = [fd, 1, 0].pack("l<s<s<")
59
- result = POLL.call(Fiddle::Pointer[buffer], 1, 0)
67
+ result = with_pointer(buffer) { |pointer| POLL.call(pointer, 1, 0) }
60
68
  raise SystemCallError.new("poll", Fiddle.last_error) if result.negative?
61
69
 
62
70
  revents = buffer.unpack1("@6S<")
@@ -71,6 +79,15 @@ module Seccomp
71
79
  notif, resp, data = buffer.unpack("S<S<S<")
72
80
  {notif: notif, resp: resp, data: data}.freeze
73
81
  end
82
+
83
+ def with_pointer(buffer)
84
+ return yield buffer unless buffer.is_a?(String)
85
+
86
+ pointer = Fiddle::Pointer.malloc(buffer.bytesize)
87
+ pointer[0, buffer.bytesize] = buffer
88
+ yield(pointer).tap { buffer.replace(pointer[0, buffer.bytesize]) }
89
+ end
90
+ private_class_method :with_pointer
74
91
  end
75
92
  end
76
93
  end
@@ -87,6 +87,9 @@ module Seccomp
87
87
  private
88
88
 
89
89
  def validate!
90
+ conflict = (@notifications + @conditional_notifications.map(&:first)).find { |name| @denials.key?(name) }
91
+ raise InvalidPolicyError, "#{conflict} cannot be both notified and denied" if conflict
92
+
90
93
  invalid_bootstrap = BOOTSTRAP_SYSCALLS.find { |name| notified?(name) || @denials.key?(name) }
91
94
  if invalid_bootstrap
92
95
  raise InvalidPolicyError, "#{invalid_bootstrap} must be allowed while transferring the listener fd"
@@ -79,6 +79,10 @@ module Seccomp
79
79
  # Emulates a successful syscall return.
80
80
  # @return [void]
81
81
  def allow!(value = 0)
82
+ unless value.is_a?(Integer) && (-(1 << 63)...(1 << 63)).cover?(value) && !(-Constants::MAX_ERRNO..-1).cover?(value)
83
+ raise ArgumentError, "value must be a signed 64-bit integer outside the errno range"
84
+ end
85
+
82
86
  respond!(value:, error: 0, flags: 0)
83
87
  end
84
88
 
@@ -130,8 +134,10 @@ module Seccomp
130
134
 
131
135
  def kill!(signal = "KILL")
132
136
  ensure_unresponded!
133
- Process.kill(signal, @tid)
134
- @responded = true
137
+ signal = Signal.list.fetch(signal.to_s.delete_prefix("SIG")) { raise ArgumentError, "unknown signal: #{signal.inspect}" } unless signal.is_a?(Integer)
138
+ tgid = Integer(File.read("/proc/#{@tid}/status")[/^Tgid:\s+(\d+)/, 1])
139
+ Libc.tgkill(tgid, @tid, signal)
140
+ error!
135
141
  end
136
142
 
137
143
  def mark_pointer_read!
@@ -54,6 +54,7 @@ module Seccomp
54
54
  @unknown_handler = ->(request) { request.error!(@default_errno) }
55
55
  @error_handler = ->(error, _request) { warn("seccomp-notify supervisor: #{error.full_message(highlight: false, order: :top)}") }
56
56
  @receive_mutex = Mutex.new
57
+ @state_mutex = Mutex.new
57
58
  @stop = false
58
59
  @listener_closed = false
59
60
  end
@@ -95,18 +96,18 @@ module Seccomp
95
96
  def run
96
97
  workers = Array.new(@concurrency) { Thread.new { event_loop } }
97
98
  status = wait_for_target
98
- wait_until_listener_closes if @target_pid && !@stop
99
- @stop = true
99
+ wait_until_listener_closes if @target_pid && !stopped?
100
+ stop
100
101
  workers.each(&:join)
101
102
  status
102
103
  ensure
103
- @stop = true
104
+ stop
104
105
  @listener.close unless @listener.closed?
105
106
  @health_writer&.close unless @health_writer&.closed?
106
107
  end
107
108
 
108
109
  def stop
109
- @stop = true
110
+ @state_mutex.synchronize { @stop = true }
110
111
  end
111
112
 
112
113
  private
@@ -115,7 +116,7 @@ module Seccomp
115
116
  return wait_until_listener_closes unless @target_pid
116
117
 
117
118
  loop do
118
- return if @stop
119
+ return if stopped?
119
120
 
120
121
  waited = Process.waitpid2(@target_pid, Process::WNOHANG)
121
122
  return waited.last if waited
@@ -129,7 +130,7 @@ module Seccomp
129
130
 
130
131
  def wait_for_non_child
131
132
  loop do
132
- return if @stop
133
+ return if stopped?
133
134
  return if target_zombie?
134
135
 
135
136
  Process.kill(0, @target_pid)
@@ -146,16 +147,16 @@ module Seccomp
146
147
  end
147
148
 
148
149
  def wait_until_listener_closes
149
- sleep(@poll_interval) until @stop || @listener_closed
150
+ sleep(@poll_interval) until stopped? || listener_closed?
150
151
  end
151
152
 
152
153
  def event_loop
153
- process_one until @stop || @listener_closed
154
+ process_one until stopped? || listener_closed?
154
155
  rescue IOError, Errno::EBADF, Errno::ENOTCONN
155
- @stop = true
156
+ stop
156
157
  rescue StandardError => error
157
158
  warn("seccomp-notify worker failed: #{error.message}")
158
- @stop = true
159
+ stop
159
160
  end
160
161
 
161
162
  def process_one
@@ -166,17 +167,17 @@ module Seccomp
166
167
  rescue Errno::EINTR
167
168
  retry
168
169
  rescue Errno::EAGAIN
169
- @listener_closed = true if Libc.poll_hup?(@listener.fileno)
170
+ mark_listener_closed if Libc.poll_hup?(@listener.fileno)
170
171
  nil
171
172
  rescue Errno::ENOENT
172
- @listener_closed = true if Libc.poll_hup?(@listener.fileno)
173
+ mark_listener_closed if Libc.poll_hup?(@listener.fileno)
173
174
  nil
174
175
  end
175
176
 
176
177
  def receive_request
177
178
  return unless IO.select([@listener], nil, nil, @poll_interval)
178
179
  if Libc.poll_hup?(@listener.fileno)
179
- @listener_closed = true
180
+ mark_listener_closed
180
181
  return
181
182
  end
182
183
 
@@ -208,6 +209,18 @@ module Seccomp
208
209
  request&.close
209
210
  end
210
211
  end
212
+
213
+ def stopped?
214
+ @state_mutex.synchronize { @stop }
215
+ end
216
+
217
+ def listener_closed?
218
+ @state_mutex.synchronize { @listener_closed }
219
+ end
220
+
221
+ def mark_listener_closed
222
+ @state_mutex.synchronize { @listener_closed = true }
223
+ end
211
224
  end
212
225
  end
213
226
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "ipaddr"
4
3
  require "socket"
4
+ require "ipaddr"
5
5
 
6
6
  module Seccomp
7
7
  module Notify
@@ -55,13 +55,16 @@ module Seccomp
55
55
  case bytes.unpack1("S<")
56
56
  when Socket::AF_INET
57
57
  raise MemoryReadError, "short AF_INET sockaddr" if length < 16
58
- Addrinfo.tcp(IPAddr.ntop(bytes.byteslice(4, 4)), bytes.byteslice(2, 2).unpack1("n"))
58
+ port = bytes.unpack1("@2n")
59
+ host = IPAddr.ntop(bytes.byteslice(4, 4))
60
+ Addrinfo.new(Socket.sockaddr_in(port, host), Socket::AF_INET, 0, 0)
59
61
  when Socket::AF_INET6
60
62
  raise MemoryReadError, "short AF_INET6 sockaddr" if length < 28
61
- address = IPAddr.ntop(bytes.byteslice(8, 16))
62
- scope = bytes.byteslice(24, 4).unpack1("L<")
63
- address = "#{address}%#{scope}" unless scope.zero?
64
- Addrinfo.tcp(address, bytes.byteslice(2, 2).unpack1("n"))
63
+ port = bytes.unpack1("@2n")
64
+ host = IPAddr.ntop(bytes.byteslice(8, 16))
65
+ scope = bytes.unpack1("@24L<")
66
+ host = "#{host}%#{scope}" unless scope.zero?
67
+ Addrinfo.new(Socket.sockaddr_in(port, host), Socket::AF_INET6, 0, 0)
65
68
  when Socket::AF_UNIX
66
69
  raw_path = bytes.byteslice(2..).to_s
67
70
  path = raw_path.start_with?("\0") ? raw_path : raw_path.split("\0", 2).first
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Seccomp
4
4
  module Notify
5
- VERSION = "0.2.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
@@ -71,8 +71,8 @@ module Seccomp
71
71
  keep_health_reader(health_reader)
72
72
  listener_fd = Filter.install!(program, flags: install_flags, raw: true)
73
73
  FdPassing.send_fd(child_socket, listener_fd)
74
+ keep_transfer_socket(child_socket)
74
75
  listener = IO.for_fd(listener_fd, "r")
75
- child_socket.close
76
76
  listener.close
77
77
  target.call
78
78
  exit! 0
@@ -122,9 +122,9 @@ module Seccomp
122
122
  keep_health_reader(health_reader)
123
123
  listener_fd = Filter.install!(program, flags: install_flags, raw: true)
124
124
  FdPassing.send_fd(parent_socket, listener_fd)
125
+ keep_transfer_socket(parent_socket)
125
126
  listener = IO.for_fd(listener_fd, "r")
126
127
  listener.close
127
- parent_socket.close
128
128
  Process.detach(supervisor_pid)
129
129
  supervisor_pid
130
130
  rescue StandardError
@@ -193,6 +193,11 @@ module Seccomp
193
193
  @supervisor_health_reader = reader
194
194
  end
195
195
 
196
+ def keep_transfer_socket(socket)
197
+ socket.close_on_exec = false
198
+ @listener_transfer_socket = socket
199
+ end
200
+
196
201
  def supervisor_health_reader
197
202
  return @supervisor_health_reader if defined?(@supervisor_health_reader)
198
203
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: seccomp-notify
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yudai Takada