seccomp-notify 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Seccomp
4
+ module Notify
5
+ class Policy
6
+ BOOTSTRAP_SYSCALLS = %i[fcntl sendmsg].freeze
7
+ ALLOWED_FLAGS = Constants::SECCOMP_FILTER_FLAG_LOG |
8
+ Constants::SECCOMP_FILTER_FLAG_SPEC_ALLOW |
9
+ Constants::SECCOMP_FILTER_FLAG_WAIT_KILLABLE_RECV
10
+ DEFAULTS = {
11
+ allow: Constants::SECCOMP_RET_ALLOW,
12
+ errno: Constants::SECCOMP_RET_ERRNO | Errno::EPERM::Errno,
13
+ kill: Constants::SECCOMP_RET_KILL_PROCESS
14
+ }.freeze
15
+
16
+ attr_reader :default_action
17
+
18
+ def initialize(default: :allow, deny_io_uring: true, flags: 0, &block)
19
+ @default_action = DEFAULTS.fetch(default) { raise InvalidPolicyError, "invalid default action: #{default.inspect}" }
20
+ raise ArgumentError, "flags must be an integer" unless flags.is_a?(Integer)
21
+
22
+ @flags = flags
23
+ @notifications = []
24
+ @denials = {}
25
+ deny_io_uring! if deny_io_uring
26
+ instance_eval(&block) if block
27
+ validate!
28
+ end
29
+
30
+ # Marks syscalls for supervisor notification.
31
+ # @param names [Array<Symbol>]
32
+ # @return [void]
33
+ def notify(*names)
34
+ @notifications.concat(names.map(&:to_sym))
35
+ end
36
+
37
+ # Rejects syscalls in the kernel without notifying the supervisor.
38
+ # @param names [Array<Symbol>]
39
+ # @param errno [Class, Integer] an Errno class or numeric errno
40
+ # @return [void]
41
+ def deny(*names, errno: Errno::EPERM)
42
+ number = Constants.errno_number(errno)
43
+ names.each { |name| @denials[name.to_sym] = number }
44
+ end
45
+
46
+ # Denies io_uring setup so submitted I/O cannot bypass seccomp.
47
+ #
48
+ # @return [void]
49
+ def deny_io_uring!
50
+ deny(:io_uring_setup, errno: Errno::ENOSYS)
51
+ end
52
+
53
+ def flags
54
+ @flags
55
+ end
56
+
57
+ def decisions(arch)
58
+ validate!
59
+ notified = @notifications.to_h { |name| [Syscalls.number(name, arch), Constants::SECCOMP_RET_USER_NOTIF] }
60
+ denied = @denials.to_h { |name, errno| [Syscalls.number(name, arch), Constants::SECCOMP_RET_ERRNO | errno] }
61
+ notified.merge(denied).tap do |decisions|
62
+ BOOTSTRAP_SYSCALLS.each do |name|
63
+ decisions[Syscalls.number(name, arch)] = Constants::SECCOMP_RET_ALLOW unless @default_action == Constants::SECCOMP_RET_ALLOW
64
+ end
65
+ end
66
+ end
67
+
68
+ def notified?(name)
69
+ @notifications.include?(name.to_sym)
70
+ end
71
+
72
+ private
73
+
74
+ def validate!
75
+ invalid_bootstrap = BOOTSTRAP_SYSCALLS.find { |name| notified?(name) || @denials.key?(name) }
76
+ if invalid_bootstrap
77
+ raise InvalidPolicyError, "#{invalid_bootstrap} must be allowed while transferring the listener fd"
78
+ end
79
+ raise InvalidPolicyError, "TSYNC and NEW_LISTENER cannot be combined" if (@flags & Constants::SECCOMP_FILTER_FLAG_TSYNC).positive?
80
+ raise InvalidPolicyError, "unsupported filter flags: 0x#{@flags.to_s(16)}" unless (@flags & ~ALLOWED_FLAGS).zero?
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Seccomp
4
+ module Notify
5
+ class Request
6
+ attr_reader :id, :tid, :flags, :nr, :instruction_pointer, :args
7
+ alias_method :pid, :tid
8
+
9
+ def self.from_binary(binary, listener:, sizes:, memory_cache:, memory_cache_mutex:, features:)
10
+ id, tid, flags, nr, audit_arch, ip, *args = binary.unpack(Structs::NOTIF_FORMAT)
11
+ arch = case audit_arch
12
+ when Constants::AUDIT_ARCH_X86_64 then :x86_64
13
+ when Constants::AUDIT_ARCH_AARCH64 then :aarch64
14
+ else raise NotSupportedError, "unsupported audit architecture 0x#{audit_arch.to_s(16)}"
15
+ end
16
+ new(id:, tid:, flags:, nr:, arch:, instruction_pointer: ip, args:, listener:, sizes:, memory_cache:, memory_cache_mutex:, features:)
17
+ end
18
+
19
+ def initialize(id:, tid:, flags:, nr:, arch:, instruction_pointer:, args:, listener:, sizes:, memory_cache:, memory_cache_mutex:, features:)
20
+ @id = id
21
+ @tid = tid
22
+ @flags = flags
23
+ @nr = nr
24
+ @arch = arch
25
+ @instruction_pointer = instruction_pointer
26
+ @args = args.freeze
27
+ @listener = listener
28
+ @sizes = sizes
29
+ @features = features
30
+ @memory = TargetMemory.new(listener, self, memory_cache, memory_cache_mutex)
31
+ @responded = false
32
+ @pointer_read = false
33
+ end
34
+
35
+ attr_reader :arch
36
+
37
+ def syscall
38
+ Syscalls.name(@nr, @arch)
39
+ end
40
+
41
+ def responded?
42
+ @responded
43
+ end
44
+
45
+ def valid?
46
+ buffer = [@id].pack("Q<")
47
+ validate_id(Ioctl::NOTIF_ID_VALID, buffer)
48
+ true
49
+ rescue Errno::EINVAL
50
+ begin
51
+ validate_id(Ioctl::NOTIF_ID_VALID_OLD, buffer)
52
+ true
53
+ rescue Errno::ENOENT
54
+ false
55
+ end
56
+ rescue Errno::ENOENT
57
+ false
58
+ end
59
+
60
+ # Reads bytes from target memory and revalidates the notification afterward.
61
+ # @return [String]
62
+ def read(address, length)
63
+ @memory.read(address, length)
64
+ end
65
+
66
+ # Reads a NUL-terminated string from target memory.
67
+ # @return [String]
68
+ def read_string(address, max: 4096)
69
+ @memory.read_string(address, max:)
70
+ end
71
+ alias_method :read_cstring, :read_string
72
+
73
+ # Decodes an AF_INET, AF_INET6, or AF_UNIX socket address.
74
+ # @return [Addrinfo]
75
+ def read_sockaddr(address, length)
76
+ @memory.read_sockaddr(address, length)
77
+ end
78
+
79
+ # Emulates a successful syscall return.
80
+ # @return [void]
81
+ def allow!(value = 0)
82
+ respond!(value:, error: 0, flags: 0)
83
+ end
84
+
85
+ # Emulates a failed syscall return.
86
+ # @return [void]
87
+ def error!(error = Errno::EPERM)
88
+ errno = Constants.errno_number(error)
89
+ respond!(value: 0, error: -errno, flags: 0)
90
+ end
91
+
92
+ # Lets the kernel execute the original syscall.
93
+ # @param unsafe [Boolean] acknowledges pointer-read TOCTOU risk
94
+ # @return [void]
95
+ def continue!(unsafe: false)
96
+ raise NotSupportedError, "SECCOMP_USER_NOTIF_FLAG_CONTINUE is unavailable" unless @features[:continue]
97
+ warn("seccomp-notify: continuing after reading target memory is subject to TOCTOU; pass unsafe: true to acknowledge") if @pointer_read && !unsafe
98
+ respond!(value: 0, error: 0, flags: Constants::SECCOMP_USER_NOTIF_FLAG_CONTINUE)
99
+ end
100
+
101
+ # Injects an open file descriptor into the target.
102
+ # @return [Integer] the target file descriptor number
103
+ def add_fd!(io, flags: 0, newfd: 0, newfd_flags: 0)
104
+ raise NotSupportedError, "SECCOMP_IOCTL_NOTIF_ADDFD is unavailable" unless @features[:addfd]
105
+ ensure_unresponded!
106
+ unless [flags, newfd, newfd_flags].all? { |value| value.is_a?(Integer) } && (0..0xffff_ffff).cover?(newfd)
107
+ raise ArgumentError, "flags and fd values must be unsigned integers"
108
+ end
109
+ unless (flags & ~Constants::SECCOMP_ADDFD_FLAG_SETFD).zero?
110
+ raise ArgumentError, "flags may only contain SECCOMP_ADDFD_FLAG_SETFD"
111
+ end
112
+ unless (newfd_flags & ~Constants::O_CLOEXEC).zero?
113
+ raise ArgumentError, "newfd_flags may only contain O_CLOEXEC"
114
+ end
115
+ if (flags & Constants::SECCOMP_ADDFD_FLAG_SETFD).zero? && !newfd.zero?
116
+ raise ArgumentError, "newfd requires SECCOMP_ADDFD_FLAG_SETFD"
117
+ end
118
+
119
+ addfd_flags = flags
120
+ addfd_flags |= Constants::SECCOMP_ADDFD_FLAG_SEND if @features[:addfd_send]
121
+ buffer = [@id, addfd_flags, io.fileno, newfd, newfd_flags].pack(Structs::ADDFD_FORMAT)
122
+ injected = Ioctl.call(@listener, Ioctl::NOTIF_ADDFD, buffer)
123
+ if @features[:addfd_send]
124
+ @responded = true
125
+ else
126
+ allow!(injected)
127
+ end
128
+ injected
129
+ end
130
+
131
+ def kill!(signal = "KILL")
132
+ ensure_unresponded!
133
+ Process.kill(signal, @tid)
134
+ @responded = true
135
+ end
136
+
137
+ def mark_pointer_read!
138
+ @pointer_read = true
139
+ end
140
+
141
+ private
142
+
143
+ def validate_id(request, buffer)
144
+ Ioctl.call(@listener, request, buffer)
145
+ rescue Errno::EINTR
146
+ retry
147
+ end
148
+
149
+ def respond!(value:, error:, flags:)
150
+ ensure_unresponded!
151
+ buffer = [@id, value, error, flags].pack(Structs::RESPONSE_FORMAT).ljust(@sizes[:resp], "\0")
152
+ Ioctl.call(@listener, Ioctl::NOTIF_SEND, buffer)
153
+ @responded = true
154
+ rescue Errno::EINTR
155
+ retry
156
+ rescue Errno::ENOENT
157
+ @responded = true
158
+ end
159
+
160
+ def ensure_unresponded!
161
+ raise AlreadyRespondedError, "notification #{@id} has already been answered" if @responded
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Seccomp
4
+ module Notify
5
+ module Structs
6
+ NOTIF_FORMAT = "Q<L<L<l<L<Q<Q<6"
7
+ RESPONSE_FORMAT = "Q<q<l<L<"
8
+ ADDFD_FORMAT = "Q<L<L<L<L<"
9
+ SIZES_FORMAT = "S<S<S<"
10
+ INSTRUCTION_FORMAT = "S<CCL<"
11
+ PROGRAM_FORMAT = "S<x6J<"
12
+
13
+ NOTIF_SIZE = 80
14
+ RESPONSE_SIZE = 24
15
+ ADDFD_SIZE = 24
16
+ SIZES_SIZE = 6
17
+ INSTRUCTION_SIZE = 8
18
+ PROGRAM_SIZE = 16
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module Seccomp
6
+ module Notify
7
+ class Supervisor
8
+ USER_OPTIONS = %i[poll_interval default_errno timeout concurrency features].freeze
9
+ EXPECTED_SIZES = {notif: Structs::NOTIF_SIZE, resp: Structs::RESPONSE_SIZE, data: 64}.freeze
10
+
11
+ attr_reader :listener, :target_pid
12
+
13
+ def self.validate_options!(options)
14
+ unknown = options.keys - USER_OPTIONS
15
+ raise ArgumentError, "unknown supervisor options: #{unknown.join(", ")}" unless unknown.empty?
16
+ if options.key?(:concurrency) && (!options[:concurrency].is_a?(Integer) || !options[:concurrency].positive?)
17
+ raise ArgumentError, "concurrency must be a positive integer"
18
+ end
19
+ if options.key?(:poll_interval) && !valid_duration?(options[:poll_interval], allow_zero: true)
20
+ raise ArgumentError, "poll_interval must be non-negative"
21
+ end
22
+ if options[:timeout] && !valid_duration?(options[:timeout], allow_zero: false)
23
+ raise ArgumentError, "timeout must be positive"
24
+ end
25
+ if options.key?(:features) && !options[:features].is_a?(Hash)
26
+ raise ArgumentError, "features must be a hash"
27
+ end
28
+ return unless options.key?(:default_errno)
29
+
30
+ Constants.errno_number(options[:default_errno])
31
+ end
32
+
33
+ def self.valid_duration?(value, allow_zero:)
34
+ value.is_a?(Numeric) && value.real? && value.finite? && (allow_zero ? !value.negative? : value.positive?)
35
+ end
36
+
37
+ def initialize(listener, target_pid: nil, target_child: nil, poll_interval: 0.1, default_errno: Errno::EPERM,
38
+ timeout: nil, concurrency: 1, features: Notify.features, health_writer: nil)
39
+ self.class.validate_options!(poll_interval:, default_errno:, timeout:, concurrency:, features:)
40
+
41
+ @listener = listener
42
+ @target_pid = target_pid
43
+ @target_child = target_child
44
+ @poll_interval = poll_interval
45
+ @default_errno = default_errno
46
+ @timeout = timeout
47
+ @concurrency = concurrency
48
+ @features = features
49
+ @health_writer = health_writer
50
+ @sizes = Notify.notif_sizes
51
+ raise NotSupportedError, "unsupported kernel notification sizes: #{@sizes.inspect}" unless @sizes == EXPECTED_SIZES
52
+
53
+ @handlers = {}
54
+ @unknown_handler = ->(request) { request.error!(@default_errno) }
55
+ @error_handler = ->(error, _request) { warn("seccomp-notify supervisor: #{error.full_message(highlight: false, order: :top)}") }
56
+ @memory_cache = {}
57
+ @memory_cache_mutex = Mutex.new
58
+ @receive_mutex = Mutex.new
59
+ @stop = false
60
+ @listener_closed = false
61
+ end
62
+
63
+ # Registers a handler for a notified syscall.
64
+ # @param syscall [Symbol]
65
+ # @yieldparam request [Request]
66
+ # @return [Supervisor]
67
+ def on(syscall, &handler)
68
+ raise ArgumentError, "handler block is required" unless handler
69
+
70
+ @handlers[syscall.to_sym] = handler
71
+ self
72
+ end
73
+
74
+ # Registers the fallback handler for unregistered syscalls.
75
+ # @yieldparam request [Request]
76
+ # @return [Supervisor]
77
+ def on_unknown(&handler)
78
+ raise ArgumentError, "handler block is required" unless handler
79
+
80
+ @unknown_handler = handler
81
+ self
82
+ end
83
+
84
+ # Registers an exception callback.
85
+ # @yieldparam error [Exception]
86
+ # @yieldparam request [Request]
87
+ # @return [Supervisor]
88
+ def on_error(&handler)
89
+ raise ArgumentError, "handler block is required" unless handler
90
+
91
+ @error_handler = handler
92
+ self
93
+ end
94
+
95
+ # Runs until the target exits and returns its process status when available.
96
+ # @return [Process::Status, nil]
97
+ def run
98
+ workers = Array.new(@concurrency) { Thread.new { event_loop } }
99
+ status = wait_for_target
100
+ wait_until_listener_closes if @target_pid && !@stop
101
+ @stop = true
102
+ workers.each(&:join)
103
+ status
104
+ ensure
105
+ @stop = true
106
+ @memory_cache.each_value { |io| io.close unless io.closed? }
107
+ @listener.close unless @listener.closed?
108
+ @health_writer&.close unless @health_writer&.closed?
109
+ end
110
+
111
+ def stop
112
+ @stop = true
113
+ end
114
+
115
+ private
116
+
117
+ def wait_for_target
118
+ return wait_until_listener_closes unless @target_pid
119
+
120
+ loop do
121
+ return if @stop
122
+
123
+ waited = Process.waitpid2(@target_pid, Process::WNOHANG)
124
+ return waited.last if waited
125
+ sleep(@poll_interval)
126
+ end
127
+ rescue Errno::ECHILD
128
+ return if @target_child
129
+
130
+ wait_for_non_child
131
+ end
132
+
133
+ def wait_for_non_child
134
+ loop do
135
+ return if @stop
136
+
137
+ Process.kill(0, @target_pid)
138
+ sleep(@poll_interval)
139
+ end
140
+ rescue Errno::ESRCH
141
+ nil
142
+ end
143
+
144
+ def wait_until_listener_closes
145
+ sleep(@poll_interval) until @stop || @listener_closed
146
+ end
147
+
148
+ def event_loop
149
+ process_one until @stop || @listener_closed
150
+ rescue IOError, Errno::EBADF, Errno::ENOTCONN
151
+ @stop = true
152
+ rescue StandardError => error
153
+ warn("seccomp-notify worker failed: #{error.message}")
154
+ @stop = true
155
+ end
156
+
157
+ def process_one
158
+ request = @receive_mutex.synchronize { receive_request }
159
+ return unless request
160
+
161
+ dispatch(request)
162
+ rescue Errno::EINTR
163
+ retry
164
+ rescue Errno::ENOENT
165
+ @listener_closed = true if Libc.poll_hup?(@listener.fileno)
166
+ nil
167
+ end
168
+
169
+ def receive_request
170
+ return unless IO.select([@listener], nil, nil, @poll_interval)
171
+
172
+ buffer = "\0" * @sizes[:notif]
173
+ Ioctl.call(@listener, Ioctl::NOTIF_RECV, buffer)
174
+ Request.from_binary(
175
+ buffer,
176
+ listener: @listener,
177
+ sizes: @sizes,
178
+ memory_cache: @memory_cache,
179
+ memory_cache_mutex: @memory_cache_mutex,
180
+ features: @features
181
+ )
182
+ end
183
+
184
+ def dispatch(request)
185
+ handler = @handlers.fetch(request.syscall, @unknown_handler)
186
+ @timeout ? Timeout.timeout(@timeout) { handler.call(request) } : handler.call(request)
187
+ rescue StandardError => error
188
+ begin
189
+ @error_handler.call(error, request)
190
+ rescue Exception => callback_error # rubocop:disable Lint/RescueException
191
+ warn("seccomp-notify error callback failed: #{callback_error.message}")
192
+ end
193
+ ensure
194
+ request&.error!(@default_errno) unless request&.responded?
195
+ end
196
+ end
197
+ end
198
+ end