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.
data/exe/straycall ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "straycall/cli"
5
+
6
+ exit Straycall::CLI.start
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ health_writer = IO.for_fd(Integer(ARGV.fetch(4)))
5
+ health_writer.sync = true
6
+ health_writer.write("R")
7
+
8
+ require "socket"
9
+ require "seccomp/notify"
10
+ require "straycall"
11
+ require "straycall/supervisor"
12
+
13
+ socket = UNIXSocket.for_fd(Integer(ARGV.fetch(0)))
14
+ target_pid = Integer(ARGV.fetch(1))
15
+ config = Marshal.load([ARGV.fetch(2)].pack("H*")) # rubocop:disable Security/MarshalLoad
16
+ report_socket = UNIXSocket.for_fd(Integer(ARGV.fetch(3)))
17
+ listener = Seccomp::Notify::FdPassing.recv_fd(socket)
18
+ socket.close
19
+ guard = Straycall::Supervisor.new(config, reporter: Straycall::Reporter::Client.new(report_socket))
20
+ supervisor = Seccomp::Notify::Supervisor.new(
21
+ listener,
22
+ target_pid:,
23
+ target_child: false,
24
+ health_writer:,
25
+ concurrency: 8
26
+ )
27
+ guard.register(supervisor)
28
+ health_writer.write("S")
29
+ supervisor.run
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "tmpdir"
5
+ require_relative "../straycall"
6
+
7
+ module Straycall
8
+ class CLI
9
+ def self.start(argv = ARGV)
10
+ new(argv).run
11
+ end
12
+
13
+ def initialize(argv)
14
+ @argv = argv.dup
15
+ @config_actions = []
16
+ end
17
+
18
+ def run
19
+ parse!
20
+ return exec(*@argv) unless Straycall.available?
21
+
22
+ require "seccomp/notify"
23
+ require_relative "supervisor"
24
+ status, violations = Supervisor.new(Straycall.config).run(@argv)
25
+ if Straycall.config.on_violation == :record
26
+ require_relative "recorder"
27
+ Recorder.new(@policy_path).write(violations)
28
+ end
29
+ return 1 if violations.any? { |violation| violation.action == :denied }
30
+
31
+ child_exit_code(status)
32
+ rescue OptionParser::ParseError, ConfigurationError => error
33
+ warn "straycall: #{error.message}"
34
+ 64
35
+ end
36
+
37
+ private
38
+
39
+ def child_exit_code(status)
40
+ return 1 unless status
41
+
42
+ status.signaled? ? 128 + status.termsig : status.exitstatus
43
+ end
44
+
45
+ def parse!
46
+ parser = OptionParser.new do |options|
47
+ options.banner = "Usage: straycall [options] -- command [args]"
48
+ options.on("--config PATH") { |value| @config_path = value }
49
+ options.on("--[no-]backtrace") { |value| @config_actions << ->(config) { config.report_backtrace = value } }
50
+ options.on("--report-path PATH") { |value| @config_actions << ->(config) { config.report_path = value } }
51
+ options.on("--record") { @config_actions << ->(config) { config.on_violation = :record } }
52
+ options.on("--policy-path PATH") { |value| @policy_path = value }
53
+ options.on("--warn") { @config_actions << ->(config) { config.on_violation = :warn } }
54
+ options.on("--prompt") { @config_actions << ->(config) { config.on_violation = :prompt } }
55
+ options.on("--preset NAME") { |value| @config_actions << ->(config) { apply_preset(config, value) } }
56
+ end
57
+ parser.order!(@argv)
58
+ @argv.shift if @argv.first == "--"
59
+ raise OptionParser::MissingArgument, "command" if @argv.empty?
60
+ @policy_path ||= ".straycall.yml"
61
+ Straycall.config = @config_path ? Config.load(@config_path) : Straycall.config
62
+ @config_actions.each { |action| action.call(Straycall.config) }
63
+ end
64
+
65
+ def apply_preset(config, name)
66
+ raise ConfigurationError, "unknown preset: #{name}" unless name == "bundle-install"
67
+
68
+ config.on_violation = :prompt
69
+ config.allow_write_under(Dir.pwd, Dir.tmpdir, Gem.dir)
70
+ config.deny_write_elsewhere!
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require_relative "policy/network"
5
+ require_relative "policy/filesystem"
6
+ require_relative "policy/exec"
7
+
8
+ module Straycall
9
+ class Config
10
+ MODES = %i[fail warn record prompt].freeze
11
+ TOP_LEVEL_KEYS = %w[network write exec on_violation report_backtrace report_path].freeze
12
+
13
+ def self.load(path)
14
+ data = YAML.safe_load_file(path, permitted_classes: [], aliases: false) || {}
15
+ raise ConfigurationError, "configuration must be a mapping" unless data.is_a?(Hash)
16
+
17
+ unknown = data.keys.map(&:to_s) - TOP_LEVEL_KEYS
18
+ raise ConfigurationError, "unknown configuration keys: #{unknown.join(", ")}" unless unknown.empty?
19
+
20
+ new.tap { |config| config.apply(data) }
21
+ rescue Psych::Exception => error
22
+ raise ConfigurationError, "invalid YAML: #{error.message}"
23
+ rescue SystemCallError => error
24
+ raise ConfigurationError, "cannot read configuration: #{error.message}"
25
+ end
26
+
27
+ attr_reader :network, :filesystem, :exec
28
+ attr_reader :report_backtrace, :report_path
29
+
30
+ def initialize
31
+ @network = Policy::Network.new
32
+ @filesystem = Policy::Filesystem.new
33
+ @exec = Policy::Exec.new
34
+ @on_violation = :fail
35
+ @report_backtrace = true
36
+ @report_path = nil
37
+ @allow_io_uring = false
38
+ end
39
+
40
+ def deny_network!
41
+ network.deny!
42
+ end
43
+
44
+ def allow_loopback(ports: nil)
45
+ network.allow_loopback(ports:)
46
+ end
47
+
48
+ def allow_unix(path)
49
+ network.allow_unix(path)
50
+ end
51
+
52
+ def allow_host(host, ports: nil)
53
+ network.allow_host(host, ports:)
54
+ end
55
+
56
+ def allow_write_under(*paths)
57
+ paths.each { |path| filesystem.allow_write_under(path) }
58
+ end
59
+
60
+ def deny_write_elsewhere!
61
+ filesystem.deny_write_elsewhere!
62
+ end
63
+
64
+ def deny_read(*paths)
65
+ paths.each { |path| filesystem.deny_read(path) }
66
+ end
67
+
68
+ def allow_exec(*paths)
69
+ paths.each { |path| exec.allow(path) }
70
+ end
71
+
72
+ def deny_exec_elsewhere!
73
+ exec.deny_elsewhere!
74
+ end
75
+
76
+ def deny_exec(*paths)
77
+ paths.each { |path| exec.deny(path) }
78
+ end
79
+
80
+ def allow_io_uring!
81
+ @allow_io_uring = true
82
+ end
83
+
84
+ def io_uring_allowed?
85
+ @allow_io_uring
86
+ end
87
+
88
+ def on_violation
89
+ @on_violation
90
+ end
91
+
92
+ def report_backtrace=(value)
93
+ raise ConfigurationError, "report_backtrace must be true or false" unless [true, false].include?(value)
94
+
95
+ @report_backtrace = value
96
+ end
97
+
98
+ def report_path=(value)
99
+ unless value.nil? || (value.is_a?(String) && !value.empty? && !value.include?("\0"))
100
+ raise ConfigurationError, "report_path must be a non-empty string without NUL bytes or nil"
101
+ end
102
+
103
+ @report_path = value
104
+ end
105
+
106
+ def on_violation=(mode)
107
+ mode = mode.to_sym if mode.respond_to?(:to_sym)
108
+ raise ConfigurationError, "on_violation must be one of: #{MODES.join(", ")}" unless MODES.include?(mode)
109
+
110
+ @on_violation = mode
111
+ end
112
+
113
+ def apply(data)
114
+ apply_network(section(data, "network")) if key?(data, "network")
115
+ apply_write(section(data, "write")) if key?(data, "write")
116
+ apply_exec(section(data, "exec")) if key?(data, "exec")
117
+ self.on_violation = fetch(data, "on_violation") if key?(data, "on_violation")
118
+ self.report_backtrace = boolean(data, "report_backtrace") if key?(data, "report_backtrace")
119
+ if key?(data, "report_path")
120
+ raise ConfigurationError, "report_path must be a string" unless fetch(data, "report_path").is_a?(String)
121
+
122
+ self.report_path = fetch(data, "report_path")
123
+ end
124
+ self
125
+ end
126
+
127
+ private
128
+
129
+ def apply_network(data)
130
+ validate_keys!(data, %w[default allow], "network")
131
+ default = key?(data, "default") ? fetch(data, "default") : "deny"
132
+ raise ConfigurationError, "network.default must be allow or deny" unless %w[allow deny].include?(default)
133
+
134
+ default == "allow" ? network.allow! : network.deny!
135
+ array(data, "allow").each do |rule|
136
+ raise ConfigurationError, "network allow rules must be mappings" unless rule.is_a?(Hash)
137
+ validate_keys!(rule, %w[host ports unix], "network allow rule")
138
+
139
+ unix = key?(rule, "unix")
140
+ host = key?(rule, "host")
141
+ raise ConfigurationError, "network allow rule requires exactly one of host or unix" if unix == host
142
+
143
+ if unix
144
+ raise ConfigurationError, "unix network allow rules cannot specify ports" if key?(rule, "ports")
145
+
146
+ path = fetch(rule, "unix")
147
+ allow_unix(string(path, "unix"))
148
+ else
149
+ address = fetch(rule, "host")
150
+ allow_host(string(address, "host"), ports: fetch(rule, "ports"))
151
+ end
152
+ end
153
+ end
154
+
155
+ def apply_write(data)
156
+ validate_keys!(data, %w[default allow deny_read], "write")
157
+ default = key?(data, "default") ? fetch(data, "default") : "allow"
158
+ raise ConfigurationError, "write.default must be allow or deny" unless %w[allow deny].include?(default)
159
+
160
+ array(data, "allow").each { |path| allow_write_under(string(path, "write.allow")) }
161
+ deny_write_elsewhere! if default == "deny"
162
+ array(data, "deny_read").each { |path| deny_read(string(path, "write.deny_read")) }
163
+ end
164
+
165
+ def apply_exec(data)
166
+ validate_keys!(data, %w[default allow deny], "exec")
167
+ default = key?(data, "default") ? fetch(data, "default") : "allow"
168
+ raise ConfigurationError, "exec.default must be allow or deny" unless %w[allow deny].include?(default)
169
+
170
+ array(data, "allow").each { |path| allow_exec(string(path, "exec.allow")) }
171
+ array(data, "deny").each { |path| deny_exec(string(path, "exec.deny")) }
172
+ deny_exec_elsewhere! if default == "deny"
173
+ end
174
+
175
+ def section(data, key)
176
+ value = fetch(data, key)
177
+ raise ConfigurationError, "#{key} must be a mapping" unless value.is_a?(Hash)
178
+
179
+ value
180
+ end
181
+
182
+ def array(data, key)
183
+ value = key?(data, key) ? fetch(data, key) : []
184
+ raise ConfigurationError, "#{key} must be an array" unless value.is_a?(Array)
185
+
186
+ value
187
+ end
188
+
189
+ def boolean(data, key)
190
+ value = fetch(data, key)
191
+ raise ConfigurationError, "#{key} must be true or false" unless [true, false].include?(value)
192
+
193
+ value
194
+ end
195
+
196
+ def string(value, label)
197
+ raise ConfigurationError, "#{label} must be a string" unless value.is_a?(String)
198
+
199
+ value
200
+ end
201
+
202
+ def validate_keys!(data, allowed, label)
203
+ unknown = data.keys.map(&:to_s) - allowed
204
+ raise ConfigurationError, "unknown #{label} keys: #{unknown.join(", ")}" unless unknown.empty?
205
+ end
206
+
207
+ def fetch(data, key)
208
+ return data[key] if data.key?(key)
209
+
210
+ data[key.to_sym]
211
+ end
212
+
213
+ def key?(data, key)
214
+ data.key?(key) || data.key?(key.to_sym)
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Straycall
4
+ module Handlers
5
+ module Exec
6
+ def handle_exec(request, dirfd_index, path_index)
7
+ path_address = request.args[path_index]
8
+ path = dirfd_index && request.read(path_address, 1) == "\0" ? "" : request.read_string(path_address)
9
+ dirfd = dirfd_index ? request.args[dirfd_index] : Policy::Filesystem::AT_FDCWD
10
+ path = @config.filesystem.resolve(path, tid: request.tid, dirfd:)
11
+ target = {type: "exec", path:}
12
+ if !@config.exec.allowed?(path) || @config.on_violation == :record
13
+ record_violation(request, target, %(allow_exec #{path.inspect}))
14
+ else
15
+ request.continue!(unsafe: true)
16
+ end
17
+ rescue PathResolutionError, Seccomp::Notify::MemoryReadError
18
+ record_violation(request, {type: "exec", path: "?"}, nil)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Straycall
4
+ module Handlers
5
+ module Filesystem
6
+ def handle_openat(request)
7
+ handle_open(request, request.args[0], request.args[1], request.args[2])
8
+ end
9
+
10
+ def handle_openat2(request)
11
+ flags = request.read(request.args[2], 8).unpack1("Q<")
12
+ handle_open(request, request.args[0], request.args[1], flags)
13
+ end
14
+
15
+ def handle_path_mutation(request, dirfd, path_address)
16
+ path = resolve_path(request, dirfd, path_address)
17
+ target = {type: "file", path:, operation: request.syscall.to_s}
18
+ if !@config.filesystem.allowed_write?(path) || @config.on_violation == :record
19
+ record_violation(request, target, %(allow_write_under #{File.dirname(path).inspect}))
20
+ else
21
+ request.continue!(unsafe: true)
22
+ end
23
+ rescue PathResolutionError, Seccomp::Notify::MemoryReadError
24
+ record_violation(request, {type: "file", path: "?"}, nil)
25
+ end
26
+
27
+ def handle_rename(request, source_dirfd, source_address, destination_dirfd, destination_address)
28
+ source = resolve_path(request, source_dirfd, source_address)
29
+ destination = resolve_path(request, destination_dirfd, destination_address)
30
+ target = {type: "file", path: destination, source:, operation: request.syscall.to_s}
31
+ allowed = @config.filesystem.allowed_write?(source) && @config.filesystem.allowed_write?(destination)
32
+ if !allowed || @config.on_violation == :record
33
+ record_violation(request, target, %(allow_write_under #{File.dirname(destination).inspect}))
34
+ else
35
+ request.continue!(unsafe: true)
36
+ end
37
+ rescue PathResolutionError, Seccomp::Notify::MemoryReadError
38
+ record_violation(request, {type: "file", path: "?"}, nil)
39
+ end
40
+
41
+ private
42
+
43
+ def handle_open(request, dirfd, path_address, flags)
44
+ path = resolve_path(request, dirfd, path_address)
45
+ filesystem = @config.filesystem
46
+ write = filesystem.write?(flags)
47
+ denied = write ? !filesystem.allowed_write?(path) : filesystem.denied_read?(path)
48
+ target = {type: "file", path:, flags:}
49
+ if denied || (@config.on_violation == :record && write)
50
+ suggestion = write ? %(allow_write_under #{File.dirname(path).inspect}) : nil
51
+ record_violation(request, target, suggestion)
52
+ else
53
+ request.continue!(unsafe: true)
54
+ end
55
+ rescue PathResolutionError, Seccomp::Notify::MemoryReadError
56
+ record_violation(request, {type: "file", path: "?", flags:}, nil)
57
+ end
58
+
59
+ def resolve_path(request, dirfd, address)
60
+ path = request.read_string(address)
61
+ @config.filesystem.resolve(path, tid: request.tid, dirfd:)
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+ require "socket"
5
+ require "seccomp/notify"
6
+
7
+ module Straycall
8
+ module InProcess
9
+ module_function
10
+
11
+ def install(config, report_socket:, inherited_report_socket:)
12
+ transfer_socket, supervisor_socket = UNIXSocket.pair
13
+ health_reader, health_writer = IO.pipe
14
+ policy = Policy.seccomp(config)
15
+ flags = policy.flags
16
+ flags |= Seccomp::Notify::Constants::SECCOMP_FILTER_FLAG_WAIT_KILLABLE_RECV if Seccomp::Notify.features[:wait_killable_recv]
17
+ program = Seccomp::Notify::BPF::Builder.new(policy, transfer_fd: transfer_socket.fileno).build
18
+
19
+ [supervisor_socket, report_socket, health_writer].each { |io| io.close_on_exec = false }
20
+ inherited_report_socket.close_on_exec = true
21
+ transfer_socket.close_on_exec = true
22
+ encoded_config = Marshal.dump(config).unpack1("H*")
23
+ clean_environment = ENV.keys.grep(/\A(?:BUNDLE|BUNDLER|RUBY(?:OPT|LIB))/).to_h { |name| [name, nil] }
24
+ load_path = [File.expand_path("..", __dir__), File.join(Gem.loaded_specs.fetch("seccomp-notify").full_gem_path, "lib")].join(File::PATH_SEPARATOR)
25
+ pid = Process.spawn(
26
+ clean_environment,
27
+ RbConfig.ruby,
28
+ "-I", load_path,
29
+ File.expand_path("../../exe/straycall-supervisor", __dir__),
30
+ supervisor_socket.fileno.to_s,
31
+ Process.pid.to_s,
32
+ encoded_config,
33
+ report_socket.fileno.to_s,
34
+ health_writer.fileno.to_s,
35
+ supervisor_socket.fileno => supervisor_socket.fileno,
36
+ report_socket.fileno => report_socket.fileno,
37
+ health_writer.fileno => health_writer.fileno,
38
+ pgroup: true
39
+ )
40
+ supervisor_socket.close
41
+ report_socket.close
42
+ health_writer.close
43
+ unless IO.select([health_reader], nil, nil, 5) && health_reader.read(1) == "R"
44
+ raise Error, "straycall supervisor failed to start"
45
+ end
46
+ health_reader.close_on_exec = false
47
+ ENV["SECCOMP_NOTIFY_HEALTH_FD"] = health_reader.fileno.to_s
48
+ @health_reader = health_reader
49
+
50
+ result = Seccomp::Notify::Libc.prctl(Seccomp::Notify::Constants::PR_SET_PTRACER, pid)
51
+ raise SystemCallError.new("prctl(PR_SET_PTRACER)", Fiddle.last_error) if result.negative?
52
+
53
+ listener = Seccomp::Notify::Filter.install!(program, flags:)
54
+ Seccomp::Notify::FdPassing.send_fd(transfer_socket, listener)
55
+ listener.close
56
+ transfer_socket.close
57
+ unless IO.select([health_reader], nil, nil, 5) && health_reader.read(1) == "S"
58
+ raise Error, "straycall supervisor failed to initialize"
59
+ end
60
+ Process.detach(pid)
61
+ pid
62
+ rescue StandardError
63
+ listener&.close unless listener&.closed?
64
+ [transfer_socket, supervisor_socket, report_socket, health_reader, health_writer].each do |io|
65
+ io&.close unless io&.closed?
66
+ end
67
+ if pid
68
+ Process.kill("KILL", pid) rescue nil
69
+ Process.waitpid(pid) rescue nil
70
+ end
71
+ raise
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest"
4
+ require_relative "../straycall"
5
+
6
+ module Straycall
7
+ module Minitest
8
+ module Hooks
9
+ def before_setup
10
+ Straycall::Minitest.ensure_supervisor!
11
+ file, line = method(name).source_location
12
+ Target.reporter&.notify_example(file:, line:, description: "#{self.class}##{name}")
13
+ super
14
+ end
15
+ end
16
+
17
+ module_function
18
+
19
+ def install
20
+ return unless Straycall.available?
21
+
22
+ require "seccomp/notify"
23
+ require_relative "in_process"
24
+ require_relative "target"
25
+ supervisor_socket, target_socket = UNIXSocket.pair
26
+ InProcess.install(Straycall.config, report_socket: supervisor_socket, inherited_report_socket: target_socket)
27
+ Target.start(target_socket)
28
+ ::Minitest::Test.prepend(Hooks)
29
+ ::Minitest.after_run do
30
+ begin
31
+ Straycall::Minitest.ensure_supervisor!
32
+ raise Error, "syscall violations were detected; see the straycall report" if Target.violation?
33
+ ensure
34
+ Target.stop
35
+ end
36
+ end
37
+ end
38
+
39
+ def ensure_supervisor!
40
+ return unless Seccomp::Notify.supervisor_alive? == false
41
+
42
+ raise Error, "straycall supervisor stopped; refusing to continue with an unmonitored suite"
43
+ end
44
+ end
45
+ end
46
+
47
+ Straycall::Minitest.install
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Straycall
4
+ module Policy
5
+ class Exec
6
+ def initialize
7
+ @default = :allow
8
+ @allowed = []
9
+ @denied = []
10
+ end
11
+
12
+ def allow(path)
13
+ validate_path!(path)
14
+ @allowed << File.expand_path(path)
15
+ end
16
+
17
+ def deny_elsewhere!
18
+ @default = :deny
19
+ end
20
+
21
+ def deny(path)
22
+ validate_path!(path)
23
+ @denied << File.expand_path(path)
24
+ end
25
+
26
+ def active?
27
+ @default == :deny || !@denied.empty?
28
+ end
29
+
30
+ def allowed?(path)
31
+ path = File.expand_path(path)
32
+ @default == :allow ? !@denied.include?(path) : @allowed.include?(path)
33
+ end
34
+
35
+ private
36
+
37
+ def validate_path!(path)
38
+ return if path.is_a?(String) && !path.empty? && !path.include?("\0")
39
+
40
+ raise ConfigurationError, "executable paths must be non-empty strings without NUL bytes"
41
+ end
42
+ end
43
+ end
44
+ end