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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +125 -0
- data/Rakefile +8 -0
- data/benchmark/require_overhead.rb +67 -0
- data/docs/index.html +186 -0
- data/docs/styles.css +718 -0
- data/examples/.straycall.yml +13 -0
- data/examples/rspec.rb +13 -0
- data/exe/straycall +6 -0
- data/exe/straycall-supervisor +29 -0
- data/lib/straycall/cli.rb +73 -0
- data/lib/straycall/config.rb +217 -0
- data/lib/straycall/handlers/exec.rb +22 -0
- data/lib/straycall/handlers/filesystem.rb +65 -0
- data/lib/straycall/in_process.rb +74 -0
- data/lib/straycall/minitest.rb +47 -0
- data/lib/straycall/policy/exec.rb +44 -0
- data/lib/straycall/policy/filesystem.rb +119 -0
- data/lib/straycall/policy/network.rb +83 -0
- data/lib/straycall/policy.rb +45 -0
- data/lib/straycall/prompt.rb +24 -0
- data/lib/straycall/recorder.rb +38 -0
- data/lib/straycall/report/console.rb +29 -0
- data/lib/straycall/report/json.rb +19 -0
- data/lib/straycall/reporter/client.rb +102 -0
- data/lib/straycall/reporter/protocol.rb +79 -0
- data/lib/straycall/reporter/symbolizer.rb +26 -0
- data/lib/straycall/reporter/thread_reporter.rb +73 -0
- data/lib/straycall/rspec.rb +51 -0
- data/lib/straycall/supervisor.rb +249 -0
- data/lib/straycall/target.rb +31 -0
- data/lib/straycall/version.rb +5 -0
- data/lib/straycall/violation.rb +9 -0
- data/lib/straycall.rb +39 -0
- metadata +94 -0
data/exe/straycall
ADDED
|
@@ -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
|