straycall 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,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "policy"
4
+ require_relative "report/console"
5
+ require_relative "report/json"
6
+ require_relative "reporter/client"
7
+ require_relative "reporter/symbolizer"
8
+ require_relative "prompt"
9
+ require_relative "handlers/filesystem"
10
+ require_relative "handlers/exec"
11
+
12
+ module Straycall
13
+ class Supervisor
14
+ include Handlers::Filesystem
15
+ include Handlers::Exec
16
+
17
+ ENDPOINT_SOCKET_DOMAINS = [Socket::AF_UNIX, Socket::AF_INET, Socket::AF_INET6].freeze
18
+ MMSGHDR_SIZE = 64
19
+ MAX_MMSG = 1024
20
+
21
+ attr_reader :violations
22
+
23
+ def initialize(config, reporter: nil, console: Report::Console.new, prompt: Prompt.new)
24
+ @config = config
25
+ @reporter = reporter
26
+ @console = console
27
+ @violations = []
28
+ @mutex = Mutex.new
29
+ @reported_locations = {}
30
+ @bound_sockets = {}
31
+ @prompt = prompt
32
+ end
33
+
34
+ def run(command)
35
+ supervisor_socket, target_socket = UNIXSocket.pair
36
+ target_socket.close_on_exec = false
37
+ supervisor = Seccomp::Notify.spawn(Policy.seccomp(@config), concurrency: 8) do
38
+ supervisor_socket.close
39
+ environment, argv = command.first.is_a?(Hash) ? [command.first.dup, command.drop(1)] : [{}, command]
40
+ reporter = File.expand_path("target", __dir__)
41
+ inherited_rubyopt = environment.key?("RUBYOPT") ? environment["RUBYOPT"] : ENV["RUBYOPT"]
42
+ environment["STRAYCALL_REPORT_FD"] = target_socket.fileno.to_s
43
+ environment["RUBYOPT"] = ["-r#{reporter}", inherited_rubyopt].compact.join(" ")
44
+ exec(environment, *argv)
45
+ end
46
+ target_socket.close
47
+ @reporter = Reporter::Client.new(supervisor_socket)
48
+ register(supervisor)
49
+ status = supervisor.run
50
+ write_report
51
+ [status, violations]
52
+ ensure
53
+ target_socket&.close unless target_socket&.closed?
54
+ @reporter&.close
55
+ end
56
+
57
+ def register(supervisor)
58
+ supervisor.on(:socket) { |request| handle_socket(request) }
59
+ supervisor.on(:connect) { |request| handle_address(request, 1, 2) }
60
+ supervisor.on(:bind) { |request| handle_bind(request) }
61
+ supervisor.on(:listen) { |request| handle_listen(request) }
62
+ supervisor.on(:sendto) { |request| handle_address(request, 4, 5) }
63
+ supervisor.on(:sendmsg) { |request| handle_sendmsg(request) }
64
+ supervisor.on(:sendmmsg) { |request| handle_sendmmsg(request) }
65
+ names = Policy.notification_names(@config)
66
+ supervisor.on(:openat) { |request| handle_openat(request) } if names.include?(:openat)
67
+ supervisor.on(:openat2) { |request| handle_openat2(request) } if names.include?(:openat2)
68
+ supervisor.on(:unlinkat) { |request| handle_path_mutation(request, request.args[0], request.args[1]) } if names.include?(:unlinkat)
69
+ supervisor.on(:mkdirat) { |request| handle_path_mutation(request, request.args[0], request.args[1]) } if names.include?(:mkdirat)
70
+ supervisor.on(:renameat) { |request| handle_rename(request, *request.args.values_at(0, 1, 2, 3)) } if names.include?(:renameat)
71
+ 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
73
+ supervisor.on(:open) { |request| handle_open(request, cwd, request.args[0], request.args[1]) } if names.include?(:open)
74
+ if names.include?(:creat)
75
+ supervisor.on(:creat) do |request|
76
+ flags = Fcntl::O_WRONLY | Fcntl::O_CREAT | Fcntl::O_TRUNC
77
+ handle_open(request, cwd, request.args[0], flags)
78
+ end
79
+ end
80
+ supervisor.on(:unlink) { |request| handle_path_mutation(request, cwd, request.args[0]) } if names.include?(:unlink)
81
+ supervisor.on(:mkdir) { |request| handle_path_mutation(request, cwd, request.args[0]) } if names.include?(:mkdir)
82
+ supervisor.on(:rename) { |request| handle_rename(request, cwd, request.args[0], cwd, request.args[1]) } if names.include?(:rename)
83
+ supervisor.on(:execve) { |request| handle_exec(request, nil, 0) } if names.include?(:execve)
84
+ supervisor.on(:execveat) { |request| handle_exec(request, 0, 1) } if names.include?(:execveat)
85
+ supervisor
86
+ end
87
+
88
+ private
89
+
90
+ def handle_socket(request)
91
+ domain = request.args[0]
92
+ return request.continue! if @config.network.allow_all? || ENDPOINT_SOCKET_DOMAINS.include?(domain)
93
+
94
+ record_violation(request, {type: "socket", domain:, operation: "socket"}, nil)
95
+ end
96
+
97
+ def handle_bind(request)
98
+ return request.continue! if @config.network.allow_all? && @config.on_violation != :record
99
+
100
+ address = request.read_sockaddr(request.args[1], request.args[2])
101
+ return request.error!(Errno::EPERM) unless request.valid?
102
+ if @config.network.allowed?(address) && @config.on_violation != :record
103
+ remember_bound_socket(request, address)
104
+ return request.continue!(unsafe: true)
105
+ end
106
+
107
+ denied = record_violation(request, target(address), suggestion(address))
108
+ remember_bound_socket(request, address) unless denied
109
+ rescue Seccomp::Notify::MemoryReadError
110
+ record_violation(request, {type: "unknown", host: "?", port: "?"}, nil)
111
+ end
112
+
113
+ def handle_listen(request)
114
+ return request.continue! if @config.network.allow_all?
115
+
116
+ identity = socket_identity(request.tid, request.args[0])
117
+ address = @mutex.synchronize { @bound_sockets[identity] }
118
+ if address && @config.network.allowed?(address) && @config.on_violation != :record
119
+ return request.continue!
120
+ end
121
+
122
+ listen_target = address ? target(address).merge(operation: "listen") : {type: "socket", fd: request.args[0], operation: "listen"}
123
+ record_violation(request, listen_target, address && suggestion(address))
124
+ rescue SystemCallError
125
+ record_violation(request, {type: "socket", fd: request.args[0], operation: "listen"}, nil)
126
+ end
127
+
128
+ def handle_address(request, address_arg, length_arg)
129
+ return request.continue! if @config.network.allow_all? && @config.on_violation != :record
130
+
131
+ if request.syscall == :sendto && (request.args[address_arg].zero? || request.args[length_arg].zero?)
132
+ return request.continue!
133
+ end
134
+
135
+ address = request.read_sockaddr(request.args[address_arg], request.args[length_arg])
136
+ return request.error!(Errno::EPERM) unless request.valid?
137
+ if @config.network.allowed?(address) && @config.on_violation != :record
138
+ return request.continue!(unsafe: true)
139
+ end
140
+
141
+ record_violation(request, target(address), suggestion(address))
142
+ rescue Seccomp::Notify::MemoryReadError
143
+ record_violation(request, {type: "unknown", host: "?", port: "?"}, nil)
144
+ end
145
+
146
+ def handle_sendmsg(request)
147
+ return request.continue! if @config.network.allow_all? && @config.on_violation != :record
148
+
149
+ name, length = request.read(request.args[1], 16).unpack("Q<L<")
150
+ return request.continue!(unsafe: true) if name.zero? || length.zero?
151
+
152
+ address = request.read_sockaddr(name, length)
153
+ return request.error!(Errno::EPERM) unless request.valid?
154
+ if @config.network.allowed?(address) && @config.on_violation != :record
155
+ return request.continue!(unsafe: true)
156
+ end
157
+
158
+ record_violation(request, target(address), suggestion(address))
159
+ rescue Seccomp::Notify::MemoryReadError
160
+ record_violation(request, {type: "unknown", host: "?", port: "?"}, nil)
161
+ end
162
+
163
+ def handle_sendmmsg(request)
164
+ return request.continue! if @config.network.allow_all? && @config.on_violation != :record
165
+
166
+ count = [request.args[2], MAX_MMSG].min
167
+ return request.continue! if count.zero?
168
+
169
+ destinations = count.times.filter_map do |index|
170
+ name, length = request.read(request.args[1] + index * MMSGHDR_SIZE, 12).unpack("Q<L<")
171
+ request.read_sockaddr(name, length) unless name.zero? || length.zero?
172
+ end
173
+ return request.error!(Errno::EPERM) unless request.valid?
174
+ observed = if @config.on_violation == :record
175
+ destinations
176
+ else
177
+ destinations.reject { |address| @config.network.allowed?(address) }
178
+ end
179
+ return request.continue!(unsafe: true) if observed.empty?
180
+ return record_violation(request, target(observed.first), suggestion(observed.first)) if @config.on_violation == :fail
181
+
182
+ denied = observed.map do |address|
183
+ record_violation(request, target(address), suggestion(address), respond: false)
184
+ end.any?
185
+ denied ? request.error!(Errno::EPERM) : request.continue!(unsafe: true)
186
+ rescue Seccomp::Notify::MemoryReadError
187
+ record_violation(request, {type: "unknown", host: "?", port: "?"}, nil)
188
+ end
189
+
190
+ def record_violation(request, target, suggestion, respond: true)
191
+ denied = @config.on_violation == :fail ||
192
+ (@config.on_violation == :prompt && !@prompt.allow?(request.syscall, report_target(target)))
193
+ action = denied ? :denied : :allowed
194
+ location = [request.syscall, request.instruction_pointer, target]
195
+ first = @mutex.synchronize { !@reported_locations.key?(location) && (@reported_locations[location] = true) }
196
+ backtrace = @reporter&.backtrace(request.tid) if first && @config.report_backtrace
197
+ origin = Reporter::Symbolizer.call(request.tid, request.instruction_pointer) unless backtrace
198
+ violation = Violation.new(request.syscall, request.tid, target, backtrace, @reporter&.example, action, suggestion, origin)
199
+ @mutex.synchronize { violations << violation }
200
+ @reporter&.notify_violation if denied
201
+ @mutex.synchronize do
202
+ write_report
203
+ @console.violation(violation)
204
+ end
205
+ denied ? request.error!(Errno::EPERM) : request.continue!(unsafe: true) if respond
206
+ denied
207
+ end
208
+
209
+ def target(address)
210
+ return {type: "unix", path: address.unix_path} if address.unix?
211
+
212
+ {type: "inet", host: address.ip_address, port: address.ip_port}
213
+ end
214
+
215
+ def suggestion(address)
216
+ return %(allow_unix #{address.unix_path.inspect}) if address.unix?
217
+ return %(allow_host #{address.ip_address.inspect}) if address.ip_port.zero?
218
+
219
+ %(allow_host #{address.ip_address.inspect}, ports: [#{address.ip_port}])
220
+ end
221
+
222
+ def write_report
223
+ Report::JSON.new(@config.report_path).write(violations) if @config.report_path
224
+ end
225
+
226
+ def report_target(target)
227
+ return target[:path] if target[:path]
228
+ return "socket domain #{target[:domain]}" if target[:domain]
229
+ return "socket fd #{target[:fd]}" if target[:type] == "socket"
230
+
231
+ "#{target[:host]}:#{target[:port]}"
232
+ end
233
+
234
+ def remember_bound_socket(request, address)
235
+ identity = socket_identity(request.tid, request.args[0])
236
+ @mutex.synchronize do
237
+ # ponytail: observations precede the kernel bind result; use pidfd_getfd + getsockname if this becomes a security boundary.
238
+ @bound_sockets[identity] = address if identity
239
+ @bound_sockets.shift if @bound_sockets.length > 4096
240
+ end
241
+ rescue SystemCallError
242
+ nil
243
+ end
244
+
245
+ def socket_identity(tid, fd)
246
+ File.readlink("/proc/#{tid}/fd/#{fd}")[/\Asocket:\[(\d+)\]\z/, 1]
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "reporter/thread_reporter"
4
+
5
+ module Straycall
6
+ module Target
7
+ module_function
8
+
9
+ def start(socket = nil)
10
+ socket ||= IO.for_fd(Integer(ENV.fetch("STRAYCALL_REPORT_FD")), autoclose: true)
11
+ @reporter = Reporter::ThreadReporter.new(socket).start
12
+ rescue KeyError, ArgumentError
13
+ nil
14
+ end
15
+
16
+ def reporter
17
+ @reporter
18
+ end
19
+
20
+ def violation?
21
+ @reporter&.violation? || false
22
+ end
23
+
24
+ def stop
25
+ @reporter&.stop
26
+ @reporter = nil
27
+ end
28
+ end
29
+ end
30
+
31
+ Straycall::Target.start if ENV["STRAYCALL_REPORT_FD"]
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Straycall
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Straycall
4
+ Violation = Struct.new(:syscall, :tid, :target, :backtrace, :example, :action, :suggestion, :origin) do
5
+ def to_h
6
+ {syscall: syscall.to_s, tid:, example:, target:, backtrace:, action: action.to_s, suggestion:, origin:}.compact
7
+ end
8
+ end
9
+ end
data/lib/straycall.rb ADDED
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "straycall/version"
4
+ require_relative "straycall/config"
5
+ require_relative "straycall/policy"
6
+ require_relative "straycall/violation"
7
+
8
+ module Straycall
9
+ class Error < StandardError; end
10
+ class ConfigurationError < Error; end
11
+ class PathResolutionError < Error; end
12
+
13
+ class << self
14
+ attr_writer :config
15
+
16
+ def config
17
+ @config ||= Config.new
18
+ end
19
+
20
+ def configure
21
+ yield config
22
+ config
23
+ end
24
+
25
+ def available?
26
+ return false if ENV["STRAYCALL"] == "0"
27
+ return false unless RUBY_PLATFORM.include?("linux") && RUBY_ENGINE == "ruby"
28
+
29
+ require "seccomp/notify"
30
+ Seccomp::Notify.supported? && Seccomp::Notify.features[:continue]
31
+ rescue LoadError, StandardError
32
+ false
33
+ end
34
+
35
+ def reset!
36
+ @config = Config.new
37
+ end
38
+ end
39
+ end
metadata ADDED
@@ -0,0 +1,94 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: straycall
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yudai Takada
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: seccomp-notify
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.2'
26
+ description: A Linux seccomp-notify sandbox that reports network, filesystem, and
27
+ process violations.
28
+ email:
29
+ - t.yudai92@gmail.com
30
+ executables:
31
+ - straycall
32
+ - straycall-supervisor
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - CHANGELOG.md
37
+ - LICENSE.txt
38
+ - README.md
39
+ - Rakefile
40
+ - benchmark/require_overhead.rb
41
+ - docs/index.html
42
+ - docs/styles.css
43
+ - examples/.straycall.yml
44
+ - examples/rspec.rb
45
+ - exe/straycall
46
+ - exe/straycall-supervisor
47
+ - lib/straycall.rb
48
+ - lib/straycall/cli.rb
49
+ - lib/straycall/config.rb
50
+ - lib/straycall/handlers/exec.rb
51
+ - lib/straycall/handlers/filesystem.rb
52
+ - lib/straycall/in_process.rb
53
+ - lib/straycall/minitest.rb
54
+ - lib/straycall/policy.rb
55
+ - lib/straycall/policy/exec.rb
56
+ - lib/straycall/policy/filesystem.rb
57
+ - lib/straycall/policy/network.rb
58
+ - lib/straycall/prompt.rb
59
+ - lib/straycall/recorder.rb
60
+ - lib/straycall/report/console.rb
61
+ - lib/straycall/report/json.rb
62
+ - lib/straycall/reporter/client.rb
63
+ - lib/straycall/reporter/protocol.rb
64
+ - lib/straycall/reporter/symbolizer.rb
65
+ - lib/straycall/reporter/thread_reporter.rb
66
+ - lib/straycall/rspec.rb
67
+ - lib/straycall/supervisor.rb
68
+ - lib/straycall/target.rb
69
+ - lib/straycall/version.rb
70
+ - lib/straycall/violation.rb
71
+ homepage: https://rubygems.org/gems/straycall
72
+ licenses:
73
+ - MIT
74
+ metadata:
75
+ rubygems_mfa_required: 'true'
76
+ homepage_uri: https://rubygems.org/gems/straycall
77
+ rdoc_options: []
78
+ require_paths:
79
+ - lib
80
+ required_ruby_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: 3.1.0
85
+ required_rubygems_version: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ requirements: []
91
+ rubygems_version: 4.0.19
92
+ specification_version: 4
93
+ summary: Report and block unintended syscalls in Ruby builds and tests
94
+ test_files: []